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