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