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