chiark / gitweb /
Rename write_metadata to specify txt
[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             else:
881                 logging.debug("...ignoring bool flag %s" % p)
882
883         else:
884             raise MetaDataException("Unrecognised build flag type '%s' at %s in %s"
885                                     % (t, p, linedesc))
886
887     def parse_buildline(lines):
888         value = "".join(lines)
889         parts = [p.replace("\\,", ",")
890                  for p in re.split(r"(?<!\\),", value)]
891         if len(parts) < 3:
892             raise MetaDataException("Invalid build format: " + value + " in " + metafile.name)
893         thisbuild = {}
894         thisbuild['origlines'] = lines
895         thisbuild['version'] = parts[0]
896         thisbuild['vercode'] = parts[1]
897         if parts[2].startswith('!'):
898             # For backwards compatibility, handle old-style disabling,
899             # including attempting to extract the commit from the message
900             thisbuild['disable'] = parts[2][1:]
901             commit = 'unknown - see disabled'
902             index = parts[2].rfind('at ')
903             if index != -1:
904                 commit = parts[2][index + 3:]
905                 if commit.endswith(')'):
906                     commit = commit[:-1]
907             thisbuild['commit'] = commit
908         else:
909             thisbuild['commit'] = parts[2]
910         for p in parts[3:]:
911             add_buildflag(p, thisbuild)
912
913         return thisbuild
914
915     def add_comments(key):
916         if not curcomments:
917             return
918         for comment in curcomments:
919             thisinfo['comments'].append([key, comment])
920         del curcomments[:]
921
922     appid, thisinfo = get_default_app_info_list(apps, metadatapath)
923     metafile = open(metadatapath, "r")
924
925     mode = 0
926     buildlines = []
927     curcomments = []
928     curbuild = None
929     vc_seen = {}
930
931     c = 0
932     for line in metafile:
933         c += 1
934         linedesc = "%s:%d" % (metafile.name, c)
935         line = line.rstrip('\r\n')
936         if mode == 3:
937             if not any(line.startswith(s) for s in (' ', '\t')):
938                 commit = curbuild['commit'] if 'commit' in curbuild else None
939                 if not commit and 'disable' not in curbuild:
940                     raise MetaDataException("No commit specified for {0} in {1}"
941                                             .format(curbuild['version'], linedesc))
942
943                 thisinfo['builds'].append(curbuild)
944                 add_comments('build:' + curbuild['vercode'])
945                 mode = 0
946             else:
947                 if line.endswith('\\'):
948                     buildlines.append(line[:-1].lstrip())
949                 else:
950                     buildlines.append(line.lstrip())
951                     bl = ''.join(buildlines)
952                     add_buildflag(bl, curbuild)
953                     buildlines = []
954
955         if mode == 0:
956             if not line:
957                 continue
958             if line.startswith("#"):
959                 curcomments.append(line)
960                 continue
961             try:
962                 field, value = line.split(':', 1)
963             except ValueError:
964                 raise MetaDataException("Invalid metadata in " + linedesc)
965             if field != field.strip() or value != value.strip():
966                 raise MetaDataException("Extra spacing found in " + linedesc)
967
968             # Translate obsolete fields...
969             if field == 'Market Version':
970                 field = 'Current Version'
971             if field == 'Market Version Code':
972                 field = 'Current Version Code'
973
974             fieldtype = metafieldtype(field)
975             if fieldtype not in ['build', 'buildv2']:
976                 add_comments(field)
977             if fieldtype == 'multiline':
978                 mode = 1
979                 thisinfo[field] = []
980                 if value:
981                     raise MetaDataException("Unexpected text on same line as " + field + " in " + linedesc)
982             elif fieldtype == 'string':
983                 thisinfo[field] = value
984             elif fieldtype == 'list':
985                 thisinfo[field] = split_list_values(value)
986             elif fieldtype == 'build':
987                 if value.endswith("\\"):
988                     mode = 2
989                     buildlines = [value[:-1]]
990                 else:
991                     curbuild = parse_buildline([value])
992                     thisinfo['builds'].append(curbuild)
993                     add_comments('build:' + thisinfo['builds'][-1]['vercode'])
994             elif fieldtype == 'buildv2':
995                 curbuild = {}
996                 vv = value.split(',')
997                 if len(vv) != 2:
998                     raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
999                                             .format(value, linedesc))
1000                 curbuild['version'] = vv[0]
1001                 curbuild['vercode'] = vv[1]
1002                 if curbuild['vercode'] in vc_seen:
1003                     raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
1004                                             curbuild['vercode'], linedesc))
1005                 vc_seen[curbuild['vercode']] = True
1006                 buildlines = []
1007                 mode = 3
1008             elif fieldtype == 'obsolete':
1009                 pass        # Just throw it away!
1010             else:
1011                 raise MetaDataException("Unrecognised field type for " + field + " in " + linedesc)
1012         elif mode == 1:     # Multiline field
1013             if line == '.':
1014                 mode = 0
1015             else:
1016                 thisinfo[field].append(line)
1017         elif mode == 2:     # Line continuation mode in Build Version
1018             if line.endswith("\\"):
1019                 buildlines.append(line[:-1])
1020             else:
1021                 buildlines.append(line)
1022                 curbuild = parse_buildline(buildlines)
1023                 thisinfo['builds'].append(curbuild)
1024                 add_comments('build:' + thisinfo['builds'][-1]['vercode'])
1025                 mode = 0
1026     add_comments(None)
1027
1028     # Mode at end of file should always be 0...
1029     if mode == 1:
1030         raise MetaDataException(field + " not terminated in " + metafile.name)
1031     elif mode == 2:
1032         raise MetaDataException("Unterminated continuation in " + metafile.name)
1033     elif mode == 3:
1034         raise MetaDataException("Unterminated build in " + metafile.name)
1035
1036     post_metadata_parse(thisinfo)
1037
1038     return (appid, thisinfo)
1039
1040
1041 # Write a metadata file in txt format.
1042 #
1043 # 'mf'      - Writer interface (file, StringIO, ...)
1044 # 'app'     - The app data
1045 def write_txt_metadata(mf, app):
1046
1047     def writecomments(key):
1048         written = 0
1049         for pf, comment in app['comments']:
1050             if pf == key:
1051                 mf.write("%s\n" % comment)
1052                 written += 1
1053         if written > 0:
1054             logging.debug("...writing comments for " + (key or 'EOF'))
1055
1056     def writefield(field, value=None):
1057         writecomments(field)
1058         if value is None:
1059             value = app[field]
1060         t = metafieldtype(field)
1061         if t == 'list':
1062             value = ','.join(value)
1063         elif t == 'multiline':
1064             if type(value) == list:
1065                 value = '\n' + '\n'.join(value) + '\n.'
1066             else:
1067                 value = '\n' + value + '\n.'
1068         mf.write("%s:%s\n" % (field, value))
1069
1070     def writefield_nonempty(field, value=None):
1071         if value is None:
1072             value = app[field]
1073         if value:
1074             writefield(field, value)
1075
1076     writefield_nonempty('Disabled')
1077     if app['AntiFeatures']:
1078         writefield('AntiFeatures')
1079     writefield_nonempty('Provides')
1080     writefield('Categories')
1081     writefield('License')
1082     writefield('Web Site')
1083     writefield('Source Code')
1084     writefield('Issue Tracker')
1085     writefield_nonempty('Changelog')
1086     writefield_nonempty('Donate')
1087     writefield_nonempty('FlattrID')
1088     writefield_nonempty('Bitcoin')
1089     writefield_nonempty('Litecoin')
1090     mf.write('\n')
1091     writefield_nonempty('Name')
1092     writefield_nonempty('Auto Name')
1093     writefield('Summary')
1094     writefield('Description', description_txt(app['Description']))
1095     mf.write('\n')
1096     if app['Requires Root']:
1097         writefield('Requires Root', 'yes')
1098         mf.write('\n')
1099     if app['Repo Type']:
1100         writefield('Repo Type')
1101         writefield('Repo')
1102         if app['Binaries']:
1103             writefield('Binaries')
1104         mf.write('\n')
1105     for build in sorted_builds(app['builds']):
1106
1107         if build['version'] == "Ignore":
1108             continue
1109
1110         writecomments('build:' + build['vercode'])
1111         mf.write("Build:%s,%s\n" % (build['version'], build['vercode']))
1112
1113         def write_builditem(key, value):
1114
1115             if key in ['version', 'vercode']:
1116                 return
1117
1118             if value == flag_defaults[key]:
1119                 return
1120
1121             t = flagtype(key)
1122
1123             logging.debug("...writing {0} : {1}".format(key, value))
1124             outline = '    %s=' % key
1125
1126             if t == 'string':
1127                 outline += value
1128             elif t == 'bool':
1129                 outline += 'yes'
1130             elif t == 'script':
1131                 outline += '&& \\\n        '.join([s.lstrip() for s in value.split('&& ')])
1132             elif t == 'list':
1133                 outline += ','.join(value) if type(value) == list else value
1134
1135             outline += '\n'
1136             mf.write(outline)
1137
1138         for flag in flag_defaults:
1139             value = build[flag]
1140             if value:
1141                 write_builditem(flag, value)
1142         mf.write('\n')
1143
1144     if app['Maintainer Notes']:
1145         writefield('Maintainer Notes', app['Maintainer Notes'])
1146         mf.write('\n')
1147
1148     writefield_nonempty('Archive Policy')
1149     writefield('Auto Update Mode')
1150     writefield('Update Check Mode')
1151     writefield_nonempty('Update Check Ignore')
1152     writefield_nonempty('Vercode Operation')
1153     writefield_nonempty('Update Check Name')
1154     writefield_nonempty('Update Check Data')
1155     if app['Current Version']:
1156         writefield('Current Version')
1157         writefield('Current Version Code')
1158     mf.write('\n')
1159     if app['No Source Since']:
1160         writefield('No Source Since')
1161         mf.write('\n')
1162     writecomments(None)