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