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