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