chiark / gitweb /
normalize Build TYPE_LIST data based on .txt
[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     'update',
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     builds = []
817     if 'builds' in app:
818         for build in app['builds']:
819             if not isinstance(build, Build):
820                 build = Build(build)
821             for k, v in build.items():
822                 if flagtype(k) == TYPE_LIST:
823                     if isinstance(v, str):
824                         build[k] = [v]
825                     elif isinstance(v, bool):
826                         if v:
827                             build[k] = ['yes']
828                         else:
829                             build[k] = []
830             builds.append(build)
831
832     if not app.get('Description'):
833         app['Description'] = 'No description available'
834
835     app.builds = sorted_builds(builds)
836
837
838 # Parse metadata for a single application.
839 #
840 #  'metadatapath' - the filename to read. The package id for the application comes
841 #               from this filename. Pass None to get a blank entry.
842 #
843 # Returns a dictionary containing all the details of the application. There are
844 # two major kinds of information in the dictionary. Keys beginning with capital
845 # letters correspond directory to identically named keys in the metadata file.
846 # Keys beginning with lower case letters are generated in one way or another,
847 # and are not found verbatim in the metadata.
848 #
849 # Known keys not originating from the metadata are:
850 #
851 #  'builds'           - a list of dictionaries containing build information
852 #                       for each defined build
853 #  'comments'         - a list of comments from the metadata file. Each is
854 #                       a list of the form [field, comment] where field is
855 #                       the name of the field it preceded in the metadata
856 #                       file. Where field is None, the comment goes at the
857 #                       end of the file. Alternatively, 'build:version' is
858 #                       for a comment before a particular build version.
859 #  'descriptionlines' - original lines of description as formatted in the
860 #                       metadata file.
861 #
862
863
864 bool_true = re.compile(r'([Yy]es|[Tt]rue)')
865 bool_false = re.compile(r'([Nn]o|[Ff]alse)')
866
867
868 def _decode_bool(s):
869     if bool_true.match(s):
870         return True
871     if bool_false.match(s):
872         return False
873     warn_or_exception("Invalid bool '%s'" % s)
874
875
876 def parse_metadata(metadatapath, check_vcs=False):
877     '''parse metadata file, optionally checking the git repo for metadata first'''
878
879     _, ext = fdroidserver.common.get_extension(metadatapath)
880     accepted = fdroidserver.common.config['accepted_formats']
881     if ext not in accepted:
882         warn_or_exception('"%s" is not an accepted format, convert to: %s' % (
883             metadatapath, ', '.join(accepted)))
884
885     app = App()
886     app.metadatapath = metadatapath
887     name, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
888     if name == '.fdroid':
889         check_vcs = False
890     else:
891         app.id = name
892
893     with open(metadatapath, 'r', encoding='utf-8') as mf:
894         if ext == 'txt':
895             parse_txt_metadata(mf, app)
896         elif ext == 'json':
897             parse_json_metadata(mf, app)
898         elif ext == 'yml':
899             parse_yaml_metadata(mf, app)
900         else:
901             warn_or_exception('Unknown metadata format: %s' % metadatapath)
902
903     if check_vcs and app.Repo:
904         build_dir = fdroidserver.common.get_build_dir(app)
905         metadata_in_repo = os.path.join(build_dir, '.fdroid.yml')
906         if not os.path.isfile(metadata_in_repo):
907             vcs, build_dir = fdroidserver.common.setup_vcs(app)
908             if isinstance(vcs, fdroidserver.common.vcs_git):
909                 vcs.gotorevision('HEAD')  # HEAD since we can't know where else to go
910         if os.path.isfile(metadata_in_repo):
911             logging.debug('Including metadata from ' + metadata_in_repo)
912             # do not include fields already provided by main metadata file
913             app_in_repo = parse_metadata(metadata_in_repo).field_dict()
914             for k, v in app_in_repo.items():
915                 if k not in app.field_dict():
916                     app.set_field(k, v)
917
918     post_metadata_parse(app)
919
920     if not app.id:
921         if app.builds:
922             build = app.builds[-1]
923             if build.subdir:
924                 root_dir = build.subdir
925             else:
926                 root_dir = '.'
927             paths = fdroidserver.common.manifest_paths(root_dir, build.gradle)
928             _, _, app.id = fdroidserver.common.parse_androidmanifests(paths, app)
929
930     return app
931
932
933 def parse_json_metadata(mf, app):
934
935     # fdroid metadata is only strings and booleans, no floats or ints.
936     # TODO create schema using https://pypi.python.org/pypi/jsonschema
937     jsoninfo = json.load(mf, parse_int=lambda s: s,
938                          parse_float=lambda s: s)
939     app.update(jsoninfo)
940     for f in ['Description', 'Maintainer Notes']:
941         v = app.get(f)
942         if v:
943             app[f] = '\n'.join(v)
944     return app
945
946
947 def parse_yaml_metadata(mf, app):
948
949     yamlinfo = yaml.load(mf, Loader=YamlLoader)
950     app.update(yamlinfo)
951     return app
952
953
954 build_line_sep = re.compile(r'(?<!\\),')
955 build_cont = re.compile(r'^[ \t]')
956
957
958 def parse_txt_metadata(mf, app):
959
960     linedesc = None
961
962     def add_buildflag(p, build):
963         if not p.strip():
964             warn_or_exception("Empty build flag at {1}"
965                               .format(buildlines[0], linedesc))
966         bv = p.split('=', 1)
967         if len(bv) != 2:
968             warn_or_exception("Invalid build flag at {0} in {1}"
969                               .format(buildlines[0], linedesc))
970
971         pk, pv = bv
972         pk = pk.lstrip()
973         if pk == 'update':
974             pk = 'androidupdate'  # avoid conflicting with Build(dict).update()
975         t = flagtype(pk)
976         if t == TYPE_LIST:
977             pv = split_list_values(pv)
978             build[pk] = pv
979         elif t == TYPE_STRING or t == TYPE_SCRIPT:
980             build[pk] = pv
981         elif t == TYPE_BOOL:
982             build[pk] = _decode_bool(pv)
983
984     def parse_buildline(lines):
985         v = "".join(lines)
986         parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
987         if len(parts) < 3:
988             warn_or_exception("Invalid build format: " + v + " in " + mf.name)
989         build = Build()
990         build.versionName = parts[0]
991         build.versionCode = parts[1]
992         check_versionCode(build.versionCode)
993
994         if parts[2].startswith('!'):
995             # For backwards compatibility, handle old-style disabling,
996             # including attempting to extract the commit from the message
997             build.disable = parts[2][1:]
998             commit = 'unknown - see disabled'
999             index = parts[2].rfind('at ')
1000             if index != -1:
1001                 commit = parts[2][index + 3:]
1002                 if commit.endswith(')'):
1003                     commit = commit[:-1]
1004             build.commit = commit
1005         else:
1006             build.commit = parts[2]
1007         for p in parts[3:]:
1008             add_buildflag(p, build)
1009
1010         return build
1011
1012     def check_versionCode(versionCode):
1013         try:
1014             int(versionCode)
1015         except ValueError:
1016             warn_or_exception('Invalid versionCode: "' + versionCode + '" is not an integer!')
1017
1018     def add_comments(key):
1019         if not curcomments:
1020             return
1021         app.comments[key] = list(curcomments)
1022         del curcomments[:]
1023
1024     mode = 0
1025     buildlines = []
1026     multiline_lines = []
1027     curcomments = []
1028     build = None
1029     vc_seen = set()
1030
1031     app.builds = []
1032
1033     c = 0
1034     for line in mf:
1035         c += 1
1036         linedesc = "%s:%d" % (mf.name, c)
1037         line = line.rstrip('\r\n')
1038         if mode == 3:
1039             if build_cont.match(line):
1040                 if line.endswith('\\'):
1041                     buildlines.append(line[:-1].lstrip())
1042                 else:
1043                     buildlines.append(line.lstrip())
1044                     bl = ''.join(buildlines)
1045                     add_buildflag(bl, build)
1046                     del buildlines[:]
1047             else:
1048                 if not build.commit and not build.disable:
1049                     warn_or_exception("No commit specified for {0} in {1}"
1050                                       .format(build.versionName, linedesc))
1051
1052                 app.builds.append(build)
1053                 add_comments('build:' + build.versionCode)
1054                 mode = 0
1055
1056         if mode == 0:
1057             if not line:
1058                 continue
1059             if line.startswith("#"):
1060                 curcomments.append(line[1:].strip())
1061                 continue
1062             try:
1063                 f, v = line.split(':', 1)
1064             except ValueError:
1065                 warn_or_exception("Invalid metadata in " + linedesc)
1066
1067             if f not in app_fields:
1068                 warn_or_exception('Unrecognised app field: ' + f)
1069
1070             # Translate obsolete fields...
1071             if f == 'Market Version':
1072                 f = 'Current Version'
1073             if f == 'Market Version Code':
1074                 f = 'Current Version Code'
1075
1076             f = f.replace(' ', '')
1077
1078             ftype = fieldtype(f)
1079             if ftype not in [TYPE_BUILD, TYPE_BUILD_V2]:
1080                 add_comments(f)
1081             if ftype == TYPE_MULTILINE:
1082                 mode = 1
1083                 if v:
1084                     warn_or_exception("Unexpected text on same line as "
1085                                       + f + " in " + linedesc)
1086             elif ftype == TYPE_STRING:
1087                 app[f] = v
1088             elif ftype == TYPE_LIST:
1089                 app[f] = split_list_values(v)
1090             elif ftype == TYPE_BUILD:
1091                 if v.endswith("\\"):
1092                     mode = 2
1093                     del buildlines[:]
1094                     buildlines.append(v[:-1])
1095                 else:
1096                     build = parse_buildline([v])
1097                     app.builds.append(build)
1098                     add_comments('build:' + app.builds[-1].versionCode)
1099             elif ftype == TYPE_BUILD_V2:
1100                 vv = v.split(',')
1101                 if len(vv) != 2:
1102                     warn_or_exception('Build should have comma-separated',
1103                                       'versionName and versionCode,',
1104                                       'not "{0}", in {1}'.format(v, linedesc))
1105                 build = Build()
1106                 build.versionName = vv[0]
1107                 build.versionCode = vv[1]
1108                 check_versionCode(build.versionCode)
1109
1110                 if build.versionCode in vc_seen:
1111                     warn_or_exception('Duplicate build recipe found for versionCode %s in %s'
1112                                       % (build.versionCode, linedesc))
1113                 vc_seen.add(build.versionCode)
1114                 del buildlines[:]
1115                 mode = 3
1116             elif ftype == TYPE_OBSOLETE:
1117                 pass        # Just throw it away!
1118             else:
1119                 warn_or_exception("Unrecognised field '" + f + "' in " + linedesc)
1120         elif mode == 1:     # Multiline field
1121             if line == '.':
1122                 mode = 0
1123                 app[f] = '\n'.join(multiline_lines)
1124                 del multiline_lines[:]
1125             else:
1126                 multiline_lines.append(line)
1127         elif mode == 2:     # Line continuation mode in Build Version
1128             if line.endswith("\\"):
1129                 buildlines.append(line[:-1])
1130             else:
1131                 buildlines.append(line)
1132                 build = parse_buildline(buildlines)
1133                 app.builds.append(build)
1134                 add_comments('build:' + app.builds[-1].versionCode)
1135                 mode = 0
1136     add_comments(None)
1137
1138     # Mode at end of file should always be 0
1139     if mode == 1:
1140         warn_or_exception(f + " not terminated in " + mf.name)
1141     if mode == 2:
1142         warn_or_exception("Unterminated continuation in " + mf.name)
1143     if mode == 3:
1144         warn_or_exception("Unterminated build in " + mf.name)
1145
1146     return app
1147
1148
1149 def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
1150
1151     def field_to_attr(f):
1152         """
1153         Translates human-readable field names to attribute names, e.g.
1154         'Auto Name' to 'AutoName'
1155         """
1156         return f.replace(' ', '')
1157
1158     def attr_to_field(k):
1159         """
1160         Translates attribute names to human-readable field names, e.g.
1161         'AutoName' to 'Auto Name'
1162         """
1163         if k in app_fields:
1164             return k
1165         f = re.sub(r'([a-z])([A-Z])', r'\1 \2', k)
1166         return f
1167
1168     def w_comments(key):
1169         if key not in app.comments:
1170             return
1171         for line in app.comments[key]:
1172             w_comment(line)
1173
1174     def w_field_always(f, v=None):
1175         key = field_to_attr(f)
1176         if v is None:
1177             v = app.get(key)
1178         w_comments(key)
1179         w_field(f, v)
1180
1181     def w_field_nonempty(f, v=None):
1182         key = field_to_attr(f)
1183         if v is None:
1184             v = app.get(key)
1185         w_comments(key)
1186         if v:
1187             w_field(f, v)
1188
1189     w_field_nonempty('Disabled')
1190     w_field_nonempty('AntiFeatures')
1191     w_field_nonempty('Provides')
1192     w_field_always('Categories')
1193     w_field_always('License')
1194     w_field_nonempty('Author Name')
1195     w_field_nonempty('Author Email')
1196     w_field_always('Web Site')
1197     w_field_always('Source Code')
1198     w_field_always('Issue Tracker')
1199     w_field_nonempty('Changelog')
1200     w_field_nonempty('Donate')
1201     w_field_nonempty('FlattrID')
1202     w_field_nonempty('Bitcoin')
1203     w_field_nonempty('Litecoin')
1204     mf.write('\n')
1205     w_field_nonempty('Name')
1206     w_field_nonempty('Auto Name')
1207     w_field_always('Summary')
1208     w_field_always('Description', description_txt(app.Description))
1209     mf.write('\n')
1210     if app.RequiresRoot:
1211         w_field_always('Requires Root', 'yes')
1212         mf.write('\n')
1213     if app.RepoType:
1214         w_field_always('Repo Type')
1215         w_field_always('Repo')
1216         if app.Binaries:
1217             w_field_always('Binaries')
1218         mf.write('\n')
1219
1220     for build in app.builds:
1221
1222         if build.versionName == "Ignore":
1223             continue
1224
1225         w_comments('build:%s' % build.versionCode)
1226         w_build(build)
1227         mf.write('\n')
1228
1229     if app.MaintainerNotes:
1230         w_field_always('Maintainer Notes', app.MaintainerNotes)
1231         mf.write('\n')
1232
1233     w_field_nonempty('Archive Policy')
1234     w_field_always('Auto Update Mode')
1235     w_field_always('Update Check Mode')
1236     w_field_nonempty('Update Check Ignore')
1237     w_field_nonempty('Vercode Operation')
1238     w_field_nonempty('Update Check Name')
1239     w_field_nonempty('Update Check Data')
1240     if app.CurrentVersion:
1241         w_field_always('Current Version')
1242         w_field_always('Current Version Code')
1243     if app.NoSourceSince:
1244         mf.write('\n')
1245         w_field_always('No Source Since')
1246     w_comments(None)
1247
1248
1249 # Write a metadata file in txt format.
1250 #
1251 # 'mf'      - Writer interface (file, StringIO, ...)
1252 # 'app'     - The app data
1253 def write_txt(mf, app):
1254
1255     def w_comment(line):
1256         mf.write("# %s\n" % line)
1257
1258     def w_field(f, v):
1259         t = fieldtype(f)
1260         if t == TYPE_LIST:
1261             v = ','.join(v)
1262         elif t == TYPE_MULTILINE:
1263             v = '\n' + v + '\n.'
1264         mf.write("%s:%s\n" % (f, v))
1265
1266     def w_build(build):
1267         mf.write("Build:%s,%s\n" % (build.versionName, build.versionCode))
1268
1269         for f in build_flags_order:
1270             v = build.get(f)
1271             if not v:
1272                 continue
1273
1274             t = flagtype(f)
1275             if f == 'androidupdate':
1276                 f == 'update'  # avoid conflicting with Build(dict).update()
1277             mf.write('    %s=' % f)
1278             if t == TYPE_STRING:
1279                 mf.write(v)
1280             elif t == TYPE_BOOL:
1281                 mf.write('yes')
1282             elif t == TYPE_SCRIPT:
1283                 first = True
1284                 for s in v.split(' && '):
1285                     if first:
1286                         first = False
1287                     else:
1288                         mf.write(' && \\\n        ')
1289                     mf.write(s)
1290             elif t == TYPE_LIST:
1291                 mf.write(','.join(v))
1292
1293             mf.write('\n')
1294
1295     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1296
1297
1298 def write_yaml(mf, app):
1299
1300     def w_comment(line):
1301         mf.write("# %s\n" % line)
1302
1303     def escape(v):
1304         if not v:
1305             return ''
1306         if any(c in v for c in [': ', '%', '@', '*']):
1307             return "'" + v.replace("'", "''") + "'"
1308         return v
1309
1310     def w_field(f, v, prefix='', t=None):
1311         if t is None:
1312             t = fieldtype(f)
1313         v = ''
1314         if t == TYPE_LIST:
1315             v = '\n'
1316             for e in v:
1317                 v += prefix + ' - ' + escape(e) + '\n'
1318         elif t == TYPE_MULTILINE:
1319             v = ' |\n'
1320             for l in v.splitlines():
1321                 if l:
1322                     v += prefix + '  ' + l + '\n'
1323                 else:
1324                     v += '\n'
1325         elif t == TYPE_BOOL:
1326             v = ' yes\n'
1327         elif t == TYPE_SCRIPT:
1328             cmds = [s + '&& \\' for s in v.split('&& ')]
1329             if len(cmds) > 0:
1330                 cmds[-1] = cmds[-1][:-len('&& \\')]
1331             w_field(f, cmds, prefix, 'multiline')
1332             return
1333         else:
1334             v = ' ' + escape(v) + '\n'
1335
1336         mf.write(prefix)
1337         mf.write(f)
1338         mf.write(":")
1339         mf.write(v)
1340
1341     global first_build
1342     first_build = True
1343
1344     def w_build(build):
1345         global first_build
1346         if first_build:
1347             mf.write("builds:\n")
1348             first_build = False
1349
1350         w_field('versionName', build.versionName, '  - ', TYPE_STRING)
1351         w_field('versionCode', build.versionCode, '    ', TYPE_STRING)
1352         for f in build_flags_order:
1353             v = build.get(f)
1354             if not v:
1355                 continue
1356
1357             w_field(f, v, '    ', flagtype(f))
1358
1359     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1360
1361
1362 def write_metadata(metadatapath, app):
1363     _, ext = fdroidserver.common.get_extension(metadatapath)
1364     accepted = fdroidserver.common.config['accepted_formats']
1365     if ext not in accepted:
1366         warn_or_exception('Cannot write "%s", not an accepted format, use: %s'
1367                           % (metadatapath, ', '.join(accepted)))
1368
1369     with open(metadatapath, 'w', encoding='utf8') as mf:
1370         if ext == 'txt':
1371             return write_txt(mf, app)
1372         elif ext == 'yml':
1373             return write_yaml(mf, app)
1374     warn_or_exception('Unknown metadata format: %s' % metadatapath)
1375
1376
1377 def add_metadata_arguments(parser):
1378     '''add common command line flags related to metadata processing'''
1379     parser.add_argument("-W", default='error',
1380                         help="force errors to be warnings, or ignore")