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