chiark / gitweb /
rewrite to yaml works for app data now (builds still missing)
[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 from collections import OrderedDict
39
40 import fdroidserver.common
41 from fdroidserver.exception import MetaDataException
42
43 srclibs = None
44 warnings_action = None
45
46
47 def warn_or_exception(value):
48     '''output warning or Exception depending on -W'''
49     if warnings_action == 'ignore':
50         pass
51     elif warnings_action == 'error':
52         raise MetaDataException(value)
53     else:
54         logging.warn(value)
55
56
57 # To filter which ones should be written to the metadata files if
58 # present
59 app_fields = set([
60     'Disabled',
61     'AntiFeatures',
62     'Provides',
63     'Categories',
64     'License',
65     'Author Name',
66     'Author Email',
67     'Author Web Site',
68     'Web Site',
69     'Source Code',
70     'Issue Tracker',
71     'Changelog',
72     'Donate',
73     'FlattrID',
74     'Bitcoin',
75     'Litecoin',
76     'Name',
77     'Auto Name',
78     'Summary',
79     'Description',
80     'Requires Root',
81     'Repo Type',
82     'Repo',
83     'Binaries',
84     'Maintainer Notes',
85     'Archive Policy',
86     'Auto Update Mode',
87     'Update Check Mode',
88     'Update Check Ignore',
89     'Vercode Operation',
90     'Update Check Name',
91     'Update Check Data',
92     'Current Version',
93     'Current Version Code',
94     'No Source Since',
95     'Build',
96
97     'comments',  # For formats that don't do inline comments
98     'builds',    # For formats that do builds as a list
99 ])
100
101
102 class App(dict):
103
104     def __init__(self, copydict=None):
105         if copydict:
106             super().__init__(copydict)
107             return
108         super().__init__()
109
110         self.Disabled = None
111         self.AntiFeatures = []
112         self.Provides = None
113         self.Categories = ['None']
114         self.License = 'Unknown'
115         self.AuthorName = None
116         self.AuthorEmail = None
117         self.AuthorWebSite = None
118         self.WebSite = ''
119         self.SourceCode = ''
120         self.IssueTracker = ''
121         self.Changelog = ''
122         self.Donate = None
123         self.FlattrID = None
124         self.Bitcoin = None
125         self.Litecoin = None
126         self.Name = None
127         self.AutoName = ''
128         self.Summary = ''
129         self.Description = ''
130         self.RequiresRoot = False
131         self.RepoType = ''
132         self.Repo = ''
133         self.Binaries = None
134         self.MaintainerNotes = ''
135         self.ArchivePolicy = None
136         self.AutoUpdateMode = 'None'
137         self.UpdateCheckMode = 'None'
138         self.UpdateCheckIgnore = None
139         self.VercodeOperation = None
140         self.UpdateCheckName = None
141         self.UpdateCheckData = None
142         self.CurrentVersion = ''
143         self.CurrentVersionCode = None
144         self.NoSourceSince = ''
145
146         self.id = None
147         self.metadatapath = None
148         self.builds = []
149         self.comments = {}
150         self.added = None
151         self.lastUpdated = None
152
153     def __getattr__(self, name):
154         if name in self:
155             return self[name]
156         else:
157             raise AttributeError("No such attribute: " + name)
158
159     def __setattr__(self, name, value):
160         self[name] = value
161
162     def __delattr__(self, name):
163         if name in self:
164             del self[name]
165         else:
166             raise AttributeError("No such attribute: " + name)
167
168     def get_last_build(self):
169         if len(self.builds) > 0:
170             return self.builds[-1]
171         else:
172             return Build()
173
174
175 TYPE_UNKNOWN = 0
176 TYPE_OBSOLETE = 1
177 TYPE_STRING = 2
178 TYPE_BOOL = 3
179 TYPE_LIST = 4
180 TYPE_SCRIPT = 5
181 TYPE_MULTILINE = 6
182 TYPE_BUILD = 7
183 TYPE_BUILD_V2 = 8
184
185 fieldtypes = {
186     'Description': TYPE_MULTILINE,
187     'MaintainerNotes': TYPE_MULTILINE,
188     'Categories': TYPE_LIST,
189     'AntiFeatures': TYPE_LIST,
190     'BuildVersion': TYPE_BUILD,
191     'Build': TYPE_BUILD_V2,
192     'UseBuilt': TYPE_OBSOLETE,
193 }
194
195
196 def fieldtype(name):
197     name = name.replace(' ', '')
198     if name in fieldtypes:
199         return fieldtypes[name]
200     return TYPE_STRING
201
202
203 # In the order in which they are laid out on files
204 build_flags_order = [
205     'disable',
206     'commit',
207     'subdir',
208     'submodules',
209     'init',
210     'patch',
211     'gradle',
212     'maven',
213     'kivy',
214     'buildozer',
215     'output',
216     'srclibs',
217     'oldsdkloc',
218     'encoding',
219     'forceversion',
220     'forcevercode',
221     'rm',
222     'extlibs',
223     'prebuild',
224     'androidupdate',
225     'target',
226     'scanignore',
227     'scandelete',
228     'build',
229     'buildjni',
230     'ndk',
231     'preassemble',
232     'gradleprops',
233     'antcommands',
234     'novcheck',
235 ]
236
237 # old .txt format has version name/code inline in the 'Build:' line
238 # but YAML and JSON have a explicit key for them
239 build_flags = ['versionName', 'versionCode'] + build_flags_order
240
241
242 class Build(dict):
243
244     def __init__(self, copydict=None):
245         super().__init__()
246         self.disable = False
247         self.commit = None
248         self.subdir = None
249         self.submodules = False
250         self.init = ''
251         self.patch = []
252         self.gradle = []
253         self.maven = False
254         self.kivy = False
255         self.buildozer = False
256         self.output = None
257         self.srclibs = []
258         self.oldsdkloc = False
259         self.encoding = None
260         self.forceversion = False
261         self.forcevercode = False
262         self.rm = []
263         self.extlibs = []
264         self.prebuild = ''
265         self.androidupdate = []
266         self.target = None
267         self.scanignore = []
268         self.scandelete = []
269         self.build = ''
270         self.buildjni = []
271         self.ndk = None
272         self.preassemble = []
273         self.gradleprops = []
274         self.antcommands = []
275         self.novcheck = False
276         if copydict:
277             super().__init__(copydict)
278             return
279
280     def __getattr__(self, name):
281         if name in self:
282             return self[name]
283         else:
284             raise AttributeError("No such attribute: " + name)
285
286     def __setattr__(self, name, value):
287         self[name] = value
288
289     def __delattr__(self, name):
290         if name in self:
291             del self[name]
292         else:
293             raise AttributeError("No such attribute: " + name)
294
295     def build_method(self):
296         for f in ['maven', 'gradle', 'kivy', 'buildozer']:
297             if self.get(f):
298                 return f
299         if self.output:
300             return 'raw'
301         return 'ant'
302
303     # like build_method, but prioritize output=
304     def output_method(self):
305         if self.output:
306             return 'raw'
307         for f in ['maven', 'gradle', 'kivy', 'buildozer']:
308             if self.get(f):
309                 return f
310         return 'ant'
311
312     def ndk_path(self):
313         version = self.ndk
314         if not version:
315             version = 'r12b'  # falls back to latest
316         paths = fdroidserver.common.config['ndk_paths']
317         if version not in paths:
318             return ''
319         return paths[version]
320
321
322 flagtypes = {
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 def write_yaml(mf, app):
959
960     def _class_as_dict_representer(dumper, data):
961         '''Creates a YAML representation of a App/Build instance'''
962         return dumper.represent_dict(data)
963
964     empty_keys = [k for k, v in app.items() if not v]
965     for k in empty_keys:
966         del app[k]
967
968     for k in ['added', 'lastUpdated', 'id', 'metadatapath']:
969         if k in app:
970             del app[k]
971
972     #yaml.add_representer(fdroidserver.metadata.App, _class_as_dict_representer)
973     #ruamel.yaml.add_representer(fdroidserver.metadata.Build, _class_as_dict_representer)
974     #yaml.dump(app.asOrderedDict(), mf, default_flow_style=False, Dumper=yamlordereddictloader.Dumper)
975
976     yaml_app_field_order = [
977         'Categories',
978         'License',
979         'Web Site',
980         'Source Code',
981         'Issue Tracker',
982         'Donate',
983         'Bitcoin',
984         '\n',
985         'Auto Name',
986         'Summary',
987         'Description',
988         '\n',
989         'Repo Type',
990         'Repo',
991         '\n',
992         'Auto Update Mode',
993         'Update Check Mode',
994         'Current Version',
995         'Current Version Code',
996     ]
997
998     preformated = ruamel.yaml.comments.CommentedMap()
999     insert_newline = False
1000     for field in yaml_app_field_order:
1001         if field is '\n':
1002             insert_newline = True
1003         else:
1004             f = field.replace(' ', '')
1005             if hasattr(app, f) and getattr(app, f):
1006                 if f in ['Description']:
1007                     preformated.update({f: ruamel.yaml.scalarstring.preserve_literal(getattr(app, f))})
1008                 else:
1009                     preformated.update({f: getattr(app, f)})
1010                 if insert_newline:
1011                     insert_newline = False
1012                     # inserting empty lines is not supported so we add a
1013                     # bogus comment and over-write its value
1014                     preformated.yaml_set_comment_before_after_key(f, 'bogus')
1015                     preformated.ca.items[f][1][0].value = '\n'
1016     # TODO implement dump for builds
1017     del(preformated['builds'])
1018
1019     ruamel.yaml.round_trip_dump(preformated, mf, indent=4, block_seq_indent=2)
1020
1021
1022 def write_yaml(mf, app):
1023
1024     def _class_as_dict_representer(dumper, data):
1025         '''Creates a YAML representation of a App/Build instance'''
1026         return dumper.represent_dict(data)
1027
1028     empty_keys = [k for k, v in app.items() if not v]
1029     for k in empty_keys:
1030         del app[k]
1031
1032     for k in ['added', 'lastUpdated', 'id', 'metadatapath']:
1033         if k in app:
1034             del app[k]
1035
1036     yaml.add_representer(fdroidserver.metadata.App, _class_as_dict_representer)
1037     yaml.add_representer(fdroidserver.metadata.Build, _class_as_dict_representer)
1038     yaml.dump(app, mf, default_flow_style=False)
1039
1040
1041 build_line_sep = re.compile(r'(?<!\\),')
1042 build_cont = re.compile(r'^[ \t]')
1043
1044
1045 def parse_txt_metadata(mf, app):
1046
1047     linedesc = None
1048
1049     def add_buildflag(p, build):
1050         if not p.strip():
1051             warn_or_exception("Empty build flag at {1}"
1052                               .format(buildlines[0], linedesc))
1053         bv = p.split('=', 1)
1054         if len(bv) != 2:
1055             warn_or_exception("Invalid build flag at {0} in {1}"
1056                               .format(buildlines[0], linedesc))
1057
1058         pk, pv = bv
1059         pk = pk.lstrip()
1060         if pk == 'update':
1061             pk = 'androidupdate'  # avoid conflicting with Build(dict).update()
1062         t = flagtype(pk)
1063         if t == TYPE_LIST:
1064             pv = split_list_values(pv)
1065             build[pk] = pv
1066         elif t == TYPE_STRING or t == TYPE_SCRIPT:
1067             build[pk] = pv
1068         elif t == TYPE_BOOL:
1069             build[pk] = _decode_bool(pv)
1070
1071     def parse_buildline(lines):
1072         v = "".join(lines)
1073         parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
1074         if len(parts) < 3:
1075             warn_or_exception("Invalid build format: " + v + " in " + mf.name)
1076         build = Build()
1077         build.versionName = parts[0]
1078         build.versionCode = parts[1]
1079         check_versionCode(build.versionCode)
1080
1081         if parts[2].startswith('!'):
1082             # For backwards compatibility, handle old-style disabling,
1083             # including attempting to extract the commit from the message
1084             build.disable = parts[2][1:]
1085             commit = 'unknown - see disabled'
1086             index = parts[2].rfind('at ')
1087             if index != -1:
1088                 commit = parts[2][index + 3:]
1089                 if commit.endswith(')'):
1090                     commit = commit[:-1]
1091             build.commit = commit
1092         else:
1093             build.commit = parts[2]
1094         for p in parts[3:]:
1095             add_buildflag(p, build)
1096
1097         return build
1098
1099     def check_versionCode(versionCode):
1100         try:
1101             int(versionCode)
1102         except ValueError:
1103             warn_or_exception('Invalid versionCode: "' + versionCode + '" is not an integer!')
1104
1105     def add_comments(key):
1106         if not curcomments:
1107             return
1108         app.comments[key] = list(curcomments)
1109         del curcomments[:]
1110
1111     mode = 0
1112     buildlines = []
1113     multiline_lines = []
1114     curcomments = []
1115     build = None
1116     vc_seen = set()
1117
1118     app.builds = []
1119
1120     c = 0
1121     for line in mf:
1122         c += 1
1123         linedesc = "%s:%d" % (mf.name, c)
1124         line = line.rstrip('\r\n')
1125         if mode == 3:
1126             if build_cont.match(line):
1127                 if line.endswith('\\'):
1128                     buildlines.append(line[:-1].lstrip())
1129                 else:
1130                     buildlines.append(line.lstrip())
1131                     bl = ''.join(buildlines)
1132                     add_buildflag(bl, build)
1133                     del buildlines[:]
1134             else:
1135                 if not build.commit and not build.disable:
1136                     warn_or_exception("No commit specified for {0} in {1}"
1137                                       .format(build.versionName, linedesc))
1138
1139                 app.builds.append(build)
1140                 add_comments('build:' + build.versionCode)
1141                 mode = 0
1142
1143         if mode == 0:
1144             if not line:
1145                 continue
1146             if line.startswith("#"):
1147                 curcomments.append(line[1:].strip())
1148                 continue
1149             try:
1150                 f, v = line.split(':', 1)
1151             except ValueError:
1152                 warn_or_exception("Invalid metadata in " + linedesc)
1153
1154             if f not in app_fields:
1155                 warn_or_exception('Unrecognised app field: ' + f)
1156
1157             # Translate obsolete fields...
1158             if f == 'Market Version':
1159                 f = 'Current Version'
1160             if f == 'Market Version Code':
1161                 f = 'Current Version Code'
1162
1163             f = f.replace(' ', '')
1164
1165             ftype = fieldtype(f)
1166             if ftype not in [TYPE_BUILD, TYPE_BUILD_V2]:
1167                 add_comments(f)
1168             if ftype == TYPE_MULTILINE:
1169                 mode = 1
1170                 if v:
1171                     warn_or_exception("Unexpected text on same line as "
1172                                       + f + " in " + linedesc)
1173             elif ftype == TYPE_STRING:
1174                 app[f] = v
1175             elif ftype == TYPE_LIST:
1176                 app[f] = split_list_values(v)
1177             elif ftype == TYPE_BUILD:
1178                 if v.endswith("\\"):
1179                     mode = 2
1180                     del buildlines[:]
1181                     buildlines.append(v[:-1])
1182                 else:
1183                     build = parse_buildline([v])
1184                     app.builds.append(build)
1185                     add_comments('build:' + app.builds[-1].versionCode)
1186             elif ftype == TYPE_BUILD_V2:
1187                 vv = v.split(',')
1188                 if len(vv) != 2:
1189                     warn_or_exception('Build should have comma-separated',
1190                                       'versionName and versionCode,',
1191                                       'not "{0}", in {1}'.format(v, linedesc))
1192                 build = Build()
1193                 build.versionName = vv[0]
1194                 build.versionCode = vv[1]
1195                 check_versionCode(build.versionCode)
1196
1197                 if build.versionCode in vc_seen:
1198                     warn_or_exception('Duplicate build recipe found for versionCode %s in %s'
1199                                       % (build.versionCode, linedesc))
1200                 vc_seen.add(build.versionCode)
1201                 del buildlines[:]
1202                 mode = 3
1203             elif ftype == TYPE_OBSOLETE:
1204                 pass        # Just throw it away!
1205             else:
1206                 warn_or_exception("Unrecognised field '" + f + "' in " + linedesc)
1207         elif mode == 1:     # Multiline field
1208             if line == '.':
1209                 mode = 0
1210                 app[f] = '\n'.join(multiline_lines)
1211                 del multiline_lines[:]
1212             else:
1213                 multiline_lines.append(line)
1214         elif mode == 2:     # Line continuation mode in Build Version
1215             if line.endswith("\\"):
1216                 buildlines.append(line[:-1])
1217             else:
1218                 buildlines.append(line)
1219                 build = parse_buildline(buildlines)
1220                 app.builds.append(build)
1221                 add_comments('build:' + app.builds[-1].versionCode)
1222                 mode = 0
1223     add_comments(None)
1224
1225     # Mode at end of file should always be 0
1226     if mode == 1:
1227         warn_or_exception(f + " not terminated in " + mf.name)
1228     if mode == 2:
1229         warn_or_exception("Unterminated continuation in " + mf.name)
1230     if mode == 3:
1231         warn_or_exception("Unterminated build in " + mf.name)
1232
1233     return app
1234
1235
1236 def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
1237
1238     def field_to_attr(f):
1239         """
1240         Translates human-readable field names to attribute names, e.g.
1241         'Auto Name' to 'AutoName'
1242         """
1243         return f.replace(' ', '')
1244
1245     def attr_to_field(k):
1246         """
1247         Translates attribute names to human-readable field names, e.g.
1248         'AutoName' to 'Auto Name'
1249         """
1250         if k in app_fields:
1251             return k
1252         f = re.sub(r'([a-z])([A-Z])', r'\1 \2', k)
1253         return f
1254
1255     def w_comments(key):
1256         if key not in app.comments:
1257             return
1258         for line in app.comments[key]:
1259             w_comment(line)
1260
1261     def w_field_always(f, v=None):
1262         key = field_to_attr(f)
1263         if v is None:
1264             v = app.get(key)
1265         w_comments(key)
1266         w_field(f, v)
1267
1268     def w_field_nonempty(f, v=None):
1269         key = field_to_attr(f)
1270         if v is None:
1271             v = app.get(key)
1272         w_comments(key)
1273         if v:
1274             w_field(f, v)
1275
1276     w_field_nonempty('Disabled')
1277     w_field_nonempty('AntiFeatures')
1278     w_field_nonempty('Provides')
1279     w_field_always('Categories')
1280     w_field_always('License')
1281     w_field_nonempty('Author Name')
1282     w_field_nonempty('Author Email')
1283     w_field_nonempty('Author Web Site')
1284     w_field_always('Web Site')
1285     w_field_always('Source Code')
1286     w_field_always('Issue Tracker')
1287     w_field_nonempty('Changelog')
1288     w_field_nonempty('Donate')
1289     w_field_nonempty('FlattrID')
1290     w_field_nonempty('Bitcoin')
1291     w_field_nonempty('Litecoin')
1292     mf.write('\n')
1293     w_field_nonempty('Name')
1294     w_field_nonempty('Auto Name')
1295     w_field_nonempty('Summary')
1296     w_field_nonempty('Description', description_txt(app.Description))
1297     mf.write('\n')
1298     if app.RequiresRoot:
1299         w_field_always('Requires Root', 'yes')
1300         mf.write('\n')
1301     if app.RepoType:
1302         w_field_always('Repo Type')
1303         w_field_always('Repo')
1304         if app.Binaries:
1305             w_field_always('Binaries')
1306         mf.write('\n')
1307
1308     for build in app.builds:
1309
1310         if build.versionName == "Ignore":
1311             continue
1312
1313         w_comments('build:%s' % build.versionCode)
1314         w_build(build)
1315         mf.write('\n')
1316
1317     if app.MaintainerNotes:
1318         w_field_always('Maintainer Notes', app.MaintainerNotes)
1319         mf.write('\n')
1320
1321     w_field_nonempty('Archive Policy')
1322     w_field_always('Auto Update Mode')
1323     w_field_always('Update Check Mode')
1324     w_field_nonempty('Update Check Ignore')
1325     w_field_nonempty('Vercode Operation')
1326     w_field_nonempty('Update Check Name')
1327     w_field_nonempty('Update Check Data')
1328     if app.CurrentVersion:
1329         w_field_always('Current Version')
1330         w_field_always('Current Version Code')
1331     if app.NoSourceSince:
1332         mf.write('\n')
1333         w_field_always('No Source Since')
1334     w_comments(None)
1335
1336
1337 # Write a metadata file in txt format.
1338 #
1339 # 'mf'      - Writer interface (file, StringIO, ...)
1340 # 'app'     - The app data
1341 def write_txt(mf, app):
1342
1343     def w_comment(line):
1344         mf.write("# %s\n" % line)
1345
1346     def w_field(f, v):
1347         t = fieldtype(f)
1348         if t == TYPE_LIST:
1349             v = ','.join(v)
1350         elif t == TYPE_MULTILINE:
1351             v = '\n' + v + '\n.'
1352         mf.write("%s:%s\n" % (f, v))
1353
1354     def w_build(build):
1355         mf.write("Build:%s,%s\n" % (build.versionName, build.versionCode))
1356
1357         for f in build_flags_order:
1358             v = build.get(f)
1359             if not v:
1360                 continue
1361
1362             t = flagtype(f)
1363             if f == 'androidupdate':
1364                 f = 'update'  # avoid conflicting with Build(dict).update()
1365             mf.write('    %s=' % f)
1366             if t == TYPE_STRING:
1367                 mf.write(v)
1368             elif t == TYPE_BOOL:
1369                 mf.write('yes')
1370             elif t == TYPE_SCRIPT:
1371                 first = True
1372                 for s in v.split(' && '):
1373                     if first:
1374                         first = False
1375                     else:
1376                         mf.write(' && \\\n        ')
1377                     mf.write(s.strip())
1378             elif t == TYPE_LIST:
1379                 mf.write(','.join(v))
1380
1381             mf.write('\n')
1382
1383     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1384
1385
1386 def write_metadata(metadatapath, app):
1387     _, ext = fdroidserver.common.get_extension(metadatapath)
1388     accepted = fdroidserver.common.config['accepted_formats']
1389     if ext not in accepted:
1390         warn_or_exception('Cannot write "%s", not an accepted format, use: %s'
1391                           % (metadatapath, ', '.join(accepted)))
1392
1393     with open(metadatapath, 'w', encoding='utf8') as mf:
1394         if ext == 'txt':
1395             return write_txt(mf, app)
1396         elif ext == 'yml':
1397             return write_yaml(mf, app)
1398     warn_or_exception('Unknown metadata format: %s' % metadatapath)
1399
1400
1401 def add_metadata_arguments(parser):
1402     '''add common command line flags related to metadata processing'''
1403     parser.add_argument("-W", default='error',
1404                         help="force errors to be warnings, or ignore")