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