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