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