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