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