chiark / gitweb /
Merge branch 'ndk' 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
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)$',
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 ruamel.yaml
993
994     _yaml_bools_true = ('y', 'Y', 'yes', 'Yes', 'YES',
995                         'true', 'True', 'TRUE',
996                         'on', 'On', 'ON')
997     _yaml_bools_false = ('n', 'N', 'no', 'No', 'NO',
998                          'false', 'False', 'FALSE',
999                          'off', 'Off', 'OFF')
1000     _yaml_bools_plus_lists = []
1001     _yaml_bools_plus_lists.extend(_yaml_bools_true)
1002     _yaml_bools_plus_lists.extend([[x] for x in _yaml_bools_true])
1003     _yaml_bools_plus_lists.extend(_yaml_bools_false)
1004     _yaml_bools_plus_lists.extend([[x] for x in _yaml_bools_false])
1005
1006     def _class_as_dict_representer(dumper, data):
1007         '''Creates a YAML representation of a App/Build instance'''
1008         return dumper.represent_dict(data)
1009
1010     def _field_to_yaml(typ, value):
1011         if typ is TYPE_STRING:
1012             if value in _yaml_bools_plus_lists:
1013                 return ruamel.yaml.scalarstring.SingleQuotedScalarString(str(value))
1014             return str(value)
1015         elif typ is TYPE_INT:
1016             return int(value)
1017         elif typ is TYPE_MULTILINE:
1018             if '\n' in value:
1019                 return ruamel.yaml.scalarstring.preserve_literal(str(value))
1020             else:
1021                 return str(value)
1022         elif typ is TYPE_SCRIPT:
1023             if len(value) > 50:
1024                 return ruamel.yaml.scalarstring.preserve_literal(value)
1025             else:
1026                 return value
1027         else:
1028             return value
1029
1030     def _app_to_yaml(app):
1031         cm = ruamel.yaml.comments.CommentedMap()
1032         insert_newline = False
1033         for field in yaml_app_field_order:
1034             if field is '\n':
1035                 # next iteration will need to insert a newline
1036                 insert_newline = True
1037             else:
1038                 if (hasattr(app, field) and getattr(app, field)) or field is 'Builds':
1039                     if field is 'Builds':
1040                         cm.update({field: _builds_to_yaml(app)})
1041                     elif field is 'CurrentVersionCode':
1042                         cm.update({field: _field_to_yaml(TYPE_INT, getattr(app, field))})
1043                     else:
1044                         cm.update({field: _field_to_yaml(fieldtype(field), getattr(app, field))})
1045
1046                     if insert_newline:
1047                         # we need to prepend a newline in front of this field
1048                         insert_newline = False
1049                         # inserting empty lines is not supported so we add a
1050                         # bogus comment and over-write its value
1051                         cm.yaml_set_comment_before_after_key(field, 'bogus')
1052                         cm.ca.items[field][1][-1].value = '\n'
1053         return cm
1054
1055     def _builds_to_yaml(app):
1056         fields = ['versionName', 'versionCode']
1057         fields.extend(build_flags_order)
1058         builds = ruamel.yaml.comments.CommentedSeq()
1059         for build in app.builds:
1060             b = ruamel.yaml.comments.CommentedMap()
1061             for field in fields:
1062                 if hasattr(build, field) and getattr(build, field):
1063                     value = getattr(build, field)
1064                     if field == 'gradle' and value == ['off']:
1065                         value = [ruamel.yaml.scalarstring.SingleQuotedScalarString('off')]
1066                     if field in ('disable', 'kivy', 'maven', 'buildozer'):
1067                         if value == 'no':
1068                             continue
1069                         elif value == 'yes':
1070                             value = 'yes'
1071                     b.update({field: _field_to_yaml(flagtype(field), value)})
1072             builds.append(b)
1073
1074         # insert extra empty lines between build entries
1075         for i in range(1, len(builds)):
1076             builds.yaml_set_comment_before_after_key(i, 'bogus')
1077             builds.ca.items[i][1][-1].value = '\n'
1078
1079         return builds
1080
1081     yaml_app_field_order = [
1082         'Disabled',
1083         'AntiFeatures',
1084         'Provides',
1085         'Categories',
1086         'License',
1087         'AuthorName',
1088         'AuthorEmail',
1089         'AuthorWebSite',
1090         'WebSite',
1091         'SourceCode',
1092         'IssueTracker',
1093         'Changelog',
1094         'Donate',
1095         'FlattrID',
1096         'Bitcoin',
1097         'Litecoin',
1098         '\n',
1099         'Name',
1100         'AutoName',
1101         'Summary',
1102         'Description',
1103         '\n',
1104         'RequiresRoot',
1105         '\n',
1106         'RepoType',
1107         'Repo',
1108         'Binaries',
1109         '\n',
1110         'Builds',
1111         '\n',
1112         'MaintainerNotes',
1113         '\n',
1114         'ArchivePolicy',
1115         'AutoUpdateMode',
1116         'UpdateCheckMode',
1117         'UpdateCheckIgnore',
1118         'VercodeOperation',
1119         'UpdateCheckName',
1120         'UpdateCheckData',
1121         'CurrentVersion',
1122         'CurrentVersionCode',
1123         '\n',
1124         'NoSourceSince',
1125     ]
1126
1127     yaml_app = _app_to_yaml(app)
1128     ruamel.yaml.round_trip_dump(yaml_app, mf, indent=4, block_seq_indent=2)
1129
1130
1131 build_line_sep = re.compile(r'(?<!\\),')
1132 build_cont = re.compile(r'^[ \t]')
1133
1134
1135 def parse_txt_metadata(mf, app):
1136
1137     linedesc = None
1138
1139     def add_buildflag(p, build):
1140         if not p.strip():
1141             warn_or_exception("Empty build flag at {1}"
1142                               .format(buildlines[0], linedesc))
1143         bv = p.split('=', 1)
1144         if len(bv) != 2:
1145             warn_or_exception("Invalid build flag at {0} in {1}"
1146                               .format(buildlines[0], linedesc))
1147
1148         pk, pv = bv
1149         pk = pk.lstrip()
1150         if pk == 'update':
1151             pk = 'androidupdate'  # avoid conflicting with Build(dict).update()
1152         t = flagtype(pk)
1153         if t == TYPE_LIST:
1154             pv = split_list_values(pv)
1155             build[pk] = pv
1156         elif t == TYPE_STRING or t == TYPE_SCRIPT:
1157             build[pk] = pv
1158         elif t == TYPE_BOOL:
1159             build[pk] = _decode_bool(pv)
1160
1161     def parse_buildline(lines):
1162         v = "".join(lines)
1163         parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
1164         if len(parts) < 3:
1165             warn_or_exception("Invalid build format: " + v + " in " + mf.name)
1166         build = Build()
1167         build.versionName = parts[0]
1168         build.versionCode = parts[1]
1169         check_versionCode(build.versionCode)
1170
1171         if parts[2].startswith('!'):
1172             # For backwards compatibility, handle old-style disabling,
1173             # including attempting to extract the commit from the message
1174             build.disable = parts[2][1:]
1175             commit = 'unknown - see disabled'
1176             index = parts[2].rfind('at ')
1177             if index != -1:
1178                 commit = parts[2][index + 3:]
1179                 if commit.endswith(')'):
1180                     commit = commit[:-1]
1181             build.commit = commit
1182         else:
1183             build.commit = parts[2]
1184         for p in parts[3:]:
1185             add_buildflag(p, build)
1186
1187         return build
1188
1189     def check_versionCode(versionCode):
1190         try:
1191             int(versionCode)
1192         except ValueError:
1193             warn_or_exception('Invalid versionCode: "' + versionCode + '" is not an integer!')
1194
1195     def add_comments(key):
1196         if not curcomments:
1197             return
1198         app.comments[key] = list(curcomments)
1199         del curcomments[:]
1200
1201     mode = 0
1202     buildlines = []
1203     multiline_lines = []
1204     curcomments = []
1205     build = None
1206     vc_seen = set()
1207
1208     app.builds = []
1209
1210     c = 0
1211     for line in mf:
1212         c += 1
1213         linedesc = "%s:%d" % (mf.name, c)
1214         line = line.rstrip('\r\n')
1215         if mode == 3:
1216             if build_cont.match(line):
1217                 if line.endswith('\\'):
1218                     buildlines.append(line[:-1].lstrip())
1219                 else:
1220                     buildlines.append(line.lstrip())
1221                     bl = ''.join(buildlines)
1222                     add_buildflag(bl, build)
1223                     del buildlines[:]
1224             else:
1225                 if not build.commit and not build.disable:
1226                     warn_or_exception("No commit specified for {0} in {1}"
1227                                       .format(build.versionName, linedesc))
1228
1229                 app.builds.append(build)
1230                 add_comments('build:' + build.versionCode)
1231                 mode = 0
1232
1233         if mode == 0:
1234             if not line:
1235                 continue
1236             if line.startswith("#"):
1237                 curcomments.append(line[1:].strip())
1238                 continue
1239             try:
1240                 f, v = line.split(':', 1)
1241             except ValueError:
1242                 warn_or_exception("Invalid metadata in " + linedesc)
1243
1244             if f not in app_fields:
1245                 warn_or_exception('Unrecognised app field: ' + f)
1246
1247             # Translate obsolete fields...
1248             if f == 'Market Version':
1249                 f = 'Current Version'
1250             if f == 'Market Version Code':
1251                 f = 'Current Version Code'
1252
1253             f = f.replace(' ', '')
1254
1255             ftype = fieldtype(f)
1256             if ftype not in [TYPE_BUILD, TYPE_BUILD_V2]:
1257                 add_comments(f)
1258             if ftype == TYPE_MULTILINE:
1259                 mode = 1
1260                 if v:
1261                     warn_or_exception("Unexpected text on same line as "
1262                                       + f + " in " + linedesc)
1263             elif ftype == TYPE_STRING:
1264                 app[f] = v
1265             elif ftype == TYPE_LIST:
1266                 app[f] = split_list_values(v)
1267             elif ftype == TYPE_BUILD:
1268                 if v.endswith("\\"):
1269                     mode = 2
1270                     del buildlines[:]
1271                     buildlines.append(v[:-1])
1272                 else:
1273                     build = parse_buildline([v])
1274                     app.builds.append(build)
1275                     add_comments('build:' + app.builds[-1].versionCode)
1276             elif ftype == TYPE_BUILD_V2:
1277                 vv = v.split(',')
1278                 if len(vv) != 2:
1279                     warn_or_exception('Build should have comma-separated',
1280                                       'versionName and versionCode,',
1281                                       'not "{0}", in {1}'.format(v, linedesc))
1282                 build = Build()
1283                 build.versionName = vv[0]
1284                 build.versionCode = vv[1]
1285                 check_versionCode(build.versionCode)
1286
1287                 if build.versionCode in vc_seen:
1288                     warn_or_exception('Duplicate build recipe found for versionCode %s in %s'
1289                                       % (build.versionCode, linedesc))
1290                 vc_seen.add(build.versionCode)
1291                 del buildlines[:]
1292                 mode = 3
1293             elif ftype == TYPE_OBSOLETE:
1294                 pass        # Just throw it away!
1295             else:
1296                 warn_or_exception("Unrecognised field '" + f + "' in " + linedesc)
1297         elif mode == 1:     # Multiline field
1298             if line == '.':
1299                 mode = 0
1300                 app[f] = '\n'.join(multiline_lines)
1301                 del multiline_lines[:]
1302             else:
1303                 multiline_lines.append(line)
1304         elif mode == 2:     # Line continuation mode in Build Version
1305             if line.endswith("\\"):
1306                 buildlines.append(line[:-1])
1307             else:
1308                 buildlines.append(line)
1309                 build = parse_buildline(buildlines)
1310                 app.builds.append(build)
1311                 add_comments('build:' + app.builds[-1].versionCode)
1312                 mode = 0
1313     add_comments(None)
1314
1315     # Mode at end of file should always be 0
1316     if mode == 1:
1317         warn_or_exception(f + " not terminated in " + mf.name)
1318     if mode == 2:
1319         warn_or_exception("Unterminated continuation in " + mf.name)
1320     if mode == 3:
1321         warn_or_exception("Unterminated build in " + mf.name)
1322
1323     return app
1324
1325
1326 def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
1327
1328     def field_to_attr(f):
1329         """
1330         Translates human-readable field names to attribute names, e.g.
1331         'Auto Name' to 'AutoName'
1332         """
1333         return f.replace(' ', '')
1334
1335     def attr_to_field(k):
1336         """
1337         Translates attribute names to human-readable field names, e.g.
1338         'AutoName' to 'Auto Name'
1339         """
1340         if k in app_fields:
1341             return k
1342         f = re.sub(r'([a-z])([A-Z])', r'\1 \2', k)
1343         return f
1344
1345     def w_comments(key):
1346         if key not in app.comments:
1347             return
1348         for line in app.comments[key]:
1349             w_comment(line)
1350
1351     def w_field_always(f, v=None):
1352         key = field_to_attr(f)
1353         if v is None:
1354             v = app.get(key)
1355         w_comments(key)
1356         w_field(f, v)
1357
1358     def w_field_nonempty(f, v=None):
1359         key = field_to_attr(f)
1360         if v is None:
1361             v = app.get(key)
1362         w_comments(key)
1363         if v:
1364             w_field(f, v)
1365
1366     w_field_nonempty('Disabled')
1367     w_field_nonempty('AntiFeatures')
1368     w_field_nonempty('Provides')
1369     w_field_always('Categories')
1370     w_field_always('License')
1371     w_field_nonempty('Author Name')
1372     w_field_nonempty('Author Email')
1373     w_field_nonempty('Author Web Site')
1374     w_field_always('Web Site')
1375     w_field_always('Source Code')
1376     w_field_always('Issue Tracker')
1377     w_field_nonempty('Changelog')
1378     w_field_nonempty('Donate')
1379     w_field_nonempty('FlattrID')
1380     w_field_nonempty('Bitcoin')
1381     w_field_nonempty('Litecoin')
1382     mf.write('\n')
1383     w_field_nonempty('Name')
1384     w_field_nonempty('Auto Name')
1385     w_field_nonempty('Summary')
1386     w_field_nonempty('Description', description_txt(app.Description))
1387     mf.write('\n')
1388     if app.RequiresRoot:
1389         w_field_always('Requires Root', 'yes')
1390         mf.write('\n')
1391     if app.RepoType:
1392         w_field_always('Repo Type')
1393         w_field_always('Repo')
1394         if app.Binaries:
1395             w_field_always('Binaries')
1396         mf.write('\n')
1397
1398     for build in app.builds:
1399
1400         if build.versionName == "Ignore":
1401             continue
1402
1403         w_comments('build:%s' % build.versionCode)
1404         w_build(build)
1405         mf.write('\n')
1406
1407     if app.MaintainerNotes:
1408         w_field_always('Maintainer Notes', app.MaintainerNotes)
1409         mf.write('\n')
1410
1411     w_field_nonempty('Archive Policy')
1412     w_field_always('Auto Update Mode')
1413     w_field_always('Update Check Mode')
1414     w_field_nonempty('Update Check Ignore')
1415     w_field_nonempty('Vercode Operation')
1416     w_field_nonempty('Update Check Name')
1417     w_field_nonempty('Update Check Data')
1418     if app.CurrentVersion:
1419         w_field_always('Current Version')
1420         w_field_always('Current Version Code')
1421     if app.NoSourceSince:
1422         mf.write('\n')
1423         w_field_always('No Source Since')
1424     w_comments(None)
1425
1426
1427 # Write a metadata file in txt format.
1428 #
1429 # 'mf'      - Writer interface (file, StringIO, ...)
1430 # 'app'     - The app data
1431 def write_txt(mf, app):
1432
1433     def w_comment(line):
1434         mf.write("# %s\n" % line)
1435
1436     def w_field(f, v):
1437         t = fieldtype(f)
1438         if t == TYPE_LIST:
1439             v = ','.join(v)
1440         elif t == TYPE_MULTILINE:
1441             v = '\n' + v + '\n.'
1442         mf.write("%s:%s\n" % (f, v))
1443
1444     def w_build(build):
1445         mf.write("Build:%s,%s\n" % (build.versionName, build.versionCode))
1446
1447         for f in build_flags_order:
1448             v = build.get(f)
1449             if not v:
1450                 continue
1451
1452             t = flagtype(f)
1453             if f == 'androidupdate':
1454                 f = 'update'  # avoid conflicting with Build(dict).update()
1455             mf.write('    %s=' % f)
1456             if t == TYPE_STRING:
1457                 mf.write(v)
1458             elif t == TYPE_BOOL:
1459                 mf.write('yes')
1460             elif t == TYPE_SCRIPT:
1461                 first = True
1462                 for s in v.split(' && '):
1463                     if first:
1464                         first = False
1465                     else:
1466                         mf.write(' && \\\n        ')
1467                     mf.write(s.strip())
1468             elif t == TYPE_LIST:
1469                 mf.write(','.join(v))
1470
1471             mf.write('\n')
1472
1473     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1474
1475
1476 def write_metadata(metadatapath, app):
1477     _, ext = fdroidserver.common.get_extension(metadatapath)
1478     accepted = fdroidserver.common.config['accepted_formats']
1479     if ext not in accepted:
1480         warn_or_exception('Cannot write "%s", not an accepted format, use: %s'
1481                           % (metadatapath, ', '.join(accepted)))
1482
1483     with open(metadatapath, 'w', encoding='utf8') as mf:
1484         if ext == 'txt':
1485             return write_txt(mf, app)
1486         elif ext == 'yml':
1487             return write_yaml(mf, app)
1488     warn_or_exception('Unknown metadata format: %s' % metadatapath)
1489
1490
1491 def add_metadata_arguments(parser):
1492     '''add common command line flags related to metadata processing'''
1493     parser.add_argument("-W", default='error',
1494                         help="force errors to be warnings, or ignore")