chiark / gitweb /
except named exception handling
[fdroidserver.git] / fdroidserver / metadata.py
index 5b56048c0f353b81b2976dec499d53dab6ffef3b..e42ca169f88a19e343a0a37035ca27ad70677a39 100644 (file)
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
+import json
 import os
 import re
 import glob
 import cgi
-import logging
+import textwrap
 
-from collections import OrderedDict
+try:
+    from cStringIO import StringIO
+except:
+    from StringIO import StringIO
+
+import yaml
+# use libyaml if it is available
+try:
+    from yaml import CLoader
+    YamlLoader = CLoader
+except ImportError:
+    from yaml import Loader
+    YamlLoader = Loader
+
+# use the C implementation when available
+import xml.etree.cElementTree as ElementTree
 
 import common
 
@@ -38,76 +54,328 @@ class MetaDataException(Exception):
     def __str__(self):
         return self.value
 
-# In the order in which they are laid out on files
-app_defaults = OrderedDict([
-    ('Disabled', None),
-    ('AntiFeatures', None),
-    ('Provides', None),
-    ('Categories', ['None']),
-    ('License', 'Unknown'),
-    ('Web Site', ''),
-    ('Source Code', ''),
-    ('Issue Tracker', ''),
-    ('Changelog', ''),
-    ('Donate', None),
-    ('FlattrID', None),
-    ('Bitcoin', None),
-    ('Litecoin', None),
-    ('Dogecoin', None),
-    ('Name', None),
-    ('Auto Name', ''),
-    ('Summary', ''),
-    ('Description', []),
-    ('Requires Root', False),
-    ('Repo Type', ''),
-    ('Repo', ''),
-    ('Binaries', None),
-    ('Maintainer Notes', []),
-    ('Archive Policy', None),
-    ('Auto Update Mode', 'None'),
-    ('Update Check Mode', 'None'),
-    ('Update Check Ignore', None),
-    ('Vercode Operation', None),
-    ('Update Check Name', None),
-    ('Update Check Data', None),
-    ('Current Version', ''),
-    ('Current Version Code', '0'),
-    ('No Source Since', ''),
+# To filter which ones should be written to the metadata files if
+# present
+app_fields = set([
+    'Disabled',
+    'AntiFeatures',
+    'Provides',
+    'Categories',
+    'License',
+    'Web Site',
+    'Source Code',
+    'Issue Tracker',
+    'Changelog',
+    'Donate',
+    'FlattrID',
+    'Bitcoin',
+    'Litecoin',
+    'Name',
+    'Auto Name',
+    'Summary',
+    'Description',
+    'Requires Root',
+    'Repo Type',
+    'Repo',
+    'Binaries',
+    'Maintainer Notes',
+    'Archive Policy',
+    'Auto Update Mode',
+    'Update Check Mode',
+    'Update Check Ignore',
+    'Vercode Operation',
+    'Update Check Name',
+    'Update Check Data',
+    'Current Version',
+    'Current Version Code',
+    'No Source Since',
+
+    'comments',  # For formats that don't do inline comments
+    'builds',    # For formats that do builds as a list
 ])
 
 
+class App():
+
+    def __init__(self):
+        self.Disabled = None
+        self.AntiFeatures = []
+        self.Provides = None
+        self.Categories = ['None']
+        self.License = 'Unknown'
+        self.WebSite = ''
+        self.SourceCode = ''
+        self.IssueTracker = ''
+        self.Changelog = ''
+        self.Donate = None
+        self.FlattrID = None
+        self.Bitcoin = None
+        self.Litecoin = None
+        self.Name = None
+        self.AutoName = ''
+        self.Summary = ''
+        self.Description = ''
+        self.RequiresRoot = False
+        self.RepoType = ''
+        self.Repo = ''
+        self.Binaries = None
+        self.MaintainerNotes = ''
+        self.ArchivePolicy = None
+        self.AutoUpdateMode = 'None'
+        self.UpdateCheckMode = 'None'
+        self.UpdateCheckIgnore = None
+        self.VercodeOperation = None
+        self.UpdateCheckName = None
+        self.UpdateCheckData = None
+        self.CurrentVersion = ''
+        self.CurrentVersionCode = '0'
+        self.NoSourceSince = ''
+
+        self.id = None
+        self.metadatapath = None
+        self.builds = []
+        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'
+    @classmethod
+    def field_to_attr(cls, f):
+        return f.replace(' ', '')
+
+    # Translates attribute names to human-readable field names, e.g.
+    # 'AutoName' to 'Auto Name'
+    @classmethod
+    def attr_to_field(cls, k):
+        if k in app_fields:
+            return k
+        f = re.sub(r'([a-z])([A-Z])', r'\1 \2', k)
+        return f
+
+    # Constructs an old-fashioned dict with the human-readable field
+    # names. Should only be used for tests.
+    def field_dict(self):
+        d = {}
+        for k, v in self.__dict__.iteritems():
+            if k == 'builds':
+                d['builds'] = []
+                for build in 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'
+    def get_field(self, f):
+        if f not in app_fields:
+            raise MetaDataException('Unrecognised app field: ' + f)
+        k = App.field_to_attr(f)
+        return getattr(self, k)
+
+    # Sets the value associated to a field name, e.g. 'Auto Name'
+    def set_field(self, f, v):
+        if f not in app_fields:
+            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):
+        if f not in app_fields:
+            raise MetaDataException('Unrecognised app field: ' + f)
+        k = App.field_to_attr(f)
+        if k not in self.__dict__:
+            self.__dict__[k] = [v]
+        else:
+            self.__dict__[k].append(v)
+
+    # Like dict.update(), but using human-readable field names
+    def update_fields(self, d):
+        for f, v in d.iteritems():
+            if f == 'builds':
+                for b in v:
+                    build = Build()
+                    build.update_flags(b)
+                    self.builds.append(build)
+            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 fieldtype(name):
+    if name in fieldtypes:
+        return fieldtypes[name]
+    return TYPE_STRING
+
+
 # In the order in which they are laid out on files
-# Sorted by their action and their place in the build timeline
-flag_defaults = OrderedDict([
-    ('disable', False),
-    ('commit', None),
-    ('subdir', None),
-    ('submodules', False),
-    ('init', ''),
-    ('patch', []),
-    ('gradle', False),
-    ('maven', False),
-    ('kivy', False),
-    ('output', None),
-    ('srclibs', []),
-    ('oldsdkloc', False),
-    ('encoding', None),
-    ('forceversion', False),
-    ('forcevercode', False),
-    ('rm', []),
-    ('extlibs', []),
-    ('prebuild', ''),
-    ('update', ['auto']),
-    ('target', None),
-    ('scanignore', []),
-    ('scandelete', []),
-    ('build', ''),
-    ('buildjni', []),
-    ('ndk', 'r9b'),  # defaults to oldest
-    ('preassemble', []),
-    ('antcommands', None),
-    ('novcheck', False),
-])
+build_flags_order = [
+    'disable',
+    'commit',
+    'subdir',
+    'submodules',
+    'init',
+    'patch',
+    'gradle',
+    'maven',
+    'kivy',
+    'output',
+    'srclibs',
+    'oldsdkloc',
+    'encoding',
+    'forceversion',
+    'forcevercode',
+    'rm',
+    'extlibs',
+    'prebuild',
+    'update',
+    'target',
+    'scanignore',
+    'scandelete',
+    'build',
+    'buildjni',
+    'ndk',
+    'preassemble',
+    'gradleprops',
+    'antcommands',
+    'novcheck',
+]
+
+
+build_flags = set(build_flags_order + ['version', 'vercode'])
+
+
+class Build():
+
+    def __init__(self):
+        self.disable = False
+        self.commit = None
+        self.subdir = None
+        self.submodules = False
+        self.init = ''
+        self.patch = []
+        self.gradle = []
+        self.maven = False
+        self.kivy = False
+        self.output = None
+        self.srclibs = []
+        self.oldsdkloc = False
+        self.encoding = None
+        self.forceversion = False
+        self.forcevercode = False
+        self.rm = []
+        self.extlibs = []
+        self.prebuild = ''
+        self.update = []
+        self.target = None
+        self.scanignore = []
+        self.scandelete = []
+        self.build = ''
+        self.buildjni = []
+        self.ndk = None
+        self.preassemble = []
+        self.gradleprops = []
+        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)
+        return getattr(self, f)
+
+    def set_flag(self, f, v):
+        if f == 'versionName':
+            f = 'version'
+        if f == 'versionCode':
+            f = 'vercode'
+        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:
+            raise MetaDataException('Unrecognised build flag: ' + f)
+        if f not in self.__dict__:
+            self.__dict__[f] = [v]
+        else:
+            self.__dict__[f].append(v)
+
+    def method(self):
+        for f in ['maven', 'gradle', 'kivy']:
+            if self.get_flag(f):
+                return f
+        if self.output:
+            return 'raw'
+        return 'ant'
+
+    def ndk_path(self):
+        version = self.ndk
+        if not version:
+            version = 'r10e'  # falls back to latest
+        paths = common.config['ndk_paths']
+        if version not in paths:
+            return ''
+        return paths[version]
+
+    def update_flags(self, d):
+        for f, v in d.iteritems():
+            self.set_flag(f, v)
+
+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 flagtypes:
+        return flagtypes[name]
+    return TYPE_STRING
 
 
 # Designates a metadata field type and checks that it matches
