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