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/>.
27 from collections import OrderedDict
34 class MetaDataException(Exception):
36 def __init__(self, value):
42 # In the order in which they are laid out on files
43 app_defaults = OrderedDict([
45 ('AntiFeatures', None),
47 ('Categories', ['None']),
48 ('License', 'Unknown'),
51 ('Issue Tracker', ''),
62 ('Requires Root', False),
66 ('Maintainer Notes', []),
67 ('Archive Policy', None),
68 ('Auto Update Mode', 'None'),
69 ('Update Check Mode', 'None'),
70 ('Update Check Ignore', None),
71 ('Vercode Operation', None),
72 ('Update Check Name', None),
73 ('Update Check Data', None),
74 ('Current Version', ''),
75 ('Current Version Code', '0'),
76 ('No Source Since', ''),
80 # In the order in which they are laid out on files
81 # Sorted by their action and their place in the build timeline
82 flag_defaults = OrderedDict([
86 ('submodules', False),
96 ('forceversion', False),
97 ('forcevercode', False),
101 ('update', ['auto']),
107 ('ndk', 'r10e'), # defaults to latest
110 ('antcommands', None),
115 # Designates a metadata field type and checks that it matches
117 # 'name' - The long name of the field type
118 # 'matching' - List of possible values or regex expression
119 # 'sep' - Separator to use if value may be a list
120 # 'fields' - Metadata fields (Field:Value) of this type
121 # 'attrs' - Build attributes (attr=value) of this type
123 class FieldValidator():
125 def __init__(self, name, matching, sep, fields, attrs):
127 self.matching = matching
128 if type(matching) is str:
129 self.compiled = re.compile(matching)
134 def _assert_regex(self, values, appid):
136 if not self.compiled.match(v):
137 raise MetaDataException("'%s' is not a valid %s in %s. "
138 % (v, self.name, appid) +
139 "Regex pattern: %s" % (self.matching))
141 def _assert_list(self, values, appid):
143 if v not in self.matching:
144 raise MetaDataException("'%s' is not a valid %s in %s. "
145 % (v, self.name, appid) +
146 "Possible values: %s" % (", ".join(self.matching)))
148 def check(self, value, appid):
149 if type(value) is not str or not value:
151 if self.sep is not None:
152 values = value.split(self.sep)
155 if type(self.matching) is list:
156 self._assert_list(values, appid)
158 self._assert_regex(values, appid)
161 # Generic value types
163 FieldValidator("Integer",
164 r'^[1-9][0-9]*$', None,
168 FieldValidator("Hexadecimal",
169 r'^[0-9a-f]+$', None,
173 FieldValidator("HTTP link",
174 r'^http[s]?://', None,
175 ["Web Site", "Source Code", "Issue Tracker", "Changelog", "Donate"], []),
177 FieldValidator("Bitcoin address",
178 r'^[a-zA-Z0-9]{27,34}$', None,
182 FieldValidator("Litecoin address",
183 r'^L[a-zA-Z0-9]{33}$', None,
187 FieldValidator("Dogecoin address",
188 r'^D[a-zA-Z0-9]{33}$', None,
192 FieldValidator("Boolean",
197 FieldValidator("bool",
200 ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
203 FieldValidator("Repo Type",
204 ['git', 'git-svn', 'svn', 'hg', 'bzr', 'srclib'], None,
208 FieldValidator("Binaries",
209 r'^http[s]?://', None,
213 FieldValidator("Archive Policy",
214 r'^[0-9]+ versions$', None,
218 FieldValidator("Anti-Feature",
219 ["Ads", "Tracking", "NonFreeNet", "NonFreeDep", "NonFreeAdd", "UpstreamNonFree"], ',',
223 FieldValidator("Auto Update Mode",
224 r"^(Version .+|None)$", None,
225 ["Auto Update Mode"],
228 FieldValidator("Update Check Mode",
229 r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$", None,
230 ["Update Check Mode"],
235 # Check an app's metadata information for integrity errors
236 def check_metadata(info):
238 for field in v.fields:
239 v.check(info[field], info['id'])
240 for build in info['builds']:
242 v.check(build[attr], info['id'])
245 # Formatter for descriptions. Create an instance, and call parseline() with
246 # each line of the description source from the metadata. At the end, call
247 # end() and then text_wiki and text_html will contain the result.
248 class DescriptionFormatter:
260 def __init__(self, linkres):
261 self.linkResolver = linkres
263 def endcur(self, notstates=None):
264 if notstates and self.state in notstates:
266 if self.state == self.stPARA:
268 elif self.state == self.stUL:
270 elif self.state == self.stOL:
274 self.text_html += '</p>'
275 self.state = self.stNONE
278 self.text_html += '</ul>'
279 self.state = self.stNONE
282 self.text_html += '</ol>'
283 self.state = self.stNONE
285 def formatted(self, txt, html):
288 txt = cgi.escape(txt)
290 index = txt.find("''")
292 return formatted + txt
293 formatted += txt[:index]
295 if txt.startswith("'''"):
301 self.bold = not self.bold
309 self.ital = not self.ital
312 def linkify(self, txt):
316 index = txt.find("[")
318 return (linkified_plain + self.formatted(txt, False), linkified_html + self.formatted(txt, True))
319 linkified_plain += self.formatted(txt[:index], False)
320 linkified_html += self.formatted(txt[:index], True)
322 if txt.startswith("[["):
323 index = txt.find("]]")
325 raise MetaDataException("Unterminated ]]")
327 if self.linkResolver:
328 url, urltext = self.linkResolver(url)
331 linkified_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
332 linkified_plain += urltext
333 txt = txt[index + 2:]
335 index = txt.find("]")
337 raise MetaDataException("Unterminated ]")
339 index2 = url.find(' ')
343 urltxt = url[index2 + 1:]
346 raise MetaDataException("Url title is just the URL - use [url]")
347 linkified_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
348 linkified_plain += urltxt
350 linkified_plain += ' (' + url + ')'
351 txt = txt[index + 1:]
353 def addtext(self, txt):
354 p, h = self.linkify(txt)
357 def parseline(self, line):
358 self.text_wiki += "%s\n" % line
361 elif line.startswith('* '):
362 self.endcur([self.stUL])
363 if self.state != self.stUL:
364 self.text_html += '<ul>'
365 self.state = self.stUL
366 self.text_html += '<li>'
367 self.addtext(line[1:])
368 self.text_html += '</li>'
369 elif line.startswith('# '):
370 self.endcur([self.stOL])
371 if self.state != self.stOL:
372 self.text_html += '<ol>'
373 self.state = self.stOL
374 self.text_html += '<li>'
375 self.addtext(line[1:])
376 self.text_html += '</li>'
378 self.endcur([self.stPARA])
379 if self.state == self.stNONE:
380 self.text_html += '<p>'
381 self.state = self.stPARA
382 elif self.state == self.stPARA:
383 self.text_html += ' '
390 # Parse multiple lines of description as written in a metadata file, returning
391 # a single string in wiki format. Used for the Maintainer Notes field as well,
392 # because it's the same format.
393 def description_wiki(lines):
394 ps = DescriptionFormatter(None)
401 # Parse multiple lines of description as written in a metadata file, returning
402 # a single string in HTML format.
403 def description_html(lines, linkres):
404 ps = DescriptionFormatter(linkres)
411 def parse_srclib(metafile):
414 if metafile and not isinstance(metafile, file):
415 metafile = open(metafile, "r")
417 # Defaults for fields that come from metadata
418 thisinfo['Repo Type'] = ''
419 thisinfo['Repo'] = ''
420 thisinfo['Subdir'] = None
421 thisinfo['Prepare'] = None
427 for line in metafile:
429 line = line.rstrip('\r\n')
430 if not line or line.startswith("#"):
434 field, value = line.split(':', 1)
436 raise MetaDataException("Invalid metadata in %s:%d" % (line, n))
438 if field == "Subdir":
439 thisinfo[field] = value.split(',')
441 thisinfo[field] = value
447 """Read all srclib metadata.
449 The information read will be accessible as metadata.srclibs, which is a
450 dictionary, keyed on srclib name, with the values each being a dictionary
451 in the same format as that returned by the parse_srclib function.
453 A MetaDataException is raised if there are any problems with the srclib
458 # They were already loaded
459 if srclibs is not None:
465 if not os.path.exists(srcdir):
468 for metafile in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
469 srclibname = os.path.basename(metafile[:-4])
470 srclibs[srclibname] = parse_srclib(metafile)
473 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
474 # returned by the parse_txt_metadata function.
475 def read_metadata(xref=True):
477 # Always read the srclibs before the apps, since they can use a srlib as
478 # their source repository.
483 for basedir in ('metadata', 'tmp'):
484 if not os.path.exists(basedir):
487 for metafile in sorted(glob.glob(os.path.join('metadata', '*.txt'))):
488 appid, appinfo = parse_txt_metadata(metafile)
489 check_metadata(appinfo)
490 apps[appid] = appinfo
492 for metafile in sorted(glob.glob(os.path.join('metadata', '*.json'))):
493 appid, appinfo = parse_json_metadata(metafile)
494 check_metadata(appinfo)
495 apps[appid] = appinfo
498 # Parse all descriptions at load time, just to ensure cross-referencing
499 # errors are caught early rather than when they hit the build server.
502 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
503 raise MetaDataException("Cannot resolve app id " + appid)
505 for appid, app in apps.iteritems():
507 description_html(app['Description'], linkres)
508 except MetaDataException, e:
509 raise MetaDataException("Problem with description of " + appid +
515 # Get the type expected for a given metadata field.
516 def metafieldtype(name):
517 if name in ['Description', 'Maintainer Notes']:
519 if name in ['Categories']:
521 if name == 'Build Version':
525 if name == 'Use Built':
527 if name not in app_defaults:
533 if name in ['extlibs', 'srclibs', 'patch', 'rm', 'buildjni', 'preassemble',
534 'update', 'scanignore', 'scandelete', 'gradle', 'antcommands',
537 if name in ['init', 'prebuild', 'build']:
539 if name in ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
545 def fill_build_defaults(build):
547 def get_build_type():
548 for t in ['maven', 'gradle', 'kivy']:
555 for flag, value in flag_defaults.iteritems():
559 build['type'] = get_build_type()
560 build['ndk_path'] = common.get_ndk_path(build['ndk'])
563 def split_list_values(s):
564 # Port legacy ';' separators
565 l = [v.strip() for v in s.replace(';', ',').split(',')]
566 return [v for v in l if v]
569 def get_default_app_info_list():
571 thisinfo.update(app_defaults)
573 # General defaults...
574 thisinfo['builds'] = []
575 thisinfo['comments'] = []
580 # Parse metadata for a single application.
582 # 'metafile' - the filename to read. The package id for the application comes
583 # from this filename. Pass None to get a blank entry.
585 # Returns a dictionary containing all the details of the application. There are
586 # two major kinds of information in the dictionary. Keys beginning with capital
587 # letters correspond directory to identically named keys in the metadata file.
588 # Keys beginning with lower case letters are generated in one way or another,
589 # and are not found verbatim in the metadata.
591 # Known keys not originating from the metadata are:
593 # 'builds' - a list of dictionaries containing build information
594 # for each defined build
595 # 'comments' - a list of comments from the metadata file. Each is
596 # a tuple of the form (field, comment) where field is
597 # the name of the field it preceded in the metadata
598 # file. Where field is None, the comment goes at the
599 # end of the file. Alternatively, 'build:version' is
600 # for a comment before a particular build version.
601 # 'descriptionlines' - original lines of description as formatted in the
606 def parse_json_metadata(metafile):
608 appid = os.path.basename(metafile)[0:-5] # strip path and .json
609 thisinfo = get_default_app_info_list()
610 thisinfo['id'] = appid
612 # fdroid metadata is only strings and booleans, no floats or ints. And
613 # json returns unicode, and fdroidserver still uses plain python strings
614 jsoninfo = json.load(open(metafile, 'r'),
615 parse_int=lambda s: s,
616 parse_float=lambda s: s)
617 supported_metadata = app_defaults.keys() + ['builds', 'comments']
618 for k, v in jsoninfo.iteritems():
619 if k == 'Requires Root':
620 if isinstance(v, basestring):
621 if re.match('^\s*(yes|true).*', v, flags=re.IGNORECASE):
623 elif re.match('^\s*(no|false).*', v, flags=re.IGNORECASE):
625 if isinstance(v, bool):
630 if k not in supported_metadata:
631 logging.warn(metafile + ' contains unknown metadata key, ignoring: ' + k)
632 thisinfo.update(jsoninfo)
634 for build in thisinfo['builds']:
635 for k, v in build.iteritems():
636 if k in ('buildjni', 'gradle', 'maven', 'kivy'):
637 # convert standard types to mixed datatype legacy format
638 if isinstance(v, bool):
643 elif k == 'versionCode':
645 del build['versionCode']
646 elif k == 'versionName':
648 del build['versionName']
650 # TODO create schema using https://pypi.python.org/pypi/jsonschema
651 return (appid, thisinfo)
654 def parse_txt_metadata(metafile):
659 def add_buildflag(p, thisbuild):
661 raise MetaDataException("Empty build flag at {1}"
662 .format(buildlines[0], linedesc))
665 raise MetaDataException("Invalid build flag at {0} in {1}"
666 .format(buildlines[0], linedesc))
669 raise MetaDataException("Duplicate definition on {0} in version {1} of {2}"
670 .format(pk, thisbuild['version'], linedesc))
673 if pk not in flag_defaults:
674 raise MetaDataException("Unrecognised build flag at {0} in {1}"
675 .format(p, linedesc))
678 pv = split_list_values(pv)
680 if len(pv) == 1 and pv[0] in ['main', 'yes']:
683 elif t == 'string' or t == 'script':
690 logging.debug("...ignoring bool flag %s" % p)
693 raise MetaDataException("Unrecognised build flag type '%s' at %s in %s"
696 def parse_buildline(lines):
697 value = "".join(lines)
698 parts = [p.replace("\\,", ",")
699 for p in re.split(r"(?<!\\),", value)]
701 raise MetaDataException("Invalid build format: " + value + " in " + metafile.name)
703 thisbuild['origlines'] = lines
704 thisbuild['version'] = parts[0]
705 thisbuild['vercode'] = parts[1]
706 if parts[2].startswith('!'):
707 # For backwards compatibility, handle old-style disabling,
708 # including attempting to extract the commit from the message
709 thisbuild['disable'] = parts[2][1:]
710 commit = 'unknown - see disabled'
711 index = parts[2].rfind('at ')
713 commit = parts[2][index + 3:]
714 if commit.endswith(')'):
716 thisbuild['commit'] = commit
718 thisbuild['commit'] = parts[2]
720 add_buildflag(p, thisbuild)
724 def add_comments(key):
727 for comment in curcomments:
728 thisinfo['comments'].append((key, comment))
731 thisinfo = get_default_app_info_list()
733 if not isinstance(metafile, file):
734 metafile = open(metafile, "r")
735 appid = metafile.name[9:-4]
736 thisinfo['id'] = appid
738 return appid, thisinfo
747 for line in metafile:
749 linedesc = "%s:%d" % (metafile.name, c)
750 line = line.rstrip('\r\n')
752 if not any(line.startswith(s) for s in (' ', '\t')):
753 commit = curbuild['commit'] if 'commit' in curbuild else None
754 if not commit and 'disable' not in curbuild:
755 raise MetaDataException("No commit specified for {0} in {1}"
756 .format(curbuild['version'], linedesc))
758 thisinfo['builds'].append(curbuild)
759 add_comments('build:' + curbuild['vercode'])
762 if line.endswith('\\'):
763 buildlines.append(line[:-1].lstrip())
765 buildlines.append(line.lstrip())
766 bl = ''.join(buildlines)
767 add_buildflag(bl, curbuild)
773 if line.startswith("#"):
774 curcomments.append(line)
777 field, value = line.split(':', 1)
779 raise MetaDataException("Invalid metadata in " + linedesc)
780 if field != field.strip() or value != value.strip():
781 raise MetaDataException("Extra spacing found in " + linedesc)
783 # Translate obsolete fields...
784 if field == 'Market Version':
785 field = 'Current Version'
786 if field == 'Market Version Code':
787 field = 'Current Version Code'
789 fieldtype = metafieldtype(field)
790 if fieldtype not in ['build', 'buildv2']:
792 if fieldtype == 'multiline':
796 raise MetaDataException("Unexpected text on same line as " + field + " in " + linedesc)
797 elif fieldtype == 'string':
798 thisinfo[field] = value
799 elif fieldtype == 'list':
800 thisinfo[field] = split_list_values(value)
801 elif fieldtype == 'build':
802 if value.endswith("\\"):
804 buildlines = [value[:-1]]
806 curbuild = parse_buildline([value])
807 thisinfo['builds'].append(curbuild)
808 add_comments('build:' + thisinfo['builds'][-1]['vercode'])
809 elif fieldtype == 'buildv2':
811 vv = value.split(',')
813 raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
814 .format(value, linedesc))
815 curbuild['version'] = vv[0]
816 curbuild['vercode'] = vv[1]
817 if curbuild['vercode'] in vc_seen:
818 raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
819 curbuild['vercode'], linedesc))
820 vc_seen[curbuild['vercode']] = True
823 elif fieldtype == 'obsolete':
824 pass # Just throw it away!
826 raise MetaDataException("Unrecognised field type for " + field + " in " + linedesc)
827 elif mode == 1: # Multiline field
831 thisinfo[field].append(line)
832 elif mode == 2: # Line continuation mode in Build Version
833 if line.endswith("\\"):
834 buildlines.append(line[:-1])
836 buildlines.append(line)
837 curbuild = parse_buildline(buildlines)
838 thisinfo['builds'].append(curbuild)
839 add_comments('build:' + thisinfo['builds'][-1]['vercode'])
843 # Mode at end of file should always be 0...
845 raise MetaDataException(field + " not terminated in " + metafile.name)
847 raise MetaDataException("Unterminated continuation in " + metafile.name)
849 raise MetaDataException("Unterminated build in " + metafile.name)
851 if not thisinfo['Description']:
852 thisinfo['Description'].append('No description available')
854 for build in thisinfo['builds']:
855 fill_build_defaults(build)
857 thisinfo['builds'] = sorted(thisinfo['builds'], key=lambda build: int(build['vercode']))
859 return (appid, thisinfo)
862 # Write a metadata file.
864 # 'dest' - The path to the output file
865 # 'app' - The app data
866 def write_metadata(dest, app):
868 def writecomments(key):
870 for pf, comment in app['comments']:
872 mf.write("%s\n" % comment)
875 logging.debug("...writing comments for " + (key or 'EOF'))
877 def writefield(field, value=None):
881 t = metafieldtype(field)
883 value = ','.join(value)
884 mf.write("%s:%s\n" % (field, value))
886 def writefield_nonempty(field, value=None):
890 writefield(field, value)
893 writefield_nonempty('Disabled')
894 writefield_nonempty('AntiFeatures')
895 writefield_nonempty('Provides')
896 writefield('Categories')
897 writefield('License')
898 writefield('Web Site')
899 writefield('Source Code')
900 writefield('Issue Tracker')
901 writefield_nonempty('Changelog')
902 writefield_nonempty('Donate')
903 writefield_nonempty('FlattrID')
904 writefield_nonempty('Bitcoin')
905 writefield_nonempty('Litecoin')
906 writefield_nonempty('Dogecoin')
908 writefield_nonempty('Name')
909 writefield_nonempty('Auto Name')
910 writefield('Summary')
911 writefield('Description', '')
912 for line in app['Description']:
913 mf.write("%s\n" % line)
916 if app['Requires Root']:
917 writefield('Requires Root', 'Yes')
920 writefield('Repo Type')
923 writefield('Binaries')
925 for build in app['builds']:
927 if build['version'] == "Ignore":
930 writecomments('build:' + build['vercode'])
931 mf.write("Build:%s,%s\n" % (build['version'], build['vercode']))
933 def write_builditem(key, value):
935 if key in ['version', 'vercode']:
938 if value == flag_defaults[key]:
943 logging.debug("...writing {0} : {1}".format(key, value))
944 outline = ' %s=' % key
951 outline += '&& \\\n '.join([s.lstrip() for s in value.split('&& ')])
953 outline += ','.join(value) if type(value) == list else value
958 for flag in flag_defaults:
961 write_builditem(flag, value)
964 if app['Maintainer Notes']:
965 writefield('Maintainer Notes', '')
966 for line in app['Maintainer Notes']:
967 mf.write("%s\n" % line)
971 writefield_nonempty('Archive Policy')
972 writefield('Auto Update Mode')
973 writefield('Update Check Mode')
974 writefield_nonempty('Update Check Ignore')
975 writefield_nonempty('Vercode Operation')
976 writefield_nonempty('Update Check Name')
977 writefield_nonempty('Update Check Data')
978 if app['Current Version']:
979 writefield('Current Version')
980 writefield('Current Version Code')
982 if app['No Source Since']:
983 writefield('No Source Since')