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