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