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