chiark / gitweb /
More improvements to yaml writing
[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 = os.path.splitext(os.path.basename(metadatapath))[0]
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                         if v:
667                             build[k] = ['yes']
668                         else:
669                             build[k] = ['no']
670                 elif keyflagtype == 'script':
671                     build[k] = re.sub(esc_newlines, '', v).lstrip().rstrip()
672                 elif keyflagtype == 'bool':
673                     # TODO handle this using <xsd:element type="xsd:boolean> in a schema
674                     if isinstance(v, basestring):
675                         if v == 'true':
676                             build[k] = True
677                         else:
678                             build[k] = False
679
680     if not thisinfo['Description']:
681         thisinfo['Description'].append('No description available')
682
683     for build in thisinfo['builds']:
684         fill_build_defaults(build)
685
686     thisinfo['builds'] = sorted_builds(thisinfo['builds'])
687
688
689 # Parse metadata for a single application.
690 #
691 #  'metadatapath' - the filename to read. The package id for the application comes
692 #               from this filename. Pass None to get a blank entry.
693 #
694 # Returns a dictionary containing all the details of the application. There are
695 # two major kinds of information in the dictionary. Keys beginning with capital
696 # letters correspond directory to identically named keys in the metadata file.
697 # Keys beginning with lower case letters are generated in one way or another,
698 # and are not found verbatim in the metadata.
699 #
700 # Known keys not originating from the metadata are:
701 #
702 #  'builds'           - a list of dictionaries containing build information
703 #                       for each defined build
704 #  'comments'         - a list of comments from the metadata file. Each is
705 #                       a list of the form [field, comment] where field is
706 #                       the name of the field it preceded in the metadata
707 #                       file. Where field is None, the comment goes at the
708 #                       end of the file. Alternatively, 'build:version' is
709 #                       for a comment before a particular build version.
710 #  'descriptionlines' - original lines of description as formatted in the
711 #                       metadata file.
712 #
713
714
715 def _decode_list(data):
716     '''convert items in a list from unicode to basestring'''
717     rv = []
718     for item in data:
719         if isinstance(item, unicode):
720             item = item.encode('utf-8')
721         elif isinstance(item, list):
722             item = _decode_list(item)
723         elif isinstance(item, dict):
724             item = _decode_dict(item)
725         rv.append(item)
726     return rv
727
728
729 def _decode_dict(data):
730     '''convert items in a dict from unicode to basestring'''
731     rv = {}
732     for key, value in data.iteritems():
733         if isinstance(key, unicode):
734             key = key.encode('utf-8')
735         if isinstance(value, unicode):
736             value = value.encode('utf-8')
737         elif isinstance(value, list):
738             value = _decode_list(value)
739         elif isinstance(value, dict):
740             value = _decode_dict(value)
741         rv[key] = value
742     return rv
743
744
745 def parse_metadata(apps, metadatapath):
746     root, ext = os.path.splitext(metadatapath)
747     metadataformat = ext[1:]
748     accepted = common.config['accepted_formats']
749     if metadataformat not in accepted:
750         logging.critical('"' + metadatapath
751                          + '" is not in an accepted format, '
752                          + 'convert to: ' + ', '.join(accepted))
753         sys.exit(1)
754
755     if metadataformat == 'txt':
756         return parse_txt_metadata(apps, metadatapath)
757     if metadataformat == 'json':
758         return parse_json_metadata(apps, metadatapath)
759     if metadataformat == 'xml':
760         return parse_xml_metadata(apps, metadatapath)
761     if metadataformat == 'yaml':
762         return parse_yaml_metadata(apps, metadatapath)
763
764     logging.critical('Unknown metadata format: ' + metadatapath)
765     sys.exit(1)
766
767
768 def parse_json_metadata(apps, metadatapath):
769
770     appid, thisinfo = get_default_app_info_list(apps, metadatapath)
771
772     # fdroid metadata is only strings and booleans, no floats or ints. And
773     # json returns unicode, and fdroidserver still uses plain python strings
774     # TODO create schema using https://pypi.python.org/pypi/jsonschema
775     jsoninfo = json.load(open(metadatapath, 'r'),
776                          object_hook=_decode_dict,
777                          parse_int=lambda s: s,
778                          parse_float=lambda s: s)
779     thisinfo.update(jsoninfo)
780     post_metadata_parse(thisinfo)
781
782     return (appid, thisinfo)
783
784
785 def parse_xml_metadata(apps, metadatapath):
786
787     appid, thisinfo = get_default_app_info_list(apps, metadatapath)
788
789     tree = ElementTree.ElementTree(file=metadatapath)
790     root = tree.getroot()
791
792     if root.tag != 'resources':
793         logging.critical(metadatapath + ' does not have root as <resources></resources>!')
794         sys.exit(1)
795
796     supported_metadata = app_defaults.keys()
797     for child in root:
798         if child.tag != 'builds':
799             # builds does not have name="" attrib
800             name = child.attrib['name']
801             if name not in supported_metadata:
802                 raise MetaDataException("Unrecognised metadata: <"
803                                         + child.tag + ' name="' + name + '">'
804                                         + child.text
805                                         + "</" + child.tag + '>')
806
807         if child.tag == 'string':
808             thisinfo[name] = child.text
809         elif child.tag == 'string-array':
810             items = []
811             for item in child:
812                 items.append(item.text)
813             thisinfo[name] = items
814         elif child.tag == 'builds':
815             builds = []
816             for build in child:
817                 builddict = dict()
818                 for key in build:
819                     builddict[key.tag] = key.text
820                 builds.append(builddict)
821             thisinfo['builds'] = builds
822
823     # TODO handle this using <xsd:element type="xsd:boolean> in a schema
824     if not isinstance(thisinfo['Requires Root'], bool):
825         if thisinfo['Requires Root'] == 'true':
826             thisinfo['Requires Root'] = True
827         else:
828             thisinfo['Requires Root'] = False
829
830     post_metadata_parse(thisinfo)
831
832     return (appid, thisinfo)
833
834
835 def parse_yaml_metadata(apps, metadatapath):
836
837     appid, thisinfo = get_default_app_info_list(apps, metadatapath)
838
839     yamlinfo = yaml.load(open(metadatapath, 'r'), Loader=YamlLoader)
840     thisinfo.update(yamlinfo)
841     post_metadata_parse(thisinfo)
842
843     return (appid, thisinfo)
844
845
846 def parse_txt_metadata(apps, metadatapath):
847
848     linedesc = None
849
850     def add_buildflag(p, thisbuild):
851         if not p.strip():
852             raise MetaDataException("Empty build flag at {1}"
853                                     .format(buildlines[0], linedesc))
854         bv = p.split('=', 1)
855         if len(bv) != 2:
856             raise MetaDataException("Invalid build flag at {0} in {1}"
857                                     .format(buildlines[0], linedesc))
858         pk, pv = bv
859         if pk in thisbuild:
860             raise MetaDataException("Duplicate definition on {0} in version {1} of {2}"
861                                     .format(pk, thisbuild['version'], linedesc))
862
863         pk = pk.lstrip()
864         if pk not in flag_defaults:
865             raise MetaDataException("Unrecognised build flag at {0} in {1}"
866                                     .format(p, linedesc))
867         t = flagtype(pk)
868         if t == 'list':
869             pv = split_list_values(pv)
870             if pk == 'gradle':
871                 if len(pv) == 1 and pv[0] in ['main', 'yes']:
872                     pv = ['yes']
873             thisbuild[pk] = pv
874         elif t == 'string' or t == 'script':
875             thisbuild[pk] = pv
876         elif t == 'bool':
877             value = pv == 'yes'
878             if value:
879                 thisbuild[pk] = True
880
881         else:
882             raise MetaDataException("Unrecognised build flag type '%s' at %s in %s"
883                                     % (t, p, linedesc))
884
885     def parse_buildline(lines):
886         value = "".join(lines)
887         parts = [p.replace("\\,", ",")
888                  for p in re.split(r"(?<!\\),", value)]
889         if len(parts) < 3:
890             raise MetaDataException("Invalid build format: " + value + " in " + metafile.name)
891         thisbuild = {}
892         thisbuild['origlines'] = lines
893         thisbuild['version'] = parts[0]
894         thisbuild['vercode'] = parts[1]
895         if parts[2].startswith('!'):
896             # For backwards compatibility, handle old-style disabling,
897             # including attempting to extract the commit from the message
898             thisbuild['disable'] = parts[2][1:]
899             commit = 'unknown - see disabled'
900             index = parts[2].rfind('at ')
901             if index != -1:
902                 commit = parts[2][index + 3:]
903                 if commit.endswith(')'):
904                     commit = commit[:-1]
905             thisbuild['commit'] = commit
906         else:
907             thisbuild['commit'] = parts[2]
908         for p in parts[3:]:
909             add_buildflag(p, thisbuild)
910
911         return thisbuild
912
913     def add_comments(key):
914         if not curcomments:
915             return
916         thisinfo['comments'][key] = list(curcomments)
917         del curcomments[:]
918
919     appid, thisinfo = get_default_app_info_list(apps, metadatapath)
920     metafile = open(metadatapath, "r")
921
922     mode = 0
923     buildlines = []
924     curcomments = []
925     curbuild = None
926     vc_seen = {}
927
928     c = 0
929     for line in metafile:
930         c += 1
931         linedesc = "%s:%d" % (metafile.name, c)
932         line = line.rstrip('\r\n')
933         if mode == 3:
934             if not any(line.startswith(s) for s in (' ', '\t')):
935                 commit = curbuild['commit'] if 'commit' in curbuild else None
936                 if not commit and 'disable' not in curbuild:
937                     raise MetaDataException("No commit specified for {0} in {1}"
938                                             .format(curbuild['version'], linedesc))
939
940                 thisinfo['builds'].append(curbuild)
941                 add_comments('build:' + curbuild['vercode'])
942                 mode = 0
943             else:
944                 if line.endswith('\\'):
945                     buildlines.append(line[:-1].lstrip())
946                 else:
947                     buildlines.append(line.lstrip())
948                     bl = ''.join(buildlines)
949                     add_buildflag(bl, curbuild)
950                     buildlines = []
951
952         if mode == 0:
953             if not line:
954                 continue
955             if line.startswith("#"):
956                 curcomments.append(line[1:].strip())
957                 continue
958             try:
959                 field, value = line.split(':', 1)
960             except ValueError:
961                 raise MetaDataException("Invalid metadata in " + linedesc)
962             if field != field.strip() or value != value.strip():
963                 raise MetaDataException("Extra spacing found in " + linedesc)
964
965             # Translate obsolete fields...
966             if field == 'Market Version':
967                 field = 'Current Version'
968             if field == 'Market Version Code':
969                 field = 'Current Version Code'
970
971             fieldtype = metafieldtype(field)
972             if fieldtype not in ['build', 'buildv2']:
973                 add_comments(field)
974             if fieldtype == 'multiline':
975                 mode = 1
976                 thisinfo[field] = []
977                 if value:
978                     raise MetaDataException("Unexpected text on same line as " + field + " in " + linedesc)
979             elif fieldtype == 'string':
980                 thisinfo[field] = value
981             elif fieldtype == 'list':
982                 thisinfo[field] = split_list_values(value)
983             elif fieldtype == 'build':
984                 if value.endswith("\\"):
985                     mode = 2
986                     buildlines = [value[:-1]]
987                 else:
988                     curbuild = parse_buildline([value])
989                     thisinfo['builds'].append(curbuild)
990                     add_comments('build:' + thisinfo['builds'][-1]['vercode'])
991             elif fieldtype == 'buildv2':
992                 curbuild = {}
993                 vv = value.split(',')
994                 if len(vv) != 2:
995                     raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
996                                             .format(value, linedesc))
997                 curbuild['version'] = vv[0]
998                 curbuild['vercode'] = vv[1]
999                 if curbuild['vercode'] in vc_seen:
1000                     raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
1001                                             curbuild['vercode'], linedesc))
1002                 vc_seen[curbuild['vercode']] = True
1003                 buildlines = []
1004                 mode = 3
1005             elif fieldtype == 'obsolete':
1006                 pass        # Just throw it away!
1007             else:
1008                 raise MetaDataException("Unrecognised field type for " + field + " in " + linedesc)
1009         elif mode == 1:     # Multiline field
1010             if line == '.':
1011                 mode = 0
1012             else:
1013                 thisinfo[field].append(line)
1014         elif mode == 2:     # Line continuation mode in Build Version
1015             if line.endswith("\\"):
1016                 buildlines.append(line[:-1])
1017             else:
1018                 buildlines.append(line)
1019                 curbuild = parse_buildline(buildlines)
1020                 thisinfo['builds'].append(curbuild)
1021                 add_comments('build:' + thisinfo['builds'][-1]['vercode'])
1022                 mode = 0
1023     add_comments(None)
1024
1025     # Mode at end of file should always be 0...
1026     if mode == 1:
1027         raise MetaDataException(field + " not terminated in " + metafile.name)
1028     elif mode == 2:
1029         raise MetaDataException("Unterminated continuation in " + metafile.name)
1030     elif mode == 3:
1031         raise MetaDataException("Unterminated build in " + metafile.name)
1032
1033     post_metadata_parse(thisinfo)
1034
1035     return (appid, thisinfo)
1036
1037
1038 def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
1039
1040     def w_comments(key):
1041         if key not in app['comments']:
1042             return
1043         for line in app['comments'][key]:
1044             w_comment(line)
1045
1046     def w_field_always(field, value=None):
1047         if value is None:
1048             value = app[field]
1049         w_comments(field)
1050         w_field(field, value)
1051
1052     def w_field_nonempty(field, value=None):
1053         if value is None:
1054             value = app[field]
1055         w_comments(field)
1056         if value:
1057             w_field(field, value)
1058
1059     w_field_nonempty('Disabled')
1060     if app['AntiFeatures']:
1061         w_field_always('AntiFeatures')
1062     w_field_nonempty('Provides')
1063     w_field_always('Categories')
1064     w_field_always('License')
1065     w_field_always('Web Site')
1066     w_field_always('Source Code')
1067     w_field_always('Issue Tracker')
1068     w_field_nonempty('Changelog')
1069     w_field_nonempty('Donate')
1070     w_field_nonempty('FlattrID')
1071     w_field_nonempty('Bitcoin')
1072     w_field_nonempty('Litecoin')
1073     mf.write('\n')
1074     w_field_nonempty('Name')
1075     w_field_nonempty('Auto Name')
1076     w_field_always('Summary')
1077     w_field_always('Description', description_txt(app['Description']))
1078     mf.write('\n')
1079     if app['Requires Root']:
1080         w_field_always('Requires Root', 'yes')
1081         mf.write('\n')
1082     if app['Repo Type']:
1083         w_field_always('Repo Type')
1084         w_field_always('Repo')
1085         if app['Binaries']:
1086             w_field_always('Binaries')
1087         mf.write('\n')
1088
1089     for build in sorted_builds(app['builds']):
1090
1091         if build['version'] == "Ignore":
1092             continue
1093
1094         w_comments('build:' + build['vercode'])
1095         w_build(build)
1096         mf.write('\n')
1097
1098     if app['Maintainer Notes']:
1099         w_field_always('Maintainer Notes', app['Maintainer Notes'])
1100         mf.write('\n')
1101
1102     w_field_nonempty('Archive Policy')
1103     w_field_always('Auto Update Mode')
1104     w_field_always('Update Check Mode')
1105     w_field_nonempty('Update Check Ignore')
1106     w_field_nonempty('Vercode Operation')
1107     w_field_nonempty('Update Check Name')
1108     w_field_nonempty('Update Check Data')
1109     if app['Current Version']:
1110         w_field_always('Current Version')
1111         w_field_always('Current Version Code')
1112     if app['No Source Since']:
1113         mf.write('\n')
1114         w_field_always('No Source Since')
1115     w_comments(None)
1116
1117
1118 # Write a metadata file in txt format.
1119 #
1120 # 'mf'      - Writer interface (file, StringIO, ...)
1121 # 'app'     - The app data
1122 def write_txt_metadata(mf, app):
1123
1124     def w_comment(line):
1125         mf.write("# %s\n" % line)
1126
1127     def w_field(field, value):
1128         t = metafieldtype(field)
1129         if t == 'list':
1130             value = ','.join(value)
1131         elif t == 'multiline':
1132             if type(value) == list:
1133                 value = '\n' + '\n'.join(value) + '\n.'
1134             else:
1135                 value = '\n' + value + '\n.'
1136         mf.write("%s:%s\n" % (field, value))
1137
1138     def w_build(build):
1139         mf.write("Build:%s,%s\n" % (build['version'], build['vercode']))
1140
1141         for key in flag_defaults:
1142             value = build[key]
1143             if not value:
1144                 continue
1145             if value == flag_defaults[key]:
1146                 continue
1147
1148             t = flagtype(key)
1149             v = '    %s=' % key
1150             if t == 'string':
1151                 v += value
1152             elif t == 'bool':
1153                 v += 'yes'
1154             elif t == 'script':
1155                 v += '&& \\\n        '.join([s.lstrip() for s in value.split('&& ')])
1156             elif t == 'list':
1157                 v += ','.join(value) if type(value) == list else value
1158
1159             mf.write(v)
1160             mf.write('\n')
1161
1162     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1163
1164
1165 def write_yaml_metadata(mf, app):
1166
1167     def w_comment(line):
1168         mf.write("# %s\n" % line)
1169
1170     def w_field(field, value, prefix='', t=None):
1171         if t is None:
1172             t = metafieldtype(field)
1173         v = ''
1174         if t == 'list':
1175             v = '\n'
1176             for e in value:
1177                 v += prefix + ' - ' + e + '\n'
1178         elif t == 'multiline':
1179             v = ' |\n'
1180             lines = []
1181             if type(value) == list:
1182                 lines = value
1183             else:
1184                 lines = value.splitlines()
1185             for l in lines:
1186                 if l:
1187                     v += prefix + '  ' + l + '\n'
1188                 else:
1189                     v += '\n'
1190         elif t == 'bool':
1191             v = ' yes\n'
1192         elif t == 'script':
1193             cmds = [s + '&& \\' for s in value.split('&& ')]
1194             if len(cmds) > 0:
1195                 cmds[-1] = cmds[-1][:-len('&& \\')]
1196             w_field(field, cmds, prefix, 'multiline')
1197             return
1198         else:
1199             v = ' ' + value + '\n'
1200
1201         mf.write("%s%s:%s" % (prefix, field, v))
1202
1203     global first_build
1204     first_build = True
1205
1206     def w_build(build):
1207         global first_build
1208         if first_build:
1209             mf.write("builds:\n")
1210             first_build = False
1211
1212         w_field('versionName', build['version'], '  - ', 'string')
1213         w_field('versionCode', build['vercode'], '    ', 'strsng')
1214         for key in flag_defaults:
1215             value = build[key]
1216             if not value:
1217                 continue
1218             if value == flag_defaults[key]:
1219                 continue
1220
1221             w_field(key, value, '    ', flagtype(key))
1222
1223     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1224
1225
1226 def write_metadata(fmt, mf, app):
1227     if fmt == 'txt':
1228         return write_txt_metadata(mf, app)
1229     if fmt == 'yaml':
1230         return write_yaml_metadata(mf, app)
1231     raise MetaDataException("Unknown metadata format given")