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