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