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