chiark / gitweb /
Ensure that builds are always sorted when writing
[fdroidserver.git] / fdroidserver / metadata.py
1 # -*- coding: utf-8 -*-
2 #
3 # metadata.py - part of the FDroid server tools
4 # Copyright (C) 2013, Ciaran Gultnieks, ciaran@ciarang.com
5 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU Affero General Public License for more details.
16 #
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 import json
21 import os
22 import re
23 import sys
24 import glob
25 import cgi
26 import logging
27
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=None):
583     if metadatapath is None:
584         appid = None
585     else:
586         appid = os.path.splitext(os.path.basename(metadatapath))[0]
587     if appid in apps:
588         logging.critical("'%s' is a duplicate! '%s' is already provided by '%s'"
589                          % (metadatapath, appid, apps[appid]['metadatapath']))
590         sys.exit(1)
591
592     thisinfo = {}
593     thisinfo.update(app_defaults)
594     thisinfo['metadatapath'] = metadatapath
595     if appid is not None:
596         thisinfo['id'] = appid
597
598     # General defaults...
599     thisinfo['builds'] = []
600     thisinfo['comments'] = []
601
602     return appid, thisinfo
603
604
605 def sorted_builds(builds):
606     return sorted(builds, key=lambda build: int(build['vercode']))
607
608
609 def post_metadata_parse(thisinfo):
610
611     supported_metadata = app_defaults.keys() + ['comments', 'builds', 'id', 'metadatapath']
612     for k, v in thisinfo.iteritems():
613         if k not in supported_metadata:
614             raise MetaDataException("Unrecognised metadata: {0}: {1}"
615                                     .format(k, v))
616         if type(v) in (float, int):
617             thisinfo[k] = str(v)
618
619     # convert to the odd internal format
620     for k in ('Description', 'Maintainer Notes'):
621         if isinstance(thisinfo[k], basestring):
622             text = thisinfo[k].rstrip().lstrip()
623             thisinfo[k] = text.split('\n')
624
625     supported_flags = (flag_defaults.keys()
626                        + ['vercode', 'version', 'versionCode', 'versionName'])
627     esc_newlines = re.compile('\\\\( |\\n)')
628
629     for build in thisinfo['builds']:
630         for k, v in build.items():
631             if k not in supported_flags:
632                 raise MetaDataException("Unrecognised build flag: {0}={1}"
633                                         .format(k, v))
634
635             if k == 'versionCode':
636                 build['vercode'] = str(v)
637                 del build['versionCode']
638             elif k == 'versionName':
639                 build['version'] = str(v)
640                 del build['versionName']
641             elif type(v) in (float, int):
642                 build[k] = str(v)
643             else:
644                 keyflagtype = flagtype(k)
645                 if keyflagtype == 'list':
646                     # these can be bools, strings or lists, but ultimately are lists
647                     if isinstance(v, basestring):
648                         build[k] = [v]
649                     elif isinstance(v, bool):
650                         if v:
651                             build[k] = ['yes']
652                         else:
653                             build[k] = ['no']
654                 elif keyflagtype == 'script':
655                     build[k] = re.sub(esc_newlines, '', v).lstrip().rstrip()
656                 elif keyflagtype == 'bool':
657                     # TODO handle this using <xsd:element type="xsd:boolean> in a schema
658                     if isinstance(v, basestring):
659                         if v == 'true':
660                             build[k] = True
661                         else:
662                             build[k] = False
663
664     if not thisinfo['Description']:
665         thisinfo['Description'].append('No description available')
666
667     for build in thisinfo['builds']:
668         fill_build_defaults(build)
669
670     thisinfo['builds'] = sorted_builds(thisinfo['builds'])
671
672
673 # Parse metadata for a single application.
674 #
675 #  'metadatapath' - the filename to read. The package id for the application comes
676 #               from this filename. Pass None to get a blank entry.
677 #
678 # Returns a dictionary containing all the details of the application. There are
679 # two major kinds of information in the dictionary. Keys beginning with capital
680 # letters correspond directory to identically named keys in the metadata file.
681 # Keys beginning with lower case letters are generated in one way or another,
682 # and are not found verbatim in the metadata.
683 #
684 # Known keys not originating from the metadata are:
685 #
686 #  'builds'           - a list of dictionaries containing build information
687 #                       for each defined build
688 #  'comments'         - a list of comments from the metadata file. Each is
689 #                       a list of the form [field, comment] where field is
690 #                       the name of the field it preceded in the metadata
691 #                       file. Where field is None, the comment goes at the
692 #                       end of the file. Alternatively, 'build:version' is
693 #                       for a comment before a particular build version.
694 #  'descriptionlines' - original lines of description as formatted in the
695 #                       metadata file.
696 #
697
698
699 def _decode_list(data):
700     '''convert items in a list from unicode to basestring'''
701     rv = []
702     for item in data:
703         if isinstance(item, unicode):
704             item = item.encode('utf-8')
705         elif isinstance(item, list):
706             item = _decode_list(item)
707         elif isinstance(item, dict):
708             item = _decode_dict(item)
709         rv.append(item)
710     return rv
711
712
713 def _decode_dict(data):
714     '''convert items in a dict from unicode to basestring'''
715     rv = {}
716     for key, value in data.iteritems():
717         if isinstance(key, unicode):
718             key = key.encode('utf-8')
719         if isinstance(value, unicode):
720             value = value.encode('utf-8')
721         elif isinstance(value, list):
722             value = _decode_list(value)
723         elif isinstance(value, dict):
724             value = _decode_dict(value)
725         rv[key] = value
726     return rv
727
728
729 def parse_metadata(apps, metadatapath):
730     root, ext = os.path.splitext(metadatapath)
731     metadataformat = ext[1:]
732     accepted = common.config['accepted_formats']
733     if metadataformat not in accepted:
734         logging.critical('"' + metadatapath
735                          + '" is not in an accepted format, '
736                          + 'convert to: ' + ', '.join(accepted))
737         sys.exit(1)
738
739     if metadataformat == 'txt':
740         return parse_txt_metadata(apps, metadatapath)
741     elif metadataformat == 'json':
742         return parse_json_metadata(apps, metadatapath)
743     elif metadataformat == 'xml':
744         return parse_xml_metadata(apps, metadatapath)
745     elif metadataformat == 'yaml':
746         return parse_yaml_metadata(apps, metadatapath)
747     else:
748         logging.critical('Unknown metadata format: ' + metadatapath)
749         sys.exit(1)
750
751
752 def parse_json_metadata(apps, metadatapath):
753
754     appid, thisinfo = get_default_app_info_list(apps, metadatapath)
755
756     # fdroid metadata is only strings and booleans, no floats or ints. And
757     # json returns unicode, and fdroidserver still uses plain python strings
758     # TODO create schema using https://pypi.python.org/pypi/jsonschema
759     jsoninfo = json.load(open(metadatapath, 'r'),
760                          object_hook=_decode_dict,
761                          parse_int=lambda s: s,
762                          parse_float=lambda s: s)
763     thisinfo.update(jsoninfo)
764     post_metadata_parse(thisinfo)
765
766     return (appid, thisinfo)
767
768
769 def parse_xml_metadata(apps, metadatapath):
770
771     appid, thisinfo = get_default_app_info_list(apps, metadatapath)
772
773     tree = ElementTree.ElementTree(file=metadatapath)
774     root = tree.getroot()
775
776     if root.tag != 'resources':
777         logging.critical(metadatapath + ' does not have root as <resources></resources>!')
778         sys.exit(1)
779
780     supported_metadata = app_defaults.keys()
781     for child in root:
782         if child.tag != 'builds':
783             # builds does not have name="" attrib
784             name = child.attrib['name']
785             if name not in supported_metadata:
786                 raise MetaDataException("Unrecognised metadata: <"
787                                         + child.tag + ' name="' + name + '">'
788                                         + child.text
789                                         + "</" + child.tag + '>')
790
791         if child.tag == 'string':
792             thisinfo[name] = child.text
793         elif child.tag == 'string-array':
794             items = []
795             for item in child:
796                 items.append(item.text)
797             thisinfo[name] = items
798         elif child.tag == 'builds':
799             builds = []
800             for build in child:
801                 builddict = dict()
802                 for key in build:
803                     builddict[key.tag] = key.text
804                 builds.append(builddict)
805             thisinfo['builds'] = builds
806
807     # TODO handle this using <xsd:element type="xsd:boolean> in a schema
808     if not isinstance(thisinfo['Requires Root'], bool):
809         if thisinfo['Requires Root'] == 'true':
810             thisinfo['Requires Root'] = True
811         else:
812             thisinfo['Requires Root'] = False
813
814     post_metadata_parse(thisinfo)
815
816     return (appid, thisinfo)
817
818
819 def parse_yaml_metadata(apps, metadatapath):
820
821     appid, thisinfo = get_default_app_info_list(apps, metadatapath)
822
823     yamlinfo = yaml.load(open(metadatapath, 'r'), Loader=YamlLoader)
824     thisinfo.update(yamlinfo)
825     post_metadata_parse(thisinfo)
826
827     return (appid, thisinfo)
828
829
830 def parse_txt_metadata(apps, metadatapath):
831
832     linedesc = None
833
834     def add_buildflag(p, thisbuild):
835         if not p.strip():
836             raise MetaDataException("Empty build flag at {1}"
837                                     .format(buildlines[0], linedesc))
838         bv = p.split('=', 1)
839         if len(bv) != 2:
840             raise MetaDataException("Invalid build flag at {0} in {1}"
841                                     .format(buildlines[0], linedesc))
842         pk, pv = bv
843         if pk in thisbuild:
844             raise MetaDataException("Duplicate definition on {0} in version {1} of {2}"
845                                     .format(pk, thisbuild['version'], linedesc))
846
847         pk = pk.lstrip()
848         if pk not in flag_defaults:
849             raise MetaDataException("Unrecognised build flag at {0} in {1}"
850                                     .format(p, linedesc))
851         t = flagtype(pk)
852         if t == 'list':
853             pv = split_list_values(pv)
854             if pk == 'gradle':
855                 if len(pv) == 1 and pv[0] in ['main', 'yes']:
856                     pv = ['yes']
857             thisbuild[pk] = pv
858         elif t == 'string' or t == 'script':
859             thisbuild[pk] = pv
860         elif t == 'bool':
861             value = pv == 'yes'
862             if value:
863                 thisbuild[pk] = True
864             else:
865                 logging.debug("...ignoring bool flag %s" % p)
866
867         else:
868             raise MetaDataException("Unrecognised build flag type '%s' at %s in %s"
869                                     % (t, p, linedesc))
870
871     def parse_buildline(lines):
872         value = "".join(lines)
873         parts = [p.replace("\\,", ",")
874                  for p in re.split(r"(?<!\\),", value)]
875         if len(parts) < 3:
876             raise MetaDataException("Invalid build format: " + value + " in " + metafile.name)
877         thisbuild = {}
878         thisbuild['origlines'] = lines
879         thisbuild['version'] = parts[0]
880         thisbuild['vercode'] = parts[1]
881         if parts[2].startswith('!'):
882             # For backwards compatibility, handle old-style disabling,
883             # including attempting to extract the commit from the message
884             thisbuild['disable'] = parts[2][1:]
885             commit = 'unknown - see disabled'
886             index = parts[2].rfind('at ')
887             if index != -1:
888                 commit = parts[2][index + 3:]
889                 if commit.endswith(')'):
890                     commit = commit[:-1]
891             thisbuild['commit'] = commit
892         else:
893             thisbuild['commit'] = parts[2]
894         for p in parts[3:]:
895             add_buildflag(p, thisbuild)
896
897         return thisbuild
898
899     def add_comments(key):
900         if not curcomments:
901             return
902         for comment in curcomments:
903             thisinfo['comments'].append([key, comment])
904         del curcomments[:]
905
906     appid, thisinfo = get_default_app_info_list(apps, metadatapath)
907     metafile = open(metadatapath, "r")
908
909     mode = 0
910     buildlines = []
911     curcomments = []
912     curbuild = None
913     vc_seen = {}
914
915     c = 0
916     for line in metafile:
917         c += 1
918         linedesc = "%s:%d" % (metafile.name, c)
919         line = line.rstrip('\r\n')
920         if mode == 3:
921             if not any(line.startswith(s) for s in (' ', '\t')):
922                 commit = curbuild['commit'] if 'commit' in curbuild else None
923                 if not commit and 'disable' not in curbuild:
924                     raise MetaDataException("No commit specified for {0} in {1}"
925                                             .format(curbuild['version'], linedesc))
926
927                 thisinfo['builds'].append(curbuild)
928                 add_comments('build:' + curbuild['vercode'])
929                 mode = 0
930             else:
931                 if line.endswith('\\'):
932                     buildlines.append(line[:-1].lstrip())
933                 else:
934                     buildlines.append(line.lstrip())
935                     bl = ''.join(buildlines)
936                     add_buildflag(bl, curbuild)
937                     buildlines = []
938
939         if mode == 0:
940             if not line:
941                 continue
942             if line.startswith("#"):
943                 curcomments.append(line)
944                 continue
945             try:
946                 field, value = line.split(':', 1)
947             except ValueError:
948                 raise MetaDataException("Invalid metadata in " + linedesc)
949             if field != field.strip() or value != value.strip():
950                 raise MetaDataException("Extra spacing found in " + linedesc)
951
952             # Translate obsolete fields...
953             if field == 'Market Version':
954                 field = 'Current Version'
955             if field == 'Market Version Code':
956                 field = 'Current Version Code'
957
958             fieldtype = metafieldtype(field)
959             if fieldtype not in ['build', 'buildv2']:
960                 add_comments(field)
961             if fieldtype == 'multiline':
962                 mode = 1
963                 thisinfo[field] = []
964                 if value:
965                     raise MetaDataException("Unexpected text on same line as " + field + " in " + linedesc)
966             elif fieldtype == 'string':
967                 thisinfo[field] = value
968             elif fieldtype == 'list':
969                 thisinfo[field] = split_list_values(value)
970             elif fieldtype == 'build':
971                 if value.endswith("\\"):
972                     mode = 2
973                     buildlines = [value[:-1]]
974                 else:
975                     curbuild = parse_buildline([value])
976                     thisinfo['builds'].append(curbuild)
977                     add_comments('build:' + thisinfo['builds'][-1]['vercode'])
978             elif fieldtype == 'buildv2':
979                 curbuild = {}
980                 vv = value.split(',')
981                 if len(vv) != 2:
982                     raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
983                                             .format(value, linedesc))
984                 curbuild['version'] = vv[0]
985                 curbuild['vercode'] = vv[1]
986                 if curbuild['vercode'] in vc_seen:
987                     raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
988                                             curbuild['vercode'], linedesc))
989                 vc_seen[curbuild['vercode']] = True
990                 buildlines = []
991                 mode = 3
992             elif fieldtype == 'obsolete':
993                 pass        # Just throw it away!
994             else:
995                 raise MetaDataException("Unrecognised field type for " + field + " in " + linedesc)
996         elif mode == 1:     # Multiline field
997             if line == '.':
998                 mode = 0
999             else:
1000                 thisinfo[field].append(line)
1001         elif mode == 2:     # Line continuation mode in Build Version
1002             if line.endswith("\\"):
1003                 buildlines.append(line[:-1])
1004             else:
1005                 buildlines.append(line)
1006                 curbuild = parse_buildline(buildlines)
1007                 thisinfo['builds'].append(curbuild)
1008                 add_comments('build:' + thisinfo['builds'][-1]['vercode'])
1009                 mode = 0
1010     add_comments(None)
1011
1012     # Mode at end of file should always be 0...
1013     if mode == 1:
1014         raise MetaDataException(field + " not terminated in " + metafile.name)
1015     elif mode == 2:
1016         raise MetaDataException("Unterminated continuation in " + metafile.name)
1017     elif mode == 3:
1018         raise MetaDataException("Unterminated build in " + metafile.name)
1019
1020     post_metadata_parse(thisinfo)
1021
1022     return (appid, thisinfo)
1023
1024
1025 # Write a metadata file.
1026 #
1027 # 'dest'    - The path to the output file
1028 # 'app'     - The app data
1029 def write_metadata(dest, app):
1030
1031     def writecomments(key):
1032         written = 0
1033         for pf, comment in app['comments']:
1034             if pf == key:
1035                 mf.write("%s\n" % comment)
1036                 written += 1
1037         if written > 0:
1038             logging.debug("...writing comments for " + (key or 'EOF'))
1039
1040     def writefield(field, value=None):
1041         writecomments(field)
1042         if value is None:
1043             value = app[field]
1044         t = metafieldtype(field)
1045         if t == 'list':
1046             value = ','.join(value)
1047         mf.write("%s:%s\n" % (field, value))
1048
1049     def writefield_nonempty(field, value=None):
1050         if value is None:
1051             value = app[field]
1052         if value:
1053             writefield(field, value)
1054
1055     mf = open(dest, 'w')
1056     writefield_nonempty('Disabled')
1057     if app['AntiFeatures']:
1058         writefield('AntiFeatures')
1059     writefield_nonempty('Provides')
1060     writefield('Categories')
1061     writefield('License')
1062     writefield('Web Site')
1063     writefield('Source Code')
1064     writefield('Issue Tracker')
1065     writefield_nonempty('Changelog')
1066     writefield_nonempty('Donate')
1067     writefield_nonempty('FlattrID')
1068     writefield_nonempty('Bitcoin')
1069     writefield_nonempty('Litecoin')
1070     writefield_nonempty('Dogecoin')
1071     mf.write('\n')
1072     writefield_nonempty('Name')
1073     writefield_nonempty('Auto Name')
1074     writefield('Summary')
1075     writefield('Description', '')
1076     for line in app['Description']:
1077         mf.write("%s\n" % line)
1078     mf.write('.\n')
1079     mf.write('\n')
1080     if app['Requires Root']:
1081         writefield('Requires Root', 'yes')
1082         mf.write('\n')
1083     if app['Repo Type']:
1084         writefield('Repo Type')
1085         writefield('Repo')
1086         if app['Binaries']:
1087             writefield('Binaries')
1088         mf.write('\n')
1089     for build in sorted_builds(app['builds']):
1090
1091         if build['version'] == "Ignore":
1092             continue
1093
1094         writecomments('build:' + build['vercode'])
1095         mf.write("Build:%s,%s\n" % (build['version'], build['vercode']))
1096
1097         def write_builditem(key, value):
1098
1099             if key in ['version', 'vercode']:
1100                 return
1101
1102             if value == flag_defaults[key]:
1103                 return
1104
1105             t = flagtype(key)
1106
1107             logging.debug("...writing {0} : {1}".format(key, value))
1108             outline = '    %s=' % key
1109
1110             if t == 'string':
1111                 outline += value
1112             elif t == 'bool':
1113                 outline += 'yes'
1114             elif t == 'script':
1115                 outline += '&& \\\n        '.join([s.lstrip() for s in value.split('&& ')])
1116             elif t == 'list':
1117                 outline += ','.join(value) if type(value) == list else value
1118
1119             outline += '\n'
1120             mf.write(outline)
1121
1122         for flag in flag_defaults:
1123             value = build[flag]
1124             if value:
1125                 write_builditem(flag, value)
1126         mf.write('\n')
1127
1128     if app['Maintainer Notes']:
1129         writefield('Maintainer Notes', '')
1130         for line in app['Maintainer Notes']:
1131             mf.write("%s\n" % line)
1132         mf.write('.\n')
1133         mf.write('\n')
1134
1135     writefield_nonempty('Archive Policy')
1136     writefield('Auto Update Mode')
1137     writefield('Update Check Mode')
1138     writefield_nonempty('Update Check Ignore')
1139     writefield_nonempty('Vercode Operation')
1140     writefield_nonempty('Update Check Name')
1141     writefield_nonempty('Update Check Data')
1142     if app['Current Version']:
1143         writefield('Current Version')
1144         writefield('Current Version Code')
1145     mf.write('\n')
1146     if app['No Source Since']:
1147         writefield('No Source Since')
1148         mf.write('\n')
1149     writecomments(None)
1150     mf.close()