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