@@ -116,41 +384,41 @@ flag_defaults = OrderedDict([
 # 'matching' - List of possible values or regex expression
 # 'sep'      - Separator to use if value may be a list
 # 'fields'   - Metadata fields (Field:Value) of this type
-# 'attrs'    - Build attributes (attr=value) of this type
+# 'flags'    - Build flags (flag=value) of this type
 #
 class FieldValidator():
 
-    def __init__(self, name, matching, sep, fields, attrs):
+    def __init__(self, name, matching, sep, fields, flags):
         self.name = name
         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.attrs = attrs
+        self.flags = flags
 
     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, value, appid):
-        if type(value) is not str or not value:
+    def check(self, v, appid):
+        if not v:
             return
-        if self.sep is not None:
-            values = value.split(self.sep)
+        if type(v) == list:
+            values = v
         else:
-            values = [value]
-        if type(self.matching) is list:
+            values = [v]
+        if type(self.matching) is set:
             self._assert_list(values, appid)
         else:
             self._assert_regex(values, appid)
@@ -170,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,
@@ -182,25 +450,9 @@ valuetypes = {
                    ["Litecoin"],
                    []),
 
-    FieldValidator("Dogecoin address",
-                   r'^D[a-zA-Z0-9]{33}$', None,
-                   ["Dogecoin"],
-                   []),
-
-    FieldValidator("Boolean",
-                   ['Yes', 'No'], None,
-                   ["Requires Root"],
-                   []),
-
-    FieldValidator("bool",
-                   ['yes', 'no'], None,
-                   [],
-                   ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
-                    'novcheck']),
-
     FieldValidator("Repo Type",
                    ['git', 'git-svn', 'svn', 'hg', 'bzr', 'srclib'], None,
-                   ["Repo Type"],
+                   ["RepoType"],
                    []),
 
     FieldValidator("Binaries",
@@ -210,52 +462,62 @@ valuetypes = {
 
     FieldValidator("Archive Policy",
                    r'^[0-9]+ versions$', None,
-                   ["Archive Policy"],
+                   ["ArchivePolicy"],
                    []),
 
     FieldValidator("Anti-Feature",
-                   ["Ads", "Tracking", "NonFreeNet", "NonFreeDep", "NonFreeAdd", "UpstreamNonFree"], ',',
+                   ["Ads", "Tracking", "NonFreeNet", "NonFreeDep", "NonFreeAdd",
+                       "UpstreamNonFree", "NonFreeAssets"], ',',
                    ["AntiFeatures"],
                    []),
 
     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"],
                    [])
 }
 
 
 # Check an app's metadata information for integrity errors
