chiark / gitweb /
makebuildserver: add NDK r15b
[fdroidserver.git] / fdroidserver / metadata.py
1 #!/usr/bin/env python3
2 #
3 # metadata.py - part of the FDroid server tools
4 # Copyright (C) 2013, Ciaran Gultnieks, ciaran@ciarang.com
5 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU Affero General Public License for more details.
16 #
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 import json
21 import os
22 import re
23 import glob
24 import html
25 import logging
26 import textwrap
27 import io
28
29 import yaml
30 # use libyaml if it is available
31 try:
32     from yaml import CLoader
33     YamlLoader = CLoader
34 except ImportError:
35     from yaml import Loader
36     YamlLoader = Loader
37
38 import fdroidserver.common
39 from fdroidserver.exception import MetaDataException
40
41 srclibs = None
42 warnings_action = None
43
44
45 def warn_or_exception(value):
46     '''output warning or Exception depending on -W'''
47     if warnings_action == 'ignore':
48         pass
49     elif warnings_action == 'error':
50         raise MetaDataException(value)
51     else:
52         logging.warn(value)
53
54
55 # To filter which ones should be written to the metadata files if
56 # present
57 app_fields = set([
58     'Disabled',
59     'AntiFeatures',
60     'Provides',
61     'Categories',
62     'License',
63     'Author Name',
64     'Author Email',
65     'Author Web Site',
66     'Web Site',
67     'Source Code',
68     'Issue Tracker',
69     'Changelog',
70     'Donate',
71     'FlattrID',
72     'Bitcoin',
73     'Litecoin',
74     'Name',
75     'Auto Name',
76     'Summary',
77     'Description',
78     'Requires Root',
79     'Repo Type',
80     'Repo',
81     'Binaries',
82     'Maintainer Notes',
83     'Archive Policy',
84     'Auto Update Mode',
85     'Update Check Mode',
86     'Update Check Ignore',
87     'Vercode Operation',
88     'Update Check Name',
89     'Update Check Data',
90     'Current Version',
91     'Current Version Code',
92     'No Source Since',
93     'Build',
94
95     'comments',  # For formats that don't do inline comments
96     'builds',    # For formats that do builds as a list
97 ])
98
99
100 class App(dict):
101
102     def __init__(self, copydict=None):
103         if copydict:
104             super().__init__(copydict)
105             return
106         super().__init__()
107
108         self.Disabled = None
109         self.AntiFeatures = []
110         self.Provides = None
111         self.Categories = ['None']
112         self.License = 'Unknown'
113         self.AuthorName = None
114         self.AuthorEmail = None
115         self.AuthorWebSite = None
116         self.WebSite = ''
117         self.SourceCode = ''
118         self.IssueTracker = ''
119         self.Changelog = ''
120         self.Donate = None
121         self.FlattrID = None
122         self.Bitcoin = None
123         self.Litecoin = None
124         self.Name = None
125         self.AutoName = ''
126         self.Summary = ''
127         self.Description = ''
128         self.RequiresRoot = False
129         self.RepoType = ''
130         self.Repo = ''
131         self.Binaries = None
132         self.MaintainerNotes = ''
133         self.ArchivePolicy = None
134         self.AutoUpdateMode = 'None'
135         self.UpdateCheckMode = 'None'
136         self.UpdateCheckIgnore = None
137         self.VercodeOperation = None
138         self.UpdateCheckName = None
139         self.UpdateCheckData = None
140         self.CurrentVersion = ''
141         self.CurrentVersionCode = None
142         self.NoSourceSince = ''
143
144         self.id = None
145         self.metadatapath = None
146         self.builds = []
147         self.comments = {}
148         self.added = None
149         self.lastUpdated = None
150
151     def __getattr__(self, name):
152         if name in self:
153             return self[name]
154         else:
155             raise AttributeError("No such attribute: " + name)
156
157     def __setattr__(self, name, value):
158         self[name] = value
159
160     def __delattr__(self, name):
161         if name in self:
162             del self[name]
163         else:
164             raise AttributeError("No such attribute: " + name)
165
166     def get_last_build(self):
167         if len(self.builds) > 0:
168             return self.builds[-1]
169         else:
170             return Build()
171
172
173 TYPE_UNKNOWN = 0
174 TYPE_OBSOLETE = 1
175 TYPE_STRING = 2
176 TYPE_BOOL = 3
177 TYPE_LIST = 4
178 TYPE_SCRIPT = 5
179 TYPE_MULTILINE = 6
180 TYPE_BUILD = 7
181 TYPE_BUILD_V2 = 8
182
183 fieldtypes = {
184     'Description': TYPE_MULTILINE,
185     'MaintainerNotes': TYPE_MULTILINE,
186     'Categories': TYPE_LIST,
187     'AntiFeatures': TYPE_LIST,
188     'BuildVersion': TYPE_BUILD,
189     'Build': TYPE_BUILD_V2,
190     'UseBuilt': TYPE_OBSOLETE,
191 }
192
193
194 def fieldtype(name):
195     name = name.replace(' ', '')
196     if name in fieldtypes:
197         return fieldtypes[name]
198     return TYPE_STRING
199
200
201 # In the order in which they are laid out on files
202 build_flags_order = [
203     'disable',
204     'commit',
205     'subdir',
206     'submodules',
207     'init',
208     'patch',
209     'gradle',
210     'maven',
211     'kivy',
212     '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     yaml.add_representer(fdroidserver.metadata.Build, _class_as_dict_representer)
973     yaml.dump(app, mf, default_flow_style=False)
974
975
976 build_line_sep = re.compile(r'(?<!\\),')
977 build_cont = re.compile(r'^[ \t]')
978
979
980 def parse_txt_metadata(mf, app):
981
982     linedesc = None
983
984     def add_buildflag(p, build):
985         if not p.strip():
986             warn_or_exception("Empty build flag at {1}"
987                               .format(buildlines[0], linedesc))
988         bv = p.split('=', 1)
989         if len(bv) != 2:
990             warn_or_exception("Invalid build flag at {0} in {1}"
991                               .format(buildlines[0], linedesc))
992
993         pk, pv = bv
994         pk = pk.lstrip()
995         if pk == 'update':
996             pk = 'androidupdate'  # avoid conflicting with Build(dict).update()
997         t = flagtype(pk)
998         if t == TYPE_LIST:
999             pv = split_list_values(pv)
1000             build[pk] = pv
1001         elif t == TYPE_STRING or t == TYPE_SCRIPT:
1002             build[pk] = pv
1003         elif t == TYPE_BOOL:
1004             build[pk] = _decode_bool(pv)
1005
1006     def parse_buildline(lines):
1007         v = "".join(lines)
1008         parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
1009         if len(parts) < 3:
1010             warn_or_exception("Invalid build format: " + v + " in " + mf.name)
1011         build = Build()
1012         build.versionName = parts[0]
1013         build.versionCode = parts[1]
1014         check_versionCode(build.versionCode)
1015
1016         if parts[2].startswith('!'):
1017             # For backwards compatibility, handle old-style disabling,
1018             # including attempting to extract the commit from the message
1019             build.disable = parts[2][1:]
1020             commit = 'unknown - see disabled'
1021             index = parts[2].rfind('at ')
1022             if index != -1:
1023                 commit = parts[2][index + 3:]
1024                 if commit.endswith(')'):
1025                     commit = commit[:-1]
1026             build.commit = commit
1027         else:
1028             build.commit = parts[2]
1029         for p in parts[3:]:
1030             add_buildflag(p, build)
1031
1032         return build
1033
1034     def check_versionCode(versionCode):
1035         try:
1036             int(versionCode)
1037         except ValueError:
1038             warn_or_exception('Invalid versionCode: "' + versionCode + '" is not an integer!')
1039
1040     def add_comments(key):
1041         if not curcomments:
1042             return
1043         app.comments[key] = list(curcomments)
1044         del curcomments[:]
1045
1046     mode = 0
1047     buildlines = []
1048     multiline_lines = []
1049     curcomments = []
1050     build = None
1051     vc_seen = set()
1052
1053     app.builds = []
1054
1055     c = 0
1056     for line in mf:
1057         c += 1
1058         linedesc = "%s:%d" % (mf.name, c)
1059         line = line.rstrip('\r\n')
1060         if mode == 3:
1061             if build_cont.match(line):
1062                 if line.endswith('\\'):
1063                     buildlines.append(line[:-1].lstrip())
1064                 else:
1065                     buildlines.append(line.lstrip())
1066                     bl = ''.join(buildlines)
1067                     add_buildflag(bl, build)
1068                     del buildlines[:]
1069             else:
1070                 if not build.commit and not build.disable:
1071                     warn_or_exception("No commit specified for {0} in {1}"
1072                                       .format(build.versionName, linedesc))
1073
1074                 app.builds.append(build)
1075                 add_comments('build:' + build.versionCode)
1076                 mode = 0
1077
1078         if mode == 0:
1079             if not line:
1080                 continue
1081             if line.startswith("#"):
1082                 curcomments.append(line[1:].strip())
1083                 continue
1084             try:
1085                 f, v = line.split(':', 1)
1086             except ValueError:
1087                 warn_or_exception("Invalid metadata in " + linedesc)
1088
1089             if f not in app_fields:
1090                 warn_or_exception('Unrecognised app field: ' + f)
1091
1092             # Translate obsolete fields...
1093             if f == 'Market Version':
1094                 f = 'Current Version'
1095             if f == 'Market Version Code':
1096                 f = 'Current Version Code'
1097
1098             f = f.replace(' ', '')
1099
1100             ftype = fieldtype(f)
1101             if ftype not in [TYPE_BUILD, TYPE_BUILD_V2]:
1102                 add_comments(f)
1103             if ftype == TYPE_MULTILINE:
1104                 mode = 1
1105                 if v:
1106                     warn_or_exception("Unexpected text on same line as "
1107                                       + f + " in " + linedesc)
1108             elif ftype == TYPE_STRING:
1109                 app[f] = v
1110             elif ftype == TYPE_LIST:
1111                 app[f] = split_list_values(v)
1112             elif ftype == TYPE_BUILD:
1113                 if v.endswith("\\"):
1114                     mode = 2
1115                     del buildlines[:]
1116                     buildlines.append(v[:-1])
1117                 else:
1118                     build = parse_buildline([v])
1119                     app.builds.append(build)
1120                     add_comments('build:' + app.builds[-1].versionCode)
1121             elif ftype == TYPE_BUILD_V2:
1122                 vv = v.split(',')
1123                 if len(vv) != 2:
1124                     warn_or_exception('Build should have comma-separated',
1125                                       'versionName and versionCode,',
1126                                       'not "{0}", in {1}'.format(v, linedesc))
1127                 build = Build()
1128                 build.versionName = vv[0]
1129                 build.versionCode = vv[1]
1130                 check_versionCode(build.versionCode)
1131
1132                 if build.versionCode in vc_seen:
1133                     warn_or_exception('Duplicate build recipe found for versionCode %s in %s'
1134                                       % (build.versionCode, linedesc))
1135                 vc_seen.add(build.versionCode)
1136                 del buildlines[:]
1137                 mode = 3
1138             elif ftype == TYPE_OBSOLETE:
1139                 pass        # Just throw it away!
1140             else:
1141                 warn_or_exception("Unrecognised field '" + f + "' in " + linedesc)
1142         elif mode == 1:     # Multiline field
1143             if line == '.':
1144                 mode = 0
1145                 app[f] = '\n'.join(multiline_lines)
1146                 del multiline_lines[:]
1147             else:
1148                 multiline_lines.append(line)
1149         elif mode == 2:     # Line continuation mode in Build Version
1150             if line.endswith("\\"):
1151                 buildlines.append(line[:-1])
1152             else:
1153                 buildlines.append(line)
1154                 build = parse_buildline(buildlines)
1155                 app.builds.append(build)
1156                 add_comments('build:' + app.builds[-1].versionCode)
1157                 mode = 0
1158     add_comments(None)
1159
1160     # Mode at end of file should always be 0
1161     if mode == 1:
1162         warn_or_exception(f + " not terminated in " + mf.name)
1163     if mode == 2:
1164         warn_or_exception("Unterminated continuation in " + mf.name)
1165     if mode == 3:
1166         warn_or_exception("Unterminated build in " + mf.name)
1167
1168     return app
1169
1170
1171 def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
1172
1173     def field_to_attr(f):
1174         """
1175         Translates human-readable field names to attribute names, e.g.
1176         'Auto Name' to 'AutoName'
1177         """
1178         return f.replace(' ', '')
1179
1180     def attr_to_field(k):
1181         """
1182         Translates attribute names to human-readable field names, e.g.
1183         'AutoName' to 'Auto Name'
1184         """
1185         if k in app_fields:
1186             return k
1187         f = re.sub(r'([a-z])([A-Z])', r'\1 \2', k)
1188         return f
1189
1190     def w_comments(key):
1191         if key not in app.comments:
1192             return
1193         for line in app.comments[key]:
1194             w_comment(line)
1195
1196     def w_field_always(f, v=None):
1197         key = field_to_attr(f)
1198         if v is None:
1199             v = app.get(key)
1200         w_comments(key)
1201         w_field(f, v)
1202
1203     def w_field_nonempty(f, v=None):
1204         key = field_to_attr(f)
1205         if v is None:
1206             v = app.get(key)
1207         w_comments(key)
1208         if v:
1209             w_field(f, v)
1210
1211     w_field_nonempty('Disabled')
1212     w_field_nonempty('AntiFeatures')
1213     w_field_nonempty('Provides')
1214     w_field_always('Categories')
1215     w_field_always('License')
1216     w_field_nonempty('Author Name')
1217     w_field_nonempty('Author Email')
1218     w_field_nonempty('Author Web Site')
1219     w_field_always('Web Site')
1220     w_field_always('Source Code')
1221     w_field_always('Issue Tracker')
1222     w_field_nonempty('Changelog')
1223     w_field_nonempty('Donate')
1224     w_field_nonempty('FlattrID')
1225     w_field_nonempty('Bitcoin')
1226     w_field_nonempty('Litecoin')
1227     mf.write('\n')
1228     w_field_nonempty('Name')
1229     w_field_nonempty('Auto Name')
1230     w_field_nonempty('Summary')
1231     w_field_nonempty('Description', description_txt(app.Description))
1232     mf.write('\n')
1233     if app.RequiresRoot:
1234         w_field_always('Requires Root', 'yes')
1235         mf.write('\n')
1236     if app.RepoType:
1237         w_field_always('Repo Type')
1238         w_field_always('Repo')
1239         if app.Binaries:
1240             w_field_always('Binaries')
1241         mf.write('\n')
1242
1243     for build in app.builds:
1244
1245         if build.versionName == "Ignore":
1246             continue
1247
1248         w_comments('build:%s' % build.versionCode)
1249         w_build(build)
1250         mf.write('\n')
1251
1252     if app.MaintainerNotes:
1253         w_field_always('Maintainer Notes', app.MaintainerNotes)
1254         mf.write('\n')
1255
1256     w_field_nonempty('Archive Policy')
1257     w_field_always('Auto Update Mode')
1258     w_field_always('Update Check Mode')
1259     w_field_nonempty('Update Check Ignore')
1260     w_field_nonempty('Vercode Operation')
1261     w_field_nonempty('Update Check Name')
1262     w_field_nonempty('Update Check Data')
1263     if app.CurrentVersion:
1264         w_field_always('Current Version')
1265         w_field_always('Current Version Code')
1266     if app.NoSourceSince:
1267         mf.write('\n')
1268         w_field_always('No Source Since')
1269     w_comments(None)
1270
1271
1272 # Write a metadata file in txt format.
1273 #
1274 # 'mf'      - Writer interface (file, StringIO, ...)
1275 # 'app'     - The app data
1276 def write_txt(mf, app):
1277
1278     def w_comment(line):
1279         mf.write("# %s\n" % line)
1280
1281     def w_field(f, v):
1282         t = fieldtype(f)
1283         if t == TYPE_LIST:
1284             v = ','.join(v)
1285         elif t == TYPE_MULTILINE:
1286             v = '\n' + v + '\n.'
1287         mf.write("%s:%s\n" % (f, v))
1288
1289     def w_build(build):
1290         mf.write("Build:%s,%s\n" % (build.versionName, build.versionCode))
1291
1292         for f in build_flags_order:
1293             v = build.get(f)
1294             if not v:
1295                 continue
1296
1297             t = flagtype(f)
1298             if f == 'androidupdate':
1299                 f = 'update'  # avoid conflicting with Build(dict).update()
1300             mf.write('    %s=' % f)
1301             if t == TYPE_STRING:
1302                 mf.write(v)
1303             elif t == TYPE_BOOL:
1304                 mf.write('yes')
1305             elif t == TYPE_SCRIPT:
1306                 first = True
1307                 for s in v.split(' && '):
1308                     if first:
1309                         first = False
1310                     else:
1311                         mf.write(' && \\\n        ')
1312                     mf.write(s.strip())
1313             elif t == TYPE_LIST:
1314                 mf.write(','.join(v))
1315
1316             mf.write('\n')
1317
1318     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1319
1320
1321 def write_metadata(metadatapath, app):
1322     _, ext = fdroidserver.common.get_extension(metadatapath)
1323     accepted = fdroidserver.common.config['accepted_formats']
1324     if ext not in accepted:
1325         warn_or_exception('Cannot write "%s", not an accepted format, use: %s'
1326                           % (metadatapath, ', '.join(accepted)))
1327
1328     with open(metadatapath, 'w', encoding='utf8') as mf:
1329         if ext == 'txt':
1330             return write_txt(mf, app)
1331         elif ext == 'yml':
1332             return write_yaml(mf, app)
1333     warn_or_exception('Unknown metadata format: %s' % metadatapath)
1334
1335
1336 def add_metadata_arguments(parser):
1337     '''add common command line flags related to metadata processing'''
1338     parser.add_argument("-W", default='error',
1339                         help="force errors to be warnings, or ignore")