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', ''),
61 ('Requires Root', False),
65 ('Maintainer Notes', []),
66 ('Archive Policy', None),
67 ('Auto Update Mode', 'None'),
68 ('Update Check Mode', 'None'),
69 ('Update Check Ignore', None),
70 ('Vercode Operation', None),
71 ('Update Check Name', None),
72 ('Update Check Data', None),
73 ('Current Version', ''),
74 ('Current Version Code', '0'),
75 ('No Source Since', ''),
79 # In the order in which they are laid out on files
80 # Sorted by their action and their place in the build timeline
81 flag_defaults = OrderedDict([
85 ('submodules', False),
95 ('forceversion', False),
96 ('forcevercode', False),
100 ('update', ['auto']),
106 ('ndk', 'r10e'), # defaults to latest
109 ('antcommands', None),
114 # Designates a metadata field type and checks that it matches
116 # 'name' - The long name of the field type
117 # 'matching' - List of possible values or regex expression
118 # 'sep' - Separator to use if value may be a list
119 # 'fields' - Metadata fields (Field:Value) of this type
120 # 'attrs' - Build attributes (attr=value) of this type
122 class FieldValidator():
124 def __init__(self, name, matching, sep, fields, attrs):
126 self.matching = matching
127 if type(matching) is str:
128 self.compiled = re.compile(matching)
133 def _assert_regex(self, values, appid):
135 if not self.compiled.match(v):
136 raise MetaDataException("'%s' is not a valid %s in %s. "
137 % (v, self.name, appid) +
138 "Regex pattern: %s" % (self.matching))
140 def _assert_list(self, values, appid):
142 if v not in self.matching:
143 raise MetaDataException("'%s' is not a valid %s in %s. "
144 % (v, self.name, appid) +
145 "Possible values: %s" % (", ".join(self.matching)))
147 def check(self, value, appid):
148 if type(value) is not str or not value:
150 if self.sep is not None:
151 values = value.split(self.sep)
154 if type(self.matching) is list:
155 self._assert_list(values, appid)
157 self._assert_regex(values, appid)
160 # Generic value types
162 FieldValidator("Integer",
163 r'^[1-9][0-9]*$', None,
167 FieldValidator("Hexadecimal",
168 r'^[0-9a-f]+$', None,
172 FieldValidator("HTTP link",
173 r'^http[s]?://', None,
174 ["Web Site", "Source Code", "Issue Tracker", "Changelog", "Donate"], []),
176 FieldValidator("Bitcoin address",
177 r'^[a-zA-Z0-9]{27,34}$', None,
181 FieldValidator("Litecoin address",
182 r'^L[a-zA-Z0-9]{33}$', None,
186 FieldValidator("Dogecoin address",
187 r'^D[a-zA-Z0-9]{33}$', None,
191 FieldValidator("Boolean",
196 FieldValidator("bool",
199 ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
202 FieldValidator("Repo Type",
203 ['git', 'git-svn', 'svn', 'hg', 'bzr', 'srclib'], None,
207 FieldValidator("Binaries",
208 r'^http[s]?://', None,
212 FieldValidator("Archive Policy",
213 r'^[0-9]+ versions$', None,
217 FieldValidator("Anti-Feature",
218 ["Ads", "Tracking", "NonFreeNet", "NonFreeDep", "NonFreeAdd", "UpstreamNonFree"], ',',
222 FieldValidator("Auto Update Mode",
223 r"^(Version .+|None)$", None,
224 ["Auto Update Mode"],
227 FieldValidator("Update Check Mode",
228 r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$", None,
229 ["Update Check Mode"],
234 # Check an app's metadata information for integrity errors
235 def check_metadata(info):
237 for field in v.fields:
238 v.check(info[field], info['id'])
239 for build in info['builds']:
241 v.check(build[attr], info['id'])
244 # Formatter for descriptions. Create an instance, and call parseline() with
245 # each line of the description source from the metadata. At the end, call
246 # end() and then text_wiki and text_html will contain the result.
247 class DescriptionFormatter:
259 def __init__(self, linkres):
260 self.linkResolver = linkres
262 def endcur(self, notstates=None):
263 if notstates and self.state in notstates:
265 if self.state == self.stPARA:
267 elif self.state == self.stUL:
269 elif self.state == self.stOL:
273 self.text_html += '</p>'
274 self.state = self.stNONE
277 self.text_html += '</ul>'
278 self.state = self.stNONE
281 self.text_html += '</ol>'
282 self.state = self.stNONE
284 def formatted(self, txt, html):
287 txt = cgi.escape(txt)
289 index = txt.find("''")
291 return formatted + txt
292 formatted += txt[:index]
294 if txt.startswith("'''"):
300 self.bold = not self.bold
308 self.ital = not self.ital
311 def linkify(self, txt):
315 index = txt.find("[")
317 return (linkified_plain + self.formatted(txt, False), linkified_html + self.formatted(txt, True))
318 linkified_plain += self.formatted(txt[:index], False)
319 linkified_html += self.formatted(txt[:index], True)
321 if txt.startswith("[["):
322 index = txt.find("]]")
324 raise MetaDataException("Unterminated ]]")
326 if self.linkResolver:
327 url, urltext = self.linkResolver(url)
330 linkified_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
331 linkified_plain += urltext
332 txt = txt[index + 2:]
334 index = txt.find("]")
336 raise MetaDataException("Unterminated ]")
338 index2 = url.find(' ')
342 urltxt = url[index2 + 1:]
345 raise MetaDataException("Url title is just the URL - use [url]")
346 linkified_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
347 linkified_plain += urltxt
349 linkified_plain += ' (' + url + ')'
350 txt = txt[index + 1:]
352 def addtext(self, txt):
353 p, h = self.linkify(txt)
356 def parseline(self, line):
357 self.text_wiki += "%s\n" % line
360 elif line.startswith('* '):
361 self.endcur([self.stUL])
362 if self.state != self.stUL:
363 self.text_html += '<ul>'
364 self.state = self.stUL
365 self.text_html += '<li>'
366 self.addtext(line[1:])
367 self.text_html += '</li>'
368 elif line.startswith('# '):
369 self.endcur([self.stOL])
370 if self.state != self.stOL:
371 self.text_html += '<ol>'
372 self.state = self.stOL
373 self.text_html += '<li>'
374 self.addtext(line[1:])
375 self.text_html += '</li>'
377 self.endcur([self.stPARA])
378 if self.state == self.stNONE:
379 self.text_html += '<p>'
380 self.state = self.stPARA
381 elif self.state == self.stPARA:
382 self.text_html += ' '
389 # Parse multiple lines of description as written in a metadata file, returning
390 # a single string in wiki format. Used for the Maintainer Notes field as well,
391 # because it's the same format.
392 def description_wiki(lines):
393 ps = DescriptionFormatter(None)
400 # Parse multiple lines of description as written in a metadata file, returning
401 # a single string in HTML format.
402 def description_html(lines, linkres):
403 ps = DescriptionFormatter(linkres)
410 def parse_srclib(metafile):
413 if metafile and not isinstance(metafile, file):
414 metafile = open(metafile, "r")
416 # Defaults for fields that come from metadata
417 thisinfo['Repo Type'] = ''
418 thisinfo['Repo'] = ''
419 thisinfo['Subdir'] = None
420 thisinfo['Prepare'] = None
426 for line in metafile:
428 line = line.rstrip('\r\n')
429 if not line or line.startswith("#"):
433 field, value = line.split(':', 1)
435 raise MetaDataException("Invalid metadata in %s:%d" % (line, n))
437 if field == "Subdir":
438 thisinfo[field] = value.split(',')
440 thisinfo[field] = value
446 """Read all srclib metadata.
448 The information read will be accessible as metadata.srclibs, which is a
449 dictionary, keyed on srclib name, with the values each being a dictionary
450 in the same format as that returned by the parse_srclib function.
452 A MetaDataException is raised if there are any problems with the srclib
457 # They were already loaded
458 if srclibs is not None:
464 if not os.path.exists(srcdir):
467 for metafile in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
468 srclibname = os.path.basename(metafile[:-4])
469 srclibs[srclibname] = parse_srclib(metafile)
472 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
473 # returned by the parse_txt_metadata function.
474 def read_metadata(xref=True):
476 # Always read the srclibs before the apps, since they can use a srlib as
477 # their source repository.
482 for basedir in ('metadata', 'tmp'):
483 if not os.path.exists(basedir):
486 for metafile in sorted(glob.glob(os.path.join('metadata', '*.txt'))):
487 appid, appinfo = parse_txt_metadata(metafile)
488 check_metadata(appinfo)
489 apps[appid] = appinfo
492 # Parse all descriptions at load time, just to ensure cross-referencing
493 # errors are caught early rather than when they hit the build server.
496 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
497 raise MetaDataException("Cannot resolve app id " + appid)
499 for appid, app in apps.iteritems():
501 description_html(app['Description'], linkres)
502 except MetaDataException, e:
503 raise MetaDataException("Problem with description of " + appid +
509 # Get the type expected for a given metadata field.
510 def metafieldtype(name):
511 if name in ['Description', 'Maintainer Notes']:
513 if name in ['Categories']:
515 if name == 'Build Version':
519 if name == 'Use Built':
521 if name not in app_defaults:
527 if name in ['extlibs', 'srclibs', 'patch', 'rm', 'buildjni', 'preassemble',
528 'update', 'scanignore', 'scandelete', 'gradle', 'antcommands',
531 if name in ['init', 'prebuild', 'build']:
533 if name in ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
539 def fill_build_defaults(build):
541 def get_build_type():
542 for t in ['maven', 'gradle', 'kivy']:
549 for flag, value in flag_defaults.iteritems():
553 build['type'] = get_build_type()
554 build['ndk_path'] = common.get_ndk_path(build['ndk'])
557 def split_list_values(s):
558 # Port legacy ';' separators
559 l = [v.strip() for v in s.replace(';', ',').split(',')]
560 return [v for v in l if v]
563 # Parse metadata for a single application.
565 # 'metafile' - the filename to read. The package id for the application comes
566 # from this filename. Pass None to get a blank entry.
568 # Returns a dictionary containing all the details of the application. There are
569 # two major kinds of information in the dictionary. Keys beginning with capital
570 # letters correspond directory to identically named keys in the metadata file.
571 # Keys beginning with lower case letters are generated in one way or another,
572 # and are not found verbatim in the metadata.
574 # Known keys not originating from the metadata are:
576 # 'builds' - a list of dictionaries containing build information
577 # for each defined build
578 # 'comments' - a list of comments from the metadata file. Each is
579 # a tuple of the form (field, comment) where field is
580 # the name of the field it preceded in the metadata
581 # file. Where field is None, the comment goes at the
582 # end of the file. Alternatively, 'build:version' is
583 # for a comment before a particular build version.
584 # 'descriptionlines' - original lines of description as formatted in the
587 def parse_txt_metadata(metafile):
592 def add_buildflag(p, thisbuild):
594 raise MetaDataException("Empty build flag at {1}"
595 .format(buildlines[0], linedesc))
598 raise MetaDataException("Invalid build flag at {0} in {1}"
599 .format(buildlines[0], linedesc))
602 raise MetaDataException("Duplicate definition on {0} in version {1} of {2}"
603 .format(pk, thisbuild['version'], linedesc))
606 if pk not in flag_defaults:
607 raise MetaDataException("Unrecognised build flag at {0} in {1}"
608 .format(p, linedesc))
611 pv = split_list_values(pv)
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] = split_list_values(value)
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('Changelog')
842 writefield_nonempty('Donate')
843 writefield_nonempty('FlattrID')
844 writefield_nonempty('Bitcoin')
845 writefield_nonempty('Litecoin')
846 writefield_nonempty('Dogecoin')
848 writefield_nonempty('Name')
849 writefield_nonempty('Auto Name')
850 writefield('Summary')
851 writefield('Description', '')
852 for line in app['Description']:
853 mf.write("%s\n" % line)
856 if app['Requires Root']:
857 writefield('Requires Root', 'Yes')
860 writefield('Repo Type')
863 writefield('Binaries')
865 for build in app['builds']:
867 if build['version'] == "Ignore":
870 writecomments('build:' + build['vercode'])
871 mf.write("Build:%s,%s\n" % (build['version'], build['vercode']))
873 def write_builditem(key, value):
875 if key in ['version', 'vercode']:
878 if value == flag_defaults[key]:
883 logging.debug("...writing {0} : {1}".format(key, value))
884 outline = ' %s=' % key
891 outline += '&& \\\n '.join([s.lstrip() for s in value.split('&& ')])
893 outline += ','.join(value) if type(value) == list else value
898 for flag in flag_defaults:
901 write_builditem(flag, value)
904 if app['Maintainer Notes']:
905 writefield('Maintainer Notes', '')
906 for line in app['Maintainer Notes']:
907 mf.write("%s\n" % line)
911 writefield_nonempty('Archive Policy')
912 writefield('Auto Update Mode')
913 writefield('Update Check Mode')
914 writefield_nonempty('Update Check Ignore')
915 writefield_nonempty('Vercode Operation')
916 writefield_nonempty('Update Check Name')
917 writefield_nonempty('Update Check Data')
918 if app['Current Version']:
919 writefield('Current Version')
920 writefield('Current Version Code')
922 if app['No Source Since']:
923 writefield('No Source Since')