1 # -*- coding: utf-8 -*-
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>
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.
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.
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/>.
26 from collections import OrderedDict
33 class MetaDataException(Exception):
35 def __init__(self, value):
41 # In the order in which they are laid out on files
42 app_defaults = OrderedDict([
44 ('AntiFeatures', None),
46 ('Categories', ['None']),
47 ('License', 'Unknown'),
50 ('Issue Tracker', ''),
60 ('Requires Root', False),
64 ('Maintainer Notes', []),
65 ('Archive Policy', None),
66 ('Auto Update Mode', 'None'),
67 ('Update Check Mode', 'None'),
68 ('Update Check Ignore', None),
69 ('Vercode Operation', None),
70 ('Update Check Name', None),
71 ('Update Check Data', None),
72 ('Current Version', ''),
73 ('Current Version Code', '0'),
74 ('No Source Since', ''),
78 # In the order in which they are laid out on files
79 # Sorted by their action and their place in the build timeline
80 flag_defaults = OrderedDict([
84 ('submodules', False),
94 ('forceversion', False),
95 ('forcevercode', False),
105 ('ndk', 'r9b'), # defaults to oldest
107 ('antcommands', None),
112 # Designates a metadata field type and checks that it matches
114 # 'name' - The long name of the field type
115 # 'matching' - List of possible values or regex expression
116 # 'sep' - Separator to use if value may be a list
117 # 'fields' - Metadata fields (Field:Value) of this type
118 # 'attrs' - Build attributes (attr=value) of this type
120 class FieldValidator():
122 def __init__(self, name, matching, sep, fields, attrs):
124 self.matching = matching
125 if type(matching) is str:
126 self.compiled = re.compile(matching)
131 def _assert_regex(self, values, appid):
133 if not self.compiled.match(v):
134 raise MetaDataException("'%s' is not a valid %s in %s. "
135 % (v, self.name, appid) +
136 "Regex pattern: %s" % (self.matching))
138 def _assert_list(self, values, appid):
140 if v not in self.matching:
141 raise MetaDataException("'%s' is not a valid %s in %s. "
142 % (v, self.name, appid) +
143 "Possible values: %s" % (", ".join(self.matching)))
145 def check(self, value, appid):
146 if type(value) is not str or not value:
148 if self.sep is not None:
149 values = value.split(self.sep)
152 if type(self.matching) is list:
153 self._assert_list(values, appid)
155 self._assert_regex(values, appid)
158 # Generic value types
160 FieldValidator("Integer",
161 r'^[1-9][0-9]*$', None,
165 FieldValidator("Hexadecimal",
166 r'^[0-9a-f]+$', None,
170 FieldValidator("HTTP link",
171 r'^http[s]?://', None,
172 ["Web Site", "Source Code", "Issue Tracker", "Donate"], []),
174 FieldValidator("Bitcoin address",
175 r'^[a-zA-Z0-9]{27,34}$', None,
179 FieldValidator("Litecoin address",
180 r'^L[a-zA-Z0-9]{33}$', None,
184 FieldValidator("Dogecoin address",
185 r'^D[a-zA-Z0-9]{33}$', None,
189 FieldValidator("Boolean",
194 FieldValidator("bool",
197 ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
200 FieldValidator("Repo Type",
201 ['git', 'git-svn', 'svn', 'hg', 'bzr', 'srclib'], None,
205 FieldValidator("Binaries",
206 r'^http[s]?://', None,
210 FieldValidator("Archive Policy",
211 r'^[0-9]+ versions$', None,
215 FieldValidator("Anti-Feature",
216 ["Ads", "Tracking", "NonFreeNet", "NonFreeDep", "NonFreeAdd", "UpstreamNonFree"], ',',
220 FieldValidator("Auto Update Mode",
221 r"^(Version .+|None)$", None,
222 ["Auto Update Mode"],
225 FieldValidator("Update Check Mode",
226 r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$", None,
227 ["Update Check Mode"],
232 # Check an app's metadata information for integrity errors
233 def check_metadata(info):
235 for field in v.fields:
236 v.check(info[field], info['id'])
237 for build in info['builds']:
239 v.check(build[attr], info['id'])
242 # Formatter for descriptions. Create an instance, and call parseline() with
243 # each line of the description source from the metadata. At the end, call
244 # end() and then text_wiki and text_html will contain the result.
245 class DescriptionFormatter:
257 def __init__(self, linkres):
258 self.linkResolver = linkres
260 def endcur(self, notstates=None):
261 if notstates and self.state in notstates:
263 if self.state == self.stPARA:
265 elif self.state == self.stUL:
267 elif self.state == self.stOL:
271 self.text_html += '</p>'
272 self.state = self.stNONE
275 self.text_html += '</ul>'
276 self.state = self.stNONE
279 self.text_html += '</ol>'
280 self.state = self.stNONE
282 def formatted(self, txt, html):
285 txt = cgi.escape(txt)
287 index = txt.find("''")
289 return formatted + txt
290 formatted += txt[:index]
292 if txt.startswith("'''"):
298 self.bold = not self.bold
306 self.ital = not self.ital
309 def linkify(self, txt):
313 index = txt.find("[")
315 return (linkified_plain + self.formatted(txt, False), linkified_html + self.formatted(txt, True))
316 linkified_plain += self.formatted(txt[:index], False)
317 linkified_html += self.formatted(txt[:index], True)
319 if txt.startswith("[["):
320 index = txt.find("]]")
322 raise MetaDataException("Unterminated ]]")
324 if self.linkResolver:
325 url, urltext = self.linkResolver(url)
328 linkified_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
329 linkified_plain += urltext
330 txt = txt[index + 2:]
332 index = txt.find("]")
334 raise MetaDataException("Unterminated ]")
336 index2 = url.find(' ')
340 urltxt = url[index2 + 1:]
343 raise MetaDataException("'%s' doesn't look like an URL" % url)
344 linkified_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
345 linkified_plain += urltxt
347 linkified_plain += ' (' + url + ')'
348 txt = txt[index + 1:]
350 def addtext(self, txt):
351 p, h = self.linkify(txt)
354 def parseline(self, line):
355 self.text_wiki += "%s\n" % line
358 elif line.startswith('* '):
359 self.endcur([self.stUL])
360 if self.state != self.stUL:
361 self.text_html += '<ul>'
362 self.state = self.stUL
363 self.text_html += '<li>'
364 self.addtext(line[1:])
365 self.text_html += '</li>'
366 elif line.startswith('# '):
367 self.endcur([self.stOL])
368 if self.state != self.stOL:
369 self.text_html += '<ol>'
370 self.state = self.stOL
371 self.text_html += '<li>'
372 self.addtext(line[1:])
373 self.text_html += '</li>'
375 self.endcur([self.stPARA])
376 if self.state == self.stNONE:
377 self.text_html += '<p>'
378 self.state = self.stPARA
379 elif self.state == self.stPARA:
380 self.text_html += ' '
387 # Parse multiple lines of description as written in a metadata file, returning
388 # a single string in wiki format. Used for the Maintainer Notes field as well,
389 # because it's the same format.
390 def description_wiki(lines):
391 ps = DescriptionFormatter(None)
398 # Parse multiple lines of description as written in a metadata file, returning
399 # a single string in HTML format.
400 def description_html(lines, linkres):
401 ps = DescriptionFormatter(linkres)
408 def parse_srclib(metafile):
411 if metafile and not isinstance(metafile, file):
412 metafile = open(metafile, "r")
414 # Defaults for fields that come from metadata
415 thisinfo['Repo Type'] = ''
416 thisinfo['Repo'] = ''
417 thisinfo['Subdir'] = None
418 thisinfo['Prepare'] = None
419 thisinfo['Srclibs'] = None
425 for line in metafile:
427 line = line.rstrip('\r\n')
428 if not line or line.startswith("#"):
432 field, value = line.split(':', 1)
434 raise MetaDataException("Invalid metadata in %s:%d" % (line, n))
436 if field == "Subdir":
437 thisinfo[field] = value.split(',')
439 thisinfo[field] = value
445 """Read all srclib metadata.
447 The information read will be accessible as metadata.srclibs, which is a
448 dictionary, keyed on srclib name, with the values each being a dictionary
449 in the same format as that returned by the parse_srclib function.
451 A MetaDataException is raised if there are any problems with the srclib
456 # They were already loaded
457 if srclibs is not None:
463 if not os.path.exists(srcdir):
466 for metafile in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
467 srclibname = os.path.basename(metafile[:-4])
468 srclibs[srclibname] = parse_srclib(metafile)
471 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
472 # returned by the parse_metadata function.
473 def read_metadata(xref=True):
475 # Always read the srclibs before the apps, since they can use a srlib as
476 # their source repository.
481 for basedir in ('metadata', 'tmp'):
482 if not os.path.exists(basedir):
485 for metafile in sorted(glob.glob(os.path.join('metadata', '*.txt'))):
486 appid, appinfo = parse_metadata(metafile)
487 check_metadata(appinfo)
488 apps[appid] = appinfo
491 # Parse all descriptions at load time, just to ensure cross-referencing
492 # errors are caught early rather than when they hit the build server.
495 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
496 raise MetaDataException("Cannot resolve app id " + appid)
498 for appid, app in apps.iteritems():
500 description_html(app['Description'], linkres)
501 except MetaDataException, e:
502 raise MetaDataException("Problem with description of " + appid +
508 # Get the type expected for a given metadata field.
509 def metafieldtype(name):
510 if name in ['Description', 'Maintainer Notes']:
512 if name in ['Categories']:
514 if name == 'Build Version':
518 if name == 'Use Built':
520 if name not in app_defaults:
526 if name in ['extlibs', 'srclibs', 'patch', 'rm', 'buildjni', 'preassemble',
527 'update', 'scanignore', 'scandelete', 'gradle', 'antcommands']:
529 if name in ['init', 'prebuild', 'build']:
531 if name in ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
537 def fill_build_defaults(build):
539 def get_build_type():
540 for t in ['maven', 'gradle', 'kivy']:
547 for flag, value in flag_defaults.iteritems():
551 build['type'] = get_build_type()
552 build['ndk_path'] = common.get_ndk_path(build['ndk'])
555 def split_list_values(s):
556 # Port legacy ';' separators
557 l = [v.strip() for v in s.replace(';', ',').split(',')]
558 return [v for v in l if v]
561 # Parse metadata for a single application.
563 # 'metafile' - the filename to read. The package id for the application comes
564 # from this filename. Pass None to get a blank entry.
566 # Returns a dictionary containing all the details of the application. There are
567 # two major kinds of information in the dictionary. Keys beginning with capital
568 # letters correspond directory to identically named keys in the metadata file.
569 # Keys beginning with lower case letters are generated in one way or another,
570 # and are not found verbatim in the metadata.
572 # Known keys not originating from the metadata are:
574 # 'builds' - a list of dictionaries containing build information
575 # for each defined build
576 # 'comments' - a list of comments from the metadata file. Each is
577 # a tuple of the form (field, comment) where field is
578 # the name of the field it preceded in the metadata
579 # file. Where field is None, the comment goes at the
580 # end of the file. Alternatively, 'build:version' is
581 # for a comment before a particular build version.
582 # 'descriptionlines' - original lines of description as formatted in the
585 def parse_metadata(metafile):
590 def add_buildflag(p, thisbuild):
593 raise MetaDataException("Invalid build flag at {0} in {1}"
594 .format(buildlines[0], linedesc))
597 raise MetaDataException("Duplicate definition on {0} in version {1} of {2}"
598 .format(pk, thisbuild['version'], linedesc))
601 if pk not in flag_defaults:
602 raise MetaDataException("Unrecognised build flag at {0} in {1}"
603 .format(p, linedesc))
606 pv = split_list_values(pv)
608 if len(pv) == 1 and pv[0] in ['main', 'yes']:
611 elif t == 'string' or t == 'script':
618 logging.debug("...ignoring bool flag %s" % p)
621 raise MetaDataException("Unrecognised build flag type '%s' at %s in %s"
624 def parse_buildline(lines):
625 value = "".join(lines)
626 parts = [p.replace("\\,", ",")
627 for p in re.split(r"(?<!\\),", value)]
629 raise MetaDataException("Invalid build format: " + value + " in " + metafile.name)
631 thisbuild['origlines'] = lines
632 thisbuild['version'] = parts[0]
633 thisbuild['vercode'] = parts[1]
634 if parts[2].startswith('!'):
635 # For backwards compatibility, handle old-style disabling,
636 # including attempting to extract the commit from the message
637 thisbuild['disable'] = parts[2][1:]
638 commit = 'unknown - see disabled'
639 index = parts[2].rfind('at ')
641 commit = parts[2][index + 3:]
642 if commit.endswith(')'):
644 thisbuild['commit'] = commit
646 thisbuild['commit'] = parts[2]
648 add_buildflag(p, thisbuild)
652 def add_comments(key):
655 for comment in curcomments:
656 thisinfo['comments'].append((key, comment))
661 if not isinstance(metafile, file):
662 metafile = open(metafile, "r")
663 appid = metafile.name[9:-4]
665 thisinfo.update(app_defaults)
666 thisinfo['id'] = appid
668 # General defaults...
669 thisinfo['builds'] = []
670 thisinfo['comments'] = []
673 return appid, thisinfo
682 for line in metafile:
684 linedesc = "%s:%d" % (metafile.name, c)
685 line = line.rstrip('\r\n')
687 if not any(line.startswith(s) for s in (' ', '\t')):
688 commit = curbuild['commit'] if 'commit' in curbuild else None
689 if not commit and 'disable' not in curbuild:
690 raise MetaDataException("No commit specified for {0} in {1}"
691 .format(curbuild['version'], linedesc))
693 thisinfo['builds'].append(curbuild)
694 add_comments('build:' + curbuild['vercode'])
697 if line.endswith('\\'):
698 buildlines.append(line[:-1].lstrip())
700 buildlines.append(line.lstrip())
701 bl = ''.join(buildlines)
702 add_buildflag(bl, curbuild)
708 if line.startswith("#"):
709 curcomments.append(line)
712 field, value = line.split(':', 1)
714 raise MetaDataException("Invalid metadata in " + linedesc)
715 if field != field.strip() or value != value.strip():
716 raise MetaDataException("Extra spacing found in " + linedesc)
718 # Translate obsolete fields...
719 if field == 'Market Version':
720 field = 'Current Version'
721 if field == 'Market Version Code':
722 field = 'Current Version Code'
724 fieldtype = metafieldtype(field)
725 if fieldtype not in ['build', 'buildv2']:
727 if fieldtype == 'multiline':
731 raise MetaDataException("Unexpected text on same line as " + field + " in " + linedesc)
732 elif fieldtype == 'string':
733 thisinfo[field] = value
734 elif fieldtype == 'list':
735 thisinfo[field] = split_list_values(value)
736 elif fieldtype == 'build':
737 if value.endswith("\\"):
739 buildlines = [value[:-1]]
741 curbuild = parse_buildline([value])
742 thisinfo['builds'].append(curbuild)
743 add_comments('build:' + thisinfo['builds'][-1]['vercode'])
744 elif fieldtype == 'buildv2':
746 vv = value.split(',')
748 raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
749 .format(value, linedesc))
750 curbuild['version'] = vv[0]
751 curbuild['vercode'] = vv[1]
752 if curbuild['vercode'] in vc_seen:
753 raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
754 curbuild['vercode'], linedesc))
755 vc_seen[curbuild['vercode']] = True
758 elif fieldtype == 'obsolete':
759 pass # Just throw it away!
761 raise MetaDataException("Unrecognised field type for " + field + " in " + linedesc)
762 elif mode == 1: # Multiline field
766 thisinfo[field].append(line)
767 elif mode == 2: # Line continuation mode in Build Version
768 if line.endswith("\\"):
769 buildlines.append(line[:-1])
771 buildlines.append(line)
772 curbuild = parse_buildline(buildlines)
773 thisinfo['builds'].append(curbuild)
774 add_comments('build:' + thisinfo['builds'][-1]['vercode'])
778 # Mode at end of file should always be 0...
780 raise MetaDataException(field + " not terminated in " + metafile.name)
782 raise MetaDataException("Unterminated continuation in " + metafile.name)
784 raise MetaDataException("Unterminated build in " + metafile.name)
786 if not thisinfo['Description']:
787 thisinfo['Description'].append('No description available')
789 for build in thisinfo['builds']:
790 fill_build_defaults(build)
792 thisinfo['builds'] = sorted(thisinfo['builds'], key=lambda build: int(build['vercode']))
794 return (appid, thisinfo)
797 # Write a metadata file.
799 # 'dest' - The path to the output file
800 # 'app' - The app data
801 def write_metadata(dest, app):
803 def writecomments(key):
805 for pf, comment in app['comments']:
807 mf.write("%s\n" % comment)
810 logging.debug("...writing comments for " + (key or 'EOF'))
812 def writefield(field, value=None):
816 t = metafieldtype(field)
818 value = ','.join(value)
819 mf.write("%s:%s\n" % (field, value))
821 def writefield_nonempty(field, value=None):
825 writefield(field, value)
828 writefield_nonempty('Disabled')
829 writefield_nonempty('AntiFeatures')
830 writefield_nonempty('Provides')
831 writefield('Categories')
832 writefield('License')
833 writefield('Web Site')
834 writefield('Source Code')
835 writefield('Issue Tracker')
836 writefield_nonempty('Donate')
837 writefield_nonempty('FlattrID')
838 writefield_nonempty('Bitcoin')
839 writefield_nonempty('Litecoin')
840 writefield_nonempty('Dogecoin')
842 writefield_nonempty('Name')
843 writefield_nonempty('Auto Name')
844 writefield('Summary')
845 writefield('Description', '')
846 for line in app['Description']:
847 mf.write("%s\n" % line)
850 if app['Requires Root']:
851 writefield('Requires Root', 'Yes')
854 writefield('Repo Type')
857 writefield('Binaries')
859 for build in app['builds']:
861 if build['version'] == "Ignore":
864 writecomments('build:' + build['vercode'])
865 mf.write("Build:%s,%s\n" % (build['version'], build['vercode']))
867 def write_builditem(key, value):
869 if key in ['version', 'vercode']:
872 if value == flag_defaults[key]:
877 logging.debug("...writing {0} : {1}".format(key, value))
878 outline = ' %s=' % key
885 outline += '&& \\\n '.join([s.lstrip() for s in value.split('&& ')])
887 outline += ','.join(value) if type(value) == list else value
892 for flag in flag_defaults:
895 write_builditem(flag, value)
898 if app['Maintainer Notes']:
899 writefield('Maintainer Notes', '')
900 for line in app['Maintainer Notes']:
901 mf.write("%s\n" % line)
905 writefield_nonempty('Archive Policy')
906 writefield('Auto Update Mode')
907 writefield('Update Check Mode')
908 writefield_nonempty('Update Check Ignore')
909 writefield_nonempty('Vercode Operation')
910 writefield_nonempty('Update Check Name')
911 writefield_nonempty('Update Check Data')
912 if app['Current Version']:
913 writefield('Current Version')
914 writefield('Current Version Code')
916 if app['No Source Since']:
917 writefield('No Source Since')