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