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