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