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