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