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