chiark / gitweb /
in JSON metadata, convert unicode to str to match the internal 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 glob
24 import cgi
25 import logging
26
27 from collections import OrderedDict
28
29 import common
30
31 srclibs = None
32
33
34 class MetaDataException(Exception):
35
36     def __init__(self, value):
37         self.value = value
38
39     def __str__(self):
40         return self.value
41
42 # In the order in which they are laid out on files
43 app_defaults = OrderedDict([
44     ('Disabled', None),
45     ('AntiFeatures', None),
46     ('Provides', None),
47     ('Categories', ['None']),
48     ('License', 'Unknown'),
49     ('Web Site', ''),
50     ('Source Code', ''),
51     ('Issue Tracker', ''),
52     ('Changelog', ''),
53     ('Donate', None),
54     ('FlattrID', None),
55     ('Bitcoin', None),
56     ('Litecoin', None),
57     ('Dogecoin', None),
58     ('Name', None),
59     ('Auto Name', ''),
60     ('Summary', ''),
61     ('Description', []),
62     ('Requires Root', False),
63     ('Repo Type', ''),
64     ('Repo', ''),
65     ('Binaries', None),
66     ('Maintainer Notes', []),
67     ('Archive Policy', None),
68     ('Auto Update Mode', 'None'),
69     ('Update Check Mode', 'None'),
70     ('Update Check Ignore', None),
71     ('Vercode Operation', None),
72     ('Update Check Name', None),
73     ('Update Check Data', None),
74     ('Current Version', ''),
75     ('Current Version Code', '0'),
76     ('No Source Since', ''),
77 ])
78
79
80 # In the order in which they are laid out on files
81 # Sorted by their action and their place in the build timeline
82 flag_defaults = OrderedDict([
83     ('disable', False),
84     ('commit', None),
85     ('subdir', None),
86     ('submodules', False),
87     ('init', ''),
88     ('patch', []),
89     ('gradle', False),
90     ('maven', False),
91     ('kivy', False),
92     ('output', None),
93     ('srclibs', []),
94     ('oldsdkloc', False),
95     ('encoding', None),
96     ('forceversion', False),
97     ('forcevercode', False),
98     ('rm', []),
99     ('extlibs', []),
100     ('prebuild', ''),
101     ('update', ['auto']),
102     ('target', None),
103     ('scanignore', []),
104     ('scandelete', []),
105     ('build', ''),
106     ('buildjni', []),
107     ('ndk', 'r10e'),  # defaults to latest
108     ('preassemble', []),
109     ('gradleprops', []),
110     ('antcommands', None),
111     ('novcheck', False),
112 ])
113
114
115 # Designates a metadata field type and checks that it matches
116 #
117 # 'name'     - The long name of the field type
118 # 'matching' - List of possible values or regex expression
119 # 'sep'      - Separator to use if value may be a list
120 # 'fields'   - Metadata fields (Field:Value) of this type
121 # 'attrs'    - Build attributes (attr=value) of this type
122 #
123 class FieldValidator():
124
125     def __init__(self, name, matching, sep, fields, attrs):
126         self.name = name
127         self.matching = matching
128         if type(matching) is str:
129             self.compiled = re.compile(matching)
130         self.sep = sep
131         self.fields = fields
132         self.attrs = attrs
133
134     def _assert_regex(self, values, appid):
135         for v in values:
136             if not self.compiled.match(v):
137                 raise MetaDataException("'%s' is not a valid %s in %s. "
138                                         % (v, self.name, appid) +
139                                         "Regex pattern: %s" % (self.matching))
140
141     def _assert_list(self, values, appid):
142         for v in values:
143             if v not in self.matching:
144                 raise MetaDataException("'%s' is not a valid %s in %s. "
145                                         % (v, self.name, appid) +
146                                         "Possible values: %s" % (", ".join(self.matching)))
147
148     def check(self, value, appid):
149         if type(value) is not str or not value:
150             return
151         if self.sep is not None:
152             values = value.split(self.sep)
153         else:
154             values = [value]
155         if type(self.matching) is list:
156             self._assert_list(values, appid)
157         else:
158             self._assert_regex(values, appid)
159
160
161 # Generic value types
162 valuetypes = {
163     FieldValidator("Integer",
164                    r'^[1-9][0-9]*$', None,
165                    [],
166                    ['vercode']),
167
168     FieldValidator("Hexadecimal",
169                    r'^[0-9a-f]+$', None,
170                    ['FlattrID'],
171                    []),
172
173     FieldValidator("HTTP link",
174                    r'^http[s]?://', None,
175                    ["Web Site", "Source Code", "Issue Tracker", "Changelog", "Donate"], []),
176
177     FieldValidator("Bitcoin address",
178                    r'^[a-zA-Z0-9]{27,34}$', None,
179                    ["Bitcoin"],
180                    []),
181
182     FieldValidator("Litecoin address",
183                    r'^L[a-zA-Z0-9]{33}$', None,
184                    ["Litecoin"],
185                    []),
186
187     FieldValidator("Dogecoin address",
188                    r'^D[a-zA-Z0-9]{33}$', None,
189                    ["Dogecoin"],
190                    []),
191
192     FieldValidator("Boolean",
193                    ['Yes', 'No'], None,
194                    ["Requires Root"],
195                    []),
196
197     FieldValidator("bool",
198                    ['yes', 'no'], None,
199                    [],
200                    ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
201                     'novcheck']),
202
203     FieldValidator("Repo Type",
204                    ['git', 'git-svn', 'svn', 'hg', 'bzr', 'srclib'], None,
205                    ["Repo Type"],
206                    []),
207
208     FieldValidator("Binaries",
209                    r'^http[s]?://', None,
210                    ["Binaries"],
211                    []),
212
213     FieldValidator("Archive Policy",
214                    r'^[0-9]+ versions$', None,
215                    ["Archive Policy"],
216                    []),
217
218     FieldValidator("Anti-Feature",
219                    ["Ads", "Tracking", "NonFreeNet", "NonFreeDep", "NonFreeAdd", "UpstreamNonFree"], ',',
220                    ["AntiFeatures"],
221                    []),
222
223     FieldValidator("Auto Update Mode",
224                    r"^(Version .+|None)$", None,
225                    ["Auto Update Mode"],
226                    []),
227
228     FieldValidator("Update Check Mode",
229                    r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$", None,
230                    ["Update Check Mode"],
231                    [])
232 }
233
234
235 # Check an app's metadata information for integrity errors
236 def check_metadata(info):
237     for v in valuetypes:
238         for field in v.fields:
239             v.check(info[field], info['id'])
240         for build in info['builds']:
241             for attr in v.attrs:
242                 v.check(build[attr], info['id'])
243
244
245 # Formatter for descriptions. Create an instance, and call parseline() with
246 # each line of the description source from the metadata. At the end, call
247 # end() and then text_wiki and text_html will contain the result.
248 class DescriptionFormatter:
249     stNONE = 0
250     stPARA = 1
251     stUL = 2
252     stOL = 3
253     bold = False
254     ital = False
255     state = stNONE
256     text_wiki = ''
257     text_html = ''
258     linkResolver = None
259
260     def __init__(self, linkres):
261         self.linkResolver = linkres
262
263     def endcur(self, notstates=None):
264         if notstates and self.state in notstates:
265             return
266         if self.state == self.stPARA:
267             self.endpara()
268         elif self.state == self.stUL:
269             self.endul()
270         elif self.state == self.stOL:
271             self.endol()
272
273     def endpara(self):
274         self.text_html += '</p>'
275         self.state = self.stNONE
276
277     def endul(self):
278         self.text_html += '</ul>'
279         self.state = self.stNONE
280
281     def endol(self):
282         self.text_html += '</ol>'
283         self.state = self.stNONE
284
285     def formatted(self, txt, html):
286         formatted = ''
287         if html:
288             txt = cgi.escape(txt)
289         while True:
290             index = txt.find("''")
291             if index == -1:
292                 return formatted + txt
293             formatted += txt[:index]
294             txt = txt[index:]
295             if txt.startswith("'''"):
296                 if html:
297                     if self.bold:
298                         formatted += '</b>'
299                     else:
300                         formatted += '<b>'
301                 self.bold = not self.bold
302                 txt = txt[3:]
303             else:
304                 if html:
305                     if self.ital:
306                         formatted += '</i>'
307                     else:
308                         formatted += '<i>'
309                 self.ital = not self.ital
310                 txt = txt[2:]
311
312     def linkify(self, txt):
313         linkified_plain = ''
314         linkified_html = ''
315         while True:
316             index = txt.find("[")
317             if index == -1:
318                 return (linkified_plain + self.formatted(txt, False), linkified_html + self.formatted(txt, True))
319             linkified_plain += self.formatted(txt[:index], False)
320             linkified_html += self.formatted(txt[:index], True)
321             txt = txt[index:]
322             if txt.startswith("[["):
323                 index = txt.find("]]")
324                 if index == -1:
325                     raise MetaDataException("Unterminated ]]")
326                 url = txt[2:index]
327                 if self.linkResolver:
328                     url, urltext = self.linkResolver(url)
329                 else:
330                     urltext = url
331                 linkified_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
332                 linkified_plain += urltext
333                 txt = txt[index + 2:]
334             else:
335                 index = txt.find("]")
336                 if index == -1:
337                     raise MetaDataException("Unterminated ]")
338                 url = txt[1:index]
339                 index2 = url.find(' ')
340                 if index2 == -1:
341                     urltxt = url
342                 else:
343                     urltxt = url[index2 + 1:]
344                     url = url[:index2]
345                     if url == urltxt:
346                         raise MetaDataException("Url title is just the URL - use [url]")
347                 linkified_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
348                 linkified_plain += urltxt
349                 if urltxt != url:
350                     linkified_plain += ' (' + url + ')'
351                 txt = txt[index + 1:]
352
353     def addtext(self, txt):
354         p, h = self.linkify(txt)
355         self.text_html += h
356
357     def parseline(self, line):
358         self.text_wiki += "%s\n" % line
359         if not line:
360             self.endcur()
361         elif line.startswith('* '):
362             self.endcur([self.stUL])
363             if self.state != self.stUL:
364                 self.text_html += '<ul>'
365                 self.state = self.stUL
366             self.text_html += '<li>'
367             self.addtext(line[1:])
368             self.text_html += '</li>'
369         elif line.startswith('# '):
370             self.endcur([self.stOL])
371             if self.state != self.stOL:
372                 self.text_html += '<ol>'
373                 self.state = self.stOL
374             self.text_html += '<li>'
375             self.addtext(line[1:])
376             self.text_html += '</li>'
377         else:
378             self.endcur([self.stPARA])
379             if self.state == self.stNONE:
380                 self.text_html += '<p>'
381                 self.state = self.stPARA
382             elif self.state == self.stPARA:
383                 self.text_html += ' '
384             self.addtext(line)
385
386     def end(self):
387         self.endcur()
388
389
390 # Parse multiple lines of description as written in a metadata file, returning
391 # a single string in wiki format. Used for the Maintainer Notes field as well,
392 # because it's the same format.
393 def description_wiki(lines):
394     ps = DescriptionFormatter(None)
395     for line in lines:
396         ps.parseline(line)
397     ps.end()
398     return ps.text_wiki
399
400
401 # Parse multiple lines of description as written in a metadata file, returning
402 # a single string in HTML format.
403 def description_html(lines, linkres):
404     ps = DescriptionFormatter(linkres)
405     for line in lines:
406         ps.parseline(line)
407     ps.end()
408     return ps.text_html
409
410
411 def parse_srclib(metafile):
412
413     thisinfo = {}
414     if metafile and not isinstance(metafile, file):
415         metafile = open(metafile, "r")
416
417     # Defaults for fields that come from metadata
418     thisinfo['Repo Type'] = ''
419     thisinfo['Repo'] = ''
420     thisinfo['Subdir'] = None
421     thisinfo['Prepare'] = None
422
423     if metafile is None:
424         return thisinfo
425
426     n = 0
427     for line in metafile:
428         n += 1
429         line = line.rstrip('\r\n')
430         if not line or line.startswith("#"):
431             continue
432
433         try:
434             field, value = line.split(':', 1)
435         except ValueError:
436             raise MetaDataException("Invalid metadata in %s:%d" % (line, n))
437
438         if field == "Subdir":
439             thisinfo[field] = value.split(',')
440         else:
441             thisinfo[field] = value
442
443     return thisinfo
444
445
446 def read_srclibs():
447     """Read all srclib metadata.
448
449     The information read will be accessible as metadata.srclibs, which is a
450     dictionary, keyed on srclib name, with the values each being a dictionary
451     in the same format as that returned by the parse_srclib function.
452
453     A MetaDataException is raised if there are any problems with the srclib
454     metadata.
455     """
456     global srclibs
457
458     # They were already loaded
459     if srclibs is not None:
460         return
461
462     srclibs = {}
463
464     srcdir = 'srclibs'
465     if not os.path.exists(srcdir):
466         os.makedirs(srcdir)
467
468     for metafile in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
469         srclibname = os.path.basename(metafile[:-4])
470         srclibs[srclibname] = parse_srclib(metafile)
471
472
473 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
474 # returned by the parse_txt_metadata function.
475 def read_metadata(xref=True):
476
477     # Always read the srclibs before the apps, since they can use a srlib as
478     # their source repository.
479     read_srclibs()
480
481     apps = {}
482
483     for basedir in ('metadata', 'tmp'):
484         if not os.path.exists(basedir):
485             os.makedirs(basedir)
486
487     for metafile in sorted(glob.glob(os.path.join('metadata', '*.txt'))):
488         appid, appinfo = parse_txt_metadata(metafile)
489         check_metadata(appinfo)
490         apps[appid] = appinfo
491
492     for metafile in sorted(glob.glob(os.path.join('metadata', '*.json'))):
493         appid, appinfo = parse_json_metadata(metafile)
494         check_metadata(appinfo)
495         apps[appid] = appinfo
496
497     if xref:
498         # Parse all descriptions at load time, just to ensure cross-referencing
499         # errors are caught early rather than when they hit the build server.
500         def linkres(appid):
501             if appid in apps:
502                 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
503             raise MetaDataException("Cannot resolve app id " + appid)
504
505         for appid, app in apps.iteritems():
506             try:
507                 description_html(app['Description'], linkres)
508             except MetaDataException, e:
509                 raise MetaDataException("Problem with description of " + appid +
510                                         " - " + str(e))
511
512     return apps
513
514
515 # Get the type expected for a given metadata field.
516 def metafieldtype(name):
517     if name in ['Description', 'Maintainer Notes']:
518         return 'multiline'
519     if name in ['Categories']:
520         return 'list'
521     if name == 'Build Version':
522         return 'build'
523     if name == 'Build':
524         return 'buildv2'
525     if name == 'Use Built':
526         return 'obsolete'
527     if name not in app_defaults:
528         return 'unknown'
529     return 'string'
530
531
532 def flagtype(name):
533     if name in ['extlibs', 'srclibs', 'patch', 'rm', 'buildjni', 'preassemble',
534                 'update', 'scanignore', 'scandelete', 'gradle', 'antcommands',
535                 'gradleprops']:
536         return 'list'
537     if name in ['init', 'prebuild', 'build']:
538         return 'script'
539     if name in ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
540                 'novcheck']:
541         return 'bool'
542     return 'string'
543
544
545 def fill_build_defaults(build):
546
547     def get_build_type():
548         for t in ['maven', 'gradle', 'kivy']:
549             if build[t]:
550                 return t
551         if build['output']:
552             return 'raw'
553         return 'ant'
554
555     for flag, value in flag_defaults.iteritems():
556         if flag in build:
557             continue
558         build[flag] = value
559     build['type'] = get_build_type()
560     build['ndk_path'] = common.get_ndk_path(build['ndk'])
561
562
563 def split_list_values(s):
564     # Port legacy ';' separators
565     l = [v.strip() for v in s.replace(';', ',').split(',')]
566     return [v for v in l if v]
567
568
569 def get_default_app_info_list():
570     thisinfo = {}
571     thisinfo.update(app_defaults)
572
573     # General defaults...
574     thisinfo['builds'] = []
575     thisinfo['comments'] = []
576
577     return thisinfo
578
579
580 def post_metadata_parse(thisinfo):
581
582     if not thisinfo['Description']:
583         thisinfo['Description'].append('No description available')
584
585     for build in thisinfo['builds']:
586         fill_build_defaults(build)
587
588     thisinfo['builds'] = sorted(thisinfo['builds'], key=lambda build: int(build['vercode']))
589
590
591 # Parse metadata for a single application.
592 #
593 #  'metafile' - the filename to read. The package id for the application comes
594 #               from this filename. Pass None to get a blank entry.
595 #
596 # Returns a dictionary containing all the details of the application. There are
597 # two major kinds of information in the dictionary. Keys beginning with capital
598 # letters correspond directory to identically named keys in the metadata file.
599 # Keys beginning with lower case letters are generated in one way or another,
600 # and are not found verbatim in the metadata.
601 #
602 # Known keys not originating from the metadata are:
603 #
604 #  'builds'           - a list of dictionaries containing build information
605 #                       for each defined build
606 #  'comments'         - a list of comments from the metadata file. Each is
607 #                       a tuple of the form (field, comment) where field is
608 #                       the name of the field it preceded in the metadata
609 #                       file. Where field is None, the comment goes at the
610 #                       end of the file. Alternatively, 'build:version' is
611 #                       for a comment before a particular build version.
612 #  'descriptionlines' - original lines of description as formatted in the
613 #                       metadata file.
614 #
615
616
617 def _decode_list(data):
618     '''convert items in a list from unicode to basestring'''
619     rv = []
620     for item in data:
621         if isinstance(item, unicode):
622             item = item.encode('utf-8')
623         elif isinstance(item, list):
624             item = _decode_list(item)
625         elif isinstance(item, dict):
626             item = _decode_dict(item)
627         rv.append(item)
628     return rv
629
630
631 def _decode_dict(data):
632     '''convert items in a dict from unicode to basestring'''
633     rv = {}
634     for key, value in data.iteritems():
635         if isinstance(key, unicode):
636             key = key.encode('utf-8')
637         if isinstance(value, unicode):
638             value = value.encode('utf-8')
639         elif isinstance(value, list):
640             value = _decode_list(value)
641         elif isinstance(value, dict):
642             value = _decode_dict(value)
643         rv[key] = value
644     return rv
645
646
647 def parse_json_metadata(metafile):
648
649     appid = os.path.basename(metafile)[0:-5]  # strip path and .json
650     thisinfo = get_default_app_info_list()
651     thisinfo['id'] = appid
652
653     # fdroid metadata is only strings and booleans, no floats or ints. And
654     # json returns unicode, and fdroidserver still uses plain python strings
655     jsoninfo = json.load(open(metafile, 'r'),
656                          object_hook=_decode_dict,
657                          parse_int=lambda s: s,
658                          parse_float=lambda s: s)
659     supported_metadata = app_defaults.keys() + ['builds', 'comments']
660     for k, v in jsoninfo.iteritems():
661         if k == 'Requires Root':
662             if isinstance(v, basestring):
663                 if re.match('^\s*(yes|true).*', v, flags=re.IGNORECASE):
664                     jsoninfo[k] = 'Yes'
665                 elif re.match('^\s*(no|false).*', v, flags=re.IGNORECASE):
666                     jsoninfo[k] = 'No'
667             if isinstance(v, bool):
668                 if v:
669                     jsoninfo[k] = 'Yes'
670                 else:
671                     jsoninfo[k] = 'No'
672         if k not in supported_metadata:
673             logging.warn(metafile + ' contains unknown metadata key, ignoring: ' + k)
674     thisinfo.update(jsoninfo)
675
676     for build in thisinfo['builds']:
677         for k, v in build.iteritems():
678             if k in ('buildjni', 'gradle', 'maven', 'kivy'):
679                 # convert standard types to mixed datatype legacy format
680                 if isinstance(v, bool):
681                     if v:
682                         build[k] = ['yes']
683                     else:
684                         build[k] = ['no']
685             elif k == 'versionCode':
686                 build['vercode'] = v
687                 del build['versionCode']
688             elif k == 'versionName':
689                 build['version'] = v
690                 del build['versionName']
691
692     # TODO create schema using https://pypi.python.org/pypi/jsonschema
693     post_metadata_parse(thisinfo)
694
695     return (appid, thisinfo)
696
697
698 def parse_txt_metadata(metafile):
699
700     appid = None
701     linedesc = None
702
703     def add_buildflag(p, thisbuild):
704         if not p.strip():
705             raise MetaDataException("Empty build flag at {1}"
706                                     .format(buildlines[0], linedesc))
707         bv = p.split('=', 1)
708         if len(bv) != 2:
709             raise MetaDataException("Invalid build flag at {0} in {1}"
710                                     .format(buildlines[0], linedesc))
711         pk, pv = bv
712         if pk in thisbuild:
713             raise MetaDataException("Duplicate definition on {0} in version {1} of {2}"
714                                     .format(pk, thisbuild['version'], linedesc))
715
716         pk = pk.lstrip()
717         if pk not in flag_defaults:
718             raise MetaDataException("Unrecognised build flag at {0} in {1}"
719                                     .format(p, linedesc))
720         t = flagtype(pk)
721         if t == 'list':
722             pv = split_list_values(pv)
723             if pk == 'gradle':
724                 if len(pv) == 1 and pv[0] in ['main', 'yes']:
725                     pv = ['yes']
726             thisbuild[pk] = pv
727         elif t == 'string' or t == 'script':
728             thisbuild[pk] = pv
729         elif t == 'bool':
730             value = pv == 'yes'
731             if value:
732                 thisbuild[pk] = True
733             else:
734                 logging.debug("...ignoring bool flag %s" % p)
735
736         else:
737             raise MetaDataException("Unrecognised build flag type '%s' at %s in %s"
738                                     % (t, p, linedesc))
739
740     def parse_buildline(lines):
741         value = "".join(lines)
742         parts = [p.replace("\\,", ",")
743                  for p in re.split(r"(?<!\\),", value)]
744         if len(parts) < 3:
745             raise MetaDataException("Invalid build format: " + value + " in " + metafile.name)
746         thisbuild = {}
747         thisbuild['origlines'] = lines
748         thisbuild['version'] = parts[0]
749         thisbuild['vercode'] = parts[1]
750         if parts[2].startswith('!'):
751             # For backwards compatibility, handle old-style disabling,
752             # including attempting to extract the commit from the message
753             thisbuild['disable'] = parts[2][1:]
754             commit = 'unknown - see disabled'
755             index = parts[2].rfind('at ')
756             if index != -1:
757                 commit = parts[2][index + 3:]
758                 if commit.endswith(')'):
759                     commit = commit[:-1]
760             thisbuild['commit'] = commit
761         else:
762             thisbuild['commit'] = parts[2]
763         for p in parts[3:]:
764             add_buildflag(p, thisbuild)
765
766         return thisbuild
767
768     def add_comments(key):
769         if not curcomments:
770             return
771         for comment in curcomments:
772             thisinfo['comments'].append((key, comment))
773         del curcomments[:]
774
775     thisinfo = get_default_app_info_list()
776     if metafile:
777         if not isinstance(metafile, file):
778             metafile = open(metafile, "r")
779         appid = metafile.name[9:-4]
780         thisinfo['id'] = appid
781     else:
782         return appid, thisinfo
783
784     mode = 0
785     buildlines = []
786     curcomments = []
787     curbuild = None
788     vc_seen = {}
789
790     c = 0
791     for line in metafile:
792         c += 1
793         linedesc = "%s:%d" % (metafile.name, c)
794         line = line.rstrip('\r\n')
795         if mode == 3:
796             if not any(line.startswith(s) for s in (' ', '\t')):
797                 commit = curbuild['commit'] if 'commit' in curbuild else None
798                 if not commit and 'disable' not in curbuild:
799                     raise MetaDataException("No commit specified for {0} in {1}"
800                                             .format(curbuild['version'], linedesc))
801
802                 thisinfo['builds'].append(curbuild)
803                 add_comments('build:' + curbuild['vercode'])
804                 mode = 0
805             else:
806                 if line.endswith('\\'):
807                     buildlines.append(line[:-1].lstrip())
808                 else:
809                     buildlines.append(line.lstrip())
810                     bl = ''.join(buildlines)
811                     add_buildflag(bl, curbuild)
812                     buildlines = []
813
814         if mode == 0:
815             if not line:
816                 continue
817             if line.startswith("#"):
818                 curcomments.append(line)
819                 continue
820             try:
821                 field, value = line.split(':', 1)
822             except ValueError:
823                 raise MetaDataException("Invalid metadata in " + linedesc)
824             if field != field.strip() or value != value.strip():
825                 raise MetaDataException("Extra spacing found in " + linedesc)
826
827             # Translate obsolete fields...
828             if field == 'Market Version':
829                 field = 'Current Version'
830             if field == 'Market Version Code':
831                 field = 'Current Version Code'
832
833             fieldtype = metafieldtype(field)
834             if fieldtype not in ['build', 'buildv2']:
835                 add_comments(field)
836             if fieldtype == 'multiline':
837                 mode = 1
838                 thisinfo[field] = []
839                 if value:
840                     raise MetaDataException("Unexpected text on same line as " + field + " in " + linedesc)
841             elif fieldtype == 'string':
842                 thisinfo[field] = value
843             elif fieldtype == 'list':
844                 thisinfo[field] = split_list_values(value)
845             elif fieldtype == 'build':
846                 if value.endswith("\\"):
847                     mode = 2
848                     buildlines = [value[:-1]]
849                 else:
850                     curbuild = parse_buildline([value])
851                     thisinfo['builds'].append(curbuild)
852                     add_comments('build:' + thisinfo['builds'][-1]['vercode'])
853             elif fieldtype == 'buildv2':
854                 curbuild = {}
855                 vv = value.split(',')
856                 if len(vv) != 2:
857                     raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
858                                             .format(value, linedesc))
859                 curbuild['version'] = vv[0]
860                 curbuild['vercode'] = vv[1]
861                 if curbuild['vercode'] in vc_seen:
862                     raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
863                                             curbuild['vercode'], linedesc))
864                 vc_seen[curbuild['vercode']] = True
865                 buildlines = []
866                 mode = 3
867             elif fieldtype == 'obsolete':
868                 pass        # Just throw it away!
869             else:
870                 raise MetaDataException("Unrecognised field type for " + field + " in " + linedesc)
871         elif mode == 1:     # Multiline field
872             if line == '.':
873                 mode = 0
874             else:
875                 thisinfo[field].append(line)
876         elif mode == 2:     # Line continuation mode in Build Version
877             if line.endswith("\\"):
878                 buildlines.append(line[:-1])
879             else:
880                 buildlines.append(line)
881                 curbuild = parse_buildline(buildlines)
882                 thisinfo['builds'].append(curbuild)
883                 add_comments('build:' + thisinfo['builds'][-1]['vercode'])
884                 mode = 0
885     add_comments(None)
886
887     # Mode at end of file should always be 0...
888     if mode == 1:
889         raise MetaDataException(field + " not terminated in " + metafile.name)
890     elif mode == 2:
891         raise MetaDataException("Unterminated continuation in " + metafile.name)
892     elif mode == 3:
893         raise MetaDataException("Unterminated build in " + metafile.name)
894
895     post_metadata_parse(thisinfo)
896
897     return (appid, thisinfo)
898
899
900 # Write a metadata file.
901 #
902 # 'dest'    - The path to the output file
903 # 'app'     - The app data
904 def write_metadata(dest, app):
905
906     def writecomments(key):
907         written = 0
908         for pf, comment in app['comments']:
909             if pf == key:
910                 mf.write("%s\n" % comment)
911                 written += 1
912         if written > 0:
913             logging.debug("...writing comments for " + (key or 'EOF'))
914
915     def writefield(field, value=None):
916         writecomments(field)
917         if value is None:
918             value = app[field]
919         t = metafieldtype(field)
920         if t == 'list':
921             value = ','.join(value)
922         mf.write("%s:%s\n" % (field, value))
923
924     def writefield_nonempty(field, value=None):
925         if value is None:
926             value = app[field]
927         if value:
928             writefield(field, value)
929
930     mf = open(dest, 'w')
931     writefield_nonempty('Disabled')
932     writefield_nonempty('AntiFeatures')
933     writefield_nonempty('Provides')
934     writefield('Categories')
935     writefield('License')
936     writefield('Web Site')
937     writefield('Source Code')
938     writefield('Issue Tracker')
939     writefield_nonempty('Changelog')
940     writefield_nonempty('Donate')
941     writefield_nonempty('FlattrID')
942     writefield_nonempty('Bitcoin')
943     writefield_nonempty('Litecoin')
944     writefield_nonempty('Dogecoin')
945     mf.write('\n')
946     writefield_nonempty('Name')
947     writefield_nonempty('Auto Name')
948     writefield('Summary')
949     writefield('Description', '')
950     for line in app['Description']:
951         mf.write("%s\n" % line)
952     mf.write('.\n')
953     mf.write('\n')
954     if app['Requires Root']:
955         writefield('Requires Root', 'Yes')
956         mf.write('\n')
957     if app['Repo Type']:
958         writefield('Repo Type')
959         writefield('Repo')
960         if app['Binaries']:
961             writefield('Binaries')
962         mf.write('\n')
963     for build in app['builds']:
964
965         if build['version'] == "Ignore":
966             continue
967
968         writecomments('build:' + build['vercode'])
969         mf.write("Build:%s,%s\n" % (build['version'], build['vercode']))
970
971         def write_builditem(key, value):
972
973             if key in ['version', 'vercode']:
974                 return
975
976             if value == flag_defaults[key]:
977                 return
978
979             t = flagtype(key)
980
981             logging.debug("...writing {0} : {1}".format(key, value))
982             outline = '    %s=' % key
983
984             if t == 'string':
985                 outline += value
986             elif t == 'bool':
987                 outline += 'yes'
988             elif t == 'script':
989                 outline += '&& \\\n        '.join([s.lstrip() for s in value.split('&& ')])
990             elif t == 'list':
991                 outline += ','.join(value) if type(value) == list else value
992
993             outline += '\n'
994             mf.write(outline)
995
996         for flag in flag_defaults:
997             value = build[flag]
998             if value:
999                 write_builditem(flag, value)
1000         mf.write('\n')
1001
1002     if app['Maintainer Notes']:
1003         writefield('Maintainer Notes', '')
1004         for line in app['Maintainer Notes']:
1005             mf.write("%s\n" % line)
1006         mf.write('.\n')
1007         mf.write('\n')
1008
1009     writefield_nonempty('Archive Policy')
1010     writefield('Auto Update Mode')
1011     writefield('Update Check Mode')
1012     writefield_nonempty('Update Check Ignore')
1013     writefield_nonempty('Vercode Operation')
1014     writefield_nonempty('Update Check Name')
1015     writefield_nonempty('Update Check Data')
1016     if app['Current Version']:
1017         writefield('Current Version')
1018         writefield('Current Version Code')
1019     mf.write('\n')
1020     if app['No Source Since']:
1021         writefield('No Source Since')
1022         mf.write('\n')
1023     writecomments(None)
1024     mf.close()