chiark / gitweb /
Remove gradle=main
[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         if pk not in build_flags:
1004             raise MetaDataException("Unrecognised build flag at {0} in {1}"
1005                                     .format(p, linedesc))
1006         t = flagtype(pk)
1007         if t == 'list':
1008             pv = split_list_values(pv)
1009             build.set_flag(pk, pv)
1010         elif t == 'string' or t == 'script':
1011             build.set_flag(pk, pv)
1012         elif t == 'bool':
1013             if pv == 'yes':
1014                 build.set_flag(pk, True)
1015
1016         else:
1017             raise MetaDataException("Unrecognised build flag type '%s' at %s in %s"
1018                                     % (t, p, linedesc))
1019
1020     def parse_buildline(lines):
1021         v = "".join(lines)
1022         parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
1023         if len(parts) < 3:
1024             raise MetaDataException("Invalid build format: " + v + " in " + metafile.name)
1025         build = Build()
1026         build.origlines = lines
1027         build.version = parts[0]
1028         build.vercode = parts[1]
1029         if parts[2].startswith('!'):
1030             # For backwards compatibility, handle old-style disabling,
1031             # including attempting to extract the commit from the message
1032             build.disable = parts[2][1:]
1033             commit = 'unknown - see disabled'
1034             index = parts[2].rfind('at ')
1035             if index != -1:
1036                 commit = parts[2][index + 3:]
1037                 if commit.endswith(')'):
1038                     commit = commit[:-1]
1039             build.commit = commit
1040         else:
1041             build.commit = parts[2]
1042         for p in parts[3:]:
1043             add_buildflag(p, build)
1044
1045         return build
1046
1047     def add_comments(key):
1048         if not curcomments:
1049             return
1050         app.comments[key] = list(curcomments)
1051         del curcomments[:]
1052
1053     app = get_default_app_info(metadatapath)
1054     metafile = open(metadatapath, "r")
1055
1056     mode = 0
1057     buildlines = []
1058     curcomments = []
1059     build = None
1060     vc_seen = {}
1061
1062     c = 0
1063     for line in metafile:
1064         c += 1
1065         linedesc = "%s:%d" % (metafile.name, c)
1066         line = line.rstrip('\r\n')
1067         if mode == 3:
1068             if build_cont.match(line):
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             else:
1077                 if not build.commit and not build.disable:
1078                     raise MetaDataException("No commit specified for {0} in {1}"
1079                                             .format(build.version, linedesc))
1080
1081                 app.builds.append(build)
1082                 add_comments('build:' + build.vercode)
1083                 mode = 0
1084
1085         if mode == 0:
1086             if not line:
1087                 continue
1088             if line.startswith("#"):
1089                 curcomments.append(line[1:].strip())
1090                 continue
1091             try:
1092                 f, v = line.split(':', 1)
1093             except ValueError:
1094                 raise MetaDataException("Invalid metadata in " + linedesc)
1095             if f != f.strip() or v != v.strip():
1096                 raise MetaDataException("Extra spacing found in " + linedesc)
1097
1098             # Translate obsolete fields...
1099             if f == 'Market Version':
1100                 f = 'Current Version'
1101             if f == 'Market Version Code':
1102                 f = 'Current Version Code'
1103
1104             fieldtype = metafieldtype(f)
1105             if fieldtype not in ['build', 'buildv2']:
1106                 add_comments(f)
1107             if fieldtype == 'multiline':
1108                 mode = 1
1109                 if v:
1110                     raise MetaDataException("Unexpected text on same line as " + f + " in " + linedesc)
1111             elif fieldtype == 'string':
1112                 app.set_field(f, v)
1113             elif fieldtype == 'list':
1114                 app.set_field(f, split_list_values(v))
1115             elif fieldtype == 'build':
1116                 if v.endswith("\\"):
1117                     mode = 2
1118                     buildlines = [v[:-1]]
1119                 else:
1120                     build = parse_buildline([v])
1121                     app.builds.append(build)
1122                     add_comments('build:' + app.builds[-1].vercode)
1123             elif fieldtype == 'buildv2':
1124                 build = Build()
1125                 vv = v.split(',')
1126                 if len(vv) != 2:
1127                     raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
1128                                             .format(v, linedesc))
1129                 build.version = vv[0]
1130                 build.vercode = vv[1]
1131                 if build.vercode in vc_seen:
1132                     raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
1133                                             build.vercode, linedesc))
1134                 vc_seen[build.vercode] = True
1135                 buildlines = []
1136                 mode = 3
1137             elif fieldtype == 'obsolete':
1138                 pass        # Just throw it away!
1139             else:
1140                 raise MetaDataException("Unrecognised field type for " + f + " in " + linedesc)
1141         elif mode == 1:     # Multiline field
1142             if line == '.':
1143                 mode = 0
1144             else:
1145                 app.append_field(f, line)
1146         elif mode == 2:     # Line continuation mode in Build Version
1147             if line.endswith("\\"):
1148                 buildlines.append(line[:-1])
1149             else:
1150                 buildlines.append(line)
1151                 build = parse_buildline(buildlines)
1152                 app.builds.append(build)
1153                 add_comments('build:' + app.builds[-1].vercode)
1154                 mode = 0
1155     add_comments(None)
1156     metafile.close()
1157
1158     # Mode at end of file should always be 0
1159     if mode == 1:
1160         raise MetaDataException(f + " not terminated in " + metafile.name)
1161     if mode == 2:
1162         raise MetaDataException("Unterminated continuation in " + metafile.name)
1163     if mode == 3:
1164         raise MetaDataException("Unterminated build in " + metafile.name)
1165
1166     return app
1167
1168
1169 def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
1170
1171     def w_comments(key):
1172         if key not in app.comments:
1173             return
1174         for line in app.comments[key]:
1175             w_comment(line)
1176
1177     def w_field_always(f, v=None):
1178         if v is None:
1179             v = app.get_field(f)
1180         w_comments(f)
1181         w_field(f, v)
1182
1183     def w_field_nonempty(f, v=None):
1184         if v is None:
1185             v = app.get_field(f)
1186         w_comments(f)
1187         if v:
1188             w_field(f, v)
1189
1190     w_field_nonempty('Disabled')
1191     if app.AntiFeatures:
1192         w_field_always('AntiFeatures')
1193     w_field_nonempty('Provides')
1194     w_field_always('Categories')
1195     w_field_always('License')
1196     w_field_always('Web Site')
1197     w_field_always('Source Code')
1198     w_field_always('Issue Tracker')
1199     w_field_nonempty('Changelog')
1200     w_field_nonempty('Donate')
1201     w_field_nonempty('FlattrID')
1202     w_field_nonempty('Bitcoin')
1203     w_field_nonempty('Litecoin')
1204     mf.write('\n')
1205     w_field_nonempty('Name')
1206     w_field_nonempty('Auto Name')
1207     w_field_always('Summary')
1208     w_field_always('Description', description_txt(app.Description))
1209     mf.write('\n')
1210     if app.RequiresRoot:
1211         w_field_always('Requires Root', 'yes')
1212         mf.write('\n')
1213     if app.RepoType:
1214         w_field_always('Repo Type')
1215         w_field_always('Repo')
1216         if app.Binaries:
1217             w_field_always('Binaries')
1218         mf.write('\n')
1219
1220     for build in sorted_builds(app.builds):
1221
1222         if build.version == "Ignore":
1223             continue
1224
1225         w_comments('build:' + build.vercode)
1226         w_build(build)
1227         mf.write('\n')
1228
1229     if app.MaintainerNotes:
1230         w_field_always('Maintainer Notes', app.MaintainerNotes)
1231         mf.write('\n')
1232
1233     w_field_nonempty('Archive Policy')
1234     w_field_always('Auto Update Mode')
1235     w_field_always('Update Check Mode')
1236     w_field_nonempty('Update Check Ignore')
1237     w_field_nonempty('Vercode Operation')
1238     w_field_nonempty('Update Check Name')
1239     w_field_nonempty('Update Check Data')
1240     if app.CurrentVersion:
1241         w_field_always('Current Version')
1242         w_field_always('Current Version Code')
1243     if app.NoSourceSince:
1244         mf.write('\n')
1245         w_field_always('No Source Since')
1246     w_comments(None)
1247
1248
1249 # Write a metadata file in txt format.
1250 #
1251 # 'mf'      - Writer interface (file, StringIO, ...)
1252 # 'app'     - The app data
1253 def write_txt_metadata(mf, app):
1254
1255     def w_comment(line):
1256         mf.write("# %s\n" % line)
1257
1258     def w_field(f, v):
1259         t = metafieldtype(f)
1260         if t == 'list':
1261             v = ','.join(v)
1262         elif t == 'multiline':
1263             if type(v) == list:
1264                 v = '\n' + '\n'.join(v) + '\n.'
1265             else:
1266                 v = '\n' + v + '\n.'
1267         mf.write("%s:%s\n" % (f, v))
1268
1269     def w_build(build):
1270         mf.write("Build:%s,%s\n" % (build.version, build.vercode))
1271
1272         for f in build_flags_order:
1273             v = build.get_flag(f)
1274             if not v:
1275                 continue
1276
1277             t = flagtype(f)
1278             out = '    %s=' % f
1279             if t == 'string':
1280                 out += v
1281             elif t == 'bool':
1282                 out += 'yes'
1283             elif t == 'script':
1284                 out += '&& \\\n        '.join([s.lstrip() for s in v.split('&& ')])
1285             elif t == 'list':
1286                 out += ','.join(v) if type(v) == list else v
1287
1288             mf.write(out)
1289             mf.write('\n')
1290
1291     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1292
1293
1294 def write_yaml_metadata(mf, app):
1295
1296     def w_comment(line):
1297         mf.write("# %s\n" % line)
1298
1299     def escape(v):
1300         if not v:
1301             return ''
1302         if any(c in v for c in [': ', '%', '@', '*']):
1303             return "'" + v.replace("'", "''") + "'"
1304         return v
1305
1306     def w_field(f, v, prefix='', t=None):
1307         if t is None:
1308             t = metafieldtype(f)
1309         v = ''
1310         if t == 'list':
1311             v = '\n'
1312             for e in v:
1313                 v += prefix + ' - ' + escape(e) + '\n'
1314         elif t == 'multiline':
1315             v = ' |\n'
1316             lines = v
1317             if type(v) == str:
1318                 lines = v.splitlines()
1319             for l in lines:
1320                 if l:
1321                     v += prefix + '  ' + l + '\n'
1322                 else:
1323                     v += '\n'
1324         elif t == 'bool':
1325             v = ' yes\n'
1326         elif t == 'script':
1327             cmds = [s + '&& \\' for s in v.split('&& ')]
1328             if len(cmds) > 0:
1329                 cmds[-1] = cmds[-1][:-len('&& \\')]
1330             w_field(f, cmds, prefix, 'multiline')
1331             return
1332         else:
1333             v = ' ' + escape(v) + '\n'
1334
1335         mf.write(prefix)
1336         mf.write(f)
1337         mf.write(":")
1338         mf.write(v)
1339
1340     global first_build
1341     first_build = True
1342
1343     def w_build(build):
1344         global first_build
1345         if first_build:
1346             mf.write("builds:\n")
1347             first_build = False
1348
1349         w_field('versionName', build.version, '  - ', 'string')
1350         w_field('versionCode', build.vercode, '    ', 'strsng')
1351         for f in build_flags_order:
1352             v = build.get_flag(f)
1353             if not v:
1354                 continue
1355
1356             w_field(f, v, '    ', flagtype(f))
1357
1358     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1359
1360
1361 def write_metadata(fmt, mf, app):
1362     if fmt == 'txt':
1363         return write_txt_metadata(mf, app)
1364     if fmt == 'yaml':
1365         return write_yaml_metadata(mf, app)
1366     raise MetaDataException("Unknown metadata format given")