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