chiark / gitweb /
Open metadata files in only one place
[fdroidserver.git] / fdroidserver / metadata.py
index 0f833c570d45c392d47b2d9c6ab063a407abc68b..163ebf01b1653427b8be1c974f1353333de5a5e7 100644 (file)
 import json
 import os
 import re
-import sys
 import glob
 import cgi
-import logging
 import textwrap
 
+try:
+    from cStringIO import StringIO
+except:
+    from StringIO import StringIO
+
 import yaml
 # use libyaml if it is available
 try:
@@ -111,12 +114,12 @@ class App():
         self.Name = None
         self.AutoName = ''
         self.Summary = ''
-        self.Description = []
+        self.Description = ''
         self.RequiresRoot = False
         self.RepoType = ''
         self.Repo = ''
         self.Binaries = None
-        self.MaintainerNotes = []
+        self.MaintainerNotes = ''
         self.ArchivePolicy = None
         self.AutoUpdateMode = 'None'
         self.UpdateCheckMode = 'None'
@@ -134,6 +137,7 @@ class App():
         self.comments = {}
         self.added = None
         self.lastupdated = None
+        self._modified = set()
 
     # Translates human-readable field names to attribute names, e.g.
     # 'Auto Name' to 'AutoName'
@@ -158,10 +162,11 @@ class App():
             if k == 'builds':
                 d['builds'] = []
                 for build in v:
-                    d['builds'].append(build.__dict__)
-            else:
-                k = App.attr_to_field(k)
-                d[k] = v
+                    b = {k: v for k, v in build.__dict__.iteritems() if not k.startswith('_')}
+                    d['builds'].append(b)
+            elif not k.startswith('_'):
+                f = App.attr_to_field(k)
+                d[f] = v
         return d
 
     # Gets the value associated to a field name, e.g. 'Auto Name'
@@ -177,6 +182,7 @@ class App():
             raise MetaDataException('Unrecognised app field: ' + f)
         k = App.field_to_attr(f)
         self.__dict__[k] = v
+        self._modified.add(k)
 
     # Appends to the value associated to a field name, e.g. 'Auto Name'
     def append_field(self, f, v):
@@ -199,21 +205,31 @@ class App():
             else:
                 self.set_field(f, v)
 
+TYPE_UNKNOWN = 0
+TYPE_OBSOLETE = 1
+TYPE_STRING = 2
+TYPE_BOOL = 3
+TYPE_LIST = 4
+TYPE_SCRIPT = 5
+TYPE_MULTILINE = 6
+TYPE_BUILD = 7
+TYPE_BUILD_V2 = 8
+
+fieldtypes = {
+    'Description': TYPE_MULTILINE,
+    'Maintainer Notes': TYPE_MULTILINE,
+    'Categories': TYPE_LIST,
+    'AntiFeatures': TYPE_LIST,
+    'Build Version': TYPE_BUILD,
+    'Build': TYPE_BUILD_V2,
+    'Use Built': TYPE_OBSOLETE,
+}
+
 
-def metafieldtype(name):
-    if name in ['Description', 'Maintainer Notes']:
-        return 'multiline'
-    if name in ['Categories', 'AntiFeatures']:
-        return 'list'
-    if name == 'Build Version':
-        return 'build'
-    if name == 'Build':
-        return 'buildv2'
-    if name == 'Use Built':
-        return 'obsolete'
-    if name not in app_fields:
-        return 'unknown'
-    return 'string'
+def fieldtype(name):
+    if name in fieldtypes:
+        return fieldtypes[name]
+    return TYPE_STRING
 
 
 # In the order in which they are laid out on files
@@ -262,7 +278,7 @@ class Build():
         self.submodules = False
         self.init = ''
         self.patch = []
-        self.gradle = False
+        self.gradle = []
         self.maven = False
         self.kivy = False
         self.output = None
@@ -274,7 +290,7 @@ class Build():
         self.rm = []
         self.extlibs = []
         self.prebuild = ''
-        self.update = None
+        self.update = []
         self.target = None
         self.scanignore = []
         self.scandelete = []
@@ -283,9 +299,11 @@ class Build():
         self.ndk = None
         self.preassemble = []
         self.gradleprops = []
-        self.antcommands = None
+        self.antcommands = []
         self.novcheck = False
 
+        self._modified = set()
+
     def get_flag(self, f):
         if f not in build_flags:
             raise MetaDataException('Unrecognised build flag: ' + f)
