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