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