chiark / gitweb /
build: log vcs tools version on every build attempt
[fdroidserver.git] / fdroidserver / metadata.py
1 #!/usr/bin/env python3
2 #
3 # metadata.py - part of the FDroid server tools
4 # Copyright (C) 2013, Ciaran Gultnieks, ciaran@ciarang.com
5 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU Affero General Public License for more details.
16 #
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 import json
21 import os
22 import re
23 import glob
24 import html
25 import logging
26 import textwrap
27 import io
28 import yaml
29 # use libyaml if it is available
30 try:
31     from yaml import CLoader
32     YamlLoader = CLoader
33 except ImportError:
34     from yaml import Loader
35     YamlLoader = Loader
36
37 import fdroidserver.common
38 from fdroidserver import _
39 from fdroidserver.exception import MetaDataException, FDroidException
40
41 srclibs = None
42 warnings_action = None
43
44
45 def warn_or_exception(value):
46     '''output warning or Exception depending on -W'''
47     if warnings_action == 'ignore':
48         pass
49     elif warnings_action == 'error':
50         raise MetaDataException(value)
51     else:
52         logging.warning(value)
53
54
55 # To filter which ones should be written to the metadata files if
56 # present
57 app_fields = set([
58     'Disabled',
59     'AntiFeatures',
60     'Provides',
61     'Categories',
62     'License',
63     'Author Name',
64     'Author Email',
65     'Author Web Site',
66     'Web Site',
67     'Source Code',
68     'Issue Tracker',
69     'Changelog',
70     'Donate',
71     'FlattrID',
72     'Bitcoin',
73     'Litecoin',
74     'Name',
75     'Auto Name',
76     'Summary',
77     'Description',
78     'Requires Root',
79     'Repo Type',
80     'Repo',
81     'Binaries',
82     'Maintainer Notes',
83     'Archive Policy',
84     'Auto Update Mode',
85     'Update Check Mode',
86     'Update Check Ignore',
87     'Vercode Operation',
88     'Update Check Name',
89     'Update Check Data',
90     'Current Version',
91     'Current Version Code',
92     'No Source Since',
93     'Build',
94
95     'comments',  # For formats that don't do inline comments
96     'builds',    # For formats that do builds as a list
97 ])
98
99
100 class App(dict):
101
102     def __init__(self, copydict=None):
103         if copydict:
104             super().__init__(copydict)
105             return
106         super().__init__()
107
108         self.Disabled = None
109         self.AntiFeatures = []
110         self.Provides = None
111         self.Categories = ['None']
112         self.License = 'Unknown'
113         self.AuthorName = None
114         self.AuthorEmail = None
115         self.AuthorWebSite = None
116         self.WebSite = ''
117         self.SourceCode = ''
118         self.IssueTracker = ''
119         self.Changelog = ''
120         self.Donate = None
121         self.FlattrID = None
122         self.Bitcoin = None
123         self.Litecoin = None
124         self.Name = None
125         self.AutoName = ''
126         self.Summary = ''
127         self.Description = ''
128         self.RequiresRoot = False
129         self.RepoType = ''
130         self.Repo = ''
131         self.Binaries = None
132         self.MaintainerNotes = ''
133         self.ArchivePolicy = None
134         self.AutoUpdateMode = 'None'
135         self.UpdateCheckMode = 'None'
136         self.UpdateCheckIgnore = None
137         self.VercodeOperation = None
138         self.UpdateCheckName = None
139         self.UpdateCheckData = None
140         self.CurrentVersion = ''
141         self.CurrentVersionCode = None
142         self.NoSourceSince = ''
143
144         self.id = None
145         self.metadatapath = None
146         self.builds = []
147         self.comments = {}
148         self.added = None
149         self.lastUpdated = None
150
151     def __getattr__(self, name):
152         if name in self:
153             return self[name]
154         else:
155             raise AttributeError("No such attribute: " + name)
156
157     def __setattr__(self, name, value):
158         self[name] = value
159
160     def __delattr__(self, name):
161         if name in self:
162             del self[name]
163         else:
164             raise AttributeError("No such attribute: " + name)
165
166     def get_last_build(self):
167         if len(self.builds) > 0:
168             return self.builds[-1]
169         else:
170             return Build()
171
172
173 TYPE_UNKNOWN = 0
174 TYPE_OBSOLETE = 1
175 TYPE_STRING = 2
176 TYPE_BOOL = 3
177 TYPE_LIST = 4
178 TYPE_SCRIPT = 5
179 TYPE_MULTILINE = 6
180 TYPE_BUILD = 7
181 TYPE_BUILD_V2 = 8
182 TYPE_INT = 9
183
184 fieldtypes = {
185     'Description': TYPE_MULTILINE,
186     'MaintainerNotes': TYPE_MULTILINE,
187     'Categories': TYPE_LIST,
188     'AntiFeatures': TYPE_LIST,
189     'BuildVersion': TYPE_BUILD,
190     'Build': TYPE_BUILD_V2,
191     'UseBuilt': TYPE_OBSOLETE,
192 }
193
194
195 def fieldtype(name):
196     name = name.replace(' ', '')
197     if name in fieldtypes:
198         return fieldtypes[name]
199     return TYPE_STRING
200
201
202 # In the order in which they are laid out on files
203 build_flags_order = [
204     'disable',
205     'commit',
206     'subdir',
207     'submodules',
208     'sudo',
209     'init',
210     'patch',
211     'gradle',
212     'maven',
213     'kivy',
214     'buildozer',
215     'output',
216     'srclibs',
217     'oldsdkloc',
218     'encoding',
219     'forceversion',
220     'forcevercode',
221     'rm',
222     'extlibs',
223     'prebuild',
224     'androidupdate',
225     'target',
226     'scanignore',
227     'scandelete',
228     'build',
229     'buildjni',
230     'ndk',
231     'preassemble',
232     'gradleprops',
233     'antcommands',
234     'novcheck',
235     'antifeatures',
236 ]
237
238 # old .txt format has version name/code inline in the 'Build:' line
239 # but YAML and JSON have a explicit key for them
240 build_flags = ['versionName', 'versionCode'] + build_flags_order
241
242
243 class Build(dict):
244
245     def __init__(self, copydict=None):
246         super().__init__()
247         self.disable = False
248         self.commit = None
249         self.subdir = None
250         self.submodules = False
251         self.sudo = ''
252         self.init = ''
253         self.patch = []
254         self.gradle = []
255         self.maven = False
256         self.kivy = False
257         self.buildozer = False
258         self.output = None
259         self.srclibs = []
260         self.oldsdkloc = False
261         self.encoding = None
262         self.forceversion = False
263         self.forcevercode = False
264         self.rm = []
265         self.extlibs = []
266         self.prebuild = ''
267         self.androidupdate = []
268         self.target = None
269         self.scanignore = []
270         self.scandelete = []
271         self.build = ''
272         self.buildjni = []
273         self.ndk = None
274         self.preassemble = []
275         self.gradleprops = []
276         self.antcommands = []
277         self.novcheck = False
278         self.antifeatures = []
279         if copydict:
280             super().__init__(copydict)
281             return
282
283     def __getattr__(self, name):
284         if name in self:
285             return self[name]
286         else:
287             raise AttributeError("No such attribute: " + name)
288
289     def __setattr__(self, name, value):
290         self[name] = value
291
292     def __delattr__(self, name):
293         if name in self:
294             del self[name]
295         else:
296             raise AttributeError("No such attribute: " + name)
297
298     def build_method(self):
299         for f in ['maven', 'gradle', 'kivy', 'buildozer']:
300             if self.get(f):
301                 return f
302         if self.output:
303             return 'raw'
304         return 'ant'
305
306     # like build_method, but prioritize output=
307     def output_method(self):
308         if self.output:
309             return 'raw'
310         for f in ['maven', 'gradle', 'kivy', 'buildozer']:
311             if self.get(f):
312                 return f
313         return 'ant'
314
315     def ndk_path(self):
316         version = self.ndk
317         if not version:
318             version = 'r12b'  # falls back to latest
319         paths = fdroidserver.common.config['ndk_paths']
320         if version not in paths:
321             return ''
322         return paths[version]
323
324
325 flagtypes = {
326     'versionCode': TYPE_INT,
327     'extlibs': TYPE_LIST,
328     'srclibs': TYPE_LIST,
329     'patch': TYPE_LIST,
330     'rm': TYPE_LIST,
331     'buildjni': TYPE_LIST,
332     'preassemble': TYPE_LIST,
333     'androidupdate': TYPE_LIST,
334     'scanignore': TYPE_LIST,
335     'scandelete': TYPE_LIST,
336     'gradle': TYPE_LIST,
337     'antcommands': TYPE_LIST,
338     'gradleprops': TYPE_LIST,
339     'sudo': TYPE_SCRIPT,
340     'init': TYPE_SCRIPT,
341     'prebuild': TYPE_SCRIPT,
342     'build': TYPE_SCRIPT,
343     'submodules': TYPE_BOOL,
344     'oldsdkloc': TYPE_BOOL,
345     'forceversion': TYPE_BOOL,
346     'forcevercode': TYPE_BOOL,
347     'novcheck': TYPE_BOOL,
348     'antifeatures': TYPE_LIST,
349 }
350
351
352 def flagtype(name):
353     if name in flagtypes:
354         return flagtypes[name]
355     return TYPE_STRING
356
357
358 class FieldValidator():
359     """
360     Designates App metadata field types and checks that it matches
361
362     'name'     - The long name of the field type
363     'matching' - List of possible values or regex expression
364     'sep'      - Separator to use if value may be a list
365     'fields'   - Metadata fields (Field:Value) of this type
366     """
367
368     def __init__(self, name, matching, fields):
369         self.name = name
370         self.matching = matching
371         self.compiled = re.compile(matching)
372         self.fields = fields
373
374     def check(self, v, appid):
375         if not v:
376             return
377         if type(v) == list:
378             values = v
379         else:
380             values = [v]
381         for v in values:
382             if not self.compiled.match(v):
383                 warn_or_exception(_("'{value}' is not a valid {field} in {appid}. Regex pattern: {pattern}")
384                                   .format(value=v, field=self.name, appid=appid, pattern=self.matching))
385
386
387 # Generic value types
388 valuetypes = {
389     FieldValidator("Flattr ID",
390                    r'^[0-9a-z]+$',
391                    ['FlattrID']),
392
393     FieldValidator("HTTP link",
394                    r'^http[s]?://',
395                    ["WebSite", "SourceCode", "IssueTracker", "Changelog", "Donate"]),
396
397     FieldValidator("Email",
398                    r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
399                    ["AuthorEmail"]),
400
401     FieldValidator("Bitcoin address",
402                    r'^[a-zA-Z0-9]{27,34}$',
403                    ["Bitcoin"]),
404
405     FieldValidator("Litecoin address",
406                    r'^L[a-zA-Z0-9]{33}$',
407                    ["Litecoin"]),
408
409     FieldValidator("Repo Type",
410                    r'^(git|git-svn|svn|hg|bzr|srclib)$',
411                    ["RepoType"]),
412
413     FieldValidator("Binaries",
414                    r'^http[s]?://',
415                    ["Binaries"]),
416
417     FieldValidator("Archive Policy",
418                    r'^[0-9]+ versions$',
419                    ["ArchivePolicy"]),
420
421     FieldValidator("Anti-Feature",
422                    r'^(Ads|Tracking|NonFreeNet|NonFreeDep|NonFreeAdd|UpstreamNonFree|NonFreeAssets|KnownVuln|ApplicationDebuggable)$',
423                    ["AntiFeatures"]),
424
425     FieldValidator("Auto Update Mode",
426                    r"^(Version .+|None)$",
427                    ["AutoUpdateMode"]),
428
429     FieldValidator("Update Check Mode",
430                    r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$",
431                    ["UpdateCheckMode"])
432 }
433
434
435 # Check an app's metadata information for integrity errors
436 def check_metadata(app):
437     for v in valuetypes:
438         for k in v.fields:
439             v.check(app[k], app.id)
440
441
442 # Formatter for descriptions. Create an instance, and call parseline() with
443 # each line of the description source from the metadata. At the end, call
444 # end() and then text_txt and text_html will contain the result.
445 class DescriptionFormatter:
446
447     stNONE = 0
448     stPARA = 1
449     stUL = 2
450     stOL = 3
451
452     def __init__(self, linkres):
453         self.bold = False
454         self.ital = False
455         self.state = self.stNONE
456         self.laststate = self.stNONE
457         self.text_html = ''
458         self.text_txt = ''
459         self.html = io.StringIO()
460         self.text = io.StringIO()
461         self.para_lines = []
462         self.linkResolver = None
463         self.linkResolver = linkres
464
465     def endcur(self, notstates=None):
466         if notstates and self.state in notstates:
467             return
468         if self.state == self.stPARA:
469             self.endpara()
470         elif self.state == self.stUL:
471             self.endul()
472         elif self.state == self.stOL:
473             self.endol()
474
475     def endpara(self):
476         self.laststate = self.state
477         self.state = self.stNONE
478         whole_para = ' '.join(self.para_lines)
479         self.addtext(whole_para)
480         wrapped = textwrap.fill(whole_para, 80,
481                                 break_long_words=False,
482                                 break_on_hyphens=False)
483         self.text.write(wrapped)
484         self.html.write('</p>')
485         del self.para_lines[:]
486
487     def endul(self):
488         self.html.write('</ul>')
489         self.laststate = self.state
490         self.state = self.stNONE
491
492     def endol(self):
493         self.html.write('</ol>')
494         self.laststate = self.state
495         self.state = self.stNONE
496
497     def formatted(self, txt, htmlbody):
498         res = ''
499         if htmlbody:
500             txt = html.escape(txt, quote=False)
501         while True:
502             index = txt.find("''")
503             if index == -1:
504                 return res + txt
505             res += txt[:index]
506             txt = txt[index:]
507             if txt.startswith("'''"):
508                 if htmlbody:
509                     if self.bold:
510                         res += '</b>'
511                     else:
512                         res += '<b>'
513                 self.bold = not self.bold
514                 txt = txt[3:]
515             else:
516                 if htmlbody:
517                     if self.ital:
518                         res += '</i>'
519                     else:
520                         res += '<i>'
521                 self.ital = not self.ital
522                 txt = txt[2:]
523
524     def linkify(self, txt):
525         res_plain = ''
526         res_html = ''
527         while True:
528             index = txt.find("[")
529             if index == -1:
530                 return (res_plain + self.formatted(txt, False), res_html + self.formatted(txt, True))
531             res_plain += self.formatted(txt[:index], False)
532             res_html += self.formatted(txt[:index], True)
533             txt = txt[index:]
534             if txt.startswith("[["):
535                 index = txt.find("]]")
536                 if index == -1:
537                     warn_or_exception(_("Unterminated ]]"))
538                 url = txt[2:index]
539                 if self.linkResolver:
540                     url, urltext = self.linkResolver(url)
541                 else:
542                     urltext = url
543                 res_html += '<a href="' + url + '">' + html.escape(urltext, quote=False) + '</a>'
544                 res_plain += urltext
545                 txt = txt[index + 2:]
546             else:
547                 index = txt.find("]")
548                 if index == -1:
549                     warn_or_exception(_("Unterminated ]"))
550                 url = txt[1:index]
551                 index2 = url.find(' ')
552                 if index2 == -1:
553                     urltxt = url
554                 else:
555                     urltxt = url[index2 + 1:]
556                     url = url[:index2]
557                     if url == urltxt:
558                         warn_or_exception(_("URL title is just the URL, use brackets: [URL]"))
559                 res_html += '<a href="' + url + '">' + html.escape(urltxt, quote=False) + '</a>'
560                 res_plain += urltxt
561                 if urltxt != url:
562                     res_plain += ' (' + url + ')'
563                 txt = txt[index + 1:]
564
565     def addtext(self, txt):
566         p, h = self.linkify(txt)
567         self.html.write(h)
568
569     def parseline(self, line):
570         if not line:
571             self.endcur()
572         elif line.startswith('* '):
573             self.endcur([self.stUL])
574             if self.state != self.stUL:
575                 self.html.write('<ul>')
576                 self.state = self.stUL
577                 if self.laststate != self.stNONE:
578                     self.text.write('\n\n')
579             else:
580                 self.text.write('\n')
581             self.text.write(line)
582             self.html.write('<li>')
583             self.addtext(line[1:])
584             self.html.write('</li>')
585         elif line.startswith('# '):
586             self.endcur([self.stOL])
587             if self.state != self.stOL:
588                 self.html.write('<ol>')
589                 self.state = self.stOL
590                 if self.laststate != self.stNONE:
591                     self.text.write('\n\n')
592             else:
593                 self.text.write('\n')
594             self.text.write(line)
595             self.html.write('<li>')
596             self.addtext(line[1:])
597             self.html.write('</li>')
598         else:
599             self.para_lines.append(line)
600             self.endcur([self.stPARA])
601             if self.state == self.stNONE:
602                 self.state = self.stPARA
603                 if self.laststate != self.stNONE:
604                     self.text.write('\n\n')
605                 self.html.write('<p>')
606
607     def end(self):
608         self.endcur()
609         self.text_txt = self.text.getvalue()
610         self.text_html = self.html.getvalue()
611         self.text.close()
612         self.html.close()
613
614
615 # Parse multiple lines of description as written in a metadata file, returning
616 # a single string in text format and wrapped to 80 columns.
617 def description_txt(s):
618     ps = DescriptionFormatter(None)
619     for line in s.splitlines():
620         ps.parseline(line)
621     ps.end()
622     return ps.text_txt
623
624
625 # Parse multiple lines of description as written in a metadata file, returning
626 # a single string in wiki format. Used for the Maintainer Notes field as well,
627 # because it's the same format.
628 def description_wiki(s):
629     return s
630
631
632 # Parse multiple lines of description as written in a metadata file, returning
633 # a single string in HTML format.
634 def description_html(s, linkres):
635     ps = DescriptionFormatter(linkres)
636     for line in s.splitlines():
637         ps.parseline(line)
638     ps.end()
639     return ps.text_html
640
641
642 def parse_srclib(metadatapath):
643
644     thisinfo = {}
645
646     # Defaults for fields that come from metadata
647     thisinfo['Repo Type'] = ''
648     thisinfo['Repo'] = ''
649     thisinfo['Subdir'] = None
650     thisinfo['Prepare'] = None
651
652     if not os.path.exists(metadatapath):
653         return thisinfo
654
655     metafile = open(metadatapath, "r", encoding='utf-8')
656
657     n = 0
658     for line in metafile:
659         n += 1
660         line = line.rstrip('\r\n')
661         if not line or line.startswith("#"):
662             continue
663
664         try:
665             f, v = line.split(':', 1)
666         except ValueError:
667             warn_or_exception(_("Invalid metadata in %s:%d") % (line, n))
668
669         if f == "Subdir":
670             thisinfo[f] = v.split(',')
671         else:
672             thisinfo[f] = v
673
674     metafile.close()
675
676     return thisinfo
677
678
679 def read_srclibs():
680     """Read all srclib metadata.
681
682     The information read will be accessible as metadata.srclibs, which is a
683     dictionary, keyed on srclib name, with the values each being a dictionary
684     in the same format as that returned by the parse_srclib function.
685
686     A MetaDataException is raised if there are any problems with the srclib
687     metadata.
688     """
689     global srclibs
690
691     # They were already loaded
692     if srclibs is not None:
693         return
694
695     srclibs = {}
696
697     srcdir = 'srclibs'
698     if not os.path.exists(srcdir):
699         os.makedirs(srcdir)
700
701     for metadatapath in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
702         srclibname = os.path.basename(metadatapath[:-4])
703         srclibs[srclibname] = parse_srclib(metadatapath)
704
705
706 def read_metadata(xref=True, check_vcs=[]):
707     """
708     Read all metadata. Returns a list of 'app' objects (which are dictionaries as
709     returned by the parse_txt_metadata function.
710
711     check_vcs is the list of packageNames to check for .fdroid.yml in source
712     """
713
714     # Always read the srclibs before the apps, since they can use a srlib as
715     # their source repository.
716     read_srclibs()
717
718     apps = {}
719
720     for basedir in ('metadata', 'tmp'):
721         if not os.path.exists(basedir):
722             os.makedirs(basedir)
723
724     # If there are multiple metadata files for a single appid, then the first
725     # file that is parsed wins over all the others, and the rest throw an
726     # exception. So the original .txt format is parsed first, at least until
727     # newer formats stabilize.
728
729     for metadatapath in sorted(glob.glob(os.path.join('metadata', '*.txt'))
730                                + glob.glob(os.path.join('metadata', '*.json'))
731                                + glob.glob(os.path.join('metadata', '*.yml'))
732                                + glob.glob('.fdroid.txt')
733                                + glob.glob('.fdroid.json')
734                                + glob.glob('.fdroid.yml')):
735         packageName, _ignored = fdroidserver.common.get_extension(os.path.basename(metadatapath))
736         if packageName in apps:
737             warn_or_exception(_("Found multiple metadata files for {appid}")
738                               .format(path=packageName))
739         app = parse_metadata(metadatapath, packageName in check_vcs)
740         check_metadata(app)
741         apps[app.id] = app
742
743     if xref:
744         # Parse all descriptions at load time, just to ensure cross-referencing
745         # errors are caught early rather than when they hit the build server.
746         def linkres(appid):
747             if appid in apps:
748                 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
749             warn_or_exception(_("Cannot resolve app id {appid}").format(appid=appid))
750
751         for appid, app in apps.items():
752             try:
753                 description_html(app.Description, linkres)
754             except MetaDataException as e:
755                 warn_or_exception(_("Problem with description of {appid}: {error}")
756                                   .format(appid=appid, error=str(e)))
757
758     return apps
759
760
761 # Port legacy ';' separators
762 list_sep = re.compile(r'[,;]')
763
764
765 def split_list_values(s):
766     res = []
767     for v in re.split(list_sep, s):
768         if not v:
769             continue
770         v = v.strip()
771         if not v:
772             continue
773         res.append(v)
774     return res
775
776
777 def get_default_app_info(metadatapath=None):
778     if metadatapath is None:
779         appid = None
780     else:
781         appid, _ignored = fdroidserver.common.get_extension(os.path.basename(metadatapath))
782
783     if appid == '.fdroid':  # we have local metadata in the app's source
784         if os.path.exists('AndroidManifest.xml'):
785             manifestroot = fdroidserver.common.parse_xml('AndroidManifest.xml')
786         else:
787             pattern = re.compile(""".*manifest\.srcFile\s+'AndroidManifest\.xml'.*""")
788             for root, dirs, files in os.walk(os.getcwd()):
789                 if 'build.gradle' in files:
790                     p = os.path.join(root, 'build.gradle')
791                     with open(p, 'rb') as f:
792                         data = f.read()
793                     m = pattern.search(data)
794                     if m:
795                         logging.debug('Using: ' + os.path.join(root, 'AndroidManifest.xml'))
796                         manifestroot = fdroidserver.common.parse_xml(os.path.join(root, 'AndroidManifest.xml'))
797                         break
798         if manifestroot is None:
799             warn_or_exception(_("Cannot find a packageName for {path}!")
800                               .format(path=metadatapath))
801         appid = manifestroot.attrib['package']
802
803     app = App()
804     app.metadatapath = metadatapath
805     if appid is not None:
806         app.id = appid
807
808     return app
809
810
811 def sorted_builds(builds):
812     return sorted(builds, key=lambda build: int(build.versionCode))
813
814
815 esc_newlines = re.compile(r'\\( |\n)')
816
817
818 def post_metadata_parse(app):
819     # TODO keep native types, convert only for .txt metadata
820     for k, v in app.items():
821         if type(v) in (float, int):
822             app[k] = str(v)
823
824     if 'Builds' in app:
825         app['builds'] = app.pop('Builds')
826
827     if 'flavours' in app and app['flavours'] == [True]:
828         app['flavours'] = 'yes'
829
830     if isinstance(app.Categories, str):
831         app.Categories = [app.Categories]
832     elif app.Categories is None:
833         app.Categories = ['None']
834     else:
835         app.Categories = [str(i) for i in app.Categories]
836
837     def _yaml_bool_unmapable(v):
838         return v in (True, False, [True], [False])
839
840     def _yaml_bool_unmap(v):
841         if v is True:
842             return 'yes'
843         elif v is False:
844             return 'no'
845         elif v == [True]:
846             return ['yes']
847         elif v == [False]:
848             return ['no']
849
850     _bool_allowed = ('disable', 'kivy', 'maven', 'buildozer')
851
852     builds = []
853     if 'builds' in app:
854         for build in app['builds']:
855             if not isinstance(build, Build):
856                 build = Build(build)
857             for k, v in build.items():
858                 if not (v is None):
859                     if flagtype(k) == TYPE_LIST:
860                         if _yaml_bool_unmapable(v):
861                             build[k] = _yaml_bool_unmap(v)
862
863                         if isinstance(v, str):
864                             build[k] = [v]
865                         elif isinstance(v, bool):
866                             if v:
867                                 build[k] = ['yes']
868                             else:
869                                 build[k] = []
870                     elif flagtype(k) is TYPE_INT:
871                         build[k] = str(v)
872                     elif flagtype(k) is TYPE_STRING:
873                         if isinstance(v, bool) and k in _bool_allowed:
874                             build[k] = v
875                         else:
876                             if _yaml_bool_unmapable(v):
877                                 build[k] = _yaml_bool_unmap(v)
878                             else:
879                                 build[k] = str(v)
880             builds.append(build)
881
882     app.builds = sorted_builds(builds)
883
884
885 # Parse metadata for a single application.
886 #
887 #  'metadatapath' - the filename to read. The package id for the application comes
888 #               from this filename. Pass None to get a blank entry.
889 #
890 # Returns a dictionary containing all the details of the application. There are
891 # two major kinds of information in the dictionary. Keys beginning with capital
892 # letters correspond directory to identically named keys in the metadata file.
893 # Keys beginning with lower case letters are generated in one way or another,
894 # and are not found verbatim in the metadata.
895 #
896 # Known keys not originating from the metadata are:
897 #
898 #  'builds'           - a list of dictionaries containing build information
899 #                       for each defined build
900 #  'comments'         - a list of comments from the metadata file. Each is
901 #                       a list of the form [field, comment] where field is
902 #                       the name of the field it preceded in the metadata
903 #                       file. Where field is None, the comment goes at the
904 #                       end of the file. Alternatively, 'build:version' is
905 #                       for a comment before a particular build version.
906 #  'descriptionlines' - original lines of description as formatted in the
907 #                       metadata file.
908 #
909
910
911 bool_true = re.compile(r'([Yy]es|[Tt]rue)')
912 bool_false = re.compile(r'([Nn]o|[Ff]alse)')
913
914
915 def _decode_bool(s):
916     if bool_true.match(s):
917         return True
918     if bool_false.match(s):
919         return False
920     warn_or_exception(_("Invalid boolean '%s'") % s)
921
922
923 def parse_metadata(metadatapath, check_vcs=False):
924     '''parse metadata file, optionally checking the git repo for metadata first'''
925
926     _ignored, ext = fdroidserver.common.get_extension(metadatapath)
927     accepted = fdroidserver.common.config['accepted_formats']
928     if ext not in accepted:
929         warn_or_exception(_('"{path}" is not an accepted format, convert to: {formats}')
930                           .format(path=metadatapath, formats=', '.join(accepted)))
931
932     app = App()
933     app.metadatapath = metadatapath
934     name, _ignored = fdroidserver.common.get_extension(os.path.basename(metadatapath))
935     if name == '.fdroid':
936         check_vcs = False
937     else:
938         app.id = name
939
940     with open(metadatapath, 'r', encoding='utf-8') as mf:
941         if ext == 'txt':
942             parse_txt_metadata(mf, app)
943         elif ext == 'json':
944             parse_json_metadata(mf, app)
945         elif ext == 'yml':
946             parse_yaml_metadata(mf, app)
947         else:
948             warn_or_exception(_('Unknown metadata format: {path}')
949                               .format(path=metadatapath))
950
951     if check_vcs and app.Repo:
952         build_dir = fdroidserver.common.get_build_dir(app)
953         metadata_in_repo = os.path.join(build_dir, '.fdroid.yml')
954         if not os.path.isfile(metadata_in_repo):
955             vcs, build_dir = fdroidserver.common.setup_vcs(app)
956             if isinstance(vcs, fdroidserver.common.vcs_git):
957                 vcs.gotorevision('HEAD')  # HEAD since we can't know where else to go
958         if os.path.isfile(metadata_in_repo):
959             logging.debug('Including metadata from ' + metadata_in_repo)
960             # do not include fields already provided by main metadata file
961             app_in_repo = parse_metadata(metadata_in_repo)
962             for k, v in app_in_repo.items():
963                 if k not in app:
964                     app[k] = v
965
966     post_metadata_parse(app)
967
968     if not app.id:
969         if app.builds:
970             build = app.builds[-1]
971             if build.subdir:
972                 root_dir = build.subdir
973             else:
974                 root_dir = '.'
975             paths = fdroidserver.common.manifest_paths(root_dir, build.gradle)
976             _ignored, _ignored, app.id = fdroidserver.common.parse_androidmanifests(paths, app)
977
978     return app
979
980
981 def parse_json_metadata(mf, app):
982
983     # fdroid metadata is only strings and booleans, no floats or ints.
984     # TODO create schema using https://pypi.python.org/pypi/jsonschema
985     jsoninfo = json.load(mf, parse_int=lambda s: s,
986                          parse_float=lambda s: s)
987     app.update(jsoninfo)
988     for f in ['Description', 'Maintainer Notes']:
989         v = app.get(f)
990         if v:
991             app[f] = '\n'.join(v)
992     return app
993
994
995 def parse_yaml_metadata(mf, app):
996     yamldata = yaml.load(mf, Loader=YamlLoader)
997     if yamldata:
998         app.update(yamldata)
999     return app
1000
1001
1002 def write_yaml(mf, app):
1003
1004     # import rumael.yaml and check version
1005     try:
1006         import ruamel.yaml
1007     except ImportError as e:
1008         raise FDroidException('ruamel.yaml not instlled, can not write metadata.') from e
1009     if not ruamel.yaml.__version__:
1010         raise FDroidException('ruamel.yaml.__version__ not accessible. Please make sure a ruamel.yaml >= 0.13 is installed..')
1011     m = re.match('(?P<major>[0-9]+)\.(?P<minor>[0-9]+)\.(?P<patch>[0-9]+)(-.+)?',
1012                  ruamel.yaml.__version__)
1013     if not m:
1014         raise FDroidException('ruamel.yaml version malfored, please install an upstream version of ruamel.yaml')
1015     if int(m.group('major')) < 0 or int(m.group('minor')) < 13:
1016         raise FDroidException('currently installed version of ruamel.yaml ({}) is too old, >= 1.13 required.'.format(ruamel.yaml.__version__))
1017     # suiteable version ruamel.yaml imported successfully
1018
1019     _yaml_bools_true = ('y', 'Y', 'yes', 'Yes', 'YES',
1020                         'true', 'True', 'TRUE',
1021                         'on', 'On', 'ON')
1022     _yaml_bools_false = ('n', 'N', 'no', 'No', 'NO',
1023                          'false', 'False', 'FALSE',
1024                          'off', 'Off', 'OFF')
1025     _yaml_bools_plus_lists = []
1026     _yaml_bools_plus_lists.extend(_yaml_bools_true)
1027     _yaml_bools_plus_lists.extend([[x] for x in _yaml_bools_true])
1028     _yaml_bools_plus_lists.extend(_yaml_bools_false)
1029     _yaml_bools_plus_lists.extend([[x] for x in _yaml_bools_false])
1030
1031     def _class_as_dict_representer(dumper, data):
1032         '''Creates a YAML representation of a App/Build instance'''
1033         return dumper.represent_dict(data)
1034
1035     def _field_to_yaml(typ, value):
1036         if typ is TYPE_STRING:
1037             if value in _yaml_bools_plus_lists:
1038                 return ruamel.yaml.scalarstring.SingleQuotedScalarString(str(value))
1039             return str(value)
1040         elif typ is TYPE_INT:
1041             return int(value)
1042         elif typ is TYPE_MULTILINE:
1043             if '\n' in value:
1044                 return ruamel.yaml.scalarstring.preserve_literal(str(value))
1045             else:
1046                 return str(value)
1047         elif typ is TYPE_SCRIPT:
1048             if len(value) > 50:
1049                 return ruamel.yaml.scalarstring.preserve_literal(value)
1050             else:
1051                 return value
1052         else:
1053             return value
1054
1055     def _app_to_yaml(app):
1056         cm = ruamel.yaml.comments.CommentedMap()
1057         insert_newline = False
1058         for field in yaml_app_field_order:
1059             if field is '\n':
1060                 # next iteration will need to insert a newline
1061                 insert_newline = True
1062             else:
1063                 if app.get(field) or field is 'Builds':
1064                     # .txt calls it 'builds' internally, everywhere else its 'Builds'
1065                     if field is 'Builds':
1066                         if app.get('builds'):
1067                             cm.update({field: _builds_to_yaml(app)})
1068                     elif field is 'CurrentVersionCode':
1069                         cm.update({field: _field_to_yaml(TYPE_INT, getattr(app, field))})
1070                     else:
1071                         cm.update({field: _field_to_yaml(fieldtype(field), getattr(app, field))})
1072
1073                     if insert_newline:
1074                         # we need to prepend a newline in front of this field
1075                         insert_newline = False
1076                         # inserting empty lines is not supported so we add a
1077                         # bogus comment and over-write its value
1078                         cm.yaml_set_comment_before_after_key(field, 'bogus')
1079                         cm.ca.items[field][1][-1].value = '\n'
1080         return cm
1081
1082     def _builds_to_yaml(app):
1083         fields = ['versionName', 'versionCode']
1084         fields.extend(build_flags_order)
1085         builds = ruamel.yaml.comments.CommentedSeq()
1086         for build in app.builds:
1087             b = ruamel.yaml.comments.CommentedMap()
1088             for field in fields:
1089                 if hasattr(build, field) and getattr(build, field):
1090                     value = getattr(build, field)
1091                     if field == 'gradle' and value == ['off']:
1092                         value = [ruamel.yaml.scalarstring.SingleQuotedScalarString('off')]
1093                     if field in ('disable', 'kivy', 'maven', 'buildozer'):
1094                         if value == 'no':
1095                             continue
1096                         elif value == 'yes':
1097                             value = 'yes'
1098                     b.update({field: _field_to_yaml(flagtype(field), value)})
1099             builds.append(b)
1100
1101         # insert extra empty lines between build entries
1102         for i in range(1, len(builds)):
1103             builds.yaml_set_comment_before_after_key(i, 'bogus')
1104             builds.ca.items[i][1][-1].value = '\n'
1105
1106         return builds
1107
1108     yaml_app_field_order = [
1109         'Disabled',
1110         'AntiFeatures',
1111         'Provides',
1112         'Categories',
1113         'License',
1114         'AuthorName',
1115         'AuthorEmail',
1116         'AuthorWebSite',
1117         'WebSite',
1118         'SourceCode',
1119         'IssueTracker',
1120         'Changelog',
1121         'Donate',
1122         'FlattrID',
1123         'Bitcoin',
1124         'Litecoin',
1125         '\n',
1126         'Name',
1127         'AutoName',
1128         'Summary',
1129         'Description',
1130         '\n',
1131         'RequiresRoot',
1132         '\n',
1133         'RepoType',
1134         'Repo',
1135         'Binaries',
1136         '\n',
1137         'Builds',
1138         '\n',
1139         'MaintainerNotes',
1140         '\n',
1141         'ArchivePolicy',
1142         'AutoUpdateMode',
1143         'UpdateCheckMode',
1144         'UpdateCheckIgnore',
1145         'VercodeOperation',
1146         'UpdateCheckName',
1147         'UpdateCheckData',
1148         'CurrentVersion',
1149         'CurrentVersionCode',
1150         '\n',
1151         'NoSourceSince',
1152     ]
1153
1154     yaml_app = _app_to_yaml(app)
1155     ruamel.yaml.round_trip_dump(yaml_app, mf, indent=4, block_seq_indent=2)
1156
1157
1158 build_line_sep = re.compile(r'(?<!\\),')
1159 build_cont = re.compile(r'^[ \t]')
1160
1161
1162 def parse_txt_metadata(mf, app):
1163
1164     linedesc = None
1165
1166     def add_buildflag(p, build):
1167         if not p.strip():
1168             warn_or_exception(_("Empty build flag at {linedesc}")
1169                               .format(linedesc=linedesc))
1170         bv = p.split('=', 1)
1171         if len(bv) != 2:
1172             warn_or_exception(_("Invalid build flag at {line} in {linedesc}")
1173                               .format(line=buildlines[0], linedesc=linedesc))
1174
1175         pk, pv = bv
1176         pk = pk.lstrip()
1177         if pk == 'update':
1178             pk = 'androidupdate'  # avoid conflicting with Build(dict).update()
1179         t = flagtype(pk)
1180         if t == TYPE_LIST:
1181             pv = split_list_values(pv)
1182             build[pk] = pv
1183         elif t == TYPE_STRING or t == TYPE_SCRIPT:
1184             build[pk] = pv
1185         elif t == TYPE_BOOL:
1186             build[pk] = _decode_bool(pv)
1187
1188     def parse_buildline(lines):
1189         v = "".join(lines)
1190         parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
1191         if len(parts) < 3:
1192             warn_or_exception(_("Invalid build format: {value} in {name}")
1193                               .format(value=v, name=mf.name))
1194         build = Build()
1195         build.versionName = parts[0]
1196         build.versionCode = parts[1]
1197         check_versionCode(build.versionCode)
1198
1199         if parts[2].startswith('!'):
1200             # For backwards compatibility, handle old-style disabling,
1201             # including attempting to extract the commit from the message
1202             build.disable = parts[2][1:]
1203             commit = 'unknown - see disabled'
1204             index = parts[2].rfind('at ')
1205             if index != -1:
1206                 commit = parts[2][index + 3:]
1207                 if commit.endswith(')'):
1208                     commit = commit[:-1]
1209             build.commit = commit
1210         else:
1211             build.commit = parts[2]
1212         for p in parts[3:]:
1213             add_buildflag(p, build)
1214
1215         return build
1216
1217     def check_versionCode(versionCode):
1218         try:
1219             int(versionCode)
1220         except ValueError:
1221             warn_or_exception(_('Invalid versionCode: "{versionCode}" is not an integer!')
1222                               .format(versionCode=versionCode))
1223
1224     def add_comments(key):
1225         if not curcomments:
1226             return
1227         app.comments[key] = list(curcomments)
1228         del curcomments[:]
1229
1230     mode = 0
1231     buildlines = []
1232     multiline_lines = []
1233     curcomments = []
1234     build = None
1235     vc_seen = set()
1236
1237     app.builds = []
1238
1239     c = 0
1240     for line in mf:
1241         c += 1
1242         linedesc = "%s:%d" % (mf.name, c)
1243         line = line.rstrip('\r\n')
1244         if mode == 3:
1245             if build_cont.match(line):
1246                 if line.endswith('\\'):
1247                     buildlines.append(line[:-1].lstrip())
1248                 else:
1249                     buildlines.append(line.lstrip())
1250                     bl = ''.join(buildlines)
1251                     add_buildflag(bl, build)
1252                     del buildlines[:]
1253             else:
1254                 if not build.commit and not build.disable:
1255                     warn_or_exception(_("No commit specified for {versionName} in {linedesc}")
1256                                       .format(versionName=build.versionName, linedesc=linedesc))
1257
1258                 app.builds.append(build)
1259                 add_comments('build:' + build.versionCode)
1260                 mode = 0
1261
1262         if mode == 0:
1263             if not line:
1264                 continue
1265             if line.startswith("#"):
1266                 curcomments.append(line[1:].strip())
1267                 continue
1268             try:
1269                 f, v = line.split(':', 1)
1270             except ValueError:
1271                 warn_or_exception(_("Invalid metadata in: ") + linedesc)
1272
1273             if f not in app_fields:
1274                 warn_or_exception(_('Unrecognised app field: ') + f)
1275
1276             # Translate obsolete fields...
1277             if f == 'Market Version':
1278                 f = 'Current Version'
1279             if f == 'Market Version Code':
1280                 f = 'Current Version Code'
1281
1282             f = f.replace(' ', '')
1283
1284             ftype = fieldtype(f)
1285             if ftype not in [TYPE_BUILD, TYPE_BUILD_V2]:
1286                 add_comments(f)
1287             if ftype == TYPE_MULTILINE:
1288                 mode = 1
1289                 if v:
1290                     warn_or_exception(_("Unexpected text on same line as {field} in {linedesc}")
1291                                       .format(field=f, linedesc=linedesc))
1292             elif ftype == TYPE_STRING:
1293                 app[f] = v
1294             elif ftype == TYPE_LIST:
1295                 app[f] = split_list_values(v)
1296             elif ftype == TYPE_BUILD:
1297                 if v.endswith("\\"):
1298                     mode = 2
1299                     del buildlines[:]
1300                     buildlines.append(v[:-1])
1301                 else:
1302                     build = parse_buildline([v])
1303                     app.builds.append(build)
1304                     add_comments('build:' + app.builds[-1].versionCode)
1305             elif ftype == TYPE_BUILD_V2:
1306                 vv = v.split(',')
1307                 if len(vv) != 2:
1308                     warn_or_exception(_('Build should have comma-separated '
1309                                         'versionName and versionCode, '
1310                                         'not "{value}", in {linedesc}')
1311                                       .format(value=v, linedesc=linedesc))
1312                 build = Build()
1313                 build.versionName = vv[0]
1314                 build.versionCode = vv[1]
1315                 check_versionCode(build.versionCode)
1316
1317                 if build.versionCode in vc_seen:
1318                     warn_or_exception(_('Duplicate build recipe found for versionCode {versionCode} in {linedesc}')
1319                                       .format(versionCode=build.versionCode, linedesc=linedesc))
1320                 vc_seen.add(build.versionCode)
1321                 del buildlines[:]
1322                 mode = 3
1323             elif ftype == TYPE_OBSOLETE:
1324                 pass        # Just throw it away!
1325             else:
1326                 warn_or_exception(_("Unrecognised field '{field}' in {linedesc}")
1327                                   .format(field=f, linedesc=linedesc))
1328         elif mode == 1:     # Multiline field
1329             if line == '.':
1330                 mode = 0
1331                 app[f] = '\n'.join(multiline_lines)
1332                 del multiline_lines[:]
1333             else:
1334                 multiline_lines.append(line)
1335         elif mode == 2:     # Line continuation mode in Build Version
1336             if line.endswith("\\"):
1337                 buildlines.append(line[:-1])
1338             else:
1339                 buildlines.append(line)
1340                 build = parse_buildline(buildlines)
1341                 app.builds.append(build)
1342                 add_comments('build:' + app.builds[-1].versionCode)
1343                 mode = 0
1344     add_comments(None)
1345
1346     # Mode at end of file should always be 0
1347     if mode == 1:
1348         warn_or_exception(_("{field} not terminated in {name}")
1349                           .format(field=f, name=mf.name))
1350     if mode == 2:
1351         warn_or_exception(_("Unterminated continuation in {name}")
1352                           .format(name=mf.name))
1353     if mode == 3:
1354         warn_or_exception(_("Unterminated build in {name}")
1355                           .format(name=mf.name))
1356
1357     return app
1358
1359
1360 def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
1361
1362     def field_to_attr(f):
1363         """
1364         Translates human-readable field names to attribute names, e.g.
1365         'Auto Name' to 'AutoName'
1366         """
1367         return f.replace(' ', '')
1368
1369     def attr_to_field(k):
1370         """
1371         Translates attribute names to human-readable field names, e.g.
1372         'AutoName' to 'Auto Name'
1373         """
1374         if k in app_fields:
1375             return k
1376         f = re.sub(r'([a-z])([A-Z])', r'\1 \2', k)
1377         return f
1378
1379     def w_comments(key):
1380         if key not in app.comments:
1381             return
1382         for line in app.comments[key]:
1383             w_comment(line)
1384
1385     def w_field_always(f, v=None):
1386         key = field_to_attr(f)
1387         if v is None:
1388             v = app.get(key)
1389         w_comments(key)
1390         w_field(f, v)
1391
1392     def w_field_nonempty(f, v=None):
1393         key = field_to_attr(f)
1394         if v is None:
1395             v = app.get(key)
1396         w_comments(key)
1397         if v:
1398             w_field(f, v)
1399
1400     w_field_nonempty('Disabled')
1401     w_field_nonempty('AntiFeatures')
1402     w_field_nonempty('Provides')
1403     w_field_always('Categories')
1404     w_field_always('License')
1405     w_field_nonempty('Author Name')
1406     w_field_nonempty('Author Email')
1407     w_field_nonempty('Author Web Site')
1408     w_field_always('Web Site')
1409     w_field_always('Source Code')
1410     w_field_always('Issue Tracker')
1411     w_field_nonempty('Changelog')
1412     w_field_nonempty('Donate')
1413     w_field_nonempty('FlattrID')
1414     w_field_nonempty('Bitcoin')
1415     w_field_nonempty('Litecoin')
1416     mf.write('\n')
1417     w_field_nonempty('Name')
1418     w_field_nonempty('Auto Name')
1419     w_field_nonempty('Summary')
1420     w_field_nonempty('Description', description_txt(app.Description))
1421     mf.write('\n')
1422     if app.RequiresRoot:
1423         w_field_always('Requires Root', 'yes')
1424         mf.write('\n')
1425     if app.RepoType:
1426         w_field_always('Repo Type')
1427         w_field_always('Repo')
1428         if app.Binaries:
1429             w_field_always('Binaries')
1430         mf.write('\n')
1431
1432     for build in app.builds:
1433
1434         if build.versionName == "Ignore":
1435             continue
1436
1437         w_comments('build:%s' % build.versionCode)
1438         w_build(build)
1439         mf.write('\n')
1440
1441     if app.MaintainerNotes:
1442         w_field_always('Maintainer Notes', app.MaintainerNotes)
1443         mf.write('\n')
1444
1445     w_field_nonempty('Archive Policy')
1446     w_field_always('Auto Update Mode')
1447     w_field_always('Update Check Mode')
1448     w_field_nonempty('Update Check Ignore')
1449     w_field_nonempty('Vercode Operation')
1450     w_field_nonempty('Update Check Name')
1451     w_field_nonempty('Update Check Data')
1452     if app.CurrentVersion:
1453         w_field_always('Current Version')
1454         w_field_always('Current Version Code')
1455     if app.NoSourceSince:
1456         mf.write('\n')
1457         w_field_always('No Source Since')
1458     w_comments(None)
1459
1460
1461 # Write a metadata file in txt format.
1462 #
1463 # 'mf'      - Writer interface (file, StringIO, ...)
1464 # 'app'     - The app data
1465 def write_txt(mf, app):
1466
1467     def w_comment(line):
1468         mf.write("# %s\n" % line)
1469
1470     def w_field(f, v):
1471         t = fieldtype(f)
1472         if t == TYPE_LIST:
1473             v = ','.join(v)
1474         elif t == TYPE_MULTILINE:
1475             v = '\n' + v + '\n.'
1476         mf.write("%s:%s\n" % (f, v))
1477
1478     def w_build(build):
1479         mf.write("Build:%s,%s\n" % (build.versionName, build.versionCode))
1480
1481         for f in build_flags_order:
1482             v = build.get(f)
1483             if not v:
1484                 continue
1485
1486             t = flagtype(f)
1487             if f == 'androidupdate':
1488                 f = 'update'  # avoid conflicting with Build(dict).update()
1489             mf.write('    %s=' % f)
1490             if t == TYPE_STRING:
1491                 mf.write(v)
1492             elif t == TYPE_BOOL:
1493                 mf.write('yes')
1494             elif t == TYPE_SCRIPT:
1495                 first = True
1496                 for s in v.split(' && '):
1497                     if first:
1498                         first = False
1499                     else:
1500                         mf.write(' && \\\n        ')
1501                     mf.write(s.strip())
1502             elif t == TYPE_LIST:
1503                 mf.write(','.join(v))
1504
1505             mf.write('\n')
1506
1507     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1508
1509
1510 def write_metadata(metadatapath, app):
1511     _ignored, ext = fdroidserver.common.get_extension(metadatapath)
1512     accepted = fdroidserver.common.config['accepted_formats']
1513     if ext not in accepted:
1514         warn_or_exception(_('Cannot write "{path}", not an accepted format, use: {formats}')
1515                           .format(path=metadatapath, formats=', '.join(accepted)))
1516
1517     try:
1518         with open(metadatapath, 'w', encoding='utf8') as mf:
1519             if ext == 'txt':
1520                 return write_txt(mf, app)
1521             elif ext == 'yml':
1522                 return write_yaml(mf, app)
1523     except FDroidException as e:
1524         os.remove(metadatapath)
1525         raise e
1526
1527     warn_or_exception(_('Unknown metadata format: %s') % metadatapath)
1528
1529
1530 def add_metadata_arguments(parser):
1531     '''add common command line flags related to metadata processing'''
1532     parser.add_argument("-W", default='error',
1533                         help=_("force errors to be warnings, or ignore"))