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