-def check_metadata(info):
+def check_metadata(app):
     for v in valuetypes:
-        for field in v.fields:
-            v.check(info[field], info['id'])
-        for build in info['builds']:
-            for attr in v.attrs:
-                v.check(build[attr], info['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 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 = ''
-    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):
@@ -269,53 +531,62 @@ class DescriptionFormatter:
             self.endol()
 
     def endpara(self):
-        self.text_html += '</p>'
+        self.laststate = self.state
         self.state = self.stNONE
+        whole_para = ' '.join(self.para_lines)
+        self.addtext(whole_para)
+        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.html.write('</ul>')
+        self.laststate = self.state
         self.state = self.stNONE
 
     def endol(self):
-        self.text_html += '</ol>'
+        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("]]")
@@ -326,8 +597,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("]")
@@ -340,86 +611,106 @@ class DescriptionFormatter:
                 else:
                     urltxt = url[index2 + 1:]
                     url = url[:index2]
-                linkified_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
-                linkified_plain += urltxt
+                    if url == urltxt:
+                        raise MetaDataException("Url title is just the URL - use [url]")
+                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])
             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])
             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
-            elif self.state == self.stPARA:
-                self.text_html += ' '
-            self.addtext(line)
+                if self.laststate != self.stNONE:
+                    self.text.write('\n\n')
+                self.html.write('<p>')
 
     def end(self):
         self.endcur()
+        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 wiki format. Used for the Maintainer Notes field as well,
-# because it's the same format.
-def description_wiki(lines):
+# a single string in text format and wrapped to 80 columns.
+def description_txt(s):
     ps = DescriptionFormatter(None)
-    for line in lines:
+    for line in s.splitlines():
         ps.parseline(line)
     ps.end()
-    return ps.text_wiki
+    return ps.text_txt
+
+
+# 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(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
 
 
-def parse_srclib(metafile):
+def parse_srclib(metadatapath):
 
     thisinfo = {}
-    if metafile and not isinstance(metafile, file):
-        metafile = open(metafile, "r")
 
     # Defaults for fields that come from metadata
     thisinfo['Repo Type'] = ''
     thisinfo['Repo'] = ''
     thisinfo['Subdir'] = None
     thisinfo['Prepare'] = None
-    thisinfo['Srclibs'] = None
 
-    if metafile is None:
+    if not os.path.exists(metadatapath):
         return thisinfo
 
+    metafile = open(metadatapath, "r")
+
     n = 0
     for line in metafile:
         n += 1
@@ -428,14 +719,16 @@ def parse_srclib(metafile):
             continue
 
         try:
-            field, value = line.split(':', 1)
+            f, v = line.split(':', 1)
         except ValueError:
             raise MetaDataException("Invalid metadata in %s:%d" % (line, n))
 
-        if field == "Subdir":
-            thisinfo[field] = value.split(',')
+        if f == "Subdir":
+            thisinfo[f] = v.split(',')
         else:
-            thisinfo[field] = value
+            thisinfo[f] = v
+
+    metafile.close()
 
     return thisinfo
 
@@ -462,13 +755,13 @@ def read_srclibs():
     if not os.path.exists(srcdir):
         os.makedirs(srcdir)
 
