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