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