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