-    for metafile in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
-        srclibname = os.path.basename(metafile[:-4])
-        srclibs[srclibname] = parse_srclib(metafile)
+    for metadatapath in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
+        srclibname = os.path.basename(metadatapath[:-4])
+        srclibs[srclibname] = parse_srclib(metadatapath)
 
 
 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
-# returned by the parse_metadata function.
+# returned by the parse_txt_metadata function.
 def read_metadata(xref=True):
 
     # Always read the srclibs before the apps, since they can use a srlib as
@@ -481,10 +774,20 @@ def read_metadata(xref=True):
         if not os.path.exists(basedir):
             os.makedirs(basedir)
 
-    for metafile in sorted(glob.glob(os.path.join('metadata', '*.txt'))):
-        appid, appinfo = parse_metadata(metafile)
-        check_metadata(appinfo)
-        apps[appid] = appinfo
+    # If there are multiple metadata files for a single appid, then the first
+    # file that is parsed wins over all the others, and the rest throw an
+    # exception. So the original .txt format is parsed first, at least until
+    # newer formats stabilize.
+
+    for metadatapath in sorted(glob.glob(os.path.join('metadata', '*.txt'))
+                               + glob.glob(os.path.join('metadata', '*.json'))
+                               + glob.glob(os.path.join('metadata', '*.xml'))
+                               + glob.glob(os.path.join('metadata', '*.yaml'))):
+        app = parse_metadata(metadatapath)
+        if app.id in apps:
+            raise MetaDataException("Found multiple metadata files for " + app.id)
+        check_metadata(app)
+        apps[app.id] = app
 
     if xref:
         # Parse all descriptions at load time, just to ensure cross-referencing
@@ -496,64 +799,88 @@ def read_metadata(xref=True):
 
         for appid, app in apps.iteritems():
             try:
-                description_html(app['Description'], linkres)
-            except MetaDataException, e:
+                description_html(app.Description, linkres)
+            except MetaDataException as e:
                 raise MetaDataException("Problem with description of " + appid +
                                         " - " + str(e))
 
     return apps
 
+# Port legacy ';' separators
+list_sep = re.compile(r'[,;]')
 
-# Get the type expected for a given metadata field.
-def metafieldtype(name):
-    if name in ['Description', 'Maintainer Notes']:
-        return 'multiline'
-    if name in ['Categories']:
-        return 'list'
-    if name == 'Build Version':
-        return 'build'
-    if name == 'Build':
-        return 'buildv2'
-    if name == 'Use Built':
-        return 'obsolete'
-    if name not in app_defaults:
-        return 'unknown'
-    return 'string'
 
+def split_list_values(s):
+    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 flagtype(name):
-    if name in ['extlibs', 'srclibs', 'patch', 'rm', 'buildjni', 'preassemble',
-                'update', 'scanignore', 'scandelete', 'gradle', 'antcommands']:
-        return 'list'
-    if name in ['init', 'prebuild', 'build']:
-        return 'script'
-    if name in ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
-                'novcheck']:
-        return 'bool'
-    return 'string'
-
-
-def fill_build_defaults(build):
-
-    def get_build_type():
-        for t in ['maven', 'gradle', 'kivy']:
-            if build[t]:
-                return t
-        if build['output']:
-            return 'raw'
-        return 'ant'
 
-    for flag, value in flag_defaults.iteritems():
-        if flag in build:
+def get_default_app_info(metadatapath=None):
+    if metadatapath is None:
+        appid = None
+    else:
+        appid, _ = common.get_extension(os.path.basename(metadatapath))
+
+    app = App()
+    app.metadatapath = metadatapath
+    if appid is not None:
+        app.id = appid
+
+    return app
+
+
+def sorted_builds(builds):
+    return sorted(builds, key=lambda build: int(build.vercode))
+
+
+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
-        build[flag] = value
-    build['type'] = get_build_type()
-    build['ndk_path'] = common.get_ndk_path(build['ndk'])
+        if type(v) in (float, int):
+            app.__dict__[k] = str(v)
+
+    for build in app.builds:
+        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 == TYPE_SCRIPT:
+                build.__dict__[k] = re.sub(esc_newlines, '', v).lstrip().rstrip()
+            elif ftype == TYPE_BOOL:
+                # TODO handle this using <xsd:element type="xsd:boolean> in a schema
+                if isinstance(v, basestring):
+                    build.__dict__[k] = _decode_bool(v)
+            elif ftype == TYPE_STRING:
+                if isinstance(v, bool) and v:
+                    build.__dict__[k] = 'yes'
+
+    if not app.Description:
+        app.Description = 'No description available'
+
+    app.builds = sorted_builds(app.builds)
 
 
 # Parse metadata for a single application.
 #
-#  'metafile' - the filename to read. The package id for the application comes
+#  'metadatapath' - the filename to read. The package id for the application comes
 #               from this filename. Pass None to get a blank entry.
 #
 # Returns a dictionary containing all the details of the application. There are
@@ -567,7 +894,7 @@ def fill_build_defaults(build):
 #  'builds'           - a list of dictionaries containing build information
 #                       for each defined build
 #  'comments'         - a list of comments from the metadata file. Each is
-#                       a tuple of the form (field, comment) where field is
+#                       a list of the form [field, comment] where field is
 #                       the name of the field it preceded in the metadata
 #                       file. Where field is None, the comment goes at the
 #                       end of the file. Alternatively, 'build:version' is
@@ -575,341 +902,499 @@ def fill_build_defaults(build):
 #  'descriptionlines' - original lines of description as formatted in the
 #                       metadata file.
 #
-def parse_metadata(metafile):
 
-    appid = None
+
+def _decode_list(data):
+    '''convert items in a list from unicode to basestring'''
+    rv = []
+    for item in data:
+        if isinstance(item, unicode):
+            item = item.encode('utf-8')
+        elif isinstance(item, list):
+            item = _decode_list(item)
+        elif isinstance(item, dict):
+            item = _decode_dict(item)
+        rv.append(item)
+    return rv
+
+
+def _decode_dict(data):
+    '''convert items in a dict from unicode to basestring'''
+    rv = {}
+    for k, v in data.iteritems():
+        if isinstance(k, unicode):
+            k = k.encode('utf-8')
+        if isinstance(v, unicode):
+            v = v.encode('utf-8')
+        elif isinstance(v, list):
+            v = _decode_list(v)
+        elif isinstance(v, dict):
+            v = _decode_dict(v)
+        rv[k] = v
+    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:
+        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(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(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(mf, app):
+
+    tree = ElementTree.ElementTree(file=mf)
+    root = tree.getroot()
+
+    if root.tag != 'resources':
+        raise MetaDataException('resources file does not have root element <resources/>')
+
+    for child in root:
+        if child.tag != 'builds':
+            # builds does not have name="" attrib
+            name = child.attrib['name']
+
+        if child.tag == 'string':
+            app.set_field(name, child.text)
+        elif child.tag == 'string-array':
+            for item in child:
+                app.append_field(name, item.text)
+        elif child.tag == 'builds':
+            for b in child:
+                build = Build()
+                for key in b:
+                    build.set_flag(key.tag, key.text)
+                app.builds.append(build)
+
+    # TODO handle this using <xsd:element type="xsd:boolean> in a schema
+    if not isinstance(app.RequiresRoot, bool):
+        app.RequiresRoot = app.RequiresRoot == 'true'
+
+    return app
+
+
+def parse_yaml_metadata(mf, app):
+
+    yamlinfo = yaml.load(mf, Loader=YamlLoader)
+    app.update_fields(yamlinfo)
+    return app
+
+
+build_line_sep = re.compile(r'(?<!\\),')
+build_cont = re.compile(r'^[ \t]')
+
+
+def parse_txt_metadata(mf, app):
+
     linedesc = None
 
-    def add_buildflag(p, thisbuild):
+    def add_buildflag(p, build):
+        if not p.strip():
+            raise MetaDataException("Empty build flag at {1}"
+                                    .format(buildlines[0], linedesc))
         bv = p.split('=', 1)
         if len(bv) != 2:
             raise MetaDataException("Invalid build flag at {0} in {1}"
                                     .format(buildlines[0], linedesc))
-        pk, pv = bv
-        if pk in thisbuild:
-            raise MetaDataException("Duplicate definition on {0} in version {1} of {2}"
-                                    .format(pk, thisbuild['version'], linedesc))
 
+        pk, pv = bv
         pk = pk.lstrip()
-        if pk not in flag_defaults:
-            raise MetaDataException("Unrecognised build flag at {0} in {1}"
-                                    .format(p, linedesc))
         t = flagtype(pk)
-        if t == 'list':
-            # Port legacy ';' separators
-            pv = [v.strip() for v in pv.replace(';', ',').split(',')]
-            if pk == 'gradle':
-                if len(pv) == 1 and pv[0] in ['main', 'yes']:
-                    pv = ['yes']
-            thisbuild[pk] = pv
-        elif t == 'string' or t == 'script':
-            thisbuild[pk] = pv
-        elif t == 'bool':
-            value = pv == 'yes'
-            if value:
-                thisbuild[pk] = True
-            else:
-                logging.debug("...ignoring bool flag %s" % p)
-
-        else:
-            raise MetaDataException("Unrecognised build flag type '%s' at %s in %s"
-                                    % (t, p, linedesc))
+        if t == TYPE_LIST:
+            pv = split_list_values(pv)
+            build.set_flag(pk, pv)
+        elif t == TYPE_STRING or t == TYPE_SCRIPT:
+            build.set_flag(pk, pv)
+        elif t == TYPE_BOOL:
+            build.set_flag(pk, _decode_bool(pv))
 
     def parse_buildline(lines):
-        value = "".join(lines)
-        parts = [p.replace("\\,", ",")
-                 for p in re.split(r"(?<!\\),", value)]
+        v = "".join(lines)
+        parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
         if len(parts) < 3:
-            raise MetaDataException("Invalid build format: " + value + " in " + metafile.name)
-        thisbuild = {}
-        thisbuild['origlines'] = lines
-        thisbuild['version'] = parts[0]
-        thisbuild['vercode'] = parts[1]
+            raise MetaDataException("Invalid build format: " + v + " in " + mf.name)
+        build = Build()
+        build.version = parts[0]
+        build.vercode = parts[1]
         if parts[2].startswith('!'):
             # For backwards compatibility, handle old-style disabling,
             # including attempting to extract the commit from the message
-            thisbuild['disable'] = parts[2][1:]
+            build.disable = parts[2][1:]
             commit = 'unknown - see disabled'
             index = parts[2].rfind('at ')
             if index != -1:
                 commit = parts[2][index + 3:]
                 if commit.endswith(')'):
                     commit = commit[:-1]
-            thisbuild['commit'] = commit
+            build.commit = commit
         else:
-            thisbuild['commit'] = parts[2]
+            build.commit = parts[2]
         for p in parts[3:]:
-            add_buildflag(p, thisbuild)
+            add_buildflag(p, build)
 
-        return thisbuild
+        return build
 
     def add_comments(key):
         if not curcomments:
             return
-        for comment in curcomments:
-            thisinfo['comments'].append((key, comment))
+        app.comments[key] = list(curcomments)
         del curcomments[:]
 
-    thisinfo = {}
-    if metafile:
-        if not isinstance(metafile, file):
-            metafile = open(metafile, "r")
-        appid = metafile.name[9:-4]
-
-    thisinfo.update(app_defaults)
-    thisinfo['id'] = appid
-
-    # General defaults...
-    thisinfo['builds'] = []
-    thisinfo['comments'] = []
-
-    if metafile is None:
-        return appid, thisinfo
-
     mode = 0
     buildlines = []
+    multiline_lines = []
     curcomments = []
-    curbuild = None
-    vc_seen = {}
+    build = None
+    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')):
-                commit = curbuild['commit'] if 'commit' in curbuild else None
-                if not commit and 'disable' not in curbuild:
-                    raise MetaDataException("No commit specified for {0} in {1}"
-                                            .format(curbuild['version'], linedesc))
-
-                thisinfo['builds'].append(curbuild)
-                add_comments('build:' + curbuild['vercode'])
-                mode = 0
-            else:
+            if build_cont.match(line):
                 if line.endswith('\\'):
                     buildlines.append(line[:-1].lstrip())
                 else:
                     buildlines.append(line.lstrip())
                     bl = ''.join(buildlines)
-                    add_buildflag(bl, curbuild)
-                    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))
+
+                app.builds.append(build)
+                add_comments('build:' + build.vercode)
+                mode = 0
 
         if mode == 0:
             if not line:
                 continue
             if line.startswith("#"):
-                curcomments.append(line)
+                curcomments.append(line[1:].strip())
                 continue
             try:
-                field, value = line.split(':', 1)
+                f, v = line.split(':', 1)
             except ValueError:
                 raise MetaDataException("Invalid metadata in " + linedesc)
-            if field != field.strip() or value != value.strip():
-                raise MetaDataException("Extra spacing found in " + linedesc)
 
             # Translate obsolete fields...
-            if field == 'Market Version':
-                field = 'Current Version'
-            if field == 'Market Version Code':
-                field = 'Current Version Code'
-
-            fieldtype = metafieldtype(field)
-            if fieldtype not in ['build', 'buildv2']:
-                add_comments(field)
-            if fieldtype == 'multiline':
+            if f == 'Market Version':
+                f = 'Current Version'
+            if f == 'Market Version Code':
+                f = 'Current Version Code'
+
+            ftype = fieldtype(f)
+            if ftype not in [TYPE_BUILD, TYPE_BUILD_V2]:
+                add_comments(f)
+            if ftype == TYPE_MULTILINE:
                 mode = 1
-                thisinfo[field] = []
-                if value:
-                    raise MetaDataException("Unexpected text on same line as " + field + " in " + linedesc)
-            elif fieldtype == 'string':
-                thisinfo[field] = value
-            elif fieldtype == 'list':
-                thisinfo[field] = [v.strip() for v in value.replace(';', ',').split(',')]
-            elif fieldtype == 'build':
-                if value.endswith("\\"):
+                if v:
+                    raise MetaDataException("Unexpected text on same line as " + f + " in " + linedesc)
+            elif ftype == TYPE_STRING:
+                app.set_field(f, v)
+            elif ftype == TYPE_LIST:
+                app.set_field(f, split_list_values(v))
+            elif ftype == TYPE_BUILD:
+                if v.endswith("\\"):
                     mode = 2
-                    buildlines = [value[:-1]]
+                    del buildlines[:]
+                    buildlines.append(v[:-1])
                 else:
-                    curbuild = parse_buildline([value])
-                    thisinfo['builds'].append(curbuild)
-                    add_comments('build:' + thisinfo['builds'][-1]['vercode'])
-            elif fieldtype == 'buildv2':
-                curbuild = {}
-                vv = value.split(',')
+                    build = parse_buildline([v])
+                    app.builds.append(build)
+                    add_comments('build:' + app.builds[-1].vercode)
+            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(value, linedesc))
-                curbuild['version'] = vv[0]
-                curbuild['vercode'] = vv[1]
-                if curbuild['vercode'] in vc_seen:
+                                            .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' % (
-                                            curbuild['vercode'], linedesc))
-                vc_seen[curbuild['vercode']] = True
-                buildlines = []
+                                            build.vercode, linedesc))
+                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 " + field + " 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:
-                thisinfo[field].append(line)
+                multiline_lines.append(line)
         elif mode == 2:     # Line continuation mode in Build Version
             if line.endswith("\\"):
                 buildlines.append(line[:-1])
             else:
                 buildlines.append(line)
-                curbuild = parse_buildline(buildlines)
-                thisinfo['builds'].append(curbuild)
-                add_comments('build:' + thisinfo['builds'][-1]['vercode'])
+                build = parse_buildline(buildlines)
+                app.builds.append(build)
+                add_comments('build:' + app.builds[-1].vercode)
                 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(field + " not terminated in " + metafile.name)
-    elif mode == 2:
-        raise MetaDataException("Unterminated continuation in " + metafile.name)
-    elif mode == 3:
-        raise MetaDataException("Unterminated build in " + metafile.name)
+        raise MetaDataException(f + " not terminated in " + mf.name)
+    if mode == 2:
+        raise MetaDataException("Unterminated continuation in " + mf.name)
+    if mode == 3:
+        raise MetaDataException("Unterminated build in " + mf.name)
 
-    if not thisinfo['Description']:
-        thisinfo['Description'].append('No description available')
+    return app
 
-    for build in thisinfo['builds']:
-        fill_build_defaults(build)
 
-    thisinfo['builds'] = sorted(thisinfo['builds'], key=lambda build: int(build['vercode']))
+def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
 
-    return (appid, thisinfo)
-
-
-# Write a metadata file.
-#
-# 'dest'    - The path to the output file
-# 'app'     - The app data
-def write_metadata(dest, app):
-
-    def writecomments(key):
-        written = 0
-        for pf, comment in app['comments']:
-            if pf == key:
-                mf.write("%s\n" % comment)
-                written += 1
-        if written > 0:
-            logging.debug("...writing comments for " + (key or 'EOF'))
-
-    def writefield(field, value=None):
-        writecomments(field)
-        if value is None:
-            value = app[field]
-        t = metafieldtype(field)
-        if t == 'list':
-            value = ','.join(value)
-        mf.write("%s:%s\n" % (field, value))
-
-    def writefield_nonempty(field, value=None):
-        if value is None:
-            value = app[field]
-        if value:
-            writefield(field, value)
-
-    mf = open(dest, 'w')
-    writefield_nonempty('Disabled')
-    writefield_nonempty('AntiFeatures')
-    writefield_nonempty('Provides')
-    writefield('Categories')
-    writefield('License')
-    writefield('Web Site')
-    writefield('Source Code')
-    writefield('Issue Tracker')
-    writefield('Changelog')
-    writefield_nonempty('Donate')
-    writefield_nonempty('FlattrID')
-    writefield_nonempty('Bitcoin')
-    writefield_nonempty('Litecoin')
-    writefield_nonempty('Dogecoin')
+    def w_comments(key):
+        if key not in app.comments:
+            return
+        for line in app.comments[key]:
+            w_comment(line)
+
+    def w_field_always(f, v=None):
+        if v is None:
+            v = app.get_field(f)
+        w_comments(f)
+        w_field(f, v)
+
+    def w_field_nonempty(f, v=None):
+        if v is None:
+            v = app.get_field(f)
+        w_comments(f)
+        if v:
+            w_field(f, v)
+
+    w_field_nonempty('Disabled')
+    if app.AntiFeatures:
+        w_field_always('AntiFeatures')
+    w_field_nonempty('Provides')
+    w_field_always('Categories')
+    w_field_always('License')
+    w_field_always('Web Site')
+    w_field_always('Source Code')
+    w_field_always('Issue Tracker')
+    w_field_nonempty('Changelog')
+    w_field_nonempty('Donate')
+    w_field_nonempty('FlattrID')
+    w_field_nonempty('Bitcoin')
+    w_field_nonempty('Litecoin')
     mf.write('\n')
-    writefield_nonempty('Name')
-    writefield_nonempty('Auto Name')
-    writefield('Summary')
-    writefield('Description', '')
-    for line in app['Description']:
-        mf.write("%s\n" % line)
-    mf.write('.\n')
+    w_field_nonempty('Name')
+    w_field_nonempty('Auto Name')
+    w_field_always('Summary')
+    w_field_always('Description', description_txt(app.Description))
     mf.write('\n')
-    if app['Requires Root']:
-        writefield('Requires Root', 'Yes')
+    if app.RequiresRoot:
+        w_field_always('Requires Root', 'yes')
         mf.write('\n')
-    if app['Repo Type']:
-        writefield('Repo Type')
-        writefield('Repo')
-        if app['Binaries']:
-            writefield('Binaries')
+    if app.RepoType:
+        w_field_always('Repo Type')
+        w_field_always('Repo')
+        if app.Binaries:
+            w_field_always('Binaries')
         mf.write('\n')
-    for build in app['builds']:
 
-        if build['version'] == "Ignore":
+    for build in sorted_builds(app.builds):
+
+        if build.version == "Ignore":
             continue
 
-        writecomments('build:' + build['vercode'])
-        mf.write("Build:%s,%s\n" % (build['version'], build['vercode']))
+        w_comments('build:' + build.vercode)
+        w_build(build)
+        mf.write('\n')
+
+    if app.MaintainerNotes:
+        w_field_always('Maintainer Notes', app.MaintainerNotes)
+        mf.write('\n')
 
-        def write_builditem(key, value):
+    w_field_nonempty('Archive Policy')
+    w_field_always('Auto Update Mode')
+    w_field_always('Update Check Mode')
+    w_field_nonempty('Update Check Ignore')
+    w_field_nonempty('Vercode Operation')
+    w_field_nonempty('Update Check Name')
+    w_field_nonempty('Update Check Data')
+    if app.CurrentVersion:
+        w_field_always('Current Version')
+        w_field_always('Current Version Code')
+    if app.NoSourceSince:
+        mf.write('\n')
+        w_field_always('No Source Since')
+    w_comments(None)
 
-            if key in ['version', 'vercode']:
-                return
 
-            if value == flag_defaults[key]:
-                return
+# Write a metadata file in txt format.
+#
+# 'mf'      - Writer interface (file, StringIO, ...)
+# 'app'     - The app data
+def write_txt_metadata(mf, app):
 
-            t = flagtype(key)
+    def w_comment(line):
+        mf.write("# %s\n" % line)
 
-            logging.debug("...writing {0} : {1}".format(key, value))
-            outline = '    %s=' % key
+    def w_field(f, v):
+        t = fieldtype(f)
+        if t == TYPE_LIST:
+            v = ','.join(v)
+        elif t == TYPE_MULTILINE:
+            v = '\n' + v + '\n.'
+        mf.write("%s:%s\n" % (f, v))
 
-            if t == 'string':
-                outline += value
-            if t == 'bool':
-                outline += 'yes'
-            elif t == 'script':
-                outline += '&& \\\n        '.join([s.lstrip() for s in value.split('&& ')])
-            elif t == 'list':
-                outline += ','.join(value) if type(value) == list else value
+    def w_build(build):
+        mf.write("Build:%s,%s\n" % (build.version, build.vercode))
 
-            outline += '\n'
-            mf.write(outline)
+        for f in build_flags_order:
+            v = build.get_flag(f)
+            if not v:
+                continue
 
-        for flag in flag_defaults:
-            value = build[flag]
-            if value:
-                write_builditem(flag, value)
-        mf.write('\n')
+            t = flagtype(f)
+            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)
+
+
+def write_yaml_metadata(mf, app):
+
+    def w_comment(line):
+        mf.write("# %s\n" % line)
+
+    def escape(v):
+        if not v:
+            return ''
+        if any(c in v for c in [': ', '%', '@', '*']):
+            return "'" + v.replace("'", "''") + "'"
+        return v
+
+    def w_field(f, v, prefix='', t=None):
+        if t is None:
+            t = fieldtype(f)
+        v = ''
+        if t == TYPE_LIST:
+            v = '\n'
+            for e in v:
+                v += prefix + ' - ' + escape(e) + '\n'
+        elif t == TYPE_MULTILINE:
+            v = ' |\n'
+            for l in v.splitlines():
+                if l:
+                    v += prefix + '  ' + l + '\n'
+                else:
+                    v += '\n'
+        elif t == TYPE_BOOL:
+            v = ' yes\n'
+        elif t == TYPE_SCRIPT:
+            cmds = [s + '&& \\' for s in v.split('&& ')]
+            if len(cmds) > 0:
+                cmds[-1] = cmds[-1][:-len('&& \\')]
+            w_field(f, cmds, prefix, 'multiline')
+            return
+        else:
+            v = ' ' + escape(v) + '\n'
+
+        mf.write(prefix)
+        mf.write(f)
+        mf.write(":")
+        mf.write(v)
+
+    global first_build
+    first_build = True
+
+    def w_build(build):
+        global first_build
+        if first_build:
+            mf.write("builds:\n")
+            first_build = False
+
+        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:
+                continue
 
-    if app['Maintainer Notes']:
-        writefield('Maintainer Notes', '')
-        for line in app['Maintainer Notes']:
-            mf.write("%s\n" % line)
-        mf.write('.\n')
-        mf.write('\n')
+            w_field(f, v, '    ', flagtype(f))
 
-    writefield_nonempty('Archive Policy')
-    writefield('Auto Update Mode')
-    writefield('Update Check Mode')
-    writefield_nonempty('Update Check Ignore')
-    writefield_nonempty('Vercode Operation')
-    writefield_nonempty('Update Check Name')
-    writefield_nonempty('Update Check Data')
-    if app['Current Version']:
-        writefield('Current Version')
-        writefield('Current Version Code')
-    mf.write('\n')
-    if app['No Source Since']:
-        writefield('No Source Since')
-        mf.write('\n')
-    writecomments(None)
-    mf.close()
+    write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
+
+
+def write_metadata(fmt, mf, app):
+    if fmt == 'txt':
+        return write_txt_metadata(mf, app)
+    if fmt == 'yaml':
+        return write_yaml_metadata(mf, app)
+    raise MetaDataException("Unknown metadata format given")