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