@@ -299,6 +317,7 @@ class Build():
         if f not in build_flags:
             raise MetaDataException('Unrecognised build flag: ' + f)
         self.__dict__[f] = v
+        self._modified.add(f)
 
     def append_flag(self, f, v):
         if f not in build_flags:
@@ -329,22 +348,34 @@ class Build():
         for f, v in d.iteritems():
             self.set_flag(f, v)
 
-list_flags = set(['extlibs', 'srclibs', 'patch', 'rm', 'buildjni', 'preassemble',
-                  'update', 'scanignore', 'scandelete', 'gradle', 'antcommands',
-                  'gradleprops'])
-script_flags = set(['init', 'prebuild', 'build'])
-bool_flags = set(['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
-                  'novcheck'])
+flagtypes = {
+    'extlibs': TYPE_LIST,
+    'srclibs': TYPE_LIST,
+    'patch': TYPE_LIST,
+    'rm': TYPE_LIST,
+    'buildjni': TYPE_LIST,
+    'preassemble': TYPE_LIST,
+    'update': TYPE_LIST,
+    'scanignore': TYPE_LIST,
+    'scandelete': TYPE_LIST,
+    'gradle': TYPE_LIST,
+    'antcommands': TYPE_LIST,
+    'gradleprops': TYPE_LIST,
+    'init': TYPE_SCRIPT,
+    'prebuild': TYPE_SCRIPT,
+    'build': TYPE_SCRIPT,
+    'submodules': TYPE_BOOL,
+    'oldsdkloc': TYPE_BOOL,
+    'forceversion': TYPE_BOOL,
+    'forcevercode': TYPE_BOOL,
+    'novcheck': TYPE_BOOL,
+}
 
 
 def flagtype(name):
-    if name in list_flags:
-        return 'list'
-    if name in script_flags:
-        return 'script'
-    if name in bool_flags:
-        return 'bool'
-    return 'string'
+    if name in flagtypes:
+        return flagtypes[name]
+    return TYPE_STRING
 
 
 # Designates a metadata field type and checks that it matches
@@ -362,6 +393,8 @@ class FieldValidator():
         self.matching = matching
         if type(matching) is str:
             self.compiled = re.compile(matching)
+        else:
+            self.matching = set(self.matching)
         self.sep = sep
         self.fields = fields
         self.flags = flags
@@ -369,25 +402,23 @@ class FieldValidator():
     def _assert_regex(self, values, appid):
         for v in values:
             if not self.compiled.match(v):
-                raise MetaDataException("'%s' is not a valid %s in %s. "
-                                        % (v, self.name, appid) +
-                                        "Regex pattern: %s" % (self.matching))
+                raise MetaDataException("'%s' is not a valid %s in %s. Regex pattern: %s"
+                                        % (v, self.name, appid, self.matching))
 
     def _assert_list(self, values, appid):
         for v in values:
             if v not in self.matching:
-                raise MetaDataException("'%s' is not a valid %s in %s. "
-                                        % (v, self.name, appid) +
-                                        "Possible values: %s" % (", ".join(self.matching)))
+                raise MetaDataException("'%s' is not a valid %s in %s. Possible values: %s"
+                                        % (v, self.name, appid, ', '.join(self.matching)))
 
     def check(self, v, appid):
-        if type(v) is not str or not v:
+        if not v:
             return
-        if self.sep is not None:
-            values = v.split(self.sep)
+        if type(v) == list:
+            values = v
         else:
             values = [v]
-        if type(self.matching) is list:
+        if type(self.matching) is set:
             self._assert_list(values, appid)
         else:
             self._assert_regex(values, appid)
