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