chiark / gitweb /
Fix issues spotted by older pyflakes
[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 = App()
956     app.metadatapath = metadatapath
957     app.id, _ = common.get_extension(os.path.basename(metadatapath))
958
959     with open(metadatapath, 'r') as mf:
960         if ext == 'txt':
961             parse_txt_metadata(mf, app)
962         elif ext == 'json':
963             parse_json_metadata(mf, app)
964         elif ext == 'xml':
965             parse_xml_metadata(mf, app)
966         elif ext == 'yaml':
967             parse_yaml_metadata(mf, app)
968         else:
969             raise MetaDataException('Unknown metadata format: %s' % metadatapath)
970
971     post_metadata_parse(app)
972     return app
973
974
975 def parse_json_metadata(mf, app):
976
977     # fdroid metadata is only strings and booleans, no floats or ints. And
978     # json returns unicode, and fdroidserver still uses plain python strings
979     # TODO create schema using https://pypi.python.org/pypi/jsonschema
980     jsoninfo = json.load(mf, 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(mf, app):
991
992     tree = ElementTree.ElementTree(file=mf)
993     root = tree.getroot()
994
995     if root.tag != 'resources':
996         raise MetaDataException('resources file does not have root element <resources/>')
997
998     for child in root:
999         if child.tag != 'builds':
1000             # builds does not have name="" attrib
1001             name = child.attrib['name']
1002
1003         if child.tag == 'string':
1004             app.set_field(name, child.text)
1005         elif child.tag == 'string-array':
1006             for item in child:
1007                 app.append_field(name, item.text)
1008         elif child.tag == 'builds':
1009             for b in child:
1010                 build = Build()
1011                 for key in b:
1012                     build.set_flag(key.tag, key.text)
1013                 app.builds.append(build)
1014
1015     # TODO handle this using <xsd:element type="xsd:boolean> in a schema
1016     if not isinstance(app.RequiresRoot, bool):
1017         app.RequiresRoot = app.RequiresRoot == 'true'
1018
1019     return app
1020
1021
1022 def parse_yaml_metadata(mf, app):
1023
1024     yamlinfo = yaml.load(mf, Loader=YamlLoader)
1025     app.update_fields(yamlinfo)
1026     return app
1027
1028
1029 build_line_sep = re.compile(r'(?<!\\),')
1030 build_cont = re.compile(r'^[ \t]')
1031
1032
1033 def parse_txt_metadata(mf, app):
1034
1035     linedesc = None
1036
1037     def add_buildflag(p, build):
1038         if not p.strip():
1039             raise MetaDataException("Empty build flag at {1}"
1040                                     .format(buildlines[0], linedesc))
1041         bv = p.split('=', 1)
1042         if len(bv) != 2:
1043             raise MetaDataException("Invalid build flag at {0} in {1}"
1044                                     .format(buildlines[0], linedesc))
1045
1046         pk, pv = bv
1047         pk = pk.lstrip()
1048         t = flagtype(pk)
1049         if t == TYPE_LIST:
1050             pv = split_list_values(pv)
1051             build.set_flag(pk, pv)
1052         elif t == TYPE_STRING or t == TYPE_SCRIPT:
1053             build.set_flag(pk, pv)
1054         elif t == TYPE_BOOL:
1055             build.set_flag(pk, _decode_bool(pv))
1056
1057     def parse_buildline(lines):
1058         v = "".join(lines)
1059         parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
1060         if len(parts) < 3:
1061             raise MetaDataException("Invalid build format: " + v + " in " + mf.name)
1062         build = Build()
1063         build.version = parts[0]
1064         build.vercode = parts[1]
1065         if parts[2].startswith('!'):
1066             # For backwards compatibility, handle old-style disabling,
1067             # including attempting to extract the commit from the message
1068             build.disable = parts[2][1:]
1069             commit = 'unknown - see disabled'
1070             index = parts[2].rfind('at ')
1071             if index != -1:
1072                 commit = parts[2][index + 3:]
1073                 if commit.endswith(')'):
1074                     commit = commit[:-1]
1075             build.commit = commit
1076         else:
1077             build.commit = parts[2]
1078         for p in parts[3:]:
1079             add_buildflag(p, build)
1080
1081         return build
1082
1083     def add_comments(key):
1084         if not curcomments:
1085             return
1086         app.comments[key] = list(curcomments)
1087         del curcomments[:]
1088
1089     mode = 0
1090     buildlines = []
1091     multiline_lines = []
1092     curcomments = []
1093     build = None
1094     vc_seen = set()
1095
1096     c = 0
1097     for line in mf:
1098         c += 1
1099         linedesc = "%s:%d" % (mf.name, c)
1100         line = line.rstrip('\r\n')
1101         if mode == 3:
1102             if build_cont.match(line):
1103                 if line.endswith('\\'):
1104                     buildlines.append(line[:-1].lstrip())
1105                 else:
1106                     buildlines.append(line.lstrip())
1107                     bl = ''.join(buildlines)
1108                     add_buildflag(bl, build)
1109                     del buildlines[:]
1110             else:
1111                 if not build.commit and not build.disable:
1112                     raise MetaDataException("No commit specified for {0} in {1}"
1113                                             .format(build.version, linedesc))
1114
1115                 app.builds.append(build)
1116                 add_comments('build:' + build.vercode)
1117                 mode = 0
1118
1119         if mode == 0:
1120             if not line:
1121                 continue
1122             if line.startswith("#"):
1123                 curcomments.append(line[1:].strip())
1124                 continue
1125             try:
1126                 f, v = line.split(':', 1)
1127             except ValueError:
1128                 raise MetaDataException("Invalid metadata in " + linedesc)
1129
1130             # Translate obsolete fields...
1131             if f == 'Market Version':
1132                 f = 'Current Version'
1133             if f == 'Market Version Code':
1134                 f = 'Current Version Code'
1135
1136             ftype = fieldtype(f)
1137             if ftype not in [TYPE_BUILD, TYPE_BUILD_V2]:
1138                 add_comments(f)
1139             if ftype == TYPE_MULTILINE:
1140                 mode = 1
1141                 if v:
1142                     raise MetaDataException("Unexpected text on same line as " + f + " in " + linedesc)
1143             elif ftype == TYPE_STRING:
1144                 app.set_field(f, v)
1145             elif ftype == TYPE_LIST:
1146                 app.set_field(f, split_list_values(v))
1147             elif ftype == TYPE_BUILD:
1148                 if v.endswith("\\"):
1149                     mode = 2
1150                     del buildlines[:]
1151                     buildlines.append(v[:-1])
1152                 else:
1153                     build = parse_buildline([v])
1154                     app.builds.append(build)
1155                     add_comments('build:' + app.builds[-1].vercode)
1156             elif ftype == TYPE_BUILD_V2:
1157                 vv = v.split(',')
1158                 if len(vv) != 2:
1159                     raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
1160                                             .format(v, linedesc))
1161                 build = Build()
1162                 build.version = vv[0]
1163                 build.vercode = vv[1]
1164                 if build.vercode in vc_seen:
1165                     raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
1166                                             build.vercode, linedesc))
1167                 vc_seen.add(build.vercode)
1168                 del buildlines[:]
1169                 mode = 3
1170             elif ftype == TYPE_OBSOLETE:
1171                 pass        # Just throw it away!
1172             else:
1173                 raise MetaDataException("Unrecognised field '" + f + "' in " + linedesc)
1174         elif mode == 1:     # Multiline field
1175             if line == '.':
1176                 mode = 0
1177                 app.set_field(f, '\n'.join(multiline_lines))
1178                 del multiline_lines[:]
1179             else:
1180                 multiline_lines.append(line)
1181         elif mode == 2:     # Line continuation mode in Build Version
1182             if line.endswith("\\"):
1183                 buildlines.append(line[:-1])
1184             else:
1185                 buildlines.append(line)
1186                 build = parse_buildline(buildlines)
1187                 app.builds.append(build)
1188                 add_comments('build:' + app.builds[-1].vercode)
1189                 mode = 0
1190     add_comments(None)
1191
1192     # Mode at end of file should always be 0
1193     if mode == 1:
1194         raise MetaDataException(f + " not terminated in " + mf.name)
1195     if mode == 2:
1196         raise MetaDataException("Unterminated continuation in " + mf.name)
1197     if mode == 3:
1198         raise MetaDataException("Unterminated build in " + mf.name)
1199
1200     return app
1201
1202
1203 def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
1204
1205     def w_comments(key):
1206         if key not in app.comments:
1207             return
1208         for line in app.comments[key]:
1209             w_comment(line)
1210
1211     def w_field_always(f, v=None):
1212         if v is None:
1213             v = app.get_field(f)
1214         w_comments(f)
1215         w_field(f, v)
1216
1217     def w_field_nonempty(f, v=None):
1218         if v is None:
1219             v = app.get_field(f)
1220         w_comments(f)
1221         if v:
1222             w_field(f, v)
1223
1224     w_field_nonempty('Disabled')
1225     if app.AntiFeatures:
1226         w_field_always('AntiFeatures')
1227     w_field_nonempty('Provides')
1228     w_field_always('Categories')
1229     w_field_always('License')
1230     w_field_always('Web Site')
1231     w_field_always('Source Code')
1232     w_field_always('Issue Tracker')
1233     w_field_nonempty('Changelog')
1234     w_field_nonempty('Donate')
1235     w_field_nonempty('FlattrID')
1236     w_field_nonempty('Bitcoin')
1237     w_field_nonempty('Litecoin')
1238     mf.write('\n')
1239     w_field_nonempty('Name')
1240     w_field_nonempty('Auto Name')
1241     w_field_always('Summary')
1242     w_field_always('Description', description_txt(app.Description))
1243     mf.write('\n')
1244     if app.RequiresRoot:
1245         w_field_always('Requires Root', 'yes')
1246         mf.write('\n')
1247     if app.RepoType:
1248         w_field_always('Repo Type')
1249         w_field_always('Repo')
1250         if app.Binaries:
1251             w_field_always('Binaries')
1252         mf.write('\n')
1253
1254     for build in sorted_builds(app.builds):
1255
1256         if build.version == "Ignore":
1257             continue
1258
1259         w_comments('build:' + build.vercode)
1260         w_build(build)
1261         mf.write('\n')
1262
1263     if app.MaintainerNotes:
1264         w_field_always('Maintainer Notes', app.MaintainerNotes)
1265         mf.write('\n')
1266
1267     w_field_nonempty('Archive Policy')
1268     w_field_always('Auto Update Mode')
1269     w_field_always('Update Check Mode')
1270     w_field_nonempty('Update Check Ignore')
1271     w_field_nonempty('Vercode Operation')
1272     w_field_nonempty('Update Check Name')
1273     w_field_nonempty('Update Check Data')
1274     if app.CurrentVersion:
1275         w_field_always('Current Version')
1276         w_field_always('Current Version Code')
1277     if app.NoSourceSince:
1278         mf.write('\n')
1279         w_field_always('No Source Since')
1280     w_comments(None)
1281
1282
1283 # Write a metadata file in txt format.
1284 #
1285 # 'mf'      - Writer interface (file, StringIO, ...)
1286 # 'app'     - The app data
1287 def write_txt_metadata(mf, app):
1288
1289     def w_comment(line):
1290         mf.write("# %s\n" % line)
1291
1292     def w_field(f, v):
1293         t = fieldtype(f)
1294         if t == TYPE_LIST:
1295             v = ','.join(v)
1296         elif t == TYPE_MULTILINE:
1297             v = '\n' + v + '\n.'
1298         mf.write("%s:%s\n" % (f, v))
1299
1300     def w_build(build):
1301         mf.write("Build:%s,%s\n" % (build.version, build.vercode))
1302
1303         for f in build_flags_order:
1304             v = build.get_flag(f)
1305             if not v:
1306                 continue
1307
1308             t = flagtype(f)
1309             mf.write('    %s=' % f)
1310             if t == TYPE_STRING:
1311                 mf.write(v)
1312             elif t == TYPE_BOOL:
1313                 mf.write('yes')
1314             elif t == TYPE_SCRIPT:
1315                 first = True
1316                 for s in v.split(' && '):
1317                     if first:
1318                         first = False
1319                     else:
1320                         mf.write(' && \\\n        ')
1321                     mf.write(s)
1322             elif t == TYPE_LIST:
1323                 mf.write(','.join(v))
1324
1325             mf.write('\n')
1326
1327     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1328
1329
1330 def write_yaml_metadata(mf, app):
1331
1332     def w_comment(line):
1333         mf.write("# %s\n" % line)
1334
1335     def escape(v):
1336         if not v:
1337             return ''
1338         if any(c in v for c in [': ', '%', '@', '*']):
1339             return "'" + v.replace("'", "''") + "'"
1340         return v
1341
1342     def w_field(f, v, prefix='', t=None):
1343         if t is None:
1344             t = fieldtype(f)
1345         v = ''
1346         if t == TYPE_LIST:
1347             v = '\n'
1348             for e in v:
1349                 v += prefix + ' - ' + escape(e) + '\n'
1350         elif t == TYPE_MULTILINE:
1351             v = ' |\n'
1352             for l in v.splitlines():
1353                 if l:
1354                     v += prefix + '  ' + l + '\n'
1355                 else:
1356                     v += '\n'
1357         elif t == TYPE_BOOL:
1358             v = ' yes\n'
1359         elif t == TYPE_SCRIPT:
1360             cmds = [s + '&& \\' for s in v.split('&& ')]
1361             if len(cmds) > 0:
1362                 cmds[-1] = cmds[-1][:-len('&& \\')]
1363             w_field(f, cmds, prefix, 'multiline')
1364             return
1365         else:
1366             v = ' ' + escape(v) + '\n'
1367
1368         mf.write(prefix)
1369         mf.write(f)
1370         mf.write(":")
1371         mf.write(v)
1372
1373     global first_build
1374     first_build = True
1375
1376     def w_build(build):
1377         global first_build
1378         if first_build:
1379             mf.write("builds:\n")
1380             first_build = False
1381
1382         w_field('versionName', build.version, '  - ', TYPE_STRING)
1383         w_field('versionCode', build.vercode, '    ', TYPE_STRING)
1384         for f in build_flags_order:
1385             v = build.get_flag(f)
1386             if not v:
1387                 continue
1388
1389             w_field(f, v, '    ', flagtype(f))
1390
1391     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1392
1393
1394 def write_metadata(fmt, mf, app):
1395     if fmt == 'txt':
1396         return write_txt_metadata(mf, app)
1397     if fmt == 'yaml':
1398         return write_yaml_metadata(mf, app)
1399     raise MetaDataException("Unknown metadata format given")