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