chiark / gitweb /
remove support for XML app metadata, its broken
[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             vcs.gotorevision('HEAD')  # HEAD since we can't know where else to go
996         if os.path.isfile(metadata_in_repo):
997             logging.debug('Including metadata from ' + metadata_in_repo)
998             app.update(parse_metadata(metadata_in_repo))
999
1000     post_metadata_parse(app)
1001
1002     if not app.id:
1003         if app.builds:
1004             build = app.builds[-1]
1005             if build.subdir:
1006                 root_dir = build.subdir
1007             else:
1008                 root_dir = '.'
1009             paths = fdroidserver.common.manifest_paths(root_dir, build.gradle)
1010             _, _, app.id = fdroidserver.common.parse_androidmanifests(paths, app)
1011
1012     return app
1013
1014
1015 def parse_json_metadata(mf, app):
1016
1017     # fdroid metadata is only strings and booleans, no floats or ints.
1018     # TODO create schema using https://pypi.python.org/pypi/jsonschema
1019     jsoninfo = json.load(mf, parse_int=lambda s: s,
1020                          parse_float=lambda s: s)
1021     app.update_fields(jsoninfo)
1022     for f in ['Description', 'Maintainer Notes']:
1023         v = app.get_field(f)
1024         app.set_field(f, '\n'.join(v))
1025     return app
1026
1027
1028 def parse_yaml_metadata(mf, app):
1029
1030     yamlinfo = yaml.load(mf, Loader=YamlLoader)
1031     app.update_fields(yamlinfo)
1032     return app
1033
1034
1035 build_line_sep = re.compile(r'(?<!\\),')
1036 build_cont = re.compile(r'^[ \t]')
1037
1038
1039 def parse_txt_metadata(mf, app):
1040
1041     linedesc = None
1042
1043     def add_buildflag(p, build):
1044         if not p.strip():
1045             warn_or_exception("Empty build flag at {1}"
1046                               .format(buildlines[0], linedesc))
1047         bv = p.split('=', 1)
1048         if len(bv) != 2:
1049             warn_or_exception("Invalid build flag at {0} in {1}"
1050                               .format(buildlines[0], linedesc))
1051
1052         pk, pv = bv
1053         pk = pk.lstrip()
1054         t = flagtype(pk)
1055         if t == TYPE_LIST:
1056             pv = split_list_values(pv)
1057             build.set_flag(pk, pv)
1058         elif t == TYPE_STRING or t == TYPE_SCRIPT:
1059             build.set_flag(pk, pv)
1060         elif t == TYPE_BOOL:
1061             build.set_flag(pk, _decode_bool(pv))
1062
1063     def parse_buildline(lines):
1064         v = "".join(lines)
1065         parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
1066         if len(parts) < 3:
1067             warn_or_exception("Invalid build format: " + v + " in " + mf.name)
1068         build = Build()
1069         build.version = parts[0]
1070         build.vercode = parts[1]
1071         check_versionCode(build.vercode)
1072
1073         if parts[2].startswith('!'):
1074             # For backwards compatibility, handle old-style disabling,
1075             # including attempting to extract the commit from the message
1076             build.disable = parts[2][1:]
1077             commit = 'unknown - see disabled'
1078             index = parts[2].rfind('at ')
1079             if index != -1:
1080                 commit = parts[2][index + 3:]
1081                 if commit.endswith(')'):
1082                     commit = commit[:-1]
1083             build.commit = commit
1084         else:
1085             build.commit = parts[2]
1086         for p in parts[3:]:
1087             add_buildflag(p, build)
1088
1089         return build
1090
1091     def check_versionCode(versionCode):
1092         try:
1093             int(versionCode)
1094         except ValueError:
1095             warn_or_exception('Invalid versionCode: "' + versionCode + '" is not an integer!')
1096
1097     def add_comments(key):
1098         if not curcomments:
1099             return
1100         app.comments[key] = list(curcomments)
1101         del curcomments[:]
1102
1103     mode = 0
1104     buildlines = []
1105     multiline_lines = []
1106     curcomments = []
1107     build = None
1108     vc_seen = set()
1109
1110     c = 0
1111     for line in mf:
1112         c += 1
1113         linedesc = "%s:%d" % (mf.name, c)
1114         line = line.rstrip('\r\n')
1115         if mode == 3:
1116             if build_cont.match(line):
1117                 if line.endswith('\\'):
1118                     buildlines.append(line[:-1].lstrip())
1119                 else:
1120                     buildlines.append(line.lstrip())
1121                     bl = ''.join(buildlines)
1122                     add_buildflag(bl, build)
1123                     del buildlines[:]
1124             else:
1125                 if not build.commit and not build.disable:
1126                     warn_or_exception("No commit specified for {0} in {1}"
1127                                       .format(build.version, linedesc))
1128
1129                 app.builds.append(build)
1130                 add_comments('build:' + build.vercode)
1131                 mode = 0
1132
1133         if mode == 0:
1134             if not line:
1135                 continue
1136             if line.startswith("#"):
1137                 curcomments.append(line[1:].strip())
1138                 continue
1139             try:
1140                 f, v = line.split(':', 1)
1141             except ValueError:
1142                 warn_or_exception("Invalid metadata in " + linedesc)
1143
1144             # Translate obsolete fields...
1145             if f == 'Market Version':
1146                 f = 'Current Version'
1147             if f == 'Market Version Code':
1148                 f = 'Current Version Code'
1149
1150             ftype = fieldtype(f)
1151             if ftype not in [TYPE_BUILD, TYPE_BUILD_V2]:
1152                 add_comments(f)
1153             if ftype == TYPE_MULTILINE:
1154                 mode = 1
1155                 if v:
1156                     warn_or_exception("Unexpected text on same line as "
1157                                       + f + " in " + linedesc)
1158             elif ftype == TYPE_STRING:
1159                 app.set_field(f, v)
1160             elif ftype == TYPE_LIST:
1161                 app.set_field(f, split_list_values(v))
1162             elif ftype == TYPE_BUILD:
1163                 if v.endswith("\\"):
1164                     mode = 2
1165                     del buildlines[:]
1166                     buildlines.append(v[:-1])
1167                 else:
1168                     build = parse_buildline([v])
1169                     app.builds.append(build)
1170                     add_comments('build:' + app.builds[-1].vercode)
1171             elif ftype == TYPE_BUILD_V2:
1172                 vv = v.split(',')
1173                 if len(vv) != 2:
1174                     warn_or_exception('Build should have comma-separated',
1175                                       'version and vercode,',
1176                                       'not "{0}", in {1}'.format(v, linedesc))
1177                 build = Build()
1178                 build.version = vv[0]
1179                 build.vercode = vv[1]
1180                 check_versionCode(build.vercode)
1181                 if build.vercode in vc_seen:
1182                     warn_or_exception('Duplicate build recipe found for vercode %s in %s'
1183                                       % (build.vercode, linedesc))
1184                 vc_seen.add(build.vercode)
1185                 del buildlines[:]
1186                 mode = 3
1187             elif ftype == TYPE_OBSOLETE:
1188                 pass        # Just throw it away!
1189             else:
1190                 warn_or_exception("Unrecognised field '" + f + "' in " + linedesc)
1191         elif mode == 1:     # Multiline field
1192             if line == '.':
1193                 mode = 0
1194                 app.set_field(f, '\n'.join(multiline_lines))
1195                 del multiline_lines[:]
1196             else:
1197                 multiline_lines.append(line)
1198         elif mode == 2:     # Line continuation mode in Build Version
1199             if line.endswith("\\"):
1200                 buildlines.append(line[:-1])
1201             else:
1202                 buildlines.append(line)
1203                 build = parse_buildline(buildlines)
1204                 app.builds.append(build)
1205                 add_comments('build:' + app.builds[-1].vercode)
1206                 mode = 0
1207     add_comments(None)
1208
1209     # Mode at end of file should always be 0
1210     if mode == 1:
1211         warn_or_exception(f + " not terminated in " + mf.name)
1212     if mode == 2:
1213         warn_or_exception("Unterminated continuation in " + mf.name)
1214     if mode == 3:
1215         warn_or_exception("Unterminated build in " + mf.name)
1216
1217     return app
1218
1219
1220 def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
1221
1222     def w_comments(key):
1223         if key not in app.comments:
1224             return
1225         for line in app.comments[key]:
1226             w_comment(line)
1227
1228     def w_field_always(f, v=None):
1229         if v is None:
1230             v = app.get_field(f)
1231         w_comments(f)
1232         w_field(f, v)
1233
1234     def w_field_nonempty(f, v=None):
1235         if v is None:
1236             v = app.get_field(f)
1237         w_comments(f)
1238         if v:
1239             w_field(f, v)
1240
1241     w_field_nonempty('Disabled')
1242     w_field_nonempty('AntiFeatures')
1243     w_field_nonempty('Provides')
1244     w_field_always('Categories')
1245     w_field_always('License')
1246     w_field_nonempty('Author Name')
1247     w_field_nonempty('Author Email')
1248     w_field_always('Web Site')
1249     w_field_always('Source Code')
1250     w_field_always('Issue Tracker')
1251     w_field_nonempty('Changelog')
1252     w_field_nonempty('Donate')
1253     w_field_nonempty('FlattrID')
1254     w_field_nonempty('Bitcoin')
1255     w_field_nonempty('Litecoin')
1256     mf.write('\n')
1257     w_field_nonempty('Name')
1258     w_field_nonempty('Auto Name')
1259     w_field_always('Summary')
1260     w_field_always('Description', description_txt(app.Description))
1261     mf.write('\n')
1262     if app.RequiresRoot:
1263         w_field_always('Requires Root', 'yes')
1264         mf.write('\n')
1265     if app.RepoType:
1266         w_field_always('Repo Type')
1267         w_field_always('Repo')
1268         if app.Binaries:
1269             w_field_always('Binaries')
1270         mf.write('\n')
1271
1272     for build in app.builds:
1273
1274         if build.version == "Ignore":
1275             continue
1276
1277         w_comments('build:' + build.vercode)
1278         w_build(build)
1279         mf.write('\n')
1280
1281     if app.MaintainerNotes:
1282         w_field_always('Maintainer Notes', app.MaintainerNotes)
1283         mf.write('\n')
1284
1285     w_field_nonempty('Archive Policy')
1286     w_field_always('Auto Update Mode')
1287     w_field_always('Update Check Mode')
1288     w_field_nonempty('Update Check Ignore')
1289     w_field_nonempty('Vercode Operation')
1290     w_field_nonempty('Update Check Name')
1291     w_field_nonempty('Update Check Data')
1292     if app.CurrentVersion:
1293         w_field_always('Current Version')
1294         w_field_always('Current Version Code')
1295     if app.NoSourceSince:
1296         mf.write('\n')
1297         w_field_always('No Source Since')
1298     w_comments(None)
1299
1300
1301 # Write a metadata file in txt format.
1302 #
1303 # 'mf'      - Writer interface (file, StringIO, ...)
1304 # 'app'     - The app data
1305 def write_txt(mf, app):
1306
1307     def w_comment(line):
1308         mf.write("# %s\n" % line)
1309
1310     def w_field(f, v):
1311         t = fieldtype(f)
1312         if t == TYPE_LIST:
1313             v = ','.join(v)
1314         elif t == TYPE_MULTILINE:
1315             v = '\n' + v + '\n.'
1316         mf.write("%s:%s\n" % (f, v))
1317
1318     def w_build(build):
1319         mf.write("Build:%s,%s\n" % (build.version, build.vercode))
1320
1321         for f in build_flags_order:
1322             v = build.get_flag(f)
1323             if not v:
1324                 continue
1325
1326             t = flagtype(f)
1327             mf.write('    %s=' % f)
1328             if t == TYPE_STRING:
1329                 mf.write(v)
1330             elif t == TYPE_BOOL:
1331                 mf.write('yes')
1332             elif t == TYPE_SCRIPT:
1333                 first = True
1334                 for s in v.split(' && '):
1335                     if first:
1336                         first = False
1337                     else:
1338                         mf.write(' && \\\n        ')
1339                     mf.write(s)
1340             elif t == TYPE_LIST:
1341                 mf.write(','.join(v))
1342
1343             mf.write('\n')
1344
1345     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1346
1347
1348 def write_yaml(mf, app):
1349
1350     def w_comment(line):
1351         mf.write("# %s\n" % line)
1352
1353     def escape(v):
1354         if not v:
1355             return ''
1356         if any(c in v for c in [': ', '%', '@', '*']):
1357             return "'" + v.replace("'", "''") + "'"
1358         return v
1359
1360     def w_field(f, v, prefix='', t=None):
1361         if t is None:
1362             t = fieldtype(f)
1363         v = ''
1364         if t == TYPE_LIST:
1365             v = '\n'
1366             for e in v:
1367                 v += prefix + ' - ' + escape(e) + '\n'
1368         elif t == TYPE_MULTILINE:
1369             v = ' |\n'
1370             for l in v.splitlines():
1371                 if l:
1372                     v += prefix + '  ' + l + '\n'
1373                 else:
1374                     v += '\n'
1375         elif t == TYPE_BOOL:
1376             v = ' yes\n'
1377         elif t == TYPE_SCRIPT:
1378             cmds = [s + '&& \\' for s in v.split('&& ')]
1379             if len(cmds) > 0:
1380                 cmds[-1] = cmds[-1][:-len('&& \\')]
1381             w_field(f, cmds, prefix, 'multiline')
1382             return
1383         else:
1384             v = ' ' + escape(v) + '\n'
1385
1386         mf.write(prefix)
1387         mf.write(f)
1388         mf.write(":")
1389         mf.write(v)
1390
1391     global first_build
1392     first_build = True
1393
1394     def w_build(build):
1395         global first_build
1396         if first_build:
1397             mf.write("builds:\n")
1398             first_build = False
1399
1400         w_field('versionName', build.version, '  - ', TYPE_STRING)
1401         w_field('versionCode', build.vercode, '    ', TYPE_STRING)
1402         for f in build_flags_order:
1403             v = build.get_flag(f)
1404             if not v:
1405                 continue
1406
1407             w_field(f, v, '    ', flagtype(f))
1408
1409     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1410
1411
1412 def write_metadata(metadatapath, app):
1413     _, ext = fdroidserver.common.get_extension(metadatapath)
1414     accepted = fdroidserver.common.config['accepted_formats']
1415     if ext not in accepted:
1416         warn_or_exception('Cannot write "%s", not an accepted format, use: %s'
1417                           % (metadatapath, ', '.join(accepted)))
1418
1419     with open(metadatapath, 'w', encoding='utf8') as mf:
1420         if ext == 'txt':
1421             return write_txt(mf, app)
1422         elif ext == 'yml':
1423             return write_yaml(mf, app)
1424     warn_or_exception('Unknown metadata format: %s' % metadatapath)
1425
1426
1427 def add_metadata_arguments(parser):
1428     '''add common command line flags related to metadata processing'''
1429     parser.add_argument("-W", default='error',
1430                         help="force errors to be warnings, or ignore")