chiark / gitweb /
0d34798d56d3f3532fc3993f0300b8606a46f301
[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 in app_fields:
213         return 'string'
214     return 'unknown'
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(s):
627     ps = DescriptionFormatter(None)
628     for line in s.splitlines():
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(s):
638     ps = DescriptionFormatter(None)
639     for line in s.splitlines():
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(s, linkres):
648     ps = DescriptionFormatter(linkres)
649     for line in s.splitlines():
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     metafile.close()
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 # Port legacy ';' separators
766 list_sep = re.compile(r'[,;]')
767
768
769 def split_list_values(s):
770     res = []
771     for v in re.split(list_sep, s):
772         if not v:
773             continue
774         v = v.strip()
775         if not v:
776             continue
777         res.append(v)
778     return res
779
780
781 def get_default_app_info(metadatapath=None):
782     if metadatapath is None:
783         appid = None
784     else:
785         appid, _ = common.get_extension(os.path.basename(metadatapath))
786
787     app = App()
788     app.metadatapath = metadatapath
789     if appid is not None:
790         app.id = appid
791
792     return app
793
794
795 def sorted_builds(builds):
796     return sorted(builds, key=lambda build: int(build.vercode))
797
798
799 esc_newlines = re.compile(r'\\( |\n)')
800
801
802 # This function uses __dict__ to be faster
803 def post_metadata_parse(app):
804
805     for k, v in app.__dict__.iteritems():
806         if type(v) in (float, int):
807             app.__dict__[k] = str(v)
808
809     for build in app.builds:
810         for k, v in build.__dict__.iteritems():
811
812             if type(v) in (float, int):
813                 build.__dict__[k] = str(v)
814                 continue
815             ftype = flagtype(k)
816
817             if ftype == 'script':
818                 build.__dict__[k] = re.sub(esc_newlines, '', v).lstrip().rstrip()
819             elif ftype == 'bool':
820                 # TODO handle this using <xsd:element type="xsd:boolean> in a schema
821                 if isinstance(v, basestring) and v == 'true':
822                     build.__dict__[k] = True
823             elif ftype == 'string':
824                 if isinstance(v, bool) and v:
825                     build.__dict__[k] = 'yes'
826
827     if not app.Description:
828         app.Description = 'No description available'
829
830     app.builds = sorted_builds(app.builds)
831
832
833 # Parse metadata for a single application.
834 #
835 #  'metadatapath' - the filename to read. The package id for the application comes
836 #               from this filename. Pass None to get a blank entry.
837 #
838 # Returns a dictionary containing all the details of the application. There are
839 # two major kinds of information in the dictionary. Keys beginning with capital
840 # letters correspond directory to identically named keys in the metadata file.
841 # Keys beginning with lower case letters are generated in one way or another,
842 # and are not found verbatim in the metadata.
843 #
844 # Known keys not originating from the metadata are:
845 #
846 #  'builds'           - a list of dictionaries containing build information
847 #                       for each defined build
848 #  'comments'         - a list of comments from the metadata file. Each is
849 #                       a list of the form [field, comment] where field is
850 #                       the name of the field it preceded in the metadata
851 #                       file. Where field is None, the comment goes at the
852 #                       end of the file. Alternatively, 'build:version' is
853 #                       for a comment before a particular build version.
854 #  'descriptionlines' - original lines of description as formatted in the
855 #                       metadata file.
856 #
857
858
859 def _decode_list(data):
860     '''convert items in a list from unicode to basestring'''
861     rv = []
862     for item in data:
863         if isinstance(item, unicode):
864             item = item.encode('utf-8')
865         elif isinstance(item, list):
866             item = _decode_list(item)
867         elif isinstance(item, dict):
868             item = _decode_dict(item)
869         rv.append(item)
870     return rv
871
872
873 def _decode_dict(data):
874     '''convert items in a dict from unicode to basestring'''
875     rv = {}
876     for k, v in data.iteritems():
877         if isinstance(k, unicode):
878             k = k.encode('utf-8')
879         if isinstance(v, unicode):
880             v = v.encode('utf-8')
881         elif isinstance(v, list):
882             v = _decode_list(v)
883         elif isinstance(v, dict):
884             v = _decode_dict(v)
885         rv[k] = v
886     return rv
887
888
889 def parse_metadata(metadatapath):
890     _, ext = common.get_extension(metadatapath)
891     accepted = common.config['accepted_formats']
892     if ext not in accepted:
893         raise MetaDataException('"%s" is not an accepted format, convert to: %s' % (
894             metadatapath, ', '.join(accepted)))
895
896     app = None
897     if ext == 'txt':
898         app = parse_txt_metadata(metadatapath)
899     elif ext == 'json':
900         app = parse_json_metadata(metadatapath)
901     elif ext == 'xml':
902         app = parse_xml_metadata(metadatapath)
903     elif ext == 'yaml':
904         app = parse_yaml_metadata(metadatapath)
905     else:
906         raise MetaDataException('Unknown metadata format: %s' % metadatapath)
907
908     post_metadata_parse(app)
909     return app
910
911
912 def parse_json_metadata(metadatapath):
913
914     app = get_default_app_info(metadatapath)
915
916     # fdroid metadata is only strings and booleans, no floats or ints. And
917     # json returns unicode, and fdroidserver still uses plain python strings
918     # TODO create schema using https://pypi.python.org/pypi/jsonschema
919     jsoninfo = None
920     with open(metadatapath, 'r') as f:
921         jsoninfo = json.load(f, object_hook=_decode_dict,
922                              parse_int=lambda s: s,
923                              parse_float=lambda s: s)
924     app.update_fields(jsoninfo)
925     for f in ['Description', 'Maintainer Notes']:
926         v = app.get_field(f)
927         app.set_field(f, '\n'.join(v))
928     return app
929
930
931 def parse_xml_metadata(metadatapath):
932
933     app = get_default_app_info(metadatapath)
934
935     tree = ElementTree.ElementTree(file=metadatapath)
936     root = tree.getroot()
937
938     if root.tag != 'resources':
939         raise MetaDataException('%s does not have root as <resources></resources>!' % metadatapath)
940
941     for child in root:
942         if child.tag != 'builds':
943             # builds does not have name="" attrib
944             name = child.attrib['name']
945
946         if child.tag == 'string':
947             app.set_field(name, child.text)
948         elif child.tag == 'string-array':
949             for item in child:
950                 app.append_field(name, item.text)
951         elif child.tag == 'builds':
952             for b in child:
953                 build = Build()
954                 for key in b:
955                     build.set_flag(key.tag, key.text)
956                 app.builds.append(build)
957
958     # TODO handle this using <xsd:element type="xsd:boolean> in a schema
959     if not isinstance(app.RequiresRoot, bool):
960         if app.RequiresRoot == 'true':
961             app.RequiresRoot = True
962         else:
963             app.RequiresRoot = False
964
965     return app
966
967
968 def parse_yaml_metadata(metadatapath):
969
970     app = get_default_app_info(metadatapath)
971
972     yamlinfo = None
973     with open(metadatapath, 'r') as f:
974         yamlinfo = yaml.load(f, Loader=YamlLoader)
975     app.update_fields(yamlinfo)
976     return app
977
978
979 build_line_sep = re.compile(r'(?<!\\),')
980 build_cont = re.compile(r'^[ \t]')
981
982
983 def parse_txt_metadata(metadatapath):
984
985     linedesc = None
986
987     def add_buildflag(p, build):
988         if not p.strip():
989             raise MetaDataException("Empty build flag at {1}"
990                                     .format(buildlines[0], linedesc))
991         bv = p.split('=', 1)
992         if len(bv) != 2:
993             raise MetaDataException("Invalid build flag at {0} in {1}"
994                                     .format(buildlines[0], linedesc))
995
996         pk, pv = bv
997         pk = pk.lstrip()
998         t = flagtype(pk)
999         if t == 'list':
1000             pv = split_list_values(pv)
1001             build.set_flag(pk, pv)
1002         elif t == 'string' or t == 'script':
1003             build.set_flag(pk, pv)
1004         elif t == 'bool':
1005             if pv == 'yes':
1006                 build.set_flag(pk, True)
1007
1008     def parse_buildline(lines):
1009         v = "".join(lines)
1010         parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
1011         if len(parts) < 3:
1012             raise MetaDataException("Invalid build format: " + v + " in " + metafile.name)
1013         build = Build()
1014         build.origlines = lines
1015         build.version = parts[0]
1016         build.vercode = parts[1]
1017         if parts[2].startswith('!'):
1018             # For backwards compatibility, handle old-style disabling,
1019             # including attempting to extract the commit from the message
1020             build.disable = parts[2][1:]
1021             commit = 'unknown - see disabled'
1022             index = parts[2].rfind('at ')
1023             if index != -1:
1024                 commit = parts[2][index + 3:]
1025                 if commit.endswith(')'):
1026                     commit = commit[:-1]
1027             build.commit = commit
1028         else:
1029             build.commit = parts[2]
1030         for p in parts[3:]:
1031             add_buildflag(p, build)
1032
1033         return build
1034
1035     def add_comments(key):
1036         if not curcomments:
1037             return
1038         app.comments[key] = list(curcomments)
1039         del curcomments[:]
1040
1041     app = get_default_app_info(metadatapath)
1042     metafile = open(metadatapath, "r")
1043
1044     mode = 0
1045     buildlines = []
1046     multiline_lines = []
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 build_cont.match(line):
1058                 if line.endswith('\\'):
1059                     buildlines.append(line[:-1].lstrip())
1060                 else:
1061                     buildlines.append(line.lstrip())
1062                     bl = ''.join(buildlines)
1063                     add_buildflag(bl, build)
1064                     del buildlines[:]
1065             else:
1066                 if not build.commit and not build.disable:
1067                     raise MetaDataException("No commit specified for {0} in {1}"
1068                                             .format(build.version, linedesc))
1069
1070                 app.builds.append(build)
1071                 add_comments('build:' + build.vercode)
1072                 mode = 0
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             ftype = metafieldtype(f)
1094             if ftype not in ['build', 'buildv2']:
1095                 add_comments(f)
1096             if ftype == 'multiline':
1097                 mode = 1
1098                 if v:
1099                     raise MetaDataException("Unexpected text on same line as " + f + " in " + linedesc)
1100             elif ftype == 'string':
1101                 app.set_field(f, v)
1102             elif ftype == 'list':
1103                 app.set_field(f, split_list_values(v))
1104             elif ftype == 'build':
1105                 if v.endswith("\\"):
1106                     mode = 2
1107                     del buildlines[:]
1108                     buildlines.append(v[:-1])
1109                 else:
1110                     build = parse_buildline([v])
1111                     app.builds.append(build)
1112                     add_comments('build:' + app.builds[-1].vercode)
1113             elif ftype == 'buildv2':
1114                 build = Build()
1115                 vv = v.split(',')
1116                 if len(vv) != 2:
1117                     raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
1118                                             .format(v, linedesc))
1119                 build.version = vv[0]
1120                 build.vercode = vv[1]
1121                 if build.vercode in vc_seen:
1122                     raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
1123                                             build.vercode, linedesc))
1124                 vc_seen[build.vercode] = True
1125                 del buildlines[:]
1126                 mode = 3
1127             elif ftype == 'obsolete':
1128                 pass        # Just throw it away!
1129             else:
1130                 raise MetaDataException("Unrecognised field type for " + f + " in " + linedesc)
1131         elif mode == 1:     # Multiline field
1132             if line == '.':
1133                 mode = 0
1134                 app.set_field(f, '\n'.join(multiline_lines))
1135                 del multiline_lines[:]
1136             else:
1137                 multiline_lines.append(line)
1138         elif mode == 2:     # Line continuation mode in Build Version
1139             if line.endswith("\\"):
1140                 buildlines.append(line[:-1])
1141             else:
1142                 buildlines.append(line)
1143                 build = parse_buildline(buildlines)
1144                 app.builds.append(build)
1145                 add_comments('build:' + app.builds[-1].vercode)
1146                 mode = 0
1147     add_comments(None)
1148     metafile.close()
1149
1150     # Mode at end of file should always be 0
1151     if mode == 1:
1152         raise MetaDataException(f + " not terminated in " + metafile.name)
1153     if mode == 2:
1154         raise MetaDataException("Unterminated continuation in " + metafile.name)
1155     if mode == 3:
1156         raise MetaDataException("Unterminated build in " + metafile.name)
1157
1158     return app
1159
1160
1161 def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
1162
1163     def w_comments(key):
1164         if key not in app.comments:
1165             return
1166         for line in app.comments[key]:
1167             w_comment(line)
1168
1169     def w_field_always(f, v=None):
1170         if v is None:
1171             v = app.get_field(f)
1172         w_comments(f)
1173         w_field(f, v)
1174
1175     def w_field_nonempty(f, v=None):
1176         if v is None:
1177             v = app.get_field(f)
1178         w_comments(f)
1179         if v:
1180             w_field(f, v)
1181
1182     w_field_nonempty('Disabled')
1183     if app.AntiFeatures:
1184         w_field_always('AntiFeatures')
1185     w_field_nonempty('Provides')
1186     w_field_always('Categories')
1187     w_field_always('License')
1188     w_field_always('Web Site')
1189     w_field_always('Source Code')
1190     w_field_always('Issue Tracker')
1191     w_field_nonempty('Changelog')
1192     w_field_nonempty('Donate')
1193     w_field_nonempty('FlattrID')
1194     w_field_nonempty('Bitcoin')
1195     w_field_nonempty('Litecoin')
1196     mf.write('\n')
1197     w_field_nonempty('Name')
1198     w_field_nonempty('Auto Name')
1199     w_field_always('Summary')
1200     w_field_always('Description', description_txt(app.Description))
1201     mf.write('\n')
1202     if app.RequiresRoot:
1203         w_field_always('Requires Root', 'yes')
1204         mf.write('\n')
1205     if app.RepoType:
1206         w_field_always('Repo Type')
1207         w_field_always('Repo')
1208         if app.Binaries:
1209             w_field_always('Binaries')
1210         mf.write('\n')
1211
1212     for build in sorted_builds(app.builds):
1213
1214         if build.version == "Ignore":
1215             continue
1216
1217         w_comments('build:' + build.vercode)
1218         w_build(build)
1219         mf.write('\n')
1220
1221     if app.MaintainerNotes:
1222         w_field_always('Maintainer Notes', app.MaintainerNotes)
1223         mf.write('\n')
1224
1225     w_field_nonempty('Archive Policy')
1226     w_field_always('Auto Update Mode')
1227     w_field_always('Update Check Mode')
1228     w_field_nonempty('Update Check Ignore')
1229     w_field_nonempty('Vercode Operation')
1230     w_field_nonempty('Update Check Name')
1231     w_field_nonempty('Update Check Data')
1232     if app.CurrentVersion:
1233         w_field_always('Current Version')
1234         w_field_always('Current Version Code')
1235     if app.NoSourceSince:
1236         mf.write('\n')
1237         w_field_always('No Source Since')
1238     w_comments(None)
1239
1240
1241 # Write a metadata file in txt format.
1242 #
1243 # 'mf'      - Writer interface (file, StringIO, ...)
1244 # 'app'     - The app data
1245 def write_txt_metadata(mf, app):
1246
1247     def w_comment(line):
1248         mf.write("# %s\n" % line)
1249
1250     def w_field(f, v):
1251         t = metafieldtype(f)
1252         if t == 'list':
1253             v = ','.join(v)
1254         elif t == 'multiline':
1255             v = '\n' + v + '\n.'
1256         mf.write("%s:%s\n" % (f, v))
1257
1258     def w_build(build):
1259         mf.write("Build:%s,%s\n" % (build.version, build.vercode))
1260
1261         for f in build_flags_order:
1262             v = build.get_flag(f)
1263             if not v:
1264                 continue
1265
1266             t = flagtype(f)
1267             out = '    %s=' % f
1268             if t == 'string':
1269                 out += v
1270             elif t == 'bool':
1271                 out += 'yes'
1272             elif t == 'script':
1273                 out += '&& \\\n        '.join([s.lstrip() for s in v.split('&& ')])
1274             elif t == 'list':
1275                 out += ','.join(v) if type(v) == list else v
1276
1277             mf.write(out)
1278             mf.write('\n')
1279
1280     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1281
1282
1283 def write_yaml_metadata(mf, app):
1284
1285     def w_comment(line):
1286         mf.write("# %s\n" % line)
1287
1288     def escape(v):
1289         if not v:
1290             return ''
1291         if any(c in v for c in [': ', '%', '@', '*']):
1292             return "'" + v.replace("'", "''") + "'"
1293         return v
1294
1295     def w_field(f, v, prefix='', t=None):
1296         if t is None:
1297             t = metafieldtype(f)
1298         v = ''
1299         if t == 'list':
1300             v = '\n'
1301             for e in v:
1302                 v += prefix + ' - ' + escape(e) + '\n'
1303         elif t == 'multiline':
1304             v = ' |\n'
1305             for l in v.splitlines():
1306                 if l:
1307                     v += prefix + '  ' + l + '\n'
1308                 else:
1309                     v += '\n'
1310         elif t == 'bool':
1311             v = ' yes\n'
1312         elif t == 'script':
1313             cmds = [s + '&& \\' for s in v.split('&& ')]
1314             if len(cmds) > 0:
1315                 cmds[-1] = cmds[-1][:-len('&& \\')]
1316             w_field(f, cmds, prefix, 'multiline')
1317             return
1318         else:
1319             v = ' ' + escape(v) + '\n'
1320
1321         mf.write(prefix)
1322         mf.write(f)
1323         mf.write(":")
1324         mf.write(v)
1325
1326     global first_build
1327     first_build = True
1328
1329     def w_build(build):
1330         global first_build
1331         if first_build:
1332             mf.write("builds:\n")
1333             first_build = False
1334
1335         w_field('versionName', build.version, '  - ', 'string')
1336         w_field('versionCode', build.vercode, '    ', 'strsng')
1337         for f in build_flags_order:
1338             v = build.get_flag(f)
1339             if not v:
1340                 continue
1341
1342             w_field(f, v, '    ', flagtype(f))
1343
1344     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1345
1346
1347 def write_metadata(fmt, mf, app):
1348     if fmt == 'txt':
1349         return write_txt_metadata(mf, app)
1350     if fmt == 'yaml':
1351         return write_yaml_metadata(mf, app)
1352     raise MetaDataException("Unknown metadata format given")