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