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
457 if not os.path.exists(srcdir):
460 for metafile in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
461 srclibname = os.path.basename(metafile[:-4])
462 srclibs[srclibname] = parse_srclib(metafile)
465 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
466 # returned by the parse_metadata function.
467 def read_metadata(xref=True):
470 for basedir in ('metadata', 'tmp'):
471 if not os.path.exists(basedir):
474 for metafile in sorted(glob.glob(os.path.join('metadata', '*.txt'))):
475 appinfo = parse_metadata(metafile)
476 check_metadata(appinfo)
480 # Parse all descriptions at load time, just to ensure cross-referencing
481 # errors are caught early rather than when they hit the build server.
484 if app['id'] == link:
485 return ("fdroid.app:" + link, "Dummy name - don't know yet")
486 raise MetaDataException("Cannot resolve app id " + link)
489 description_html(app['Description'], linkres)
491 raise MetaDataException("Problem with description of " + app['id'] +
497 # Get the type expected for a given metadata field.
498 def metafieldtype(name):
499 if name in ['Description', 'Maintainer Notes']:
501 if name in ['Categories']:
503 if name == 'Build Version':
507 if name == 'Use Built':
509 if name not in app_defaults:
515 if name in ['extlibs', 'srclibs', 'patch', 'rm', 'buildjni',
516 'update', 'scanignore', 'scandelete']:
518 if name in ['init', 'prebuild', 'build']:
520 if name in ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
526 # Parse metadata for a single application.
528 # 'metafile' - the filename to read. The package id for the application comes
529 # from this filename. Pass None to get a blank entry.
531 # Returns a dictionary containing all the details of the application. There are
532 # two major kinds of information in the dictionary. Keys beginning with capital
533 # letters correspond directory to identically named keys in the metadata file.
534 # Keys beginning with lower case letters are generated in one way or another,
535 # and are not found verbatim in the metadata.
537 # Known keys not originating from the metadata are:
539 # 'id' - the application's package ID
540 # 'builds' - a list of dictionaries containing build information
541 # for each defined build
542 # 'comments' - a list of comments from the metadata file. Each is
543 # a tuple of the form (field, comment) where field is
544 # the name of the field it preceded in the metadata
545 # file. Where field is None, the comment goes at the
546 # end of the file. Alternatively, 'build:version' is
547 # for a comment before a particular build version.
548 # 'descriptionlines' - original lines of description as formatted in the
551 def parse_metadata(metafile):
555 def add_buildflag(p, thisbuild):
558 raise MetaDataException("Invalid build flag at {0} in {1}"
559 .format(buildlines[0], linedesc))
562 raise MetaDataException("Duplicate definition on {0} in version {1} of {2}"
563 .format(pk, thisbuild['version'], linedesc))
566 if pk not in flag_defaults:
567 raise MetaDataException("Unrecognised build flag at {0} in {1}"
568 .format(p, linedesc))
571 # Port legacy ';' separators
572 thisbuild[pk] = [v.strip() for v in pv.replace(';', ',').split(',')]
573 elif t == 'string' or t == 'script':
580 logging.debug("...ignoring bool flag %s" % p)
583 raise MetaDataException("Unrecognised build flag type '%s' at %s in %s"
586 def parse_buildline(lines):
587 value = "".join(lines)
588 parts = [p.replace("\\,", ",")
589 for p in re.split(r"(?<!\\),", value)]
591 raise MetaDataException("Invalid build format: " + value + " in " + metafile.name)
593 thisbuild['origlines'] = lines
594 thisbuild['version'] = parts[0]
595 thisbuild['vercode'] = parts[1]
596 if parts[2].startswith('!'):
597 # For backwards compatibility, handle old-style disabling,
598 # including attempting to extract the commit from the message
599 thisbuild['disable'] = parts[2][1:]
600 commit = 'unknown - see disabled'
601 index = parts[2].rfind('at ')
603 commit = parts[2][index + 3:]
604 if commit.endswith(')'):
606 thisbuild['commit'] = commit
608 thisbuild['commit'] = parts[2]
610 add_buildflag(p, thisbuild)
614 def add_comments(key):
617 for comment in curcomments:
618 thisinfo['comments'].append((key, comment))
621 def get_build_type(build):
622 for t in ['maven', 'gradle', 'kivy']:
631 if not isinstance(metafile, file):
632 metafile = open(metafile, "r")
633 thisinfo['id'] = metafile.name[9:-4]
635 thisinfo['id'] = None
637 thisinfo.update(app_defaults)
639 # General defaults...
640 thisinfo['builds'] = []
641 thisinfo['comments'] = []
653 for line in metafile:
655 linedesc = "%s:%d" % (metafile.name, c)
656 line = line.rstrip('\r\n')
658 if not any(line.startswith(s) for s in (' ', '\t')):
659 if 'commit' not in curbuild and 'disable' not in curbuild:
660 raise MetaDataException("No commit specified for {0} in {1}"
661 .format(curbuild['version'], linedesc))
663 thisinfo['builds'].append(curbuild)
664 add_comments('build:' + curbuild['vercode'])
667 if line.endswith('\\'):
668 buildlines.append(line[:-1].lstrip())
670 buildlines.append(line.lstrip())
671 bl = ''.join(buildlines)
672 add_buildflag(bl, curbuild)
678 if line.startswith("#"):
679 curcomments.append(line)
682 field, value = line.split(':', 1)
684 raise MetaDataException("Invalid metadata in " + linedesc)
685 if field != field.strip() or value != value.strip():
686 raise MetaDataException("Extra spacing found in " + linedesc)
688 # Translate obsolete fields...
689 if field == 'Market Version':
690 field = 'Current Version'
691 if field == 'Market Version Code':
692 field = 'Current Version Code'
694 fieldtype = metafieldtype(field)
695 if fieldtype not in ['build', 'buildv2']:
697 if fieldtype == 'multiline':
701 raise MetaDataException("Unexpected text on same line as " + field + " in " + linedesc)
702 elif fieldtype == 'string':
703 thisinfo[field] = value
704 elif fieldtype == 'list':
705 thisinfo[field] = [v.strip() for v in value.replace(';', ',').split(',')]
706 elif fieldtype == 'build':
707 if value.endswith("\\"):
709 buildlines = [value[:-1]]
711 curbuild = parse_buildline([value])
712 thisinfo['builds'].append(curbuild)
713 add_comments('build:' + thisinfo['builds'][-1]['vercode'])
714 elif fieldtype == 'buildv2':
716 vv = value.split(',')
718 raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
719 .format(value, linedesc))
720 curbuild['version'] = vv[0]
721 curbuild['vercode'] = vv[1]
722 if curbuild['vercode'] in vc_seen:
723 raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
724 curbuild['vercode'], linedesc))
725 vc_seen[curbuild['vercode']] = True
728 elif fieldtype == 'obsolete':
729 pass # Just throw it away!
731 raise MetaDataException("Unrecognised field type for " + field + " in " + linedesc)
732 elif mode == 1: # Multiline field
736 thisinfo[field].append(line)
737 elif mode == 2: # Line continuation mode in Build Version
738 if line.endswith("\\"):
739 buildlines.append(line[:-1])
741 buildlines.append(line)
742 curbuild = parse_buildline(buildlines)
743 thisinfo['builds'].append(curbuild)
744 add_comments('build:' + thisinfo['builds'][-1]['vercode'])
748 # Mode at end of file should always be 0...
750 raise MetaDataException(field + " not terminated in " + metafile.name)
752 raise MetaDataException("Unterminated continuation in " + metafile.name)
754 raise MetaDataException("Unterminated build in " + metafile.name)
756 if not thisinfo['Description']:
757 thisinfo['Description'].append('No description available')
759 for build in thisinfo['builds']:
760 for flag, value in flag_defaults.iteritems():
764 build['type'] = get_build_type(build)
769 # Write a metadata file.
771 # 'dest' - The path to the output file
772 # 'app' - The app data
773 def write_metadata(dest, app):
775 def writecomments(key):
777 for pf, comment in app['comments']:
779 mf.write("%s\n" % comment)
782 logging.debug("...writing comments for " + (key if key else 'EOF'))
784 def writefield(field, value=None):
788 t = metafieldtype(field)
790 value = ','.join(value)
791 mf.write("%s:%s\n" % (field, value))
795 writefield('Disabled')
796 if app['AntiFeatures']:
797 writefield('AntiFeatures')
799 writefield('Provides')
800 writefield('Categories')
801 writefield('License')
802 writefield('Web Site')
803 writefield('Source Code')
804 writefield('Issue Tracker')
808 writefield('FlattrID')
810 writefield('Bitcoin')
812 writefield('Litecoin')
814 writefield('Dogecoin')
819 writefield('Auto Name')
820 writefield('Summary')
821 writefield('Description', '')
822 for line in app['Description']:
823 mf.write("%s\n" % line)
826 if app['Requires Root']:
827 writefield('Requires Root', 'Yes')
830 writefield('Repo Type')
833 for build in app['builds']:
834 writecomments('build:' + build['vercode'])
835 mf.write("Build:%s,%s\n" % (build['version'], build['vercode']))
837 def write_builditem(key, value):
839 if key in ['version', 'vercode']:
842 if value == flag_defaults[key]:
847 logging.debug("...writing {0} : {1}".format(key, value))
848 outline = ' %s=' % key
855 outline += '&& \\\n '.join([s.lstrip() for s in value.split('&& ')])
857 outline += ','.join(value) if type(value) == list else value
862 for flag in flag_defaults:
865 write_builditem(flag, value)
868 if app['Maintainer Notes']:
869 writefield('Maintainer Notes', '')
870 for line in app['Maintainer Notes']:
871 mf.write("%s\n" % line)
875 if app['Archive Policy']:
876 writefield('Archive Policy')
877 writefield('Auto Update Mode')
878 writefield('Update Check Mode')
879 if app['Update Check Ignore']:
880 writefield('Update Check Ignore')
881 if app['Vercode Operation']:
882 writefield('Vercode Operation')
883 if app['Update Check Name']:
884 writefield('Update Check Name')
885 if app['Update Check Data']:
886 writefield('Update Check Data')
887 if app['Current Version']:
888 writefield('Current Version')
889 writefield('Current Version Code')
891 if app['No Source Since']:
892 writefield('No Source Since')