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
90 class FieldValidator():
92 def __init__(self, name, matching, sep, fields, attrs):
94 self.matching = matching
95 if type(matching) is str:
96 self.compiled = re.compile(matching)
101 def _assert_regex(self, values, appid):
103 if not self.compiled.match(v):
104 raise MetaDataException("'%s' is not a valid %s in %s. "
105 % (v, self.name, appid) +
106 "Regex pattern: %s" % (self.matching))
108 def _assert_list(self, values, appid):
110 if v not in self.matching:
111 raise MetaDataException("'%s' is not a valid %s in %s. "
112 % (v, self.name, appid) +
113 "Possible values: %s" % (", ".join(self.matching)))
115 def check(self, value, appid):
116 if type(value) is not str or not value:
118 if self.sep is not None:
119 values = value.split(self.sep)
122 if type(self.matching) is list:
123 self._assert_list(values, appid)
125 self._assert_regex(values, appid)
128 # Generic value types
130 FieldValidator("Integer",
131 r'^[1-9][0-9]*$', None,
135 FieldValidator("HTTP link",
136 r'^http[s]?://', None,
137 ["Web Site", "Source Code", "Issue Tracker", "Donate"], []),
139 FieldValidator("Bitcoin address",
140 r'^[a-zA-Z0-9]{27,34}$', None,
144 FieldValidator("Litecoin address",
145 r'^L[a-zA-Z0-9]{33}$', None,
149 FieldValidator("Dogecoin address",
150 r'^D[a-zA-Z0-9]{33}$', None,
154 FieldValidator("Boolean",
159 FieldValidator("bool",
162 ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
165 FieldValidator("Repo Type",
166 ['git', 'git-svn', 'svn', 'hg', 'bzr', 'srclib'], None,
170 FieldValidator("Archive Policy",
171 r'^[0-9]+ versions$', None,
175 FieldValidator("Anti-Feature",
176 ["Ads", "Tracking", "NonFreeNet", "NonFreeDep", "NonFreeAdd", "UpstreamNonFree"], ',',
180 FieldValidator("Auto Update Mode",
181 r"^(Version .+|None)$", None,
182 ["Auto Update Mode"],
185 FieldValidator("Update Check Mode",
186 r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$", None,
187 ["Update Check Mode"],
192 # Check an app's metadata information for integrity errors
193 def check_metadata(info):
195 for field in v.fields:
197 v.check(info[field], info['id'])
198 for build in info['builds']:
201 v.check(build[attr], info['id'])
204 # Formatter for descriptions. Create an instance, and call parseline() with
205 # each line of the description source from the metadata. At the end, call
206 # end() and then text_plain, text_wiki and text_html will contain the result.
207 class DescriptionFormatter:
220 def __init__(self, linkres):
221 self.linkResolver = linkres
223 def endcur(self, notstates=None):
224 if notstates and self.state in notstates:
226 if self.state == self.stPARA:
228 elif self.state == self.stUL:
230 elif self.state == self.stOL:
234 self.text_plain += '\n'
235 self.text_html += '</p>'
236 self.state = self.stNONE
239 self.text_html += '</ul>'
240 self.state = self.stNONE
243 self.text_html += '</ol>'
244 self.state = self.stNONE
246 def formatted(self, txt, html):
249 txt = cgi.escape(txt)
251 index = txt.find("''")
253 return formatted + txt
254 formatted += txt[:index]
256 if txt.startswith("'''"):
262 self.bold = not self.bold
270 self.ital = not self.ital
273 def linkify(self, txt):
277 index = txt.find("[")
279 return (linkified_plain + self.formatted(txt, False), linkified_html + self.formatted(txt, True))
280 linkified_plain += self.formatted(txt[:index], False)
281 linkified_html += self.formatted(txt[:index], True)
283 if txt.startswith("[["):
284 index = txt.find("]]")
286 raise MetaDataException("Unterminated ]]")
288 if self.linkResolver:
289 url, urltext = self.linkResolver(url)
292 linkified_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
293 linkified_plain += urltext
294 txt = txt[index + 2:]
296 index = txt.find("]")
298 raise MetaDataException("Unterminated ]")
300 index2 = url.find(' ')
304 urltxt = url[index2 + 1:]
306 linkified_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
307 linkified_plain += urltxt
309 linkified_plain += ' (' + url + ')'
310 txt = txt[index + 1:]
312 def addtext(self, txt):
313 p, h = self.linkify(txt)
317 def parseline(self, line):
318 self.text_wiki += "%s\n" % line
321 elif line.startswith('* '):
322 self.endcur([self.stUL])
323 if self.state != self.stUL:
324 self.text_html += '<ul>'
325 self.state = self.stUL
326 self.text_html += '<li>'
327 self.text_plain += '* '
328 self.addtext(line[1:])
329 self.text_html += '</li>'
330 elif line.startswith('# '):
331 self.endcur([self.stOL])
332 if self.state != self.stOL:
333 self.text_html += '<ol>'
334 self.state = self.stOL
335 self.text_html += '<li>'
336 self.text_plain += '* ' # TODO: lazy - put the numbers in!
337 self.addtext(line[1:])
338 self.text_html += '</li>'
340 self.endcur([self.stPARA])
341 if self.state == self.stNONE:
342 self.text_html += '<p>'
343 self.state = self.stPARA
344 elif self.state == self.stPARA:
345 self.text_html += ' '
346 self.text_plain += ' '
353 # Parse multiple lines of description as written in a metadata file, returning
354 # a single string in plain text format.
355 def description_plain(lines, linkres):
356 ps = DescriptionFormatter(linkres)
363 # Parse multiple lines of description as written in a metadata file, returning
364 # a single string in wiki format. Used for the Maintainer Notes field as well,
365 # because it's the same format.
366 def description_wiki(lines):
367 ps = DescriptionFormatter(None)
374 # Parse multiple lines of description as written in a metadata file, returning
375 # a single string in HTML format.
376 def description_html(lines, linkres):
377 ps = DescriptionFormatter(linkres)
384 def parse_srclib(metafile):
387 if metafile and not isinstance(metafile, file):
388 metafile = open(metafile, "r")
390 # Defaults for fields that come from metadata
391 thisinfo['Repo Type'] = ''
392 thisinfo['Repo'] = ''
393 thisinfo['Subdir'] = None
394 thisinfo['Prepare'] = None
395 thisinfo['Srclibs'] = None
401 for line in metafile:
403 line = line.rstrip('\r\n')
404 if not line or line.startswith("#"):
408 field, value = line.split(':', 1)
410 raise MetaDataException("Invalid metadata in %s:%d" % (line, n))
412 if field == "Subdir":
413 thisinfo[field] = value.split(',')
415 thisinfo[field] = value
421 """Read all srclib metadata.
423 The information read will be accessible as metadata.srclibs, which is a
424 dictionary, keyed on srclib name, with the values each being a dictionary
425 in the same format as that returned by the parse_srclib function.
427 A MetaDataException is raised if there are any problems with the srclib
434 if not os.path.exists(srcdir):
437 for metafile in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
438 srclibname = os.path.basename(metafile[:-4])
439 srclibs[srclibname] = parse_srclib(metafile)
442 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
443 # returned by the parse_metadata function.
444 def read_metadata(xref=True):
447 for basedir in ('metadata', 'tmp'):
448 if not os.path.exists(basedir):
451 for metafile in sorted(glob.glob(os.path.join('metadata', '*.txt'))):
452 appinfo = parse_metadata(metafile)
453 check_metadata(appinfo)
457 # Parse all descriptions at load time, just to ensure cross-referencing
458 # errors are caught early rather than when they hit the build server.
461 if app['id'] == link:
462 return ("fdroid.app:" + link, "Dummy name - don't know yet")
463 raise MetaDataException("Cannot resolve app id " + link)
466 description_html(app['Description'], linkres)
468 raise MetaDataException("Problem with description of " + app['id'] +
474 # Get the type expected for a given metadata field.
475 def metafieldtype(name):
476 if name in ['Description', 'Maintainer Notes']:
478 if name in ['Categories']:
480 if name == 'Build Version':
484 if name == 'Use Built':
486 if name not in app_defaults:
492 if name in ['extlibs', 'srclibs', 'patch', 'rm', 'buildjni',
493 'update', 'scanignore', 'scandelete']:
495 if name in ['init', 'prebuild', 'build']:
497 if name in ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
503 # Parse metadata for a single application.
505 # 'metafile' - the filename to read. The package id for the application comes
506 # from this filename. Pass None to get a blank entry.
508 # Returns a dictionary containing all the details of the application. There are
509 # two major kinds of information in the dictionary. Keys beginning with capital
510 # letters correspond directory to identically named keys in the metadata file.
511 # Keys beginning with lower case letters are generated in one way or another,
512 # and are not found verbatim in the metadata.
514 # Known keys not originating from the metadata are:
516 # 'id' - the application's package ID
517 # 'builds' - a list of dictionaries containing build information
518 # for each defined build
519 # 'comments' - a list of comments from the metadata file. Each is
520 # a tuple of the form (field, comment) where field is
521 # the name of the field it preceded in the metadata
522 # file. Where field is None, the comment goes at the
523 # end of the file. Alternatively, 'build:version' is
524 # for a comment before a particular build version.
525 # 'descriptionlines' - original lines of description as formatted in the
528 def parse_metadata(metafile):
532 def add_buildflag(p, thisbuild):
535 raise MetaDataException("Invalid build flag at {0} in {1}"
536 .format(buildlines[0], linedesc))
539 raise MetaDataException("Duplicate definition on {0} in version {1} of {2}"
540 .format(pk, thisbuild['version'], linedesc))
543 if pk not in ordered_flags:
544 raise MetaDataException("Unrecognised build flag at {0} in {1}"
545 .format(p, linedesc))
548 # Port legacy ';' separators
549 thisbuild[pk] = [v.strip() for v in pv.replace(';', ',').split(',')]
550 elif t == 'string' or t == 'script':
557 logging.debug("...ignoring bool flag %s" % p)
560 raise MetaDataException("Unrecognised build flag type '%s' at %s in %s"
563 def parse_buildline(lines):
564 value = "".join(lines)
565 parts = [p.replace("\\,", ",")
566 for p in re.split(r"(?<!\\),", value)]
568 raise MetaDataException("Invalid build format: " + value + " in " + metafile.name)
570 thisbuild['origlines'] = lines
571 thisbuild['version'] = parts[0]
572 thisbuild['vercode'] = parts[1]
573 if parts[2].startswith('!'):
574 # For backwards compatibility, handle old-style disabling,
575 # including attempting to extract the commit from the message
576 thisbuild['disable'] = parts[2][1:]
577 commit = 'unknown - see disabled'
578 index = parts[2].rfind('at ')
580 commit = parts[2][index + 3:]
581 if commit.endswith(')'):
583 thisbuild['commit'] = commit
585 thisbuild['commit'] = parts[2]
587 add_buildflag(p, thisbuild)
591 def add_comments(key):
594 for comment in curcomments:
595 thisinfo['comments'].append((key, comment))
598 def get_build_type(build):
599 for t in ['maven', 'gradle', 'kivy']:
600 if build.get(t, 'no') != 'no':
602 if 'output' in build:
608 if not isinstance(metafile, file):
609 metafile = open(metafile, "r")
610 thisinfo['id'] = metafile.name[9:-4]
612 thisinfo['id'] = None
614 thisinfo.update(app_defaults)
616 # General defaults...
617 thisinfo['builds'] = []
618 thisinfo['comments'] = []
628 def fill_bool_defaults(build):
629 # TODO: quick fix to make bool flags default to False
630 # Should provide defaults for all flags instead of using
631 # build.get(flagname, default) each time
632 for f in ordered_flags:
635 if flagtype(f) == 'bool':
639 for line in metafile:
641 linedesc = "%s:%d" % (metafile.name, c)
642 line = line.rstrip('\r\n')
644 if not any(line.startswith(s) for s in (' ', '\t')):
645 if 'commit' not in curbuild and 'disable' not in curbuild:
646 raise MetaDataException("No commit specified for {0} in {1}"
647 .format(curbuild['version'], linedesc))
649 fill_bool_defaults(curbuild)
650 thisinfo['builds'].append(curbuild)
651 add_comments('build:' + curbuild['version'])
654 if line.endswith('\\'):
655 buildlines.append(line[:-1].lstrip())
657 buildlines.append(line.lstrip())
658 bl = ''.join(buildlines)
659 add_buildflag(bl, curbuild)
665 if line.startswith("#"):
666 curcomments.append(line)
669 field, value = line.split(':', 1)
671 raise MetaDataException("Invalid metadata in " + linedesc)
672 if field != field.strip() or value != value.strip():
673 raise MetaDataException("Extra spacing found in " + linedesc)
675 # Translate obsolete fields...
676 if field == 'Market Version':
677 field = 'Current Version'
678 if field == 'Market Version Code':
679 field = 'Current Version Code'
681 fieldtype = metafieldtype(field)
682 if fieldtype not in ['build', 'buildv2']:
684 if fieldtype == 'multiline':
688 raise MetaDataException("Unexpected text on same line as " + field + " in " + linedesc)
689 elif fieldtype == 'string':
690 thisinfo[field] = value
691 elif fieldtype == 'list':
692 thisinfo[field] = [v.strip() for v in value.replace(';', ',').split(',')]
693 elif fieldtype == 'build':
694 if value.endswith("\\"):
696 buildlines = [value[:-1]]
698 thisinfo['builds'].append(parse_buildline([value]))
699 add_comments('build:' + thisinfo['builds'][-1]['version'])
700 elif fieldtype == 'buildv2':
702 vv = value.split(',')
704 raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
705 .format(value, linedesc))
706 curbuild['version'] = vv[0]
707 curbuild['vercode'] = vv[1]
710 elif fieldtype == 'obsolete':
711 pass # Just throw it away!
713 raise MetaDataException("Unrecognised field type for " + field + " in " + linedesc)
714 elif mode == 1: # Multiline field
718 thisinfo[field].append(line)
719 elif mode == 2: # Line continuation mode in Build Version
720 if line.endswith("\\"):
721 buildlines.append(line[:-1])
723 buildlines.append(line)
724 curbuild = parse_buildline(buildlines)
725 fill_bool_defaults(curbuild)
726 thisinfo['builds'].append(curbuild)
727 add_comments('build:' + thisinfo['builds'][-1]['version'])
731 # Mode at end of file should always be 0...
733 raise MetaDataException(field + " not terminated in " + metafile.name)
735 raise MetaDataException("Unterminated continuation in " + metafile.name)
737 raise MetaDataException("Unterminated build in " + metafile.name)
739 if not thisinfo['Description']:
740 thisinfo['Description'].append('No description available')
742 for build in thisinfo['builds']:
743 build['type'] = get_build_type(build)
748 # Write a metadata file.
750 # 'dest' - The path to the output file
751 # 'app' - The app data
752 def write_metadata(dest, app):
754 def writecomments(key):
756 for pf, comment in app['comments']:
758 mf.write("%s\n" % comment)
761 logging.debug("...writing comments for " + (key if key else 'EOF'))
763 def writefield(field, value=None):
767 t = metafieldtype(field)
769 value = ','.join(value)
770 mf.write("%s:%s\n" % (field, value))
774 writefield('Disabled')
775 if app['AntiFeatures']:
776 writefield('AntiFeatures')
778 writefield('Provides')
779 writefield('Categories')
780 writefield('License')
781 writefield('Web Site')
782 writefield('Source Code')
783 writefield('Issue Tracker')
787 writefield('FlattrID')
789 writefield('Bitcoin')
791 writefield('Litecoin')
793 writefield('Dogecoin')
798 writefield('Auto Name')
799 writefield('Summary')
800 writefield('Description', '')
801 for line in app['Description']:
802 mf.write("%s\n" % line)
805 if app['Requires Root']:
806 writefield('Requires Root', 'Yes')
809 writefield('Repo Type')
812 for build in app['builds']:
813 writecomments('build:' + build['version'])
814 mf.write("Build:%s,%s\n" % (build['version'], build['vercode']))
816 def write_builditem(key, value):
818 if key in ['version', 'vercode', 'origlines', 'type']:
822 if t == 'bool' and value == False:
825 logging.debug("...writing {0} : {1}".format(key, value))
826 outline = ' %s=' % key
833 outline += '&& \\\n '.join([s.lstrip() for s in value.split('&& ')])
835 outline += ','.join(value) if type(value) == list else value
840 for key in ordered_flags:
842 write_builditem(key, build[key])
845 if 'Maintainer Notes' in app:
846 writefield('Maintainer Notes', '')
847 for line in app['Maintainer Notes']:
848 mf.write("%s\n" % line)
852 if app['Archive Policy']:
853 writefield('Archive Policy')
854 writefield('Auto Update Mode')
855 writefield('Update Check Mode')
856 if app['Update Check Ignore']:
857 writefield('Update Check Ignore')
858 if app['Vercode Operation']:
859 writefield('Vercode Operation')
860 if app['Update Check Data']:
861 writefield('Update Check Data')
862 if app['Current Version']:
863 writefield('Current Version')
864 writefield('Current Version Code')
866 if app['No Source Since']:
867 writefield('No Source Since')