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