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