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