chiark / gitweb /
739e964a83c9f6220580c876e230432aa7d6b8cb
[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.json')
732                                + glob.glob('.fdroid.yml')):
733         packageName, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
734         if packageName in apps:
735             warn_or_exception("Found multiple metadata files for " + packageName)
736         app = parse_metadata(metadatapath, packageName in check_vcs)
737         check_metadata(app)
738         apps[app.id] = app
739
740     if xref:
741         # Parse all descriptions at load time, just to ensure cross-referencing
742         # errors are caught early rather than when they hit the build server.
743         def linkres(appid):
744             if appid in apps:
745                 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
746             warn_or_exception("Cannot resolve app id " + appid)
747
748         for appid, app in apps.items():
749             try:
750                 description_html(app.Description, linkres)
751             except MetaDataException as e:
752                 warn_or_exception("Problem with description of " + appid +
753                                   " - " + str(e))
754
755     return apps
756
757
758 # Port legacy ';' separators
759 list_sep = re.compile(r'[,;]')
760
761
762 def split_list_values(s):
763     res = []
764     for v in re.split(list_sep, s):
765         if not v:
766             continue
767         v = v.strip()
768         if not v:
769             continue
770         res.append(v)
771     return res
772
773
774 def get_default_app_info(metadatapath=None):
775     if metadatapath is None:
776         appid = None
777     else:
778         appid, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
779
780     if appid == '.fdroid':  # we have local metadata in the app's source
781         if os.path.exists('AndroidManifest.xml'):
782             manifestroot = fdroidserver.common.parse_xml('AndroidManifest.xml')
783         else:
784             pattern = re.compile(""".*manifest\.srcFile\s+'AndroidManifest\.xml'.*""")
785             for root, dirs, files in os.walk(os.getcwd()):
786                 if 'build.gradle' in files:
787                     p = os.path.join(root, 'build.gradle')
788                     with open(p, 'rb') as f:
789                         data = f.read()
790                     m = pattern.search(data)
791                     if m:
792                         logging.debug('Using: ' + os.path.join(root, 'AndroidManifest.xml'))
793                         manifestroot = fdroidserver.common.parse_xml(os.path.join(root, 'AndroidManifest.xml'))
794                         break
795         if manifestroot is None:
796             warn_or_exception("Cannot find a packageName for {0}!".format(metadatapath))
797         appid = manifestroot.attrib['package']
798
799     app = App()
800     app.metadatapath = metadatapath
801     if appid is not None:
802         app.id = appid
803
804     return app
805
806
807 def sorted_builds(builds):
808     return sorted(builds, key=lambda build: int(build.versionCode))
809
810
811 esc_newlines = re.compile(r'\\( |\n)')
812
813
814 def post_metadata_parse(app):
815     # TODO keep native types, convert only for .txt metadata
816     for k, v in app.items():
817         if type(v) in (float, int):
818             app[k] = str(v)
819
820     if 'Builds' in app:
821         app['builds'] = app.pop('Builds')
822
823     if 'flavours' in app and app['flavours'] == [True]:
824         app['flavours'] = 'yes'
825
826     if isinstance(app.Categories, str):
827         app.Categories = [app.Categories]
828     elif app.Categories is None:
829         app.Categories = ['None']
830     else:
831         app.Categories = [str(i) for i in app.Categories]
832
833     def _yaml_bool_unmapable(v):
834         return v in (True, False, [True], [False])
835
836     def _yaml_bool_unmap(v):
837         if v is True:
838             return 'yes'
839         elif v is False:
840             return 'no'
841         elif v == [True]:
842             return ['yes']
843         elif v == [False]:
844             return ['no']
845
846     _bool_allowed = ('disable', 'kivy', 'maven', 'buildozer')
847
848     builds = []
849     if 'builds' in app:
850         for build in app['builds']:
851             if not isinstance(build, Build):
852                 build = Build(build)
853             for k, v in build.items():
854                 if not (v is None):
855                     if flagtype(k) == TYPE_LIST:
856                         if _yaml_bool_unmapable(v):
857                             build[k] = _yaml_bool_unmap(v)
858
859                         if isinstance(v, str):
860                             build[k] = [v]
861                         elif isinstance(v, bool):
862                             if v:
863                                 build[k] = ['yes']
864                             else:
865                                 build[k] = []
866                     elif flagtype(k) is TYPE_INT:
867                         build[k] = str(v)
868                     elif flagtype(k) is TYPE_STRING:
869                         if isinstance(v, bool) and k in _bool_allowed:
870                             build[k] = v
871                         else:
872                             if _yaml_bool_unmapable(v):
873                                 build[k] = _yaml_bool_unmap(v)
874                             else:
875                                 build[k] = str(v)
876             builds.append(build)
877
878     app.builds = sorted_builds(builds)
879
880
881 # Parse metadata for a single application.
882 #
883 #  'metadatapath' - the filename to read. The package id for the application comes
884 #               from this filename. Pass None to get a blank entry.
885 #
886 # Returns a dictionary containing all the details of the application. There are
887 # two major kinds of information in the dictionary. Keys beginning with capital
888 # letters correspond directory to identically named keys in the metadata file.
889 # Keys beginning with lower case letters are generated in one way or another,
890 # and are not found verbatim in the metadata.
891 #
892 # Known keys not originating from the metadata are:
893 #
894 #  'builds'           - a list of dictionaries containing build information
895 #                       for each defined build
896 #  'comments'         - a list of comments from the metadata file. Each is
897 #                       a list of the form [field, comment] where field is
898 #                       the name of the field it preceded in the metadata
899 #                       file. Where field is None, the comment goes at the
900 #                       end of the file. Alternatively, 'build:version' is
901 #                       for a comment before a particular build version.
902 #  'descriptionlines' - original lines of description as formatted in the
903 #                       metadata file.
904 #
905
906
907 bool_true = re.compile(r'([Yy]es|[Tt]rue)')
908 bool_false = re.compile(r'([Nn]o|[Ff]alse)')
909
910
911 def _decode_bool(s):
912     if bool_true.match(s):
913         return True
914     if bool_false.match(s):
915         return False
916     warn_or_exception("Invalid bool '%s'" % s)
917
918
919 def parse_metadata(metadatapath, check_vcs=False):
920     '''parse metadata file, optionally checking the git repo for metadata first'''
921
922     _, ext = fdroidserver.common.get_extension(metadatapath)
923     accepted = fdroidserver.common.config['accepted_formats']
924     if ext not in accepted:
925         warn_or_exception('"%s" is not an accepted format, convert to: %s' % (
926             metadatapath, ', '.join(accepted)))
927
928     app = App()
929     app.metadatapath = metadatapath
930     name, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
931     if name == '.fdroid':
932         check_vcs = False
933     else:
934         app.id = name
935
936     with open(metadatapath, 'r', encoding='utf-8') as mf:
937         if ext == 'txt':
938             parse_txt_metadata(mf, app)
939         elif ext == 'json':
940             parse_json_metadata(mf, app)
941         elif ext == 'yml':
942             parse_yaml_metadata(mf, app)
943         else:
944             warn_or_exception('Unknown metadata format: %s' % metadatapath)
945
946     if check_vcs and app.Repo:
947         build_dir = fdroidserver.common.get_build_dir(app)
948         metadata_in_repo = os.path.join(build_dir, '.fdroid.yml')
949         if not os.path.isfile(metadata_in_repo):
950             vcs, build_dir = fdroidserver.common.setup_vcs(app)
951             if isinstance(vcs, fdroidserver.common.vcs_git):
952                 vcs.gotorevision('HEAD')  # HEAD since we can't know where else to go
953         if os.path.isfile(metadata_in_repo):
954             logging.debug('Including metadata from ' + metadata_in_repo)
955             # do not include fields already provided by main metadata file
956             app_in_repo = parse_metadata(metadata_in_repo)
957             for k, v in app_in_repo.items():
958                 if k not in app:
959                     app[k] = v
960
961     post_metadata_parse(app)
962
963     if not app.id:
964         if app.builds:
965             build = app.builds[-1]
966             if build.subdir:
967                 root_dir = build.subdir
968             else:
969                 root_dir = '.'
970             paths = fdroidserver.common.manifest_paths(root_dir, build.gradle)
971             _, _, app.id = fdroidserver.common.parse_androidmanifests(paths, app)
972
973     return app
974
975
976 def parse_json_metadata(mf, app):
977
978     # fdroid metadata is only strings and booleans, no floats or ints.
979     # TODO create schema using https://pypi.python.org/pypi/jsonschema
980     jsoninfo = json.load(mf, parse_int=lambda s: s,
981                          parse_float=lambda s: s)
982     app.update(jsoninfo)
983     for f in ['Description', 'Maintainer Notes']:
984         v = app.get(f)
985         if v:
986             app[f] = '\n'.join(v)
987     return app
988
989
990 def parse_yaml_metadata(mf, app):
991     yamldata = yaml.load(mf, Loader=YamlLoader)
992     if yamldata:
993         app.update(yamldata)
994     return app
995
996
997 def write_yaml(mf, app):
998
999     # import rumael.yaml and check version
1000     try:
1001         import ruamel.yaml
1002     except ImportError as e:
1003         raise FDroidException('ruamel.yaml not instlled, can not write metadata.') from e
1004     if not ruamel.yaml.__version__:
1005         raise FDroidException('ruamel.yaml.__version__ not accessible. Please make sure a ruamel.yaml >= 0.13 is installed..')
1006     m = re.match('(?P<major>[0-9]+)\.(?P<minor>[0-9]+)\.(?P<patch>[0-9]+)(-.+)?',
1007                  ruamel.yaml.__version__)
1008     if not m:
1009         raise FDroidException('ruamel.yaml version malfored, please install an upstream version of ruamel.yaml')
1010     if int(m.group('major')) < 0 or int(m.group('minor')) < 13:
1011         raise FDroidException('currently installed version of ruamel.yaml ({}) is too old, >= 1.13 required.'.format(ruamel.yaml.__version__))
1012     # suiteable version ruamel.yaml imported successfully
1013
1014     _yaml_bools_true = ('y', 'Y', 'yes', 'Yes', 'YES',
1015                         'true', 'True', 'TRUE',
1016                         'on', 'On', 'ON')
1017     _yaml_bools_false = ('n', 'N', 'no', 'No', 'NO',
1018                          'false', 'False', 'FALSE',
1019                          'off', 'Off', 'OFF')
1020     _yaml_bools_plus_lists = []
1021     _yaml_bools_plus_lists.extend(_yaml_bools_true)
1022     _yaml_bools_plus_lists.extend([[x] for x in _yaml_bools_true])
1023     _yaml_bools_plus_lists.extend(_yaml_bools_false)
1024     _yaml_bools_plus_lists.extend([[x] for x in _yaml_bools_false])
1025
1026     def _class_as_dict_representer(dumper, data):
1027         '''Creates a YAML representation of a App/Build instance'''
1028         return dumper.represent_dict(data)
1029
1030     def _field_to_yaml(typ, value):
1031         if typ is TYPE_STRING:
1032             if value in _yaml_bools_plus_lists:
1033                 return ruamel.yaml.scalarstring.SingleQuotedScalarString(str(value))
1034             return str(value)
1035         elif typ is TYPE_INT:
1036             return int(value)
1037         elif typ is TYPE_MULTILINE:
1038             if '\n' in value:
1039                 return ruamel.yaml.scalarstring.preserve_literal(str(value))
1040             else:
1041                 return str(value)
1042         elif typ is TYPE_SCRIPT:
1043             if len(value) > 50:
1044                 return ruamel.yaml.scalarstring.preserve_literal(value)
1045             else:
1046                 return value
1047         else:
1048             return value
1049
1050     def _app_to_yaml(app):
1051         cm = ruamel.yaml.comments.CommentedMap()
1052         insert_newline = False
1053         for field in yaml_app_field_order:
1054             if field is '\n':
1055                 # next iteration will need to insert a newline
1056                 insert_newline = True
1057             else:
1058                 if app.get(field) or field is 'Builds':
1059                     # .txt calls it 'builds' internally, everywhere else its 'Builds'
1060                     if field is 'Builds':
1061                         if app.get('builds'):
1062                             cm.update({field: _builds_to_yaml(app)})
1063                     elif field is 'CurrentVersionCode':
1064                         cm.update({field: _field_to_yaml(TYPE_INT, getattr(app, field))})
1065                     else:
1066                         cm.update({field: _field_to_yaml(fieldtype(field), getattr(app, field))})
1067
1068                     if insert_newline:
1069                         # we need to prepend a newline in front of this field
1070                         insert_newline = False
1071                         # inserting empty lines is not supported so we add a
1072                         # bogus comment and over-write its value
1073                         cm.yaml_set_comment_before_after_key(field, 'bogus')
1074                         cm.ca.items[field][1][-1].value = '\n'
1075         return cm
1076
1077     def _builds_to_yaml(app):
1078         fields = ['versionName', 'versionCode']
1079         fields.extend(build_flags_order)
1080         builds = ruamel.yaml.comments.CommentedSeq()
1081         for build in app.builds:
1082             b = ruamel.yaml.comments.CommentedMap()
1083             for field in fields:
1084                 if hasattr(build, field) and getattr(build, field):
1085                     value = getattr(build, field)
1086                     if field == 'gradle' and value == ['off']:
1087                         value = [ruamel.yaml.scalarstring.SingleQuotedScalarString('off')]
1088                     if field in ('disable', 'kivy', 'maven', 'buildozer'):
1089                         if value == 'no':
1090                             continue
1091                         elif value == 'yes':
1092                             value = 'yes'
1093                     b.update({field: _field_to_yaml(flagtype(field), value)})
1094             builds.append(b)
1095
1096         # insert extra empty lines between build entries
1097         for i in range(1, len(builds)):
1098             builds.yaml_set_comment_before_after_key(i, 'bogus')
1099             builds.ca.items[i][1][-1].value = '\n'
1100
1101         return builds
1102
1103     yaml_app_field_order = [
1104         'Disabled',
1105         'AntiFeatures',
1106         'Provides',
1107         'Categories',
1108         'License',
1109         'AuthorName',
1110         'AuthorEmail',
1111         'AuthorWebSite',
1112         'WebSite',
1113         'SourceCode',
1114         'IssueTracker',
1115         'Changelog',
1116         'Donate',
1117         'FlattrID',
1118         'Bitcoin',
1119         'Litecoin',
1120         '\n',
1121         'Name',
1122         'AutoName',
1123         'Summary',
1124         'Description',
1125         '\n',
1126         'RequiresRoot',
1127         '\n',
1128         'RepoType',
1129         'Repo',
1130         'Binaries',
1131         '\n',
1132         'Builds',
1133         '\n',
1134         'MaintainerNotes',
1135         '\n',
1136         'ArchivePolicy',
1137         'AutoUpdateMode',
1138         'UpdateCheckMode',
1139         'UpdateCheckIgnore',
1140         'VercodeOperation',
1141         'UpdateCheckName',
1142         'UpdateCheckData',
1143         'CurrentVersion',
1144         'CurrentVersionCode',
1145         '\n',
1146         'NoSourceSince',
1147     ]
1148
1149     yaml_app = _app_to_yaml(app)
1150     ruamel.yaml.round_trip_dump(yaml_app, mf, indent=4, block_seq_indent=2)
1151
1152
1153 build_line_sep = re.compile(r'(?<!\\),')
1154 build_cont = re.compile(r'^[ \t]')
1155
1156
1157 def parse_txt_metadata(mf, app):
1158
1159     linedesc = None
1160
1161     def add_buildflag(p, build):
1162         if not p.strip():
1163             warn_or_exception("Empty build flag at {1}"
1164                               .format(buildlines[0], linedesc))
1165         bv = p.split('=', 1)
1166         if len(bv) != 2:
1167             warn_or_exception("Invalid build flag at {0} in {1}"
1168                               .format(buildlines[0], linedesc))
1169
1170         pk, pv = bv
1171         pk = pk.lstrip()
1172         if pk == 'update':
1173             pk = 'androidupdate'  # avoid conflicting with Build(dict).update()
1174         t = flagtype(pk)
1175         if t == TYPE_LIST:
1176             pv = split_list_values(pv)
1177             build[pk] = pv
1178         elif t == TYPE_STRING or t == TYPE_SCRIPT:
1179             build[pk] = pv
1180         elif t == TYPE_BOOL:
1181             build[pk] = _decode_bool(pv)
1182
1183     def parse_buildline(lines):
1184         v = "".join(lines)
1185         parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
1186         if len(parts) < 3:
1187             warn_or_exception("Invalid build format: " + v + " in " + mf.name)
1188         build = Build()
1189         build.versionName = parts[0]
1190         build.versionCode = parts[1]
1191         check_versionCode(build.versionCode)
1192
1193         if parts[2].startswith('!'):
1194             # For backwards compatibility, handle old-style disabling,
1195             # including attempting to extract the commit from the message
1196             build.disable = parts[2][1:]
1197             commit = 'unknown - see disabled'
1198             index = parts[2].rfind('at ')
1199             if index != -1:
1200                 commit = parts[2][index + 3:]
1201                 if commit.endswith(')'):
1202                     commit = commit[:-1]
1203             build.commit = commit
1204         else:
1205             build.commit = parts[2]
1206         for p in parts[3:]:
1207             add_buildflag(p, build)
1208
1209         return build
1210
1211     def check_versionCode(versionCode):
1212         try:
1213             int(versionCode)
1214         except ValueError:
1215             warn_or_exception('Invalid versionCode: "' + versionCode + '" is not an integer!')
1216
1217     def add_comments(key):
1218         if not curcomments:
1219             return
1220         app.comments[key] = list(curcomments)
1221         del curcomments[:]
1222
1223     mode = 0
1224     buildlines = []
1225     multiline_lines = []
1226     curcomments = []
1227     build = None
1228     vc_seen = set()
1229
1230     app.builds = []
1231
1232     c = 0
1233     for line in mf:
1234         c += 1
1235         linedesc = "%s:%d" % (mf.name, c)
1236         line = line.rstrip('\r\n')
1237         if mode == 3:
1238             if build_cont.match(line):
1239                 if line.endswith('\\'):
1240                     buildlines.append(line[:-1].lstrip())
1241                 else:
1242                     buildlines.append(line.lstrip())
1243                     bl = ''.join(buildlines)
1244                     add_buildflag(bl, build)
1245                     del buildlines[:]
1246             else:
1247                 if not build.commit and not build.disable:
1248                     warn_or_exception("No commit specified for {0} in {1}"
1249                                       .format(build.versionName, linedesc))
1250
1251                 app.builds.append(build)
1252                 add_comments('build:' + build.versionCode)
1253                 mode = 0
1254
1255         if mode == 0:
1256             if not line:
1257                 continue
1258             if line.startswith("#"):
1259                 curcomments.append(line[1:].strip())
1260                 continue
1261             try:
1262                 f, v = line.split(':', 1)
1263             except ValueError:
1264                 warn_or_exception("Invalid metadata in " + linedesc)
1265
1266             if f not in app_fields:
1267                 warn_or_exception('Unrecognised app field: ' + f)
1268
1269             # Translate obsolete fields...
1270             if f == 'Market Version':
1271                 f = 'Current Version'
1272             if f == 'Market Version Code':
1273                 f = 'Current Version Code'
1274
1275             f = f.replace(' ', '')
1276
1277             ftype = fieldtype(f)
1278             if ftype not in [TYPE_BUILD, TYPE_BUILD_V2]:
1279                 add_comments(f)
1280             if ftype == TYPE_MULTILINE:
1281                 mode = 1
1282                 if v:
1283                     warn_or_exception("Unexpected text on same line as "
1284                                       + f + " in " + linedesc)
1285             elif ftype == TYPE_STRING:
1286                 app[f] = v
1287             elif ftype == TYPE_LIST:
1288                 app[f] = split_list_values(v)
1289             elif ftype == TYPE_BUILD:
1290                 if v.endswith("\\"):
1291                     mode = 2
1292                     del buildlines[:]
1293                     buildlines.append(v[:-1])
1294                 else:
1295                     build = parse_buildline([v])
1296                     app.builds.append(build)
1297                     add_comments('build:' + app.builds[-1].versionCode)
1298             elif ftype == TYPE_BUILD_V2:
1299                 vv = v.split(',')
1300                 if len(vv) != 2:
1301                     warn_or_exception('Build should have comma-separated',
1302                                       'versionName and versionCode,',
1303                                       'not "{0}", in {1}'.format(v, linedesc))
1304                 build = Build()
1305                 build.versionName = vv[0]
1306                 build.versionCode = vv[1]
1307                 check_versionCode(build.versionCode)
1308
1309                 if build.versionCode in vc_seen:
1310                     warn_or_exception('Duplicate build recipe found for versionCode %s in %s'
1311                                       % (build.versionCode, linedesc))
1312                 vc_seen.add(build.versionCode)
1313                 del buildlines[:]
1314                 mode = 3
1315             elif ftype == TYPE_OBSOLETE:
1316                 pass        # Just throw it away!
1317             else:
1318                 warn_or_exception("Unrecognised field '" + f + "' in " + linedesc)
1319         elif mode == 1:     # Multiline field
1320             if line == '.':
1321                 mode = 0
1322                 app[f] = '\n'.join(multiline_lines)
1323                 del multiline_lines[:]
1324             else:
1325                 multiline_lines.append(line)
1326         elif mode == 2:     # Line continuation mode in Build Version
1327             if line.endswith("\\"):
1328                 buildlines.append(line[:-1])
1329             else:
1330                 buildlines.append(line)
1331                 build = parse_buildline(buildlines)
1332                 app.builds.append(build)
1333                 add_comments('build:' + app.builds[-1].versionCode)
1334                 mode = 0
1335     add_comments(None)
1336
1337     # Mode at end of file should always be 0
1338     if mode == 1:
1339         warn_or_exception(f + " not terminated in " + mf.name)
1340     if mode == 2:
1341         warn_or_exception("Unterminated continuation in " + mf.name)
1342     if mode == 3:
1343         warn_or_exception("Unterminated build in " + mf.name)
1344
1345     return app
1346
1347
1348 def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
1349
1350     def field_to_attr(f):
1351         """
1352         Translates human-readable field names to attribute names, e.g.
1353         'Auto Name' to 'AutoName'
1354         """
1355         return f.replace(' ', '')
1356
1357     def attr_to_field(k):
1358         """
1359         Translates attribute names to human-readable field names, e.g.
1360         'AutoName' to 'Auto Name'
1361         """
1362         if k in app_fields:
1363             return k
1364         f = re.sub(r'([a-z])([A-Z])', r'\1 \2', k)
1365         return f
1366
1367     def w_comments(key):
1368         if key not in app.comments:
1369             return
1370         for line in app.comments[key]:
1371             w_comment(line)
1372
1373     def w_field_always(f, v=None):
1374         key = field_to_attr(f)
1375         if v is None:
1376             v = app.get(key)
1377         w_comments(key)
1378         w_field(f, v)
1379
1380     def w_field_nonempty(f, v=None):
1381         key = field_to_attr(f)
1382         if v is None:
1383             v = app.get(key)
1384         w_comments(key)
1385         if v:
1386             w_field(f, v)
1387
1388     w_field_nonempty('Disabled')
1389     w_field_nonempty('AntiFeatures')
1390     w_field_nonempty('Provides')
1391     w_field_always('Categories')
1392     w_field_always('License')
1393     w_field_nonempty('Author Name')
1394     w_field_nonempty('Author Email')
1395     w_field_nonempty('Author Web Site')
1396     w_field_always('Web Site')
1397     w_field_always('Source Code')
1398     w_field_always('Issue Tracker')
1399     w_field_nonempty('Changelog')
1400     w_field_nonempty('Donate')
1401     w_field_nonempty('FlattrID')
1402     w_field_nonempty('Bitcoin')
1403     w_field_nonempty('Litecoin')
1404     mf.write('\n')
1405     w_field_nonempty('Name')
1406     w_field_nonempty('Auto Name')
1407     w_field_nonempty('Summary')
1408     w_field_nonempty('Description', description_txt(app.Description))
1409     mf.write('\n')
1410     if app.RequiresRoot:
1411         w_field_always('Requires Root', 'yes')
1412         mf.write('\n')
1413     if app.RepoType:
1414         w_field_always('Repo Type')
1415         w_field_always('Repo')
1416         if app.Binaries:
1417             w_field_always('Binaries')
1418         mf.write('\n')
1419
1420     for build in app.builds:
1421
1422         if build.versionName == "Ignore":
1423             continue
1424
1425         w_comments('build:%s' % build.versionCode)
1426         w_build(build)
1427         mf.write('\n')
1428
1429     if app.MaintainerNotes:
1430         w_field_always('Maintainer Notes', app.MaintainerNotes)
1431         mf.write('\n')
1432
1433     w_field_nonempty('Archive Policy')
1434     w_field_always('Auto Update Mode')
1435     w_field_always('Update Check Mode')
1436     w_field_nonempty('Update Check Ignore')
1437     w_field_nonempty('Vercode Operation')
1438     w_field_nonempty('Update Check Name')
1439     w_field_nonempty('Update Check Data')
1440     if app.CurrentVersion:
1441         w_field_always('Current Version')
1442         w_field_always('Current Version Code')
1443     if app.NoSourceSince:
1444         mf.write('\n')
1445         w_field_always('No Source Since')
1446     w_comments(None)
1447
1448
1449 # Write a metadata file in txt format.
1450 #
1451 # 'mf'      - Writer interface (file, StringIO, ...)
1452 # 'app'     - The app data
1453 def write_txt(mf, app):
1454
1455     def w_comment(line):
1456         mf.write("# %s\n" % line)
1457
1458     def w_field(f, v):
1459         t = fieldtype(f)
1460         if t == TYPE_LIST:
1461             v = ','.join(v)
1462         elif t == TYPE_MULTILINE:
1463             v = '\n' + v + '\n.'
1464         mf.write("%s:%s\n" % (f, v))
1465
1466     def w_build(build):
1467         mf.write("Build:%s,%s\n" % (build.versionName, build.versionCode))
1468
1469         for f in build_flags_order:
1470             v = build.get(f)
1471             if not v:
1472                 continue
1473
1474             t = flagtype(f)
1475             if f == 'androidupdate':
1476                 f = 'update'  # avoid conflicting with Build(dict).update()
1477             mf.write('    %s=' % f)
1478             if t == TYPE_STRING:
1479                 mf.write(v)
1480             elif t == TYPE_BOOL:
1481                 mf.write('yes')
1482             elif t == TYPE_SCRIPT:
1483                 first = True
1484                 for s in v.split(' && '):
1485                     if first:
1486                         first = False
1487                     else:
1488                         mf.write(' && \\\n        ')
1489                     mf.write(s.strip())
1490             elif t == TYPE_LIST:
1491                 mf.write(','.join(v))
1492
1493             mf.write('\n')
1494
1495     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1496
1497
1498 def write_metadata(metadatapath, app):
1499     _, ext = fdroidserver.common.get_extension(metadatapath)
1500     accepted = fdroidserver.common.config['accepted_formats']
1501     if ext not in accepted:
1502         warn_or_exception('Cannot write "%s", not an accepted format, use: %s'
1503                           % (metadatapath, ', '.join(accepted)))
1504
1505     try:
1506         with open(metadatapath, 'w', encoding='utf8') as mf:
1507             if ext == 'txt':
1508                 return write_txt(mf, app)
1509             elif ext == 'yml':
1510                 return write_yaml(mf, app)
1511     except FDroidException as e:
1512         os.remove(metadatapath)
1513         raise e
1514
1515     warn_or_exception('Unknown metadata format: %s' % metadatapath)
1516
1517
1518 def add_metadata_arguments(parser):
1519     '''add common command line flags related to metadata processing'''
1520     parser.add_argument("-W", default='error',
1521                         help="force errors to be warnings, or ignore")