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
31 class MetaDataException(Exception):
32 def __init__(self, value):
38 # In the order in which they are laid out on files
39 app_defaults = OrderedDict([
41 ('AntiFeatures', None),
43 ('Categories', ['None']),
44 ('License', 'Unknown'),
47 ('Issue Tracker', ''),
57 ('Requires Root', False),
60 ('Maintainer Notes', []),
61 ('Archive Policy', None),
62 ('Auto Update Mode', 'None'),
63 ('Update Check Mode', 'None'),
64 ('Update Check Ignore', None),
65 ('Vercode Operation', None),
66 ('Update Check Name', None),
67 ('Update Check Data', None),
68 ('Current Version', ''),
69 ('Current Version Code', '0'),
70 ('No Source Since', ''),
74 # In the order in which they are laid out on files
75 # Sorted by their action and their place in the build timeline
76 flag_defaults = OrderedDict([
80 ('submodules', False),
90 ('forceversion', False),
91 ('forcevercode', False),
102 ('antcommand', None),
107 # Designates a metadata field type and checks that it matches
109 # 'name' - The long name of the field type
110 # 'matching' - List of possible values or regex expression
111 # 'sep' - Separator to use if value may be a list
112 # 'fields' - Metadata fields (Field:Value) of this type
113 # 'attrs' - Build attributes (attr=value) of this type
115 class FieldValidator():
117 def __init__(self, name, matching, sep, fields, attrs):
119 self.matching = matching
120 if type(matching) is str:
121 self.compiled = re.compile(matching)
126 def _assert_regex(self, values, appid):
128 if not self.compiled.match(v):
129 raise MetaDataException("'%s' is not a valid %s in %s. "
130 % (v, self.name, appid) +
131 "Regex pattern: %s" % (self.matching))
133 def _assert_list(self, values, appid):
135 if v not in self.matching:
136 raise MetaDataException("'%s' is not a valid %s in %s. "
137 % (v, self.name, appid) +
138 "Possible values: %s" % (", ".join(self.matching)))
140 def check(self, value, appid):
141 if type(value) is not str or not value:
143 if self.sep is not None:
144 values = value.split(self.sep)
147 if type(self.matching) is list:
148 self._assert_list(values, appid)
150 self._assert_regex(values, appid)
153 # Generic value types
155 FieldValidator("Integer",
156 r'^[1-9][0-9]*$', None,
160 FieldValidator("HTTP link",
161 r'^http[s]?://', None,
162 ["Web Site", "Source Code", "Issue Tracker", "Donate"], []),
164 FieldValidator("Bitcoin address",
165 r'^[a-zA-Z0-9]{27,34}$', None,
169 FieldValidator("Litecoin address",
170 r'^L[a-zA-Z0-9]{33}$', None,
174 FieldValidator("Dogecoin address",
175 r'^D[a-zA-Z0-9]{33}$', None,
179 FieldValidator("Boolean",
184 FieldValidator("bool",
187 ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
190 FieldValidator("Repo Type",
191 ['git', 'git-svn', 'svn', 'hg', 'bzr', 'srclib'], None,
195 FieldValidator("Archive Policy",
196 r'^[0-9]+ versions$', None,
200 FieldValidator("Anti-Feature",
201 ["Ads", "Tracking", "NonFreeNet", "NonFreeDep", "NonFreeAdd", "UpstreamNonFree"], ',',
205 FieldValidator("Auto Update Mode",
206 r"^(Version .+|None)$", None,
207 ["Auto Update Mode"],
210 FieldValidator("Update Check Mode",
211 r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$", None,
212 ["Update Check Mode"],
217 # Check an app's metadata information for integrity errors
218 def check_metadata(info):
220 for field in v.fields:
221 v.check(info[field], info['id'])
222 for build in info['builds']:
224 v.check(build[attr], info['id'])
227 # Formatter for descriptions. Create an instance, and call parseline() with
228 # each line of the description source from the metadata. At the end, call
229 # end() and then text_plain, text_wiki and text_html will contain the result.
230 class DescriptionFormatter:
243 def __init__(self, linkres):
244 self.linkResolver = linkres
246 def endcur(self, notstates=None):
247 if notstates and self.state in notstates:
249 if self.state == self.stPARA:
251 elif self.state == self.stUL:
253 elif self.state == self.stOL:
257 self.text_plain += '\n'
258 self.text_html += '</p>'
259 self.state = self.stNONE
262 self.text_html += '</ul>'
263 self.state = self.stNONE
266 self.text_html += '</ol>'
267 self.state = self.stNONE
269 def formatted(self, txt, html):
272 txt = cgi.escape(txt)
274 index = txt.find("''")
276 return formatted + txt
277 formatted += txt[:index]
279 if txt.startswith("'''"):
285 self.bold = not self.bold
293 self.ital = not self.ital
296 def linkify(self, txt):
300 index = txt.find("[")
302 return (linkified_plain + self.formatted(txt, False), linkified_html + self.formatted(txt, True))
303 linkified_plain += self.formatted(txt[:index], False)
304 linkified_html += self.formatted(txt[:index], True)
306 if txt.startswith("[["):
307 index = txt.find("]]")
309 raise MetaDataException("Unterminated ]]")
311 if self.linkResolver:
312 url, urltext = self.linkResolver(url)
315 linkified_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
316 linkified_plain += urltext
317 txt = txt[index + 2:]
319 index = txt.find("]")
321 raise MetaDataException("Unterminated ]")
323 index2 = url.find(' ')
327 urltxt = url[index2 + 1:]
329 linkified_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
330 linkified_plain += urltxt
332 linkified_plain += ' (' + url + ')'
333 txt = txt[index + 1:]
335 def addtext(self, txt):
336 p, h = self.linkify(txt)
340 def parseline(self, line):
341 self.text_wiki += "%s\n" % line
344 elif line.startswith('* '):
345 self.endcur([self.stUL])
346 if self.state != self.stUL:
347 self.text_html += '<ul>'
348 self.state = self.stUL
349 self.text_html += '<li>'
350 self.text_plain += '* '
351 self.addtext(line[1:])
352 self.text_html += '</li>'
353 elif line.startswith('# '):
354 self.endcur([self.stOL])
355 if self.state != self.stOL:
356 self.text_html += '<ol>'
357 self.state = self.stOL
358 self.text_html += '<li>'
359 self.text_plain += '* ' # TODO: lazy - put the numbers in!
360 self.addtext(line[1:])
361 self.text_html += '</li>'
363 self.endcur([self.stPARA])
364 if self.state == self.stNONE:
365 self.text_html += '<p>'
366 self.state = self.stPARA
367 elif self.state == self.stPARA:
368 self.text_html += ' '
369 self.text_plain += ' '
376 # Parse multiple lines of description as written in a metadata file, returning
377 # a single string in plain text format.
378 def description_plain(lines, linkres):
379 ps = DescriptionFormatter(linkres)
386 # Parse multiple lines of description as written in a metadata file, returning
387 # a single string in wiki format. Used for the Maintainer Notes field as well,
388 # because it's the same format.
389 def description_wiki(lines):
390 ps = DescriptionFormatter(None)
397 # Parse multiple lines of description as written in a metadata file, returning
398 # a single string in HTML format.
399 def description_html(lines, linkres):
400 ps = DescriptionFormatter(linkres)
407 def parse_srclib(metafile):
410 if metafile and not isinstance(metafile, file):
411 metafile = open(metafile, "r")
413 # Defaults for fields that come from metadata
414 thisinfo['Repo Type'] = ''
415 thisinfo['Repo'] = ''
416 thisinfo['Subdir'] = None
417 thisinfo['Prepare'] = None
418 thisinfo['Srclibs'] = None
424 for line in metafile:
426 line = line.rstrip('\r\n')
427 if not line or line.startswith("#"):
431 field, value = line.split(':', 1)
433 raise MetaDataException("Invalid metadata in %s:%d" % (line, n))
435 if field == "Subdir":
436 thisinfo[field] = value.split(',')
438 thisinfo[field] = value
444 """Read all srclib metadata.
446 The information read will be accessible as metadata.srclibs, which is a
447 dictionary, keyed on srclib name, with the values each being a dictionary
448 in the same format as that returned by the parse_srclib function.
450 A MetaDataException is raised if there are any problems with the srclib
455 # They were already loaded
456 if srclibs is not None:
462 if not os.path.exists(srcdir):
465 for metafile in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
466 srclibname = os.path.basename(metafile[:-4])
467 srclibs[srclibname] = parse_srclib(metafile)
470 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
471 # returned by the parse_metadata function.
472 def read_metadata(xref=True):
474 # Always read the srclibs before the apps, since they can use a srlib as
475 # their source repository.
480 for basedir in ('metadata', 'tmp'):
481 if not os.path.exists(basedir):
484 for metafile in sorted(glob.glob(os.path.join('metadata', '*.txt'))):
485 appinfo = parse_metadata(metafile)
486 check_metadata(appinfo)
490 # Parse all descriptions at load time, just to ensure cross-referencing
491 # errors are caught early rather than when they hit the build server.
494 if app['id'] == link:
495 return ("fdroid.app:" + link, "Dummy name - don't know yet")
496 raise MetaDataException("Cannot resolve app id " + link)
499 description_html(app['Description'], linkres)
500 except MetaDataException, e:
501 raise MetaDataException("Problem with description of " + app['id'] +
507 # Get the type expected for a given metadata field.
508 def metafieldtype(name):
509 if name in ['Description', 'Maintainer Notes']:
511 if name in ['Categories']:
513 if name == 'Build Version':
517 if name == 'Use Built':
519 if name not in app_defaults:
525 if name in ['extlibs', 'srclibs', 'patch', 'rm', 'buildjni',
526 'update', 'scanignore', 'scandelete']:
528 if name in ['init', 'prebuild', 'build']:
530 if name in ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
536 def fill_build_defaults(build):
538 def get_build_type():
539 for t in ['maven', 'gradle', 'kivy']:
546 for flag, value in flag_defaults.iteritems():
550 build['type'] = get_build_type()
553 # Parse metadata for a single application.
555 # 'metafile' - the filename to read. The package id for the application comes
556 # from this filename. Pass None to get a blank entry.
558 # Returns a dictionary containing all the details of the application. There are
559 # two major kinds of information in the dictionary. Keys beginning with capital
560 # letters correspond directory to identically named keys in the metadata file.
561 # Keys beginning with lower case letters are generated in one way or another,
562 # and are not found verbatim in the metadata.
564 # Known keys not originating from the metadata are:
566 # 'id' - the application's package ID
567 # 'builds' - a list of dictionaries containing build information
568 # for each defined build
569 # 'comments' - a list of comments from the metadata file. Each is
570 # a tuple of the form (field, comment) where field is
571 # the name of the field it preceded in the metadata
572 # file. Where field is None, the comment goes at the
573 # end of the file. Alternatively, 'build:version' is
574 # for a comment before a particular build version.
575 # 'descriptionlines' - original lines of description as formatted in the
578 def parse_metadata(metafile):
582 def add_buildflag(p, thisbuild):
585 raise MetaDataException("Invalid build flag at {0} in {1}"
586 .format(buildlines[0], linedesc))
589 raise MetaDataException("Duplicate definition on {0} in version {1} of {2}"
590 .format(pk, thisbuild['version'], linedesc))
593 if pk not in flag_defaults:
594 raise MetaDataException("Unrecognised build flag at {0} in {1}"
595 .format(p, linedesc))
598 # Port legacy ';' separators
599 thisbuild[pk] = [v.strip() for v in pv.replace(';', ',').split(',')]
600 elif t == 'string' or t == 'script':
607 logging.debug("...ignoring bool flag %s" % p)
610 raise MetaDataException("Unrecognised build flag type '%s' at %s in %s"
613 def parse_buildline(lines):
614 value = "".join(lines)
615 parts = [p.replace("\\,", ",")
616 for p in re.split(r"(?<!\\),", value)]
618 raise MetaDataException("Invalid build format: " + value + " in " + metafile.name)
620 thisbuild['origlines'] = lines
621 thisbuild['version'] = parts[0]
622 thisbuild['vercode'] = parts[1]
623 if parts[2].startswith('!'):
624 # For backwards compatibility, handle old-style disabling,
625 # including attempting to extract the commit from the message
626 thisbuild['disable'] = parts[2][1:]
627 commit = 'unknown - see disabled'
628 index = parts[2].rfind('at ')
630 commit = parts[2][index + 3:]
631 if commit.endswith(')'):
633 thisbuild['commit'] = commit
635 thisbuild['commit'] = parts[2]
637 add_buildflag(p, thisbuild)
641 def add_comments(key):
644 for comment in curcomments:
645 thisinfo['comments'].append((key, comment))
650 if not isinstance(metafile, file):
651 metafile = open(metafile, "r")
652 thisinfo['id'] = metafile.name[9:-4]
654 thisinfo['id'] = None
656 thisinfo.update(app_defaults)
658 # General defaults...
659 thisinfo['builds'] = []
660 thisinfo['comments'] = []
672 for line in metafile:
674 linedesc = "%s:%d" % (metafile.name, c)
675 line = line.rstrip('\r\n')
677 if not any(line.startswith(s) for s in (' ', '\t')):
678 if 'commit' not in curbuild and 'disable' not in curbuild:
679 raise MetaDataException("No commit specified for {0} in {1}"
680 .format(curbuild['version'], linedesc))
682 thisinfo['builds'].append(curbuild)
683 add_comments('build:' + curbuild['vercode'])
686 if line.endswith('\\'):
687 buildlines.append(line[:-1].lstrip())
689 buildlines.append(line.lstrip())
690 bl = ''.join(buildlines)
691 add_buildflag(bl, curbuild)
697 if line.startswith("#"):
698 curcomments.append(line)
701 field, value = line.split(':', 1)
703 raise MetaDataException("Invalid metadata in " + linedesc)
704 if field != field.strip() or value != value.strip():
705 raise MetaDataException("Extra spacing found in " + linedesc)
707 # Translate obsolete fields...
708 if field == 'Market Version':
709 field = 'Current Version'
710 if field == 'Market Version Code':
711 field = 'Current Version Code'
713 fieldtype = metafieldtype(field)
714 if fieldtype not in ['build', 'buildv2']:
716 if fieldtype == 'multiline':
720 raise MetaDataException("Unexpected text on same line as " + field + " in " + linedesc)
721 elif fieldtype == 'string':
722 thisinfo[field] = value
723 elif fieldtype == 'list':
724 thisinfo[field] = [v.strip() for v in value.replace(';', ',').split(',')]
725 elif fieldtype == 'build':
726 if value.endswith("\\"):
728 buildlines = [value[:-1]]
730 curbuild = parse_buildline([value])
731 thisinfo['builds'].append(curbuild)
732 add_comments('build:' + thisinfo['builds'][-1]['vercode'])
733 elif fieldtype == 'buildv2':
735 vv = value.split(',')
737 raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
738 .format(value, linedesc))
739 curbuild['version'] = vv[0]
740 curbuild['vercode'] = vv[1]
741 if curbuild['vercode'] in vc_seen:
742 raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
743 curbuild['vercode'], linedesc))
744 vc_seen[curbuild['vercode']] = True
747 elif fieldtype == 'obsolete':
748 pass # Just throw it away!
750 raise MetaDataException("Unrecognised field type for " + field + " in " + linedesc)
751 elif mode == 1: # Multiline field
755 thisinfo[field].append(line)
756 elif mode == 2: # Line continuation mode in Build Version
757 if line.endswith("\\"):
758 buildlines.append(line[:-1])
760 buildlines.append(line)
761 curbuild = parse_buildline(buildlines)
762 thisinfo['builds'].append(curbuild)
763 add_comments('build:' + thisinfo['builds'][-1]['vercode'])
767 # Mode at end of file should always be 0...
769 raise MetaDataException(field + " not terminated in " + metafile.name)
771 raise MetaDataException("Unterminated continuation in " + metafile.name)
773 raise MetaDataException("Unterminated build in " + metafile.name)
775 if not thisinfo['Description']:
776 thisinfo['Description'].append('No description available')
778 for build in thisinfo['builds']:
779 fill_build_defaults(build)
784 # Write a metadata file.
786 # 'dest' - The path to the output file
787 # 'app' - The app data
788 def write_metadata(dest, app):
790 def writecomments(key):
792 for pf, comment in app['comments']:
794 mf.write("%s\n" % comment)
797 logging.debug("...writing comments for " + (key or 'EOF'))
799 def writefield(field, value=None):
803 t = metafieldtype(field)
805 value = ','.join(value)
806 mf.write("%s:%s\n" % (field, value))
810 writefield('Disabled')
811 if app['AntiFeatures']:
812 writefield('AntiFeatures')
814 writefield('Provides')
815 writefield('Categories')
816 writefield('License')
817 writefield('Web Site')
818 writefield('Source Code')
819 writefield('Issue Tracker')
823 writefield('FlattrID')
825 writefield('Bitcoin')
827 writefield('Litecoin')
829 writefield('Dogecoin')
834 writefield('Auto Name')
835 writefield('Summary')
836 writefield('Description', '')
837 for line in app['Description']:
838 mf.write("%s\n" % line)
841 if app['Requires Root']:
842 writefield('Requires Root', 'Yes')
845 writefield('Repo Type')
848 for build in app['builds']:
850 if build['version'] == "Ignore":
853 writecomments('build:' + build['vercode'])
854 mf.write("Build:%s,%s\n" % (build['version'], build['vercode']))
856 def write_builditem(key, value):
858 if key in ['version', 'vercode']:
861 if value == flag_defaults[key]:
866 logging.debug("...writing {0} : {1}".format(key, value))
867 outline = ' %s=' % key
874 outline += '&& \\\n '.join([s.lstrip() for s in value.split('&& ')])
876 outline += ','.join(value) if type(value) == list else value
881 for flag in flag_defaults:
884 write_builditem(flag, value)
887 if app['Maintainer Notes']:
888 writefield('Maintainer Notes', '')
889 for line in app['Maintainer Notes']:
890 mf.write("%s\n" % line)
894 if app['Archive Policy']:
895 writefield('Archive Policy')
896 writefield('Auto Update Mode')
897 writefield('Update Check Mode')
898 if app['Update Check Ignore']:
899 writefield('Update Check Ignore')
900 if app['Vercode Operation']:
901 writefield('Vercode Operation')
902 if app['Update Check Name']:
903 writefield('Update Check Name')
904 if app['Update Check Data']:
905 writefield('Update Check Data')
906 if app['Current Version']:
907 writefield('Current Version')
908 writefield('Current Version Code')
910 if app['No Source Since']:
911 writefield('No Source Since')