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