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 class MetaDataException(Exception):
27 def __init__(self, value):
31 return repr(self.value)
37 'Categories': ['None'],
51 'Archive Policy': None,
52 'Update Check Mode': 'None',
53 'Update Check Name': None,
54 'Update Check Data': None,
55 'Vercode Operation': None,
56 'Auto Update Mode': 'None',
57 'Current Version': '',
58 'Current Version Code': '0',
61 'Requires Root': False,
66 # This defines the preferred order for the build items - as in the
67 # manual, they're roughly in order of application.
69 'disable', 'commit', 'subdir', 'submodules', 'init',
70 'gradle', 'maven', 'kivy', 'output', 'oldsdkloc', 'target',
71 'update', 'encoding', 'forceversion', 'forcevercode', 'rm',
72 'extlibs', 'srclibs', 'patch', 'prebuild', 'scanignore',
73 'scandelete', 'build', 'buildjni', 'preassemble', 'bindir',
74 'antcommand', 'novcheck'
78 # Designates a metadata field type and checks that it matches
80 # 'name' - The long name of the field type
81 # 'matching' - List of possible values or regex expression
82 # 'sep' - Separator to use if value may be a list
83 # 'fields' - Metadata fields (Field:Value) of this type
84 # 'attrs' - Build attributes (attr=value) of this type
87 def __init__(self, name, matching, sep, fields, attrs):
89 self.matching = matching
90 if type(matching) is str:
91 self.compiled = re.compile(matching)
96 def _assert_regex(self, values, appid):
98 if not self.compiled.match(v):
99 raise MetaDataException("'%s' is not a valid %s in %s. "
100 % (v, self.name, appid) +
101 "Regex pattern: %s" % (self.matching))
103 def _assert_list(self, values, appid):
105 if v not in self.matching:
106 raise MetaDataException("'%s' is not a valid %s in %s. "
107 % (v, self.name, appid) +
108 "Possible values: %s" % (", ".join(self.matching)))
110 def check(self, value, appid):
111 if type(value) is not str or not value:
113 if self.sep is not None:
114 values = value.split(self.sep)
117 if type(self.matching) is list:
118 self._assert_list(values, appid)
120 self._assert_regex(values, appid)
123 # Generic value types
125 'int': FieldType("Integer",
126 r'^[1-9][0-9]*$', None,
130 'http': FieldType("HTTP link",
131 r'^http[s]?://', None,
132 ["Web Site", "Source Code", "Issue Tracker", "Donate"], []),
134 'bitcoin': FieldType("Bitcoin address",
135 r'^[a-zA-Z0-9]{27,34}$', None,
139 'litecoin': FieldType("Litecoin address",
140 r'^L[a-zA-Z0-9]{33}$', None,
144 'dogecoin': FieldType("Dogecoin address",
145 r'^D[a-zA-Z0-9]{33}$', None,
149 'Bool': FieldType("Boolean",
154 'bool': FieldType("Boolean",
157 ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
160 'Repo Type': FieldType("Repo Type",
161 ['git', 'git-svn', 'svn', 'hg', 'bzr', 'srclib'], None,
165 'archive': FieldType("Archive Policy",
166 r'^[0-9]+ versions$', None,
170 'antifeatures': FieldType("Anti-Feature",
171 ["Ads", "Tracking", "NonFreeNet", "NonFreeDep", "NonFreeAdd", "UpstreamNonFree"], ',',
175 'autoupdatemodes': FieldType("Auto Update Mode",
176 r"^(Version .+|None)$", None,
177 ["Auto Update Mode"],
180 'updatecheckmodes': FieldType("Update Check Mode",
181 r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$", None,
182 ["Update Check Mode"],
186 # Check an app's metadata information for integrity errors
187 def check_metadata(info):
188 for k, t in valuetypes.iteritems():
189 for field in t.fields:
191 t.check(info[field], info['id'])
193 info[field] = info[field] == "Yes"
194 for build in info['builds']:
197 t.check(build[attr], info['id'])
199 build[attr] = build[attr] == "yes"
203 # Formatter for descriptions. Create an instance, and call parseline() with
204 # each line of the description source from the metadata. At the end, call
205 # end() and then text_plain, text_wiki and text_html will contain the result.
206 class DescriptionFormatter:
218 def __init__(self, linkres):
219 self.linkResolver = linkres
220 def endcur(self, notstates=None):
221 if notstates and self.state in notstates:
223 if self.state == self.stPARA:
225 elif self.state == self.stUL:
227 elif self.state == self.stOL:
230 self.text_plain += '\n'
231 self.text_html += '</p>'
232 self.state = self.stNONE
234 self.text_html += '</ul>'
235 self.state = self.stNONE
237 self.text_html += '</ol>'
238 self.state = self.stNONE
240 def formatted(self, txt, html):
243 txt = cgi.escape(txt)
245 index = txt.find("''")
247 return formatted + txt
248 formatted += txt[:index]
250 if txt.startswith("'''"):
256 self.bold = not self.bold
264 self.ital = not self.ital
268 def linkify(self, txt):
272 index = txt.find("[")
274 return (linkified_plain + self.formatted(txt, False), linkified_html + self.formatted(txt, True))
275 linkified_plain += self.formatted(txt[:index], False)
276 linkified_html += self.formatted(txt[:index], True)
278 if txt.startswith("[["):
279 index = txt.find("]]")
281 raise MetaDataException("Unterminated ]]")
283 if self.linkResolver:
284 url, urltext = self.linkResolver(url)
287 linkified_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
288 linkified_plain += urltext
291 index = txt.find("]")
293 raise MetaDataException("Unterminated ]")
295 index2 = url.find(' ')
299 urltxt = url[index2 + 1:]
301 linkified_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
302 linkified_plain += urltxt
304 linkified_plain += ' (' + url + ')'
307 def addtext(self, txt):
308 p, h = self.linkify(txt)
312 def parseline(self, line):
313 self.text_wiki += "%s\n" % line
316 elif line.startswith('* '):
317 self.endcur([self.stUL])
318 if self.state != self.stUL:
319 self.text_html += '<ul>'
320 self.state = self.stUL
321 self.text_html += '<li>'
322 self.text_plain += '* '
323 self.addtext(line[1:])
324 self.text_html += '</li>'
325 elif line.startswith('# '):
326 self.endcur([self.stOL])
327 if self.state != self.stOL:
328 self.text_html += '<ol>'
329 self.state = self.stOL
330 self.text_html += '<li>'
331 self.text_plain += '* ' #TODO: lazy - put the numbers in!
332 self.addtext(line[1:])
333 self.text_html += '</li>'
335 self.endcur([self.stPARA])
336 if self.state == self.stNONE:
337 self.text_html += '<p>'
338 self.state = self.stPARA
339 elif self.state == self.stPARA:
340 self.text_html += ' '
341 self.text_plain += ' '
347 # Parse multiple lines of description as written in a metadata file, returning
348 # a single string in plain text format.
349 def description_plain(lines, linkres):
350 ps = DescriptionFormatter(linkres)
356 # Parse multiple lines of description as written in a metadata file, returning
357 # a single string in wiki format. Used for the Maintainer Notes field as well,
358 # because it's the same format.
359 def description_wiki(lines):
360 ps = DescriptionFormatter(None)
366 # Parse multiple lines of description as written in a metadata file, returning
367 # a single string in HTML format.
368 def description_html(lines, linkres):
369 ps = DescriptionFormatter(linkres)
375 def parse_srclib(metafile, **kw):
378 if metafile and not isinstance(metafile, file):
379 metafile = open(metafile, "r")
381 # Defaults for fields that come from metadata
382 thisinfo['Repo Type'] = ''
383 thisinfo['Repo'] = ''
384 thisinfo['Subdir'] = None
385 thisinfo['Prepare'] = None
386 thisinfo['Srclibs'] = None
392 for line in metafile:
394 line = line.rstrip('\r\n')
395 if not line or line.startswith("#"):
399 field, value = line.split(':', 1)
401 raise MetaDataException("Invalid metadata in %s:%d" % (line, n))
403 if field == "Subdir":
404 thisinfo[field] = value.split(',')
406 thisinfo[field] = value
410 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
411 # returned by the parse_metadata function.
412 def read_metadata(xref=True, package=None, store=True):
415 for basedir in ('metadata', 'tmp'):
416 if not os.path.exists(basedir):
419 for metafile in sorted(glob.glob(os.path.join('metadata', '*.txt'))):
420 if package is None or metafile == os.path.join('metadata', package + '.txt'):
421 appinfo = parse_metadata(metafile)
422 check_metadata(appinfo)
426 # Parse all descriptions at load time, just to ensure cross-referencing
427 # errors are caught early rather than when they hit the build server.
430 if app['id'] == link:
431 return ("fdroid.app:" + link, "Dummy name - don't know yet")
432 raise MetaDataException("Cannot resolve app id " + link)
435 description_html(app['Description'], linkres)
437 raise MetaDataException("Problem with description of " + app['id'] +
442 # Get the type expected for a given metadata field.
443 def metafieldtype(name):
444 if name in ['Description', 'Maintainer Notes']:
446 if name in ['Categories']:
448 if name == 'Build Version':
452 if name == 'Use Built':
454 if name not in app_defaults:
459 if name in ['extlibs', 'srclibs', 'patch', 'rm', 'buildjni',
460 'update', 'scanignore', 'scandelete']:
462 if name in ['init', 'prebuild', 'build']:
466 # Parse metadata for a single application.
468 # 'metafile' - the filename to read. The package id for the application comes
469 # from this filename. Pass None to get a blank entry.
471 # Returns a dictionary containing all the details of the application. There are
472 # two major kinds of information in the dictionary. Keys beginning with capital
473 # letters correspond directory to identically named keys in the metadata file.
474 # Keys beginning with lower case letters are generated in one way or another,
475 # and are not found verbatim in the metadata.
477 # Known keys not originating from the metadata are:
479 # 'id' - the application's package ID
480 # 'builds' - a list of dictionaries containing build information
481 # for each defined build
482 # 'comments' - a list of comments from the metadata file. Each is
483 # a tuple of the form (field, comment) where field is
484 # the name of the field it preceded in the metadata
485 # file. Where field is None, the comment goes at the
486 # end of the file. Alternatively, 'build:version' is
487 # for a comment before a particular build version.
488 # 'descriptionlines' - original lines of description as formatted in the
491 def parse_metadata(metafile):
495 def add_buildflag(p, thisbuild):
498 raise MetaDataException("Invalid build flag at {0} in {1}".
499 format(buildlines[0], linedesc))
502 raise MetaDataException("Duplicate definition on {0} in version {1} of {2}".
503 format(pk, thisbuild['version'], linedesc))
506 if pk not in ordered_flags:
507 raise MetaDataException("Unrecognised build flag at {0} in {1}".
511 # Port legacy ';' separators
512 thisbuild[pk] = [v.strip() for v in pv.replace(';', ',').split(',')]
518 raise MetaDataException("Unrecognised build flag type '%s' at %s in %s" % (
521 def parse_buildline(lines):
522 value = "".join(lines)
523 parts = [p.replace("\\,", ",")
524 for p in re.split(r"(?<!\\),", value)]
526 raise MetaDataException("Invalid build format: " + value + " in " + metafile.name)
528 thisbuild['origlines'] = lines
529 thisbuild['version'] = parts[0]
530 thisbuild['vercode'] = parts[1]
531 if parts[2].startswith('!'):
532 # For backwards compatibility, handle old-style disabling,
533 # including attempting to extract the commit from the message
534 thisbuild['disable'] = parts[2][1:]
535 commit = 'unknown - see disabled'
536 index = parts[2].rfind('at ')
538 commit = parts[2][index+3:]
539 if commit.endswith(')'):
541 thisbuild['commit'] = commit
543 thisbuild['commit'] = parts[2]
545 add_buildflag(p, thisbuild)
549 def add_comments(key):
552 for comment in curcomments:
553 thisinfo['comments'].append((key, comment))
556 def get_build_type(build):
557 for t in ['maven', 'gradle', 'kivy']:
558 if build.get(t, 'no') != 'no':
560 if 'output' in build:
566 if not isinstance(metafile, file):
567 metafile = open(metafile, "r")
568 thisinfo['id'] = metafile.name[9:-4]
570 thisinfo['id'] = None
572 thisinfo.update(app_defaults)
574 # General defaults...
575 thisinfo['builds'] = []
576 thisinfo['comments'] = []
587 for line in metafile:
589 linedesc = "%s:%d" % (metafile.name, c)
590 line = line.rstrip('\r\n')
592 if not any(line.startswith(s) for s in (' ', '\t')):
593 if 'commit' not in curbuild and 'disable' not in curbuild:
594 raise MetaDataException("No commit specified for {0} in {1}".format(
595 curbuild['version'], linedesc))
596 thisinfo['builds'].append(curbuild)
597 add_comments('build:' + curbuild['version'])
600 if line.endswith('\\'):
601 buildlines.append(line[:-1].lstrip())
603 buildlines.append(line.lstrip())
604 bl = ''.join(buildlines)
605 add_buildflag(bl, curbuild)
611 if line.startswith("#"):
612 curcomments.append(line)
615 field, value = line.split(':', 1)
617 raise MetaDataException("Invalid metadata in "+linedesc)
618 if field != field.strip() or value != value.strip():
619 raise MetaDataException("Extra spacing found in "+linedesc)
621 # Translate obsolete fields...
622 if field == 'Market Version':
623 field = 'Current Version'
624 if field == 'Market Version Code':
625 field = 'Current Version Code'
627 fieldtype = metafieldtype(field)
628 if fieldtype not in ['build', 'buildv2']:
630 if fieldtype == 'multiline':
634 raise MetaDataException("Unexpected text on same line as " + field + " in " + linedesc)
635 elif fieldtype == 'string':
636 thisinfo[field] = value
637 elif fieldtype == 'list':
638 thisinfo[field] = [v.strip() for v in value.replace(';', ',').split(',')]
639 elif fieldtype == 'build':
640 if value.endswith("\\"):
642 buildlines = [value[:-1]]
644 thisinfo['builds'].append(parse_buildline([value]))
645 add_comments('build:' + thisinfo['builds'][-1]['version'])
646 elif fieldtype == 'buildv2':
648 vv = value.split(',')
650 raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'.
651 format(value, linedesc))
652 curbuild['version'] = vv[0]
653 curbuild['vercode'] = vv[1]
656 elif fieldtype == 'obsolete':
657 pass # Just throw it away!
659 raise MetaDataException("Unrecognised field type for " + field + " in " + linedesc)
660 elif mode == 1: # Multiline field
664 thisinfo[field].append(line)
665 elif mode == 2: # Line continuation mode in Build Version
666 if line.endswith("\\"):
667 buildlines.append(line[:-1])
669 buildlines.append(line)
670 thisinfo['builds'].append(
671 parse_buildline(buildlines))
672 add_comments('build:' + thisinfo['builds'][-1]['version'])
676 # Mode at end of file should always be 0...
678 raise MetaDataException(field + " not terminated in " + metafile.name)
680 raise MetaDataException("Unterminated continuation in " + metafile.name)
682 raise MetaDataException("Unterminated build in " + metafile.name)
684 if not thisinfo['Description']:
685 thisinfo['Description'].append('No description available')
687 for build in thisinfo['builds']:
688 build['type'] = get_build_type(build)
692 # Write a metadata file.
694 # 'dest' - The path to the output file
695 # 'app' - The app data
696 def write_metadata(dest, app):
698 def writecomments(key):
700 for pf, comment in app['comments']:
702 mf.write("%s\n" % comment)
705 logging.debug("...writing comments for " + (key if key else 'EOF'))
707 def writefield(field, value=None):
711 t = metafieldtype(field)
713 value = ','.join(value)
714 mf.write("%s:%s\n" % (field, value))
718 writefield('Disabled')
719 if app['AntiFeatures']:
720 writefield('AntiFeatures')
722 writefield('Provides')
723 writefield('Categories')
724 writefield('License')
725 writefield('Web Site')
726 writefield('Source Code')
727 writefield('Issue Tracker')
731 writefield('FlattrID')
733 writefield('Bitcoin')
735 writefield('Litecoin')
737 writefield('Dogecoin')
742 writefield('Auto Name')
743 writefield('Summary')
744 writefield('Description', '')
745 for line in app['Description']:
746 mf.write("%s\n" % line)
749 if app['Requires Root']:
750 writefield('Requires Root', 'Yes')
753 writefield('Repo Type')
756 for build in app['builds']:
757 writecomments('build:' + build['version'])
758 mf.write("Build:%s,%s\n" % (build['version'], build['vercode']))
760 def write_builditem(key, value):
761 if key in ['version', 'vercode', 'origlines', 'type']:
763 if key in valuetypes['bool'].attrs:
768 logging.debug("...writing {0} : {1}".format(key, value))
769 outline = ' %s=' % key
773 outline += '&& \\\n '.join([s.lstrip() for s in value.split('&& ')])
775 outline += ','.join(value) if type(value) == list else value
779 for key in ordered_flags:
781 write_builditem(key, build[key])
784 if 'Maintainer Notes' in app:
785 writefield('Maintainer Notes', '')
786 for line in app['Maintainer Notes']:
787 mf.write("%s\n" % line)
792 if app['Archive Policy']:
793 writefield('Archive Policy')
794 writefield('Auto Update Mode')
795 writefield('Update Check Mode')
796 if app['Vercode Operation']:
797 writefield('Vercode Operation')
798 if app['Update Check Data']:
799 writefield('Update Check Data')
800 if app['Current Version']:
801 writefield('Current Version')
802 writefield('Current Version Code')
804 if app['No Source Since']:
805 writefield('No Source Since')