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