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