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