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("Hexadecimal",
161 r'^[0-9a-f]+$', None,
165 FieldValidator("HTTP link",
166 r'^http[s]?://', None,
167 ["Web Site", "Source Code", "Issue Tracker", "Donate"], []),
169 FieldValidator("Bitcoin address",
170 r'^[a-zA-Z0-9]{27,34}$', None,
174 FieldValidator("Litecoin address",
175 r'^L[a-zA-Z0-9]{33}$', None,
179 FieldValidator("Dogecoin address",
180 r'^D[a-zA-Z0-9]{33}$', None,
184 FieldValidator("Boolean",
189 FieldValidator("bool",
192 ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
195 FieldValidator("Repo Type",
196 ['git', 'git-svn', 'svn', 'hg', 'bzr', 'srclib'], None,
200 FieldValidator("Archive Policy",
201 r'^[0-9]+ versions$', None,
205 FieldValidator("Anti-Feature",
206 ["Ads", "Tracking", "NonFreeNet", "NonFreeDep", "NonFreeAdd", "UpstreamNonFree"], ',',
210 FieldValidator("Auto Update Mode",
211 r"^(Version .+|None)$", None,
212 ["Auto Update Mode"],
215 FieldValidator("Update Check Mode",
216 r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$", None,
217 ["Update Check Mode"],
222 # Check an app's metadata information for integrity errors
223 def check_metadata(info):
225 for field in v.fields:
226 v.check(info[field], info['id'])
227 for build in info['builds']:
229 v.check(build[attr], info['id'])
232 # Formatter for descriptions. Create an instance, and call parseline() with
233 # each line of the description source from the metadata. At the end, call
234 # end() and then text_plain, text_wiki and text_html will contain the result.
235 class DescriptionFormatter:
248 def __init__(self, linkres):
249 self.linkResolver = linkres
251 def endcur(self, notstates=None):
252 if notstates and self.state in notstates:
254 if self.state == self.stPARA:
256 elif self.state == self.stUL:
258 elif self.state == self.stOL:
262 self.text_plain += '\n'
263 self.text_html += '</p>'
264 self.state = self.stNONE
267 self.text_html += '</ul>'
268 self.state = self.stNONE
271 self.text_html += '</ol>'
272 self.state = self.stNONE
274 def formatted(self, txt, html):
277 txt = cgi.escape(txt)
279 index = txt.find("''")
281 return formatted + txt
282 formatted += txt[:index]
284 if txt.startswith("'''"):
290 self.bold = not self.bold
298 self.ital = not self.ital
301 def linkify(self, txt):
305 index = txt.find("[")
307 return (linkified_plain + self.formatted(txt, False), linkified_html + self.formatted(txt, True))
308 linkified_plain += self.formatted(txt[:index], False)
309 linkified_html += self.formatted(txt[:index], True)
311 if txt.startswith("[["):
312 index = txt.find("]]")
314 raise MetaDataException("Unterminated ]]")
316 if self.linkResolver:
317 url, urltext = self.linkResolver(url)
320 linkified_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
321 linkified_plain += urltext
322 txt = txt[index + 2:]
324 index = txt.find("]")
326 raise MetaDataException("Unterminated ]")
328 index2 = url.find(' ')
332 urltxt = url[index2 + 1:]
334 linkified_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
335 linkified_plain += urltxt
337 linkified_plain += ' (' + url + ')'
338 txt = txt[index + 1:]
340 def addtext(self, txt):
341 p, h = self.linkify(txt)
345 def parseline(self, line):
346 self.text_wiki += "%s\n" % line
349 elif line.startswith('* '):
350 self.endcur([self.stUL])
351 if self.state != self.stUL:
352 self.text_html += '<ul>'
353 self.state = self.stUL
354 self.text_html += '<li>'
355 self.text_plain += '* '
356 self.addtext(line[1:])
357 self.text_html += '</li>'
358 elif line.startswith('# '):
359 self.endcur([self.stOL])
360 if self.state != self.stOL:
361 self.text_html += '<ol>'
362 self.state = self.stOL
363 self.text_html += '<li>'
364 self.text_plain += '* ' # TODO: lazy - put the numbers in!
365 self.addtext(line[1:])
366 self.text_html += '</li>'
368 self.endcur([self.stPARA])
369 if self.state == self.stNONE:
370 self.text_html += '<p>'
371 self.state = self.stPARA
372 elif self.state == self.stPARA:
373 self.text_html += ' '
374 self.text_plain += ' '
381 # Parse multiple lines of description as written in a metadata file, returning
382 # a single string in plain text format.
383 def description_plain(lines, linkres):
384 ps = DescriptionFormatter(linkres)
391 # Parse multiple lines of description as written in a metadata file, returning
392 # a single string in wiki format. Used for the Maintainer Notes field as well,
393 # because it's the same format.
394 def description_wiki(lines):
395 ps = DescriptionFormatter(None)
402 # Parse multiple lines of description as written in a metadata file, returning
403 # a single string in HTML format.
404 def description_html(lines, linkres):
405 ps = DescriptionFormatter(linkres)
412 def parse_srclib(metafile):
415 if metafile and not isinstance(metafile, file):
416 metafile = open(metafile, "r")
418 # Defaults for fields that come from metadata
419 thisinfo['Repo Type'] = ''
420 thisinfo['Repo'] = ''
421 thisinfo['Subdir'] = None
422 thisinfo['Prepare'] = None
423 thisinfo['Srclibs'] = None
429 for line in metafile:
431 line = line.rstrip('\r\n')
432 if not line or line.startswith("#"):
436 field, value = line.split(':', 1)
438 raise MetaDataException("Invalid metadata in %s:%d" % (line, n))
440 if field == "Subdir":
441 thisinfo[field] = value.split(',')
443 thisinfo[field] = value
449 """Read all srclib metadata.
451 The information read will be accessible as metadata.srclibs, which is a
452 dictionary, keyed on srclib name, with the values each being a dictionary
453 in the same format as that returned by the parse_srclib function.
455 A MetaDataException is raised if there are any problems with the srclib
460 # They were already loaded
461 if srclibs is not None:
467 if not os.path.exists(srcdir):
470 for metafile in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
471 srclibname = os.path.basename(metafile[:-4])
472 srclibs[srclibname] = parse_srclib(metafile)
475 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
476 # returned by the parse_metadata function.
477 def read_metadata(xref=True):
479 # Always read the srclibs before the apps, since they can use a srlib as
480 # their source repository.
485 for basedir in ('metadata', 'tmp'):
486 if not os.path.exists(basedir):
489 for metafile in sorted(glob.glob(os.path.join('metadata', '*.txt'))):
490 appid, appinfo = parse_metadata(metafile)
491 check_metadata(appinfo)
492 apps[appid] = appinfo
495 # Parse all descriptions at load time, just to ensure cross-referencing
496 # errors are caught early rather than when they hit the build server.
499 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
500 raise MetaDataException("Cannot resolve app id " + appid)
502 for appid, app in apps.iteritems():
504 description_html(app['Description'], linkres)
505 except MetaDataException, e:
506 raise MetaDataException("Problem with description of " + appid +
512 # Get the type expected for a given metadata field.
513 def metafieldtype(name):
514 if name in ['Description', 'Maintainer Notes']:
516 if name in ['Categories']:
518 if name == 'Build Version':
522 if name == 'Use Built':
524 if name not in app_defaults:
530 if name in ['extlibs', 'srclibs', 'patch', 'rm', 'buildjni',
531 'update', 'scanignore', 'scandelete']:
533 if name in ['init', 'prebuild', 'build']:
535 if name in ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
541 def fill_build_defaults(build):
543 def get_build_type():
544 for t in ['maven', 'gradle', 'kivy']:
551 for flag, value in flag_defaults.iteritems():
555 build['type'] = get_build_type()
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 # Port legacy ';' separators
604 thisbuild[pk] = [v.strip() for v in pv.replace(';', ',').split(',')]
605 elif t == 'string' or t == 'script':
612 logging.debug("...ignoring bool flag %s" % p)
615 raise MetaDataException("Unrecognised build flag type '%s' at %s in %s"
618 def parse_buildline(lines):
619 value = "".join(lines)
620 parts = [p.replace("\\,", ",")
621 for p in re.split(r"(?<!\\),", value)]
623 raise MetaDataException("Invalid build format: " + value + " in " + metafile.name)
625 thisbuild['origlines'] = lines
626 thisbuild['version'] = parts[0]
627 thisbuild['vercode'] = parts[1]
628 if parts[2].startswith('!'):
629 # For backwards compatibility, handle old-style disabling,
630 # including attempting to extract the commit from the message
631 thisbuild['disable'] = parts[2][1:]
632 commit = 'unknown - see disabled'
633 index = parts[2].rfind('at ')
635 commit = parts[2][index + 3:]
636 if commit.endswith(')'):
638 thisbuild['commit'] = commit
640 thisbuild['commit'] = parts[2]
642 add_buildflag(p, thisbuild)
646 def add_comments(key):
649 for comment in curcomments:
650 thisinfo['comments'].append((key, comment))
655 if not isinstance(metafile, file):
656 metafile = open(metafile, "r")
657 appid = metafile.name[9:-4]
659 thisinfo.update(app_defaults)
660 thisinfo['id'] = appid
662 # General defaults...
663 thisinfo['builds'] = []
664 thisinfo['comments'] = []
667 return appid, thisinfo
676 for line in metafile:
678 linedesc = "%s:%d" % (metafile.name, c)
679 line = line.rstrip('\r\n')
681 if not any(line.startswith(s) for s in (' ', '\t')):
682 commit = curbuild['commit'] if 'commit' in curbuild else None
683 if not commit and 'disable' not in curbuild:
684 raise MetaDataException("No commit specified for {0} in {1}"
685 .format(curbuild['version'], linedesc))
687 thisinfo['builds'].append(curbuild)
688 add_comments('build:' + curbuild['vercode'])
691 if line.endswith('\\'):
692 buildlines.append(line[:-1].lstrip())
694 buildlines.append(line.lstrip())
695 bl = ''.join(buildlines)
696 add_buildflag(bl, curbuild)
702 if line.startswith("#"):
703 curcomments.append(line)
706 field, value = line.split(':', 1)
708 raise MetaDataException("Invalid metadata in " + linedesc)
709 if field != field.strip() or value != value.strip():
710 raise MetaDataException("Extra spacing found in " + linedesc)
712 # Translate obsolete fields...
713 if field == 'Market Version':
714 field = 'Current Version'
715 if field == 'Market Version Code':
716 field = 'Current Version Code'
718 fieldtype = metafieldtype(field)
719 if fieldtype not in ['build', 'buildv2']:
721 if fieldtype == 'multiline':
725 raise MetaDataException("Unexpected text on same line as " + field + " in " + linedesc)
726 elif fieldtype == 'string':
727 thisinfo[field] = value
728 elif fieldtype == 'list':
729 thisinfo[field] = [v.strip() for v in value.replace(';', ',').split(',')]
730 elif fieldtype == 'build':
731 if value.endswith("\\"):
733 buildlines = [value[:-1]]
735 curbuild = parse_buildline([value])
736 thisinfo['builds'].append(curbuild)
737 add_comments('build:' + thisinfo['builds'][-1]['vercode'])
738 elif fieldtype == 'buildv2':
740 vv = value.split(',')
742 raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
743 .format(value, linedesc))
744 curbuild['version'] = vv[0]
745 curbuild['vercode'] = vv[1]
746 if curbuild['vercode'] in vc_seen:
747 raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
748 curbuild['vercode'], linedesc))
749 vc_seen[curbuild['vercode']] = True
752 elif fieldtype == 'obsolete':
753 pass # Just throw it away!
755 raise MetaDataException("Unrecognised field type for " + field + " in " + linedesc)
756 elif mode == 1: # Multiline field
760 thisinfo[field].append(line)
761 elif mode == 2: # Line continuation mode in Build Version
762 if line.endswith("\\"):
763 buildlines.append(line[:-1])
765 buildlines.append(line)
766 curbuild = parse_buildline(buildlines)
767 thisinfo['builds'].append(curbuild)
768 add_comments('build:' + thisinfo['builds'][-1]['vercode'])
772 # Mode at end of file should always be 0...
774 raise MetaDataException(field + " not terminated in " + metafile.name)
776 raise MetaDataException("Unterminated continuation in " + metafile.name)
778 raise MetaDataException("Unterminated build in " + metafile.name)
780 if not thisinfo['Description']:
781 thisinfo['Description'].append('No description available')
783 for build in thisinfo['builds']:
784 fill_build_defaults(build)
786 return (appid, thisinfo)
789 # Write a metadata file.
791 # 'dest' - The path to the output file
792 # 'app' - The app data
793 def write_metadata(dest, app):
795 def writecomments(key):
797 for pf, comment in app['comments']:
799 mf.write("%s\n" % comment)
802 logging.debug("...writing comments for " + (key or 'EOF'))
804 def writefield(field, value=None):
808 t = metafieldtype(field)
810 value = ','.join(value)
811 mf.write("%s:%s\n" % (field, value))
813 def writefield_nonempty(field, value=None):
817 writefield(field, value)
820 writefield_nonempty('Disabled')
821 writefield_nonempty('AntiFeatures')
822 writefield_nonempty('Provides')
823 writefield('Categories')
824 writefield('License')
825 writefield('Web Site')
826 writefield('Source Code')
827 writefield('Issue Tracker')
828 writefield_nonempty('Donate')
829 writefield_nonempty('FlattrID')
830 writefield_nonempty('Bitcoin')
831 writefield_nonempty('Litecoin')
832 writefield_nonempty('Dogecoin')
834 writefield_nonempty('Name')
835 writefield_nonempty('Auto Name')
836 writefield('Summary')
837 writefield('Description', '')
838 for line in app['Description']:
839 mf.write("%s\n" % line)
842 if app['Requires Root']:
843 writefield('Requires Root', 'Yes')
846 writefield('Repo Type')
849 for build in app['builds']:
851 if build['version'] == "Ignore":
854 writecomments('build:' + build['vercode'])
855 mf.write("Build:%s,%s\n" % (build['version'], build['vercode']))
857 def write_builditem(key, value):
859 if key in ['version', 'vercode']:
862 if value == flag_defaults[key]:
867 logging.debug("...writing {0} : {1}".format(key, value))
868 outline = ' %s=' % key
875 outline += '&& \\\n '.join([s.lstrip() for s in value.split('&& ')])
877 outline += ','.join(value) if type(value) == list else value
882 for flag in flag_defaults:
885 write_builditem(flag, value)
888 if app['Maintainer Notes']:
889 writefield('Maintainer Notes', '')
890 for line in app['Maintainer Notes']:
891 mf.write("%s\n" % line)
895 writefield_nonempty('Archive Policy')
896 writefield('Auto Update Mode')
897 writefield('Update Check Mode')
898 writefield_nonempty('Update Check Ignore')
899 writefield_nonempty('Vercode Operation')
900 writefield_nonempty('Update Check Name')
901 writefield_nonempty('Update Check Data')
902 if app['Current Version']:
903 writefield('Current Version')
904 writefield('Current Version Code')
906 if app['No Source Since']:
907 writefield('No Source Since')