chiark / gitweb /
Apply some autopep8-python2 suggestions
[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 srclibs = None
29
30
31 class MetaDataException(Exception):
32
33     def __init__(self, value):
34         self.value = value
35
36     def __str__(self):
37         return self.value
38
39 # In the order in which they are laid out on files
40 app_defaults = OrderedDict([
41     ('Disabled', None),
42     ('AntiFeatures', None),
43     ('Provides', None),
44     ('Categories', ['None']),
45     ('License', 'Unknown'),
46     ('Web Site', ''),
47     ('Source Code', ''),
48     ('Issue Tracker', ''),
49     ('Donate', None),
50     ('FlattrID', None),
51     ('Bitcoin', None),
52     ('Litecoin', None),
53     ('Dogecoin', None),
54     ('Name', None),
55     ('Auto Name', ''),
56     ('Summary', ''),
57     ('Description', []),
58     ('Requires Root', False),
59     ('Repo Type', ''),
60     ('Repo', ''),
61     ('Binaries', None),
62     ('Maintainer Notes', []),
63     ('Archive Policy', None),
64     ('Auto Update Mode', 'None'),
65     ('Update Check Mode', 'None'),
66     ('Update Check Ignore', None),
67     ('Vercode Operation', None),
68     ('Update Check Name', None),
69     ('Update Check Data', None),
70     ('Current Version', ''),
71     ('Current Version Code', '0'),
72     ('No Source Since', ''),
73 ])
74
75
76 # In the order in which they are laid out on files
77 # Sorted by their action and their place in the build timeline
78 flag_defaults = OrderedDict([
79     ('disable', False),
80     ('commit', None),
81     ('subdir', None),
82     ('submodules', False),
83     ('init', ''),
84     ('patch', []),
85     ('gradle', False),
86     ('maven', False),
87     ('kivy', False),
88     ('output', None),
89     ('srclibs', []),
90     ('oldsdkloc', False),
91     ('encoding', None),
92     ('forceversion', False),
93     ('forcevercode', False),
94     ('rm', []),
95     ('extlibs', []),
96     ('prebuild', ''),
97     ('update', ['auto']),
98     ('target', None),
99     ('scanignore', []),
100     ('scandelete', []),
101     ('build', ''),
102     ('buildjni', []),
103     ('preassemble', []),
104     ('antcommands', None),
105     ('novcheck', False),
106 ])
107
108
109 # Designates a metadata field type and checks that it matches
110 #
111 # 'name'     - The long name of the field type
112 # 'matching' - List of possible values or regex expression
113 # 'sep'      - Separator to use if value may be a list
114 # 'fields'   - Metadata fields (Field:Value) of this type
115 # 'attrs'    - Build attributes (attr=value) of this type
116 #
117 class FieldValidator():
118
119     def __init__(self, name, matching, sep, fields, attrs):
120         self.name = name
121         self.matching = matching
122         if type(matching) is str:
123             self.compiled = re.compile(matching)
124         self.sep = sep
125         self.fields = fields
126         self.attrs = attrs
127
128     def _assert_regex(self, values, appid):
129         for v in values:
130             if not self.compiled.match(v):
131                 raise MetaDataException("'%s' is not a valid %s in %s. "
132                                         % (v, self.name, appid) +
133                                         "Regex pattern: %s" % (self.matching))
134
135     def _assert_list(self, values, appid):
136         for v in values:
137             if v not in self.matching:
138                 raise MetaDataException("'%s' is not a valid %s in %s. "
139                                         % (v, self.name, appid) +
140                                         "Possible values: %s" % (", ".join(self.matching)))
141
142     def check(self, value, appid):
143         if type(value) is not str or not value:
144             return
145         if self.sep is not None:
146             values = value.split(self.sep)
147         else:
148             values = [value]
149         if type(self.matching) is list:
150             self._assert_list(values, appid)
151         else:
152             self._assert_regex(values, appid)
153
154
155 # Generic value types
156 valuetypes = {
157     FieldValidator("Integer",
158                    r'^[1-9][0-9]*$', None,
159                    [],
160                    ['vercode']),
161
162     FieldValidator("Hexadecimal",
163                    r'^[0-9a-f]+$', None,
164                    ['FlattrID'],
165                    []),
166
167     FieldValidator("HTTP link",
168                    r'^http[s]?://', None,
169                    ["Web Site", "Source Code", "Issue Tracker", "Donate"], []),
170
171     FieldValidator("Bitcoin address",
172                    r'^[a-zA-Z0-9]{27,34}$', None,
173                    ["Bitcoin"],
174                    []),
175
176     FieldValidator("Litecoin address",
177                    r'^L[a-zA-Z0-9]{33}$', None,
178                    ["Litecoin"],
179                    []),
180
181     FieldValidator("Dogecoin address",
182                    r'^D[a-zA-Z0-9]{33}$', None,
183                    ["Dogecoin"],
184                    []),
185
186     FieldValidator("Boolean",
187                    ['Yes', 'No'], None,
188                    ["Requires Root"],
189                    []),
190
191     FieldValidator("bool",
192                    ['yes', 'no'], None,
193                    [],
194                    ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
195                     'novcheck']),
196
197     FieldValidator("Repo Type",
198                    ['git', 'git-svn', 'svn', 'hg', 'bzr', 'srclib'], None,
199                    ["Repo Type"],
200                    []),
201
202     FieldValidator("Binaries",
203                    r'^http[s]?://', None,
204                    ["Binaries"],
205                    []),
206
207     FieldValidator("Archive Policy",
208                    r'^[0-9]+ versions$', None,
209                    ["Archive Policy"],
210                    []),
211
212     FieldValidator("Anti-Feature",
213                    ["Ads", "Tracking", "NonFreeNet", "NonFreeDep", "NonFreeAdd", "UpstreamNonFree"], ',',
214                    ["AntiFeatures"],
215                    []),
216
217     FieldValidator("Auto Update Mode",
218                    r"^(Version .+|None)$", None,
219                    ["Auto Update Mode"],
220                    []),
221
222     FieldValidator("Update Check Mode",
223                    r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$", None,
224                    ["Update Check Mode"],
225                    [])
226 }
227
228
229 # Check an app's metadata information for integrity errors
230 def check_metadata(info):
231     for v in valuetypes:
232         for field in v.fields:
233             v.check(info[field], info['id'])
234         for build in info['builds']:
235             for attr in v.attrs:
236                 v.check(build[attr], info['id'])
237
238
239 # Formatter for descriptions. Create an instance, and call parseline() with
240 # each line of the description source from the metadata. At the end, call
241 # end() and then text_plain, text_wiki and text_html will contain the result.
242 class DescriptionFormatter:
243     stNONE = 0
244     stPARA = 1
245     stUL = 2
246     stOL = 3
247     bold = False
248     ital = False
249     state = stNONE
250     text_plain = ''
251     text_wiki = ''
252     text_html = ''
253     linkResolver = None
254
255     def __init__(self, linkres):
256         self.linkResolver = linkres
257
258     def endcur(self, notstates=None):
259         if notstates and self.state in notstates:
260             return
261         if self.state == self.stPARA:
262             self.endpara()
263         elif self.state == self.stUL:
264             self.endul()
265         elif self.state == self.stOL:
266             self.endol()
267
268     def endpara(self):
269         self.text_plain += '\n'
270         self.text_html += '</p>'
271         self.state = self.stNONE
272
273     def endul(self):
274         self.text_html += '</ul>'
275         self.state = self.stNONE
276
277     def endol(self):
278         self.text_html += '</ol>'
279         self.state = self.stNONE
280
281     def formatted(self, txt, html):
282         formatted = ''
283         if html:
284             txt = cgi.escape(txt)
285         while True:
286             index = txt.find("''")
287             if index == -1:
288                 return formatted + txt
289             formatted += txt[:index]
290             txt = txt[index:]
291             if txt.startswith("'''"):
292                 if html:
293                     if self.bold:
294                         formatted += '</b>'
295                     else:
296                         formatted += '<b>'
297                 self.bold = not self.bold
298                 txt = txt[3:]
299             else:
300                 if html:
301                     if self.ital:
302                         formatted += '</i>'
303                     else:
304                         formatted += '<i>'
305                 self.ital = not self.ital
306                 txt = txt[2:]
307
308     def linkify(self, txt):
309         linkified_plain = ''
310         linkified_html = ''
311         while True:
312             index = txt.find("[")
313             if index == -1:
314                 return (linkified_plain + self.formatted(txt, False), linkified_html + self.formatted(txt, True))
315             linkified_plain += self.formatted(txt[:index], False)
316             linkified_html += self.formatted(txt[:index], True)
317             txt = txt[index:]
318             if txt.startswith("[["):
319                 index = txt.find("]]")
320                 if index == -1:
321                     raise MetaDataException("Unterminated ]]")
322                 url = txt[2:index]
323                 if self.linkResolver:
324                     url, urltext = self.linkResolver(url)
325                 else:
326                     urltext = url
327                 linkified_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
328                 linkified_plain += urltext
329                 txt = txt[index + 2:]
330             else:
331                 index = txt.find("]")
332                 if index == -1:
333                     raise MetaDataException("Unterminated ]")
334                 url = txt[1:index]
335                 index2 = url.find(' ')
336                 if index2 == -1:
337                     urltxt = url
338                 else:
339                     urltxt = url[index2 + 1:]
340                     url = url[:index2]
341                 linkified_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
342                 linkified_plain += urltxt
343                 if urltxt != url:
344                     linkified_plain += ' (' + url + ')'
345                 txt = txt[index + 1:]
346
347     def addtext(self, txt):
348         p, h = self.linkify(txt)
349         self.text_plain += p
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.text_plain += '* '
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.text_plain += '* '  # TODO: lazy - put the numbers in!
372             self.addtext(line[1:])
373             self.text_html += '</li>'
374         else:
375             self.endcur([self.stPARA])
376             if self.state == self.stNONE:
377                 self.text_html += '<p>'
378                 self.state = self.stPARA
379             elif self.state == self.stPARA:
380                 self.text_html += ' '
381                 self.text_plain += ' '
382             self.addtext(line)
383
384     def end(self):
385         self.endcur()
386
387
388 # Parse multiple lines of description as written in a metadata file, returning
389 # a single string in plain text format.
390 def description_plain(lines, linkres):
391     ps = DescriptionFormatter(linkres)
392     for line in lines:
393         ps.parseline(line)
394     ps.end()
395     return ps.text_plain
396
397
398 # Parse multiple lines of description as written in a metadata file, returning
399 # a single string in wiki format. Used for the Maintainer Notes field as well,
400 # because it's the same format.
401 def description_wiki(lines):
402     ps = DescriptionFormatter(None)
403     for line in lines:
404         ps.parseline(line)
405     ps.end()
406     return ps.text_wiki
407
408
409 # Parse multiple lines of description as written in a metadata file, returning
410 # a single string in HTML format.
411 def description_html(lines, linkres):
412     ps = DescriptionFormatter(linkres)
413     for line in lines:
414         ps.parseline(line)
415     ps.end()
416     return ps.text_html
417
418
419 def parse_srclib(metafile):
420
421     thisinfo = {}
422     if metafile and not isinstance(metafile, file):
423         metafile = open(metafile, "r")
424
425     # Defaults for fields that come from metadata
426     thisinfo['Repo Type'] = ''
427     thisinfo['Repo'] = ''
428     thisinfo['Subdir'] = None
429     thisinfo['Prepare'] = None
430     thisinfo['Srclibs'] = None
431
432     if metafile is None:
433         return thisinfo
434
435     n = 0
436     for line in metafile:
437         n += 1
438         line = line.rstrip('\r\n')
439         if not line or line.startswith("#"):
440             continue
441
442         try:
443             field, value = line.split(':', 1)
444         except ValueError:
445             raise MetaDataException("Invalid metadata in %s:%d" % (line, n))
446
447         if field == "Subdir":
448             thisinfo[field] = value.split(',')
449         else:
450             thisinfo[field] = value
451
452     return thisinfo
453
454
455 def read_srclibs():
456     """Read all srclib metadata.
457
458     The information read will be accessible as metadata.srclibs, which is a
459     dictionary, keyed on srclib name, with the values each being a dictionary
460     in the same format as that returned by the parse_srclib function.
461
462     A MetaDataException is raised if there are any problems with the srclib
463     metadata.
464     """
465     global srclibs
466
467     # They were already loaded
468     if srclibs is not None:
469         return
470
471     srclibs = {}
472
473     srcdir = 'srclibs'
474     if not os.path.exists(srcdir):
475         os.makedirs(srcdir)
476
477     for metafile in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
478         srclibname = os.path.basename(metafile[:-4])
479         srclibs[srclibname] = parse_srclib(metafile)
480
481
482 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
483 # returned by the parse_metadata function.
484 def read_metadata(xref=True):
485
486     # Always read the srclibs before the apps, since they can use a srlib as
487     # their source repository.
488     read_srclibs()
489
490     apps = {}
491
492     for basedir in ('metadata', 'tmp'):
493         if not os.path.exists(basedir):
494             os.makedirs(basedir)
495
496     for metafile in sorted(glob.glob(os.path.join('metadata', '*.txt'))):
497         appid, appinfo = parse_metadata(metafile)
498         check_metadata(appinfo)
499         apps[appid] = appinfo
500
501     if xref:
502         # Parse all descriptions at load time, just to ensure cross-referencing
503         # errors are caught early rather than when they hit the build server.
504         def linkres(appid):
505             if appid in apps:
506                 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
507             raise MetaDataException("Cannot resolve app id " + appid)
508
509         for appid, app in apps.iteritems():
510             try:
511                 description_html(app['Description'], linkres)
512             except MetaDataException, e:
513                 raise MetaDataException("Problem with description of " + appid +
514                                         " - " + str(e))
515
516     return apps
517
518
519 # Get the type expected for a given metadata field.
520 def metafieldtype(name):
521     if name in ['Description', 'Maintainer Notes']:
522         return 'multiline'
523     if name in ['Categories']:
524         return 'list'
525     if name == 'Build Version':
526         return 'build'
527     if name == 'Build':
528         return 'buildv2'
529     if name == 'Use Built':
530         return 'obsolete'
531     if name not in app_defaults:
532         return 'unknown'
533     return 'string'
534
535
536 def flagtype(name):
537     if name in ['extlibs', 'srclibs', 'patch', 'rm', 'buildjni', 'preassemble',
538                 'update', 'scanignore', 'scandelete', 'gradle', 'antcommands']:
539         return 'list'
540     if name in ['init', 'prebuild', 'build']:
541         return 'script'
542     if name in ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
543                 'novcheck']:
544         return 'bool'
545     return 'string'
546
547
548 def fill_build_defaults(build):
549
550     def get_build_type():
551         for t in ['maven', 'gradle', 'kivy']:
552             if build[t]:
553                 return t
554         if build['output']:
555             return 'raw'
556         return 'ant'
557
558     for flag, value in flag_defaults.iteritems():
559         if flag in build:
560             continue
561         build[flag] = value
562     build['type'] = get_build_type()
563
564
565 # Parse metadata for a single application.
566 #
567 #  'metafile' - the filename to read. The package id for the application comes
568 #               from this filename. Pass None to get a blank entry.
569 #
570 # Returns a dictionary containing all the details of the application. There are
571 # two major kinds of information in the dictionary. Keys beginning with capital
572 # letters correspond directory to identically named keys in the metadata file.
573 # Keys beginning with lower case letters are generated in one way or another,
574 # and are not found verbatim in the metadata.
575 #
576 # Known keys not originating from the metadata are:
577 #
578 #  'builds'           - a list of dictionaries containing build information
579 #                       for each defined build
580 #  'comments'         - a list of comments from the metadata file. Each is
581 #                       a tuple of the form (field, comment) where field is
582 #                       the name of the field it preceded in the metadata
583 #                       file. Where field is None, the comment goes at the
584 #                       end of the file. Alternatively, 'build:version' is
585 #                       for a comment before a particular build version.
586 #  'descriptionlines' - original lines of description as formatted in the
587 #                       metadata file.
588 #
589 def parse_metadata(metafile):
590
591     appid = None
592     linedesc = None
593
594     def add_buildflag(p, thisbuild):
595         bv = p.split('=', 1)
596         if len(bv) != 2:
597             raise MetaDataException("Invalid build flag at {0} in {1}"
598                                     .format(buildlines[0], linedesc))
599         pk, pv = bv
600         if pk in thisbuild:
601             raise MetaDataException("Duplicate definition on {0} in version {1} of {2}"
602                                     .format(pk, thisbuild['version'], linedesc))
603
604         pk = pk.lstrip()
605         if pk not in flag_defaults:
606             raise MetaDataException("Unrecognised build flag at {0} in {1}"
607                                     .format(p, linedesc))
608         t = flagtype(pk)
609         if t == 'list':
610             # Port legacy ';' separators
611             pv = [v.strip() for v in pv.replace(';', ',').split(',')]
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] = [v.strip() for v in value.replace(';', ',').split(',')]
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('Donate')
842     writefield_nonempty('FlattrID')
843     writefield_nonempty('Bitcoin')
844     writefield_nonempty('Litecoin')
845     writefield_nonempty('Dogecoin')
846     mf.write('\n')
847     writefield_nonempty('Name')
848     writefield_nonempty('Auto Name')
849     writefield('Summary')
850     writefield('Description', '')
851     for line in app['Description']:
852         mf.write("%s\n" % line)
853     mf.write('.\n')
854     mf.write('\n')
855     if app['Requires Root']:
856         writefield('Requires Root', 'Yes')
857         mf.write('\n')
858     if app['Repo Type']:
859         writefield('Repo Type')
860         writefield('Repo')
861         if app['Binaries']:
862             writefield('Binaries')
863         mf.write('\n')
864     for build in app['builds']:
865
866         if build['version'] == "Ignore":
867             continue
868
869         writecomments('build:' + build['vercode'])
870         mf.write("Build:%s,%s\n" % (build['version'], build['vercode']))
871
872         def write_builditem(key, value):
873
874             if key in ['version', 'vercode']:
875                 return
876
877             if value == flag_defaults[key]:
878                 return
879
880             t = flagtype(key)
881
882             logging.debug("...writing {0} : {1}".format(key, value))
883             outline = '    %s=' % key
884
885             if t == 'string':
886                 outline += value
887             if t == 'bool':
888                 outline += 'yes'
889             elif t == 'script':
890                 outline += '&& \\\n        '.join([s.lstrip() for s in value.split('&& ')])
891             elif t == 'list':
892                 outline += ','.join(value) if type(value) == list else value
893
894             outline += '\n'
895             mf.write(outline)
896
897         for flag in flag_defaults:
898             value = build[flag]
899             if value:
900                 write_builditem(flag, value)
901         mf.write('\n')
902
903     if app['Maintainer Notes']:
904         writefield('Maintainer Notes', '')
905         for line in app['Maintainer Notes']:
906             mf.write("%s\n" % line)
907         mf.write('.\n')
908         mf.write('\n')
909
910     writefield_nonempty('Archive Policy')
911     writefield('Auto Update Mode')
912     writefield('Update Check Mode')
913     writefield_nonempty('Update Check Ignore')
914     writefield_nonempty('Vercode Operation')
915     writefield_nonempty('Update Check Name')
916     writefield_nonempty('Update Check Data')
917     if app['Current Version']:
918         writefield('Current Version')
919         writefield('Current Version Code')
920     mf.write('\n')
921     if app['No Source Since']:
922         writefield('No Source Since')
923         mf.write('\n')
924     writecomments(None)
925     mf.close()