chiark / gitweb /
Add Liberapay support
[fdroidserver.git] / fdroidserver / metadata.py
index 6ca9aace4ab13797592332b56bb5163ba65da162..3c03df728ff60cc17bfd99f8b641903aafc0806c 100644 (file)
@@ -25,8 +25,8 @@ import html
 import logging
 import textwrap
 import io
-
 import yaml
+from collections import OrderedDict
 # use libyaml if it is available
 try:
     from yaml import CLoader
@@ -36,7 +36,8 @@ except ImportError:
     YamlLoader = Loader
 
 import fdroidserver.common
-from fdroidserver.exception import MetaDataException
+from fdroidserver import _
+from fdroidserver.exception import MetaDataException, FDroidException
 
 srclibs = None
 warnings_action = None
@@ -49,7 +50,7 @@ def warn_or_exception(value):
     elif warnings_action == 'error':
         raise MetaDataException(value)
     else:
-        logging.warn(value)
+        logging.warning(value)
 
 
 # To filter which ones should be written to the metadata files if
@@ -69,6 +70,7 @@ app_fields = set([
     'Changelog',
     'Donate',
     'FlattrID',
+    'LiberapayID',
     'Bitcoin',
     'Litecoin',
     'Name',
@@ -119,6 +121,7 @@ class App(dict):
         self.Changelog = ''
         self.Donate = None
         self.FlattrID = None
+        self.LiberapayID = None
         self.Bitcoin = None
         self.Litecoin = None
         self.Name = None
@@ -179,6 +182,7 @@ TYPE_SCRIPT = 5
 TYPE_MULTILINE = 6
 TYPE_BUILD = 7
 TYPE_BUILD_V2 = 8
+TYPE_INT = 9
 
 fieldtypes = {
     'Description': TYPE_MULTILINE,
@@ -204,11 +208,13 @@ build_flags_order = [
     'commit',
     'subdir',
     'submodules',
+    'sudo',
     'init',
     'patch',
     'gradle',
     'maven',
     'kivy',
+    'buildozer',
     'output',
     'srclibs',
     'oldsdkloc',
@@ -229,6 +235,7 @@ build_flags_order = [
     'gradleprops',
     'antcommands',
     'novcheck',
+    'antifeatures',
 ]
 
 # old .txt format has version name/code inline in the 'Build:' line
@@ -244,11 +251,13 @@ class Build(dict):
         self.commit = None
         self.subdir = None
         self.submodules = False
+        self.sudo = ''
         self.init = ''
         self.patch = []
         self.gradle = []
         self.maven = False
         self.kivy = False
+        self.buildozer = False
         self.output = None
         self.srclibs = []
         self.oldsdkloc = False
@@ -269,6 +278,7 @@ class Build(dict):
         self.gradleprops = []
         self.antcommands = []
         self.novcheck = False
+        self.antifeatures = []
         if copydict:
             super().__init__(copydict)
             return
@@ -289,7 +299,7 @@ class Build(dict):
             raise AttributeError("No such attribute: " + name)
 
     def build_method(self):
-        for f in ['maven', 'gradle', 'kivy']:
+        for f in ['maven', 'gradle', 'kivy', 'buildozer']:
             if self.get(f):
                 return f
         if self.output:
@@ -300,7 +310,7 @@ class Build(dict):
     def output_method(self):
         if self.output:
             return 'raw'
-        for f in ['maven', 'gradle', 'kivy']:
+        for f in ['maven', 'gradle', 'kivy', 'buildozer']:
             if self.get(f):
                 return f
         return 'ant'
@@ -316,6 +326,7 @@ class Build(dict):
 
 
 flagtypes = {
+    'versionCode': TYPE_INT,
     'extlibs': TYPE_LIST,
     'srclibs': TYPE_LIST,
     'patch': TYPE_LIST,
@@ -328,6 +339,7 @@ flagtypes = {
     'gradle': TYPE_LIST,
     'antcommands': TYPE_LIST,
     'gradleprops': TYPE_LIST,
+    'sudo': TYPE_SCRIPT,
     'init': TYPE_SCRIPT,
     'prebuild': TYPE_SCRIPT,
     'build': TYPE_SCRIPT,
@@ -336,6 +348,7 @@ flagtypes = {
     'forceversion': TYPE_BOOL,
     'forcevercode': TYPE_BOOL,
     'novcheck': TYPE_BOOL,
+    'antifeatures': TYPE_LIST,
 }
 
 
@@ -370,16 +383,20 @@ class FieldValidator():
             values = [v]
         for v in values:
             if not self.compiled.match(v):
-                warn_or_exception("'%s' is not a valid %s in %s. Regex pattern: %s"
-                                  % (v, self.name, appid, self.matching))
+                warn_or_exception(_("'{value}' is not a valid {field} in {appid}. Regex pattern: {pattern}")
+                                  .format(value=v, field=self.name, appid=appid, pattern=self.matching))
 
 
 # Generic value types
 valuetypes = {
-    FieldValidator("Hexadecimal",
-                   r'^[0-9a-f]+$',
+    FieldValidator("Flattr ID",
+                   r'^[0-9a-z]+$',
                    ['FlattrID']),
 
+    FieldValidator("Liberapay ID",
+                   r'^[0-9]+$',
+                   ['LiberapayID']),
+
     FieldValidator("HTTP link",
                    r'^http[s]?://',
                    ["WebSite", "SourceCode", "IssueTracker", "Changelog", "Donate"]),
@@ -409,7 +426,7 @@ valuetypes = {
                    ["ArchivePolicy"]),
 
     FieldValidator("Anti-Feature",
-                   r'^(Ads|Tracking|NonFreeNet|NonFreeDep|NonFreeAdd|UpstreamNonFree|NonFreeAssets|KnownVuln)$',
+                   r'^(Ads|Tracking|NonFreeNet|NonFreeDep|NonFreeAdd|UpstreamNonFree|NonFreeAssets|KnownVuln|ApplicationDebuggable)$',
                    ["AntiFeatures"]),
 
     FieldValidator("Auto Update Mode",
@@ -524,7 +541,7 @@ class DescriptionFormatter:
             if txt.startswith("[["):
                 index = txt.find("]]")
                 if index == -1:
-                    warn_or_exception("Unterminated ]]")
+                    warn_or_exception(_("Unterminated ]]"))
                 url = txt[2:index]
                 if self.linkResolver:
                     url, urltext = self.linkResolver(url)
@@ -536,7 +553,7 @@ class DescriptionFormatter:
             else:
                 index = txt.find("]")
                 if index == -1:
-                    warn_or_exception("Unterminated ]")
+                    warn_or_exception(_("Unterminated ]"))
                 url = txt[1:index]
                 index2 = url.find(' ')
                 if index2 == -1:
@@ -545,7 +562,7 @@ class DescriptionFormatter:
                     urltxt = url[index2 + 1:]
                     url = url[:index2]
                     if url == urltxt:
-                        warn_or_exception("Url title is just the URL - use [url]")
+                        warn_or_exception(_("URL title is just the URL, use brackets: [URL]"))
                 res_html += '<a href="' + url + '">' + html.escape(urltxt, quote=False) + '</a>'
                 res_plain += urltxt
                 if urltxt != url:
@@ -654,7 +671,7 @@ def parse_srclib(metadatapath):
         try:
             f, v = line.split(':', 1)
         except ValueError:
-            warn_or_exception("Invalid metadata in %s:%d" % (line, n))
+            warn_or_exception(_("Invalid metadata in %s:%d") % (line, n))
 
         if f == "Subdir":
             thisinfo[f] = v.split(',')
@@ -693,37 +710,56 @@ def read_srclibs():
         srclibs[srclibname] = parse_srclib(metadatapath)
 
 
-def read_metadata(xref=True, check_vcs=[]):
-    """
-    Read all metadata. Returns a list of 'app' objects (which are dictionaries as
-    returned by the parse_txt_metadata function.
+def read_metadata(xref=True, check_vcs=[], sort_by_time=False):
+    """Return a list of App instances sorted newest first
+
+    This reads all of the metadata files in a 'data' repository, then
+    builds a list of App instances from those files.  The list is
+    sorted based on creation time, newest first.  Most of the time,
+    the newer files are the most interesting.
+
+    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.
 
     check_vcs is the list of packageNames to check for .fdroid.yml in source
+
     """
 
     # Always read the srclibs before the apps, since they can use a srlib as
     # their source repository.
     read_srclibs()
 
-    apps = {}
+    apps = OrderedDict()
 
     for basedir in ('metadata', 'tmp'):
         if not os.path.exists(basedir):
             os.makedirs(basedir)
 
-    # 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', '*.yml'))
-                               + glob.glob('.fdroid.json')
-                               + glob.glob('.fdroid.yml')):
-        packageName, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
+    metadatafiles = (glob.glob(os.path.join('metadata', '*.txt'))
+                     + glob.glob(os.path.join('metadata', '*.json'))
+                     + glob.glob(os.path.join('metadata', '*.yml'))
+                     + glob.glob('.fdroid.txt')
+                     + glob.glob('.fdroid.json')
+                     + glob.glob('.fdroid.yml'))
+
+    if sort_by_time:
+        entries = ((os.stat(path).st_mtime, path) for path in metadatafiles)
+        metadatafiles = []
+        for _ignored, path in sorted(entries, reverse=True):
+            metadatafiles.append(path)
+    else:
+        # most things want the index alpha sorted for stability
+        metadatafiles = sorted(metadatafiles)
+
+    for metadatapath in metadatafiles:
+        if metadatapath == '.fdroid.txt':
+            warn_or_exception(_('.fdroid.txt is not supported!  Convert to .fdroid.yml or .fdroid.json.'))
+        packageName, _ignored = fdroidserver.common.get_extension(os.path.basename(metadatapath))
         if packageName in apps:
-            warn_or_exception("Found multiple metadata files for " + packageName)
+            warn_or_exception(_("Found multiple metadata files for {appid}")
+                              .format(path=packageName))
         app = parse_metadata(metadatapath, packageName in check_vcs)
         check_metadata(app)
         apps[app.id] = app
@@ -734,14 +770,14 @@ def read_metadata(xref=True, check_vcs=[]):
         def linkres(appid):
             if appid in apps:
                 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
-            warn_or_exception("Cannot resolve app id " + appid)
+            warn_or_exception(_("Cannot resolve app id {appid}").format(appid=appid))
 
         for appid, app in apps.items():
             try:
                 description_html(app.Description, linkres)
             except MetaDataException as e:
-                warn_or_exception("Problem with description of " + appid +
-                                  " - " + str(e))
+                warn_or_exception(_("Problem with description of {appid}: {error}")
+                                  .format(appid=appid, error=str(e)))
 
     return apps
 
@@ -766,7 +802,7 @@ def get_default_app_info(metadatapath=None):
     if metadatapath is None:
         appid = None
     else:
-        appid, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
+        appid, _ignored = 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'):
@@ -784,7 +820,8 @@ def get_default_app_info(metadatapath=None):
                         manifestroot = fdroidserver.common.parse_xml(os.path.join(root, 'AndroidManifest.xml'))
                         break
         if manifestroot is None:
-            warn_or_exception("Cannot find a packageName for {0}!".format(metadatapath))
+            warn_or_exception(_("Cannot find a packageName for {path}!")
+                              .format(path=metadatapath))
         appid = manifestroot.attrib['package']
 
     app = App()
@@ -808,6 +845,12 @@ def post_metadata_parse(app):
         if type(v) in (float, int):
             app[k] = str(v)
 
+    if 'Builds' in app:
+        app['builds'] = app.pop('Builds')
+
+    if 'flavours' in app and app['flavours'] == [True]:
+        app['flavours'] = 'yes'
+
     if isinstance(app.Categories, str):
         app.Categories = [app.Categories]
     elif app.Categories is None:
@@ -815,22 +858,49 @@ def post_metadata_parse(app):
     else:
         app.Categories = [str(i) for i in app.Categories]
 
+    def _yaml_bool_unmapable(v):
+        return v in (True, False, [True], [False])
+
+    def _yaml_bool_unmap(v):
+        if v is True:
+            return 'yes'
+        elif v is False:
+            return 'no'
+        elif v == [True]:
+            return ['yes']
+        elif v == [False]:
+            return ['no']
+
+    _bool_allowed = ('disable', 'kivy', 'maven', 'buildozer')
+
     builds = []
     if 'builds' in app:
         for build in app['builds']:
             if not isinstance(build, Build):
                 build = Build(build)
             for k, v in build.items():
-                if flagtype(k) == TYPE_LIST:
-                    if isinstance(v, str):
-                        build[k] = [v]
-                    elif isinstance(v, bool):
-                        if v:
-                            build[k] = ['yes']
+                if not (v is None):
+                    if flagtype(k) == TYPE_LIST:
+                        if _yaml_bool_unmapable(v):
+                            build[k] = _yaml_bool_unmap(v)
+
+                        if isinstance(v, str):
+                            build[k] = [v]
+                        elif isinstance(v, bool):
+                            if v:
+                                build[k] = ['yes']
+                            else:
+                                build[k] = []
+                    elif flagtype(k) is TYPE_INT:
+                        build[k] = str(v)
+                    elif flagtype(k) is TYPE_STRING:
+                        if isinstance(v, bool) and k in _bool_allowed:
+                            build[k] = v
                         else:
-                            build[k] = []
-                elif flagtype(k) == TYPE_STRING and type(v) in (float, int):
-                    build[k] = str(v)
+                            if _yaml_bool_unmapable(v):
+                                build[k] = _yaml_bool_unmap(v)
+                            else:
+                                build[k] = str(v)
             builds.append(build)
 
     app.builds = sorted_builds(builds)
@@ -871,21 +941,21 @@ def _decode_bool(s):
         return True
     if bool_false.match(s):
         return False
-    warn_or_exception("Invalid bool '%s'" % s)
+    warn_or_exception(_("Invalid boolean '%s'") % s)
 
 
 def parse_metadata(metadatapath, check_vcs=False):
     '''parse metadata file, optionally checking the git repo for metadata first'''
 
-    _, ext = fdroidserver.common.get_extension(metadatapath)
+    _ignored, ext = fdroidserver.common.get_extension(metadatapath)
     accepted = fdroidserver.common.config['accepted_formats']
     if ext not in accepted:
-        warn_or_exception('"%s" is not an accepted format, convert to: %s' % (
-            metadatapath, ', '.join(accepted)))
+        warn_or_exception(_('"{path}" is not an accepted format, convert to: {formats}')
+                          .format(path=metadatapath, formats=', '.join(accepted)))
 
     app = App()
     app.metadatapath = metadatapath
-    name, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
+    name, _ignored = fdroidserver.common.get_extension(os.path.basename(metadatapath))
     if name == '.fdroid':
         check_vcs = False
     else:
@@ -899,7 +969,8 @@ def parse_metadata(metadatapath, check_vcs=False):
         elif ext == 'yml':
             parse_yaml_metadata(mf, app)
         else:
-            warn_or_exception('Unknown metadata format: %s' % metadatapath)
+            warn_or_exception(_('Unknown metadata format: {path}')
+                              .format(path=metadatapath))
 
     if check_vcs and app.Repo:
         build_dir = fdroidserver.common.get_build_dir(app)
@@ -926,7 +997,7 @@ def parse_metadata(metadatapath, check_vcs=False):
             else:
                 root_dir = '.'
             paths = fdroidserver.common.manifest_paths(root_dir, build.gradle)
-            _, _, app.id = fdroidserver.common.parse_androidmanifests(paths, app)
+            _ignored, _ignored, app.id = fdroidserver.common.parse_androidmanifests(paths, app)
 
     return app
 
@@ -946,29 +1017,167 @@ def parse_json_metadata(mf, app):
 
 
 def parse_yaml_metadata(mf, app):
-
-    yamlinfo = yaml.load(mf, Loader=YamlLoader)
-    app.update(yamlinfo)
+    yamldata = yaml.load(mf, Loader=YamlLoader)
+    if yamldata:
+        app.update(yamldata)
     return app
 
 
 def write_yaml(mf, app):
 
+    # import rumael.yaml and check version
+    try:
+        import ruamel.yaml
+    except ImportError as e:
+        raise FDroidException('ruamel.yaml not instlled, can not write metadata.') from e
+    if not ruamel.yaml.__version__:
+        raise FDroidException('ruamel.yaml.__version__ not accessible. Please make sure a ruamel.yaml >= 0.13 is installed..')
+    m = re.match('(?P<major>[0-9]+)\.(?P<minor>[0-9]+)\.(?P<patch>[0-9]+)(-.+)?',
+                 ruamel.yaml.__version__)
+    if not m:
+        raise FDroidException('ruamel.yaml version malfored, please install an upstream version of ruamel.yaml')
+    if int(m.group('major')) < 0 or int(m.group('minor')) < 13:
+        raise FDroidException('currently installed version of ruamel.yaml ({}) is too old, >= 1.13 required.'.format(ruamel.yaml.__version__))
+    # suiteable version ruamel.yaml imported successfully
+
+    _yaml_bools_true = ('y', 'Y', 'yes', 'Yes', 'YES',
+                        'true', 'True', 'TRUE',
+                        'on', 'On', 'ON')
+    _yaml_bools_false = ('n', 'N', 'no', 'No', 'NO',
+                         'false', 'False', 'FALSE',
+                         'off', 'Off', 'OFF')
+    _yaml_bools_plus_lists = []
+    _yaml_bools_plus_lists.extend(_yaml_bools_true)
+    _yaml_bools_plus_lists.extend([[x] for x in _yaml_bools_true])
+    _yaml_bools_plus_lists.extend(_yaml_bools_false)
+    _yaml_bools_plus_lists.extend([[x] for x in _yaml_bools_false])
+
     def _class_as_dict_representer(dumper, data):
         '''Creates a YAML representation of a App/Build instance'''
         return dumper.represent_dict(data)
 
-    empty_keys = [k for k, v in app.items() if not v]
-    for k in empty_keys:
-        del app[k]
-
-    for k in ['added', 'lastUpdated', 'id', 'metadatapath']:
-        if k in app:
-            del app[k]
-
-    yaml.add_representer(fdroidserver.metadata.App, _class_as_dict_representer)
-    yaml.add_representer(fdroidserver.metadata.Build, _class_as_dict_representer)
-    yaml.dump(app, mf, default_flow_style=False)
+    def _field_to_yaml(typ, value):
+        if typ is TYPE_STRING:
+            if value in _yaml_bools_plus_lists:
+                return ruamel.yaml.scalarstring.SingleQuotedScalarString(str(value))
+            return str(value)
+        elif typ is TYPE_INT:
+            return int(value)
+        elif typ is TYPE_MULTILINE:
+            if '\n' in value:
+                return ruamel.yaml.scalarstring.preserve_literal(str(value))
+            else:
+                return str(value)
+        elif typ is TYPE_SCRIPT:
+            if len(value) > 50:
+                return ruamel.yaml.scalarstring.preserve_literal(value)
+            else:
+                return value
+        else:
+            return value
+
+    def _app_to_yaml(app):
+        cm = ruamel.yaml.comments.CommentedMap()
+        insert_newline = False
+        for field in yaml_app_field_order:
+            if field is '\n':
+                # next iteration will need to insert a newline
+                insert_newline = True
+            else:
+                if app.get(field) or field is 'Builds':
+                    # .txt calls it 'builds' internally, everywhere else its 'Builds'
+                    if field is 'Builds':
+                        if app.get('builds'):
+                            cm.update({field: _builds_to_yaml(app)})
+                    elif field is 'CurrentVersionCode':
+                        cm.update({field: _field_to_yaml(TYPE_INT, getattr(app, field))})
+                    else:
+                        cm.update({field: _field_to_yaml(fieldtype(field), getattr(app, field))})
+
+                    if insert_newline:
+                        # we need to prepend a newline in front of this field
+                        insert_newline = False
+                        # inserting empty lines is not supported so we add a
+                        # bogus comment and over-write its value
+                        cm.yaml_set_comment_before_after_key(field, 'bogus')
+                        cm.ca.items[field][1][-1].value = '\n'
+        return cm
+
+    def _builds_to_yaml(app):
+        fields = ['versionName', 'versionCode']
+        fields.extend(build_flags_order)
+        builds = ruamel.yaml.comments.CommentedSeq()
+        for build in app.builds:
+            b = ruamel.yaml.comments.CommentedMap()
+            for field in fields:
+                if hasattr(build, field) and getattr(build, field):
+                    value = getattr(build, field)
+                    if field == 'gradle' and value == ['off']:
+                        value = [ruamel.yaml.scalarstring.SingleQuotedScalarString('off')]
+                    if field in ('disable', 'kivy', 'maven', 'buildozer'):
+                        if value == 'no':
+                            continue
+                        elif value == 'yes':
+                            value = 'yes'
+                    b.update({field: _field_to_yaml(flagtype(field), value)})
+            builds.append(b)
+
+        # insert extra empty lines between build entries
+        for i in range(1, len(builds)):
+            builds.yaml_set_comment_before_after_key(i, 'bogus')
+            builds.ca.items[i][1][-1].value = '\n'
+
+        return builds
+
+    yaml_app_field_order = [
+        'Disabled',
+        'AntiFeatures',
+        'Provides',
+        'Categories',
+        'License',
+        'AuthorName',
+        'AuthorEmail',
+        'AuthorWebSite',
+        'WebSite',
+        'SourceCode',
+        'IssueTracker',
+        'Changelog',
+        'Donate',
+        'FlattrID',
+        'LiberapayID',
+        'Bitcoin',
+        'Litecoin',
+        '\n',
+        'Name',
+        'AutoName',
+        'Summary',
+        'Description',
+        '\n',
+        'RequiresRoot',
+        '\n',
+        'RepoType',
+        'Repo',
+        'Binaries',
+        '\n',
+        'Builds',
+        '\n',
+        'MaintainerNotes',
+        '\n',
+        'ArchivePolicy',
+        'AutoUpdateMode',
+        'UpdateCheckMode',
+        'UpdateCheckIgnore',
+        'VercodeOperation',
+        'UpdateCheckName',
+        'UpdateCheckData',
+        'CurrentVersion',
+        'CurrentVersionCode',
+        '\n',
+        'NoSourceSince',
+    ]
+
+    yaml_app = _app_to_yaml(app)
+    ruamel.yaml.round_trip_dump(yaml_app, mf, indent=4, block_seq_indent=2)
 
 
 build_line_sep = re.compile(r'(?<!\\),')
@@ -981,12 +1190,12 @@ def parse_txt_metadata(mf, app):
 
     def add_buildflag(p, build):
         if not p.strip():
-            warn_or_exception("Empty build flag at {1}"
-                              .format(buildlines[0], linedesc))
+            warn_or_exception(_("Empty build flag at {linedesc}")
+                              .format(linedesc=linedesc))
         bv = p.split('=', 1)
         if len(bv) != 2:
-            warn_or_exception("Invalid build flag at {0} in {1}"
-                              .format(buildlines[0], linedesc))
+            warn_or_exception(_("Invalid build flag at {line} in {linedesc}")
+                              .format(line=buildlines[0], linedesc=linedesc))
 
         pk, pv = bv
         pk = pk.lstrip()
@@ -1005,7 +1214,8 @@ def parse_txt_metadata(mf, app):
         v = "".join(lines)
         parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
         if len(parts) < 3:
-            warn_or_exception("Invalid build format: " + v + " in " + mf.name)
+            warn_or_exception(_("Invalid build format: {value} in {name}")
+                              .format(value=v, name=mf.name))
         build = Build()
         build.versionName = parts[0]
         build.versionCode = parts[1]
@@ -1033,7 +1243,8 @@ def parse_txt_metadata(mf, app):
         try:
             int(versionCode)
         except ValueError:
-            warn_or_exception('Invalid versionCode: "' + versionCode + '" is not an integer!')
+            warn_or_exception(_('Invalid versionCode: "{versionCode}" is not an integer!')
+                              .format(versionCode=versionCode))
 
     def add_comments(key):
         if not curcomments:
@@ -1066,8 +1277,8 @@ def parse_txt_metadata(mf, app):
                     del buildlines[:]
             else:
                 if not build.commit and not build.disable:
-                    warn_or_exception("No commit specified for {0} in {1}"
-                                      .format(build.versionName, linedesc))
+                    warn_or_exception(_("No commit specified for {versionName} in {linedesc}")
+                                      .format(versionName=build.versionName, linedesc=linedesc))
 
                 app.builds.append(build)
                 add_comments('build:' + build.versionCode)
@@ -1082,10 +1293,10 @@ def parse_txt_metadata(mf, app):
             try:
                 f, v = line.split(':', 1)
             except ValueError:
-                warn_or_exception("Invalid metadata in " + linedesc)
+                warn_or_exception(_("Invalid metadata in: ") + linedesc)
 
             if f not in app_fields:
-                warn_or_exception('Unrecognised app field: ' + f)
+                warn_or_exception(_('Unrecognised app field: ') + f)
 
             # Translate obsolete fields...
             if f == 'Market Version':
@@ -1101,8 +1312,8 @@ def parse_txt_metadata(mf, app):
             if ftype == TYPE_MULTILINE:
                 mode = 1
                 if v:
-                    warn_or_exception("Unexpected text on same line as "
-                                      + f + " in " + linedesc)
+                    warn_or_exception(_("Unexpected text on same line as {field} in {linedesc}")
+                                      .format(field=f, linedesc=linedesc))
             elif ftype == TYPE_STRING:
                 app[f] = v
             elif ftype == TYPE_LIST:
@@ -1119,24 +1330,26 @@ def parse_txt_metadata(mf, app):
             elif ftype == TYPE_BUILD_V2:
                 vv = v.split(',')
                 if len(vv) != 2:
-                    warn_or_exception('Build should have comma-separated',
-                                      'versionName and versionCode,',
-                                      'not "{0}", in {1}'.format(v, linedesc))
+                    warn_or_exception(_('Build should have comma-separated '
+                                        'versionName and versionCode, '
+                                        'not "{value}", in {linedesc}')
+                                      .format(value=v, linedesc=linedesc))
                 build = Build()
                 build.versionName = vv[0]
                 build.versionCode = vv[1]
                 check_versionCode(build.versionCode)
 
                 if build.versionCode in vc_seen:
-                    warn_or_exception('Duplicate build recipe found for versionCode %s in %s'
-                                      % (build.versionCode, linedesc))
+                    warn_or_exception(_('Duplicate build recipe found for versionCode {versionCode} in {linedesc}')
+                                      .format(versionCode=build.versionCode, linedesc=linedesc))
                 vc_seen.add(build.versionCode)
                 del buildlines[:]
                 mode = 3
             elif ftype == TYPE_OBSOLETE:
                 pass        # Just throw it away!
             else:
-                warn_or_exception("Unrecognised field '" + f + "' in " + linedesc)
+                warn_or_exception(_("Unrecognised field '{field}' in {linedesc}")
+                                  .format(field=f, linedesc=linedesc))
         elif mode == 1:     # Multiline field
             if line == '.':
                 mode = 0
@@ -1157,11 +1370,14 @@ def parse_txt_metadata(mf, app):
 
     # Mode at end of file should always be 0
     if mode == 1:
-        warn_or_exception(f + " not terminated in " + mf.name)
+        warn_or_exception(_("{field} not terminated in {name}")
+                          .format(field=f, name=mf.name))
     if mode == 2:
-        warn_or_exception("Unterminated continuation in " + mf.name)
+        warn_or_exception(_("Unterminated continuation in {name}")
+                          .format(name=mf.name))
     if mode == 3:
-        warn_or_exception("Unterminated build in " + mf.name)
+        warn_or_exception(_("Unterminated build in {name}")
+                          .format(name=mf.name))
 
     return app
 
@@ -1220,6 +1436,7 @@ def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
     w_field_nonempty('Changelog')
     w_field_nonempty('Donate')
     w_field_nonempty('FlattrID')
+    w_field_nonempty('LiberapayID')
     w_field_nonempty('Bitcoin')
     w_field_nonempty('Litecoin')
     mf.write('\n')
@@ -1317,21 +1534,26 @@ def write_txt(mf, app):
 
 
 def write_metadata(metadatapath, app):
-    _, ext = fdroidserver.common.get_extension(metadatapath)
+    _ignored, ext = fdroidserver.common.get_extension(metadatapath)
     accepted = fdroidserver.common.config['accepted_formats']
     if ext not in accepted:
-        warn_or_exception('Cannot write "%s", not an accepted format, use: %s'
-                          % (metadatapath, ', '.join(accepted)))
+        warn_or_exception(_('Cannot write "{path}", not an accepted format, use: {formats}')
+                          .format(path=metadatapath, formats=', '.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)
-    warn_or_exception('Unknown metadata format: %s' % metadatapath)
+    try:
+        with open(metadatapath, 'w', encoding='utf8') as mf:
+            if ext == 'txt':
+                return write_txt(mf, app)
+            elif ext == 'yml':
+                return write_yaml(mf, app)
+    except FDroidException as e:
+        os.remove(metadatapath)
+        raise e
+
+    warn_or_exception(_('Unknown metadata format: %s') % metadatapath)
 
 
 def add_metadata_arguments(parser):
     '''add common command line flags related to metadata processing'''
-    parser.add_argument("-W", default='error',
-                        help="force errors to be warnings, or ignore")
+    parser.add_argument("-W", choices=['error', 'warn', 'ignore'], default='error',
+                        help=_("force metadata errors (default) to be warnings, or to be ignored."))