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