chiark / gitweb /
Add "Changelog:" metadata field.
[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     thisinfo['Srclibs'] = None
419
420     if metafile is None:
421         return thisinfo
422
423     n = 0
424     for line in metafile:
425         n += 1
426         line = line.rstrip('\r\n')
427         if not line or line.startswith("#"):
428             continue
429
430         try:
431             field, value = line.split(':', 1)
432         except ValueError:
433             raise MetaDataException("Invalid metadata in %s:%d" % (line, n))
434
435         if field == "Subdir":
436             thisinfo[field] = value.split(',')
437         else:
438             thisinfo[field] = value
439
440     return thisinfo
441
442
443 def read_srclibs():
444     """Read all srclib metadata.
445
446     The information read will be accessible as metadata.srclibs, which is a
447     dictionary, keyed on srclib name, with the values each being a dictionary
448     in the same format as that returned by the parse_srclib function.
449
450     A MetaDataException is raised if there are any problems with the srclib
451     metadata.
452     """
453     global srclibs
454
455     # They were already loaded
456     if srclibs is not None:
457         return
458
459     srclibs = {}
460
461     srcdir = 'srclibs'
462     if not os.path.exists(srcdir):
463         os.makedirs(srcdir)
464
465     for metafile in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
466         srclibname = os.path.basename(metafile[:-4])
467         srclibs[srclibname] = parse_srclib(metafile)
468
469
470 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
471 # returned by the parse_metadata function.
472 def read_metadata(xref=True):
473
474     # Always read the srclibs before the apps, since they can use a srlib as
475     # their source repository.
476     read_srclibs()
477
478     apps = {}
479
480     for basedir in ('metadata', 'tmp'):
481         if not os.path.exists(basedir):
482             os.makedirs(basedir)
483
484     for metafile in sorted(glob.glob(os.path.join('metadata', '*.txt'))):
485         appid, appinfo = parse_metadata(metafile)
486         check_metadata(appinfo)
487         apps[appid] = appinfo
488
489     if xref:
490         # Parse all descriptions at load time, just to ensure cross-referencing
491         # errors are caught early rather than when they hit the build server.
492         def linkres(appid):
493             if appid in apps:
494                 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
495             raise MetaDataException("Cannot resolve app id " + appid)
496
497         for appid, app in apps.iteritems():
498             try:
499                 description_html(app['Description'], linkres)
500             except MetaDataException, e:
501                 raise MetaDataException("Problem with description of " + appid +
502                                         " - " + str(e))
503
504     return apps
505
506
507 # Get the type expected for a given metadata field.
508 def metafieldtype(name):
509     if name in ['Description', 'Maintainer Notes']:
510         return 'multiline'
511     if name in ['Categories']:
512         return 'list'
513     if name == 'Build Version':
514         return 'build'
515     if name == 'Build':
516         return 'buildv2'
517     if name == 'Use Built':
518         return 'obsolete'
519     if name not in app_defaults:
520         return 'unknown'
521     return 'string'
522
523
524 def flagtype(name):
525     if name in ['extlibs', 'srclibs', 'patch', 'rm', 'buildjni', 'preassemble',
526                 'update', 'scanignore', 'scandelete', 'gradle', 'antcommands']:
527         return 'list'
528     if name in ['init', 'prebuild', 'build']:
529         return 'script'
530     if name in ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
531                 'novcheck']:
532         return 'bool'
533     return 'string'
534
535
536 def fill_build_defaults(build):
537
538     def get_build_type():
539         for t in ['maven', 'gradle', 'kivy']:
540             if build[t]:
541                 return t
542         if build['output']:
543             return 'raw'
544         return 'ant'
545
546     for flag, value in flag_defaults.iteritems():
547         if flag in build:
548             continue
549         build[flag] = value
550     build['type'] = get_build_type()
551     build['ndk_path'] = common.get_ndk_path(build['ndk'])
552
553
554 # Parse metadata for a single application.
555 #
556 #  'metafile' - the filename to read. The package id for the application comes
557 #               from this filename. Pass None to get a blank entry.
558 #
559 # Returns a dictionary containing all the details of the application. There are
560 # two major kinds of information in the dictionary. Keys beginning with capital
561 # letters correspond directory to identically named keys in the metadata file.
562 # Keys beginning with lower case letters are generated in one way or another,
563 # and are not found verbatim in the metadata.
564 #
565 # Known keys not originating from the metadata are:
566 #
567 #  'builds'           - a list of dictionaries containing build information
568 #                       for each defined build
569 #  'comments'         - a list of comments from the metadata file. Each is
570 #                       a tuple of the form (field, comment) where field is
571 #                       the name of the field it preceded in the metadata
572 #                       file. Where field is None, the comment goes at the
573 #                       end of the file. Alternatively, 'build:version' is
574 #                       for a comment before a particular build version.
575 #  'descriptionlines' - original lines of description as formatted in the
576 #                       metadata file.
577 #
578 def parse_metadata(metafile):
579
580     appid = None
581     linedesc = None
582
583     def add_buildflag(p, thisbuild):
584         bv = p.split('=', 1)
585         if len(bv) != 2:
586             raise MetaDataException("Invalid build flag at {0} in {1}"
587                                     .format(buildlines[0], linedesc))
588         pk, pv = bv
589         if pk in thisbuild:
590             raise MetaDataException("Duplicate definition on {0} in version {1} of {2}"
591                                     .format(pk, thisbuild['version'], linedesc))
592
593         pk = pk.lstrip()
594         if pk not in flag_defaults:
595             raise MetaDataException("Unrecognised build flag at {0} in {1}"
596                                     .format(p, linedesc))
597         t = flagtype(pk)
598         if t == 'list':
599             # Port legacy ';' separators
600             pv = [v.strip() for v in pv.replace(';', ',').split(',')]
601             if pk == 'gradle':
602                 if len(pv) == 1 and pv[0] in ['main', 'yes']:
603                     pv = ['yes']
604             thisbuild[pk] = pv
605         elif t == 'string' or t == 'script':
606             thisbuild[pk] = pv
607         elif t == 'bool':
608             value = pv == 'yes'
609             if value:
610                 thisbuild[pk] = True
611             else:
612                 logging.debug("...ignoring bool flag %s" % p)
613
614         else:
615             raise MetaDataException("Unrecognised build flag type '%s' at %s in %s"
616                                     % (t, p, linedesc))
617
618     def parse_buildline(lines):
619         value = "".join(lines)
620         parts = [p.replace("\\,", ",")
621                  for p in re.split(r"(?<!\\),", value)]
622         if len(parts) < 3:
623             raise MetaDataException("Invalid build format: " + value + " in " + metafile.name)
624         thisbuild = {}
625         thisbuild['origlines'] = lines
626         thisbuild['version'] = parts[0]
627         thisbuild['vercode'] = parts[1]
628         if parts[2].startswith('!'):
629             # For backwards compatibility, handle old-style disabling,
630             # including attempting to extract the commit from the message
631             thisbuild['disable'] = parts[2][1:]
632             commit = 'unknown - see disabled'
633             index = parts[2].rfind('at ')
634             if index != -1:
635                 commit = parts[2][index + 3:]
636                 if commit.endswith(')'):
637                     commit = commit[:-1]
638             thisbuild['commit'] = commit
639         else:
640             thisbuild['commit'] = parts[2]
641         for p in parts[3:]:
642             add_buildflag(p, thisbuild)
643
644         return thisbuild
645
646     def add_comments(key):
647         if not curcomments:
648             return
649         for comment in curcomments:
650             thisinfo['comments'].append((key, comment))
651         del curcomments[:]
652
653     thisinfo = {}
654     if metafile:
655         if not isinstance(metafile, file):
656             metafile = open(metafile, "r")
657         appid = metafile.name[9:-4]
658
659     thisinfo.update(app_defaults)
660     thisinfo['id'] = appid
661
662     # General defaults...
663     thisinfo['builds'] = []
664     thisinfo['comments'] = []
665
666     if metafile is None:
667         return appid, thisinfo
668
669     mode = 0
670     buildlines = []
671     curcomments = []
672     curbuild = None
673     vc_seen = {}
674
675     c = 0
676     for line in metafile:
677         c += 1
678         linedesc = "%s:%d" % (metafile.name, c)
679         line = line.rstrip('\r\n')
680         if mode == 3:
681             if not any(line.startswith(s) for s in (' ', '\t')):
682                 commit = curbuild['commit'] if 'commit' in curbuild else None
683                 if not commit and 'disable' not in curbuild:
684                     raise MetaDataException("No commit specified for {0} in {1}"
685                                             .format(curbuild['version'], linedesc))
686
687                 thisinfo['builds'].append(curbuild)
688                 add_comments('build:' + curbuild['vercode'])
689                 mode = 0
690             else:
691                 if line.endswith('\\'):
692                     buildlines.append(line[:-1].lstrip())
693                 else:
694                     buildlines.append(line.lstrip())
695                     bl = ''.join(buildlines)
696                     add_buildflag(bl, curbuild)
697                     buildlines = []
698
699         if mode == 0:
700             if not line:
701                 continue
702             if line.startswith("#"):
703                 curcomments.append(line)
704                 continue
705             try:
706                 field, value = line.split(':', 1)
707             except ValueError:
708                 raise MetaDataException("Invalid metadata in " + linedesc)
709             if field != field.strip() or value != value.strip():
710                 raise MetaDataException("Extra spacing found in " + linedesc)
711
712             # Translate obsolete fields...
713             if field == 'Market Version':
714                 field = 'Current Version'
715             if field == 'Market Version Code':
716                 field = 'Current Version Code'
717
718             fieldtype = metafieldtype(field)
719             if fieldtype not in ['build', 'buildv2']:
720                 add_comments(field)
721             if fieldtype == 'multiline':
722                 mode = 1
723                 thisinfo[field] = []
724                 if value:
725                     raise MetaDataException("Unexpected text on same line as " + field + " in " + linedesc)
726             elif fieldtype == 'string':
727                 thisinfo[field] = value
728             elif fieldtype == 'list':
729                 thisinfo[field] = [v.strip() for v in value.replace(';', ',').split(',')]
730             elif fieldtype == 'build':
731                 if value.endswith("\\"):
732                     mode = 2
733                     buildlines = [value[:-1]]
734                 else:
735                     curbuild = parse_buildline([value])
736                     thisinfo['builds'].append(curbuild)
737                     add_comments('build:' + thisinfo['builds'][-1]['vercode'])
738             elif fieldtype == 'buildv2':
739                 curbuild = {}
740                 vv = value.split(',')
741                 if len(vv) != 2:
742                     raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
743                                             .format(value, linedesc))
744                 curbuild['version'] = vv[0]
745                 curbuild['vercode'] = vv[1]
746                 if curbuild['vercode'] in vc_seen:
747                     raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
748                                             curbuild['vercode'], linedesc))
749                 vc_seen[curbuild['vercode']] = True
750                 buildlines = []
751                 mode = 3
752             elif fieldtype == 'obsolete':
753                 pass        # Just throw it away!
754             else:
755                 raise MetaDataException("Unrecognised field type for " + field + " in " + linedesc)
756         elif mode == 1:     # Multiline field
757             if line == '.':
758                 mode = 0
759             else:
760                 thisinfo[field].append(line)
761         elif mode == 2:     # Line continuation mode in Build Version
762             if line.endswith("\\"):
763                 buildlines.append(line[:-1])
764             else:
765                 buildlines.append(line)
766                 curbuild = parse_buildline(buildlines)
767                 thisinfo['builds'].append(curbuild)
768                 add_comments('build:' + thisinfo['builds'][-1]['vercode'])
769                 mode = 0
770     add_comments(None)
771
772     # Mode at end of file should always be 0...
773     if mode == 1:
774         raise MetaDataException(field + " not terminated in " + metafile.name)
775     elif mode == 2:
776         raise MetaDataException("Unterminated continuation in " + metafile.name)
777     elif mode == 3:
778         raise MetaDataException("Unterminated build in " + metafile.name)
779
780     if not thisinfo['Description']:
781         thisinfo['Description'].append('No description available')
782
783     for build in thisinfo['builds']:
784         fill_build_defaults(build)
785
786     thisinfo['builds'] = sorted(thisinfo['builds'], key=lambda build: int(build['vercode']))
787
788     return (appid, thisinfo)
789
790
791 # Write a metadata file.
792 #
793 # 'dest'    - The path to the output file
794 # 'app'     - The app data
795 def write_metadata(dest, app):
796
797     def writecomments(key):
798         written = 0
799         for pf, comment in app['comments']:
800             if pf == key:
801                 mf.write("%s\n" % comment)
802                 written += 1
803         if written > 0:
804             logging.debug("...writing comments for " + (key or 'EOF'))
805
806     def writefield(field, value=None):
807         writecomments(field)
808         if value is None:
809             value = app[field]
810         t = metafieldtype(field)
811         if t == 'list':
812             value = ','.join(value)
813         mf.write("%s:%s\n" % (field, value))
814
815     def writefield_nonempty(field, value=None):
816         if value is None:
817             value = app[field]
818         if value:
819             writefield(field, value)
820
821     mf = open(dest, 'w')
822     writefield_nonempty('Disabled')
823     writefield_nonempty('AntiFeatures')
824     writefield_nonempty('Provides')
825     writefield('Categories')
826     writefield('License')
827     writefield('Web Site')
828     writefield('Source Code')
829     writefield('Issue Tracker')
830     writefield('Changelog')
831     writefield_nonempty('Donate')
832     writefield_nonempty('FlattrID')
833     writefield_nonempty('Bitcoin')
834     writefield_nonempty('Litecoin')
835     writefield_nonempty('Dogecoin')
836     mf.write('\n')
837     writefield_nonempty('Name')
838     writefield_nonempty('Auto Name')
839     writefield('Summary')
840     writefield('Description', '')
841     for line in app['Description']:
842         mf.write("%s\n" % line)
843     mf.write('.\n')
844     mf.write('\n')
845     if app['Requires Root']:
846         writefield('Requires Root', 'Yes')
847         mf.write('\n')
848     if app['Repo Type']:
849         writefield('Repo Type')
850         writefield('Repo')
851         if app['Binaries']:
852             writefield('Binaries')
853         mf.write('\n')
854     for build in app['builds']:
855
856         if build['version'] == "Ignore":
857             continue
858
859         writecomments('build:' + build['vercode'])
860         mf.write("Build:%s,%s\n" % (build['version'], build['vercode']))
861
862         def write_builditem(key, value):
863
864             if key in ['version', 'vercode']:
865                 return
866
867             if value == flag_defaults[key]:
868                 return
869
870             t = flagtype(key)
871
872             logging.debug("...writing {0} : {1}".format(key, value))
873             outline = '    %s=' % key
874
875             if t == 'string':
876                 outline += value
877             if t == 'bool':
878                 outline += 'yes'
879             elif t == 'script':
880                 outline += '&& \\\n        '.join([s.lstrip() for s in value.split('&& ')])
881             elif t == 'list':
882                 outline += ','.join(value) if type(value) == list else value
883
884             outline += '\n'
885             mf.write(outline)
886
887         for flag in flag_defaults:
888             value = build[flag]
889             if value:
890                 write_builditem(flag, value)
891         mf.write('\n')
892
893     if app['Maintainer Notes']:
894         writefield('Maintainer Notes', '')
895         for line in app['Maintainer Notes']:
896             mf.write("%s\n" % line)
897         mf.write('.\n')
898         mf.write('\n')
899
900     writefield_nonempty('Archive Policy')
901     writefield('Auto Update Mode')
902     writefield('Update Check Mode')
903     writefield_nonempty('Update Check Ignore')
904     writefield_nonempty('Vercode Operation')
905     writefield_nonempty('Update Check Name')
906     writefield_nonempty('Update Check Data')
907     if app['Current Version']:
908         writefield('Current Version')
909         writefield('Current Version Code')
910     mf.write('\n')
911     if app['No Source Since']:
912         writefield('No Source Since')
913         mf.write('\n')
914     writecomments(None)
915     mf.close()