chiark / gitweb /
Pre-compile more regexes
[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 import textwrap
28
29 import yaml
30 # use libyaml if it is available
31 try:
32     from yaml import CLoader
33     YamlLoader = CLoader
34 except ImportError:
35     from yaml import Loader
36     YamlLoader = Loader
37
38 # use the C implementation when available
39 import xml.etree.cElementTree as ElementTree
40
41 import common
42
43 srclibs = None
44
45
46 class MetaDataException(Exception):
47
48     def __init__(self, value):
49         self.value = value
50
51     def __str__(self):
52         return self.value
53
54 # To filter which ones should be written to the metadata files if
55 # present
56 app_fields = set([
57     'Disabled',
58     'AntiFeatures',
59     'Provides',
60     'Categories',
61     'License',
62     'Web Site',
63     'Source Code',
64     'Issue Tracker',
65     'Changelog',
66     'Donate',
67     'FlattrID',
68     'Bitcoin',
69     'Litecoin',
70     'Name',
71     'Auto Name',
72     'Summary',
73     'Description',
74     'Requires Root',
75     'Repo Type',
76     'Repo',
77     'Binaries',
78     'Maintainer Notes',
79     'Archive Policy',
80     'Auto Update Mode',
81     'Update Check Mode',
82     'Update Check Ignore',
83     'Vercode Operation',
84     'Update Check Name',
85     'Update Check Data',
86     'Current Version',
87     'Current Version Code',
88     'No Source Since',
89
90     'comments',  # For formats that don't do inline comments
91     'builds',    # For formats that do builds as a list
92 ])
93
94
95 class App():
96
97     def __init__(self):
98         self.Disabled = None
99         self.AntiFeatures = []
100         self.Provides = None
101         self.Categories = ['None']
102         self.License = 'Unknown'
103         self.WebSite = ''
104         self.SourceCode = ''
105         self.IssueTracker = ''
106         self.Changelog = ''
107         self.Donate = None
108         self.FlattrID = None
109         self.Bitcoin = None
110         self.Litecoin = None
111         self.Name = None
112         self.AutoName = ''
113         self.Summary = ''
114         self.Description = []
115         self.RequiresRoot = False
116         self.RepoType = ''
117         self.Repo = ''
118         self.Binaries = None
119         self.MaintainerNotes = []
120         self.ArchivePolicy = None
121         self.AutoUpdateMode = 'None'
122         self.UpdateCheckMode = 'None'
123         self.UpdateCheckIgnore = None
124         self.VercodeOperation = None
125         self.UpdateCheckName = None
126         self.UpdateCheckData = None
127         self.CurrentVersion = ''
128         self.CurrentVersionCode = '0'
129         self.NoSourceSince = ''
130
131         self.id = None
132         self.metadatapath = None
133         self.builds = []
134         self.comments = {}
135         self.added = None
136         self.lastupdated = None
137
138     # Translates human-readable field names to attribute names, e.g.
139     # 'Auto Name' to 'AutoName'
140     @classmethod
141     def field_to_attr(cls, f):
142         return f.replace(' ', '')
143
144     # Translates attribute names to human-readable field names, e.g.
145     # 'AutoName' to 'Auto Name'
146     @classmethod
147     def attr_to_field(cls, k):
148         if k in app_fields:
149             return k
150         f = re.sub(r'([a-z])([A-Z])', r'\1 \2', k)
151         return f
152
153     # Constructs an old-fashioned dict with the human-readable field
154     # names. Should only be used for tests.
155     def field_dict(self):
156         d = {}
157         for k, v in self.__dict__.iteritems():
158             if k == 'builds':
159                 d['builds'] = []
160                 for build in v:
161                     d['builds'].append(build.__dict__)
162             else:
163                 k = App.attr_to_field(k)
164                 d[k] = v
165         return d
166
167     # Gets the value associated to a field name, e.g. 'Auto Name'
168     def get_field(self, f):
169         if f not in app_fields:
170             raise MetaDataException('Unrecognised app field: ' + f)
171         k = App.field_to_attr(f)
172         return getattr(self, k)
173
174     # Sets the value associated to a field name, e.g. 'Auto Name'
175     def set_field(self, f, v):
176         if f not in app_fields:
177             raise MetaDataException('Unrecognised app field: ' + f)
178         k = App.field_to_attr(f)
179         self.__dict__[k] = v
180
181     # Appends to the value associated to a field name, e.g. 'Auto Name'
182     def append_field(self, f, v):
183         if f not in app_fields:
184             raise MetaDataException('Unrecognised app field: ' + f)
185         k = App.field_to_attr(f)
186         if k not in self.__dict__:
187             self.__dict__[k] = [v]
188         else:
189             self.__dict__[k].append(v)
190
191     # Like dict.update(), but using human-readable field names
192     def update_fields(self, d):
193         for f, v in d.iteritems():
194             if f == 'builds':
195                 for b in v:
196                     build = Build()
197                     build.update_flags(b)
198                     self.builds.append(build)
199             else:
200                 self.set_field(f, v)
201
202
203 def metafieldtype(name):
204     if name in ['Description', 'Maintainer Notes']:
205         return 'multiline'
206     if name in ['Categories', 'AntiFeatures']:
207         return 'list'
208     if name == 'Build Version':
209         return 'build'
210     if name == 'Build':
211         return 'buildv2'
212     if name == 'Use Built':
213         return 'obsolete'
214     if name not in app_fields:
215         return 'unknown'
216     return 'string'
217
218
219 # In the order in which they are laid out on files
220 build_flags_order = [
221     'disable',
222     'commit',
223     'subdir',
224     'submodules',
225     'init',
226     'patch',
227     'gradle',
228     'maven',
229     'kivy',
230     'output',
231     'srclibs',
232     'oldsdkloc',
233     'encoding',
234     'forceversion',
235     'forcevercode',
236     'rm',
237     'extlibs',
238     'prebuild',
239     'update',
240     'target',
241     'scanignore',
242     'scandelete',
243     'build',
244     'buildjni',
245     'ndk',
246     'preassemble',
247     'gradleprops',
248     'antcommands',
249     'novcheck',
250 ]
251
252
253 build_flags = set(build_flags_order + ['version', 'vercode'])
254
255
256 class Build():
257
258     def __init__(self):
259         self.disable = False
260         self.commit = None
261         self.subdir = None
262         self.submodules = False
263         self.init = ''
264         self.patch = []
265         self.gradle = False
266         self.maven = False
267         self.kivy = False
268         self.output = None
269         self.srclibs = []
270         self.oldsdkloc = False
271         self.encoding = None
272         self.forceversion = False
273         self.forcevercode = False
274         self.rm = []
275         self.extlibs = []
276         self.prebuild = ''
277         self.update = None
278         self.target = None
279         self.scanignore = []
280         self.scandelete = []
281         self.build = ''
282         self.buildjni = []
283         self.ndk = None
284         self.preassemble = []
285         self.gradleprops = []
286         self.antcommands = None
287         self.novcheck = False
288
289     def get_flag(self, f):
290         if f not in build_flags:
291             raise MetaDataException('Unrecognised build flag: ' + f)
292         return getattr(self, f)
293
294     def set_flag(self, f, v):
295         if f == 'versionName':
296             f = 'version'
297         if f == 'versionCode':
298             f = 'vercode'
299         if f not in build_flags:
300             raise MetaDataException('Unrecognised build flag: ' + f)
301         setattr(self, f, v)
302
303     def append_flag(self, f, v):
304         if f not in build_flags:
305             raise MetaDataException('Unrecognised build flag: ' + f)
306         if f not in self.__dict__:
307             self.__dict__[f] = [v]
308         else:
309             self.__dict__[f].append(v)
310
311     def method(self):
312         for f in ['maven', 'gradle', 'kivy']:
313             if self.get_flag(f):
314                 return f
315         if self.output:
316             return 'raw'
317         return 'ant'
318
319     def ndk_path(self):
320         version = self.ndk
321         if not version:
322             version = 'r10e'  # falls back to latest
323         paths = common.config['ndk_paths']
324         if version not in paths:
325             return ''
326         return paths[version]
327
328     def update_flags(self, d):
329         for f, v in d.iteritems():
330             self.set_flag(f, v)
331
332
333 def flagtype(name):
334     if name in ['extlibs', 'srclibs', 'patch', 'rm', 'buildjni', 'preassemble',
335                 'update', 'scanignore', 'scandelete', 'gradle', 'antcommands',
336                 'gradleprops']:
337         return 'list'
338     if name in ['init', 'prebuild', 'build']:
339         return 'script'
340     if name in ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
341                 'novcheck']:
342         return 'bool'
343     return 'string'
344
345
346 # Designates a metadata field type and checks that it matches
347 #
348 # 'name'     - The long name of the field type
349 # 'matching' - List of possible values or regex expression
350 # 'sep'      - Separator to use if value may be a list
351 # 'fields'   - Metadata fields (Field:Value) of this type
352 # 'flags'    - Build flags (flag=value) of this type
353 #
354 class FieldValidator():
355
356     def __init__(self, name, matching, sep, fields, flags):
357         self.name = name
358         self.matching = matching
359         if type(matching) is str:
360             self.compiled = re.compile(matching)
361         self.sep = sep
362         self.fields = fields
363         self.flags = flags
364
365     def _assert_regex(self, values, appid):
366         for v in values:
367             if not self.compiled.match(v):
368                 raise MetaDataException("'%s' is not a valid %s in %s. "
369                                         % (v, self.name, appid) +
370                                         "Regex pattern: %s" % (self.matching))
371
372     def _assert_list(self, values, appid):
373         for v in values:
374             if v not in self.matching:
375                 raise MetaDataException("'%s' is not a valid %s in %s. "
376                                         % (v, self.name, appid) +
377                                         "Possible values: %s" % (", ".join(self.matching)))
378
379     def check(self, v, appid):
380         if type(v) is not str or not v:
381             return
382         if self.sep is not None:
383             values = v.split(self.sep)
384         else:
385             values = [v]
386         if type(self.matching) is list:
387             self._assert_list(values, appid)
388         else:
389             self._assert_regex(values, appid)
390
391
392 # Generic value types
393 valuetypes = {
394     FieldValidator("Integer",
395                    r'^[1-9][0-9]*$', None,
396                    [],
397                    ['vercode']),
398
399     FieldValidator("Hexadecimal",
400                    r'^[0-9a-f]+$', None,
401                    ['FlattrID'],
402                    []),
403
404     FieldValidator("HTTP link",
405                    r'^http[s]?://', None,
406                    ["Web Site", "Source Code", "Issue Tracker", "Changelog", "Donate"], []),
407
408     FieldValidator("Bitcoin address",
409                    r'^[a-zA-Z0-9]{27,34}$', None,
410                    ["Bitcoin"],
411                    []),
412
413     FieldValidator("Litecoin address",
414                    r'^L[a-zA-Z0-9]{33}$', None,
415                    ["Litecoin"],
416                    []),
417
418     FieldValidator("bool",
419                    r'([Yy]es|[Nn]o|[Tt]rue|[Ff]alse)', None,
420                    ["Requires Root"],
421                    ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
422                     'novcheck']),
423
424     FieldValidator("Repo Type",
425                    ['git', 'git-svn', 'svn', 'hg', 'bzr', 'srclib'], None,
426                    ["Repo Type"],
427                    []),
428
429     FieldValidator("Binaries",
430                    r'^http[s]?://', None,
431                    ["Binaries"],
432                    []),
433
434     FieldValidator("Archive Policy",
435                    r'^[0-9]+ versions$', None,
436                    ["Archive Policy"],
437                    []),
438
439     FieldValidator("Anti-Feature",
440                    ["Ads", "Tracking", "NonFreeNet", "NonFreeDep", "NonFreeAdd", "UpstreamNonFree"], ',',
441                    ["AntiFeatures"],
442                    []),
443
444     FieldValidator("Auto Update Mode",
445                    r"^(Version .+|None)$", None,
446                    ["Auto Update Mode"],
447                    []),
448
449     FieldValidator("Update Check Mode",
450                    r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$", None,
451                    ["Update Check Mode"],
452                    [])
453 }
454
455
456 # Check an app's metadata information for integrity errors
457 def check_metadata(app):
458     for v in valuetypes:
459         for f in v.fields:
460             v.check(app.get_field(f), app.id)
461         for build in app.builds:
462             for f in v.flags:
463                 v.check(build.get_flag(f), app.id)
464
465
466 # Formatter for descriptions. Create an instance, and call parseline() with
467 # each line of the description source from the metadata. At the end, call
468 # end() and then text_wiki and text_html will contain the result.
469 class DescriptionFormatter:
470     stNONE = 0
471     stPARA = 1
472     stUL = 2
473     stOL = 3
474     bold = False
475     ital = False
476     state = stNONE
477     text_wiki = ''
478     text_html = ''
479     text_txt = ''
480     para_lines = []
481     linkResolver = None
482
483     def __init__(self, linkres):
484         self.linkResolver = linkres
485
486     def endcur(self, notstates=None):
487         if notstates and self.state in notstates:
488             return
489         if self.state == self.stPARA:
490             self.endpara()
491         elif self.state == self.stUL:
492             self.endul()
493         elif self.state == self.stOL:
494             self.endol()
495
496     def endpara(self):
497         self.state = self.stNONE
498         whole_para = ' '.join(self.para_lines)
499         self.addtext(whole_para)
500         self.text_txt += textwrap.fill(whole_para, 80,
501                                        break_long_words=False,
502                                        break_on_hyphens=False) + '\n\n'
503         self.text_html += '</p>'
504         del self.para_lines[:]
505
506     def endul(self):
507         self.text_html += '</ul>'
508         self.text_txt += '\n'
509         self.state = self.stNONE
510
511     def endol(self):
512         self.text_html += '</ol>'
513         self.text_txt += '\n'
514         self.state = self.stNONE
515
516     def formatted(self, txt, html):
517         formatted = ''
518         if html:
519             txt = cgi.escape(txt)
520         while True:
521             index = txt.find("''")
522             if index == -1:
523                 return formatted + txt
524             formatted += txt[:index]
525             txt = txt[index:]
526             if txt.startswith("'''"):
527                 if html:
528                     if self.bold:
529                         formatted += '</b>'
530                     else:
531                         formatted += '<b>'
532                 self.bold = not self.bold
533                 txt = txt[3:]
534             else:
535                 if html:
536                     if self.ital:
537                         formatted += '</i>'
538                     else:
539                         formatted += '<i>'
540                 self.ital = not self.ital
541                 txt = txt[2:]
542
543     def linkify(self, txt):
544         linkified_plain = ''
545         linkified_html = ''
546         while True:
547             index = txt.find("[")
548             if index == -1:
549                 return (linkified_plain + self.formatted(txt, False), linkified_html + self.formatted(txt, True))
550             linkified_plain += self.formatted(txt[:index], False)
551             linkified_html += self.formatted(txt[:index], True)
552             txt = txt[index:]
553             if txt.startswith("[["):
554                 index = txt.find("]]")
555                 if index == -1:
556                     raise MetaDataException("Unterminated ]]")
557                 url = txt[2:index]
558                 if self.linkResolver:
559                     url, urltext = self.linkResolver(url)
560                 else:
561                     urltext = url
562                 linkified_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
563                 linkified_plain += urltext
564                 txt = txt[index + 2:]
565             else:
566                 index = txt.find("]")
567                 if index == -1:
568                     raise MetaDataException("Unterminated ]")
569                 url = txt[1:index]
570                 index2 = url.find(' ')
571                 if index2 == -1:
572                     urltxt = url
573                 else:
574                     urltxt = url[index2 + 1:]
575                     url = url[:index2]
576                     if url == urltxt:
577                         raise MetaDataException("Url title is just the URL - use [url]")
578                 linkified_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
579                 linkified_plain += urltxt
580                 if urltxt != url:
581                     linkified_plain += ' (' + url + ')'
582                 txt = txt[index + 1:]
583
584     def addtext(self, txt):
585         p, h = self.linkify(txt)
586         self.text_html += h
587
588     def parseline(self, line):
589         self.text_wiki += "%s\n" % line
590         if not line:
591             self.endcur()
592         elif line.startswith('* '):
593             self.endcur([self.stUL])
594             self.text_txt += "%s\n" % line
595             if self.state != self.stUL:
596                 self.text_html += '<ul>'
597                 self.state = self.stUL
598             self.text_html += '<li>'
599             self.addtext(line[1:])
600             self.text_html += '</li>'
601         elif line.startswith('# '):
602             self.endcur([self.stOL])
603             self.text_txt += "%s\n" % line
604             if self.state != self.stOL:
605                 self.text_html += '<ol>'
606                 self.state = self.stOL
607             self.text_html += '<li>'
608             self.addtext(line[1:])
609             self.text_html += '</li>'
610         else:
611             self.para_lines.append(line)
612             self.endcur([self.stPARA])
613             if self.state == self.stNONE:
614                 self.text_html += '<p>'
615                 self.state = self.stPARA
616
617     def end(self):
618         self.endcur()
619         self.text_txt = self.text_txt.strip()
620
621
622 # Parse multiple lines of description as written in a metadata file, returning
623 # a single string in text format and wrapped to 80 columns.
624 def description_txt(lines):
625     ps = DescriptionFormatter(None)
626     for line in lines:
627         ps.parseline(line)
628     ps.end()
629     return ps.text_txt
630
631
632 # Parse multiple lines of description as written in a metadata file, returning
633 # a single string in wiki format. Used for the Maintainer Notes field as well,
634 # because it's the same format.
635 def description_wiki(lines):
636     ps = DescriptionFormatter(None)
637     for line in lines:
638         ps.parseline(line)
639     ps.end()
640     return ps.text_wiki
641
642
643 # Parse multiple lines of description as written in a metadata file, returning
644 # a single string in HTML format.
645 def description_html(lines, linkres):
646     ps = DescriptionFormatter(linkres)
647     for line in lines:
648         ps.parseline(line)
649     ps.end()
650     return ps.text_html
651
652
653 def parse_srclib(metadatapath):
654
655     thisinfo = {}
656
657     # Defaults for fields that come from metadata
658     thisinfo['Repo Type'] = ''
659     thisinfo['Repo'] = ''
660     thisinfo['Subdir'] = None
661     thisinfo['Prepare'] = None
662
663     if not os.path.exists(metadatapath):
664         return thisinfo
665
666     metafile = open(metadatapath, "r")
667
668     n = 0
669     for line in metafile:
670         n += 1
671         line = line.rstrip('\r\n')
672         if not line or line.startswith("#"):
673             continue
674
675         try:
676             f, v = line.split(':', 1)
677         except ValueError:
678             raise MetaDataException("Invalid metadata in %s:%d" % (line, n))
679
680         if f == "Subdir":
681             thisinfo[f] = v.split(',')
682         else:
683             thisinfo[f] = v
684
685     return thisinfo
686
687
688 def read_srclibs():
689     """Read all srclib metadata.
690
691     The information read will be accessible as metadata.srclibs, which is a
692     dictionary, keyed on srclib name, with the values each being a dictionary
693     in the same format as that returned by the parse_srclib function.
694
695     A MetaDataException is raised if there are any problems with the srclib
696     metadata.
697     """
698     global srclibs
699
700     # They were already loaded
701     if srclibs is not None:
702         return
703
704     srclibs = {}
705
706     srcdir = 'srclibs'
707     if not os.path.exists(srcdir):
708         os.makedirs(srcdir)
709
710     for metadatapath in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
711         srclibname = os.path.basename(metadatapath[:-4])
712         srclibs[srclibname] = parse_srclib(metadatapath)
713
714
715 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
716 # returned by the parse_txt_metadata function.
717 def read_metadata(xref=True):
718
719     # Always read the srclibs before the apps, since they can use a srlib as
720     # their source repository.
721     read_srclibs()
722
723     apps = {}
724
725     for basedir in ('metadata', 'tmp'):
726         if not os.path.exists(basedir):
727             os.makedirs(basedir)
728
729     # If there are multiple metadata files for a single appid, then the first
730     # file that is parsed wins over all the others, and the rest throw an
731     # exception. So the original .txt format is parsed first, at least until
732     # newer formats stabilize.
733
734     for metadatapath in sorted(glob.glob(os.path.join('metadata', '*.txt'))
735                                + glob.glob(os.path.join('metadata', '*.json'))
736                                + glob.glob(os.path.join('metadata', '*.xml'))
737                                + glob.glob(os.path.join('metadata', '*.yaml'))):
738         app = parse_metadata(metadatapath)
739         if app.id in apps:
740             raise MetaDataException("Found multiple metadata files for " + app.id)
741         check_metadata(app)
742         apps[app.id] = app
743
744     if xref:
745         # Parse all descriptions at load time, just to ensure cross-referencing
746         # errors are caught early rather than when they hit the build server.
747         def linkres(appid):
748             if appid in apps:
749                 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
750             raise MetaDataException("Cannot resolve app id " + appid)
751
752         for appid, app in apps.iteritems():
753             try:
754                 description_html(app.Description, linkres)
755             except MetaDataException, e:
756                 raise MetaDataException("Problem with description of " + appid +
757                                         " - " + str(e))
758
759     return apps
760
761
762 def split_list_values(s):
763     # Port legacy ';' separators
764     l = [v.strip() for v in s.replace(';', ',').split(',')]
765     return [v for v in l if v]
766
767
768 def get_default_app_info(metadatapath=None):
769     if metadatapath is None:
770         appid = None
771     else:
772         appid, _ = common.get_extension(os.path.basename(metadatapath))
773
774     app = App()
775     app.metadatapath = metadatapath
776     if appid is not None:
777         app.id = appid
778
779     return app
780
781
782 def sorted_builds(builds):
783     return sorted(builds, key=lambda build: int(build.vercode))
784
785
786 esc_newlines = re.compile('\\\\( |\\n)')
787
788
789 def post_metadata_parse(app):
790
791     for f in app_fields:
792         v = app.get_field(f)
793         if type(v) in (float, int):
794             app.set_field(f, str(v))
795
796     # convert to the odd internal format
797     for f in ('Description', 'Maintainer Notes'):
798         v = app.get_field(f)
799         if isinstance(v, basestring):
800             text = v.rstrip().lstrip()
801             app.set_field(f, text.split('\n'))
802
803     for build in app.builds:
804         for k in build_flags:
805             v = build.get_flag(k)
806
807             if type(v) in (float, int):
808                 build.set_flag(k, v)
809             else:
810                 keyflagtype = flagtype(k)
811
812                 if keyflagtype == 'script':
813                     build.set_flag(k, re.sub(esc_newlines, '', v).lstrip().rstrip())
814                 elif keyflagtype == 'bool':
815                     # TODO handle this using <xsd:element type="xsd:boolean> in a schema
816                     if isinstance(v, basestring) and v == 'true':
817                         build.set_flag(k, 'true')
818                 elif keyflagtype == 'string':
819                     if isinstance(v, bool) and v:
820                         build.set_flag(k, 'yes')
821
822     if not app.Description:
823         app.Description = ['No description available']
824
825     app.builds = sorted_builds(app.builds)
826
827
828 # Parse metadata for a single application.
829 #
830 #  'metadatapath' - the filename to read. The package id for the application comes
831 #               from this filename. Pass None to get a blank entry.
832 #
833 # Returns a dictionary containing all the details of the application. There are
834 # two major kinds of information in the dictionary. Keys beginning with capital
835 # letters correspond directory to identically named keys in the metadata file.
836 # Keys beginning with lower case letters are generated in one way or another,
837 # and are not found verbatim in the metadata.
838 #
839 # Known keys not originating from the metadata are:
840 #
841 #  'builds'           - a list of dictionaries containing build information
842 #                       for each defined build
843 #  'comments'         - a list of comments from the metadata file. Each is
844 #                       a list of the form [field, comment] where field is
845 #                       the name of the field it preceded in the metadata
846 #                       file. Where field is None, the comment goes at the
847 #                       end of the file. Alternatively, 'build:version' is
848 #                       for a comment before a particular build version.
849 #  'descriptionlines' - original lines of description as formatted in the
850 #                       metadata file.
851 #
852
853
854 def _decode_list(data):
855     '''convert items in a list from unicode to basestring'''
856     rv = []
857     for item in data:
858         if isinstance(item, unicode):
859             item = item.encode('utf-8')
860         elif isinstance(item, list):
861             item = _decode_list(item)
862         elif isinstance(item, dict):
863             item = _decode_dict(item)
864         rv.append(item)
865     return rv
866
867
868 def _decode_dict(data):
869     '''convert items in a dict from unicode to basestring'''
870     rv = {}
871     for k, v in data.iteritems():
872         if isinstance(k, unicode):
873             k = k.encode('utf-8')
874         if isinstance(v, unicode):
875             v = v.encode('utf-8')
876         elif isinstance(v, list):
877             v = _decode_list(v)
878         elif isinstance(v, dict):
879             v = _decode_dict(v)
880         rv[k] = v
881     return rv
882
883
884 def parse_metadata(metadatapath):
885     _, ext = common.get_extension(metadatapath)
886     accepted = common.config['accepted_formats']
887     if ext not in accepted:
888         logging.critical('"' + metadatapath
889                          + '" is not in an accepted format, '
890                          + 'convert to: ' + ', '.join(accepted))
891         sys.exit(1)
892
893     if ext == 'txt':
894         return parse_txt_metadata(metadatapath)
895     if ext == 'json':
896         return parse_json_metadata(metadatapath)
897     if ext == 'xml':
898         return parse_xml_metadata(metadatapath)
899     if ext == 'yaml':
900         return parse_yaml_metadata(metadatapath)
901
902     logging.critical('Unknown metadata format: ' + metadatapath)
903     sys.exit(1)
904
905
906 def parse_json_metadata(metadatapath):
907
908     app = get_default_app_info(metadatapath)
909
910     # fdroid metadata is only strings and booleans, no floats or ints. And
911     # json returns unicode, and fdroidserver still uses plain python strings
912     # TODO create schema using https://pypi.python.org/pypi/jsonschema
913     jsoninfo = json.load(open(metadatapath, 'r'),
914                          object_hook=_decode_dict,
915                          parse_int=lambda s: s,
916                          parse_float=lambda s: s)
917     app.update_fields(jsoninfo)
918     post_metadata_parse(app)
919
920     return app
921
922
923 def parse_xml_metadata(metadatapath):
924
925     app = get_default_app_info(metadatapath)
926
927     tree = ElementTree.ElementTree(file=metadatapath)
928     root = tree.getroot()
929
930     if root.tag != 'resources':
931         logging.critical(metadatapath + ' does not have root as <resources></resources>!')
932         sys.exit(1)
933
934     for child in root:
935         if child.tag != 'builds':
936             # builds does not have name="" attrib
937             name = child.attrib['name']
938
939         if child.tag == 'string':
940             app.set_field(name, child.text)
941         elif child.tag == 'string-array':
942             for item in child:
943                 app.append_field(name, item.text)
944         elif child.tag == 'builds':
945             for b in child:
946                 build = Build()
947                 for key in b:
948                     build.set_flag(key.tag, key.text)
949                 app.builds.append(build)
950
951     # TODO handle this using <xsd:element type="xsd:boolean> in a schema
952     if not isinstance(app.RequiresRoot, bool):
953         if app.RequiresRoot == 'true':
954             app.RequiresRoot = True
955         else:
956             app.RequiresRoot = False
957
958     post_metadata_parse(app)
959
960     return app
961
962
963 def parse_yaml_metadata(metadatapath):
964
965     app = get_default_app_info(metadatapath)
966
967     yamlinfo = yaml.load(open(metadatapath, 'r'), Loader=YamlLoader)
968     app.update_fields(yamlinfo)
969     post_metadata_parse(app)
970
971     return app
972
973
974 build_line_sep = re.compile(r"(?<!\\),")
975
976
977 def parse_txt_metadata(metadatapath):
978
979     linedesc = None
980
981     def add_buildflag(p, build):
982         if not p.strip():
983             raise MetaDataException("Empty build flag at {1}"
984                                     .format(buildlines[0], linedesc))
985         bv = p.split('=', 1)
986         if len(bv) != 2:
987             raise MetaDataException("Invalid build flag at {0} in {1}"
988                                     .format(buildlines[0], linedesc))
989
990         pk, pv = bv
991         pk = pk.lstrip()
992         if pk not in build_flags:
993             raise MetaDataException("Unrecognised build flag at {0} in {1}"
994                                     .format(p, linedesc))
995         t = flagtype(pk)
996         if t == 'list':
997             pv = split_list_values(pv)
998             if pk == 'gradle':
999                 if len(pv) == 1 and pv[0] in ['main', 'yes']:
1000                     pv = ['yes']
1001             build.set_flag(pk, pv)
1002         elif t == 'string' or t == 'script':
1003             build.set_flag(pk, pv)
1004         elif t == 'bool':
1005             v = pv == 'yes'
1006             if v:
1007                 build.set_flag(pk, True)
1008
1009         else:
1010             raise MetaDataException("Unrecognised build flag type '%s' at %s in %s"
1011                                     % (t, p, linedesc))
1012
1013     def parse_buildline(lines):
1014         v = "".join(lines)
1015         parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
1016         if len(parts) < 3:
1017             raise MetaDataException("Invalid build format: " + v + " in " + metafile.name)
1018         build = Build()
1019         build.origlines = lines
1020         build.version = parts[0]
1021         build.vercode = parts[1]
1022         if parts[2].startswith('!'):
1023             # For backwards compatibility, handle old-style disabling,
1024             # including attempting to extract the commit from the message
1025             build.disable = parts[2][1:]
1026             commit = 'unknown - see disabled'
1027             index = parts[2].rfind('at ')
1028             if index != -1:
1029                 commit = parts[2][index + 3:]
1030                 if commit.endswith(')'):
1031                     commit = commit[:-1]
1032             build.commit = commit
1033         else:
1034             build.commit = parts[2]
1035         for p in parts[3:]:
1036             add_buildflag(p, build)
1037
1038         return build
1039
1040     def add_comments(key):
1041         if not curcomments:
1042             return
1043         app.comments[key] = list(curcomments)
1044         del curcomments[:]
1045
1046     app = get_default_app_info(metadatapath)
1047     metafile = open(metadatapath, "r")
1048
1049     mode = 0
1050     buildlines = []
1051     curcomments = []
1052     build = None
1053     vc_seen = {}
1054
1055     c = 0
1056     for line in metafile:
1057         c += 1
1058         linedesc = "%s:%d" % (metafile.name, c)
1059         line = line.rstrip('\r\n')
1060         if mode == 3:
1061             if not any(line.startswith(s) for s in (' ', '\t')):
1062                 if not build.commit and not build.disable:
1063                     raise MetaDataException("No commit specified for {0} in {1}"
1064                                             .format(build.version, linedesc))
1065
1066                 app.builds.append(build)
1067                 add_comments('build:' + build.vercode)
1068                 mode = 0
1069             else:
1070                 if line.endswith('\\'):
1071                     buildlines.append(line[:-1].lstrip())
1072                 else:
1073                     buildlines.append(line.lstrip())
1074                     bl = ''.join(buildlines)
1075                     add_buildflag(bl, build)
1076                     buildlines = []
1077
1078         if mode == 0:
1079             if not line:
1080                 continue
1081             if line.startswith("#"):
1082                 curcomments.append(line[1:].strip())
1083                 continue
1084             try:
1085                 f, v = line.split(':', 1)
1086             except ValueError:
1087                 raise MetaDataException("Invalid metadata in " + linedesc)
1088             if f != f.strip() or v != v.strip():
1089                 raise MetaDataException("Extra spacing found in " + linedesc)
1090
1091             # Translate obsolete fields...
1092             if f == 'Market Version':
1093                 f = 'Current Version'
1094             if f == 'Market Version Code':
1095                 f = 'Current Version Code'
1096
1097             fieldtype = metafieldtype(f)
1098             if fieldtype not in ['build', 'buildv2']:
1099                 add_comments(f)
1100             if fieldtype == 'multiline':
1101                 mode = 1
1102                 if v:
1103                     raise MetaDataException("Unexpected text on same line as " + f + " in " + linedesc)
1104             elif fieldtype == 'string':
1105                 app.set_field(f, v)
1106             elif fieldtype == 'list':
1107                 app.set_field(f, split_list_values(v))
1108             elif fieldtype == 'build':
1109                 if v.endswith("\\"):
1110                     mode = 2
1111                     buildlines = [v[:-1]]
1112                 else:
1113                     build = parse_buildline([v])
1114                     app.builds.append(build)
1115                     add_comments('build:' + app.builds[-1].vercode)
1116             elif fieldtype == 'buildv2':
1117                 build = Build()
1118                 vv = v.split(',')
1119                 if len(vv) != 2:
1120                     raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
1121                                             .format(v, linedesc))
1122                 build.version = vv[0]
1123                 build.vercode = vv[1]
1124                 if build.vercode in vc_seen:
1125                     raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
1126                                             build.vercode, linedesc))
1127                 vc_seen[build.vercode] = True
1128                 buildlines = []
1129                 mode = 3
1130             elif fieldtype == 'obsolete':
1131                 pass        # Just throw it away!
1132             else:
1133                 raise MetaDataException("Unrecognised field type for " + f + " in " + linedesc)
1134         elif mode == 1:     # Multiline field
1135             if line == '.':
1136                 mode = 0
1137             else:
1138                 app.append_field(f, line)
1139         elif mode == 2:     # Line continuation mode in Build Version
1140             if line.endswith("\\"):
1141                 buildlines.append(line[:-1])
1142             else:
1143                 buildlines.append(line)
1144                 build = parse_buildline(buildlines)
1145                 app.builds.append(build)
1146                 add_comments('build:' + app.builds[-1].vercode)
1147                 mode = 0
1148     add_comments(None)
1149
1150     # Mode at end of file should always be 0...
1151     if mode == 1:
1152         raise MetaDataException(f + " not terminated in " + metafile.name)
1153     elif mode == 2:
1154         raise MetaDataException("Unterminated continuation in " + metafile.name)
1155     elif mode == 3:
1156         raise MetaDataException("Unterminated build in " + metafile.name)
1157
1158     post_metadata_parse(app)
1159
1160     return app
1161
1162
1163 def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
1164
1165     def w_comments(key):
1166         if key not in app.comments:
1167             return
1168         for line in app.comments[key]:
1169             w_comment(line)
1170
1171     def w_field_always(f, v=None):
1172         if v is None:
1173             v = app.get_field(f)
1174         w_comments(f)
1175         w_field(f, v)
1176
1177     def w_field_nonempty(f, v=None):
1178         if v is None:
1179             v = app.get_field(f)
1180         w_comments(f)
1181         if v:
1182             w_field(f, v)
1183
1184     w_field_nonempty('Disabled')
1185     if app.AntiFeatures:
1186         w_field_always('AntiFeatures')
1187     w_field_nonempty('Provides')
1188     w_field_always('Categories')
1189     w_field_always('License')
1190     w_field_always('Web Site')
1191     w_field_always('Source Code')
1192     w_field_always('Issue Tracker')
1193     w_field_nonempty('Changelog')
1194     w_field_nonempty('Donate')
1195     w_field_nonempty('FlattrID')
1196     w_field_nonempty('Bitcoin')
1197     w_field_nonempty('Litecoin')
1198     mf.write('\n')
1199     w_field_nonempty('Name')
1200     w_field_nonempty('Auto Name')
1201     w_field_always('Summary')
1202     w_field_always('Description', description_txt(app.Description))
1203     mf.write('\n')
1204     if app.RequiresRoot:
1205         w_field_always('Requires Root', 'yes')
1206         mf.write('\n')
1207     if app.RepoType:
1208         w_field_always('Repo Type')
1209         w_field_always('Repo')
1210         if app.Binaries:
1211             w_field_always('Binaries')
1212         mf.write('\n')
1213
1214     for build in sorted_builds(app.builds):
1215
1216         if build.version == "Ignore":
1217             continue
1218
1219         w_comments('build:' + build.vercode)
1220         w_build(build)
1221         mf.write('\n')
1222
1223     if app.MaintainerNotes:
1224         w_field_always('Maintainer Notes', app.MaintainerNotes)
1225         mf.write('\n')
1226
1227     w_field_nonempty('Archive Policy')
1228     w_field_always('Auto Update Mode')
1229     w_field_always('Update Check Mode')
1230     w_field_nonempty('Update Check Ignore')
1231     w_field_nonempty('Vercode Operation')
1232     w_field_nonempty('Update Check Name')
1233     w_field_nonempty('Update Check Data')
1234     if app.CurrentVersion:
1235         w_field_always('Current Version')
1236         w_field_always('Current Version Code')
1237     if app.NoSourceSince:
1238         mf.write('\n')
1239         w_field_always('No Source Since')
1240     w_comments(None)
1241
1242
1243 # Write a metadata file in txt format.
1244 #
1245 # 'mf'      - Writer interface (file, StringIO, ...)
1246 # 'app'     - The app data
1247 def write_txt_metadata(mf, app):
1248
1249     def w_comment(line):
1250         mf.write("# %s\n" % line)
1251
1252     def w_field(f, v):
1253         t = metafieldtype(f)
1254         if t == 'list':
1255             v = ','.join(v)
1256         elif t == 'multiline':
1257             if type(v) == list:
1258                 v = '\n' + '\n'.join(v) + '\n.'
1259             else:
1260                 v = '\n' + v + '\n.'
1261         mf.write("%s:%s\n" % (f, v))
1262
1263     def w_build(build):
1264         mf.write("Build:%s,%s\n" % (build.version, build.vercode))
1265
1266         for f in build_flags_order:
1267             v = build.get_flag(f)
1268             if not v:
1269                 continue
1270
1271             t = flagtype(f)
1272             out = '    %s=' % f
1273             if t == 'string':
1274                 out += v
1275             elif t == 'bool':
1276                 out += 'yes'
1277             elif t == 'script':
1278                 out += '&& \\\n        '.join([s.lstrip() for s in v.split('&& ')])
1279             elif t == 'list':
1280                 out += ','.join(v) if type(v) == list else v
1281
1282             mf.write(out)
1283             mf.write('\n')
1284
1285     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1286
1287
1288 def write_yaml_metadata(mf, app):
1289
1290     def w_comment(line):
1291         mf.write("# %s\n" % line)
1292
1293     def escape(v):
1294         if not v:
1295             return ''
1296         if any(c in v for c in [': ', '%', '@', '*']):
1297             return "'" + v.replace("'", "''") + "'"
1298         return v
1299
1300     def w_field(f, v, prefix='', t=None):
1301         if t is None:
1302             t = metafieldtype(f)
1303         v = ''
1304         if t == 'list':
1305             v = '\n'
1306             for e in v:
1307                 v += prefix + ' - ' + escape(e) + '\n'
1308         elif t == 'multiline':
1309             v = ' |\n'
1310             lines = v
1311             if type(v) == str:
1312                 lines = v.splitlines()
1313             for l in lines:
1314                 if l:
1315                     v += prefix + '  ' + l + '\n'
1316                 else:
1317                     v += '\n'
1318         elif t == 'bool':
1319             v = ' yes\n'
1320         elif t == 'script':
1321             cmds = [s + '&& \\' for s in v.split('&& ')]
1322             if len(cmds) > 0:
1323                 cmds[-1] = cmds[-1][:-len('&& \\')]
1324             w_field(f, cmds, prefix, 'multiline')
1325             return
1326         else:
1327             v = ' ' + escape(v) + '\n'
1328
1329         mf.write(prefix)
1330         mf.write(f)
1331         mf.write(":")
1332         mf.write(v)
1333
1334     global first_build
1335     first_build = True
1336
1337     def w_build(build):
1338         global first_build
1339         if first_build:
1340             mf.write("builds:\n")
1341             first_build = False
1342
1343         w_field('versionName', build.version, '  - ', 'string')
1344         w_field('versionCode', build.vercode, '    ', 'strsng')
1345         for f in build_flags_order:
1346             v = build.get_flag(f)
1347             if not v:
1348                 continue
1349
1350             w_field(f, v, '    ', flagtype(f))
1351
1352     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1353
1354
1355 def write_metadata(fmt, mf, app):
1356     if fmt == 'txt':
1357         return write_txt_metadata(mf, app)
1358     if fmt == 'yaml':
1359         return write_yaml_metadata(mf, app)
1360     raise MetaDataException("Unknown metadata format given")