@@ -407,7 +438,7 @@ valuetypes = {
 
     FieldValidator("HTTP link",
                    r'^http[s]?://', None,
-                   ["Web Site", "Source Code", "Issue Tracker", "Changelog", "Donate"], []),
+                   ["WebSite", "SourceCode", "IssueTracker", "Changelog", "Donate"], []),
 
     FieldValidator("Bitcoin address",
                    r'^[a-zA-Z0-9]{27,34}$', None,
@@ -419,15 +450,9 @@ valuetypes = {
                    ["Litecoin"],
                    []),
 
-    FieldValidator("bool",
-                   r'([Yy]es|[Nn]o|[Tt]rue|[Ff]alse)', None,
-                   ["Requires Root"],
-                   ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
-                    'novcheck']),
-
     FieldValidator("Repo Type",
                    ['git', 'git-svn', 'svn', 'hg', 'bzr', 'srclib'], None,
-                   ["Repo Type"],
+                   ["RepoType"],
                    []),
 
     FieldValidator("Binaries",
@@ -437,7 +462,7 @@ valuetypes = {
 
     FieldValidator("Archive Policy",
                    r'^[0-9]+ versions$', None,
-                   ["Archive Policy"],
+                   ["ArchivePolicy"],
                    []),
 
     FieldValidator("Anti-Feature",
@@ -447,12 +472,12 @@ valuetypes = {
 
     FieldValidator("Auto Update Mode",
                    r"^(Version .+|None)$", None,
-                   ["Auto Update Mode"],
+                   ["AutoUpdateMode"],
                    []),
 
     FieldValidator("Update Check Mode",
                    r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$", None,
-                   ["Update Check Mode"],
+                   ["UpdateCheckMode"],
                    [])
 }
 
@@ -460,31 +485,38 @@ valuetypes = {
 # Check an app's metadata information for integrity errors
 def check_metadata(app):
     for v in valuetypes:
-        for f in v.fields:
-            v.check(app.get_field(f), app.id)
+        for k in v.fields:
+            if k not in app._modified:
+                continue
+            v.check(app.__dict__[k], app.id)
         for build in app.builds:
-            for f in v.flags:
-                v.check(build.get_flag(f), app.id)
+            for k in v.flags:
+                if k not in build._modified:
+                    continue
+                v.check(build.__dict__[k], app.id)
 
 
 # Formatter for descriptions. Create an instance, and call parseline() with
 # each line of the description source from the metadata. At the end, call
-# end() and then text_wiki and text_html will contain the result.
+# end() and then text_txt and text_html will contain the result.
 class DescriptionFormatter:
+
     stNONE = 0
     stPARA = 1
     stUL = 2
     stOL = 3
-    bold = False
-    ital = False
-    state = stNONE
-    text_wiki = ''
-    text_html = ''
-    text_txt = ''
-    para_lines = []
-    linkResolver = None
 
     def __init__(self, linkres):
+        self.bold = False
+        self.ital = False
+        self.state = self.stNONE
+        self.laststate = self.stNONE
+        self.text_html = ''
+        self.text_txt = ''
+        self.html = StringIO()
+        self.text = StringIO()
+        self.para_lines = []
+        self.linkResolver = None
         self.linkResolver = linkres
 
     def endcur(self, notstates=None):
@@ -498,61 +530,62 @@ class DescriptionFormatter:
             self.endol()
 
     def endpara(self):
+        self.laststate = self.state
         self.state = self.stNONE
         whole_para = ' '.join(self.para_lines)
         self.addtext(whole_para)
-        self.text_txt += textwrap.fill(whole_para, 80,
-                                       break_long_words=False,
-                                       break_on_hyphens=False) + '\n\n'
-        self.text_html += '</p>'
+        self.text.write(textwrap.fill(whole_para, 80,
+                                      break_long_words=False,
+                                      break_on_hyphens=False))
+        self.html.write('</p>')
         del self.para_lines[:]
 
     def endul(self):
-        self.text_html += '</ul>'
-        self.text_txt += '\n'
+        self.html.write('</ul>')
+        self.laststate = self.state
         self.state = self.stNONE
 
     def endol(self):
-        self.text_html += '</ol>'
-        self.text_txt += '\n'
+        self.html.write('</ol>')
+        self.laststate = self.state
         self.state = self.stNONE
 
     def formatted(self, txt, html):
-        formatted = ''
+        res = ''
         if html:
             txt = cgi.escape(txt)
         while True:
             index = txt.find("''")
             if index == -1:
-                return formatted + txt
-            formatted += txt[:index]
+                return res + txt
+            res += txt[:index]
             txt = txt[index:]
             if txt.startswith("'''"):
                 if html:
                     if self.bold:
-                        formatted += '</b>'
+                        res += '</b>'
                     else:
-                        formatted += '<b>'
+                        res += '<b>'
                 self.bold = not self.bold
                 txt = txt[3:]
             else:
                 if html:
                     if self.ital:
-                        formatted += '</i>'
+                        res += '</i>'
                     else:
-                        formatted += '<i>'
+                        res += '<i>'
                 self.ital = not self.ital
                 txt = txt[2:]
 
     def linkify(self, txt):
-        linkified_plain = ''
-        linkified_html = ''
+        res_plain = ''
+        res_html = ''
         while True:
             index = txt.find("[")
             if index == -1:
-                return (linkified_plain + self.formatted(txt, False), linkified_html + self.formatted(txt, True))
-            linkified_plain += self.formatted(txt[:index], False)
-            linkified_html += self.formatted(txt[:index], True)
+                return (res_plain + self.formatted(txt, False), res_html + self.formatted(txt, True))
+            res_plain += self.formatted(txt[:index], False)
+            res_html += self.formatted(txt[:index], True)
             txt = txt[index:]
             if txt.startswith("[["):
                 index = txt.find("]]")
@@ -563,8 +596,8 @@ class DescriptionFormatter:
                     url, urltext = self.linkResolver(url)
                 else:
                     urltext = url
-                linkified_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
-                linkified_plain += urltext
+                res_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
+                res_plain += urltext
                 txt = txt[index + 2:]
             else:
                 index = txt.find("]")
@@ -579,55 +612,67 @@ class DescriptionFormatter:
                     url = url[:index2]
                     if url == urltxt:
                         raise MetaDataException("Url title is just the URL - use [url]")
-                linkified_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
-                linkified_plain += urltxt
+                res_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
+                res_plain += urltxt
                 if urltxt != url:
-                    linkified_plain += ' (' + url + ')'
+                    res_plain += ' (' + url + ')'
                 txt = txt[index + 1:]
 
     def addtext(self, txt):
         p, h = self.linkify(txt)
-        self.text_html += h
+        self.html.write(h)
 
     def parseline(self, line):
-        self.text_wiki += "%s\n" % line
         if not line:
             self.endcur()
         elif line.startswith('* '):
             self.endcur([self.stUL])
-            self.text_txt += "%s\n" % line
             if self.state != self.stUL:
-                self.text_html += '<ul>'
+                self.html.write('<ul>')
                 self.state = self.stUL
-            self.text_html += '<li>'
+                if self.laststate != self.stNONE:
+                    self.text.write('\n\n')
+            else:
+                self.text.write('\n')
+            self.text.write(line)
+            self.html.write('<li>')
             self.addtext(line[1:])
-            self.text_html += '</li>'
+            self.html.write('</li>')
         elif line.startswith('# '):
             self.endcur([self.stOL])
-            self.text_txt += "%s\n" % line
             if self.state != self.stOL:
-                self.text_html += '<ol>'
+                self.html.write('<ol>')
                 self.state = self.stOL
-            self.text_html += '<li>'
+                if self.laststate != self.stNONE:
+                    self.text.write('\n\n')
+            else:
+                self.text.write('\n')
+            self.text.write(line)
+            self.html.write('<li>')
             self.addtext(line[1:])
-            self.text_html += '</li>'
+            self.html.write('</li>')
         else:
             self.para_lines.append(line)
             self.endcur([self.stPARA])
             if self.state == self.stNONE:
-                self.text_html += '<p>'
                 self.state = self.stPARA
+                if self.laststate != self.stNONE:
+                    self.text.write('\n\n')
+                self.html.write('<p>')
 
     def end(self):
         self.endcur()
-        self.text_txt = self.text_txt.strip()
+        self.text_txt = self.text.getvalue()
+        self.text_html = self.html.getvalue()
+        self.text.close()
+        self.html.close()
 
 
 # Parse multiple lines of description as written in a metadata file, returning
 # a single string in text format and wrapped to 80 columns.
-def description_txt(lines):
+def description_txt(s):
     ps = DescriptionFormatter(None)
-    for line in lines:
+    for line in s.splitlines():
         ps.parseline(line)
     ps.end()
     return ps.text_txt
@@ -636,19 +681,15 @@ def description_txt(lines):
 # Parse multiple lines of description as written in a metadata file, returning
 # a single string in wiki format. Used for the Maintainer Notes field as well,
 # because it's the same format.
-def description_wiki(lines):
-    ps = DescriptionFormatter(None)
-    for line in lines:
-        ps.parseline(line)
-    ps.end()
-    return ps.text_wiki
+def description_wiki(s):
+    return s
 
 
 # Parse multiple lines of description as written in a metadata file, returning
 # a single string in HTML format.
-def description_html(lines, linkres):
+def description_html(s, linkres):
     ps = DescriptionFormatter(linkres)
-    for line in lines:
+    for line in s.splitlines():
         ps.parseline(line)
     ps.end()
     return ps.text_html
@@ -686,6 +727,8 @@ def parse_srclib(metadatapath):
         else:
             thisinfo[f] = v
 
+    metafile.close()
+
     return thisinfo
 
 
@@ -762,11 +805,20 @@ def read_metadata(xref=True):
 
     return apps
 
+# Port legacy ';' separators
+list_sep = re.compile(r'[,;]')
+
 
 def split_list_values(s):
-    # Port legacy ';' separators
-    l = [v.strip() for v in s.replace(';', ',').split(',')]
-    return [v for v in l if v]
+    res = []
+    for v in re.split(list_sep, s):
+        if not v:
+            continue
+        v = v.strip()
+        if not v:
+            continue
+        res.append(v)
+    return res
 
 
 def get_default_app_info(metadatapath=None):
@@ -787,44 +839,40 @@ def sorted_builds(builds):
     return sorted(builds, key=lambda build: int(build.vercode))
 
 
-esc_newlines = re.compile('\\\\( |\\n)')
+esc_newlines = re.compile(r'\\( |\n)')
 
 
 # This function uses __dict__ to be faster
 def post_metadata_parse(app):
 
     for k, v in app.__dict__.iteritems():
+        if k not in app._modified:
+            continue
         if type(v) in (float, int):
-            app.__dict__[f] = str(v)
+            app.__dict__[k] = str(v)
 
     for build in app.builds:
-        for k, v in app.__dict__.iteritems():
+        for k, v in build.__dict__.iteritems():
 
+            if k not in build._modified:
+                continue
             if type(v) in (float, int):
                 build.__dict__[k] = str(v)
                 continue
-
             ftype = flagtype(k)
 
-            if ftype == 'script':
+            if ftype == TYPE_SCRIPT:
                 build.__dict__[k] = re.sub(esc_newlines, '', v).lstrip().rstrip()
-            elif ftype == 'bool':
+            elif ftype == TYPE_BOOL:
                 # TODO handle this using <xsd:element type="xsd:boolean> in a schema
-                if isinstance(v, basestring) and v == 'true':
-                    build.__dict__[k] = True
-            elif ftype == 'string':
+                if isinstance(v, basestring):
+                    build.__dict__[k] = _decode_bool(v)
+            elif ftype == TYPE_STRING:
                 if isinstance(v, bool) and v:
                     build.__dict__[k] = 'yes'
 
-    # convert to the odd internal format
-    for f in ('Description', 'Maintainer Notes'):
-        v = app.get_field(f)
-        if isinstance(v, basestring):
-            text = v.rstrip().lstrip()
-            app.set_field(f, text.split('\n'))
-
     if not app.Description:
-        app.Description = ['No description available']
+        app.Description = 'No description available'
 
     app.builds = sorted_builds(app.builds)
 
@@ -885,57 +933,67 @@ def _decode_dict(data):
     return rv
 
 
+bool_true = re.compile(r'([Yy]es|[Tt]rue)')
+bool_false = re.compile(r'([Nn]o|[Ff]alse)')
+
+
+def _decode_bool(s):
+    if bool_true.match(s):
+        return True
+    if bool_false.match(s):
+        return False
+    raise MetaDataException("Invalid bool '%s'" % s)
+
+
 def parse_metadata(metadatapath):
     _, ext = common.get_extension(metadatapath)
     accepted = common.config['accepted_formats']
     if ext not in accepted:
-        logging.critical('"' + metadatapath
-                         + '" is not in an accepted format, '
-                         + 'convert to: ' + ', '.join(accepted))
-        sys.exit(1)
-
-    app = None
-    if ext == 'txt':
-        app = parse_txt_metadata(metadatapath)
-    elif ext == 'json':
-        app = parse_json_metadata(metadatapath)
-    elif ext == 'xml':
-        app = parse_xml_metadata(metadatapath)
-    elif ext == 'yaml':
-        app = parse_yaml_metadata(metadatapath)
-    else:
-        logging.critical('Unknown metadata format: ' + metadatapath)
-        sys.exit(1)
+        raise MetaDataException('"%s" is not an accepted format, convert to: %s' % (
+            metadatapath, ', '.join(accepted)))
+
+    app = App()
+    app.metadatapath = metadatapath
+    app.id, _ = common.get_extension(os.path.basename(metadatapath))
+
+    with open(metadatapath, 'r') as mf:
+        if ext == 'txt':
+            parse_txt_metadata(mf, app)
+        elif ext == 'json':
+            parse_json_metadata(mf, app)
+        elif ext == 'xml':
+            parse_xml_metadata(mf, app)
+        elif ext == 'yaml':
+            parse_yaml_metadata(mf, app)
+        else:
+            raise MetaDataException('Unknown metadata format: %s' % metadatapath)
 
     post_metadata_parse(app)
     return app
 
 
-def parse_json_metadata(metadatapath):
-
-    app = get_default_app_info(metadatapath)
+def parse_json_metadata(mf, app):
 
     # fdroid metadata is only strings and booleans, no floats or ints. And
     # json returns unicode, and fdroidserver still uses plain python strings
     # TODO create schema using https://pypi.python.org/pypi/jsonschema
-    jsoninfo = json.load(open(metadatapath, 'r'),
-                         object_hook=_decode_dict,
+    jsoninfo = json.load(mf, object_hook=_decode_dict,
                          parse_int=lambda s: s,
                          parse_float=lambda s: s)
     app.update_fields(jsoninfo)
+    for f in ['Description', 'Maintainer Notes']:
+        v = app.get_field(f)
+        app.set_field(f, '\n'.join(v))
     return app
 
 
-def parse_xml_metadata(metadatapath):
-
-    app = get_default_app_info(metadatapath)
+def parse_xml_metadata(mf, app):
 
-    tree = ElementTree.ElementTree(file=metadatapath)
+    tree = ElementTree.ElementTree(file=mf)
     root = tree.getroot()
 
     if root.tag != 'resources':
-        logging.critical(metadatapath + ' does not have root as <resources></resources>!')
-        sys.exit(1)
+        raise MetaDataException('%s does not have root as <resources></resources>!' % metadatapath)
 
     for child in root:
         if child.tag != 'builds':
@@ -956,27 +1014,23 @@ def parse_xml_metadata(metadatapath):
 
     # TODO handle this using <xsd:element type="xsd:boolean> in a schema
     if not isinstance(app.RequiresRoot, bool):
-        if app.RequiresRoot == 'true':
-            app.RequiresRoot = True
-        else:
-            app.RequiresRoot = False
+        app.RequiresRoot = app.RequiresRoot == 'true'
 
     return app
 
 
-def parse_yaml_metadata(metadatapath):
-
-    app = get_default_app_info(metadatapath)
+def parse_yaml_metadata(mf, app):
 
-    yamlinfo = yaml.load(open(metadatapath, 'r'), Loader=YamlLoader)
+    yamlinfo = yaml.load(mf, Loader=YamlLoader)
     app.update_fields(yamlinfo)
     return app
 
 
-build_line_sep = re.compile(r"(?<!\\),")
+build_line_sep = re.compile(r'(?<!\\),')
+build_cont = re.compile(r'^[ \t]')
 
 
-def parse_txt_metadata(metadatapath):
+def parse_txt_metadata(mf, app):
 
     linedesc = None
 
@@ -991,26 +1045,14 @@ def parse_txt_metadata(metadatapath):
 
         pk, pv = bv
         pk = pk.lstrip()
-        if pk not in build_flags:
-            raise MetaDataException("Unrecognised build flag at {0} in {1}"
-                                    .format(p, linedesc))
         t = flagtype(pk)
-        if t == 'list':
+        if t == TYPE_LIST:
             pv = split_list_values(pv)
-            if pk == 'gradle':
-                if len(pv) == 1 and pv[0] in ['main', 'yes']:
-                    pv = ['yes']
             build.set_flag(pk, pv)
-        elif t == 'string' or t == 'script':
+        elif t == TYPE_STRING or t == TYPE_SCRIPT:
             build.set_flag(pk, pv)
-        elif t == 'bool':
-            v = pv == 'yes'
-            if v:
-                build.set_flag(pk, True)
-
-        else:
-            raise MetaDataException("Unrecognised build flag type '%s' at %s in %s"
-                                    % (t, p, linedesc))
+        elif t == TYPE_BOOL:
+            build.set_flag(pk, _decode_bool(pv))
 
     def parse_buildline(lines):
         v = "".join(lines)
@@ -1018,7 +1060,6 @@ def parse_txt_metadata(metadatapath):
         if len(parts) < 3:
             raise MetaDataException("Invalid build format: " + v + " in " + metafile.name)
         build = Build()
-        build.origlines = lines
         build.version = parts[0]
         build.vercode = parts[1]
         if parts[2].startswith('!'):
@@ -1045,22 +1086,28 @@ def parse_txt_metadata(metadatapath):
         app.comments[key] = list(curcomments)
         del curcomments[:]
 
-    app = get_default_app_info(metadatapath)
-    metafile = open(metadatapath, "r")
-
     mode = 0
     buildlines = []
+    multiline_lines = []
     curcomments = []
     build = None
-    vc_seen = {}
+    vc_seen = set()
 
     c = 0
-    for line in metafile:
+    for line in mf:
         c += 1
-        linedesc = "%s:%d" % (metafile.name, c)
+        linedesc = "%s:%d" % (mf.name, c)
         line = line.rstrip('\r\n')
         if mode == 3:
-            if not any(line.startswith(s) for s in (' ', '\t')):
+            if build_cont.match(line):
+                if line.endswith('\\'):
+                    buildlines.append(line[:-1].lstrip())
+                else:
+                    buildlines.append(line.lstrip())
+                    bl = ''.join(buildlines)
+                    add_buildflag(bl, build)
+                    del buildlines[:]
+            else:
                 if not build.commit and not build.disable:
                     raise MetaDataException("No commit specified for {0} in {1}"
                                             .format(build.version, linedesc))
@@ -1068,14 +1115,6 @@ def parse_txt_metadata(metadatapath):
                 app.builds.append(build)
                 add_comments('build:' + build.vercode)
                 mode = 0
-            else:
-                if line.endswith('\\'):
-                    buildlines.append(line[:-1].lstrip())
-                else:
-                    buildlines.append(line.lstrip())
-                    bl = ''.join(buildlines)
-                    add_buildflag(bl, build)
-                    buildlines = []
 
         if mode == 0:
             if not line:
@@ -1087,8 +1126,6 @@ def parse_txt_metadata(metadatapath):
                 f, v = line.split(':', 1)
             except ValueError:
                 raise MetaDataException("Invalid metadata in " + linedesc)
-            if f != f.strip() or v != v.strip():
-                raise MetaDataException("Extra spacing found in " + linedesc)
 
             # Translate obsolete fields...
             if f == 'Market Version':
@@ -1096,48 +1133,51 @@ def parse_txt_metadata(metadatapath):
             if f == 'Market Version Code':
                 f = 'Current Version Code'
 
-            fieldtype = metafieldtype(f)
-            if fieldtype not in ['build', 'buildv2']:
+            ftype = fieldtype(f)
+            if ftype not in [TYPE_BUILD, TYPE_BUILD_V2]:
                 add_comments(f)
-            if fieldtype == 'multiline':
+            if ftype == TYPE_MULTILINE:
                 mode = 1
                 if v:
                     raise MetaDataException("Unexpected text on same line as " + f + " in " + linedesc)
-            elif fieldtype == 'string':
+            elif ftype == TYPE_STRING:
                 app.set_field(f, v)
-            elif fieldtype == 'list':
+            elif ftype == TYPE_LIST:
                 app.set_field(f, split_list_values(v))
-            elif fieldtype == 'build':
+            elif ftype == TYPE_BUILD:
                 if v.endswith("\\"):
                     mode = 2
-                    buildlines = [v[:-1]]
+                    del buildlines[:]
+                    buildlines.append(v[:-1])
                 else:
                     build = parse_buildline([v])
                     app.builds.append(build)
                     add_comments('build:' + app.builds[-1].vercode)
-            elif fieldtype == 'buildv2':
-                build = Build()
+            elif ftype == TYPE_BUILD_V2:
                 vv = v.split(',')
                 if len(vv) != 2:
                     raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
                                             .format(v, linedesc))
+                build = Build()
                 build.version = vv[0]
                 build.vercode = vv[1]
                 if build.vercode in vc_seen:
                     raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
                                             build.vercode, linedesc))
-                vc_seen[build.vercode] = True
-                buildlines = []
+                vc_seen.add(build.vercode)
+                del buildlines[:]
                 mode = 3
-            elif fieldtype == 'obsolete':
+            elif ftype == TYPE_OBSOLETE:
                 pass        # Just throw it away!
             else:
-                raise MetaDataException("Unrecognised field type for " + f + " in " + linedesc)
+                raise MetaDataException("Unrecognised field '" + f + "' in " + linedesc)
         elif mode == 1:     # Multiline field
             if line == '.':
                 mode = 0
+                app.set_field(f, '\n'.join(multiline_lines))
+                del multiline_lines[:]
             else:
-                app.append_field(f, line)
+                multiline_lines.append(line)
         elif mode == 2:     # Line continuation mode in Build Version
             if line.endswith("\\"):
                 buildlines.append(line[:-1])
@@ -1149,12 +1189,12 @@ def parse_txt_metadata(metadatapath):
                 mode = 0
     add_comments(None)
 
-    # Mode at end of file should always be 0...
+    # Mode at end of file should always be 0
     if mode == 1:
-        raise MetaDataException(f + " not terminated in " + metafile.name)
-    elif mode == 2:
+        raise MetaDataException(f + " not terminated in " + mf.name)
+    if mode == 2:
         raise MetaDataException("Unterminated continuation in " + metafile.name)
-    elif mode == 3:
+    if mode == 3:
         raise MetaDataException("Unterminated build in " + metafile.name)
 
     return app
@@ -1250,14 +1290,11 @@ def write_txt_metadata(mf, app):
         mf.write("# %s\n" % line)
 
     def w_field(f, v):
-        t = metafieldtype(f)
-        if t == 'list':
+        t = fieldtype(f)
+        if t == TYPE_LIST:
             v = ','.join(v)
-        elif t == 'multiline':
-            if type(v) == list:
-                v = '\n' + '\n'.join(v) + '\n.'
-            else:
-                v = '\n' + v + '\n.'
+        elif t == TYPE_MULTILINE:
+            v = '\n' + v + '\n.'
         mf.write("%s:%s\n" % (f, v))
 
     def w_build(build):
@@ -1269,17 +1306,22 @@ def write_txt_metadata(mf, app):
                 continue
 
             t = flagtype(f)
-            out = '    %s=' % f
-            if t == 'string':
-                out += v
-            elif t == 'bool':
-                out += 'yes'
-            elif t == 'script':
-                out += '&& \\\n        '.join([s.lstrip() for s in v.split('&& ')])
-            elif t == 'list':
-                out += ','.join(v) if type(v) == list else v
-
-            mf.write(out)
+            mf.write('    %s=' % f)
+            if t == TYPE_STRING:
+                mf.write(v)
+            elif t == TYPE_BOOL:
+                mf.write('yes')
+            elif t == TYPE_SCRIPT:
+                first = True
+                for s in v.split(' && '):
+                    if first:
+                        first = False
+                    else:
+                        mf.write(' && \\\n        ')
+                    mf.write(s)
+            elif t == TYPE_LIST:
+                mf.write(','.join(v))
+
             mf.write('\n')
 
     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
@@ -1299,25 +1341,22 @@ def write_yaml_metadata(mf, app):
 
     def w_field(f, v, prefix='', t=None):
         if t is None:
-            t = metafieldtype(f)
+            t = fieldtype(f)
         v = ''
-        if t == 'list':
+        if t == TYPE_LIST:
             v = '\n'
             for e in v:
                 v += prefix + ' - ' + escape(e) + '\n'
-        elif t == 'multiline':
+        elif t == TYPE_MULTILINE:
             v = ' |\n'
-            lines = v
-            if type(v) == str:
-                lines = v.splitlines()
-            for l in lines:
+            for l in v.splitlines():
                 if l:
                     v += prefix + '  ' + l + '\n'
                 else:
                     v += '\n'
-        elif t == 'bool':
+        elif t == TYPE_BOOL:
             v = ' yes\n'
-        elif t == 'script':
+        elif t == TYPE_SCRIPT:
             cmds = [s + '&& \\' for s in v.split('&& ')]
             if len(cmds) > 0:
                 cmds[-1] = cmds[-1][:-len('&& \\')]
@@ -1340,8 +1379,8 @@ def write_yaml_metadata(mf, app):
             mf.write("builds:\n")
             first_build = False
 
-        w_field('versionName', build.version, '  - ', 'string')
-        w_field('versionCode', build.vercode, '    ', 'strsng')
+        w_field('versionName', build.version, '  - ', TYPE_STRING)
+        w_field('versionCode', build.vercode, '    ', TYPE_STRING)
         for f in build_flags_order:
             v = build.get_flag(f)
             if not v: