chiark / gitweb /
check whether metadata file is a duplicate before parsing it
[fdroidserver.git] / fdroidserver / metadata.py
index 58fcb9b05f95d356102f77a44a48015eefba7059..9ba9b1fdef17d8a1ecb0acb9c2a9c5dd418d80c6 100644 (file)
@@ -1,4 +1,4 @@
-# -*- coding: utf-8 -*-
+#!/usr/bin/env python3
 #
 # metadata.py - part of the FDroid server tools
 # Copyright (C) 2013, Ciaran Gultnieks, ciaran@ciarang.com
@@ -22,12 +22,9 @@ import os
 import re
 import glob
 import cgi
+import logging
 import textwrap
-
-try:
-    from cStringIO import StringIO
-except:
-    from StringIO import StringIO
+import io
 
 import yaml
 # use libyaml if it is available
@@ -41,7 +38,7 @@ except ImportError:
 # use the C implementation when available
 import xml.etree.cElementTree as ElementTree
 
-import common
+import fdroidserver.common
 
 srclibs = None
 
@@ -62,6 +59,8 @@ app_fields = set([
     'Provides',
     'Categories',
     'License',
+    'Author Name',
+    'Author Email',
     'Web Site',
     'Source Code',
     'Issue Tracker',
@@ -103,6 +102,8 @@ class App():
         self.Provides = None
         self.Categories = ['None']
         self.License = 'Unknown'
+        self.AuthorName = None
+        self.AuthorEmail = None
         self.WebSite = ''
         self.SourceCode = ''
         self.IssueTracker = ''
@@ -128,7 +129,7 @@ class App():
         self.UpdateCheckName = None
         self.UpdateCheckData = None
         self.CurrentVersion = ''
-        self.CurrentVersionCode = '0'
+        self.CurrentVersionCode = None
         self.NoSourceSince = ''
 
         self.id = None
@@ -137,6 +138,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'
@@ -157,14 +159,15 @@ class App():
     # names. Should only be used for tests.
     def field_dict(self):
         d = {}
-        for k, v in self.__dict__.iteritems():
+        for k, v in self.__dict__.items():
             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__.items() 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'
@@ -180,6 +183,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):
@@ -193,7 +197,7 @@ class App():
 
     # Like dict.update(), but using human-readable field names
     def update_fields(self, d):
-        for f, v in d.iteritems():
+        for f, v in d.items():
             if f == 'builds':
                 for b in v:
                     build = Build()
@@ -223,7 +227,7 @@ fieldtypes = {
 }
 
 
-def metafieldtype(name):
+def fieldtype(name):
     if name in fieldtypes:
         return fieldtypes[name]
     return TYPE_STRING
@@ -275,7 +279,7 @@ class Build():
         self.submodules = False
         self.init = ''
         self.patch = []
-        self.gradle = False
+        self.gradle = []
         self.maven = False
         self.kivy = False
         self.output = None
@@ -287,7 +291,7 @@ class Build():
         self.rm = []
         self.extlibs = []
         self.prebuild = ''
-        self.update = None
+        self.update = []
         self.target = None
         self.scanignore = []
         self.scandelete = []
@@ -296,9 +300,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)
@@ -312,6 +318,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:
@@ -321,7 +328,7 @@ class Build():
         else:
             self.__dict__[f].append(v)
 
-    def method(self):
+    def build_method(self):
         for f in ['maven', 'gradle', 'kivy']:
             if self.get_flag(f):
                 return f
@@ -329,17 +336,26 @@ class Build():
             return 'raw'
         return 'ant'
 
+    # like build_method, but prioritize output=
+    def output_method(self):
+        if self.output:
+            return 'raw'
+        for f in ['maven', 'gradle', 'kivy']:
+            if self.get_flag(f):
+                return f
+        return 'ant'
+
     def ndk_path(self):
         version = self.ndk
         if not version:
             version = 'r10e'  # falls back to latest
-        paths = common.config['ndk_paths']
+        paths = fdroidserver.common.config['ndk_paths']
         if version not in paths:
             return ''
         return paths[version]
 
     def update_flags(self, d):
-        for f, v in d.iteritems():
+        for f, v in d.items():
             self.set_flag(f, v)
 
 flagtypes = {
@@ -382,102 +398,83 @@ def flagtype(name):
 #
 class FieldValidator():
 
-    def __init__(self, name, matching, sep, fields, flags):
+    def __init__(self, name, matching, fields, flags):
         self.name = name
         self.matching = matching
-        if type(matching) is str:
-            self.compiled = re.compile(matching)
-        self.sep = sep
+        self.compiled = re.compile(matching)
         self.fields = fields
         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))
-
-    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)))
-
     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:
-            self._assert_list(values, appid)
-        else:
-            self._assert_regex(values, appid)
-
+        for v in values:
+            if not self.compiled.match(v):
+                raise MetaDataException("'%s' is not a valid %s in %s. Regex pattern: %s"
+                                        % (v, self.name, appid, self.matching))
 
 # Generic value types
 valuetypes = {
     FieldValidator("Integer",
-                   r'^[1-9][0-9]*$', None,
+                   r'^[1-9][0-9]*$',
                    [],
                    ['vercode']),
 
     FieldValidator("Hexadecimal",
-                   r'^[0-9a-f]+$', None,
+                   r'^[0-9a-f]+$',
                    ['FlattrID'],
                    []),
 
     FieldValidator("HTTP link",
-                   r'^http[s]?://', None,
-                   ["Web Site", "Source Code", "Issue Tracker", "Changelog", "Donate"], []),
+                   r'^http[s]?://',
+                   ["WebSite", "SourceCode", "IssueTracker", "Changelog", "Donate"], []),
+
+    FieldValidator("Email",
+                   r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
+                   ["AuthorEmail"], []),
 
     FieldValidator("Bitcoin address",
-                   r'^[a-zA-Z0-9]{27,34}$', None,
+                   r'^[a-zA-Z0-9]{27,34}$',
                    ["Bitcoin"],
                    []),
 
     FieldValidator("Litecoin address",
-                   r'^L[a-zA-Z0-9]{33}$', None,
+                   r'^L[a-zA-Z0-9]{33}$',
                    ["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"],
+                   r'^(git|git-svn|svn|hg|bzr|srclib)$',
+                   ["RepoType"],
                    []),
 
     FieldValidator("Binaries",
-                   r'^http[s]?://', None,
+                   r'^http[s]?://',
                    ["Binaries"],
                    []),
 
     FieldValidator("Archive Policy",
-                   r'^[0-9]+ versions$', None,
-                   ["Archive Policy"],
+                   r'^[0-9]+ versions$',
+                   ["ArchivePolicy"],
                    []),
 
     FieldValidator("Anti-Feature",
-                   ["Ads", "Tracking", "NonFreeNet", "NonFreeDep", "NonFreeAdd", "UpstreamNonFree"], ',',
+                   r'^(Ads|Tracking|NonFreeNet|NonFreeDep|NonFreeAdd|UpstreamNonFree|NonFreeAssets)$',
                    ["AntiFeatures"],
                    []),
 
     FieldValidator("Auto Update Mode",
-                   r"^(Version .+|None)$", None,
-                   ["Auto Update Mode"],
+                   r"^(Version .+|None)$",
+                   ["AutoUpdateMode"],
                    []),
 
     FieldValidator("Update Check Mode",
-                   r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$", None,
-                   ["Update Check Mode"],
+                   r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$",
+                   ["UpdateCheckMode"],
                    [])
 }
 
@@ -485,11 +482,15 @@ 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
@@ -506,10 +507,11 @@ class DescriptionFormatter:
         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.html = io.StringIO()
+        self.text = io.StringIO()
         self.para_lines = []
         self.linkResolver = None
         self.linkResolver = linkres
@@ -525,24 +527,25 @@ 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.write(textwrap.fill(whole_para, 80,
-                                      break_long_words=False,
-                                      break_on_hyphens=False))
-        self.text.write('\n\n')
+        wrapped = textwrap.fill(whole_para, 80,
+                                break_long_words=False,
+                                break_on_hyphens=False)
+        self.text.write(wrapped)
         self.html.write('</p>')
         del self.para_lines[:]
 
     def endul(self):
         self.html.write('</ul>')
-        self.text.write('\n')
+        self.laststate = self.state
         self.state = self.stNONE
 
     def endol(self):
         self.html.write('</ol>')
-        self.text.write('\n')
+        self.laststate = self.state
         self.state = self.stNONE
 
     def formatted(self, txt, html):
@@ -622,21 +625,27 @@ class DescriptionFormatter:
             self.endcur()
         elif line.startswith('* '):
             self.endcur([self.stUL])
-            self.text.write(line)
-            self.text.write('\n')
             if self.state != self.stUL:
                 self.html.write('<ul>')
                 self.state = self.stUL
+                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.html.write('</li>')
         elif line.startswith('# '):
             self.endcur([self.stOL])
-            self.text.write(line)
-            self.text.write('\n')
             if self.state != self.stOL:
                 self.html.write('<ol>')
                 self.state = self.stOL
+                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.html.write('</li>')
@@ -644,12 +653,14 @@ class DescriptionFormatter:
             self.para_lines.append(line)
             self.endcur([self.stPARA])
             if self.state == self.stNONE:
-                self.html.write('<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.getvalue().rstrip()
+        self.text_txt = self.text.getvalue()
         self.text_html = self.html.getvalue()
         self.text.close()
         self.html.close()
@@ -695,7 +706,7 @@ def parse_srclib(metadatapath):
     if not os.path.exists(metadatapath):
         return thisinfo
 
-    metafile = open(metadatapath, "r")
+    metafile = open(metadatapath, "r", encoding='utf-8')
 
     n = 0
     for line in metafile:
@@ -768,10 +779,14 @@ def read_metadata(xref=True):
     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'))):
+                               + glob.glob(os.path.join('metadata', '*.yml'))
+                               + glob.glob('.fdroid.json')
+                               + glob.glob('.fdroid.xml')
+                               + glob.glob('.fdroid.yml')):
+        packageName, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
+        if packageName in apps:
+            raise MetaDataException("Found multiple metadata files for " + packageName)
         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
 
@@ -783,10 +798,10 @@ def read_metadata(xref=True):
                 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
             raise MetaDataException("Cannot resolve app id " + appid)
 
-        for appid, app in apps.iteritems():
+        for appid, app in apps.items():
             try:
                 description_html(app.Description, linkres)
-            except MetaDataException, e:
+            except MetaDataException as e:
                 raise MetaDataException("Problem with description of " + appid +
                                         " - " + str(e))
 
@@ -812,7 +827,26 @@ def get_default_app_info(metadatapath=None):
     if metadatapath is None:
         appid = None
     else:
-        appid, _ = common.get_extension(os.path.basename(metadatapath))
+        appid, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
+
+    if appid == '.fdroid':  # we have local metadata in the app's source
+        if os.path.exists('AndroidManifest.xml'):
+            manifestroot = fdroidserver.common.parse_xml('AndroidManifest.xml')
+        else:
+            pattern = re.compile(""".*manifest\.srcFile\s+'AndroidManifest\.xml'.*""")
+            for root, dirs, files in os.walk(os.getcwd()):
+                if 'build.gradle' in files:
+                    p = os.path.join(root, 'build.gradle')
+                    with open(p, 'rb') as f:
+                        data = f.read()
+                    m = pattern.search(data)
+                    if m:
+                        logging.debug('Using: ' + os.path.join(root, 'AndroidManifest.xml'))
+                        manifestroot = fdroidserver.common.parse_xml(os.path.join(root, 'AndroidManifest.xml'))
+                        break
+        if manifestroot is None:
+            raise MetaDataException("Cannot find a packageName for {0}!".format(metadatapath))
+        appid = manifestroot.attrib['package']
 
     app = App()
     app.metadatapath = metadatapath
@@ -832,13 +866,14 @@ 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():
+    for k in app._modified:
+        v = app.__dict__[k]
         if type(v) in (float, int):
             app.__dict__[k] = str(v)
 
     for build in app.builds:
-        for k, v in build.__dict__.iteritems():
-
+        for k in build._modified:
+            v = build.__dict__[k]
             if type(v) in (float, int):
                 build.__dict__[k] = str(v)
                 continue
@@ -848,9 +883,9 @@ def post_metadata_parse(app):
                 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) and v == 'true':
-                    build.__dict__[k] = True
-            elif ftype == TYPE_BOOL:
+                if isinstance(v, str):
+                    build.__dict__[k] = _decode_bool(v)
+            elif ftype == TYPE_STRING:
                 if isinstance(v, bool) and v:
                     build.__dict__[k] = 'yes'
 
@@ -886,71 +921,51 @@ def post_metadata_parse(app):
 #
 
 
-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']
+    _, ext = fdroidserver.common.get_extension(metadatapath)
+    accepted = fdroidserver.common.config['accepted_formats']
     if ext not in accepted:
         raise MetaDataException('"%s" is not an accepted format, convert to: %s' % (
             metadatapath, ', '.join(accepted)))
 
-    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:
-        raise MetaDataException('Unknown metadata format: %s' % metadatapath)
+    app = App()
+    app.metadatapath = metadatapath
+    app.id, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
+
+    with open(metadatapath, 'r', encoding='utf-8') 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 == 'yml':
+            parse_yaml_metadata(mf, app)
+        else:
+            raise MetaDataException('Unknown metadata format: %s' % metadatapath)
 
     post_metadata_parse(app)
     return app
 
 
-def parse_json_metadata(metadatapath):
+def parse_json_metadata(mf, app):
 
-    app = get_default_app_info(metadatapath)
-
-    # fdroid metadata is only strings and booleans, no floats or ints. And
-    # json returns unicode, and fdroidserver still uses plain python strings
+    # fdroid metadata is only strings and booleans, no floats or ints.
     # TODO create schema using https://pypi.python.org/pypi/jsonschema
-    jsoninfo = None
-    with open(metadatapath, 'r') as f:
-        jsoninfo = json.load(f, object_hook=_decode_dict,
-                             parse_int=lambda s: s,
-                             parse_float=lambda s: s)
+    jsoninfo = json.load(mf, 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)
@@ -958,15 +973,13 @@ def parse_json_metadata(metadatapath):
     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':
-        raise MetaDataException('%s does not have root as <resources></resources>!' % metadatapath)
+        raise MetaDataException('resources file does not have root element <resources/>')
 
     for child in root:
         if child.tag != 'builds':
@@ -992,13 +1005,9 @@ def parse_xml_metadata(metadatapath):
     return app
 
 
-def parse_yaml_metadata(metadatapath):
+def parse_yaml_metadata(mf, app):
 
-    app = get_default_app_info(metadatapath)
-
-    yamlinfo = None
-    with open(metadatapath, 'r') as f:
-        yamlinfo = yaml.load(f, Loader=YamlLoader)
+    yamlinfo = yaml.load(mf, Loader=YamlLoader)
     app.update_fields(yamlinfo)
     return app
 
@@ -1007,7 +1016,7 @@ 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
 
@@ -1029,16 +1038,14 @@ def parse_txt_metadata(metadatapath):
         elif t == TYPE_STRING or t == TYPE_SCRIPT:
             build.set_flag(pk, pv)
         elif t == TYPE_BOOL:
-            if pv == 'yes':
-                build.set_flag(pk, True)
+            build.set_flag(pk, _decode_bool(pv))
 
     def parse_buildline(lines):
         v = "".join(lines)
         parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
         if len(parts) < 3:
-            raise MetaDataException("Invalid build format: " + v + " in " + metafile.name)
+            raise MetaDataException("Invalid build format: " + v + " in " + mf.name)
         build = Build()
-        build.origlines = lines
         build.version = parts[0]
         build.vercode = parts[1]
         if parts[2].startswith('!'):
@@ -1065,9 +1072,6 @@ 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 = []
@@ -1076,9 +1080,9 @@ def parse_txt_metadata(metadatapath):
     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 build_cont.match(line):
@@ -1115,7 +1119,7 @@ def parse_txt_metadata(metadatapath):
             if f == 'Market Version Code':
                 f = 'Current Version Code'
 
-            ftype = metafieldtype(f)
+            ftype = fieldtype(f)
             if ftype not in [TYPE_BUILD, TYPE_BUILD_V2]:
                 add_comments(f)
             if ftype == TYPE_MULTILINE:
@@ -1170,15 +1174,14 @@ def parse_txt_metadata(metadatapath):
                 add_comments('build:' + app.builds[-1].vercode)
                 mode = 0
     add_comments(None)
-    metafile.close()
 
     # Mode at end of file should always be 0
     if mode == 1:
-        raise MetaDataException(f + " not terminated in " + metafile.name)
+        raise MetaDataException(f + " not terminated in " + mf.name)
     if mode == 2:
-        raise MetaDataException("Unterminated continuation in " + metafile.name)
+        raise MetaDataException("Unterminated continuation in " + mf.name)
     if mode == 3:
-        raise MetaDataException("Unterminated build in " + metafile.name)
+        raise MetaDataException("Unterminated build in " + mf.name)
 
     return app
 
@@ -1205,11 +1208,12 @@ def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
             w_field(f, v)
 
     w_field_nonempty('Disabled')
-    if app.AntiFeatures:
-        w_field_always('AntiFeatures')
+    w_field_nonempty('AntiFeatures')
     w_field_nonempty('Provides')
     w_field_always('Categories')
     w_field_always('License')
+    w_field_nonempty('Author Name')
+    w_field_nonempty('Author Email')
     w_field_always('Web Site')
     w_field_always('Source Code')
     w_field_always('Issue Tracker')
@@ -1234,7 +1238,7 @@ def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
             w_field_always('Binaries')
         mf.write('\n')
 
-    for build in sorted_builds(app.builds):
+    for build in app.builds:
 
         if build.version == "Ignore":
             continue
@@ -1267,13 +1271,13 @@ def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
 #
 # 'mf'      - Writer interface (file, StringIO, ...)
 # 'app'     - The app data
-def write_txt_metadata(mf, app):
+def write_txt(mf, app):
 
     def w_comment(line):
         mf.write("# %s\n" % line)
 
     def w_field(f, v):
-        t = metafieldtype(f)
+        t = fieldtype(f)
         if t == TYPE_LIST:
             v = ','.join(v)
         elif t == TYPE_MULTILINE:
@@ -1289,23 +1293,28 @@ def write_txt_metadata(mf, app):
                 continue
 
             t = flagtype(f)
-            out = '    %s=' % f
+            mf.write('    %s=' % f)
             if t == TYPE_STRING:
-                out += v
+                mf.write(v)
             elif t == TYPE_BOOL:
-                out += 'yes'
+                mf.write('yes')
             elif t == TYPE_SCRIPT:
-                out += '&& \\\n        '.join([s.lstrip() for s in v.split('&& ')])
+                first = True
+                for s in v.split(' && '):
+                    if first:
+                        first = False
+                    else:
+                        mf.write(' && \\\n        ')
+                    mf.write(s)
             elif t == TYPE_LIST:
-                out += ','.join(v) if type(v) == list else v
+                mf.write(','.join(v))
 
-            mf.write(out)
             mf.write('\n')
 
     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
 
 
-def write_yaml_metadata(mf, app):
+def write_yaml(mf, app):
 
     def w_comment(line):
         mf.write("# %s\n" % line)
@@ -1319,7 +1328,7 @@ 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 == TYPE_LIST:
             v = '\n'
@@ -1369,9 +1378,16 @@ def write_yaml_metadata(mf, app):
     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")
+def write_metadata(metadatapath, app):
+    _, ext = fdroidserver.common.get_extension(metadatapath)
+    accepted = fdroidserver.common.config['accepted_formats']
+    if ext not in accepted:
+        raise MetaDataException('Cannot write "%s", not an accepted format, use: %s' % (
+            metadatapath, ', '.join(accepted)))
+
+    with open(metadatapath, 'w', encoding='utf8') as mf:
+        if ext == 'txt':
+            return write_txt(mf, app)
+        elif ext == 'yml':
+            return write_yaml(mf, app)
+    raise MetaDataException('Unknown metadata format: %s' % metadatapath)