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