chiark / gitweb /
metadata: allow links spanning multiple lines
[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 sys
24 import glob
25 import cgi
26 import logging
27 import textwrap
28
29 import yaml
30 # use libyaml if it is available
31 try:
32     from yaml import CLoader
33     YamlLoader = CLoader
34 except ImportError:
35     from yaml import Loader
36     YamlLoader = Loader
37
38 # use the C implementation when available
39 import xml.etree.cElementTree as ElementTree
40
41 from collections import OrderedDict
42
43 import common
44
45 srclibs = None
46
47
48 class MetaDataException(Exception):
49
50     def __init__(self, value):
51         self.value = value
52
53     def __str__(self):
54         return self.value
55
56 # In the order in which they are laid out on files
57 app_defaults = OrderedDict([
58     ('Disabled', None),
59     ('AntiFeatures', []),
60     ('Provides', None),
61     ('Categories', ['None']),
62     ('License', 'Unknown'),
63     ('Web Site', ''),
64     ('Source Code', ''),
65     ('Issue Tracker', ''),
66     ('Changelog', ''),
67     ('Donate', None),
68     ('FlattrID', None),
69     ('Bitcoin', None),
70     ('Litecoin', None),
71     ('Dogecoin', None),
72     ('Name', None),
73     ('Auto Name', ''),
74     ('Summary', ''),
75     ('Description', []),
76     ('Requires Root', False),
77     ('Repo Type', ''),
78     ('Repo', ''),
79     ('Binaries', None),
80     ('Maintainer Notes', []),
81     ('Archive Policy', None),
82     ('Auto Update Mode', 'None'),
83     ('Update Check Mode', 'None'),
84     ('Update Check Ignore', None),
85     ('Vercode Operation', None),
86     ('Update Check Name', None),
87     ('Update Check Data', None),
88     ('Current Version', ''),
89     ('Current Version Code', '0'),
90     ('No Source Since', ''),
91 ])
92
93
94 # In the order in which they are laid out on files
95 # Sorted by their action and their place in the build timeline
96 # These variables can have varying datatypes. For example, anything with
97 # flagtype(v) == 'list' is inited as False, then set as a list of strings.
98 flag_defaults = OrderedDict([
99     ('disable', False),
100     ('commit', None),
101     ('subdir', None),
102     ('submodules', False),
103     ('init', ''),
104     ('patch', []),
105     ('gradle', False),
106     ('maven', False),
107     ('kivy', False),
108     ('output', None),
109     ('srclibs', []),
110     ('oldsdkloc', False),
111     ('encoding', None),
112     ('forceversion', False),
113     ('forcevercode', False),
114     ('rm', []),
115     ('extlibs', []),
116     ('prebuild', ''),
117     ('update', ['auto']),
118     ('target', None),
119     ('scanignore', []),
120     ('scandelete', []),
121     ('build', ''),
122     ('buildjni', []),
123     ('ndk', 'r10e'),  # defaults to latest
124     ('preassemble', []),
125     ('gradleprops', []),
126     ('antcommands', None),
127     ('novcheck', False),
128 ])
129
130
131 # Designates a metadata field type and checks that it matches
132 #
133 # 'name'     - The long name of the field type
134 # 'matching' - List of possible values or regex expression
135 # 'sep'      - Separator to use if value may be a list
136 # 'fields'   - Metadata fields (Field:Value) of this type
137 # 'attrs'    - Build attributes (attr=value) of this type
138 #
139 class FieldValidator():
140
141     def __init__(self, name, matching, sep, fields, attrs):
142         self.name = name
143         self.matching = matching
144         if type(matching) is str:
145             self.compiled = re.compile(matching)
146         self.sep = sep
147         self.fields = fields
148         self.attrs = attrs
149
150     def _assert_regex(self, values, appid):
151         for v in values:
152             if not self.compiled.match(v):
153                 raise MetaDataException("'%s' is not a valid %s in %s. "
154                                         % (v, self.name, appid) +
155                                         "Regex pattern: %s" % (self.matching))
156
157     def _assert_list(self, values, appid):
158         for v in values:
159             if v not in self.matching:
160                 raise MetaDataException("'%s' is not a valid %s in %s. "
161                                         % (v, self.name, appid) +
162                                         "Possible values: %s" % (", ".join(self.matching)))
163
164     def check(self, value, appid):
165         if type(value) is not str or not value:
166             return
167         if self.sep is not None:
168             values = value.split(self.sep)
169         else:
170             values = [value]
171         if type(self.matching) is list:
172             self._assert_list(values, appid)
173         else:
174             self._assert_regex(values, appid)
175
176
177 # Generic value types
178 valuetypes = {
179     FieldValidator("Integer",
180                    r'^[1-9][0-9]*$', None,
181                    [],
182                    ['vercode']),
183
184     FieldValidator("Hexadecimal",
185                    r'^[0-9a-f]+$', None,
186                    ['FlattrID'],
187                    []),
188
189     FieldValidator("HTTP link",
190                    r'^http[s]?://', None,
191                    ["Web Site", "Source Code", "Issue Tracker", "Changelog", "Donate"], []),
192
193     FieldValidator("Bitcoin address",
194                    r'^[a-zA-Z0-9]{27,34}$', None,
195                    ["Bitcoin"],
196                    []),
197
198     FieldValidator("Litecoin address",
199                    r'^L[a-zA-Z0-9]{33}$', None,
200                    ["Litecoin"],
201                    []),
202
203     FieldValidator("Dogecoin address",
204                    r'^D[a-zA-Z0-9]{33}$', None,
205                    ["Dogecoin"],
206                    []),
207
208     FieldValidator("bool",
209                    r'([Yy]es|[Nn]o|[Tt]rue|[Ff]alse)', None,
210                    ["Requires Root"],
211                    ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
212                     'novcheck']),
213
214     FieldValidator("Repo Type",
215                    ['git', 'git-svn', 'svn', 'hg', 'bzr', 'srclib'], None,
216                    ["Repo Type"],
217                    []),
218
219     FieldValidator("Binaries",
220                    r'^http[s]?://', None,
221                    ["Binaries"],
222                    []),
223
224     FieldValidator("Archive Policy",
225                    r'^[0-9]+ versions$', None,
226                    ["Archive Policy"],
227                    []),
228
229     FieldValidator("Anti-Feature",
230                    ["Ads", "Tracking", "NonFreeNet", "NonFreeDep", "NonFreeAdd", "UpstreamNonFree"], ',',
231                    ["AntiFeatures"],
232                    []),
233
234     FieldValidator("Auto Update Mode",
235                    r"^(Version .+|None)$", None,
236                    ["Auto Update Mode"],
237                    []),
238
239     FieldValidator("Update Check Mode",
240                    r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$", None,
241                    ["Update Check Mode"],
242                    [])
243 }
244
245
246 # Check an app's metadata information for integrity errors
247 def check_metadata(info):
248     for v in valuetypes:
249         for field in v.fields:
250             v.check(info[field], info['id'])
251         for build in info['builds']:
252             for attr in v.attrs:
253                 v.check(build[attr], info['id'])
254
255
256 # Formatter for descriptions. Create an instance, and call parseline() with
257 # each line of the description source from the metadata. At the end, call
258 # end() and then text_wiki and text_html will contain the result.
259 class DescriptionFormatter:
260     stNONE = 0
261     stPARA = 1
262     stUL = 2
263     stOL = 3
264     bold = False
265     ital = False
266     state = stNONE
267     text_wiki = ''
268     text_html = ''
269     text_txt = ''
270     para_lines = []
271     linkResolver = None
272
273     def __init__(self, linkres):
274         self.linkResolver = linkres
275
276     def endcur(self, notstates=None):
277         if notstates and self.state in notstates:
278             return
279         if self.state == self.stPARA:
280             self.endpara()
281         elif self.state == self.stUL:
282             self.endul()
283         elif self.state == self.stOL:
284             self.endol()
285
286     def endpara(self):
287         self.text_html += '</p>'
288         self.state = self.stNONE
289         whole_para = ' '.join(self.para_lines)
290         self.addtext(whole_para)
291         self.text_txt += textwrap.fill(whole_para, 80) + '\n\n'
292         del self.para_lines[:]
293
294     def endul(self):
295         self.text_html += '</ul>'
296         self.text_txt += '\n'
297         self.state = self.stNONE
298
299     def endol(self):
300         self.text_html += '</ol>'
301         self.text_txt += '\n'
302         self.state = self.stNONE
303
304     def formatted(self, txt, html):
305         formatted = ''
306         if html:
307             txt = cgi.escape(txt)
308         while True:
309             index = txt.find("''")
310             if index == -1:
311                 return formatted + txt
312             formatted += txt[:index]
313             txt = txt[index:]
314             if txt.startswith("'''"):
315                 if html:
316                     if self.bold:
317                         formatted += '</b>'
318                     else:
319                         formatted += '<b>'
320                 self.bold = not self.bold
321                 txt = txt[3:]
322             else:
323                 if html:
324                     if self.ital:
325                         formatted += '</i>'
326                     else:
327                         formatted += '<i>'
328                 self.ital = not self.ital
329                 txt = txt[2:]
330
331     def linkify(self, txt):
332         linkified_plain = ''
333         linkified_html = ''
334         while True:
335             index = txt.find("[")
336             if index == -1:
337                 return (linkified_plain + self.formatted(txt, False), linkified_html + self.formatted(txt, True))
338             linkified_plain += self.formatted(txt[:index], False)
339             linkified_html += self.formatted(txt[:index], True)
340             txt = txt[index:]
341             if txt.startswith("[["):
342                 index = txt.find("]]")
343                 if index == -1:
344                     raise MetaDataException("Unterminated ]]")
345                 url = txt[2:index]
346                 if self.linkResolver:
347                     url, urltext = self.linkResolver(url)
348                 else:
349                     urltext = url
350                 linkified_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
351                 linkified_plain += urltext
352                 txt = txt[index + 2:]
353             else:
354                 index = txt.find("]")
355                 if index == -1:
356                     raise MetaDataException("Unterminated ]")
357                 url = txt[1:index]
358                 index2 = url.find(' ')
359                 if index2 == -1:
360                     urltxt = url
361                 else:
362                     urltxt = url[index2 + 1:]
363                     url = url[:index2]
364                     if url == urltxt:
365                         raise MetaDataException("Url title is just the URL - use [url]")
366                 linkified_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
367                 linkified_plain += urltxt
368                 if urltxt != url:
369                     linkified_plain += ' (' + url + ')'
370                 txt = txt[index + 1:]
371
372     def addtext(self, txt):
373         p, h = self.linkify(txt)
374         self.text_html += h
375
376     def parseline(self, line):
377         self.text_wiki += "%s\n" % line
378         if not line:
379             self.endcur()
380         elif line.startswith('* '):
381             self.endcur([self.stUL])
382             self.text_txt += "%s\n" % line
383             if self.state != self.stUL:
384                 self.text_html += '<ul>'
385                 self.state = self.stUL
386             self.text_html += '<li>'
387             self.addtext(line[1:])
388             self.text_html += '</li>'
389         elif line.startswith('# '):
390             self.endcur([self.stOL])
391             self.text_txt += "%s\n" % line
392             if self.state != self.stOL:
393                 self.text_html += '<ol>'
394                 self.state = self.stOL
395             self.text_html += '<li>'
396             self.addtext(line[1:])
397             self.text_html += '</li>'
398         else:
399             self.para_lines.append(line)
400             self.endcur([self.stPARA])
401             if self.state == self.stNONE:
402                 self.text_html += '<p>'
403                 self.state = self.stPARA
404             elif self.state == self.stPARA:
405                 self.text_html += ' '
406
407     def end(self):
408         self.endcur()
409         self.text_txt = self.text_txt.strip()
410
411
412 # Parse multiple lines of description as written in a metadata file, returning
413 # a single string in text format and wrapped to 80 columns.
414 def description_txt(lines):
415     ps = DescriptionFormatter(None)
416     for line in lines:
417         ps.parseline(line)
418     ps.end()
419     return ps.text_txt
420
421
422 # Parse multiple lines of description as written in a metadata file, returning
423 # a single string in wiki format. Used for the Maintainer Notes field as well,
424 # because it's the same format.
425 def description_wiki(lines):
426     ps = DescriptionFormatter(None)
427     for line in lines:
428         ps.parseline(line)
429     ps.end()
430     return ps.text_wiki
431
432
433 # Parse multiple lines of description as written in a metadata file, returning
434 # a single string in HTML format.
435 def description_html(lines, linkres):
436     ps = DescriptionFormatter(linkres)
437     for line in lines:
438         ps.parseline(line)
439     ps.end()
440     return ps.text_html
441
442
443 def parse_srclib(metadatapath):
444
445     thisinfo = {}
446
447     # Defaults for fields that come from metadata
448     thisinfo['Repo Type'] = ''
449     thisinfo['Repo'] = ''
450     thisinfo['Subdir'] = None
451     thisinfo['Prepare'] = None
452
453     if not os.path.exists(metadatapath):
454         return thisinfo
455
456     metafile = open(metadatapath, "r")
457
458     n = 0
459     for line in metafile:
460         n += 1
461         line = line.rstrip('\r\n')
462         if not line or line.startswith("#"):
463             continue
464
465         try:
466             field, value = line.split(':', 1)
467         except ValueError:
468             raise MetaDataException("Invalid metadata in %s:%d" % (line, n))
469
470         if field == "Subdir":
471             thisinfo[field] = value.split(',')
472         else:
473             thisinfo[field] = value
474
475     return thisinfo
476
477
478 def read_srclibs():
479     """Read all srclib metadata.
480
481     The information read will be accessible as metadata.srclibs, which is a
482     dictionary, keyed on srclib name, with the values each being a dictionary
483     in the same format as that returned by the parse_srclib function.
484
485     A MetaDataException is raised if there are any problems with the srclib
486     metadata.
487     """
488     global srclibs
489
490     # They were already loaded
491     if srclibs is not None:
492         return
493
494     srclibs = {}
495
496     srcdir = 'srclibs'
497     if not os.path.exists(srcdir):
498         os.makedirs(srcdir)
499
500     for metadatapath in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
501         srclibname = os.path.basename(metadatapath[:-4])
502         srclibs[srclibname] = parse_srclib(metadatapath)
503
504
505 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
506 # returned by the parse_txt_metadata function.
507 def read_metadata(xref=True):
508
509     # Always read the srclibs before the apps, since they can use a srlib as
510     # their source repository.
511     read_srclibs()
512
513     apps = {}
514
515     for basedir in ('metadata', 'tmp'):
516         if not os.path.exists(basedir):
517             os.makedirs(basedir)
518
519     # If there are multiple metadata files for a single appid, then the first
520     # file that is parsed wins over all the others, and the rest throw an
521     # exception. So the original .txt format is parsed first, at least until
522     # newer formats stabilize.
523
524     for metadatapath in sorted(glob.glob(os.path.join('metadata', '*.txt'))
525                                + glob.glob(os.path.join('metadata', '*.json'))
526                                + glob.glob(os.path.join('metadata', '*.xml'))
527                                + glob.glob(os.path.join('metadata', '*.yaml'))):
528         appid, appinfo = parse_metadata(apps, metadatapath)
529         check_metadata(appinfo)
530         apps[appid] = appinfo
531
532     if xref:
533         # Parse all descriptions at load time, just to ensure cross-referencing
534         # errors are caught early rather than when they hit the build server.
535         def linkres(appid):
536             if appid in apps:
537                 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
538             raise MetaDataException("Cannot resolve app id " + appid)
539
540         for appid, app in apps.iteritems():
541             try:
542                 description_html(app['Description'], linkres)
543             except MetaDataException, e:
544                 raise MetaDataException("Problem with description of " + appid +
545                                         " - " + str(e))
546
547     return apps
548
549
550 # Get the type expected for a given metadata field.
551 def metafieldtype(name):
552     if name in ['Description', 'Maintainer Notes']:
553         return 'multiline'
554     if name in ['Categories', 'AntiFeatures']:
555         return 'list'
556     if name == 'Build Version':
557         return 'build'
558     if name == 'Build':
559         return 'buildv2'
560     if name == 'Use Built':
561         return 'obsolete'
562     if name not in app_defaults:
563         return 'unknown'
564     return 'string'
565
566
567 def flagtype(name):
568     if name in ['extlibs', 'srclibs', 'patch', 'rm', 'buildjni', 'preassemble',
569                 'update', 'scanignore', 'scandelete', 'gradle', 'antcommands',
570                 'gradleprops']:
571         return 'list'
572     if name in ['init', 'prebuild', 'build']:
573         return 'script'
574     if name in ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
575                 'novcheck']:
576         return 'bool'
577     return 'string'
578
579
580 def fill_build_defaults(build):
581
582     def get_build_type():
583         for t in ['maven', 'gradle', 'kivy']:
584             if build[t]:
585                 return t
586         if build['output']:
587             return 'raw'
588         return 'ant'
589
590     for flag, value in flag_defaults.iteritems():
591         if flag in build:
592             continue
593         build[flag] = value
594     build['type'] = get_build_type()
595     build['ndk_path'] = common.get_ndk_path(build['ndk'])
596
597
598 def split_list_values(s):
599     # Port legacy ';' separators
600     l = [v.strip() for v in s.replace(';', ',').split(',')]
601     return [v for v in l if v]
602
603
604 def get_default_app_info_list(apps, metadatapath=None):
605     if metadatapath is None:
606         appid = None
607     else:
608         appid = os.path.splitext(os.path.basename(metadatapath))[0]
609     if appid in apps:
610         logging.critical("'%s' is a duplicate! '%s' is already provided by '%s'"
611                          % (metadatapath, appid, apps[appid]['metadatapath']))
612         sys.exit(1)
613
614     thisinfo = {}
615     thisinfo.update(app_defaults)
616     thisinfo['metadatapath'] = metadatapath
617     if appid is not None:
618         thisinfo['id'] = appid
619
620     # General defaults...
621     thisinfo['builds'] = []
622     thisinfo['comments'] = []
623
624     return appid, thisinfo
625
626
627 def sorted_builds(builds):
628     return sorted(builds, key=lambda build: int(build['vercode']))
629
630
631 def post_metadata_parse(thisinfo):
632
633     supported_metadata = app_defaults.keys() + ['comments', 'builds', 'id', 'metadatapath']
634     for k, v in thisinfo.iteritems():
635         if k not in supported_metadata:
636             raise MetaDataException("Unrecognised metadata: {0}: {1}"
637                                     .format(k, v))
638         if type(v) in (float, int):
639             thisinfo[k] = str(v)
640
641     # convert to the odd internal format
642     for k in ('Description', 'Maintainer Notes'):
643         if isinstance(thisinfo[k], basestring):
644             text = thisinfo[k].rstrip().lstrip()
645             thisinfo[k] = text.split('\n')
646
647     supported_flags = (flag_defaults.keys()
648                        + ['vercode', 'version', 'versionCode', 'versionName'])
649     esc_newlines = re.compile('\\\\( |\\n)')
650
651     for build in thisinfo['builds']:
652         for k, v in build.items():
653             if k not in supported_flags:
654                 raise MetaDataException("Unrecognised build flag: {0}={1}"
655                                         .format(k, v))
656
657             if k == 'versionCode':
658                 build['vercode'] = str(v)
659                 del build['versionCode']
660             elif k == 'versionName':
661                 build['version'] = str(v)
662                 del build['versionName']
663             elif type(v) in (float, int):
664                 build[k] = str(v)
665             else:
666                 keyflagtype = flagtype(k)
667                 if keyflagtype == 'list':
668                     # these can be bools, strings or lists, but ultimately are lists
669                     if isinstance(v, basestring):
670                         build[k] = [v]
671                     elif isinstance(v, bool):
672                         if v:
673                             build[k] = ['yes']
674                         else:
675                             build[k] = ['no']
676                 elif keyflagtype == 'script':
677                     build[k] = re.sub(esc_newlines, '', v).lstrip().rstrip()
678                 elif keyflagtype == 'bool':
679                     # TODO handle this using <xsd:element type="xsd:boolean> in a schema
680                     if isinstance(v, basestring):
681                         if v == 'true':
682                             build[k] = True
683                         else:
684                             build[k] = False
685
686     if not thisinfo['Description']:
687         thisinfo['Description'].append('No description available')
688
689     for build in thisinfo['builds']:
690         fill_build_defaults(build)
691
692     thisinfo['builds'] = sorted_builds(thisinfo['builds'])
693
694
695 # Parse metadata for a single application.
696 #
697 #  'metadatapath' - the filename to read. The package id for the application comes
698 #               from this filename. Pass None to get a blank entry.
699 #
700 # Returns a dictionary containing all the details of the application. There are
701 # two major kinds of information in the dictionary. Keys beginning with capital
702 # letters correspond directory to identically named keys in the metadata file.
703 # Keys beginning with lower case letters are generated in one way or another,
704 # and are not found verbatim in the metadata.
705 #
706 # Known keys not originating from the metadata are:
707 #
708 #  'builds'           - a list of dictionaries containing build information
709 #                       for each defined build
710 #  'comments'         - a list of comments from the metadata file. Each is
711 #                       a list of the form [field, comment] where field is
712 #                       the name of the field it preceded in the metadata
713 #                       file. Where field is None, the comment goes at the
714 #                       end of the file. Alternatively, 'build:version' is
715 #                       for a comment before a particular build version.
716 #  'descriptionlines' - original lines of description as formatted in the
717 #                       metadata file.
718 #
719
720
721 def _decode_list(data):
722     '''convert items in a list from unicode to basestring'''
723     rv = []
724     for item in data:
725         if isinstance(item, unicode):
726             item = item.encode('utf-8')
727         elif isinstance(item, list):
728             item = _decode_list(item)
729         elif isinstance(item, dict):
730             item = _decode_dict(item)
731         rv.append(item)
732     return rv
733
734
735 def _decode_dict(data):
736     '''convert items in a dict from unicode to basestring'''
737     rv = {}
738     for key, value in data.iteritems():
739         if isinstance(key, unicode):
740             key = key.encode('utf-8')
741         if isinstance(value, unicode):
742             value = value.encode('utf-8')
743         elif isinstance(value, list):
744             value = _decode_list(value)
745         elif isinstance(value, dict):
746             value = _decode_dict(value)
747         rv[key] = value
748     return rv
749
750
751 def parse_metadata(apps, metadatapath):
752     root, ext = os.path.splitext(metadatapath)
753     metadataformat = ext[1:]
754     accepted = common.config['accepted_formats']
755     if metadataformat not in accepted:
756         logging.critical('"' + metadatapath
757                          + '" is not in an accepted format, '
758                          + 'convert to: ' + ', '.join(accepted))
759         sys.exit(1)
760
761     if metadataformat == 'txt':
762         return parse_txt_metadata(apps, metadatapath)
763     elif metadataformat == 'json':
764         return parse_json_metadata(apps, metadatapath)
765     elif metadataformat == 'xml':
766         return parse_xml_metadata(apps, metadatapath)
767     elif metadataformat == 'yaml':
768         return parse_yaml_metadata(apps, metadatapath)
769     else:
770         logging.critical('Unknown metadata format: ' + metadatapath)
771         sys.exit(1)
772
773
774 def parse_json_metadata(apps, metadatapath):
775
776     appid, thisinfo = get_default_app_info_list(apps, metadatapath)
777
778     # fdroid metadata is only strings and booleans, no floats or ints. And
779     # json returns unicode, and fdroidserver still uses plain python strings
780     # TODO create schema using https://pypi.python.org/pypi/jsonschema
781     jsoninfo = json.load(open(metadatapath, 'r'),
782                          object_hook=_decode_dict,
783                          parse_int=lambda s: s,
784                          parse_float=lambda s: s)
785     thisinfo.update(jsoninfo)
786     post_metadata_parse(thisinfo)
787
788     return (appid, thisinfo)
789
790
791 def parse_xml_metadata(apps, metadatapath):
792
793     appid, thisinfo = get_default_app_info_list(apps, metadatapath)
794
795     tree = ElementTree.ElementTree(file=metadatapath)
796     root = tree.getroot()
797
798     if root.tag != 'resources':
799         logging.critical(metadatapath + ' does not have root as <resources></resources>!')
800         sys.exit(1)
801
802     supported_metadata = app_defaults.keys()
803     for child in root:
804         if child.tag != 'builds':
805             # builds does not have name="" attrib
806             name = child.attrib['name']
807             if name not in supported_metadata:
808                 raise MetaDataException("Unrecognised metadata: <"
809                                         + child.tag + ' name="' + name + '">'
810                                         + child.text
811                                         + "</" + child.tag + '>')
812
813         if child.tag == 'string':
814             thisinfo[name] = child.text
815         elif child.tag == 'string-array':
816             items = []
817             for item in child:
818                 items.append(item.text)
819             thisinfo[name] = items
820         elif child.tag == 'builds':
821             builds = []
822             for build in child:
823                 builddict = dict()
824                 for key in build:
825                     builddict[key.tag] = key.text
826                 builds.append(builddict)
827             thisinfo['builds'] = builds
828
829     # TODO handle this using <xsd:element type="xsd:boolean> in a schema
830     if not isinstance(thisinfo['Requires Root'], bool):
831         if thisinfo['Requires Root'] == 'true':
832             thisinfo['Requires Root'] = True
833         else:
834             thisinfo['Requires Root'] = False
835
836     post_metadata_parse(thisinfo)
837
838     return (appid, thisinfo)
839
840
841 def parse_yaml_metadata(apps, metadatapath):
842
843     appid, thisinfo = get_default_app_info_list(apps, metadatapath)
844
845     yamlinfo = yaml.load(open(metadatapath, 'r'), Loader=YamlLoader)
846     thisinfo.update(yamlinfo)
847     post_metadata_parse(thisinfo)
848
849     return (appid, thisinfo)
850
851
852 def parse_txt_metadata(apps, metadatapath):
853
854     linedesc = None
855
856     def add_buildflag(p, thisbuild):
857         if not p.strip():
858             raise MetaDataException("Empty build flag at {1}"
859                                     .format(buildlines[0], linedesc))
860         bv = p.split('=', 1)
861         if len(bv) != 2:
862             raise MetaDataException("Invalid build flag at {0} in {1}"
863                                     .format(buildlines[0], linedesc))
864         pk, pv = bv
865         if pk in thisbuild:
866             raise MetaDataException("Duplicate definition on {0} in version {1} of {2}"
867                                     .format(pk, thisbuild['version'], linedesc))
868
869         pk = pk.lstrip()
870         if pk not in flag_defaults:
871             raise MetaDataException("Unrecognised build flag at {0} in {1}"
872                                     .format(p, linedesc))
873         t = flagtype(pk)
874         if t == 'list':
875             pv = split_list_values(pv)
876             if pk == 'gradle':
877                 if len(pv) == 1 and pv[0] in ['main', 'yes']:
878                     pv = ['yes']
879             thisbuild[pk] = pv
880         elif t == 'string' or t == 'script':
881             thisbuild[pk] = pv
882         elif t == 'bool':
883             value = pv == 'yes'
884             if value:
885                 thisbuild[pk] = True
886             else:
887                 logging.debug("...ignoring bool flag %s" % p)
888
889         else:
890             raise MetaDataException("Unrecognised build flag type '%s' at %s in %s"
891                                     % (t, p, linedesc))
892
893     def parse_buildline(lines):
894         value = "".join(lines)
895         parts = [p.replace("\\,", ",")
896                  for p in re.split(r"(?<!\\),", value)]
897         if len(parts) < 3:
898             raise MetaDataException("Invalid build format: " + value + " in " + metafile.name)
899         thisbuild = {}
900         thisbuild['origlines'] = lines
901         thisbuild['version'] = parts[0]
902         thisbuild['vercode'] = parts[1]
903         if parts[2].startswith('!'):
904             # For backwards compatibility, handle old-style disabling,
905             # including attempting to extract the commit from the message
906             thisbuild['disable'] = parts[2][1:]
907             commit = 'unknown - see disabled'
908             index = parts[2].rfind('at ')
909             if index != -1:
910                 commit = parts[2][index + 3:]
911                 if commit.endswith(')'):
912                     commit = commit[:-1]
913             thisbuild['commit'] = commit
914         else:
915             thisbuild['commit'] = parts[2]
916         for p in parts[3:]:
917             add_buildflag(p, thisbuild)
918
919         return thisbuild
920
921     def add_comments(key):
922         if not curcomments:
923             return
924         for comment in curcomments:
925             thisinfo['comments'].append([key, comment])
926         del curcomments[:]
927
928     appid, thisinfo = get_default_app_info_list(apps, metadatapath)
929     metafile = open(metadatapath, "r")
930
931     mode = 0
932     buildlines = []
933     curcomments = []
934     curbuild = None
935     vc_seen = {}
936
937     c = 0
938     for line in metafile:
939         c += 1
940         linedesc = "%s:%d" % (metafile.name, c)
941         line = line.rstrip('\r\n')
942         if mode == 3:
943             if not any(line.startswith(s) for s in (' ', '\t')):
944                 commit = curbuild['commit'] if 'commit' in curbuild else None
945                 if not commit and 'disable' not in curbuild:
946                     raise MetaDataException("No commit specified for {0} in {1}"
947                                             .format(curbuild['version'], linedesc))
948
949                 thisinfo['builds'].append(curbuild)
950                 add_comments('build:' + curbuild['vercode'])
951                 mode = 0
952             else:
953                 if line.endswith('\\'):
954                     buildlines.append(line[:-1].lstrip())
955                 else:
956                     buildlines.append(line.lstrip())
957                     bl = ''.join(buildlines)
958                     add_buildflag(bl, curbuild)
959                     buildlines = []
960
961         if mode == 0:
962             if not line:
963                 continue
964             if line.startswith("#"):
965                 curcomments.append(line)
966                 continue
967             try:
968                 field, value = line.split(':', 1)
969             except ValueError:
970                 raise MetaDataException("Invalid metadata in " + linedesc)
971             if field != field.strip() or value != value.strip():
972                 raise MetaDataException("Extra spacing found in " + linedesc)
973
974             # Translate obsolete fields...
975             if field == 'Market Version':
976                 field = 'Current Version'
977             if field == 'Market Version Code':
978                 field = 'Current Version Code'
979
980             fieldtype = metafieldtype(field)
981             if fieldtype not in ['build', 'buildv2']:
982                 add_comments(field)
983             if fieldtype == 'multiline':
984                 mode = 1
985                 thisinfo[field] = []
986                 if value:
987                     raise MetaDataException("Unexpected text on same line as " + field + " in " + linedesc)
988             elif fieldtype == 'string':
989                 thisinfo[field] = value
990             elif fieldtype == 'list':
991                 thisinfo[field] = split_list_values(value)
992             elif fieldtype == 'build':
993                 if value.endswith("\\"):
994                     mode = 2
995                     buildlines = [value[:-1]]
996                 else:
997                     curbuild = parse_buildline([value])
998                     thisinfo['builds'].append(curbuild)
999                     add_comments('build:' + thisinfo['builds'][-1]['vercode'])
1000             elif fieldtype == 'buildv2':
1001                 curbuild = {}
1002                 vv = value.split(',')
1003                 if len(vv) != 2:
1004                     raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
1005                                             .format(value, linedesc))
1006                 curbuild['version'] = vv[0]
1007                 curbuild['vercode'] = vv[1]
1008                 if curbuild['vercode'] in vc_seen:
1009                     raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
1010                                             curbuild['vercode'], linedesc))
1011                 vc_seen[curbuild['vercode']] = True
1012                 buildlines = []
1013                 mode = 3
1014             elif fieldtype == 'obsolete':
1015                 pass        # Just throw it away!
1016             else:
1017                 raise MetaDataException("Unrecognised field type for " + field + " in " + linedesc)
1018         elif mode == 1:     # Multiline field
1019             if line == '.':
1020                 mode = 0
1021             else:
1022                 thisinfo[field].append(line)
1023         elif mode == 2:     # Line continuation mode in Build Version
1024             if line.endswith("\\"):
1025                 buildlines.append(line[:-1])
1026             else:
1027                 buildlines.append(line)
1028                 curbuild = parse_buildline(buildlines)
1029                 thisinfo['builds'].append(curbuild)
1030                 add_comments('build:' + thisinfo['builds'][-1]['vercode'])
1031                 mode = 0
1032     add_comments(None)
1033
1034     # Mode at end of file should always be 0...
1035     if mode == 1:
1036         raise MetaDataException(field + " not terminated in " + metafile.name)
1037     elif mode == 2:
1038         raise MetaDataException("Unterminated continuation in " + metafile.name)
1039     elif mode == 3:
1040         raise MetaDataException("Unterminated build in " + metafile.name)
1041
1042     post_metadata_parse(thisinfo)
1043
1044     return (appid, thisinfo)
1045
1046
1047 # Write a metadata file.
1048 #
1049 # 'dest'    - The path to the output file
1050 # 'app'     - The app data
1051 def write_metadata(dest, app):
1052
1053     def writecomments(key):
1054         written = 0
1055         for pf, comment in app['comments']:
1056             if pf == key:
1057                 mf.write("%s\n" % comment)
1058                 written += 1
1059         if written > 0:
1060             logging.debug("...writing comments for " + (key or 'EOF'))
1061
1062     def writefield(field, value=None):
1063         writecomments(field)
1064         if value is None:
1065             value = app[field]
1066         t = metafieldtype(field)
1067         if t == 'list':
1068             value = ','.join(value)
1069         elif t == 'multiline':
1070             if type(value) == list:
1071                 value = '\n' + '\n'.join(value) + '\n.'
1072             else:
1073                 value = '\n' + value + '\n.'
1074         mf.write("%s:%s\n" % (field, value))
1075
1076     def writefield_nonempty(field, value=None):
1077         if value is None:
1078             value = app[field]
1079         if value:
1080             writefield(field, value)
1081
1082     mf = open(dest, 'w')
1083     writefield_nonempty('Disabled')
1084     if app['AntiFeatures']:
1085         writefield('AntiFeatures')
1086     writefield_nonempty('Provides')
1087     writefield('Categories')
1088     writefield('License')
1089     writefield('Web Site')
1090     writefield('Source Code')
1091     writefield('Issue Tracker')
1092     writefield_nonempty('Changelog')
1093     writefield_nonempty('Donate')
1094     writefield_nonempty('FlattrID')
1095     writefield_nonempty('Bitcoin')
1096     writefield_nonempty('Litecoin')
1097     writefield_nonempty('Dogecoin')
1098     mf.write('\n')
1099     writefield_nonempty('Name')
1100     writefield_nonempty('Auto Name')
1101     writefield('Summary')
1102     writefield('Description', description_txt(app['Description']))
1103     mf.write('\n')
1104     if app['Requires Root']:
1105         writefield('Requires Root', 'yes')
1106         mf.write('\n')
1107     if app['Repo Type']:
1108         writefield('Repo Type')
1109         writefield('Repo')
1110         if app['Binaries']:
1111             writefield('Binaries')
1112         mf.write('\n')
1113     for build in sorted_builds(app['builds']):
1114
1115         if build['version'] == "Ignore":
1116             continue
1117
1118         writecomments('build:' + build['vercode'])
1119         mf.write("Build:%s,%s\n" % (build['version'], build['vercode']))
1120
1121         def write_builditem(key, value):
1122
1123             if key in ['version', 'vercode']:
1124                 return
1125
1126             if value == flag_defaults[key]:
1127                 return
1128
1129             t = flagtype(key)
1130
1131             logging.debug("...writing {0} : {1}".format(key, value))
1132             outline = '    %s=' % key
1133
1134             if t == 'string':
1135                 outline += value
1136             elif t == 'bool':
1137                 outline += 'yes'
1138             elif t == 'script':
1139                 outline += '&& \\\n        '.join([s.lstrip() for s in value.split('&& ')])
1140             elif t == 'list':
1141                 outline += ','.join(value) if type(value) == list else value
1142
1143             outline += '\n'
1144             mf.write(outline)
1145
1146         for flag in flag_defaults:
1147             value = build[flag]
1148             if value:
1149                 write_builditem(flag, value)
1150         mf.write('\n')
1151
1152     if app['Maintainer Notes']:
1153         writefield('Maintainer Notes', app['Maintainer Notes'])
1154         mf.write('\n')
1155
1156     writefield_nonempty('Archive Policy')
1157     writefield('Auto Update Mode')
1158     writefield('Update Check Mode')
1159     writefield_nonempty('Update Check Ignore')
1160     writefield_nonempty('Vercode Operation')
1161     writefield_nonempty('Update Check Name')
1162     writefield_nonempty('Update Check Data')
1163     if app['Current Version']:
1164         writefield('Current Version')
1165         writefield('Current Version Code')
1166     mf.write('\n')
1167     if app['No Source Since']:
1168         writefield('No Source Since')
1169         mf.write('\n')
1170     writecomments(None)
1171     mf.close()