chiark / gitweb /
Allow gpg home directory to be overridden
[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     thisinfo['Srclibs'] = 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_nonempty('Donate')
835     writefield_nonempty('FlattrID')
836     writefield_nonempty('Bitcoin')
837     writefield_nonempty('Litecoin')
838     writefield_nonempty('Dogecoin')
839     mf.write('\n')
840     writefield_nonempty('Name')
841     writefield_nonempty('Auto Name')
842     writefield('Summary')
843     writefield('Description', '')
844     for line in app['Description']:
845         mf.write("%s\n" % line)
846     mf.write('.\n')
847     mf.write('\n')
848     if app['Requires Root']:
849         writefield('Requires Root', 'Yes')
850         mf.write('\n')
851     if app['Repo Type']:
852         writefield('Repo Type')
853         writefield('Repo')
854         if app['Binaries']:
855             writefield('Binaries')
856         mf.write('\n')
857     for build in app['builds']:
858
859         if build['version'] == "Ignore":
860             continue
861
862         writecomments('build:' + build['vercode'])
863         mf.write("Build:%s,%s\n" % (build['version'], build['vercode']))
864
865         def write_builditem(key, value):
866
867             if key in ['version', 'vercode']:
868                 return
869
870             if value == flag_defaults[key]:
871                 return
872
873             t = flagtype(key)
874
875             logging.debug("...writing {0} : {1}".format(key, value))
876             outline = '    %s=' % key
877
878             if t == 'string':
879                 outline += value
880             if t == 'bool':
881                 outline += 'yes'
882             elif t == 'script':
883                 outline += '&& \\\n        '.join([s.lstrip() for s in value.split('&& ')])
884             elif t == 'list':
885                 outline += ','.join(value) if type(value) == list else value
886
887             outline += '\n'
888             mf.write(outline)
889
890         for flag in flag_defaults:
891             value = build[flag]
892             if value:
893                 write_builditem(flag, value)
894         mf.write('\n')
895
896     if app['Maintainer Notes']:
897         writefield('Maintainer Notes', '')
898         for line in app['Maintainer Notes']:
899             mf.write("%s\n" % line)
900         mf.write('.\n')
901         mf.write('\n')
902
903     writefield_nonempty('Archive Policy')
904     writefield('Auto Update Mode')
905     writefield('Update Check Mode')
906     writefield_nonempty('Update Check Ignore')
907     writefield_nonempty('Vercode Operation')
908     writefield_nonempty('Update Check Name')
909     writefield_nonempty('Update Check Data')
910     if app['Current Version']:
911         writefield('Current Version')
912         writefield('Current Version Code')
913     mf.write('\n')
914     if app['No Source Since']:
915         writefield('No Source Since')
916         mf.write('\n')
917     writecomments(None)
918     mf.close()