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/>.
24 class MetaDataException(Exception):
25 def __init__(self, value):
29 return repr(self.value)
49 'Archive Policy': None,
50 'Update Check Mode': 'None',
51 'Update Check Data': None,
52 'Vercode Operation': None,
53 'Auto Update Mode': 'None',
54 'Current Version': '',
55 'Current Version Code': '0',
58 'Requires Root': False,
63 # This defines the preferred order for the build items - as in the
64 # manual, they're roughly in order of application.
66 'disable', 'commit', 'subdir', 'submodules', 'init',
67 'gradle', 'maven', 'kivy', 'output', 'oldsdkloc', 'target',
68 'update', 'encoding', 'forceversion', 'forcevercode', 'rm',
69 'extlibs', 'srclibs', 'patch', 'prebuild', 'scanignore',
70 'scandelete', 'build', 'buildjni', 'preassemble', 'bindir',
71 'antcommand', 'novcheck'
75 # Designates a metadata field type and checks that it matches
77 # 'name' - The long name of the field type
78 # 'matching' - List of possible values or regex expression
79 # 'sep' - Separator to use if value may be a list
80 # 'fields' - Metadata fields (Field:Value) of this type
81 # 'attrs' - Build attributes (attr=value) of this type
84 def __init__(self, name, matching, sep, fields, attrs):
86 self.matching = matching
87 if type(matching) is str:
88 self.compiled = re.compile(matching)
93 def _assert_regex(self, values, appid):
95 if not self.compiled.match(v):
96 raise MetaDataException("'%s' is not a valid %s in %s. "
97 % (v, self.name, appid) +
98 "Regex pattern: %s" % (self.matching))
100 def _assert_list(self, values, appid):
102 if v not in self.matching:
103 raise MetaDataException("'%s' is not a valid %s in %s. "
104 % (v, self.name, appid) +
105 "Possible values: %s" % (", ".join(self.matching)))
107 def check(self, value, appid):
108 if type(value) is not str or not value:
110 if self.sep is not None:
111 values = value.split(self.sep)
114 if type(self.matching) is list:
115 self._assert_list(values, appid)
117 self._assert_regex(values, appid)
120 # Generic value types
122 'int' : FieldType("Integer",
123 r'^[1-9][0-9]*$', None,
127 'http' : FieldType("HTTP link",
128 r'^http[s]?://', None,
129 [ "Web Site", "Source Code", "Issue Tracker", "Donate" ], []),
131 'bitcoin' : FieldType("Bitcoin address",
132 r'^[a-zA-Z0-9]{27,34}$', None,
136 'litecoin' : FieldType("Litecoin address",
137 r'^L[a-zA-Z0-9]{33}$', None,
141 'dogecoin' : FieldType("Dogecoin address",
142 r'^D[a-zA-Z0-9]{33}$', None,
146 'Bool' : FieldType("Boolean",
151 'bool' : FieldType("Boolean",
154 [ 'submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
157 'Repo Type' : FieldType("Repo Type",
158 [ 'git', 'git-svn', 'svn', 'hg', 'bzr', 'srclib' ], None,
162 'archive' : FieldType("Archive Policy",
163 r'^[0-9]+ versions$', None,
164 [ "Archive Policy" ],
167 'antifeatures' : FieldType("Anti-Feature",
168 [ "Ads", "Tracking", "NonFreeNet", "NonFreeDep", "NonFreeAdd", "UpstreamNonFree" ], ',',
172 'autoupdatemodes' : FieldType("Auto Update Mode",
173 r"^(Version .+|None)$", None,
174 [ "Auto Update Mode" ],
177 'updatecheckmodes' : FieldType("Update Check Mode",
178 r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$", None,
179 [ "Update Check Mode" ],
183 # Check an app's metadata information for integrity errors
184 def check_metadata(info):
185 for k, t in valuetypes.iteritems():
186 for field in t.fields:
188 t.check(info[field], info['id'])
190 info[field] = info[field] == "Yes"
191 for build in info['builds']:
194 t.check(build[attr], info['id'])
196 build[attr] = build[attr] == "yes"
200 # Formatter for descriptions. Create an instance, and call parseline() with
201 # each line of the description source from the metadata. At the end, call
202 # end() and then text_plain, text_wiki and text_html will contain the result.
203 class DescriptionFormatter:
215 def __init__(self, linkres):
216 self.linkResolver = linkres
217 def endcur(self, notstates=None):
218 if notstates and self.state in notstates:
220 if self.state == self.stPARA:
222 elif self.state == self.stUL:
224 elif self.state == self.stOL:
227 self.text_plain += '\n'
228 self.text_html += '</p>'
229 self.state = self.stNONE
231 self.text_html += '</ul>'
232 self.state = self.stNONE
234 self.text_html += '</ol>'
235 self.state = self.stNONE
237 def formatted(self, txt, html):
240 txt = cgi.escape(txt)
242 index = txt.find("''")
244 return formatted + txt
245 formatted += txt[:index]
247 if txt.startswith("'''"):
253 self.bold = not self.bold
261 self.ital = not self.ital
265 def linkify(self, txt):
269 index = txt.find("[")
271 return (linkified_plain + self.formatted(txt, False), linkified_html + self.formatted(txt, True))
272 linkified_plain += self.formatted(txt[:index], False)
273 linkified_html += self.formatted(txt[:index], True)
275 if txt.startswith("[["):
276 index = txt.find("]]")
278 raise MetaDataException("Unterminated ]]")
280 if self.linkResolver:
281 url, urltext = self.linkResolver(url)
284 linkified_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
285 linkified_plain += urltext
288 index = txt.find("]")
290 raise MetaDataException("Unterminated ]")
292 index2 = url.find(' ')
296 urltxt = url[index2 + 1:]
298 linkified_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
299 linkified_plain += urltxt
301 linkified_plain += ' (' + url + ')'
304 def addtext(self, txt):
305 p, h = self.linkify(txt)
309 def parseline(self, line):
310 self.text_wiki += "%s\n" % line
313 elif line.startswith('*'):
314 self.endcur([self.stUL])
315 if self.state != self.stUL:
316 self.text_html += '<ul>'
317 self.state = self.stUL
318 self.text_html += '<li>'
319 self.text_plain += '*'
320 self.addtext(line[1:])
321 self.text_html += '</li>'
322 elif line.startswith('#'):
323 self.endcur([self.stOL])
324 if self.state != self.stOL:
325 self.text_html += '<ol>'
326 self.state = self.stOL
327 self.text_html += '<li>'
328 self.text_plain += '*' #TODO: lazy - put the numbers in!
329 self.addtext(line[1:])
330 self.text_html += '</li>'
332 self.endcur([self.stPARA])
333 if self.state == self.stNONE:
334 self.text_html += '<p>'
335 self.state = self.stPARA
336 elif self.state == self.stPARA:
337 self.text_html += ' '
338 self.text_plain += ' '
344 # Parse multiple lines of description as written in a metadata file, returning
345 # a single string in plain text format.
346 def description_plain(lines, linkres):
347 ps = DescriptionFormatter(linkres)
353 # Parse multiple lines of description as written in a metadata file, returning
354 # a single string in wiki format. Used for the Maintainer Notes field as well,
355 # because it's the same format.
356 def description_wiki(lines):
357 ps = DescriptionFormatter(None)
363 # Parse multiple lines of description as written in a metadata file, returning
364 # a single string in HTML format.
365 def description_html(lines,linkres):
366 ps = DescriptionFormatter(linkres)
372 def parse_srclib(metafile, **kw):
375 if metafile and not isinstance(metafile, file):
376 metafile = open(metafile, "r")
378 # Defaults for fields that come from metadata
379 thisinfo['Repo Type'] = ''
380 thisinfo['Repo'] = ''
381 thisinfo['Subdir'] = None
382 thisinfo['Prepare'] = None
383 thisinfo['Srclibs'] = None
389 for line in metafile:
391 line = line.rstrip('\r\n')
392 if not line or line.startswith("#"):
396 field, value = line.split(':',1)
398 raise MetaDataException("Invalid metadata in %s:%d" % (line, n))
400 if field == "Subdir":
401 thisinfo[field] = value.split(',')
403 thisinfo[field] = value
407 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
408 # returned by the parse_metadata function.
409 def read_metadata(xref=True, package=None, store=True):
412 for basedir in ('metadata', 'tmp'):
413 if not os.path.exists(basedir):
416 for metafile in sorted(glob.glob(os.path.join('metadata', '*.txt'))):
417 if package is None or metafile == os.path.join('metadata', package + '.txt'):
418 appinfo = parse_metadata(metafile)
419 check_metadata(appinfo)
423 # Parse all descriptions at load time, just to ensure cross-referencing
424 # errors are caught early rather than when they hit the build server.
427 if app['id'] == link:
428 return ("fdroid.app:" + link, "Dummy name - don't know yet")
429 raise MetaDataException("Cannot resolve app id " + link)
432 description_html(app['Description'], linkres)
434 raise MetaDataException("Problem with description of " + app['id'] +
439 # Get the type expected for a given metadata field.
440 def metafieldtype(name):
441 if name in ['Description', 'Maintainer Notes']:
443 if name in ['Categories']:
445 if name == 'Build Version':
449 if name == 'Use Built':
451 if name not in app_defaults:
456 if name in ['extlibs', 'srclibs', 'patch', 'rm', 'buildjni',
457 'update', 'scanignore', 'scandelete']:
459 if name in ['init', 'prebuild', 'build']:
463 # Parse metadata for a single application.
465 # 'metafile' - the filename to read. The package id for the application comes
466 # from this filename. Pass None to get a blank entry.
468 # Returns a dictionary containing all the details of the application. There are
469 # two major kinds of information in the dictionary. Keys beginning with capital
470 # letters correspond directory to identically named keys in the metadata file.
471 # Keys beginning with lower case letters are generated in one way or another,
472 # and are not found verbatim in the metadata.
474 # Known keys not originating from the metadata are:
476 # 'id' - the application's package ID
477 # 'builds' - a list of dictionaries containing build information
478 # for each defined build
479 # 'comments' - a list of comments from the metadata file. Each is
480 # a tuple of the form (field, comment) where field is
481 # the name of the field it preceded in the metadata
482 # file. Where field is None, the comment goes at the
483 # end of the file. Alternatively, 'build:version' is
484 # for a comment before a particular build version.
485 # 'descriptionlines' - original lines of description as formatted in the
488 def parse_metadata(metafile):
492 def add_buildflag(p, thisbuild):
495 raise MetaDataException("Invalid build flag at {0} in {1}".
496 format(buildlines[0], linedesc))
499 raise MetaDataException("Duplicate definition on {0} in version {1} of {2}".
500 format(pk, thisbuild['version'], linedesc))
503 if pk not in ordered_flags:
504 raise MetaDataException("Unrecognised build flag at {0} in {1}".
508 # Port legacy ';' separators
509 thisbuild[pk] = [v.strip() for v in pv.replace(';',',').split(',')]
515 raise MetaDataException("Unrecognised build flag type '%s' at %s in %s" % (
518 def parse_buildline(lines):
519 value = "".join(lines)
520 parts = [p.replace("\\,", ",")
521 for p in re.split(r"(?<!\\),", value)]
523 raise MetaDataException("Invalid build format: " + value + " in " + metafile.name)
525 thisbuild['origlines'] = lines
526 thisbuild['version'] = parts[0]
527 thisbuild['vercode'] = parts[1]
528 if parts[2].startswith('!'):
529 # For backwards compatibility, handle old-style disabling,
530 # including attempting to extract the commit from the message
531 thisbuild['disable'] = parts[2][1:]
532 commit = 'unknown - see disabled'
533 index = parts[2].rfind('at ')
535 commit = parts[2][index+3:]
536 if commit.endswith(')'):
538 thisbuild['commit'] = commit
540 thisbuild['commit'] = parts[2]
542 add_buildflag(p, thisbuild)
546 def add_comments(key):
549 for comment in curcomments:
550 thisinfo['comments'].append((key, comment))
553 def get_build_type(build):
554 for t in ['maven', 'gradle', 'kivy']:
555 if build.get(t, 'no') != 'no':
557 if 'output' in build:
563 if not isinstance(metafile, file):
564 metafile = open(metafile, "r")
565 thisinfo['id'] = metafile.name[9:-4]
567 thisinfo['id'] = None
569 thisinfo.update(app_defaults)
571 # General defaults...
572 thisinfo['builds'] = []
573 thisinfo['comments'] = []
584 for line in metafile:
586 linedesc = "%s:%d" % (metafile.name, c)
587 line = line.rstrip('\r\n')
589 if not any(line.startswith(s) for s in (' ', '\t')):
590 if 'commit' not in curbuild and 'disable' not in curbuild:
591 raise MetaDataException("No commit specified for {0} in {1}".format(
592 curbuild['version'], linedesc))
593 thisinfo['builds'].append(curbuild)
594 add_comments('build:' + curbuild['version'])
597 if line.endswith('\\'):
598 buildlines.append(line[:-1].lstrip())
600 buildlines.append(line.lstrip())
601 bl = ''.join(buildlines)
602 add_buildflag(bl, curbuild)
608 if line.startswith("#"):
609 curcomments.append(line)
612 field, value = line.split(':',1)
614 raise MetaDataException("Invalid metadata in "+linedesc)
615 if field != field.strip() or value != value.strip():
616 raise MetaDataException("Extra spacing found in "+linedesc)
618 # Translate obsolete fields...
619 if field == 'Market Version':
620 field = 'Current Version'
621 if field == 'Market Version Code':
622 field = 'Current Version Code'
624 fieldtype = metafieldtype(field)
625 if fieldtype not in ['build', 'buildv2']:
627 if fieldtype == 'multiline':
631 raise MetaDataException("Unexpected text on same line as " + field + " in " + linedesc)
632 elif fieldtype == 'string':
633 thisinfo[field] = value
634 elif fieldtype == 'list':
635 thisinfo[field] = [v.strip() for v in value.replace(';',',').split(',')]
636 elif fieldtype == 'build':
637 if value.endswith("\\"):
639 buildlines = [value[:-1]]
641 thisinfo['builds'].append(parse_buildline([value]))
642 add_comments('build:' + thisinfo['builds'][-1]['version'])
643 elif fieldtype == 'buildv2':
645 vv = value.split(',')
647 raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'.
648 format(value, linedesc))
649 curbuild['version'] = vv[0]
650 curbuild['vercode'] = vv[1]
653 elif fieldtype == 'obsolete':
654 pass # Just throw it away!
656 raise MetaDataException("Unrecognised field type for " + field + " in " + linedesc)
657 elif mode == 1: # Multiline field
661 thisinfo[field].append(line)
662 elif mode == 2: # Line continuation mode in Build Version
663 if line.endswith("\\"):
664 buildlines.append(line[:-1])
666 buildlines.append(line)
667 thisinfo['builds'].append(
668 parse_buildline(buildlines))
669 add_comments('build:' + thisinfo['builds'][-1]['version'])
673 # Mode at end of file should always be 0...
675 raise MetaDataException(field + " not terminated in " + metafile.name)
677 raise MetaDataException("Unterminated continuation in " + metafile.name)
679 raise MetaDataException("Unterminated build in " + metafile.name)
681 if not thisinfo['Description']:
682 thisinfo['Description'].append('No description available')
684 for build in thisinfo['builds']:
685 build['type'] = get_build_type(build)
689 # Write a metadata file.
691 # 'dest' - The path to the output file
692 # 'app' - The app data
693 def write_metadata(dest, app):
695 def writecomments(key):
697 for pf, comment in app['comments']:
699 mf.write("%s\n" % comment)
702 logging.debug("...writing comments for " + (key if key else 'EOF'))
704 def writefield(field, value=None):
708 mf.write("%s:%s\n" % (field, value))
712 writefield('Disabled')
713 if app['AntiFeatures']:
714 writefield('AntiFeatures')
716 writefield('Provides')
717 writefield('Categories')
718 writefield('License')
719 writefield('Web Site')
720 writefield('Source Code')
721 writefield('Issue Tracker')
725 writefield('FlattrID')
727 writefield('Bitcoin')
729 writefield('Litecoin')
731 writefield('Dogecoin')
736 writefield('Auto Name')
737 writefield('Summary')
738 writefield('Description', '')
739 for line in app['Description']:
740 mf.write("%s\n" % line)
743 if app['Requires Root']:
744 writefield('Requires Root', 'Yes')
747 writefield('Repo Type')
750 for build in app['builds']:
751 writecomments('build:' + build['version'])
752 mf.write("Build:%s,%s\n" % ( build['version'], build['vercode']))
754 def write_builditem(key, value):
755 if key in ['version', 'vercode', 'origlines', 'type']:
757 if key in valuetypes['bool'].attrs:
762 logging.debug("...writing {0} : {1}".format(key, value))
763 outline = ' %s=' % key
767 outline += '&& \\\n '.join([s.lstrip() for s in value.split('&& ')])
769 outline += ','.join(value) if type(value) == list else value
773 for key in ordered_flags:
775 write_builditem(key, build[key])
778 if 'Maintainer Notes' in app:
779 writefield('Maintainer Notes', '')
780 for line in app['Maintainer Notes']:
781 mf.write("%s\n" % line)
786 if app['Archive Policy']:
787 writefield('Archive Policy')
788 writefield('Auto Update Mode')
789 writefield('Update Check Mode')
790 if app['Vercode Operation']:
791 writefield('Vercode Operation')
792 if app['Update Check Data']:
793 writefield('Update Check Data')
794 if app['Current Version']:
795 writefield('Current Version')
796 writefield('Current Version Code')
798 if app['No Source Since']:
799 writefield('No Source Since')