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:]
342 linkified_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
343 linkified_plain += urltxt
345 linkified_plain += ' (' + url + ')'
346 txt = txt[index + 1:]
348 def addtext(self, txt):
349 p, h = self.linkify(txt)
352 def parseline(self, line):
353 self.text_wiki += "%s\n" % line
356 elif line.startswith('* '):
357 self.endcur([self.stUL])
358 if self.state != self.stUL:
359 self.text_html += '<ul>'
360 self.state = self.stUL
361 self.text_html += '<li>'
362 self.addtext(line[1:])
363 self.text_html += '</li>'
364 elif line.startswith('# '):
365 self.endcur([self.stOL])
366 if self.state != self.stOL:
367 self.text_html += '<ol>'
368 self.state = self.stOL
369 self.text_html += '<li>'
370 self.addtext(line[1:])
371 self.text_html += '</li>'
373 self.endcur([self.stPARA])
374 if self.state == self.stNONE:
375 self.text_html += '<p>'
376 self.state = self.stPARA
377 elif self.state == self.stPARA:
378 self.text_html += ' '
385 # Parse multiple lines of description as written in a metadata file, returning
386 # a single string in wiki format. Used for the Maintainer Notes field as well,
387 # because it's the same format.
388 def description_wiki(lines):
389 ps = DescriptionFormatter(None)
396 # Parse multiple lines of description as written in a metadata file, returning
397 # a single string in HTML format.
398 def description_html(lines, linkres):
399 ps = DescriptionFormatter(linkres)
406 def parse_srclib(metafile):
409 if metafile and not isinstance(metafile, file):
410 metafile = open(metafile, "r")
412 # Defaults for fields that come from metadata
413 thisinfo['Repo Type'] = ''
414 thisinfo['Repo'] = ''
415 thisinfo['Subdir'] = None
416 thisinfo['Prepare'] = None
422 for line in metafile:
424 line = line.rstrip('\r\n')
425 if not line or line.startswith("#"):
429 field, value = line.split(':', 1)
431 raise MetaDataException("Invalid metadata in %s:%d" % (line, n))
433 if field == "Subdir":
434 thisinfo[field] = value.split(',')
436 thisinfo[field] = value
442 """Read all srclib metadata.
444 The information read will be accessible as metadata.srclibs, which is a
445 dictionary, keyed on srclib name, with the values each being a dictionary
446 in the same format as that returned by the parse_srclib function.
448 A MetaDataException is raised if there are any problems with the srclib
453 # They were already loaded
454 if srclibs is not None:
460 if not os.path.exists(srcdir):
463 for metafile in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
464 srclibname = os.path.basename(metafile[:-4])
465 srclibs[srclibname] = parse_srclib(metafile)
468 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
469 # returned by the parse_metadata function.
470 def read_metadata(xref=True):
472 # Always read the srclibs before the apps, since they can use a srlib as
473 # their source repository.
478 for basedir in ('metadata', 'tmp'):
479 if not os.path.exists(basedir):
482 for metafile in sorted(glob.glob(os.path.join('metadata', '*.txt'))):
483 appid, appinfo = parse_metadata(metafile)
484 check_metadata(appinfo)
485 apps[appid] = appinfo
488 # Parse all descriptions at load time, just to ensure cross-referencing
489 # errors are caught early rather than when they hit the build server.
492 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
493 raise MetaDataException("Cannot resolve app id " + appid)
495 for appid, app in apps.iteritems():
497 description_html(app['Description'], linkres)
498 except MetaDataException, e:
499 raise MetaDataException("Problem with description of " + appid +
505 # Get the type expected for a given metadata field.
506 def metafieldtype(name):
507 if name in ['Description', 'Maintainer Notes']:
509 if name in ['Categories']:
511 if name == 'Build Version':
515 if name == 'Use Built':
517 if name not in app_defaults:
523 if name in ['extlibs', 'srclibs', 'patch', 'rm', 'buildjni', 'preassemble',
524 'update', 'scanignore', 'scandelete', 'gradle', 'antcommands']:
526 if name in ['init', 'prebuild', 'build']:
528 if name in ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
534 def fill_build_defaults(build):
536 def get_build_type():
537 for t in ['maven', 'gradle', 'kivy']:
544 for flag, value in flag_defaults.iteritems():
548 build['type'] = get_build_type()
549 build['ndk_path'] = common.get_ndk_path(build['ndk'])
552 def split_list_values(s):
553 # Port legacy ';' separators
554 l = [v.strip() for v in s.replace(';', ',').split(',')]
555 return [v for v in l if v]
558 # Parse metadata for a single application.
560 # 'metafile' - the filename to read. The package id for the application comes
561 # from this filename. Pass None to get a blank entry.
563 # Returns a dictionary containing all the details of the application. There are
564 # two major kinds of information in the dictionary. Keys beginning with capital
565 # letters correspond directory to identically named keys in the metadata file.
566 # Keys beginning with lower case letters are generated in one way or another,
567 # and are not found verbatim in the metadata.
569 # Known keys not originating from the metadata are:
571 # 'builds' - a list of dictionaries containing build information
572 # for each defined build
573 # 'comments' - a list of comments from the metadata file. Each is
574 # a tuple of the form (field, comment) where field is
575 # the name of the field it preceded in the metadata
576 # file. Where field is None, the comment goes at the
577 # end of the file. Alternatively, 'build:version' is
578 # for a comment before a particular build version.
579 # 'descriptionlines' - original lines of description as formatted in the
582 def parse_metadata(metafile):
587 def add_buildflag(p, thisbuild):
590 raise MetaDataException("Invalid build flag at {0} in {1}"
591 .format(buildlines[0], linedesc))
594 raise MetaDataException("Duplicate definition on {0} in version {1} of {2}"
595 .format(pk, thisbuild['version'], linedesc))
598 if pk not in flag_defaults:
599 raise MetaDataException("Unrecognised build flag at {0} in {1}"
600 .format(p, linedesc))
603 pv = split_list_values(pv)
605 if len(pv) == 1 and pv[0] in ['main', 'yes']:
608 elif t == 'string' or t == 'script':
615 logging.debug("...ignoring bool flag %s" % p)
618 raise MetaDataException("Unrecognised build flag type '%s' at %s in %s"
621 def parse_buildline(lines):
622 value = "".join(lines)
623 parts = [p.replace("\\,", ",")
624 for p in re.split(r"(?<!\\),", value)]
626 raise MetaDataException("Invalid build format: " + value + " in " + metafile.name)
628 thisbuild['origlines'] = lines
629 thisbuild['version'] = parts[0]
630 thisbuild['vercode'] = parts[1]
631 if parts[2].startswith('!'):
632 # For backwards compatibility, handle old-style disabling,
633 # including attempting to extract the commit from the message
634 thisbuild['disable'] = parts[2][1:]
635 commit = 'unknown - see disabled'
636 index = parts[2].rfind('at ')
638 commit = parts[2][index + 3:]
639 if commit.endswith(')'):
641 thisbuild['commit'] = commit
643 thisbuild['commit'] = parts[2]
645 add_buildflag(p, thisbuild)
649 def add_comments(key):
652 for comment in curcomments:
653 thisinfo['comments'].append((key, comment))
658 if not isinstance(metafile, file):
659 metafile = open(metafile, "r")
660 appid = metafile.name[9:-4]
662 thisinfo.update(app_defaults)
663 thisinfo['id'] = appid
665 # General defaults...
666 thisinfo['builds'] = []
667 thisinfo['comments'] = []
670 return appid, thisinfo
679 for line in metafile:
681 linedesc = "%s:%d" % (metafile.name, c)
682 line = line.rstrip('\r\n')
684 if not any(line.startswith(s) for s in (' ', '\t')):
685 commit = curbuild['commit'] if 'commit' in curbuild else None
686 if not commit and 'disable' not in curbuild:
687 raise MetaDataException("No commit specified for {0} in {1}"
688 .format(curbuild['version'], linedesc))
690 thisinfo['builds'].append(curbuild)
691 add_comments('build:' + curbuild['vercode'])
694 if line.endswith('\\'):
695 buildlines.append(line[:-1].lstrip())
697 buildlines.append(line.lstrip())
698 bl = ''.join(buildlines)
699 add_buildflag(bl, curbuild)
705 if line.startswith("#"):
706 curcomments.append(line)
709 field, value = line.split(':', 1)
711 raise MetaDataException("Invalid metadata in " + linedesc)
712 if field != field.strip() or value != value.strip():
713 raise MetaDataException("Extra spacing found in " + linedesc)
715 # Translate obsolete fields...
716 if field == 'Market Version':
717 field = 'Current Version'
718 if field == 'Market Version Code':
719 field = 'Current Version Code'
721 fieldtype = metafieldtype(field)
722 if fieldtype not in ['build', 'buildv2']:
724 if fieldtype == 'multiline':
728 raise MetaDataException("Unexpected text on same line as " + field + " in " + linedesc)
729 elif fieldtype == 'string':
730 thisinfo[field] = value
731 elif fieldtype == 'list':
732 thisinfo[field] = split_list_values(value)
733 elif fieldtype == 'build':
734 if value.endswith("\\"):
736 buildlines = [value[:-1]]
738 curbuild = parse_buildline([value])
739 thisinfo['builds'].append(curbuild)
740 add_comments('build:' + thisinfo['builds'][-1]['vercode'])
741 elif fieldtype == 'buildv2':
743 vv = value.split(',')
745 raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
746 .format(value, linedesc))
747 curbuild['version'] = vv[0]
748 curbuild['vercode'] = vv[1]
749 if curbuild['vercode'] in vc_seen:
750 raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
751 curbuild['vercode'], linedesc))
752 vc_seen[curbuild['vercode']] = True
755 elif fieldtype == 'obsolete':
756 pass # Just throw it away!
758 raise MetaDataException("Unrecognised field type for " + field + " in " + linedesc)
759 elif mode == 1: # Multiline field
763 thisinfo[field].append(line)
764 elif mode == 2: # Line continuation mode in Build Version
765 if line.endswith("\\"):
766 buildlines.append(line[:-1])
768 buildlines.append(line)
769 curbuild = parse_buildline(buildlines)
770 thisinfo['builds'].append(curbuild)
771 add_comments('build:' + thisinfo['builds'][-1]['vercode'])
775 # Mode at end of file should always be 0...
777 raise MetaDataException(field + " not terminated in " + metafile.name)
779 raise MetaDataException("Unterminated continuation in " + metafile.name)
781 raise MetaDataException("Unterminated build in " + metafile.name)
783 if not thisinfo['Description']:
784 thisinfo['Description'].append('No description available')
786 for build in thisinfo['builds']:
787 fill_build_defaults(build)
789 thisinfo['builds'] = sorted(thisinfo['builds'], key=lambda build: int(build['vercode']))
791 return (appid, thisinfo)
794 # Write a metadata file.
796 # 'dest' - The path to the output file
797 # 'app' - The app data
798 def write_metadata(dest, app):
800 def writecomments(key):
802 for pf, comment in app['comments']:
804 mf.write("%s\n" % comment)
807 logging.debug("...writing comments for " + (key or 'EOF'))
809 def writefield(field, value=None):
813 t = metafieldtype(field)
815 value = ','.join(value)
816 mf.write("%s:%s\n" % (field, value))
818 def writefield_nonempty(field, value=None):
822 writefield(field, value)
825 writefield_nonempty('Disabled')
826 writefield_nonempty('AntiFeatures')
827 writefield_nonempty('Provides')
828 writefield('Categories')
829 writefield('License')
830 writefield('Web Site')
831 writefield('Source Code')
832 writefield('Issue Tracker')
833 writefield_nonempty('Donate')
834 writefield_nonempty('FlattrID')
835 writefield_nonempty('Bitcoin')
836 writefield_nonempty('Litecoin')
837 writefield_nonempty('Dogecoin')
839 writefield_nonempty('Name')
840 writefield_nonempty('Auto Name')
841 writefield('Summary')
842 writefield('Description', '')
843 for line in app['Description']:
844 mf.write("%s\n" % line)
847 if app['Requires Root']:
848 writefield('Requires Root', 'Yes')
851 writefield('Repo Type')
854 writefield('Binaries')
856 for build in app['builds']:
858 if build['version'] == "Ignore":
861 writecomments('build:' + build['vercode'])
862 mf.write("Build:%s,%s\n" % (build['version'], build['vercode']))
864 def write_builditem(key, value):
866 if key in ['version', 'vercode']:
869 if value == flag_defaults[key]:
874 logging.debug("...writing {0} : {1}".format(key, value))
875 outline = ' %s=' % key
882 outline += '&& \\\n '.join([s.lstrip() for s in value.split('&& ')])
884 outline += ','.join(value) if type(value) == list else value
889 for flag in flag_defaults:
892 write_builditem(flag, value)
895 if app['Maintainer Notes']:
896 writefield('Maintainer Notes', '')
897 for line in app['Maintainer Notes']:
898 mf.write("%s\n" % line)
902 writefield_nonempty('Archive Policy')
903 writefield('Auto Update Mode')
904 writefield('Update Check Mode')
905 writefield_nonempty('Update Check Ignore')
906 writefield_nonempty('Vercode Operation')
907 writefield_nonempty('Update Check Name')
908 writefield_nonempty('Update Check Data')
909 if app['Current Version']:
910 writefield('Current Version')
911 writefield('Current Version Code')
913 if app['No Source Since']:
914 writefield('No Source Since')