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