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