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