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