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