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