chiark / gitweb /
Don't leave an empty line at the end
[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'] = []
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         for comment in curcomments:
917             thisinfo['comments'].append([key, comment])
918         del curcomments[:]
919
920     appid, thisinfo = get_default_app_info_list(apps, metadatapath)
921     metafile = open(metadatapath, "r")
922
923     mode = 0
924     buildlines = []
925     curcomments = []
926     curbuild = None
927     vc_seen = {}
928
929     c = 0
930     for line in metafile:
931         c += 1
932         linedesc = "%s:%d" % (metafile.name, c)
933         line = line.rstrip('\r\n')
934         if mode == 3:
935             if not any(line.startswith(s) for s in (' ', '\t')):
936                 commit = curbuild['commit'] if 'commit' in curbuild else None
937                 if not commit and 'disable' not in curbuild:
938                     raise MetaDataException("No commit specified for {0} in {1}"
939                                             .format(curbuild['version'], linedesc))
940
941                 thisinfo['builds'].append(curbuild)
942                 add_comments('build:' + curbuild['vercode'])
943                 mode = 0
944             else:
945                 if line.endswith('\\'):
946                     buildlines.append(line[:-1].lstrip())
947                 else:
948                     buildlines.append(line.lstrip())
949                     bl = ''.join(buildlines)
950                     add_buildflag(bl, curbuild)
951                     buildlines = []
952
953         if mode == 0:
954             if not line:
955                 continue
956             if line.startswith("#"):
957                 curcomments.append(line)
958                 continue
959             try:
960                 field, value = line.split(':', 1)
961             except ValueError:
962                 raise MetaDataException("Invalid metadata in " + linedesc)
963             if field != field.strip() or value != value.strip():
964                 raise MetaDataException("Extra spacing found in " + linedesc)
965
966             # Translate obsolete fields...
967             if field == 'Market Version':
968                 field = 'Current Version'
969             if field == 'Market Version Code':
970                 field = 'Current Version Code'
971
972             fieldtype = metafieldtype(field)
973             if fieldtype not in ['build', 'buildv2']:
974                 add_comments(field)
975             if fieldtype == 'multiline':
976                 mode = 1
977                 thisinfo[field] = []
978                 if value:
979                     raise MetaDataException("Unexpected text on same line as " + field + " in " + linedesc)
980             elif fieldtype == 'string':
981                 thisinfo[field] = value
982             elif fieldtype == 'list':
983                 thisinfo[field] = split_list_values(value)
984             elif fieldtype == 'build':
985                 if value.endswith("\\"):
986                     mode = 2
987                     buildlines = [value[:-1]]
988                 else:
989                     curbuild = parse_buildline([value])
990                     thisinfo['builds'].append(curbuild)
991                     add_comments('build:' + thisinfo['builds'][-1]['vercode'])
992             elif fieldtype == 'buildv2':
993                 curbuild = {}
994                 vv = value.split(',')
995                 if len(vv) != 2:
996                     raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
997                                             .format(value, linedesc))
998                 curbuild['version'] = vv[0]
999                 curbuild['vercode'] = vv[1]
1000                 if curbuild['vercode'] in vc_seen:
1001                     raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
1002                                             curbuild['vercode'], linedesc))
1003                 vc_seen[curbuild['vercode']] = True
1004                 buildlines = []
1005                 mode = 3
1006             elif fieldtype == 'obsolete':
1007                 pass        # Just throw it away!
1008             else:
1009                 raise MetaDataException("Unrecognised field type for " + field + " in " + linedesc)
1010         elif mode == 1:     # Multiline field
1011             if line == '.':
1012                 mode = 0
1013             else:
1014                 thisinfo[field].append(line)
1015         elif mode == 2:     # Line continuation mode in Build Version
1016             if line.endswith("\\"):
1017                 buildlines.append(line[:-1])
1018             else:
1019                 buildlines.append(line)
1020                 curbuild = parse_buildline(buildlines)
1021                 thisinfo['builds'].append(curbuild)
1022                 add_comments('build:' + thisinfo['builds'][-1]['vercode'])
1023                 mode = 0
1024     add_comments(None)
1025
1026     # Mode at end of file should always be 0...
1027     if mode == 1:
1028         raise MetaDataException(field + " not terminated in " + metafile.name)
1029     elif mode == 2:
1030         raise MetaDataException("Unterminated continuation in " + metafile.name)
1031     elif mode == 3:
1032         raise MetaDataException("Unterminated build in " + metafile.name)
1033
1034     post_metadata_parse(thisinfo)
1035
1036     return (appid, thisinfo)
1037
1038
1039 def write_metadata(mf, app, w_comment, w_field, w_build):
1040
1041     def w_field_nonempty(field, value=None):
1042         if value is None:
1043             value = app[field]
1044         if value:
1045             w_field(field, value)
1046
1047     w_field_nonempty('Disabled')
1048     if app['AntiFeatures']:
1049         w_field('AntiFeatures')
1050     w_field_nonempty('Provides')
1051     w_field('Categories')
1052     w_field('License')
1053     w_field('Web Site')
1054     w_field('Source Code')
1055     w_field('Issue Tracker')
1056     w_field_nonempty('Changelog')
1057     w_field_nonempty('Donate')
1058     w_field_nonempty('FlattrID')
1059     w_field_nonempty('Bitcoin')
1060     w_field_nonempty('Litecoin')
1061     mf.write('\n')
1062     w_field_nonempty('Name')
1063     w_field_nonempty('Auto Name')
1064     w_field('Summary')
1065     w_field('Description', description_txt(app['Description']))
1066     mf.write('\n')
1067     if app['Requires Root']:
1068         w_field('Requires Root', 'yes')
1069         mf.write('\n')
1070     if app['Repo Type']:
1071         w_field('Repo Type')
1072         w_field('Repo')
1073         if app['Binaries']:
1074             w_field('Binaries')
1075         mf.write('\n')
1076
1077     for build in sorted_builds(app['builds']):
1078
1079         if build['version'] == "Ignore":
1080             continue
1081
1082         w_comment('build:' + build['vercode'])
1083         w_build(build)
1084         mf.write('\n')
1085
1086     if app['Maintainer Notes']:
1087         w_field('Maintainer Notes', app['Maintainer Notes'])
1088         mf.write('\n')
1089
1090     w_field_nonempty('Archive Policy')
1091     w_field('Auto Update Mode')
1092     w_field('Update Check Mode')
1093     w_field_nonempty('Update Check Ignore')
1094     w_field_nonempty('Vercode Operation')
1095     w_field_nonempty('Update Check Name')
1096     w_field_nonempty('Update Check Data')
1097     if app['Current Version']:
1098         w_field('Current Version')
1099         w_field('Current Version Code')
1100     if app['No Source Since']:
1101         mf.write('\n')
1102         w_field('No Source Since')
1103     w_comment(None)
1104
1105
1106 # Write a metadata file in txt format.
1107 #
1108 # 'mf'      - Writer interface (file, StringIO, ...)
1109 # 'app'     - The app data
1110 def write_txt_metadata(mf, app):
1111
1112     def w_comment(key):
1113         written = 0
1114         for pf, comment in app['comments']:
1115             if pf == key:
1116                 mf.write("%s\n" % comment)
1117                 written += 1
1118
1119     def w_field(field, value=None):
1120         w_comment(field)
1121         if value is None:
1122             value = app[field]
1123         t = metafieldtype(field)
1124         if t == 'list':
1125             value = ','.join(value)
1126         elif t == 'multiline':
1127             if type(value) == list:
1128                 value = '\n' + '\n'.join(value) + '\n.'
1129             else:
1130                 value = '\n' + value + '\n.'
1131         mf.write("%s:%s\n" % (field, value))
1132
1133     def w_build(build):
1134         mf.write("Build:%s,%s\n" % (build['version'], build['vercode']))
1135
1136         for key in flag_defaults:
1137             value = build[key]
1138             if not value:
1139                 continue
1140             if value == flag_defaults[key]:
1141                 continue
1142
1143             t = flagtype(key)
1144             v = '    %s=' % key
1145             if t == 'string':
1146                 v += value
1147             elif t == 'bool':
1148                 v += 'yes'
1149             elif t == 'script':
1150                 v += '&& \\\n        '.join([s.lstrip() for s in value.split('&& ')])
1151             elif t == 'list':
1152                 v += ','.join(value) if type(value) == list else value
1153
1154             mf.write(v)
1155             mf.write('\n')
1156
1157     write_metadata(mf, app, w_comment, w_field, w_build)
1158
1159
1160 def write_yaml_metadata(mf, app):
1161
1162     def w_comment(key):
1163         pass
1164
1165     def w_field(field, value=None, prefix='', t=None):
1166         w_comment(field)
1167         if value is None:
1168             value = app[field]
1169         if t is None:
1170             t = metafieldtype(field)
1171         v = ''
1172         if t == 'list':
1173             v = '\n'
1174             for e in value:
1175                 v += prefix + ' - ' + e + '\n'
1176         elif t == 'multiline':
1177             v = ' |\n'
1178             lines = []
1179             if type(value) == list:
1180                 lines = value
1181             else:
1182                 lines = value.splitlines()
1183             for l in lines:
1184                 if l:
1185                     v += prefix + '  ' + l + '\n'
1186                 else:
1187                     v += '\n'
1188         else:
1189             v = ' ' + value + '\n'
1190
1191         mf.write("%s%s:%s" % (prefix, field, v))
1192
1193     global first_build
1194     first_build = True
1195
1196     def w_build(build):
1197         global first_build
1198         if first_build:
1199             mf.write("builds:\n")
1200             first_build = False
1201
1202         w_field('versionName', build['version'], '  - ', 'string')
1203         w_field('versionCode', build['vercode'], '    ', 'strsng')
1204         for key in flag_defaults:
1205             value = build[key]
1206             if not value:
1207                 continue
1208             if value == flag_defaults[key]:
1209                 continue
1210
1211             w_field(key, value, '    ', flagtype(key))
1212
1213     write_metadata(mf, app, w_comment, w_field, w_build)