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