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