chiark / gitweb /
Keep track of what attrs were modified in apps/builds
[fdroidserver.git] / fdroidserver / metadata.py
1 # -*- coding: utf-8 -*-
2 #
3 # metadata.py - part of the FDroid server tools
4 # Copyright (C) 2013, Ciaran Gultnieks, ciaran@ciarang.com
5 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU Affero General Public License for more details.
16 #
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 import json
21 import os
22 import re
23 import 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                    ["Web Site", "Source Code", "Issue Tracker", "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                    ["Repo Type"],
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                    ["Archive Policy"],
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                    ["Auto Update Mode"],
475                    []),
476
477     FieldValidator("Update Check Mode",
478                    r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$", None,
479                    ["Update Check Mode"],
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 f in v.fields:
488             v.check(app.get_field(f), app.id)
489         for build in app.builds:
490             for f in v.flags:
491                 v.check(build.get_flag(f), app.id)
492
493
494 # Formatter for descriptions. Create an instance, and call parseline() with
495 # each line of the description source from the metadata. At the end, call
496 # end() and then text_txt and text_html will contain the result.
497 class DescriptionFormatter:
498
499     stNONE = 0
500     stPARA = 1
501     stUL = 2
502     stOL = 3
503
504     def __init__(self, linkres):
505         self.bold = False
506         self.ital = False
507         self.state = self.stNONE
508         self.laststate = self.stNONE
509         self.text_html = ''
510         self.text_txt = ''
511         self.html = StringIO()
512         self.text = StringIO()
513         self.para_lines = []
514         self.linkResolver = None
515         self.linkResolver = linkres
516
517     def endcur(self, notstates=None):
518         if notstates and self.state in notstates:
519             return
520         if self.state == self.stPARA:
521             self.endpara()
522         elif self.state == self.stUL:
523             self.endul()
524         elif self.state == self.stOL:
525             self.endol()
526
527     def endpara(self):
528         self.laststate = self.state
529         self.state = self.stNONE
530         whole_para = ' '.join(self.para_lines)
531         self.addtext(whole_para)
532         self.text.write(textwrap.fill(whole_para, 80,
533                                       break_long_words=False,
534                                       break_on_hyphens=False))
535         self.html.write('</p>')
536         del self.para_lines[:]
537
538     def endul(self):
539         self.html.write('</ul>')
540         self.laststate = self.state
541         self.state = self.stNONE
542
543     def endol(self):
544         self.html.write('</ol>')
545         self.laststate = self.state
546         self.state = self.stNONE
547
548     def formatted(self, txt, html):
549         res = ''
550         if html:
551             txt = cgi.escape(txt)
552         while True:
553             index = txt.find("''")
554             if index == -1:
555                 return res + txt
556             res += txt[:index]
557             txt = txt[index:]
558             if txt.startswith("'''"):
559                 if html:
560                     if self.bold:
561                         res += '</b>'
562                     else:
563                         res += '<b>'
564                 self.bold = not self.bold
565                 txt = txt[3:]
566             else:
567                 if html:
568                     if self.ital:
569                         res += '</i>'
570                     else:
571                         res += '<i>'
572                 self.ital = not self.ital
573                 txt = txt[2:]
574
575     def linkify(self, txt):
576         res_plain = ''
577         res_html = ''
578         while True:
579             index = txt.find("[")
580             if index == -1:
581                 return (res_plain + self.formatted(txt, False), res_html + self.formatted(txt, True))
582             res_plain += self.formatted(txt[:index], False)
583             res_html += self.formatted(txt[:index], True)
584             txt = txt[index:]
585             if txt.startswith("[["):
586                 index = txt.find("]]")
587                 if index == -1:
588                     raise MetaDataException("Unterminated ]]")
589                 url = txt[2:index]
590                 if self.linkResolver:
591                     url, urltext = self.linkResolver(url)
592                 else:
593                     urltext = url
594                 res_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
595                 res_plain += urltext
596                 txt = txt[index + 2:]
597             else:
598                 index = txt.find("]")
599                 if index == -1:
600                     raise MetaDataException("Unterminated ]")
601                 url = txt[1:index]
602                 index2 = url.find(' ')
603                 if index2 == -1:
604                     urltxt = url
605                 else:
606                     urltxt = url[index2 + 1:]
607                     url = url[:index2]
608                     if url == urltxt:
609                         raise MetaDataException("Url title is just the URL - use [url]")
610                 res_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
611                 res_plain += urltxt
612                 if urltxt != url:
613                     res_plain += ' (' + url + ')'
614                 txt = txt[index + 1:]
615
616     def addtext(self, txt):
617         p, h = self.linkify(txt)
618         self.html.write(h)
619
620     def parseline(self, line):
621         if not line:
622             self.endcur()
623         elif line.startswith('* '):
624             self.endcur([self.stUL])
625             if self.state != self.stUL:
626                 self.html.write('<ul>')
627                 self.state = self.stUL
628                 if self.laststate != self.stNONE:
629                     self.text.write('\n\n')
630             else:
631                 self.text.write('\n')
632             self.text.write(line)
633             self.html.write('<li>')
634             self.addtext(line[1:])
635             self.html.write('</li>')
636         elif line.startswith('# '):
637             self.endcur([self.stOL])
638             if self.state != self.stOL:
639                 self.html.write('<ol>')
640                 self.state = self.stOL
641                 if self.laststate != self.stNONE:
642                     self.text.write('\n\n')
643             else:
644                 self.text.write('\n')
645             self.text.write(line)
646             self.html.write('<li>')
647             self.addtext(line[1:])
648             self.html.write('</li>')
649         else:
650             self.para_lines.append(line)
651             self.endcur([self.stPARA])
652             if self.state == self.stNONE:
653                 self.state = self.stPARA
654                 if self.laststate != self.stNONE:
655                     self.text.write('\n\n')
656                 self.html.write('<p>')
657
658     def end(self):
659         self.endcur()
660         self.text_txt = self.text.getvalue()
661         self.text_html = self.html.getvalue()
662         self.text.close()
663         self.html.close()
664
665
666 # Parse multiple lines of description as written in a metadata file, returning
667 # a single string in text format and wrapped to 80 columns.
668 def description_txt(s):
669     ps = DescriptionFormatter(None)
670     for line in s.splitlines():
671         ps.parseline(line)
672     ps.end()
673     return ps.text_txt
674
675
676 # Parse multiple lines of description as written in a metadata file, returning
677 # a single string in wiki format. Used for the Maintainer Notes field as well,
678 # because it's the same format.
679 def description_wiki(s):
680     return s
681
682
683 # Parse multiple lines of description as written in a metadata file, returning
684 # a single string in HTML format.
685 def description_html(s, linkres):
686     ps = DescriptionFormatter(linkres)
687     for line in s.splitlines():
688         ps.parseline(line)
689     ps.end()
690     return ps.text_html
691
692
693 def parse_srclib(metadatapath):
694
695     thisinfo = {}
696
697     # Defaults for fields that come from metadata
698     thisinfo['Repo Type'] = ''
699     thisinfo['Repo'] = ''
700     thisinfo['Subdir'] = None
701     thisinfo['Prepare'] = None
702
703     if not os.path.exists(metadatapath):
704         return thisinfo
705
706     metafile = open(metadatapath, "r")
707
708     n = 0
709     for line in metafile:
710         n += 1
711         line = line.rstrip('\r\n')
712         if not line or line.startswith("#"):
713             continue
714
715         try:
716             f, v = line.split(':', 1)
717         except ValueError:
718             raise MetaDataException("Invalid metadata in %s:%d" % (line, n))
719
720         if f == "Subdir":
721             thisinfo[f] = v.split(',')
722         else:
723             thisinfo[f] = v
724
725     metafile.close()
726
727     return thisinfo
728
729
730 def read_srclibs():
731     """Read all srclib metadata.
732
733     The information read will be accessible as metadata.srclibs, which is a
734     dictionary, keyed on srclib name, with the values each being a dictionary
735     in the same format as that returned by the parse_srclib function.
736
737     A MetaDataException is raised if there are any problems with the srclib
738     metadata.
739     """
740     global srclibs
741
742     # They were already loaded
743     if srclibs is not None:
744         return
745
746     srclibs = {}
747
748     srcdir = 'srclibs'
749     if not os.path.exists(srcdir):
750         os.makedirs(srcdir)
751
752     for metadatapath in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
753         srclibname = os.path.basename(metadatapath[:-4])
754         srclibs[srclibname] = parse_srclib(metadatapath)
755
756
757 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
758 # returned by the parse_txt_metadata function.
759 def read_metadata(xref=True):
760
761     # Always read the srclibs before the apps, since they can use a srlib as
762     # their source repository.
763     read_srclibs()
764
765     apps = {}
766
767     for basedir in ('metadata', 'tmp'):
768         if not os.path.exists(basedir):
769             os.makedirs(basedir)
770
771     # If there are multiple metadata files for a single appid, then the first
772     # file that is parsed wins over all the others, and the rest throw an
773     # exception. So the original .txt format is parsed first, at least until
774     # newer formats stabilize.
775
776     for metadatapath in sorted(glob.glob(os.path.join('metadata', '*.txt'))
777                                + glob.glob(os.path.join('metadata', '*.json'))
778                                + glob.glob(os.path.join('metadata', '*.xml'))
779                                + glob.glob(os.path.join('metadata', '*.yaml'))):
780         app = parse_metadata(metadatapath)
781         if app.id in apps:
782             raise MetaDataException("Found multiple metadata files for " + app.id)
783         check_metadata(app)
784         apps[app.id] = app
785
786     if xref:
787         # Parse all descriptions at load time, just to ensure cross-referencing
788         # errors are caught early rather than when they hit the build server.
789         def linkres(appid):
790             if appid in apps:
791                 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
792             raise MetaDataException("Cannot resolve app id " + appid)
793
794         for appid, app in apps.iteritems():
795             try:
796                 description_html(app.Description, linkres)
797             except MetaDataException, e:
798                 raise MetaDataException("Problem with description of " + appid +
799                                         " - " + str(e))
800
801     return apps
802
803 # Port legacy ';' separators
804 list_sep = re.compile(r'[,;]')
805
806
807 def split_list_values(s):
808     res = []
809     for v in re.split(list_sep, s):
810         if not v:
811             continue
812         v = v.strip()
813         if not v:
814             continue
815         res.append(v)
816     return res
817
818
819 def get_default_app_info(metadatapath=None):
820     if metadatapath is None:
821         appid = None
822     else:
823         appid, _ = common.get_extension(os.path.basename(metadatapath))
824
825     app = App()
826     app.metadatapath = metadatapath
827     if appid is not None:
828         app.id = appid
829
830     return app
831
832
833 def sorted_builds(builds):
834     return sorted(builds, key=lambda build: int(build.vercode))
835
836
837 esc_newlines = re.compile(r'\\( |\n)')
838
839
840 # This function uses __dict__ to be faster
841 def post_metadata_parse(app):
842
843     for k, v in app.__dict__.iteritems():
844         if k not in app._modified:
845             continue
846         if type(v) in (float, int):
847             app.__dict__[k] = str(v)
848
849     for build in app.builds:
850         for k, v in build.__dict__.iteritems():
851
852             if k not in build._modified:
853                 continue
854             if type(v) in (float, int):
855                 build.__dict__[k] = str(v)
856                 continue
857             ftype = flagtype(k)
858
859             if ftype == TYPE_SCRIPT:
860                 build.__dict__[k] = re.sub(esc_newlines, '', v).lstrip().rstrip()
861             elif ftype == TYPE_BOOL:
862                 # TODO handle this using <xsd:element type="xsd:boolean> in a schema
863                 if isinstance(v, basestring):
864                     build.__dict__[k] = _decode_bool(v)
865             elif ftype == TYPE_STRING:
866                 if isinstance(v, bool) and v:
867                     build.__dict__[k] = 'yes'
868
869     if not app.Description:
870         app.Description = 'No description available'
871
872     app.builds = sorted_builds(app.builds)
873
874
875 # Parse metadata for a single application.
876 #
877 #  'metadatapath' - the filename to read. The package id for the application comes
878 #               from this filename. Pass None to get a blank entry.
879 #
880 # Returns a dictionary containing all the details of the application. There are
881 # two major kinds of information in the dictionary. Keys beginning with capital
882 # letters correspond directory to identically named keys in the metadata file.
883 # Keys beginning with lower case letters are generated in one way or another,
884 # and are not found verbatim in the metadata.
885 #
886 # Known keys not originating from the metadata are:
887 #
888 #  'builds'           - a list of dictionaries containing build information
889 #                       for each defined build
890 #  'comments'         - a list of comments from the metadata file. Each is
891 #                       a list of the form [field, comment] where field is
892 #                       the name of the field it preceded in the metadata
893 #                       file. Where field is None, the comment goes at the
894 #                       end of the file. Alternatively, 'build:version' is
895 #                       for a comment before a particular build version.
896 #  'descriptionlines' - original lines of description as formatted in the
897 #                       metadata file.
898 #
899
900
901 def _decode_list(data):
902     '''convert items in a list from unicode to basestring'''
903     rv = []
904     for item in data:
905         if isinstance(item, unicode):
906             item = item.encode('utf-8')
907         elif isinstance(item, list):
908             item = _decode_list(item)
909         elif isinstance(item, dict):
910             item = _decode_dict(item)
911         rv.append(item)
912     return rv
913
914
915 def _decode_dict(data):
916     '''convert items in a dict from unicode to basestring'''
917     rv = {}
918     for k, v in data.iteritems():
919         if isinstance(k, unicode):
920             k = k.encode('utf-8')
921         if isinstance(v, unicode):
922             v = v.encode('utf-8')
923         elif isinstance(v, list):
924             v = _decode_list(v)
925         elif isinstance(v, dict):
926             v = _decode_dict(v)
927         rv[k] = v
928     return rv
929
930
931 bool_true = re.compile(r'([Yy]es|[Tt]rue)')
932 bool_false = re.compile(r'([Nn]o|[Ff]alse)')
933
934
935 def _decode_bool(s):
936     if bool_true.match(s):
937         return True
938     if bool_false.match(s):
939         return False
940     raise MetaDataException("Invalid bool '%s'" % s)
941
942
943 def parse_metadata(metadatapath):
944     _, ext = common.get_extension(metadatapath)
945     accepted = common.config['accepted_formats']
946     if ext not in accepted:
947         raise MetaDataException('"%s" is not an accepted format, convert to: %s' % (
948             metadatapath, ', '.join(accepted)))
949
950     app = None
951     if ext == 'txt':
952         app = parse_txt_metadata(metadatapath)
953     elif ext == 'json':
954         app = parse_json_metadata(metadatapath)
955     elif ext == 'xml':
956         app = parse_xml_metadata(metadatapath)
957     elif ext == 'yaml':
958         app = parse_yaml_metadata(metadatapath)
959     else:
960         raise MetaDataException('Unknown metadata format: %s' % metadatapath)
961
962     post_metadata_parse(app)
963     return app
964
965
966 def parse_json_metadata(metadatapath):
967
968     app = get_default_app_info(metadatapath)
969
970     # fdroid metadata is only strings and booleans, no floats or ints. And
971     # json returns unicode, and fdroidserver still uses plain python strings
972     # TODO create schema using https://pypi.python.org/pypi/jsonschema
973     jsoninfo = None
974     with open(metadatapath, 'r') as f:
975         jsoninfo = json.load(f, object_hook=_decode_dict,
976                              parse_int=lambda s: s,
977                              parse_float=lambda s: s)
978     app.update_fields(jsoninfo)
979     for f in ['Description', 'Maintainer Notes']:
980         v = app.get_field(f)
981         app.set_field(f, '\n'.join(v))
982     return app
983
984
985 def parse_xml_metadata(metadatapath):
986
987     app = get_default_app_info(metadatapath)
988
989     tree = ElementTree.ElementTree(file=metadatapath)
990     root = tree.getroot()
991
992     if root.tag != 'resources':
993         raise MetaDataException('%s does not have root as <resources></resources>!' % metadatapath)
994
995     for child in root:
996         if child.tag != 'builds':
997             # builds does not have name="" attrib
998             name = child.attrib['name']
999
1000         if child.tag == 'string':
1001             app.set_field(name, child.text)
1002         elif child.tag == 'string-array':
1003             for item in child:
1004                 app.append_field(name, item.text)
1005         elif child.tag == 'builds':
1006             for b in child:
1007                 build = Build()
1008                 for key in b:
1009                     build.set_flag(key.tag, key.text)
1010                 app.builds.append(build)
1011
1012     # TODO handle this using <xsd:element type="xsd:boolean> in a schema
1013     if not isinstance(app.RequiresRoot, bool):
1014         app.RequiresRoot = app.RequiresRoot == 'true'
1015
1016     return app
1017
1018
1019 def parse_yaml_metadata(metadatapath):
1020
1021     app = get_default_app_info(metadatapath)
1022
1023     yamlinfo = None
1024     with open(metadatapath, 'r') as f:
1025         yamlinfo = yaml.load(f, Loader=YamlLoader)
1026     app.update_fields(yamlinfo)
1027     return app
1028
1029
1030 build_line_sep = re.compile(r'(?<!\\),')
1031 build_cont = re.compile(r'^[ \t]')
1032
1033
1034 def parse_txt_metadata(metadatapath):
1035
1036     linedesc = None
1037
1038     def add_buildflag(p, build):
1039         if not p.strip():
1040             raise MetaDataException("Empty build flag at {1}"
1041                                     .format(buildlines[0], linedesc))
1042         bv = p.split('=', 1)
1043         if len(bv) != 2:
1044             raise MetaDataException("Invalid build flag at {0} in {1}"
1045                                     .format(buildlines[0], linedesc))
1046
1047         pk, pv = bv
1048         pk = pk.lstrip()
1049         t = flagtype(pk)
1050         if t == TYPE_LIST:
1051             pv = split_list_values(pv)
1052             build.set_flag(pk, pv)
1053         elif t == TYPE_STRING or t == TYPE_SCRIPT:
1054             build.set_flag(pk, pv)
1055         elif t == TYPE_BOOL:
1056             build.set_flag(pk, _decode_bool(pv))
1057
1058     def parse_buildline(lines):
1059         v = "".join(lines)
1060         parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
1061         if len(parts) < 3:
1062             raise MetaDataException("Invalid build format: " + v + " in " + metafile.name)
1063         build = Build()
1064         build.origlines = lines
1065         build.version = parts[0]
1066         build.vercode = parts[1]
1067         if parts[2].startswith('!'):
1068             # For backwards compatibility, handle old-style disabling,
1069             # including attempting to extract the commit from the message
1070             build.disable = parts[2][1:]
1071             commit = 'unknown - see disabled'
1072             index = parts[2].rfind('at ')
1073             if index != -1:
1074                 commit = parts[2][index + 3:]
1075                 if commit.endswith(')'):
1076                     commit = commit[:-1]
1077             build.commit = commit
1078         else:
1079             build.commit = parts[2]
1080         for p in parts[3:]:
1081             add_buildflag(p, build)
1082
1083         return build
1084
1085     def add_comments(key):
1086         if not curcomments:
1087             return
1088         app.comments[key] = list(curcomments)
1089         del curcomments[:]
1090
1091     app = get_default_app_info(metadatapath)
1092     metafile = open(metadatapath, "r")
1093
1094     mode = 0
1095     buildlines = []
1096     multiline_lines = []
1097     curcomments = []
1098     build = None
1099     vc_seen = set()
1100
1101     c = 0
1102     for line in metafile:
1103         c += 1
1104         linedesc = "%s:%d" % (metafile.name, c)
1105         line = line.rstrip('\r\n')
1106         if mode == 3:
1107             if build_cont.match(line):
1108                 if line.endswith('\\'):
1109                     buildlines.append(line[:-1].lstrip())
1110                 else:
1111                     buildlines.append(line.lstrip())
1112                     bl = ''.join(buildlines)
1113                     add_buildflag(bl, build)
1114                     del buildlines[:]
1115             else:
1116                 if not build.commit and not build.disable:
1117                     raise MetaDataException("No commit specified for {0} in {1}"
1118                                             .format(build.version, linedesc))
1119
1120                 app.builds.append(build)
1121                 add_comments('build:' + build.vercode)
1122                 mode = 0
1123
1124         if mode == 0:
1125             if not line:
1126                 continue
1127             if line.startswith("#"):
1128                 curcomments.append(line[1:].strip())
1129                 continue
1130             try:
1131                 f, v = line.split(':', 1)
1132             except ValueError:
1133                 raise MetaDataException("Invalid metadata in " + linedesc)
1134
1135             # Translate obsolete fields...
1136             if f == 'Market Version':
1137                 f = 'Current Version'
1138             if f == 'Market Version Code':
1139                 f = 'Current Version Code'
1140
1141             ftype = fieldtype(f)
1142             if ftype not in [TYPE_BUILD, TYPE_BUILD_V2]:
1143                 add_comments(f)
1144             if ftype == TYPE_MULTILINE:
1145                 mode = 1
1146                 if v:
1147                     raise MetaDataException("Unexpected text on same line as " + f + " in " + linedesc)
1148             elif ftype == TYPE_STRING:
1149                 app.set_field(f, v)
1150             elif ftype == TYPE_LIST:
1151                 app.set_field(f, split_list_values(v))
1152             elif ftype == TYPE_BUILD:
1153                 if v.endswith("\\"):
1154                     mode = 2
1155                     del buildlines[:]
1156                     buildlines.append(v[:-1])
1157                 else:
1158                     build = parse_buildline([v])
1159                     app.builds.append(build)
1160                     add_comments('build:' + app.builds[-1].vercode)
1161             elif ftype == TYPE_BUILD_V2:
1162                 vv = v.split(',')
1163                 if len(vv) != 2:
1164                     raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
1165                                             .format(v, linedesc))
1166                 build = Build()
1167                 build.version = vv[0]
1168                 build.vercode = vv[1]
1169                 if build.vercode in vc_seen:
1170                     raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
1171                                             build.vercode, linedesc))
1172                 vc_seen.add(build.vercode)
1173                 del buildlines[:]
1174                 mode = 3
1175             elif ftype == TYPE_OBSOLETE:
1176                 pass        # Just throw it away!
1177             else:
1178                 raise MetaDataException("Unrecognised field '" + f + "' in " + linedesc)
1179         elif mode == 1:     # Multiline field
1180             if line == '.':
1181                 mode = 0
1182                 app.set_field(f, '\n'.join(multiline_lines))
1183                 del multiline_lines[:]
1184             else:
1185                 multiline_lines.append(line)
1186         elif mode == 2:     # Line continuation mode in Build Version
1187             if line.endswith("\\"):
1188                 buildlines.append(line[:-1])
1189             else:
1190                 buildlines.append(line)
1191                 build = parse_buildline(buildlines)
1192                 app.builds.append(build)
1193                 add_comments('build:' + app.builds[-1].vercode)
1194                 mode = 0
1195     add_comments(None)
1196     metafile.close()
1197
1198     # Mode at end of file should always be 0
1199     if mode == 1:
1200         raise MetaDataException(f + " not terminated in " + metafile.name)
1201     if mode == 2:
1202         raise MetaDataException("Unterminated continuation in " + metafile.name)
1203     if mode == 3:
1204         raise MetaDataException("Unterminated build in " + metafile.name)
1205
1206     return app
1207
1208
1209 def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
1210
1211     def w_comments(key):
1212         if key not in app.comments:
1213             return
1214         for line in app.comments[key]:
1215             w_comment(line)
1216
1217     def w_field_always(f, v=None):
1218         if v is None:
1219             v = app.get_field(f)
1220         w_comments(f)
1221         w_field(f, v)
1222
1223     def w_field_nonempty(f, v=None):
1224         if v is None:
1225             v = app.get_field(f)
1226         w_comments(f)
1227         if v:
1228             w_field(f, v)
1229
1230     w_field_nonempty('Disabled')
1231     if app.AntiFeatures:
1232         w_field_always('AntiFeatures')
1233     w_field_nonempty('Provides')
1234     w_field_always('Categories')
1235     w_field_always('License')
1236     w_field_always('Web Site')
1237     w_field_always('Source Code')
1238     w_field_always('Issue Tracker')
1239     w_field_nonempty('Changelog')
1240     w_field_nonempty('Donate')
1241     w_field_nonempty('FlattrID')
1242     w_field_nonempty('Bitcoin')
1243     w_field_nonempty('Litecoin')
1244     mf.write('\n')
1245     w_field_nonempty('Name')
1246     w_field_nonempty('Auto Name')
1247     w_field_always('Summary')
1248     w_field_always('Description', description_txt(app.Description))
1249     mf.write('\n')
1250     if app.RequiresRoot:
1251         w_field_always('Requires Root', 'yes')
1252         mf.write('\n')
1253     if app.RepoType:
1254         w_field_always('Repo Type')
1255         w_field_always('Repo')
1256         if app.Binaries:
1257             w_field_always('Binaries')
1258         mf.write('\n')
1259
1260     for build in sorted_builds(app.builds):
1261
1262         if build.version == "Ignore":
1263             continue
1264
1265         w_comments('build:' + build.vercode)
1266         w_build(build)
1267         mf.write('\n')
1268
1269     if app.MaintainerNotes:
1270         w_field_always('Maintainer Notes', app.MaintainerNotes)
1271         mf.write('\n')
1272
1273     w_field_nonempty('Archive Policy')
1274     w_field_always('Auto Update Mode')
1275     w_field_always('Update Check Mode')
1276     w_field_nonempty('Update Check Ignore')
1277     w_field_nonempty('Vercode Operation')
1278     w_field_nonempty('Update Check Name')
1279     w_field_nonempty('Update Check Data')
1280     if app.CurrentVersion:
1281         w_field_always('Current Version')
1282         w_field_always('Current Version Code')
1283     if app.NoSourceSince:
1284         mf.write('\n')
1285         w_field_always('No Source Since')
1286     w_comments(None)
1287
1288
1289 # Write a metadata file in txt format.
1290 #
1291 # 'mf'      - Writer interface (file, StringIO, ...)
1292 # 'app'     - The app data
1293 def write_txt_metadata(mf, app):
1294
1295     def w_comment(line):
1296         mf.write("# %s\n" % line)
1297
1298     def w_field(f, v):
1299         t = fieldtype(f)
1300         if t == TYPE_LIST:
1301             v = ','.join(v)
1302         elif t == TYPE_MULTILINE:
1303             v = '\n' + v + '\n.'
1304         mf.write("%s:%s\n" % (f, v))
1305
1306     def w_build(build):
1307         mf.write("Build:%s,%s\n" % (build.version, build.vercode))
1308
1309         for f in build_flags_order:
1310             v = build.get_flag(f)
1311             if not v:
1312                 continue
1313
1314             t = flagtype(f)
1315             mf.write('    %s=' % f)
1316             if t == TYPE_STRING:
1317                 mf.write(v)
1318             elif t == TYPE_BOOL:
1319                 mf.write('yes')
1320             elif t == TYPE_SCRIPT:
1321                 first = True
1322                 for s in v.split(' && '):
1323                     if first:
1324                         first = False
1325                     else:
1326                         mf.write(' && \\\n        ')
1327                     mf.write(s)
1328             elif t == TYPE_LIST:
1329                 mf.write(','.join(v))
1330
1331             mf.write('\n')
1332
1333     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1334
1335
1336 def write_yaml_metadata(mf, app):
1337
1338     def w_comment(line):
1339         mf.write("# %s\n" % line)
1340
1341     def escape(v):
1342         if not v:
1343             return ''
1344         if any(c in v for c in [': ', '%', '@', '*']):
1345             return "'" + v.replace("'", "''") + "'"
1346         return v
1347
1348     def w_field(f, v, prefix='', t=None):
1349         if t is None:
1350             t = fieldtype(f)
1351         v = ''
1352         if t == TYPE_LIST:
1353             v = '\n'
1354             for e in v:
1355                 v += prefix + ' - ' + escape(e) + '\n'
1356         elif t == TYPE_MULTILINE:
1357             v = ' |\n'
1358             for l in v.splitlines():
1359                 if l:
1360                     v += prefix + '  ' + l + '\n'
1361                 else:
1362                     v += '\n'
1363         elif t == TYPE_BOOL:
1364             v = ' yes\n'
1365         elif t == TYPE_SCRIPT:
1366             cmds = [s + '&& \\' for s in v.split('&& ')]
1367             if len(cmds) > 0:
1368                 cmds[-1] = cmds[-1][:-len('&& \\')]
1369             w_field(f, cmds, prefix, 'multiline')
1370             return
1371         else:
1372             v = ' ' + escape(v) + '\n'
1373
1374         mf.write(prefix)
1375         mf.write(f)
1376         mf.write(":")
1377         mf.write(v)
1378
1379     global first_build
1380     first_build = True
1381
1382     def w_build(build):
1383         global first_build
1384         if first_build:
1385             mf.write("builds:\n")
1386             first_build = False
1387
1388         w_field('versionName', build.version, '  - ', TYPE_STRING)
1389         w_field('versionCode', build.vercode, '    ', TYPE_STRING)
1390         for f in build_flags_order:
1391             v = build.get_flag(f)
1392             if not v:
1393                 continue
1394
1395             w_field(f, v, '    ', flagtype(f))
1396
1397     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1398
1399
1400 def write_metadata(fmt, mf, app):
1401     if fmt == 'txt':
1402         return write_txt_metadata(mf, app)
1403     if fmt == 'yaml':
1404         return write_yaml_metadata(mf, app)
1405     raise MetaDataException("Unknown metadata format given")