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):
33 def __init__(self, value):
39 # In the order in which they are laid out on files
40 app_defaults = OrderedDict([
42 ('AntiFeatures', None),
44 ('Categories', ['None']),
45 ('License', 'Unknown'),
48 ('Issue Tracker', ''),
58 ('Requires Root', False),
62 ('Maintainer Notes', []),
63 ('Archive Policy', None),
64 ('Auto Update Mode', 'None'),
65 ('Update Check Mode', 'None'),
66 ('Update Check Ignore', None),
67 ('Vercode Operation', None),
68 ('Update Check Name', None),
69 ('Update Check Data', None),
70 ('Current Version', ''),
71 ('Current Version Code', '0'),
72 ('No Source Since', ''),
76 # In the order in which they are laid out on files
77 # Sorted by their action and their place in the build timeline
78 flag_defaults = OrderedDict([
82 ('submodules', False),
92 ('forceversion', False),
93 ('forcevercode', False),
104 ('antcommands', None),
109 # Designates a metadata field type and checks that it matches
111 # 'name' - The long name of the field type
112 # 'matching' - List of possible values or regex expression
113 # 'sep' - Separator to use if value may be a list
114 # 'fields' - Metadata fields (Field:Value) of this type
115 # 'attrs' - Build attributes (attr=value) of this type
117 class FieldValidator():
119 def __init__(self, name, matching, sep, fields, attrs):
121 self.matching = matching
122 if type(matching) is str:
123 self.compiled = re.compile(matching)
128 def _assert_regex(self, values, appid):
130 if not self.compiled.match(v):
131 raise MetaDataException("'%s' is not a valid %s in %s. "
132 % (v, self.name, appid) +
133 "Regex pattern: %s" % (self.matching))
135 def _assert_list(self, values, appid):
137 if v not in self.matching:
138 raise MetaDataException("'%s' is not a valid %s in %s. "
139 % (v, self.name, appid) +
140 "Possible values: %s" % (", ".join(self.matching)))
142 def check(self, value, appid):
143 if type(value) is not str or not value:
145 if self.sep is not None:
146 values = value.split(self.sep)
149 if type(self.matching) is list:
150 self._assert_list(values, appid)
152 self._assert_regex(values, appid)
155 # Generic value types
157 FieldValidator("Integer",
158 r'^[1-9][0-9]*$', None,
162 FieldValidator("Hexadecimal",
163 r'^[0-9a-f]+$', None,
167 FieldValidator("HTTP link",
168 r'^http[s]?://', None,
169 ["Web Site", "Source Code", "Issue Tracker", "Donate"], []),
171 FieldValidator("Bitcoin address",
172 r'^[a-zA-Z0-9]{27,34}$', None,
176 FieldValidator("Litecoin address",
177 r'^L[a-zA-Z0-9]{33}$', None,
181 FieldValidator("Dogecoin address",
182 r'^D[a-zA-Z0-9]{33}$', None,
186 FieldValidator("Boolean",
191 FieldValidator("bool",
194 ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
197 FieldValidator("Repo Type",
198 ['git', 'git-svn', 'svn', 'hg', 'bzr', 'srclib'], None,
202 FieldValidator("Binaries",
203 r'^http[s]?://', None,
207 FieldValidator("Archive Policy",
208 r'^[0-9]+ versions$', None,
212 FieldValidator("Anti-Feature",
213 ["Ads", "Tracking", "NonFreeNet", "NonFreeDep", "NonFreeAdd", "UpstreamNonFree"], ',',
217 FieldValidator("Auto Update Mode",
218 r"^(Version .+|None)$", None,
219 ["Auto Update Mode"],
222 FieldValidator("Update Check Mode",
223 r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$", None,
224 ["Update Check Mode"],
229 # Check an app's metadata information for integrity errors
230 def check_metadata(info):
232 for field in v.fields:
233 v.check(info[field], info['id'])
234 for build in info['builds']:
236 v.check(build[attr], info['id'])
239 # Formatter for descriptions. Create an instance, and call parseline() with
240 # each line of the description source from the metadata. At the end, call
241 # end() and then text_plain, text_wiki and text_html will contain the result.
242 class DescriptionFormatter:
255 def __init__(self, linkres):
256 self.linkResolver = linkres
258 def endcur(self, notstates=None):
259 if notstates and self.state in notstates:
261 if self.state == self.stPARA:
263 elif self.state == self.stUL:
265 elif self.state == self.stOL:
269 self.text_plain += '\n'
270 self.text_html += '</p>'
271 self.state = self.stNONE
274 self.text_html += '</ul>'
275 self.state = self.stNONE
278 self.text_html += '</ol>'
279 self.state = self.stNONE
281 def formatted(self, txt, html):
284 txt = cgi.escape(txt)
286 index = txt.find("''")
288 return formatted + txt
289 formatted += txt[:index]
291 if txt.startswith("'''"):
297 self.bold = not self.bold
305 self.ital = not self.ital
308 def linkify(self, txt):
312 index = txt.find("[")
314 return (linkified_plain + self.formatted(txt, False), linkified_html + self.formatted(txt, True))
315 linkified_plain += self.formatted(txt[:index], False)
316 linkified_html += self.formatted(txt[:index], True)
318 if txt.startswith("[["):
319 index = txt.find("]]")
321 raise MetaDataException("Unterminated ]]")
323 if self.linkResolver:
324 url, urltext = self.linkResolver(url)
327 linkified_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
328 linkified_plain += urltext
329 txt = txt[index + 2:]
331 index = txt.find("]")
333 raise MetaDataException("Unterminated ]")
335 index2 = url.find(' ')
339 urltxt = url[index2 + 1:]
341 linkified_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
342 linkified_plain += urltxt
344 linkified_plain += ' (' + url + ')'
345 txt = txt[index + 1:]
347 def addtext(self, txt):
348 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.text_plain += '* '
363 self.addtext(line[1:])
364 self.text_html += '</li>'
365 elif line.startswith('# '):
366 self.endcur([self.stOL])
367 if self.state != self.stOL:
368 self.text_html += '<ol>'
369 self.state = self.stOL
370 self.text_html += '<li>'
371 self.text_plain += '* ' # TODO: lazy - put the numbers in!
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 += ' '
381 self.text_plain += ' '
388 # Parse multiple lines of description as written in a metadata file, returning
389 # a single string in plain text format.
390 def description_plain(lines, linkres):
391 ps = DescriptionFormatter(linkres)
398 # Parse multiple lines of description as written in a metadata file, returning
399 # a single string in wiki format. Used for the Maintainer Notes field as well,
400 # because it's the same format.
401 def description_wiki(lines):
402 ps = DescriptionFormatter(None)
409 # Parse multiple lines of description as written in a metadata file, returning
410 # a single string in HTML format.
411 def description_html(lines, linkres):
412 ps = DescriptionFormatter(linkres)
419 def parse_srclib(metafile):
422 if metafile and not isinstance(metafile, file):
423 metafile = open(metafile, "r")
425 # Defaults for fields that come from metadata
426 thisinfo['Repo Type'] = ''
427 thisinfo['Repo'] = ''
428 thisinfo['Subdir'] = None
429 thisinfo['Prepare'] = None
430 thisinfo['Srclibs'] = None
436 for line in metafile:
438 line = line.rstrip('\r\n')
439 if not line or line.startswith("#"):
443 field, value = line.split(':', 1)
445 raise MetaDataException("Invalid metadata in %s:%d" % (line, n))
447 if field == "Subdir":
448 thisinfo[field] = value.split(',')
450 thisinfo[field] = value
456 """Read all srclib metadata.
458 The information read will be accessible as metadata.srclibs, which is a
459 dictionary, keyed on srclib name, with the values each being a dictionary
460 in the same format as that returned by the parse_srclib function.
462 A MetaDataException is raised if there are any problems with the srclib
467 # They were already loaded
468 if srclibs is not None:
474 if not os.path.exists(srcdir):
477 for metafile in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
478 srclibname = os.path.basename(metafile[:-4])
479 srclibs[srclibname] = parse_srclib(metafile)
482 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
483 # returned by the parse_metadata function.
484 def read_metadata(xref=True):
486 # Always read the srclibs before the apps, since they can use a srlib as
487 # their source repository.
492 for basedir in ('metadata', 'tmp'):
493 if not os.path.exists(basedir):
496 for metafile in sorted(glob.glob(os.path.join('metadata', '*.txt'))):
497 appid, appinfo = parse_metadata(metafile)
498 check_metadata(appinfo)
499 apps[appid] = appinfo
502 # Parse all descriptions at load time, just to ensure cross-referencing
503 # errors are caught early rather than when they hit the build server.
506 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
507 raise MetaDataException("Cannot resolve app id " + appid)
509 for appid, app in apps.iteritems():
511 description_html(app['Description'], linkres)
512 except MetaDataException, e:
513 raise MetaDataException("Problem with description of " + appid +
519 # Get the type expected for a given metadata field.
520 def metafieldtype(name):
521 if name in ['Description', 'Maintainer Notes']:
523 if name in ['Categories']:
525 if name == 'Build Version':
529 if name == 'Use Built':
531 if name not in app_defaults:
537 if name in ['extlibs', 'srclibs', 'patch', 'rm', 'buildjni', 'preassemble',
538 'update', 'scanignore', 'scandelete', 'gradle', 'antcommands']:
540 if name in ['init', 'prebuild', 'build']:
542 if name in ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
548 def fill_build_defaults(build):
550 def get_build_type():
551 for t in ['maven', 'gradle', 'kivy']:
558 for flag, value in flag_defaults.iteritems():
562 build['type'] = get_build_type()
565 # Parse metadata for a single application.
567 # 'metafile' - the filename to read. The package id for the application comes
568 # from this filename. Pass None to get a blank entry.
570 # Returns a dictionary containing all the details of the application. There are
571 # two major kinds of information in the dictionary. Keys beginning with capital
572 # letters correspond directory to identically named keys in the metadata file.
573 # Keys beginning with lower case letters are generated in one way or another,
574 # and are not found verbatim in the metadata.
576 # Known keys not originating from the metadata are:
578 # 'builds' - a list of dictionaries containing build information
579 # for each defined build
580 # 'comments' - a list of comments from the metadata file. Each is
581 # a tuple of the form (field, comment) where field is
582 # the name of the field it preceded in the metadata
583 # file. Where field is None, the comment goes at the
584 # end of the file. Alternatively, 'build:version' is
585 # for a comment before a particular build version.
586 # 'descriptionlines' - original lines of description as formatted in the
589 def parse_metadata(metafile):
594 def add_buildflag(p, thisbuild):
597 raise MetaDataException("Invalid build flag at {0} in {1}"
598 .format(buildlines[0], linedesc))
601 raise MetaDataException("Duplicate definition on {0} in version {1} of {2}"
602 .format(pk, thisbuild['version'], linedesc))
605 if pk not in flag_defaults:
606 raise MetaDataException("Unrecognised build flag at {0} in {1}"
607 .format(p, linedesc))
610 # Port legacy ';' separators
611 pv = [v.strip() for v in pv.replace(';', ',').split(',')]
613 if len(pv) == 1 and pv[0] in ['main', 'yes']:
616 elif t == 'string' or t == 'script':
623 logging.debug("...ignoring bool flag %s" % p)
626 raise MetaDataException("Unrecognised build flag type '%s' at %s in %s"
629 def parse_buildline(lines):
630 value = "".join(lines)
631 parts = [p.replace("\\,", ",")
632 for p in re.split(r"(?<!\\),", value)]
634 raise MetaDataException("Invalid build format: " + value + " in " + metafile.name)
636 thisbuild['origlines'] = lines
637 thisbuild['version'] = parts[0]
638 thisbuild['vercode'] = parts[1]
639 if parts[2].startswith('!'):
640 # For backwards compatibility, handle old-style disabling,
641 # including attempting to extract the commit from the message
642 thisbuild['disable'] = parts[2][1:]
643 commit = 'unknown - see disabled'
644 index = parts[2].rfind('at ')
646 commit = parts[2][index + 3:]
647 if commit.endswith(')'):
649 thisbuild['commit'] = commit
651 thisbuild['commit'] = parts[2]
653 add_buildflag(p, thisbuild)
657 def add_comments(key):
660 for comment in curcomments:
661 thisinfo['comments'].append((key, comment))
666 if not isinstance(metafile, file):
667 metafile = open(metafile, "r")
668 appid = metafile.name[9:-4]
670 thisinfo.update(app_defaults)
671 thisinfo['id'] = appid
673 # General defaults...
674 thisinfo['builds'] = []
675 thisinfo['comments'] = []
678 return appid, thisinfo
687 for line in metafile:
689 linedesc = "%s:%d" % (metafile.name, c)
690 line = line.rstrip('\r\n')
692 if not any(line.startswith(s) for s in (' ', '\t')):
693 commit = curbuild['commit'] if 'commit' in curbuild else None
694 if not commit and 'disable' not in curbuild:
695 raise MetaDataException("No commit specified for {0} in {1}"
696 .format(curbuild['version'], linedesc))
698 thisinfo['builds'].append(curbuild)
699 add_comments('build:' + curbuild['vercode'])
702 if line.endswith('\\'):
703 buildlines.append(line[:-1].lstrip())
705 buildlines.append(line.lstrip())
706 bl = ''.join(buildlines)
707 add_buildflag(bl, curbuild)
713 if line.startswith("#"):
714 curcomments.append(line)
717 field, value = line.split(':', 1)
719 raise MetaDataException("Invalid metadata in " + linedesc)
720 if field != field.strip() or value != value.strip():
721 raise MetaDataException("Extra spacing found in " + linedesc)
723 # Translate obsolete fields...
724 if field == 'Market Version':
725 field = 'Current Version'
726 if field == 'Market Version Code':
727 field = 'Current Version Code'
729 fieldtype = metafieldtype(field)
730 if fieldtype not in ['build', 'buildv2']:
732 if fieldtype == 'multiline':
736 raise MetaDataException("Unexpected text on same line as " + field + " in " + linedesc)
737 elif fieldtype == 'string':
738 thisinfo[field] = value
739 elif fieldtype == 'list':
740 thisinfo[field] = [v.strip() for v in value.replace(';', ',').split(',')]
741 elif fieldtype == 'build':
742 if value.endswith("\\"):
744 buildlines = [value[:-1]]
746 curbuild = parse_buildline([value])
747 thisinfo['builds'].append(curbuild)
748 add_comments('build:' + thisinfo['builds'][-1]['vercode'])
749 elif fieldtype == 'buildv2':
751 vv = value.split(',')
753 raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
754 .format(value, linedesc))
755 curbuild['version'] = vv[0]
756 curbuild['vercode'] = vv[1]
757 if curbuild['vercode'] in vc_seen:
758 raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
759 curbuild['vercode'], linedesc))
760 vc_seen[curbuild['vercode']] = True
763 elif fieldtype == 'obsolete':
764 pass # Just throw it away!
766 raise MetaDataException("Unrecognised field type for " + field + " in " + linedesc)
767 elif mode == 1: # Multiline field
771 thisinfo[field].append(line)
772 elif mode == 2: # Line continuation mode in Build Version
773 if line.endswith("\\"):
774 buildlines.append(line[:-1])
776 buildlines.append(line)
777 curbuild = parse_buildline(buildlines)
778 thisinfo['builds'].append(curbuild)
779 add_comments('build:' + thisinfo['builds'][-1]['vercode'])
783 # Mode at end of file should always be 0...
785 raise MetaDataException(field + " not terminated in " + metafile.name)
787 raise MetaDataException("Unterminated continuation in " + metafile.name)
789 raise MetaDataException("Unterminated build in " + metafile.name)
791 if not thisinfo['Description']:
792 thisinfo['Description'].append('No description available')
794 for build in thisinfo['builds']:
795 fill_build_defaults(build)
797 thisinfo['builds'] = sorted(thisinfo['builds'], key=lambda build: int(build['vercode']))
799 return (appid, thisinfo)
802 # Write a metadata file.
804 # 'dest' - The path to the output file
805 # 'app' - The app data
806 def write_metadata(dest, app):
808 def writecomments(key):
810 for pf, comment in app['comments']:
812 mf.write("%s\n" % comment)
815 logging.debug("...writing comments for " + (key or 'EOF'))
817 def writefield(field, value=None):
821 t = metafieldtype(field)
823 value = ','.join(value)
824 mf.write("%s:%s\n" % (field, value))
826 def writefield_nonempty(field, value=None):
830 writefield(field, value)
833 writefield_nonempty('Disabled')
834 writefield_nonempty('AntiFeatures')
835 writefield_nonempty('Provides')
836 writefield('Categories')
837 writefield('License')
838 writefield('Web Site')
839 writefield('Source Code')
840 writefield('Issue Tracker')
841 writefield_nonempty('Donate')
842 writefield_nonempty('FlattrID')
843 writefield_nonempty('Bitcoin')
844 writefield_nonempty('Litecoin')
845 writefield_nonempty('Dogecoin')
847 writefield_nonempty('Name')
848 writefield_nonempty('Auto Name')
849 writefield('Summary')
850 writefield('Description', '')
851 for line in app['Description']:
852 mf.write("%s\n" % line)
855 if app['Requires Root']:
856 writefield('Requires Root', 'Yes')
859 writefield('Repo Type')
862 writefield('Binaries')
864 for build in app['builds']:
866 if build['version'] == "Ignore":
869 writecomments('build:' + build['vercode'])
870 mf.write("Build:%s,%s\n" % (build['version'], build['vercode']))
872 def write_builditem(key, value):
874 if key in ['version', 'vercode']:
877 if value == flag_defaults[key]:
882 logging.debug("...writing {0} : {1}".format(key, value))
883 outline = ' %s=' % key
890 outline += '&& \\\n '.join([s.lstrip() for s in value.split('&& ')])
892 outline += ','.join(value) if type(value) == list else value
897 for flag in flag_defaults:
900 write_builditem(flag, value)
903 if app['Maintainer Notes']:
904 writefield('Maintainer Notes', '')
905 for line in app['Maintainer Notes']:
906 mf.write("%s\n" % line)
910 writefield_nonempty('Archive Policy')
911 writefield('Auto Update Mode')
912 writefield('Update Check Mode')
913 writefield_nonempty('Update Check Ignore')
914 writefield_nonempty('Vercode Operation')
915 writefield_nonempty('Update Check Name')
916 writefield_nonempty('Update Check Data')
917 if app['Current Version']:
918 writefield('Current Version')
919 writefield('Current Version Code')
921 if app['No Source Since']:
922 writefield('No Source Since')