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