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