chiark / gitweb /
metadata: properly store nums as strs and bools as bools
[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, str(v))
809                 continue
810
811             ftype = flagtype(k)
812
813             if ftype == 'script':
814                 build.set_flag(k, re.sub(esc_newlines, '', v).lstrip().rstrip())
815             elif ftype == 'bool':
816                 # TODO handle this using <xsd:element type="xsd:boolean> in a schema
817                 if isinstance(v, basestring) and v == 'true':
818                     build.set_flag(k, True)
819             elif ftype == 'string':
820                 if isinstance(v, bool) and v:
821                     build.set_flag(k, 'yes')
822
823     if not app.Description:
824         app.Description = ['No description available']
825
826     app.builds = sorted_builds(app.builds)
827
828
829 # Parse metadata for a single application.
830 #
831 #  'metadatapath' - the filename to read. The package id for the application comes
832 #               from this filename. Pass None to get a blank entry.
833 #
834 # Returns a dictionary containing all the details of the application. There are
835 # two major kinds of information in the dictionary. Keys beginning with capital
836 # letters correspond directory to identically named keys in the metadata file.
837 # Keys beginning with lower case letters are generated in one way or another,
838 # and are not found verbatim in the metadata.
839 #
840 # Known keys not originating from the metadata are:
841 #
842 #  'builds'           - a list of dictionaries containing build information
843 #                       for each defined build
844 #  'comments'         - a list of comments from the metadata file. Each is
845 #                       a list of the form [field, comment] where field is
846 #                       the name of the field it preceded in the metadata
847 #                       file. Where field is None, the comment goes at the
848 #                       end of the file. Alternatively, 'build:version' is
849 #                       for a comment before a particular build version.
850 #  'descriptionlines' - original lines of description as formatted in the
851 #                       metadata file.
852 #
853
854
855 def _decode_list(data):
856     '''convert items in a list from unicode to basestring'''
857     rv = []
858     for item in data:
859         if isinstance(item, unicode):
860             item = item.encode('utf-8')
861         elif isinstance(item, list):
862             item = _decode_list(item)
863         elif isinstance(item, dict):
864             item = _decode_dict(item)
865         rv.append(item)
866     return rv
867
868
869 def _decode_dict(data):
870     '''convert items in a dict from unicode to basestring'''
871     rv = {}
872     for k, v in data.iteritems():
873         if isinstance(k, unicode):
874             k = k.encode('utf-8')
875         if isinstance(v, unicode):
876             v = v.encode('utf-8')
877         elif isinstance(v, list):
878             v = _decode_list(v)
879         elif isinstance(v, dict):
880             v = _decode_dict(v)
881         rv[k] = v
882     return rv
883
884
885 def parse_metadata(metadatapath):
886     _, ext = common.get_extension(metadatapath)
887     accepted = common.config['accepted_formats']
888     if ext not in accepted:
889         logging.critical('"' + metadatapath
890                          + '" is not in an accepted format, '
891                          + 'convert to: ' + ', '.join(accepted))
892         sys.exit(1)
893
894     if ext == 'txt':
895         return parse_txt_metadata(metadatapath)
896     if ext == 'json':
897         return parse_json_metadata(metadatapath)
898     if ext == 'xml':
899         return parse_xml_metadata(metadatapath)
900     if ext == 'yaml':
901         return parse_yaml_metadata(metadatapath)
902
903     logging.critical('Unknown metadata format: ' + metadatapath)
904     sys.exit(1)
905
906
907 def parse_json_metadata(metadatapath):
908
909     app = get_default_app_info(metadatapath)
910
911     # fdroid metadata is only strings and booleans, no floats or ints. And
912     # json returns unicode, and fdroidserver still uses plain python strings
913     # TODO create schema using https://pypi.python.org/pypi/jsonschema
914     jsoninfo = json.load(open(metadatapath, 'r'),
915                          object_hook=_decode_dict,
916                          parse_int=lambda s: s,
917                          parse_float=lambda s: s)
918     app.update_fields(jsoninfo)
919     post_metadata_parse(app)
920
921     return app
922
923
924 def parse_xml_metadata(metadatapath):
925
926     app = get_default_app_info(metadatapath)
927
928     tree = ElementTree.ElementTree(file=metadatapath)
929     root = tree.getroot()
930
931     if root.tag != 'resources':
932         logging.critical(metadatapath + ' does not have root as <resources></resources>!')
933         sys.exit(1)
934
935     for child in root:
936         if child.tag != 'builds':
937             # builds does not have name="" attrib
938             name = child.attrib['name']
939
940         if child.tag == 'string':
941             app.set_field(name, child.text)
942         elif child.tag == 'string-array':
943             for item in child:
944                 app.append_field(name, item.text)
945         elif child.tag == 'builds':
946             for b in child:
947                 build = Build()
948                 for key in b:
949                     build.set_flag(key.tag, key.text)
950                 app.builds.append(build)
951
952     # TODO handle this using <xsd:element type="xsd:boolean> in a schema
953     if not isinstance(app.RequiresRoot, bool):
954         if app.RequiresRoot == 'true':
955             app.RequiresRoot = True
956         else:
957             app.RequiresRoot = False
958
959     post_metadata_parse(app)
960
961     return app
962
963
964 def parse_yaml_metadata(metadatapath):
965
966     app = get_default_app_info(metadatapath)
967
968     yamlinfo = yaml.load(open(metadatapath, 'r'), Loader=YamlLoader)
969     app.update_fields(yamlinfo)
970     post_metadata_parse(app)
971
972     return app
973
974
975 build_line_sep = re.compile(r"(?<!\\),")
976
977
978 def parse_txt_metadata(metadatapath):
979
980     linedesc = None
981
982     def add_buildflag(p, build):
983         if not p.strip():
984             raise MetaDataException("Empty build flag at {1}"
985                                     .format(buildlines[0], linedesc))
986         bv = p.split('=', 1)
987         if len(bv) != 2:
988             raise MetaDataException("Invalid build flag at {0} in {1}"
989                                     .format(buildlines[0], linedesc))
990
991         pk, pv = bv
992         pk = pk.lstrip()
993         if pk not in build_flags:
994             raise MetaDataException("Unrecognised build flag at {0} in {1}"
995                                     .format(p, linedesc))
996         t = flagtype(pk)
997         if t == 'list':
998             pv = split_list_values(pv)
999             if pk == 'gradle':
1000                 if len(pv) == 1 and pv[0] in ['main', 'yes']:
1001                     pv = ['yes']
1002             build.set_flag(pk, pv)
1003         elif t == 'string' or t == 'script':
1004             build.set_flag(pk, pv)
1005         elif t == 'bool':
1006             v = pv == 'yes'
1007             if v:
1008                 build.set_flag(pk, True)
1009
1010         else:
1011             raise MetaDataException("Unrecognised build flag type '%s' at %s in %s"
1012                                     % (t, p, linedesc))
1013
1014     def parse_buildline(lines):
1015         v = "".join(lines)
1016         parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
1017         if len(parts) < 3:
1018             raise MetaDataException("Invalid build format: " + v + " in " + metafile.name)
1019         build = Build()
1020         build.origlines = lines
1021         build.version = parts[0]
1022         build.vercode = parts[1]
1023         if parts[2].startswith('!'):
1024             # For backwards compatibility, handle old-style disabling,
1025             # including attempting to extract the commit from the message
1026             build.disable = parts[2][1:]
1027             commit = 'unknown - see disabled'
1028             index = parts[2].rfind('at ')
1029             if index != -1:
1030                 commit = parts[2][index + 3:]
1031                 if commit.endswith(')'):
1032                     commit = commit[:-1]
1033             build.commit = commit
1034         else:
1035             build.commit = parts[2]
1036         for p in parts[3:]:
1037             add_buildflag(p, build)
1038
1039         return build
1040
1041     def add_comments(key):
1042         if not curcomments:
1043             return
1044         app.comments[key] = list(curcomments)
1045         del curcomments[:]
1046
1047     app = get_default_app_info(metadatapath)
1048     metafile = open(metadatapath, "r")
1049
1050     mode = 0
1051     buildlines = []
1052     curcomments = []
1053     build = None
1054     vc_seen = {}
1055
1056     c = 0
1057     for line in metafile:
1058         c += 1
1059         linedesc = "%s:%d" % (metafile.name, c)
1060         line = line.rstrip('\r\n')
1061         if mode == 3:
1062             if not any(line.startswith(s) for s in (' ', '\t')):
1063                 if not build.commit and not build.disable:
1064                     raise MetaDataException("No commit specified for {0} in {1}"
1065                                             .format(build.version, linedesc))
1066
1067                 app.builds.append(build)
1068                 add_comments('build:' + build.vercode)
1069                 mode = 0
1070             else:
1071                 if line.endswith('\\'):
1072                     buildlines.append(line[:-1].lstrip())
1073                 else:
1074                     buildlines.append(line.lstrip())
1075                     bl = ''.join(buildlines)
1076                     add_buildflag(bl, build)
1077                     buildlines = []
1078
1079         if mode == 0:
1080             if not line:
1081                 continue
1082             if line.startswith("#"):
1083                 curcomments.append(line[1:].strip())
1084                 continue
1085             try:
1086                 f, v = line.split(':', 1)
1087             except ValueError:
1088                 raise MetaDataException("Invalid metadata in " + linedesc)
1089             if f != f.strip() or v != v.strip():
1090                 raise MetaDataException("Extra spacing found in " + linedesc)
1091
1092             # Translate obsolete fields...
1093             if f == 'Market Version':
1094                 f = 'Current Version'
1095             if f == 'Market Version Code':
1096                 f = 'Current Version Code'
1097
1098             fieldtype = metafieldtype(f)
1099             if fieldtype not in ['build', 'buildv2']:
1100                 add_comments(f)
1101             if fieldtype == 'multiline':
1102                 mode = 1
1103                 if v:
1104                     raise MetaDataException("Unexpected text on same line as " + f + " in " + linedesc)
1105             elif fieldtype == 'string':
1106                 app.set_field(f, v)
1107             elif fieldtype == 'list':
1108                 app.set_field(f, split_list_values(v))
1109             elif fieldtype == 'build':
1110                 if v.endswith("\\"):
1111                     mode = 2
1112                     buildlines = [v[:-1]]
1113                 else:
1114                     build = parse_buildline([v])
1115                     app.builds.append(build)
1116                     add_comments('build:' + app.builds[-1].vercode)
1117             elif fieldtype == 'buildv2':
1118                 build = Build()
1119                 vv = v.split(',')
1120                 if len(vv) != 2:
1121                     raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
1122                                             .format(v, linedesc))
1123                 build.version = vv[0]
1124                 build.vercode = vv[1]
1125                 if build.vercode in vc_seen:
1126                     raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
1127                                             build.vercode, linedesc))
1128                 vc_seen[build.vercode] = True
1129                 buildlines = []
1130                 mode = 3
1131             elif fieldtype == 'obsolete':
1132                 pass        # Just throw it away!
1133             else:
1134                 raise MetaDataException("Unrecognised field type for " + f + " in " + linedesc)
1135         elif mode == 1:     # Multiline field
1136             if line == '.':
1137                 mode = 0
1138             else:
1139                 app.append_field(f, line)
1140         elif mode == 2:     # Line continuation mode in Build Version
1141             if line.endswith("\\"):
1142                 buildlines.append(line[:-1])
1143             else:
1144                 buildlines.append(line)
1145                 build = parse_buildline(buildlines)
1146                 app.builds.append(build)
1147                 add_comments('build:' + app.builds[-1].vercode)
1148                 mode = 0
1149     add_comments(None)
1150
1151     # Mode at end of file should always be 0...
1152     if mode == 1:
1153         raise MetaDataException(f + " not terminated in " + metafile.name)
1154     elif mode == 2:
1155         raise MetaDataException("Unterminated continuation in " + metafile.name)
1156     elif mode == 3:
1157         raise MetaDataException("Unterminated build in " + metafile.name)
1158
1159     post_metadata_parse(app)
1160
1161     return app
1162
1163
1164 def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
1165
1166     def w_comments(key):
1167         if key not in app.comments:
1168             return
1169         for line in app.comments[key]:
1170             w_comment(line)
1171
1172     def w_field_always(f, v=None):
1173         if v is None:
1174             v = app.get_field(f)
1175         w_comments(f)
1176         w_field(f, v)
1177
1178     def w_field_nonempty(f, v=None):
1179         if v is None:
1180             v = app.get_field(f)
1181         w_comments(f)
1182         if v:
1183             w_field(f, v)
1184
1185     w_field_nonempty('Disabled')
1186     if app.AntiFeatures:
1187         w_field_always('AntiFeatures')
1188     w_field_nonempty('Provides')
1189     w_field_always('Categories')
1190     w_field_always('License')
1191     w_field_always('Web Site')
1192     w_field_always('Source Code')
1193     w_field_always('Issue Tracker')
1194     w_field_nonempty('Changelog')
1195     w_field_nonempty('Donate')
1196     w_field_nonempty('FlattrID')
1197     w_field_nonempty('Bitcoin')
1198     w_field_nonempty('Litecoin')
1199     mf.write('\n')
1200     w_field_nonempty('Name')
1201     w_field_nonempty('Auto Name')
1202     w_field_always('Summary')
1203     w_field_always('Description', description_txt(app.Description))
1204     mf.write('\n')
1205     if app.RequiresRoot:
1206         w_field_always('Requires Root', 'yes')
1207         mf.write('\n')
1208     if app.RepoType:
1209         w_field_always('Repo Type')
1210         w_field_always('Repo')
1211         if app.Binaries:
1212             w_field_always('Binaries')
1213         mf.write('\n')
1214
1215     for build in sorted_builds(app.builds):
1216
1217         if build.version == "Ignore":
1218             continue
1219
1220         w_comments('build:' + build.vercode)
1221         w_build(build)
1222         mf.write('\n')
1223
1224     if app.MaintainerNotes:
1225         w_field_always('Maintainer Notes', app.MaintainerNotes)
1226         mf.write('\n')
1227
1228     w_field_nonempty('Archive Policy')
1229     w_field_always('Auto Update Mode')
1230     w_field_always('Update Check Mode')
1231     w_field_nonempty('Update Check Ignore')
1232     w_field_nonempty('Vercode Operation')
1233     w_field_nonempty('Update Check Name')
1234     w_field_nonempty('Update Check Data')
1235     if app.CurrentVersion:
1236         w_field_always('Current Version')
1237         w_field_always('Current Version Code')
1238     if app.NoSourceSince:
1239         mf.write('\n')
1240         w_field_always('No Source Since')
1241     w_comments(None)
1242
1243
1244 # Write a metadata file in txt format.
1245 #
1246 # 'mf'      - Writer interface (file, StringIO, ...)
1247 # 'app'     - The app data
1248 def write_txt_metadata(mf, app):
1249
1250     def w_comment(line):
1251         mf.write("# %s\n" % line)
1252
1253     def w_field(f, v):
1254         t = metafieldtype(f)
1255         if t == 'list':
1256             v = ','.join(v)
1257         elif t == 'multiline':
1258             if type(v) == list:
1259                 v = '\n' + '\n'.join(v) + '\n.'
1260             else:
1261                 v = '\n' + v + '\n.'
1262         mf.write("%s:%s\n" % (f, v))
1263
1264     def w_build(build):
1265         mf.write("Build:%s,%s\n" % (build.version, build.vercode))
1266
1267         for f in build_flags_order:
1268             v = build.get_flag(f)
1269             if not v:
1270                 continue
1271
1272             t = flagtype(f)
1273             out = '    %s=' % f
1274             if t == 'string':
1275                 out += v
1276             elif t == 'bool':
1277                 out += 'yes'
1278             elif t == 'script':
1279                 out += '&& \\\n        '.join([s.lstrip() for s in v.split('&& ')])
1280             elif t == 'list':
1281                 out += ','.join(v) if type(v) == list else v
1282
1283             mf.write(out)
1284             mf.write('\n')
1285
1286     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1287
1288
1289 def write_yaml_metadata(mf, app):
1290
1291     def w_comment(line):
1292         mf.write("# %s\n" % line)
1293
1294     def escape(v):
1295         if not v:
1296             return ''
1297         if any(c in v for c in [': ', '%', '@', '*']):
1298             return "'" + v.replace("'", "''") + "'"
1299         return v
1300
1301     def w_field(f, v, prefix='', t=None):
1302         if t is None:
1303             t = metafieldtype(f)
1304         v = ''
1305         if t == 'list':
1306             v = '\n'
1307             for e in v:
1308                 v += prefix + ' - ' + escape(e) + '\n'
1309         elif t == 'multiline':
1310             v = ' |\n'
1311             lines = v
1312             if type(v) == str:
1313                 lines = v.splitlines()
1314             for l in lines:
1315                 if l:
1316                     v += prefix + '  ' + l + '\n'
1317                 else:
1318                     v += '\n'
1319         elif t == 'bool':
1320             v = ' yes\n'
1321         elif t == 'script':
1322             cmds = [s + '&& \\' for s in v.split('&& ')]
1323             if len(cmds) > 0:
1324                 cmds[-1] = cmds[-1][:-len('&& \\')]
1325             w_field(f, cmds, prefix, 'multiline')
1326             return
1327         else:
1328             v = ' ' + escape(v) + '\n'
1329
1330         mf.write(prefix)
1331         mf.write(f)
1332         mf.write(":")
1333         mf.write(v)
1334
1335     global first_build
1336     first_build = True
1337
1338     def w_build(build):
1339         global first_build
1340         if first_build:
1341             mf.write("builds:\n")
1342             first_build = False
1343
1344         w_field('versionName', build.version, '  - ', 'string')
1345         w_field('versionCode', build.vercode, '    ', 'strsng')
1346         for f in build_flags_order:
1347             v = build.get_flag(f)
1348             if not v:
1349                 continue
1350
1351             w_field(f, v, '    ', flagtype(f))
1352
1353     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1354
1355
1356 def write_metadata(fmt, mf, app):
1357     if fmt == 'txt':
1358         return write_txt_metadata(mf, app)
1359     if fmt == 'yaml':
1360         return write_yaml_metadata(mf, app)
1361     raise MetaDataException("Unknown metadata format given")