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