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