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 'Archive Policy': None,
55 'Update Check Mode': 'None',
56 'Update Check Ignore': None,
57 'Update Check Name': None,
58 'Update Check Data': None,
59 'Vercode Operation': None,
60 'Auto Update Mode': 'None',
61 'Current Version': '',
62 'Current Version Code': '0',
65 'Requires Root': False,
70 # This defines the preferred order for the build items - as in the
71 # manual, they're roughly in order of application.
73 'disable', 'commit', 'subdir', 'submodules', 'init',
74 'gradle', 'maven', 'kivy', 'output', 'oldsdkloc', 'target',
75 'update', 'encoding', 'forceversion', 'forcevercode', 'rm',
76 'extlibs', 'srclibs', 'patch', 'prebuild', 'scanignore',
77 'scandelete', 'build', 'buildjni', 'preassemble', 'bindir',
78 'antcommand', 'novcheck'
82 # Designates a metadata field type and checks that it matches
84 # 'name' - The long name of the field type
85 # 'matching' - List of possible values or regex expression
86 # 'sep' - Separator to use if value may be a list
87 # 'fields' - Metadata fields (Field:Value) of this type
88 # 'attrs' - Build attributes (attr=value) of this type
91 def __init__(self, name, matching, sep, fields, attrs):
93 self.matching = matching
94 if type(matching) is str:
95 self.compiled = re.compile(matching)
100 def _assert_regex(self, values, appid):
102 if not self.compiled.match(v):
103 raise MetaDataException("'%s' is not a valid %s in %s. "
104 % (v, self.name, appid) +
105 "Regex pattern: %s" % (self.matching))
107 def _assert_list(self, values, appid):
109 if v not in self.matching:
110 raise MetaDataException("'%s' is not a valid %s in %s. "
111 % (v, self.name, appid) +
112 "Possible values: %s" % (", ".join(self.matching)))
114 def check(self, value, appid):
115 if type(value) is not str or not value:
117 if self.sep is not None:
118 values = value.split(self.sep)
121 if type(self.matching) is list:
122 self._assert_list(values, appid)
124 self._assert_regex(values, appid)
127 # Generic value types
129 'int': FieldType("Integer",
130 r'^[1-9][0-9]*$', None,
134 'http': FieldType("HTTP link",
135 r'^http[s]?://', None,
136 ["Web Site", "Source Code", "Issue Tracker", "Donate"], []),
138 'bitcoin': FieldType("Bitcoin address",
139 r'^[a-zA-Z0-9]{27,34}$', None,
143 'litecoin': FieldType("Litecoin address",
144 r'^L[a-zA-Z0-9]{33}$', None,
148 'dogecoin': FieldType("Dogecoin address",
149 r'^D[a-zA-Z0-9]{33}$', None,
153 'Bool': FieldType("Boolean",
158 'bool': FieldType("Boolean",
161 ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
164 'Repo Type': FieldType("Repo Type",
165 ['git', 'git-svn', 'svn', 'hg', 'bzr', 'srclib'], None,
169 'archive': FieldType("Archive Policy",
170 r'^[0-9]+ versions$', None,
174 'antifeatures': FieldType("Anti-Feature",
175 ["Ads", "Tracking", "NonFreeNet", "NonFreeDep", "NonFreeAdd", "UpstreamNonFree"], ',',
179 'autoupdatemodes': FieldType("Auto Update Mode",
180 r"^(Version .+|None)$", None,
181 ["Auto Update Mode"],
184 'updatecheckmodes': FieldType("Update Check Mode",
185 r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$", None,
186 ["Update Check Mode"],
191 # Check an app's metadata information for integrity errors
192 def check_metadata(info):
193 for k, t in valuetypes.iteritems():
194 for field in t.fields:
196 t.check(info[field], info['id'])
198 info[field] = info[field] == "Yes"
199 for build in info['builds']:
202 t.check(build[attr], info['id'])
204 build[attr] = build[attr] == "yes"
209 # Formatter for descriptions. Create an instance, and call parseline() with
210 # each line of the description source from the metadata. At the end, call
211 # end() and then text_plain, text_wiki and text_html will contain the result.
212 class DescriptionFormatter:
225 def __init__(self, linkres):
226 self.linkResolver = linkres
228 def endcur(self, notstates=None):
229 if notstates and self.state in notstates:
231 if self.state == self.stPARA:
233 elif self.state == self.stUL:
235 elif self.state == self.stOL:
239 self.text_plain += '\n'
240 self.text_html += '</p>'
241 self.state = self.stNONE
244 self.text_html += '</ul>'
245 self.state = self.stNONE
248 self.text_html += '</ol>'
249 self.state = self.stNONE
251 def formatted(self, txt, html):
254 txt = cgi.escape(txt)
256 index = txt.find("''")
258 return formatted + txt
259 formatted += txt[:index]
261 if txt.startswith("'''"):
267 self.bold = not self.bold
275 self.ital = not self.ital
278 def linkify(self, txt):
282 index = txt.find("[")
284 return (linkified_plain + self.formatted(txt, False), linkified_html + self.formatted(txt, True))
285 linkified_plain += self.formatted(txt[:index], False)
286 linkified_html += self.formatted(txt[:index], True)
288 if txt.startswith("[["):
289 index = txt.find("]]")
291 raise MetaDataException("Unterminated ]]")
293 if self.linkResolver:
294 url, urltext = self.linkResolver(url)
297 linkified_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
298 linkified_plain += urltext
299 txt = txt[index + 2:]
301 index = txt.find("]")
303 raise MetaDataException("Unterminated ]")
305 index2 = url.find(' ')
309 urltxt = url[index2 + 1:]
311 linkified_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
312 linkified_plain += urltxt
314 linkified_plain += ' (' + url + ')'
315 txt = txt[index + 1:]
317 def addtext(self, txt):
318 p, h = self.linkify(txt)
322 def parseline(self, line):
323 self.text_wiki += "%s\n" % line
326 elif line.startswith('* '):
327 self.endcur([self.stUL])
328 if self.state != self.stUL:
329 self.text_html += '<ul>'
330 self.state = self.stUL
331 self.text_html += '<li>'
332 self.text_plain += '* '
333 self.addtext(line[1:])
334 self.text_html += '</li>'
335 elif line.startswith('# '):
336 self.endcur([self.stOL])
337 if self.state != self.stOL:
338 self.text_html += '<ol>'
339 self.state = self.stOL
340 self.text_html += '<li>'
341 self.text_plain += '* ' # TODO: lazy - put the numbers in!
342 self.addtext(line[1:])
343 self.text_html += '</li>'
345 self.endcur([self.stPARA])
346 if self.state == self.stNONE:
347 self.text_html += '<p>'
348 self.state = self.stPARA
349 elif self.state == self.stPARA:
350 self.text_html += ' '
351 self.text_plain += ' '
358 # Parse multiple lines of description as written in a metadata file, returning
359 # a single string in plain text format.
360 def description_plain(lines, linkres):
361 ps = DescriptionFormatter(linkres)
368 # Parse multiple lines of description as written in a metadata file, returning
369 # a single string in wiki format. Used for the Maintainer Notes field as well,
370 # because it's the same format.
371 def description_wiki(lines):
372 ps = DescriptionFormatter(None)
379 # Parse multiple lines of description as written in a metadata file, returning
380 # a single string in HTML format.
381 def description_html(lines, linkres):
382 ps = DescriptionFormatter(linkres)
389 def parse_srclib(metafile):
392 if metafile and not isinstance(metafile, file):
393 metafile = open(metafile, "r")
395 # Defaults for fields that come from metadata
396 thisinfo['Repo Type'] = ''
397 thisinfo['Repo'] = ''
398 thisinfo['Subdir'] = None
399 thisinfo['Prepare'] = None
400 thisinfo['Srclibs'] = None
406 for line in metafile:
408 line = line.rstrip('\r\n')
409 if not line or line.startswith("#"):
413 field, value = line.split(':', 1)
415 raise MetaDataException("Invalid metadata in %s:%d" % (line, n))
417 if field == "Subdir":
418 thisinfo[field] = value.split(',')
420 thisinfo[field] = value
426 """Read all srclib metadata.
428 The information read will be accessible as metadata.srclibs, which is a
429 dictionary, keyed on srclib name, with the values each being a dictionary
430 in the same format as that returned by the parse_srclib function.
432 A MetaDataException is raised if there are any problems with the srclib
439 if not os.path.exists(srcdir):
442 for metafile in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
443 srclibname = os.path.basename(metafile[:-4])
444 srclibs[srclibname] = parse_srclib(metafile)
447 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
448 # returned by the parse_metadata function.
449 def read_metadata(xref=True):
452 for basedir in ('metadata', 'tmp'):
453 if not os.path.exists(basedir):
456 for metafile in sorted(glob.glob(os.path.join('metadata', '*.txt'))):
457 appinfo = parse_metadata(metafile)
458 check_metadata(appinfo)
462 # Parse all descriptions at load time, just to ensure cross-referencing
463 # errors are caught early rather than when they hit the build server.
466 if app['id'] == link:
467 return ("fdroid.app:" + link, "Dummy name - don't know yet")
468 raise MetaDataException("Cannot resolve app id " + link)
471 description_html(app['Description'], linkres)
473 raise MetaDataException("Problem with description of " + app['id'] +
479 # Get the type expected for a given metadata field.
480 def metafieldtype(name):
481 if name in ['Description', 'Maintainer Notes']:
483 if name in ['Categories']:
485 if name == 'Build Version':
489 if name == 'Use Built':
491 if name not in app_defaults:
497 if name in ['extlibs', 'srclibs', 'patch', 'rm', 'buildjni',
498 'update', 'scanignore', 'scandelete']:
500 if name in ['init', 'prebuild', 'build']:
505 # Parse metadata for a single application.
507 # 'metafile' - the filename to read. The package id for the application comes
508 # from this filename. Pass None to get a blank entry.
510 # Returns a dictionary containing all the details of the application. There are
511 # two major kinds of information in the dictionary. Keys beginning with capital
512 # letters correspond directory to identically named keys in the metadata file.
513 # Keys beginning with lower case letters are generated in one way or another,
514 # and are not found verbatim in the metadata.
516 # Known keys not originating from the metadata are:
518 # 'id' - the application's package ID
519 # 'builds' - a list of dictionaries containing build information
520 # for each defined build
521 # 'comments' - a list of comments from the metadata file. Each is
522 # a tuple of the form (field, comment) where field is
523 # the name of the field it preceded in the metadata
524 # file. Where field is None, the comment goes at the
525 # end of the file. Alternatively, 'build:version' is
526 # for a comment before a particular build version.
527 # 'descriptionlines' - original lines of description as formatted in the
530 def parse_metadata(metafile):
534 def add_buildflag(p, thisbuild):
537 raise MetaDataException("Invalid build flag at {0} in {1}"
538 .format(buildlines[0], linedesc))
541 raise MetaDataException("Duplicate definition on {0} in version {1} of {2}"
542 .format(pk, thisbuild['version'], linedesc))
545 if pk not in ordered_flags:
546 raise MetaDataException("Unrecognised build flag at {0} in {1}"
547 .format(p, linedesc))
550 # Port legacy ';' separators
551 thisbuild[pk] = [v.strip() for v in pv.replace(';', ',').split(',')]
557 raise MetaDataException("Unrecognised build flag type '%s' at %s in %s"
560 def parse_buildline(lines):
561 value = "".join(lines)
562 parts = [p.replace("\\,", ",")
563 for p in re.split(r"(?<!\\),", value)]
565 raise MetaDataException("Invalid build format: " + value + " in " + metafile.name)
567 thisbuild['origlines'] = lines
568 thisbuild['version'] = parts[0]
569 thisbuild['vercode'] = parts[1]
570 if parts[2].startswith('!'):
571 # For backwards compatibility, handle old-style disabling,
572 # including attempting to extract the commit from the message
573 thisbuild['disable'] = parts[2][1:]
574 commit = 'unknown - see disabled'
575 index = parts[2].rfind('at ')
577 commit = parts[2][index + 3:]
578 if commit.endswith(')'):
580 thisbuild['commit'] = commit
582 thisbuild['commit'] = parts[2]
584 add_buildflag(p, thisbuild)
588 def add_comments(key):
591 for comment in curcomments:
592 thisinfo['comments'].append((key, comment))
595 def get_build_type(build):
596 for t in ['maven', 'gradle', 'kivy']:
597 if build.get(t, 'no') != 'no':
599 if 'output' in build:
605 if not isinstance(metafile, file):
606 metafile = open(metafile, "r")
607 thisinfo['id'] = metafile.name[9:-4]
609 thisinfo['id'] = None
611 thisinfo.update(app_defaults)
613 # General defaults...
614 thisinfo['builds'] = []
615 thisinfo['comments'] = []
626 for line in metafile:
628 linedesc = "%s:%d" % (metafile.name, c)
629 line = line.rstrip('\r\n')
631 if not any(line.startswith(s) for s in (' ', '\t')):
632 if 'commit' not in curbuild and 'disable' not in curbuild:
633 raise MetaDataException("No commit specified for {0} in {1}"
634 .format(curbuild['version'], linedesc))
635 thisinfo['builds'].append(curbuild)
636 add_comments('build:' + curbuild['version'])
639 if line.endswith('\\'):
640 buildlines.append(line[:-1].lstrip())
642 buildlines.append(line.lstrip())
643 bl = ''.join(buildlines)
644 add_buildflag(bl, curbuild)
650 if line.startswith("#"):
651 curcomments.append(line)
654 field, value = line.split(':', 1)
656 raise MetaDataException("Invalid metadata in " + linedesc)
657 if field != field.strip() or value != value.strip():
658 raise MetaDataException("Extra spacing found in " + linedesc)
660 # Translate obsolete fields...
661 if field == 'Market Version':
662 field = 'Current Version'
663 if field == 'Market Version Code':
664 field = 'Current Version Code'
666 fieldtype = metafieldtype(field)
667 if fieldtype not in ['build', 'buildv2']:
669 if fieldtype == 'multiline':
673 raise MetaDataException("Unexpected text on same line as " + field + " in " + linedesc)
674 elif fieldtype == 'string':
675 thisinfo[field] = value
676 elif fieldtype == 'list':
677 thisinfo[field] = [v.strip() for v in value.replace(';', ',').split(',')]
678 elif fieldtype == 'build':
679 if value.endswith("\\"):
681 buildlines = [value[:-1]]
683 thisinfo['builds'].append(parse_buildline([value]))
684 add_comments('build:' + thisinfo['builds'][-1]['version'])
685 elif fieldtype == 'buildv2':
687 vv = value.split(',')
689 raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
690 .format(value, linedesc))
691 curbuild['version'] = vv[0]
692 curbuild['vercode'] = vv[1]
695 elif fieldtype == 'obsolete':
696 pass # Just throw it away!
698 raise MetaDataException("Unrecognised field type for " + field + " in " + linedesc)
699 elif mode == 1: # Multiline field
703 thisinfo[field].append(line)
704 elif mode == 2: # Line continuation mode in Build Version
705 if line.endswith("\\"):
706 buildlines.append(line[:-1])
708 buildlines.append(line)
709 thisinfo['builds'].append(
710 parse_buildline(buildlines))
711 add_comments('build:' + thisinfo['builds'][-1]['version'])
715 # Mode at end of file should always be 0...
717 raise MetaDataException(field + " not terminated in " + metafile.name)
719 raise MetaDataException("Unterminated continuation in " + metafile.name)
721 raise MetaDataException("Unterminated build in " + metafile.name)
723 if not thisinfo['Description']:
724 thisinfo['Description'].append('No description available')
726 for build in thisinfo['builds']:
727 build['type'] = get_build_type(build)
732 # Write a metadata file.
734 # 'dest' - The path to the output file
735 # 'app' - The app data
736 def write_metadata(dest, app):
738 def writecomments(key):
740 for pf, comment in app['comments']:
742 mf.write("%s\n" % comment)
745 logging.debug("...writing comments for " + (key if key else 'EOF'))
747 def writefield(field, value=None):
751 t = metafieldtype(field)
753 value = ','.join(value)
754 mf.write("%s:%s\n" % (field, value))
758 writefield('Disabled')
759 if app['AntiFeatures']:
760 writefield('AntiFeatures')
762 writefield('Provides')
763 writefield('Categories')
764 writefield('License')
765 writefield('Web Site')
766 writefield('Source Code')
767 writefield('Issue Tracker')
771 writefield('FlattrID')
773 writefield('Bitcoin')
775 writefield('Litecoin')
777 writefield('Dogecoin')
782 writefield('Auto Name')
783 writefield('Summary')
784 writefield('Description', '')
785 for line in app['Description']:
786 mf.write("%s\n" % line)
789 if app['Requires Root']:
790 writefield('Requires Root', 'Yes')
793 writefield('Repo Type')
796 for build in app['builds']:
797 writecomments('build:' + build['version'])
798 mf.write("Build:%s,%s\n" % (build['version'], build['vercode']))
800 def write_builditem(key, value):
801 if key in ['version', 'vercode', 'origlines', 'type']:
803 if key in valuetypes['bool'].attrs:
808 logging.debug("...writing {0} : {1}".format(key, value))
809 outline = ' %s=' % key
813 outline += '&& \\\n '.join([s.lstrip() for s in value.split('&& ')])
815 outline += ','.join(value) if type(value) == list else value
819 for key in ordered_flags:
821 write_builditem(key, build[key])
824 if 'Maintainer Notes' in app:
825 writefield('Maintainer Notes', '')
826 for line in app['Maintainer Notes']:
827 mf.write("%s\n" % line)
831 if app['Archive Policy']:
832 writefield('Archive Policy')
833 writefield('Auto Update Mode')
834 writefield('Update Check Mode')
835 if app['Update Check Ignore']:
836 writefield('Update Check Ignore')
837 if app['Vercode Operation']:
838 writefield('Vercode Operation')
839 if app['Update Check Data']:
840 writefield('Update Check Data')
841 if app['Current Version']:
842 writefield('Current Version')
843 writefield('Current Version Code')
845 if app['No Source Since']:
846 writefield('No Source Since')