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/>.
29 class MetaDataException(Exception):
30 def __init__(self, value):
40 'Categories': ['None'],
54 'Requires Root': False,
57 'Maintainer Notes': [],
58 'Archive Policy': None,
59 'Auto Update Mode': 'None',
60 'Update Check Mode': 'None',
61 'Update Check Ignore': None,
62 'Vercode Operation': None,
63 'Update Check Name': None,
64 'Update Check Data': None,
65 'Current Version': '',
66 'Current Version Code': '0',
71 # This defines the preferred order for the build items - as in the
72 # manual, they're roughly in order of application.
74 'disable', 'commit', 'subdir', 'submodules', 'init',
75 'gradle', 'maven', 'kivy', 'output', 'oldsdkloc', 'target',
76 'update', 'encoding', 'forceversion', 'forcevercode', 'rm',
77 'extlibs', 'srclibs', 'patch', 'prebuild', 'scanignore',
78 'scandelete', 'build', 'buildjni', 'preassemble', 'bindir',
79 'antcommand', 'novcheck'
83 # Designates a metadata field type and checks that it matches
85 # 'name' - The long name of the field type
86 # 'matching' - List of possible values or regex expression
87 # 'sep' - Separator to use if value may be a list
88 # 'fields' - Metadata fields (Field:Value) of this type
89 # 'attrs' - Build attributes (attr=value) of this type
91 class FieldValidator():
93 def __init__(self, name, matching, sep, fields, attrs):
95 self.matching = matching
96 if type(matching) is str:
97 self.compiled = re.compile(matching)
102 def _assert_regex(self, values, appid):
104 if not self.compiled.match(v):
105 raise MetaDataException("'%s' is not a valid %s in %s. "
106 % (v, self.name, appid) +
107 "Regex pattern: %s" % (self.matching))
109 def _assert_list(self, values, appid):
111 if v not in self.matching:
112 raise MetaDataException("'%s' is not a valid %s in %s. "
113 % (v, self.name, appid) +
114 "Possible values: %s" % (", ".join(self.matching)))
116 def check(self, value, appid):
117 if type(value) is not str or not value:
119 if self.sep is not None:
120 values = value.split(self.sep)
123 if type(self.matching) is list:
124 self._assert_list(values, appid)
126 self._assert_regex(values, appid)
129 # Generic value types
131 FieldValidator("Integer",
132 r'^[1-9][0-9]*$', None,
136 FieldValidator("HTTP link",
137 r'^http[s]?://', None,
138 ["Web Site", "Source Code", "Issue Tracker", "Donate"], []),
140 FieldValidator("Bitcoin address",
141 r'^[a-zA-Z0-9]{27,34}$', None,
145 FieldValidator("Litecoin address",
146 r'^L[a-zA-Z0-9]{33}$', None,
150 FieldValidator("Dogecoin address",
151 r'^D[a-zA-Z0-9]{33}$', None,
155 FieldValidator("Boolean",
160 FieldValidator("bool",
163 ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
166 FieldValidator("Repo Type",
167 ['git', 'git-svn', 'svn', 'hg', 'bzr', 'srclib'], None,
171 FieldValidator("Archive Policy",
172 r'^[0-9]+ versions$', None,
176 FieldValidator("Anti-Feature",
177 ["Ads", "Tracking", "NonFreeNet", "NonFreeDep", "NonFreeAdd", "UpstreamNonFree"], ',',
181 FieldValidator("Auto Update Mode",
182 r"^(Version .+|None)$", None,
183 ["Auto Update Mode"],
186 FieldValidator("Update Check Mode",
187 r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$", None,
188 ["Update Check Mode"],
193 # Check an app's metadata information for integrity errors
194 def check_metadata(info):
196 for field in v.fields:
198 v.check(info[field], info['id'])
199 for build in info['builds']:
202 v.check(build[attr], info['id'])
205 # Formatter for descriptions. Create an instance, and call parseline() with
206 # each line of the description source from the metadata. At the end, call
207 # end() and then text_plain, text_wiki and text_html will contain the result.
208 class DescriptionFormatter:
221 def __init__(self, linkres):
222 self.linkResolver = linkres
224 def endcur(self, notstates=None):
225 if notstates and self.state in notstates:
227 if self.state == self.stPARA:
229 elif self.state == self.stUL:
231 elif self.state == self.stOL:
235 self.text_plain += '\n'
236 self.text_html += '</p>'
237 self.state = self.stNONE
240 self.text_html += '</ul>'
241 self.state = self.stNONE
244 self.text_html += '</ol>'
245 self.state = self.stNONE
247 def formatted(self, txt, html):
250 txt = cgi.escape(txt)
252 index = txt.find("''")
254 return formatted + txt
255 formatted += txt[:index]
257 if txt.startswith("'''"):
263 self.bold = not self.bold
271 self.ital = not self.ital
274 def linkify(self, txt):
278 index = txt.find("[")
280 return (linkified_plain + self.formatted(txt, False), linkified_html + self.formatted(txt, True))
281 linkified_plain += self.formatted(txt[:index], False)
282 linkified_html += self.formatted(txt[:index], True)
284 if txt.startswith("[["):
285 index = txt.find("]]")
287 raise MetaDataException("Unterminated ]]")
289 if self.linkResolver:
290 url, urltext = self.linkResolver(url)
293 linkified_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
294 linkified_plain += urltext
295 txt = txt[index + 2:]
297 index = txt.find("]")
299 raise MetaDataException("Unterminated ]")
301 index2 = url.find(' ')
305 urltxt = url[index2 + 1:]
307 linkified_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
308 linkified_plain += urltxt
310 linkified_plain += ' (' + url + ')'
311 txt = txt[index + 1:]
313 def addtext(self, txt):
314 p, h = self.linkify(txt)
318 def parseline(self, line):
319 self.text_wiki += "%s\n" % line
322 elif line.startswith('* '):
323 self.endcur([self.stUL])
324 if self.state != self.stUL:
325 self.text_html += '<ul>'
326 self.state = self.stUL
327 self.text_html += '<li>'
328 self.text_plain += '* '
329 self.addtext(line[1:])
330 self.text_html += '</li>'
331 elif line.startswith('# '):
332 self.endcur([self.stOL])
333 if self.state != self.stOL:
334 self.text_html += '<ol>'
335 self.state = self.stOL
336 self.text_html += '<li>'
337 self.text_plain += '* ' # TODO: lazy - put the numbers in!
338 self.addtext(line[1:])
339 self.text_html += '</li>'
341 self.endcur([self.stPARA])
342 if self.state == self.stNONE:
343 self.text_html += '<p>'
344 self.state = self.stPARA
345 elif self.state == self.stPARA:
346 self.text_html += ' '
347 self.text_plain += ' '
354 # Parse multiple lines of description as written in a metadata file, returning
355 # a single string in plain text format.
356 def description_plain(lines, linkres):
357 ps = DescriptionFormatter(linkres)
364 # Parse multiple lines of description as written in a metadata file, returning
365 # a single string in wiki format. Used for the Maintainer Notes field as well,
366 # because it's the same format.
367 def description_wiki(lines):
368 ps = DescriptionFormatter(None)
375 # Parse multiple lines of description as written in a metadata file, returning
376 # a single string in HTML format.
377 def description_html(lines, linkres):
378 ps = DescriptionFormatter(linkres)
385 def parse_srclib(metafile):
388 if metafile and not isinstance(metafile, file):
389 metafile = open(metafile, "r")
391 # Defaults for fields that come from metadata
392 thisinfo['Repo Type'] = ''
393 thisinfo['Repo'] = ''
394 thisinfo['Subdir'] = None
395 thisinfo['Prepare'] = None
396 thisinfo['Srclibs'] = None
402 for line in metafile:
404 line = line.rstrip('\r\n')
405 if not line or line.startswith("#"):
409 field, value = line.split(':', 1)
411 raise MetaDataException("Invalid metadata in %s:%d" % (line, n))
413 if field == "Subdir":
414 thisinfo[field] = value.split(',')
416 thisinfo[field] = value
422 """Read all srclib metadata.
424 The information read will be accessible as metadata.srclibs, which is a
425 dictionary, keyed on srclib name, with the values each being a dictionary
426 in the same format as that returned by the parse_srclib function.
428 A MetaDataException is raised if there are any problems with the srclib
435 if not os.path.exists(srcdir):
438 for metafile in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
439 srclibname = os.path.basename(metafile[:-4])
440 srclibs[srclibname] = parse_srclib(metafile)
443 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
444 # returned by the parse_metadata function.
445 def read_metadata(xref=True):
448 for basedir in ('metadata', 'tmp'):
449 if not os.path.exists(basedir):
452 for metafile in sorted(glob.glob(os.path.join('metadata', '*.txt'))):
453 appinfo = parse_metadata(metafile)
454 check_metadata(appinfo)
458 # Parse all descriptions at load time, just to ensure cross-referencing
459 # errors are caught early rather than when they hit the build server.
462 if app['id'] == link:
463 return ("fdroid.app:" + link, "Dummy name - don't know yet")
464 raise MetaDataException("Cannot resolve app id " + link)
467 description_html(app['Description'], linkres)
469 raise MetaDataException("Problem with description of " + app['id'] +
475 # Get the type expected for a given metadata field.
476 def metafieldtype(name):
477 if name in ['Description', 'Maintainer Notes']:
479 if name in ['Categories']:
481 if name == 'Build Version':
485 if name == 'Use Built':
487 if name not in app_defaults:
493 if name in ['extlibs', 'srclibs', 'patch', 'rm', 'buildjni',
494 'update', 'scanignore', 'scandelete']:
496 if name in ['init', 'prebuild', 'build']:
498 if name in ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
504 # Parse metadata for a single application.
506 # 'metafile' - the filename to read. The package id for the application comes
507 # from this filename. Pass None to get a blank entry.
509 # Returns a dictionary containing all the details of the application. There are
510 # two major kinds of information in the dictionary. Keys beginning with capital
511 # letters correspond directory to identically named keys in the metadata file.
512 # Keys beginning with lower case letters are generated in one way or another,
513 # and are not found verbatim in the metadata.
515 # Known keys not originating from the metadata are:
517 # 'id' - the application's package ID
518 # 'builds' - a list of dictionaries containing build information
519 # for each defined build
520 # 'comments' - a list of comments from the metadata file. Each is
521 # a tuple of the form (field, comment) where field is
522 # the name of the field it preceded in the metadata
523 # file. Where field is None, the comment goes at the
524 # end of the file. Alternatively, 'build:version' is
525 # for a comment before a particular build version.
526 # 'descriptionlines' - original lines of description as formatted in the
529 def parse_metadata(metafile):
533 def add_buildflag(p, thisbuild):
536 raise MetaDataException("Invalid build flag at {0} in {1}"
537 .format(buildlines[0], linedesc))
540 raise MetaDataException("Duplicate definition on {0} in version {1} of {2}"
541 .format(pk, thisbuild['version'], linedesc))
544 if pk not in ordered_flags:
545 raise MetaDataException("Unrecognised build flag at {0} in {1}"
546 .format(p, linedesc))
549 # Port legacy ';' separators
550 thisbuild[pk] = [v.strip() for v in pv.replace(';', ',').split(',')]
551 elif t == 'string' or t == 'script':
558 logging.debug("...ignoring bool flag %s" % p)
561 raise MetaDataException("Unrecognised build flag type '%s' at %s in %s"
564 def parse_buildline(lines):
565 value = "".join(lines)
566 parts = [p.replace("\\,", ",")
567 for p in re.split(r"(?<!\\),", value)]
569 raise MetaDataException("Invalid build format: " + value + " in " + metafile.name)
571 thisbuild['origlines'] = lines
572 thisbuild['version'] = parts[0]
573 thisbuild['vercode'] = parts[1]
574 if parts[2].startswith('!'):
575 # For backwards compatibility, handle old-style disabling,
576 # including attempting to extract the commit from the message
577 thisbuild['disable'] = parts[2][1:]
578 commit = 'unknown - see disabled'
579 index = parts[2].rfind('at ')
581 commit = parts[2][index + 3:]
582 if commit.endswith(')'):
584 thisbuild['commit'] = commit
586 thisbuild['commit'] = parts[2]
588 add_buildflag(p, thisbuild)
592 def add_comments(key):
595 for comment in curcomments:
596 thisinfo['comments'].append((key, comment))
599 def get_build_type(build):
600 for t in ['maven', 'gradle', 'kivy']:
601 if build.get(t, 'no') != 'no':
603 if 'output' in build:
609 if not isinstance(metafile, file):
610 metafile = open(metafile, "r")
611 thisinfo['id'] = metafile.name[9:-4]
613 thisinfo['id'] = None
615 thisinfo.update(app_defaults)
617 # General defaults...
618 thisinfo['builds'] = []
619 thisinfo['comments'] = []
629 def fill_bool_defaults(build):
630 # TODO: quick fix to make bool flags default to False
631 # Should provide defaults for all flags instead of using
632 # build.get(flagname, default) each time
633 for f in ordered_flags:
636 if flagtype(f) == 'bool':
640 for line in metafile:
642 linedesc = "%s:%d" % (metafile.name, c)
643 line = line.rstrip('\r\n')
645 if not any(line.startswith(s) for s in (' ', '\t')):
646 if 'commit' not in curbuild and 'disable' not in curbuild:
647 raise MetaDataException("No commit specified for {0} in {1}"
648 .format(curbuild['version'], linedesc))
650 fill_bool_defaults(curbuild)
651 thisinfo['builds'].append(curbuild)
652 add_comments('build:' + curbuild['version'])
655 if line.endswith('\\'):
656 buildlines.append(line[:-1].lstrip())
658 buildlines.append(line.lstrip())
659 bl = ''.join(buildlines)
660 add_buildflag(bl, curbuild)
666 if line.startswith("#"):
667 curcomments.append(line)
670 field, value = line.split(':', 1)
672 raise MetaDataException("Invalid metadata in " + linedesc)
673 if field != field.strip() or value != value.strip():
674 raise MetaDataException("Extra spacing found in " + linedesc)
676 # Translate obsolete fields...
677 if field == 'Market Version':
678 field = 'Current Version'
679 if field == 'Market Version Code':
680 field = 'Current Version Code'
682 fieldtype = metafieldtype(field)
683 if fieldtype not in ['build', 'buildv2']:
685 if fieldtype == 'multiline':
689 raise MetaDataException("Unexpected text on same line as " + field + " in " + linedesc)
690 elif fieldtype == 'string':
691 thisinfo[field] = value
692 elif fieldtype == 'list':
693 thisinfo[field] = [v.strip() for v in value.replace(';', ',').split(',')]
694 elif fieldtype == 'build':
695 if value.endswith("\\"):
697 buildlines = [value[:-1]]
699 thisinfo['builds'].append(parse_buildline([value]))
700 add_comments('build:' + thisinfo['builds'][-1]['version'])
701 elif fieldtype == 'buildv2':
703 vv = value.split(',')
705 raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
706 .format(value, linedesc))
707 curbuild['version'] = vv[0]
708 curbuild['vercode'] = vv[1]
711 elif fieldtype == 'obsolete':
712 pass # Just throw it away!
714 raise MetaDataException("Unrecognised field type for " + field + " in " + linedesc)
715 elif mode == 1: # Multiline field
719 thisinfo[field].append(line)
720 elif mode == 2: # Line continuation mode in Build Version
721 if line.endswith("\\"):
722 buildlines.append(line[:-1])
724 buildlines.append(line)
725 curbuild = parse_buildline(buildlines)
726 fill_bool_defaults(curbuild)
727 thisinfo['builds'].append(curbuild)
728 add_comments('build:' + thisinfo['builds'][-1]['version'])
732 # Mode at end of file should always be 0...
734 raise MetaDataException(field + " not terminated in " + metafile.name)
736 raise MetaDataException("Unterminated continuation in " + metafile.name)
738 raise MetaDataException("Unterminated build in " + metafile.name)
740 if not thisinfo['Description']:
741 thisinfo['Description'].append('No description available')
743 for build in thisinfo['builds']:
744 build['type'] = get_build_type(build)
749 # Write a metadata file.
751 # 'dest' - The path to the output file
752 # 'app' - The app data
753 def write_metadata(dest, app):
755 def writecomments(key):
757 for pf, comment in app['comments']:
759 mf.write("%s\n" % comment)
762 logging.debug("...writing comments for " + (key if key else 'EOF'))
764 def writefield(field, value=None):
768 t = metafieldtype(field)
770 value = ','.join(value)
771 mf.write("%s:%s\n" % (field, value))
775 writefield('Disabled')
776 if app['AntiFeatures']:
777 writefield('AntiFeatures')
779 writefield('Provides')
780 writefield('Categories')
781 writefield('License')
782 writefield('Web Site')
783 writefield('Source Code')
784 writefield('Issue Tracker')
788 writefield('FlattrID')
790 writefield('Bitcoin')
792 writefield('Litecoin')
794 writefield('Dogecoin')
799 writefield('Auto Name')
800 writefield('Summary')
801 writefield('Description', '')
802 for line in app['Description']:
803 mf.write("%s\n" % line)
806 if app['Requires Root']:
807 writefield('Requires Root', 'Yes')
810 writefield('Repo Type')
813 for build in app['builds']:
814 writecomments('build:' + build['version'])
815 mf.write("Build:%s,%s\n" % (build['version'], build['vercode']))
817 def write_builditem(key, value):
819 if key in ['version', 'vercode', 'origlines', 'type']:
823 if t == 'bool' and value == False:
826 logging.debug("...writing {0} : {1}".format(key, value))
827 outline = ' %s=' % key
834 outline += '&& \\\n '.join([s.lstrip() for s in value.split('&& ')])
836 outline += ','.join(value) if type(value) == list else value
841 for key in ordered_flags:
843 write_builditem(key, build[key])
846 if app['Maintainer Notes']:
847 writefield('Maintainer Notes', '')
848 for line in app['Maintainer Notes']:
849 mf.write("%s\n" % line)
853 if app['Archive Policy']:
854 writefield('Archive Policy')
855 writefield('Auto Update Mode')
856 writefield('Update Check Mode')
857 if app['Update Check Ignore']:
858 writefield('Update Check Ignore')
859 if app['Vercode Operation']:
860 writefield('Vercode Operation')
861 if app['Update Check Name']:
862 writefield('Update Check Name')
863 if app['Update Check Data']:
864 writefield('Update Check Data')
865 if app['Current Version']:
866 writefield('Current Version')
867 writefield('Current Version Code')
869 if app['No Source Since']:
870 writefield('No Source Since')