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