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