chiark / gitweb /
exit with error if duplicate metadata file is found
[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         appid, appinfo = parse_txt_metadata(apps, metadatapath)
504         check_metadata(appinfo)
505         apps[appid] = appinfo
506
507     for metadatapath in sorted(glob.glob(os.path.join('metadata', '*.json'))):
508         appid, appinfo = parse_json_metadata(apps, metadatapath)
509         check_metadata(appinfo)
510         apps[appid] = appinfo
511
512     for metadatapath in sorted(glob.glob(os.path.join('metadata', '*.xml'))):
513         appid, appinfo = parse_xml_metadata(apps, metadatapath)
514         check_metadata(appinfo)
515         apps[appid] = appinfo
516
517     for metadatapath in sorted(glob.glob(os.path.join('metadata', '*.yaml'))):
518         appid, appinfo = parse_yaml_metadata(apps, metadatapath)
519         check_metadata(appinfo)
520         apps[appid] = appinfo
521
522     if xref:
523         # Parse all descriptions at load time, just to ensure cross-referencing
524         # errors are caught early rather than when they hit the build server.
525         def linkres(appid):
526             if appid in apps:
527                 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
528             raise MetaDataException("Cannot resolve app id " + appid)
529
530         for appid, app in apps.iteritems():
531             try:
532                 description_html(app['Description'], linkres)
533             except MetaDataException, e:
534                 raise MetaDataException("Problem with description of " + appid +
535                                         " - " + str(e))
536
537     return apps
538
539
540 # Get the type expected for a given metadata field.
541 def metafieldtype(name):
542     if name in ['Description', 'Maintainer Notes']:
543         return 'multiline'
544     if name in ['Categories', 'AntiFeatures']:
545         return 'list'
546     if name == 'Build Version':
547         return 'build'
548     if name == 'Build':
549         return 'buildv2'
550     if name == 'Use Built':
551         return 'obsolete'
552     if name not in app_defaults:
553         return 'unknown'
554     return 'string'
555
556
557 def flagtype(name):
558     if name in ['extlibs', 'srclibs', 'patch', 'rm', 'buildjni', 'preassemble',
559                 'update', 'scanignore', 'scandelete', 'gradle', 'antcommands',
560                 'gradleprops']:
561         return 'list'
562     if name in ['init', 'prebuild', 'build']:
563         return 'script'
564     if name in ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
565                 'novcheck']:
566         return 'bool'
567     return 'string'
568
569
570 def fill_build_defaults(build):
571
572     def get_build_type():
573         for t in ['maven', 'gradle', 'kivy']:
574             if build[t]:
575                 return t
576         if build['output']:
577             return 'raw'
578         return 'ant'
579
580     for flag, value in flag_defaults.iteritems():
581         if flag in build:
582             continue
583         build[flag] = value
584     build['type'] = get_build_type()
585     build['ndk_path'] = common.get_ndk_path(build['ndk'])
586
587
588 def split_list_values(s):
589     # Port legacy ';' separators
590     l = [v.strip() for v in s.replace(';', ',').split(',')]
591     return [v for v in l if v]
592
593
594 def get_default_app_info_list(apps, metadatapath):
595     appid = os.path.splitext(os.path.basename(metadatapath))[0]
596     if appid in apps:
597         logging.critical("'%s' is a duplicate! '%s' is already provided by '%s'"
598                          % (metadatapath, appid, apps[appid]['metadatapath']))
599         sys.exit(1)
600
601     thisinfo = {}
602     thisinfo.update(app_defaults)
603     thisinfo['metadatapath'] = metadatapath
604     if appid is not None:
605         thisinfo['id'] = appid
606
607     # General defaults...
608     thisinfo['builds'] = []
609     thisinfo['comments'] = []
610
611     return appid, thisinfo
612
613
614 def post_metadata_parse(thisinfo):
615
616     supported_metadata = app_defaults.keys() + ['comments', 'builds', 'id', 'metadatapath']
617     for k, v in thisinfo.iteritems():
618         if k not in supported_metadata:
619             raise MetaDataException("Unrecognised metadata: {0}: {1}"
620                                     .format(k, v))
621         if type(v) in (float, int):
622             thisinfo[k] = str(v)
623
624     # convert to the odd internal format
625     for k in ('Description', 'Maintainer Notes'):
626         if isinstance(thisinfo[k], basestring):
627             text = thisinfo[k].rstrip().lstrip()
628             thisinfo[k] = text.split('\n')
629
630     supported_flags = (flag_defaults.keys()
631                        + ['vercode', 'version', 'versionCode', 'versionName'])
632     esc_newlines = re.compile('\\\\( |\\n)')
633
634     for build in thisinfo['builds']:
635         for k, v in build.items():
636             if k not in supported_flags:
637                 raise MetaDataException("Unrecognised build flag: {0}={1}"
638                                         .format(k, v))
639
640             if k == 'versionCode':
641                 build['vercode'] = str(v)
642                 del build['versionCode']
643             elif k == 'versionName':
644                 build['version'] = str(v)
645                 del build['versionName']
646             elif type(v) in (float, int):
647                 build[k] = str(v)
648             else:
649                 keyflagtype = flagtype(k)
650                 if keyflagtype == 'list':
651                     # these can be bools, strings or lists, but ultimately are lists
652                     if isinstance(v, basestring):
653                         build[k] = [v]
654                     elif isinstance(v, bool):
655                         if v:
656                             build[k] = ['yes']
657                         else:
658                             build[k] = ['no']
659                 elif keyflagtype == 'script':
660                     build[k] = re.sub(esc_newlines, '', v).lstrip().rstrip()
661                 elif keyflagtype == 'bool':
662                     # TODO handle this using <xsd:element type="xsd:boolean> in a schema
663                     if isinstance(v, basestring):
664                         if v == 'true':
665                             build[k] = True
666                         else:
667                             build[k] = False
668
669     if not thisinfo['Description']:
670         thisinfo['Description'].append('No description available')
671
672     for build in thisinfo['builds']:
673         fill_build_defaults(build)
674
675     thisinfo['builds'] = sorted(thisinfo['builds'], key=lambda build: int(build['vercode']))
676
677
678 # Parse metadata for a single application.
679 #
680 #  'metadatapath' - the filename to read. The package id for the application comes
681 #               from this filename. Pass None to get a blank entry.
682 #
683 # Returns a dictionary containing all the details of the application. There are
684 # two major kinds of information in the dictionary. Keys beginning with capital
685 # letters correspond directory to identically named keys in the metadata file.
686 # Keys beginning with lower case letters are generated in one way or another,
687 # and are not found verbatim in the metadata.
688 #
689 # Known keys not originating from the metadata are:
690 #
691 #  'builds'           - a list of dictionaries containing build information
692 #                       for each defined build
693 #  'comments'         - a list of comments from the metadata file. Each is
694 #                       a list of the form [field, comment] where field is
695 #                       the name of the field it preceded in the metadata
696 #                       file. Where field is None, the comment goes at the
697 #                       end of the file. Alternatively, 'build:version' is
698 #                       for a comment before a particular build version.
699 #  'descriptionlines' - original lines of description as formatted in the
700 #                       metadata file.
701 #
702
703
704 def _decode_list(data):
705     '''convert items in a list from unicode to basestring'''
706     rv = []
707     for item in data:
708         if isinstance(item, unicode):
709             item = item.encode('utf-8')
710         elif isinstance(item, list):
711             item = _decode_list(item)
712         elif isinstance(item, dict):
713             item = _decode_dict(item)
714         rv.append(item)
715     return rv
716
717
718 def _decode_dict(data):
719     '''convert items in a dict from unicode to basestring'''
720     rv = {}
721     for key, value in data.iteritems():
722         if isinstance(key, unicode):
723             key = key.encode('utf-8')
724         if isinstance(value, unicode):
725             value = value.encode('utf-8')
726         elif isinstance(value, list):
727             value = _decode_list(value)
728         elif isinstance(value, dict):
729             value = _decode_dict(value)
730         rv[key] = value
731     return rv
732
733
734 def parse_json_metadata(apps, metadatapath):
735
736     appid, thisinfo = get_default_app_info_list(apps, metadatapath)
737
738     # fdroid metadata is only strings and booleans, no floats or ints. And
739     # json returns unicode, and fdroidserver still uses plain python strings
740     # TODO create schema using https://pypi.python.org/pypi/jsonschema
741     jsoninfo = json.load(open(metadatapath, 'r'),
742                          object_hook=_decode_dict,
743                          parse_int=lambda s: s,
744                          parse_float=lambda s: s)
745     thisinfo.update(jsoninfo)
746     post_metadata_parse(thisinfo)
747
748     return (appid, thisinfo)
749
750
751 def parse_xml_metadata(apps, metadatapath):
752
753     appid, thisinfo = get_default_app_info_list(apps, metadatapath)
754
755     tree = ElementTree.ElementTree(file=metadatapath)
756     root = tree.getroot()
757
758     if root.tag != 'resources':
759         logging.critical(metadatapath + ' does not have root as <resources></resources>!')
760         sys.exit(1)
761
762     supported_metadata = app_defaults.keys()
763     for child in root:
764         if child.tag != 'builds':
765             # builds does not have name="" attrib
766             name = child.attrib['name']
767             if name not in supported_metadata:
768                 raise MetaDataException("Unrecognised metadata: <"
769                                         + child.tag + ' name="' + name + '">'
770                                         + child.text
771                                         + "</" + child.tag + '>')
772
773         if child.tag == 'string':
774             thisinfo[name] = child.text
775         elif child.tag == 'string-array':
776             items = []
777             for item in child:
778                 items.append(item.text)
779             thisinfo[name] = items
780         elif child.tag == 'builds':
781             builds = []
782             for build in child:
783                 builddict = dict()
784                 for key in build:
785                     builddict[key.tag] = key.text
786                 builds.append(builddict)
787             thisinfo['builds'] = builds
788
789     # TODO handle this using <xsd:element type="xsd:boolean> in a schema
790     if not isinstance(thisinfo['Requires Root'], bool):
791         if thisinfo['Requires Root'] == 'true':
792             thisinfo['Requires Root'] = True
793         else:
794             thisinfo['Requires Root'] = False
795
796     post_metadata_parse(thisinfo)
797
798     return (appid, thisinfo)
799
800
801 def parse_yaml_metadata(apps, metadatapath):
802
803     appid, thisinfo = get_default_app_info_list(apps, metadatapath)
804
805     yamlinfo = yaml.load(open(metadatapath, 'r'), Loader=YamlLoader)
806     thisinfo.update(yamlinfo)
807     post_metadata_parse(thisinfo)
808
809     return (appid, thisinfo)
810
811
812 def parse_txt_metadata(apps, metadatapath):
813
814     linedesc = None
815
816     def add_buildflag(p, thisbuild):
817         if not p.strip():
818             raise MetaDataException("Empty build flag at {1}"
819                                     .format(buildlines[0], linedesc))
820         bv = p.split('=', 1)
821         if len(bv) != 2:
822             raise MetaDataException("Invalid build flag at {0} in {1}"
823                                     .format(buildlines[0], linedesc))
824         pk, pv = bv
825         if pk in thisbuild:
826             raise MetaDataException("Duplicate definition on {0} in version {1} of {2}"
827                                     .format(pk, thisbuild['version'], linedesc))
828
829         pk = pk.lstrip()
830         if pk not in flag_defaults:
831             raise MetaDataException("Unrecognised build flag at {0} in {1}"
832                                     .format(p, linedesc))
833         t = flagtype(pk)
834         if t == 'list':
835             pv = split_list_values(pv)
836             if pk == 'gradle':
837                 if len(pv) == 1 and pv[0] in ['main', 'yes']:
838                     pv = ['yes']
839             thisbuild[pk] = pv
840         elif t == 'string' or t == 'script':
841             thisbuild[pk] = pv
842         elif t == 'bool':
843             value = pv == 'yes'
844             if value:
845                 thisbuild[pk] = True
846             else:
847                 logging.debug("...ignoring bool flag %s" % p)
848
849         else:
850             raise MetaDataException("Unrecognised build flag type '%s' at %s in %s"
851                                     % (t, p, linedesc))
852
853     def parse_buildline(lines):
854         value = "".join(lines)
855         parts = [p.replace("\\,", ",")
856                  for p in re.split(r"(?<!\\),", value)]
857         if len(parts) < 3:
858             raise MetaDataException("Invalid build format: " + value + " in " + metafile.name)
859         thisbuild = {}
860         thisbuild['origlines'] = lines
861         thisbuild['version'] = parts[0]
862         thisbuild['vercode'] = parts[1]
863         if parts[2].startswith('!'):
864             # For backwards compatibility, handle old-style disabling,
865             # including attempting to extract the commit from the message
866             thisbuild['disable'] = parts[2][1:]
867             commit = 'unknown - see disabled'
868             index = parts[2].rfind('at ')
869             if index != -1:
870                 commit = parts[2][index + 3:]
871                 if commit.endswith(')'):
872                     commit = commit[:-1]
873             thisbuild['commit'] = commit
874         else:
875             thisbuild['commit'] = parts[2]
876         for p in parts[3:]:
877             add_buildflag(p, thisbuild)
878
879         return thisbuild
880
881     def add_comments(key):
882         if not curcomments:
883             return
884         for comment in curcomments:
885             thisinfo['comments'].append([key, comment])
886         del curcomments[:]
887
888     appid, thisinfo = get_default_app_info_list(apps, metadatapath)
889     metafile = open(metadatapath, "r")
890
891     mode = 0
892     buildlines = []
893     curcomments = []
894     curbuild = None
895     vc_seen = {}
896
897     c = 0
898     for line in metafile:
899         c += 1
900         linedesc = "%s:%d" % (metafile.name, c)
901         line = line.rstrip('\r\n')
902         if mode == 3:
903             if not any(line.startswith(s) for s in (' ', '\t')):
904                 commit = curbuild['commit'] if 'commit' in curbuild else None
905                 if not commit and 'disable' not in curbuild:
906                     raise MetaDataException("No commit specified for {0} in {1}"
907                                             .format(curbuild['version'], linedesc))
908
909                 thisinfo['builds'].append(curbuild)
910                 add_comments('build:' + curbuild['vercode'])
911                 mode = 0
912             else:
913                 if line.endswith('\\'):
914                     buildlines.append(line[:-1].lstrip())
915                 else:
916                     buildlines.append(line.lstrip())
917                     bl = ''.join(buildlines)
918                     add_buildflag(bl, curbuild)
919                     buildlines = []
920
921         if mode == 0:
922             if not line:
923                 continue
924             if line.startswith("#"):
925                 curcomments.append(line)
926                 continue
927             try:
928                 field, value = line.split(':', 1)
929             except ValueError:
930                 raise MetaDataException("Invalid metadata in " + linedesc)
931             if field != field.strip() or value != value.strip():
932                 raise MetaDataException("Extra spacing found in " + linedesc)
933
934             # Translate obsolete fields...
935             if field == 'Market Version':
936                 field = 'Current Version'
937             if field == 'Market Version Code':
938                 field = 'Current Version Code'
939
940             fieldtype = metafieldtype(field)
941             if fieldtype not in ['build', 'buildv2']:
942                 add_comments(field)
943             if fieldtype == 'multiline':
944                 mode = 1
945                 thisinfo[field] = []
946                 if value:
947                     raise MetaDataException("Unexpected text on same line as " + field + " in " + linedesc)
948             elif fieldtype == 'string':
949                 thisinfo[field] = value
950             elif fieldtype == 'list':
951                 thisinfo[field] = split_list_values(value)
952             elif fieldtype == 'build':
953                 if value.endswith("\\"):
954                     mode = 2
955                     buildlines = [value[:-1]]
956                 else:
957                     curbuild = parse_buildline([value])
958                     thisinfo['builds'].append(curbuild)
959                     add_comments('build:' + thisinfo['builds'][-1]['vercode'])
960             elif fieldtype == 'buildv2':
961                 curbuild = {}
962                 vv = value.split(',')
963                 if len(vv) != 2:
964                     raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
965                                             .format(value, linedesc))
966                 curbuild['version'] = vv[0]
967                 curbuild['vercode'] = vv[1]
968                 if curbuild['vercode'] in vc_seen:
969                     raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
970                                             curbuild['vercode'], linedesc))
971                 vc_seen[curbuild['vercode']] = True
972                 buildlines = []
973                 mode = 3
974             elif fieldtype == 'obsolete':
975                 pass        # Just throw it away!
976             else:
977                 raise MetaDataException("Unrecognised field type for " + field + " in " + linedesc)
978         elif mode == 1:     # Multiline field
979             if line == '.':
980                 mode = 0
981             else:
982                 thisinfo[field].append(line)
983         elif mode == 2:     # Line continuation mode in Build Version
984             if line.endswith("\\"):
985                 buildlines.append(line[:-1])
986             else:
987                 buildlines.append(line)
988                 curbuild = parse_buildline(buildlines)
989                 thisinfo['builds'].append(curbuild)
990                 add_comments('build:' + thisinfo['builds'][-1]['vercode'])
991                 mode = 0
992     add_comments(None)
993
994     # Mode at end of file should always be 0...
995     if mode == 1:
996         raise MetaDataException(field + " not terminated in " + metafile.name)
997     elif mode == 2:
998         raise MetaDataException("Unterminated continuation in " + metafile.name)
999     elif mode == 3:
1000         raise MetaDataException("Unterminated build in " + metafile.name)
1001
1002     post_metadata_parse(thisinfo)
1003
1004     return (appid, thisinfo)
1005
1006
1007 # Write a metadata file.
1008 #
1009 # 'dest'    - The path to the output file
1010 # 'app'     - The app data
1011 def write_metadata(dest, app):
1012
1013     def writecomments(key):
1014         written = 0
1015         for pf, comment in app['comments']:
1016             if pf == key:
1017                 mf.write("%s\n" % comment)
1018                 written += 1
1019         if written > 0:
1020             logging.debug("...writing comments for " + (key or 'EOF'))
1021
1022     def writefield(field, value=None):
1023         writecomments(field)
1024         if value is None:
1025             value = app[field]
1026         t = metafieldtype(field)
1027         if t == 'list':
1028             value = ','.join(value)
1029         mf.write("%s:%s\n" % (field, value))
1030
1031     def writefield_nonempty(field, value=None):
1032         if value is None:
1033             value = app[field]
1034         if value:
1035             writefield(field, value)
1036
1037     mf = open(dest, 'w')
1038     writefield_nonempty('Disabled')
1039     writefield('AntiFeatures')
1040     writefield_nonempty('Provides')
1041     writefield('Categories')
1042     writefield('License')
1043     writefield('Web Site')
1044     writefield('Source Code')
1045     writefield('Issue Tracker')
1046     writefield_nonempty('Changelog')
1047     writefield_nonempty('Donate')
1048     writefield_nonempty('FlattrID')
1049     writefield_nonempty('Bitcoin')
1050     writefield_nonempty('Litecoin')
1051     writefield_nonempty('Dogecoin')
1052     mf.write('\n')
1053     writefield_nonempty('Name')
1054     writefield_nonempty('Auto Name')
1055     writefield('Summary')
1056     writefield('Description', '')
1057     for line in app['Description']:
1058         mf.write("%s\n" % line)
1059     mf.write('.\n')
1060     mf.write('\n')
1061     if app['Requires Root']:
1062         writefield('Requires Root', 'yes')
1063         mf.write('\n')
1064     if app['Repo Type']:
1065         writefield('Repo Type')
1066         writefield('Repo')
1067         if app['Binaries']:
1068             writefield('Binaries')
1069         mf.write('\n')
1070     for build in app['builds']:
1071
1072         if build['version'] == "Ignore":
1073             continue
1074
1075         writecomments('build:' + build['vercode'])
1076         mf.write("Build:%s,%s\n" % (build['version'], build['vercode']))
1077
1078         def write_builditem(key, value):
1079
1080             if key in ['version', 'vercode']:
1081                 return
1082
1083             if value == flag_defaults[key]:
1084                 return
1085
1086             t = flagtype(key)
1087
1088             logging.debug("...writing {0} : {1}".format(key, value))
1089             outline = '    %s=' % key
1090
1091             if t == 'string':
1092                 outline += value
1093             elif t == 'bool':
1094                 outline += 'yes'
1095             elif t == 'script':
1096                 outline += '&& \\\n        '.join([s.lstrip() for s in value.split('&& ')])
1097             elif t == 'list':
1098                 outline += ','.join(value) if type(value) == list else value
1099
1100             outline += '\n'
1101             mf.write(outline)
1102
1103         for flag in flag_defaults:
1104             value = build[flag]
1105             if value:
1106                 write_builditem(flag, value)
1107         mf.write('\n')
1108
1109     if app['Maintainer Notes']:
1110         writefield('Maintainer Notes', '')
1111         for line in app['Maintainer Notes']:
1112             mf.write("%s\n" % line)
1113         mf.write('.\n')
1114         mf.write('\n')
1115
1116     writefield_nonempty('Archive Policy')
1117     writefield('Auto Update Mode')
1118     writefield('Update Check Mode')
1119     writefield_nonempty('Update Check Ignore')
1120     writefield_nonempty('Vercode Operation')
1121     writefield_nonempty('Update Check Name')
1122     writefield_nonempty('Update Check Data')
1123     if app['Current Version']:
1124         writefield('Current Version')
1125         writefield('Current Version Code')
1126     mf.write('\n')
1127     if app['No Source Since']:
1128         writefield('No Source Since')
1129         mf.write('\n')
1130     writecomments(None)
1131     mf.close()