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