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