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