chiark / gitweb /
rewrite metadata builds list to yaml
[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                     b.update({field: getattr(build, field)})
974             builds.append(b)
975
976         # insert extra empty lines between builds
977         for i in range(1, len(builds)):
978             builds.yaml_set_comment_before_after_key(i, 'bogus')
979             builds.ca.items[i][1][-1].value = '\n'
980
981         return builds
982
983     empty_keys = [k for k, v in app.items() if not v]
984     for k in empty_keys:
985         del app[k]
986
987     for k in ['added', 'lastUpdated', 'id', 'metadatapath']:
988         if k in app:
989             del app[k]
990
991     # yaml.add_representer(fdroidserver.metadata.App, _class_as_dict_representer)
992     ruamel.yaml.add_representer(fdroidserver.metadata.Build, _class_as_dict_representer)
993     # yaml.dump(app.asOrderedDict(), mf, default_flow_style=False, Dumper=yamlordereddictloader.Dumper)
994
995     yaml_app_field_order = [
996         'Categories',
997         'License',
998         'WebSite',
999         'SourceCode',
1000         'IssueTracker',
1001         'Changelog',
1002         'Donate',
1003         'FlattrID',
1004         'Bitcoin',
1005         '\n',
1006         'AutoName',
1007         'Summary',
1008         'Description',
1009         '\n',
1010         'RepoType',
1011         'Repo',
1012         '\n',
1013         'Builds',
1014         '\n',
1015         'ArchivePolicy',
1016         'AutoUpdateMode',
1017         'UpdateCheckMode',
1018         'CurrentVersion',
1019         'CurrentVersionCode',
1020     ]
1021
1022     preformated = ruamel.yaml.comments.CommentedMap()
1023     insert_newline = False
1024     for field in yaml_app_field_order:
1025         if field is '\n':
1026             insert_newline = True
1027         else:
1028             if (hasattr(app, field) and getattr(app, field)) or field is 'Builds':
1029                 if field in ['Description']:
1030                     preformated.update({field: ruamel.yaml.scalarstring.preserve_literal(getattr(app, field))})
1031                 elif field is 'Builds':
1032                     preformated.update({'Builds': _builds_to_yaml(app)})
1033                 else:
1034                     preformated.update({field: getattr(app, field)})
1035
1036                 if insert_newline:
1037                     insert_newline = False
1038                     # inserting empty lines is not supported so we add a
1039                     # bogus comment and over-write its value
1040                     preformated.yaml_set_comment_before_after_key(field, 'bogus')
1041                     preformated.ca.items[field][1][-1].value = '\n'
1042
1043     ruamel.yaml.round_trip_dump(preformated, mf, indent=4, block_seq_indent=2)
1044
1045
1046 build_line_sep = re.compile(r'(?<!\\),')
1047 build_cont = re.compile(r'^[ \t]')
1048
1049
1050 def parse_txt_metadata(mf, app):
1051
1052     linedesc = None
1053
1054     def add_buildflag(p, build):
1055         if not p.strip():
1056             warn_or_exception("Empty build flag at {1}"
1057                               .format(buildlines[0], linedesc))
1058         bv = p.split('=', 1)
1059         if len(bv) != 2:
1060             warn_or_exception("Invalid build flag at {0} in {1}"
1061                               .format(buildlines[0], linedesc))
1062
1063         pk, pv = bv
1064         pk = pk.lstrip()
1065         if pk == 'update':
1066             pk = 'androidupdate'  # avoid conflicting with Build(dict).update()
1067         t = flagtype(pk)
1068         if t == TYPE_LIST:
1069             pv = split_list_values(pv)
1070             build[pk] = pv
1071         elif t == TYPE_STRING or t == TYPE_SCRIPT:
1072             build[pk] = pv
1073         elif t == TYPE_BOOL:
1074             build[pk] = _decode_bool(pv)
1075
1076     def parse_buildline(lines):
1077         v = "".join(lines)
1078         parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
1079         if len(parts) < 3:
1080             warn_or_exception("Invalid build format: " + v + " in " + mf.name)
1081         build = Build()
1082         build.versionName = parts[0]
1083         build.versionCode = parts[1]
1084         check_versionCode(build.versionCode)
1085
1086         if parts[2].startswith('!'):
1087             # For backwards compatibility, handle old-style disabling,
1088             # including attempting to extract the commit from the message
1089             build.disable = parts[2][1:]
1090             commit = 'unknown - see disabled'
1091             index = parts[2].rfind('at ')
1092             if index != -1:
1093                 commit = parts[2][index + 3:]
1094                 if commit.endswith(')'):
1095                     commit = commit[:-1]
1096             build.commit = commit
1097         else:
1098             build.commit = parts[2]
1099         for p in parts[3:]:
1100             add_buildflag(p, build)
1101
1102         return build
1103
1104     def check_versionCode(versionCode):
1105         try:
1106             int(versionCode)
1107         except ValueError:
1108             warn_or_exception('Invalid versionCode: "' + versionCode + '" is not an integer!')
1109
1110     def add_comments(key):
1111         if not curcomments:
1112             return
1113         app.comments[key] = list(curcomments)
1114         del curcomments[:]
1115
1116     mode = 0
1117     buildlines = []
1118     multiline_lines = []
1119     curcomments = []
1120     build = None
1121     vc_seen = set()
1122
1123     app.builds = []
1124
1125     c = 0
1126     for line in mf:
1127         c += 1
1128         linedesc = "%s:%d" % (mf.name, c)
1129         line = line.rstrip('\r\n')
1130         if mode == 3:
1131             if build_cont.match(line):
1132                 if line.endswith('\\'):
1133                     buildlines.append(line[:-1].lstrip())
1134                 else:
1135                     buildlines.append(line.lstrip())
1136                     bl = ''.join(buildlines)
1137                     add_buildflag(bl, build)
1138                     del buildlines[:]
1139             else:
1140                 if not build.commit and not build.disable:
1141                     warn_or_exception("No commit specified for {0} in {1}"
1142                                       .format(build.versionName, linedesc))
1143
1144                 app.builds.append(build)
1145                 add_comments('build:' + build.versionCode)
1146                 mode = 0
1147
1148         if mode == 0:
1149             if not line:
1150                 continue
1151             if line.startswith("#"):
1152                 curcomments.append(line[1:].strip())
1153                 continue
1154             try:
1155                 f, v = line.split(':', 1)
1156             except ValueError:
1157                 warn_or_exception("Invalid metadata in " + linedesc)
1158
1159             if f not in app_fields:
1160                 warn_or_exception('Unrecognised app field: ' + f)
1161
1162             # Translate obsolete fields...
1163             if f == 'Market Version':
1164                 f = 'Current Version'
1165             if f == 'Market Version Code':
1166                 f = 'Current Version Code'
1167
1168             f = f.replace(' ', '')
1169
1170             ftype = fieldtype(f)
1171             if ftype not in [TYPE_BUILD, TYPE_BUILD_V2]:
1172                 add_comments(f)
1173             if ftype == TYPE_MULTILINE:
1174                 mode = 1
1175                 if v:
1176                     warn_or_exception("Unexpected text on same line as "
1177                                       + f + " in " + linedesc)
1178             elif ftype == TYPE_STRING:
1179                 app[f] = v
1180             elif ftype == TYPE_LIST:
1181                 app[f] = split_list_values(v)
1182             elif ftype == TYPE_BUILD:
1183                 if v.endswith("\\"):
1184                     mode = 2
1185                     del buildlines[:]
1186                     buildlines.append(v[:-1])
1187                 else:
1188                     build = parse_buildline([v])
1189                     app.builds.append(build)
1190                     add_comments('build:' + app.builds[-1].versionCode)
1191             elif ftype == TYPE_BUILD_V2:
1192                 vv = v.split(',')
1193                 if len(vv) != 2:
1194                     warn_or_exception('Build should have comma-separated',
1195                                       'versionName and versionCode,',
1196                                       'not "{0}", in {1}'.format(v, linedesc))
1197                 build = Build()
1198                 build.versionName = vv[0]
1199                 build.versionCode = vv[1]
1200                 check_versionCode(build.versionCode)
1201
1202                 if build.versionCode in vc_seen:
1203                     warn_or_exception('Duplicate build recipe found for versionCode %s in %s'
1204                                       % (build.versionCode, linedesc))
1205                 vc_seen.add(build.versionCode)
1206                 del buildlines[:]
1207                 mode = 3
1208             elif ftype == TYPE_OBSOLETE:
1209                 pass        # Just throw it away!
1210             else:
1211                 warn_or_exception("Unrecognised field '" + f + "' in " + linedesc)
1212         elif mode == 1:     # Multiline field
1213             if line == '.':
1214                 mode = 0
1215                 app[f] = '\n'.join(multiline_lines)
1216                 del multiline_lines[:]
1217             else:
1218                 multiline_lines.append(line)
1219         elif mode == 2:     # Line continuation mode in Build Version
1220             if line.endswith("\\"):
1221                 buildlines.append(line[:-1])
1222             else:
1223                 buildlines.append(line)
1224                 build = parse_buildline(buildlines)
1225                 app.builds.append(build)
1226                 add_comments('build:' + app.builds[-1].versionCode)
1227                 mode = 0
1228     add_comments(None)
1229
1230     # Mode at end of file should always be 0
1231     if mode == 1:
1232         warn_or_exception(f + " not terminated in " + mf.name)
1233     if mode == 2:
1234         warn_or_exception("Unterminated continuation in " + mf.name)
1235     if mode == 3:
1236         warn_or_exception("Unterminated build in " + mf.name)
1237
1238     return app
1239
1240
1241 def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
1242
1243     def field_to_attr(f):
1244         """
1245         Translates human-readable field names to attribute names, e.g.
1246         'Auto Name' to 'AutoName'
1247         """
1248         return f.replace(' ', '')
1249
1250     def attr_to_field(k):
1251         """
1252         Translates attribute names to human-readable field names, e.g.
1253         'AutoName' to 'Auto Name'
1254         """
1255         if k in app_fields:
1256             return k
1257         f = re.sub(r'([a-z])([A-Z])', r'\1 \2', k)
1258         return f
1259
1260     def w_comments(key):
1261         if key not in app.comments:
1262             return
1263         for line in app.comments[key]:
1264             w_comment(line)
1265
1266     def w_field_always(f, v=None):
1267         key = field_to_attr(f)
1268         if v is None:
1269             v = app.get(key)
1270         w_comments(key)
1271         w_field(f, v)
1272
1273     def w_field_nonempty(f, v=None):
1274         key = field_to_attr(f)
1275         if v is None:
1276             v = app.get(key)
1277         w_comments(key)
1278         if v:
1279             w_field(f, v)
1280
1281     w_field_nonempty('Disabled')
1282     w_field_nonempty('AntiFeatures')
1283     w_field_nonempty('Provides')
1284     w_field_always('Categories')
1285     w_field_always('License')
1286     w_field_nonempty('Author Name')
1287     w_field_nonempty('Author Email')
1288     w_field_nonempty('Author Web Site')
1289     w_field_always('Web Site')
1290     w_field_always('Source Code')
1291     w_field_always('Issue Tracker')
1292     w_field_nonempty('Changelog')
1293     w_field_nonempty('Donate')
1294     w_field_nonempty('FlattrID')
1295     w_field_nonempty('Bitcoin')
1296     w_field_nonempty('Litecoin')
1297     mf.write('\n')
1298     w_field_nonempty('Name')
1299     w_field_nonempty('Auto Name')
1300     w_field_nonempty('Summary')
1301     w_field_nonempty('Description', description_txt(app.Description))
1302     mf.write('\n')
1303     if app.RequiresRoot:
1304         w_field_always('Requires Root', 'yes')
1305         mf.write('\n')
1306     if app.RepoType:
1307         w_field_always('Repo Type')
1308         w_field_always('Repo')
1309         if app.Binaries:
1310             w_field_always('Binaries')
1311         mf.write('\n')
1312
1313     for build in app.builds:
1314
1315         if build.versionName == "Ignore":
1316             continue
1317
1318         w_comments('build:%s' % build.versionCode)
1319         w_build(build)
1320         mf.write('\n')
1321
1322     if app.MaintainerNotes:
1323         w_field_always('Maintainer Notes', app.MaintainerNotes)
1324         mf.write('\n')
1325
1326     w_field_nonempty('Archive Policy')
1327     w_field_always('Auto Update Mode')
1328     w_field_always('Update Check Mode')
1329     w_field_nonempty('Update Check Ignore')
1330     w_field_nonempty('Vercode Operation')
1331     w_field_nonempty('Update Check Name')
1332     w_field_nonempty('Update Check Data')
1333     if app.CurrentVersion:
1334         w_field_always('Current Version')
1335         w_field_always('Current Version Code')
1336     if app.NoSourceSince:
1337         mf.write('\n')
1338         w_field_always('No Source Since')
1339     w_comments(None)
1340
1341
1342 # Write a metadata file in txt format.
1343 #
1344 # 'mf'      - Writer interface (file, StringIO, ...)
1345 # 'app'     - The app data
1346 def write_txt(mf, app):
1347
1348     def w_comment(line):
1349         mf.write("# %s\n" % line)
1350
1351     def w_field(f, v):
1352         t = fieldtype(f)
1353         if t == TYPE_LIST:
1354             v = ','.join(v)
1355         elif t == TYPE_MULTILINE:
1356             v = '\n' + v + '\n.'
1357         mf.write("%s:%s\n" % (f, v))
1358
1359     def w_build(build):
1360         mf.write("Build:%s,%s\n" % (build.versionName, build.versionCode))
1361
1362         for f in build_flags_order:
1363             v = build.get(f)
1364             if not v:
1365                 continue
1366
1367             t = flagtype(f)
1368             if f == 'androidupdate':
1369                 f = 'update'  # avoid conflicting with Build(dict).update()
1370             mf.write('    %s=' % f)
1371             if t == TYPE_STRING:
1372                 mf.write(v)
1373             elif t == TYPE_BOOL:
1374                 mf.write('yes')
1375             elif t == TYPE_SCRIPT:
1376                 first = True
1377                 for s in v.split(' && '):
1378                     if first:
1379                         first = False
1380                     else:
1381                         mf.write(' && \\\n        ')
1382                     mf.write(s.strip())
1383             elif t == TYPE_LIST:
1384                 mf.write(','.join(v))
1385
1386             mf.write('\n')
1387
1388     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1389
1390
1391 def write_metadata(metadatapath, app):
1392     _, ext = fdroidserver.common.get_extension(metadatapath)
1393     accepted = fdroidserver.common.config['accepted_formats']
1394     if ext not in accepted:
1395         warn_or_exception('Cannot write "%s", not an accepted format, use: %s'
1396                           % (metadatapath, ', '.join(accepted)))
1397
1398     with open(metadatapath, 'w', encoding='utf8') as mf:
1399         if ext == 'txt':
1400             return write_txt(mf, app)
1401         elif ext == 'yml':
1402             return write_yaml(mf, app)
1403     warn_or_exception('Unknown metadata format: %s' % metadatapath)
1404
1405
1406 def add_metadata_arguments(parser):
1407     '''add common command line flags related to metadata processing'''
1408     parser.add_argument("-W", default='error',
1409                         help="force errors to be warnings, or ignore")