chiark / gitweb /
convert comments above functions to python docstrings
[fdroidserver.git] / fdroidserver / metadata.py
1 #!/usr/bin/env python3
2 #
3 # metadata.py - part of the FDroid server tools
4 # Copyright (C) 2013, Ciaran Gultnieks, ciaran@ciarang.com
5 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU Affero General Public License for more details.
16 #
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 import json
21 import os
22 import re
23 import glob
24 import cgi
25 import logging
26 import textwrap
27 import io
28
29 import yaml
30 # use libyaml if it is available
31 try:
32     from yaml import CLoader
33     YamlLoader = CLoader
34 except ImportError:
35     from yaml import Loader
36     YamlLoader = Loader
37
38 # use the C implementation when available
39 import xml.etree.cElementTree as ElementTree
40
41 import fdroidserver.common
42
43 srclibs = None
44 warnings_action = None
45
46
47 class MetaDataException(Exception):
48
49     def __init__(self, value):
50         self.value = value
51
52     def __str__(self):
53         return self.value
54
55
56 def warn_or_exception(value):
57     '''output warning or Exception depending on -W'''
58     if warnings_action == 'ignore':
59         pass
60     elif warnings_action == 'error':
61         raise MetaDataException(value)
62     else:
63         logging.warn(value)
64
65
66 # To filter which ones should be written to the metadata files if
67 # present
68 app_fields = set([
69     'Disabled',
70     'AntiFeatures',
71     'Provides',
72     'Categories',
73     'License',
74     'Author Name',
75     'Author Email',
76     'Web Site',
77     'Source Code',
78     'Issue Tracker',
79     'Changelog',
80     'Donate',
81     'FlattrID',
82     'Bitcoin',
83     'Litecoin',
84     'Name',
85     'Auto Name',
86     'Summary',
87     'Description',
88     'Requires Root',
89     'Repo Type',
90     'Repo',
91     'Binaries',
92     'Maintainer Notes',
93     'Archive Policy',
94     'Auto Update Mode',
95     'Update Check Mode',
96     'Update Check Ignore',
97     'Vercode Operation',
98     'Update Check Name',
99     'Update Check Data',
100     'Current Version',
101     'Current Version Code',
102     'No Source Since',
103
104     'comments',  # For formats that don't do inline comments
105     'builds',    # For formats that do builds as a list
106 ])
107
108
109 class App():
110
111     def __init__(self):
112         self.Disabled = None
113         self.AntiFeatures = []
114         self.Provides = None
115         self.Categories = ['None']
116         self.License = 'Unknown'
117         self.AuthorName = None
118         self.AuthorEmail = None
119         self.WebSite = ''
120         self.SourceCode = ''
121         self.IssueTracker = ''
122         self.Changelog = ''
123         self.Donate = None
124         self.FlattrID = None
125         self.Bitcoin = None
126         self.Litecoin = None
127         self.Name = None
128         self.AutoName = ''
129         self.Summary = ''
130         self.Description = ''
131         self.RequiresRoot = False
132         self.RepoType = ''
133         self.Repo = ''
134         self.Binaries = None
135         self.MaintainerNotes = ''
136         self.ArchivePolicy = None
137         self.AutoUpdateMode = 'None'
138         self.UpdateCheckMode = 'None'
139         self.UpdateCheckIgnore = None
140         self.VercodeOperation = None
141         self.UpdateCheckName = None
142         self.UpdateCheckData = None
143         self.CurrentVersion = ''
144         self.CurrentVersionCode = None
145         self.NoSourceSince = ''
146
147         self.id = None
148         self.metadatapath = None
149         self.builds = []
150         self.comments = {}
151         self.added = None
152         self.lastupdated = None
153         self._modified = set()
154
155     @classmethod
156     def field_to_attr(cls, f):
157         """
158         Translates human-readable field names to attribute names, e.g.
159         'Auto Name' to 'AutoName'
160         """
161         return f.replace(' ', '')
162
163     @classmethod
164     def attr_to_field(cls, k):
165         """
166         Translates attribute names to human-readable field names, e.g.
167         'AutoName' to 'Auto Name'
168         """
169         if k in app_fields:
170             return k
171         f = re.sub(r'([a-z])([A-Z])', r'\1 \2', k)
172         return f
173
174     def field_dict(self):
175         """
176         Constructs an old-fashioned dict with the human-readable field
177         names. Should only be used for tests.
178         """
179         d = {}
180         for k, v in self.__dict__.items():
181             if k == 'builds':
182                 d['builds'] = []
183                 for build in v:
184                     b = {k: v for k, v in build.__dict__.items() if not k.startswith('_')}
185                     d['builds'].append(b)
186             elif not k.startswith('_'):
187                 f = App.attr_to_field(k)
188                 d[f] = v
189         return d
190
191     def get_field(self, f):
192         """Gets the value associated to a field name, e.g. 'Auto Name'"""
193         if f not in app_fields:
194             warn_or_exception('Unrecognised app field: ' + f)
195         k = App.field_to_attr(f)
196         return getattr(self, k)
197
198     def set_field(self, f, v):
199         """Sets the value associated to a field name, e.g. 'Auto Name'"""
200         if f not in app_fields:
201             warn_or_exception('Unrecognised app field: ' + f)
202         k = App.field_to_attr(f)
203         self.__dict__[k] = v
204         self._modified.add(k)
205
206     def append_field(self, f, v):
207         """Appends to the value associated to a field name, e.g. 'Auto Name'"""
208         if f not in app_fields:
209             warn_or_exception('Unrecognised app field: ' + f)
210         k = App.field_to_attr(f)
211         if k not in self.__dict__:
212             self.__dict__[k] = [v]
213         else:
214             self.__dict__[k].append(v)
215
216     def update_fields(self, d):
217         '''Like dict.update(), but using human-readable field names'''
218         for f, v in d.items():
219             if f == 'builds':
220                 for b in v:
221                     build = Build()
222                     build.update_flags(b)
223                     self.builds.append(build)
224             else:
225                 self.set_field(f, v)
226
227
228 TYPE_UNKNOWN = 0
229 TYPE_OBSOLETE = 1
230 TYPE_STRING = 2
231 TYPE_BOOL = 3
232 TYPE_LIST = 4
233 TYPE_SCRIPT = 5
234 TYPE_MULTILINE = 6
235 TYPE_BUILD = 7
236 TYPE_BUILD_V2 = 8
237
238 fieldtypes = {
239     'Description': TYPE_MULTILINE,
240     'Maintainer Notes': TYPE_MULTILINE,
241     'Categories': TYPE_LIST,
242     'AntiFeatures': TYPE_LIST,
243     'Build Version': TYPE_BUILD,
244     'Build': TYPE_BUILD_V2,
245     'Use Built': TYPE_OBSOLETE,
246 }
247
248
249 def fieldtype(name):
250     if name in fieldtypes:
251         return fieldtypes[name]
252     return TYPE_STRING
253
254
255 # In the order in which they are laid out on files
256 build_flags_order = [
257     'disable',
258     'commit',
259     'subdir',
260     'submodules',
261     'init',
262     'patch',
263     'gradle',
264     'maven',
265     'kivy',
266     'output',
267     'srclibs',
268     'oldsdkloc',
269     'encoding',
270     'forceversion',
271     'forcevercode',
272     'rm',
273     'extlibs',
274     'prebuild',
275     'update',
276     'target',
277     'scanignore',
278     'scandelete',
279     'build',
280     'buildjni',
281     'ndk',
282     'preassemble',
283     'gradleprops',
284     'antcommands',
285     'novcheck',
286 ]
287
288
289 build_flags = set(build_flags_order + ['version', 'vercode'])
290
291
292 class Build():
293
294     def __init__(self):
295         self.disable = False
296         self.commit = None
297         self.subdir = None
298         self.submodules = False
299         self.init = ''
300         self.patch = []
301         self.gradle = []
302         self.maven = False
303         self.kivy = False
304         self.output = None
305         self.srclibs = []
306         self.oldsdkloc = False
307         self.encoding = None
308         self.forceversion = False
309         self.forcevercode = False
310         self.rm = []
311         self.extlibs = []
312         self.prebuild = ''
313         self.update = []
314         self.target = None
315         self.scanignore = []
316         self.scandelete = []
317         self.build = ''
318         self.buildjni = []
319         self.ndk = None
320         self.preassemble = []
321         self.gradleprops = []
322         self.antcommands = []
323         self.novcheck = False
324
325         self._modified = set()
326
327     def get_flag(self, f):
328         if f not in build_flags:
329             warn_or_exception('Unrecognised build flag: ' + f)
330         return getattr(self, f)
331
332     def set_flag(self, f, v):
333         if f == 'versionName':
334             f = 'version'
335         if f == 'versionCode':
336             f = 'vercode'
337         if f not in build_flags:
338             warn_or_exception('Unrecognised build flag: ' + f)
339         self.__dict__[f] = v
340         self._modified.add(f)
341
342     def append_flag(self, f, v):
343         if f not in build_flags:
344             warn_or_exception('Unrecognised build flag: ' + f)
345         if f not in self.__dict__:
346             self.__dict__[f] = [v]
347         else:
348             self.__dict__[f].append(v)
349
350     def build_method(self):
351         for f in ['maven', 'gradle', 'kivy']:
352             if self.get_flag(f):
353                 return f
354         if self.output:
355             return 'raw'
356         return 'ant'
357
358     # like build_method, but prioritize output=
359     def output_method(self):
360         if self.output:
361             return 'raw'
362         for f in ['maven', 'gradle', 'kivy']:
363             if self.get_flag(f):
364                 return f
365         return 'ant'
366
367     def ndk_path(self):
368         version = self.ndk
369         if not version:
370             version = 'r12b'  # falls back to latest
371         paths = fdroidserver.common.config['ndk_paths']
372         if version not in paths:
373             return ''
374         return paths[version]
375
376     def update_flags(self, d):
377         for f, v in d.items():
378             self.set_flag(f, v)
379
380
381 flagtypes = {
382     'extlibs': TYPE_LIST,
383     'srclibs': TYPE_LIST,
384     'patch': TYPE_LIST,
385     'rm': TYPE_LIST,
386     'buildjni': TYPE_LIST,
387     'preassemble': TYPE_LIST,
388     'update': TYPE_LIST,
389     'scanignore': TYPE_LIST,
390     'scandelete': TYPE_LIST,
391     'gradle': TYPE_LIST,
392     'antcommands': TYPE_LIST,
393     'gradleprops': TYPE_LIST,
394     'init': TYPE_SCRIPT,
395     'prebuild': TYPE_SCRIPT,
396     'build': TYPE_SCRIPT,
397     'submodules': TYPE_BOOL,
398     'oldsdkloc': TYPE_BOOL,
399     'forceversion': TYPE_BOOL,
400     'forcevercode': TYPE_BOOL,
401     'novcheck': TYPE_BOOL,
402 }
403
404
405 def flagtype(name):
406     if name in flagtypes:
407         return flagtypes[name]
408     return TYPE_STRING
409
410
411 # Designates a metadata field type and checks that it matches
412 #
413 # 'name'     - The long name of the field type
414 # 'matching' - List of possible values or regex expression
415 # 'sep'      - Separator to use if value may be a list
416 # 'fields'   - Metadata fields (Field:Value) of this type
417 # 'flags'    - Build flags (flag=value) of this type
418 #
419 class FieldValidator():
420
421     def __init__(self, name, matching, fields, flags):
422         self.name = name
423         self.matching = matching
424         self.compiled = re.compile(matching)
425         self.fields = fields
426         self.flags = flags
427
428     def check(self, v, appid):
429         if not v:
430             return
431         if type(v) == list:
432             values = v
433         else:
434             values = [v]
435         for v in values:
436             if not self.compiled.match(v):
437                 warn_or_exception("'%s' is not a valid %s in %s. Regex pattern: %s"
438                                   % (v, self.name, appid, self.matching))
439
440
441 # Generic value types
442 valuetypes = {
443     FieldValidator("Integer",
444                    r'^[1-9][0-9]*$',
445                    [],
446                    ['vercode']),
447
448     FieldValidator("Hexadecimal",
449                    r'^[0-9a-f]+$',
450                    ['FlattrID'],
451                    []),
452
453     FieldValidator("HTTP link",
454                    r'^http[s]?://',
455                    ["WebSite", "SourceCode", "IssueTracker", "Changelog", "Donate"], []),
456
457     FieldValidator("Email",
458                    r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
459                    ["AuthorEmail"], []),
460
461     FieldValidator("Bitcoin address",
462                    r'^[a-zA-Z0-9]{27,34}$',
463                    ["Bitcoin"],
464                    []),
465
466     FieldValidator("Litecoin address",
467                    r'^L[a-zA-Z0-9]{33}$',
468                    ["Litecoin"],
469                    []),
470
471     FieldValidator("Repo Type",
472                    r'^(git|git-svn|svn|hg|bzr|srclib)$',
473                    ["RepoType"],
474                    []),
475
476     FieldValidator("Binaries",
477                    r'^http[s]?://',
478                    ["Binaries"],
479                    []),
480
481     FieldValidator("Archive Policy",
482                    r'^[0-9]+ versions$',
483                    ["ArchivePolicy"],
484                    []),
485
486     FieldValidator("Anti-Feature",
487                    r'^(Ads|Tracking|NonFreeNet|NonFreeDep|NonFreeAdd|UpstreamNonFree|NonFreeAssets)$',
488                    ["AntiFeatures"],
489                    []),
490
491     FieldValidator("Auto Update Mode",
492                    r"^(Version .+|None)$",
493                    ["AutoUpdateMode"],
494                    []),
495
496     FieldValidator("Update Check Mode",
497                    r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$",
498                    ["UpdateCheckMode"],
499                    [])
500 }
501
502
503 # Check an app's metadata information for integrity errors
504 def check_metadata(app):
505     for v in valuetypes:
506         for k in v.fields:
507             if k not in app._modified:
508                 continue
509             v.check(app.__dict__[k], app.id)
510         for build in app.builds:
511             for k in v.flags:
512                 if k not in build._modified:
513                     continue
514                 v.check(build.__dict__[k], app.id)
515
516
517 # Formatter for descriptions. Create an instance, and call parseline() with
518 # each line of the description source from the metadata. At the end, call
519 # end() and then text_txt and text_html will contain the result.
520 class DescriptionFormatter:
521
522     stNONE = 0
523     stPARA = 1
524     stUL = 2
525     stOL = 3
526
527     def __init__(self, linkres):
528         self.bold = False
529         self.ital = False
530         self.state = self.stNONE
531         self.laststate = self.stNONE
532         self.text_html = ''
533         self.text_txt = ''
534         self.html = io.StringIO()
535         self.text = io.StringIO()
536         self.para_lines = []
537         self.linkResolver = None
538         self.linkResolver = linkres
539
540     def endcur(self, notstates=None):
541         if notstates and self.state in notstates:
542             return
543         if self.state == self.stPARA:
544             self.endpara()
545         elif self.state == self.stUL:
546             self.endul()
547         elif self.state == self.stOL:
548             self.endol()
549
550     def endpara(self):
551         self.laststate = self.state
552         self.state = self.stNONE
553         whole_para = ' '.join(self.para_lines)
554         self.addtext(whole_para)
555         wrapped = textwrap.fill(whole_para, 80,
556                                 break_long_words=False,
557                                 break_on_hyphens=False)
558         self.text.write(wrapped)
559         self.html.write('</p>')
560         del self.para_lines[:]
561
562     def endul(self):
563         self.html.write('</ul>')
564         self.laststate = self.state
565         self.state = self.stNONE
566
567     def endol(self):
568         self.html.write('</ol>')
569         self.laststate = self.state
570         self.state = self.stNONE
571
572     def formatted(self, txt, html):
573         res = ''
574         if html:
575             txt = cgi.escape(txt)
576         while True:
577             index = txt.find("''")
578             if index == -1:
579                 return res + txt
580             res += txt[:index]
581             txt = txt[index:]
582             if txt.startswith("'''"):
583                 if html:
584                     if self.bold:
585                         res += '</b>'
586                     else:
587                         res += '<b>'
588                 self.bold = not self.bold
589                 txt = txt[3:]
590             else:
591                 if html:
592                     if self.ital:
593                         res += '</i>'
594                     else:
595                         res += '<i>'
596                 self.ital = not self.ital
597                 txt = txt[2:]
598
599     def linkify(self, txt):
600         res_plain = ''
601         res_html = ''
602         while True:
603             index = txt.find("[")
604             if index == -1:
605                 return (res_plain + self.formatted(txt, False), res_html + self.formatted(txt, True))
606             res_plain += self.formatted(txt[:index], False)
607             res_html += self.formatted(txt[:index], True)
608             txt = txt[index:]
609             if txt.startswith("[["):
610                 index = txt.find("]]")
611                 if index == -1:
612                     warn_or_exception("Unterminated ]]")
613                 url = txt[2:index]
614                 if self.linkResolver:
615                     url, urltext = self.linkResolver(url)
616                 else:
617                     urltext = url
618                 res_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
619                 res_plain += urltext
620                 txt = txt[index + 2:]
621             else:
622                 index = txt.find("]")
623                 if index == -1:
624                     warn_or_exception("Unterminated ]")
625                 url = txt[1:index]
626                 index2 = url.find(' ')
627                 if index2 == -1:
628                     urltxt = url
629                 else:
630                     urltxt = url[index2 + 1:]
631                     url = url[:index2]
632                     if url == urltxt:
633                         warn_or_exception("Url title is just the URL - use [url]")
634                 res_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
635                 res_plain += urltxt
636                 if urltxt != url:
637                     res_plain += ' (' + url + ')'
638                 txt = txt[index + 1:]
639
640     def addtext(self, txt):
641         p, h = self.linkify(txt)
642         self.html.write(h)
643
644     def parseline(self, line):
645         if not line:
646             self.endcur()
647         elif line.startswith('* '):
648             self.endcur([self.stUL])
649             if self.state != self.stUL:
650                 self.html.write('<ul>')
651                 self.state = self.stUL
652                 if self.laststate != self.stNONE:
653                     self.text.write('\n\n')
654             else:
655                 self.text.write('\n')
656             self.text.write(line)
657             self.html.write('<li>')
658             self.addtext(line[1:])
659             self.html.write('</li>')
660         elif line.startswith('# '):
661             self.endcur([self.stOL])
662             if self.state != self.stOL:
663                 self.html.write('<ol>')
664                 self.state = self.stOL
665                 if self.laststate != self.stNONE:
666                     self.text.write('\n\n')
667             else:
668                 self.text.write('\n')
669             self.text.write(line)
670             self.html.write('<li>')
671             self.addtext(line[1:])
672             self.html.write('</li>')
673         else:
674             self.para_lines.append(line)
675             self.endcur([self.stPARA])
676             if self.state == self.stNONE:
677                 self.state = self.stPARA
678                 if self.laststate != self.stNONE:
679                     self.text.write('\n\n')
680                 self.html.write('<p>')
681
682     def end(self):
683         self.endcur()
684         self.text_txt = self.text.getvalue()
685         self.text_html = self.html.getvalue()
686         self.text.close()
687         self.html.close()
688
689
690 # Parse multiple lines of description as written in a metadata file, returning
691 # a single string in text format and wrapped to 80 columns.
692 def description_txt(s):
693     ps = DescriptionFormatter(None)
694     for line in s.splitlines():
695         ps.parseline(line)
696     ps.end()
697     return ps.text_txt
698
699
700 # Parse multiple lines of description as written in a metadata file, returning
701 # a single string in wiki format. Used for the Maintainer Notes field as well,
702 # because it's the same format.
703 def description_wiki(s):
704     return s
705
706
707 # Parse multiple lines of description as written in a metadata file, returning
708 # a single string in HTML format.
709 def description_html(s, linkres):
710     ps = DescriptionFormatter(linkres)
711     for line in s.splitlines():
712         ps.parseline(line)
713     ps.end()
714     return ps.text_html
715
716
717 def parse_srclib(metadatapath):
718
719     thisinfo = {}
720
721     # Defaults for fields that come from metadata
722     thisinfo['Repo Type'] = ''
723     thisinfo['Repo'] = ''
724     thisinfo['Subdir'] = None
725     thisinfo['Prepare'] = None
726
727     if not os.path.exists(metadatapath):
728         return thisinfo
729
730     metafile = open(metadatapath, "r", encoding='utf-8')
731
732     n = 0
733     for line in metafile:
734         n += 1
735         line = line.rstrip('\r\n')
736         if not line or line.startswith("#"):
737             continue
738
739         try:
740             f, v = line.split(':', 1)
741         except ValueError:
742             warn_or_exception("Invalid metadata in %s:%d" % (line, n))
743
744         if f == "Subdir":
745             thisinfo[f] = v.split(',')
746         else:
747             thisinfo[f] = v
748
749     metafile.close()
750
751     return thisinfo
752
753
754 def read_srclibs():
755     """Read all srclib metadata.
756
757     The information read will be accessible as metadata.srclibs, which is a
758     dictionary, keyed on srclib name, with the values each being a dictionary
759     in the same format as that returned by the parse_srclib function.
760
761     A MetaDataException is raised if there are any problems with the srclib
762     metadata.
763     """
764     global srclibs
765
766     # They were already loaded
767     if srclibs is not None:
768         return
769
770     srclibs = {}
771
772     srcdir = 'srclibs'
773     if not os.path.exists(srcdir):
774         os.makedirs(srcdir)
775
776     for metadatapath in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
777         srclibname = os.path.basename(metadatapath[:-4])
778         srclibs[srclibname] = parse_srclib(metadatapath)
779
780
781 def read_metadata(xref=True):
782     """
783     Read all metadata. Returns a list of 'app' objects (which are dictionaries as
784     returned by the parse_txt_metadata function.
785     """
786
787     # Always read the srclibs before the apps, since they can use a srlib as
788     # their source repository.
789     read_srclibs()
790
791     apps = {}
792
793     for basedir in ('metadata', 'tmp'):
794         if not os.path.exists(basedir):
795             os.makedirs(basedir)
796
797     # If there are multiple metadata files for a single appid, then the first
798     # file that is parsed wins over all the others, and the rest throw an
799     # exception. So the original .txt format is parsed first, at least until
800     # newer formats stabilize.
801
802     for metadatapath in sorted(glob.glob(os.path.join('metadata', '*.txt'))
803                                + glob.glob(os.path.join('metadata', '*.json'))
804                                + glob.glob(os.path.join('metadata', '*.xml'))
805                                + glob.glob(os.path.join('metadata', '*.yml'))
806                                + glob.glob('.fdroid.json')
807                                + glob.glob('.fdroid.xml')
808                                + glob.glob('.fdroid.yml')):
809         packageName, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
810         if packageName in apps:
811             warn_or_exception("Found multiple metadata files for " + packageName)
812         app = parse_metadata(metadatapath)
813         check_metadata(app)
814         apps[app.id] = app
815
816     if xref:
817         # Parse all descriptions at load time, just to ensure cross-referencing
818         # errors are caught early rather than when they hit the build server.
819         def linkres(appid):
820             if appid in apps:
821                 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
822             warn_or_exception("Cannot resolve app id " + appid)
823
824         for appid, app in apps.items():
825             try:
826                 description_html(app.Description, linkres)
827             except MetaDataException as e:
828                 warn_or_exception("Problem with description of " + appid +
829                                   " - " + str(e))
830
831     return apps
832
833
834 # Port legacy ';' separators
835 list_sep = re.compile(r'[,;]')
836
837
838 def split_list_values(s):
839     res = []
840     for v in re.split(list_sep, s):
841         if not v:
842             continue
843         v = v.strip()
844         if not v:
845             continue
846         res.append(v)
847     return res
848
849
850 def get_default_app_info(metadatapath=None):
851     if metadatapath is None:
852         appid = None
853     else:
854         appid, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
855
856     if appid == '.fdroid':  # we have local metadata in the app's source
857         if os.path.exists('AndroidManifest.xml'):
858             manifestroot = fdroidserver.common.parse_xml('AndroidManifest.xml')
859         else:
860             pattern = re.compile(""".*manifest\.srcFile\s+'AndroidManifest\.xml'.*""")
861             for root, dirs, files in os.walk(os.getcwd()):
862                 if 'build.gradle' in files:
863                     p = os.path.join(root, 'build.gradle')
864                     with open(p, 'rb') as f:
865                         data = f.read()
866                     m = pattern.search(data)
867                     if m:
868                         logging.debug('Using: ' + os.path.join(root, 'AndroidManifest.xml'))
869                         manifestroot = fdroidserver.common.parse_xml(os.path.join(root, 'AndroidManifest.xml'))
870                         break
871         if manifestroot is None:
872             warn_or_exception("Cannot find a packageName for {0}!".format(metadatapath))
873         appid = manifestroot.attrib['package']
874
875     app = App()
876     app.metadatapath = metadatapath
877     if appid is not None:
878         app.id = appid
879
880     return app
881
882
883 def sorted_builds(builds):
884     return sorted(builds, key=lambda build: int(build.vercode))
885
886
887 esc_newlines = re.compile(r'\\( |\n)')
888
889
890 # This function uses __dict__ to be faster
891 def post_metadata_parse(app):
892
893     for k in app._modified:
894         v = app.__dict__[k]
895         if type(v) in (float, int):
896             app.__dict__[k] = str(v)
897
898     for build in app.builds:
899         for k in build._modified:
900             v = build.__dict__[k]
901             if type(v) in (float, int):
902                 build.__dict__[k] = str(v)
903                 continue
904             ftype = flagtype(k)
905
906             if ftype == TYPE_SCRIPT:
907                 build.__dict__[k] = re.sub(esc_newlines, '', v).lstrip().rstrip()
908             elif ftype == TYPE_BOOL:
909                 # TODO handle this using <xsd:element type="xsd:boolean> in a schema
910                 if isinstance(v, str):
911                     build.__dict__[k] = _decode_bool(v)
912             elif ftype == TYPE_STRING:
913                 if isinstance(v, bool) and v:
914                     build.__dict__[k] = 'yes'
915             elif ftype == TYPE_LIST:
916                 if isinstance(v, bool) and v:
917                     build.__dict__[k] = ['yes']
918                 elif isinstance(v, str):
919                     build.__dict__[k] = [v]
920
921     if not app.Description:
922         app.Description = 'No description available'
923
924     app.builds = sorted_builds(app.builds)
925
926
927 # Parse metadata for a single application.
928 #
929 #  'metadatapath' - the filename to read. The package id for the application comes
930 #               from this filename. Pass None to get a blank entry.
931 #
932 # Returns a dictionary containing all the details of the application. There are
933 # two major kinds of information in the dictionary. Keys beginning with capital
934 # letters correspond directory to identically named keys in the metadata file.
935 # Keys beginning with lower case letters are generated in one way or another,
936 # and are not found verbatim in the metadata.
937 #
938 # Known keys not originating from the metadata are:
939 #
940 #  'builds'           - a list of dictionaries containing build information
941 #                       for each defined build
942 #  'comments'         - a list of comments from the metadata file. Each is
943 #                       a list of the form [field, comment] where field is
944 #                       the name of the field it preceded in the metadata
945 #                       file. Where field is None, the comment goes at the
946 #                       end of the file. Alternatively, 'build:version' is
947 #                       for a comment before a particular build version.
948 #  'descriptionlines' - original lines of description as formatted in the
949 #                       metadata file.
950 #
951
952
953 bool_true = re.compile(r'([Yy]es|[Tt]rue)')
954 bool_false = re.compile(r'([Nn]o|[Ff]alse)')
955
956
957 def _decode_bool(s):
958     if bool_true.match(s):
959         return True
960     if bool_false.match(s):
961         return False
962     warn_or_exception("Invalid bool '%s'" % s)
963
964
965 def parse_metadata(metadatapath):
966     _, ext = fdroidserver.common.get_extension(metadatapath)
967     accepted = fdroidserver.common.config['accepted_formats']
968     if ext not in accepted:
969         warn_or_exception('"%s" is not an accepted format, convert to: %s' % (
970             metadatapath, ', '.join(accepted)))
971
972     app = App()
973     app.metadatapath = metadatapath
974     app.id, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
975
976     with open(metadatapath, 'r', encoding='utf-8') as mf:
977         if ext == 'txt':
978             parse_txt_metadata(mf, app)
979         elif ext == 'json':
980             parse_json_metadata(mf, app)
981         elif ext == 'xml':
982             parse_xml_metadata(mf, app)
983         elif ext == 'yml':
984             parse_yaml_metadata(mf, app)
985         else:
986             warn_or_exception('Unknown metadata format: %s' % metadatapath)
987
988     post_metadata_parse(app)
989     return app
990
991
992 def parse_json_metadata(mf, app):
993
994     # fdroid metadata is only strings and booleans, no floats or ints.
995     # TODO create schema using https://pypi.python.org/pypi/jsonschema
996     jsoninfo = json.load(mf, parse_int=lambda s: s,
997                          parse_float=lambda s: s)
998     app.update_fields(jsoninfo)
999     for f in ['Description', 'Maintainer Notes']:
1000         v = app.get_field(f)
1001         app.set_field(f, '\n'.join(v))
1002     return app
1003
1004
1005 def parse_xml_metadata(mf, app):
1006
1007     tree = ElementTree.ElementTree(file=mf)
1008     root = tree.getroot()
1009
1010     if root.tag != 'resources':
1011         warn_or_exception('resources file does not have root element <resources/>')
1012
1013     for child in root:
1014         if child.tag != 'builds':
1015             # builds does not have name="" attrib
1016             name = child.attrib['name']
1017
1018         if child.tag == 'string':
1019             app.set_field(name, child.text)
1020         elif child.tag == 'string-array':
1021             for item in child:
1022                 app.append_field(name, item.text)
1023         elif child.tag == 'builds':
1024             for b in child:
1025                 build = Build()
1026                 for key in b:
1027                     build.set_flag(key.tag, key.text)
1028                 app.builds.append(build)
1029
1030     # TODO handle this using <xsd:element type="xsd:boolean> in a schema
1031     if not isinstance(app.RequiresRoot, bool):
1032         app.RequiresRoot = app.RequiresRoot == 'true'
1033
1034     return app
1035
1036
1037 def parse_yaml_metadata(mf, app):
1038
1039     yamlinfo = yaml.load(mf, Loader=YamlLoader)
1040     app.update_fields(yamlinfo)
1041     return app
1042
1043
1044 build_line_sep = re.compile(r'(?<!\\),')
1045 build_cont = re.compile(r'^[ \t]')
1046
1047
1048 def parse_txt_metadata(mf, app):
1049
1050     linedesc = None
1051
1052     def add_buildflag(p, build):
1053         if not p.strip():
1054             warn_or_exception("Empty build flag at {1}"
1055                               .format(buildlines[0], linedesc))
1056         bv = p.split('=', 1)
1057         if len(bv) != 2:
1058             warn_or_exception("Invalid build flag at {0} in {1}"
1059                               .format(buildlines[0], linedesc))
1060
1061         pk, pv = bv
1062         pk = pk.lstrip()
1063         t = flagtype(pk)
1064         if t == TYPE_LIST:
1065             pv = split_list_values(pv)
1066             build.set_flag(pk, pv)
1067         elif t == TYPE_STRING or t == TYPE_SCRIPT:
1068             build.set_flag(pk, pv)
1069         elif t == TYPE_BOOL:
1070             build.set_flag(pk, _decode_bool(pv))
1071
1072     def parse_buildline(lines):
1073         v = "".join(lines)
1074         parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
1075         if len(parts) < 3:
1076             warn_or_exception("Invalid build format: " + v + " in " + mf.name)
1077         build = Build()
1078         build.version = parts[0]
1079         build.vercode = parts[1]
1080         if parts[2].startswith('!'):
1081             # For backwards compatibility, handle old-style disabling,
1082             # including attempting to extract the commit from the message
1083             build.disable = parts[2][1:]
1084             commit = 'unknown - see disabled'
1085             index = parts[2].rfind('at ')
1086             if index != -1:
1087                 commit = parts[2][index + 3:]
1088                 if commit.endswith(')'):
1089                     commit = commit[:-1]
1090             build.commit = commit
1091         else:
1092             build.commit = parts[2]
1093         for p in parts[3:]:
1094             add_buildflag(p, build)
1095
1096         return build
1097
1098     def add_comments(key):
1099         if not curcomments:
1100             return
1101         app.comments[key] = list(curcomments)
1102         del curcomments[:]
1103
1104     mode = 0
1105     buildlines = []
1106     multiline_lines = []
1107     curcomments = []
1108     build = None
1109     vc_seen = set()
1110
1111     c = 0
1112     for line in mf:
1113         c += 1
1114         linedesc = "%s:%d" % (mf.name, c)
1115         line = line.rstrip('\r\n')
1116         if mode == 3:
1117             if build_cont.match(line):
1118                 if line.endswith('\\'):
1119                     buildlines.append(line[:-1].lstrip())
1120                 else:
1121                     buildlines.append(line.lstrip())
1122                     bl = ''.join(buildlines)
1123                     add_buildflag(bl, build)
1124                     del buildlines[:]
1125             else:
1126                 if not build.commit and not build.disable:
1127                     warn_or_exception("No commit specified for {0} in {1}"
1128                                       .format(build.version, linedesc))
1129
1130                 app.builds.append(build)
1131                 add_comments('build:' + build.vercode)
1132                 mode = 0
1133
1134         if mode == 0:
1135             if not line:
1136                 continue
1137             if line.startswith("#"):
1138                 curcomments.append(line[1:].strip())
1139                 continue
1140             try:
1141                 f, v = line.split(':', 1)
1142             except ValueError:
1143                 warn_or_exception("Invalid metadata in " + linedesc)
1144
1145             # Translate obsolete fields...
1146             if f == 'Market Version':
1147                 f = 'Current Version'
1148             if f == 'Market Version Code':
1149                 f = 'Current Version Code'
1150
1151             ftype = fieldtype(f)
1152             if ftype not in [TYPE_BUILD, TYPE_BUILD_V2]:
1153                 add_comments(f)
1154             if ftype == TYPE_MULTILINE:
1155                 mode = 1
1156                 if v:
1157                     warn_or_exception("Unexpected text on same line as "
1158                                       + f + " in " + linedesc)
1159             elif ftype == TYPE_STRING:
1160                 app.set_field(f, v)
1161             elif ftype == TYPE_LIST:
1162                 app.set_field(f, split_list_values(v))
1163             elif ftype == TYPE_BUILD:
1164                 if v.endswith("\\"):
1165                     mode = 2
1166                     del buildlines[:]
1167                     buildlines.append(v[:-1])
1168                 else:
1169                     build = parse_buildline([v])
1170                     app.builds.append(build)
1171                     add_comments('build:' + app.builds[-1].vercode)
1172             elif ftype == TYPE_BUILD_V2:
1173                 vv = v.split(',')
1174                 if len(vv) != 2:
1175                     warn_or_exception('Build should have comma-separated',
1176                                       'version and vercode,',
1177                                       'not "{0}", in {1}'.format(v, linedesc))
1178                 build = Build()
1179                 build.version = vv[0]
1180                 build.vercode = vv[1]
1181                 if build.vercode in vc_seen:
1182                     warn_or_exception('Duplicate build recipe found for vercode %s in %s'
1183                                       % (build.vercode, linedesc))
1184                 vc_seen.add(build.vercode)
1185                 del buildlines[:]
1186                 mode = 3
1187             elif ftype == TYPE_OBSOLETE:
1188                 pass        # Just throw it away!
1189             else:
1190                 warn_or_exception("Unrecognised field '" + f + "' in " + linedesc)
1191         elif mode == 1:     # Multiline field
1192             if line == '.':
1193                 mode = 0
1194                 app.set_field(f, '\n'.join(multiline_lines))
1195                 del multiline_lines[:]
1196             else:
1197                 multiline_lines.append(line)
1198         elif mode == 2:     # Line continuation mode in Build Version
1199             if line.endswith("\\"):
1200                 buildlines.append(line[:-1])
1201             else:
1202                 buildlines.append(line)
1203                 build = parse_buildline(buildlines)
1204                 app.builds.append(build)
1205                 add_comments('build:' + app.builds[-1].vercode)
1206                 mode = 0
1207     add_comments(None)
1208
1209     # Mode at end of file should always be 0
1210     if mode == 1:
1211         warn_or_exception(f + " not terminated in " + mf.name)
1212     if mode == 2:
1213         warn_or_exception("Unterminated continuation in " + mf.name)
1214     if mode == 3:
1215         warn_or_exception("Unterminated build in " + mf.name)
1216
1217     return app
1218
1219
1220 def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
1221
1222     def w_comments(key):
1223         if key not in app.comments:
1224             return
1225         for line in app.comments[key]:
1226             w_comment(line)
1227
1228     def w_field_always(f, v=None):
1229         if v is None:
1230             v = app.get_field(f)
1231         w_comments(f)
1232         w_field(f, v)
1233
1234     def w_field_nonempty(f, v=None):
1235         if v is None:
1236             v = app.get_field(f)
1237         w_comments(f)
1238         if v:
1239             w_field(f, v)
1240
1241     w_field_nonempty('Disabled')
1242     w_field_nonempty('AntiFeatures')
1243     w_field_nonempty('Provides')
1244     w_field_always('Categories')
1245     w_field_always('License')
1246     w_field_nonempty('Author Name')
1247     w_field_nonempty('Author Email')
1248     w_field_always('Web Site')
1249     w_field_always('Source Code')
1250     w_field_always('Issue Tracker')
1251     w_field_nonempty('Changelog')
1252     w_field_nonempty('Donate')
1253     w_field_nonempty('FlattrID')
1254     w_field_nonempty('Bitcoin')
1255     w_field_nonempty('Litecoin')
1256     mf.write('\n')
1257     w_field_nonempty('Name')
1258     w_field_nonempty('Auto Name')
1259     w_field_always('Summary')
1260     w_field_always('Description', description_txt(app.Description))
1261     mf.write('\n')
1262     if app.RequiresRoot:
1263         w_field_always('Requires Root', 'yes')
1264         mf.write('\n')
1265     if app.RepoType:
1266         w_field_always('Repo Type')
1267         w_field_always('Repo')
1268         if app.Binaries:
1269             w_field_always('Binaries')
1270         mf.write('\n')
1271
1272     for build in app.builds:
1273
1274         if build.version == "Ignore":
1275             continue
1276
1277         w_comments('build:' + build.vercode)
1278         w_build(build)
1279         mf.write('\n')
1280
1281     if app.MaintainerNotes:
1282         w_field_always('Maintainer Notes', app.MaintainerNotes)
1283         mf.write('\n')
1284
1285     w_field_nonempty('Archive Policy')
1286     w_field_always('Auto Update Mode')
1287     w_field_always('Update Check Mode')
1288     w_field_nonempty('Update Check Ignore')
1289     w_field_nonempty('Vercode Operation')
1290     w_field_nonempty('Update Check Name')
1291     w_field_nonempty('Update Check Data')
1292     if app.CurrentVersion:
1293         w_field_always('Current Version')
1294         w_field_always('Current Version Code')
1295     if app.NoSourceSince:
1296         mf.write('\n')
1297         w_field_always('No Source Since')
1298     w_comments(None)
1299
1300
1301 # Write a metadata file in txt format.
1302 #
1303 # 'mf'      - Writer interface (file, StringIO, ...)
1304 # 'app'     - The app data
1305 def write_txt(mf, app):
1306
1307     def w_comment(line):
1308         mf.write("# %s\n" % line)
1309
1310     def w_field(f, v):
1311         t = fieldtype(f)
1312         if t == TYPE_LIST:
1313             v = ','.join(v)
1314         elif t == TYPE_MULTILINE:
1315             v = '\n' + v + '\n.'
1316         mf.write("%s:%s\n" % (f, v))
1317
1318     def w_build(build):
1319         mf.write("Build:%s,%s\n" % (build.version, build.vercode))
1320
1321         for f in build_flags_order:
1322             v = build.get_flag(f)
1323             if not v:
1324                 continue
1325
1326             t = flagtype(f)
1327             mf.write('    %s=' % f)
1328             if t == TYPE_STRING:
1329                 mf.write(v)
1330             elif t == TYPE_BOOL:
1331                 mf.write('yes')
1332             elif t == TYPE_SCRIPT:
1333                 first = True
1334                 for s in v.split(' && '):
1335                     if first:
1336                         first = False
1337                     else:
1338                         mf.write(' && \\\n        ')
1339                     mf.write(s)
1340             elif t == TYPE_LIST:
1341                 mf.write(','.join(v))
1342
1343             mf.write('\n')
1344
1345     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1346
1347
1348 def write_yaml(mf, app):
1349
1350     def w_comment(line):
1351         mf.write("# %s\n" % line)
1352
1353     def escape(v):
1354         if not v:
1355             return ''
1356         if any(c in v for c in [': ', '%', '@', '*']):
1357             return "'" + v.replace("'", "''") + "'"
1358         return v
1359
1360     def w_field(f, v, prefix='', t=None):
1361         if t is None:
1362             t = fieldtype(f)
1363         v = ''
1364         if t == TYPE_LIST:
1365             v = '\n'
1366             for e in v:
1367                 v += prefix + ' - ' + escape(e) + '\n'
1368         elif t == TYPE_MULTILINE:
1369             v = ' |\n'
1370             for l in v.splitlines():
1371                 if l:
1372                     v += prefix + '  ' + l + '\n'
1373                 else:
1374                     v += '\n'
1375         elif t == TYPE_BOOL:
1376             v = ' yes\n'
1377         elif t == TYPE_SCRIPT:
1378             cmds = [s + '&& \\' for s in v.split('&& ')]
1379             if len(cmds) > 0:
1380                 cmds[-1] = cmds[-1][:-len('&& \\')]
1381             w_field(f, cmds, prefix, 'multiline')
1382             return
1383         else:
1384             v = ' ' + escape(v) + '\n'
1385
1386         mf.write(prefix)
1387         mf.write(f)
1388         mf.write(":")
1389         mf.write(v)
1390
1391     global first_build
1392     first_build = True
1393
1394     def w_build(build):
1395         global first_build
1396         if first_build:
1397             mf.write("builds:\n")
1398             first_build = False
1399
1400         w_field('versionName', build.version, '  - ', TYPE_STRING)
1401         w_field('versionCode', build.vercode, '    ', TYPE_STRING)
1402         for f in build_flags_order:
1403             v = build.get_flag(f)
1404             if not v:
1405                 continue
1406
1407             w_field(f, v, '    ', flagtype(f))
1408
1409     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1410
1411
1412 def write_metadata(metadatapath, app):
1413     _, ext = fdroidserver.common.get_extension(metadatapath)
1414     accepted = fdroidserver.common.config['accepted_formats']
1415     if ext not in accepted:
1416         warn_or_exception('Cannot write "%s", not an accepted format, use: %s'
1417                           % (metadatapath, ', '.join(accepted)))
1418
1419     with open(metadatapath, 'w', encoding='utf8') as mf:
1420         if ext == 'txt':
1421             return write_txt(mf, app)
1422         elif ext == 'yml':
1423             return write_yaml(mf, app)
1424     warn_or_exception('Unknown metadata format: %s' % metadatapath)
1425
1426
1427 def add_metadata_arguments(parser):
1428     '''add common command line flags related to metadata processing'''
1429     parser.add_argument("-W", default='error',
1430                         help="force errors to be warnings, or ignore")