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