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