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