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