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 from collections import OrderedDict
33 class MetaDataException(Exception):
35 def __init__(self, value):
41 # In the order in which they are laid out on files
42 app_defaults = OrderedDict([
44 ('AntiFeatures', None),
46 ('Categories', ['None']),
47 ('License', 'Unknown'),
50 ('Issue Tracker', ''),
61 ('Requires Root', False),
65 ('Maintainer Notes', []),
66 ('Archive Policy', None),
67 ('Auto Update Mode', 'None'),
68 ('Update Check Mode', 'None'),
69 ('Update Check Ignore', None),
70 ('Vercode Operation', None),
71 ('Update Check Name', None),
72 ('Update Check Data', None),
73 ('Current Version', ''),
74 ('Current Version Code', '0'),
75 ('No Source Since', ''),
79 # In the order in which they are laid out on files
80 # Sorted by their action and their place in the build timeline
81 flag_defaults = OrderedDict([
85 ('submodules', False),
95 ('forceversion', False),
96 ('forcevercode', False),
100 ('update', ['auto']),
106 ('ndk', 'r10e'), # defaults to latest
109 ('antcommands', None),
114 # Designates a metadata field type and checks that it matches
116 # 'name' - The long name of the field type
117 # 'matching' - List of possible values or regex expression
118 # 'sep' - Separator to use if value may be a list
119 # 'fields' - Metadata fields (Field:Value) of this type
120 # 'attrs' - Build attributes (attr=value) of this type
122 class FieldValidator():
124 def __init__(self, name, matching, sep, fields, attrs):
126 self.matching = matching
127 if type(matching) is str:
128 self.compiled = re.compile(matching)
133 def _assert_regex(self, values, appid):
135 if not self.compiled.match(v):
136 raise MetaDataException("'%s' is not a valid %s in %s. "
137 % (v, self.name, appid) +
138 "Regex pattern: %s" % (self.matching))
140 def _assert_list(self, values, appid):
142 if v not in self.matching:
143 raise MetaDataException("'%s' is not a valid %s in %s. "
144 % (v, self.name, appid) +
145 "Possible values: %s" % (", ".join(self.matching)))
147 def check(self, value, appid):
148 if type(value) is not str or not value:
150 if self.sep is not None:
151 values = value.split(self.sep)
154 if type(self.matching) is list:
155 self._assert_list(values, appid)
157 self._assert_regex(values, appid)
160 # Generic value types
162 FieldValidator("Integer",
163 r'^[1-9][0-9]*$', None,
167 FieldValidator("Hexadecimal",
168 r'^[0-9a-f]+$', None,
172 FieldValidator("HTTP link",
173 r'^http[s]?://', None,
174 ["Web Site", "Source Code", "Issue Tracker", "Changelog", "Donate"], []),
176 FieldValidator("Bitcoin address",
177 r'^[a-zA-Z0-9]{27,34}$', None,
181 FieldValidator("Litecoin address",
182 r'^L[a-zA-Z0-9]{33}$', None,
186 FieldValidator("Dogecoin address",
187 r'^D[a-zA-Z0-9]{33}$', None,
191 FieldValidator("Boolean",
196 FieldValidator("bool",
199 ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
202 FieldValidator("Repo Type",
203 ['git', 'git-svn', 'svn', 'hg', 'bzr', 'srclib'], None,
207 FieldValidator("Binaries",
208 r'^http[s]?://', None,
212 FieldValidator("Archive Policy",
213 r'^[0-9]+ versions$', None,
217 FieldValidator("Anti-Feature",
218 ["Ads", "Tracking", "NonFreeNet", "NonFreeDep", "NonFreeAdd", "UpstreamNonFree"], ',',
222 FieldValidator("Auto Update Mode",
223 r"^(Version .+|None)$", None,
224 ["Auto Update Mode"],
227 FieldValidator("Update Check Mode",
228 r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$", None,
229 ["Update Check Mode"],
234 # Check an app's metadata information for integrity errors
235 def check_metadata(info):
237 for field in v.fields:
238 v.check(info[field], info['id'])
239 for build in info['builds']:
241 v.check(build[attr], info['id'])
244 # Formatter for descriptions. Create an instance, and call parseline() with
245 # each line of the description source from the metadata. At the end, call
246 # end() and then text_wiki and text_html will contain the result.
247 class DescriptionFormatter:
259 def __init__(self, linkres):
260 self.linkResolver = linkres
262 def endcur(self, notstates=None):
263 if notstates and self.state in notstates:
265 if self.state == self.stPARA:
267 elif self.state == self.stUL:
269 elif self.state == self.stOL:
273 self.text_html += '</p>'
274 self.state = self.stNONE
277 self.text_html += '</ul>'
278 self.state = self.stNONE
281 self.text_html += '</ol>'
282 self.state = self.stNONE
284 def formatted(self, txt, html):
287 txt = cgi.escape(txt)
289 index = txt.find("''")
291 return formatted + txt
292 formatted += txt[:index]
294 if txt.startswith("'''"):
300 self.bold = not self.bold
308 self.ital = not self.ital
311 def linkify(self, txt):
315 index = txt.find("[")
317 return (linkified_plain + self.formatted(txt, False), linkified_html + self.formatted(txt, True))
318 linkified_plain += self.formatted(txt[:index], False)
319 linkified_html += self.formatted(txt[:index], True)
321 if txt.startswith("[["):
322 index = txt.find("]]")
324 raise MetaDataException("Unterminated ]]")
326 if self.linkResolver:
327 url, urltext = self.linkResolver(url)
330 linkified_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
331 linkified_plain += urltext
332 txt = txt[index + 2:]
334 index = txt.find("]")
336 raise MetaDataException("Unterminated ]")
338 index2 = url.find(' ')
342 urltxt = url[index2 + 1:]
345 raise MetaDataException("Url title is just the URL - use [url]")
346 linkified_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
347 linkified_plain += urltxt
349 linkified_plain += ' (' + url + ')'
350 txt = txt[index + 1:]
352 def addtext(self, txt):
353 p, h = self.linkify(txt)
356 def parseline(self, line):
357 self.text_wiki += "%s\n" % line
360 elif line.startswith('* '):
361 self.endcur([self.stUL])
362 if self.state != self.stUL:
363 self.text_html += '<ul>'
364 self.state = self.stUL
365 self.text_html += '<li>'
366 self.addtext(line[1:])
367 self.text_html += '</li>'
368 elif line.startswith('# '):
369 self.endcur([self.stOL])
370 if self.state != self.stOL:
371 self.text_html += '<ol>'
372 self.state = self.stOL
373 self.text_html += '<li>'
374 self.addtext(line[1:])
375 self.text_html += '</li>'
377 self.endcur([self.stPARA])
378 if self.state == self.stNONE:
379 self.text_html += '<p>'
380 self.state = self.stPARA
381 elif self.state == self.stPARA:
382 self.text_html += ' '
389 # Parse multiple lines of description as written in a metadata file, returning
390 # a single string in wiki format. Used for the Maintainer Notes field as well,
391 # because it's the same format.
392 def description_wiki(lines):
393 ps = DescriptionFormatter(None)
400 # Parse multiple lines of description as written in a metadata file, returning
401 # a single string in HTML format.
402 def description_html(lines, linkres):
403 ps = DescriptionFormatter(linkres)
410 def parse_srclib(metafile):
413 if metafile and not isinstance(metafile, file):
414 metafile = open(metafile, "r")
416 # Defaults for fields that come from metadata
417 thisinfo['Repo Type'] = ''
418 thisinfo['Repo'] = ''
419 thisinfo['Subdir'] = None
420 thisinfo['Prepare'] = None
426 for line in metafile:
428 line = line.rstrip('\r\n')
429 if not line or line.startswith("#"):
433 field, value = line.split(':', 1)
435 raise MetaDataException("Invalid metadata in %s:%d" % (line, n))
437 if field == "Subdir":
438 thisinfo[field] = value.split(',')
440 thisinfo[field] = value
446 """Read all srclib metadata.
448 The information read will be accessible as metadata.srclibs, which is a
449 dictionary, keyed on srclib name, with the values each being a dictionary
450 in the same format as that returned by the parse_srclib function.
452 A MetaDataException is raised if there are any problems with the srclib
457 # They were already loaded
458 if srclibs is not None:
464 if not os.path.exists(srcdir):
467 for metafile in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
468 srclibname = os.path.basename(metafile[:-4])
469 srclibs[srclibname] = parse_srclib(metafile)
472 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
473 # returned by the parse_txt_metadata function.
474 def read_metadata(xref=True):
476 # Always read the srclibs before the apps, since they can use a srlib as
477 # their source repository.
482 for basedir in ('metadata', 'tmp'):
483 if not os.path.exists(basedir):
486 for metafile in sorted(glob.glob(os.path.join('metadata', '*.txt'))):
487 appid, appinfo = parse_txt_metadata(metafile)
488 check_metadata(appinfo)
489 apps[appid] = appinfo
492 # Parse all descriptions at load time, just to ensure cross-referencing
493 # errors are caught early rather than when they hit the build server.
496 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
497 raise MetaDataException("Cannot resolve app id " + appid)
499 for appid, app in apps.iteritems():
501 description_html(app['Description'], linkres)
502 except MetaDataException, e:
503 raise MetaDataException("Problem with description of " + appid +
509 # Get the type expected for a given metadata field.
510 def metafieldtype(name):
511 if name in ['Description', 'Maintainer Notes']:
513 if name in ['Categories']:
515 if name == 'Build Version':
519 if name == 'Use Built':
521 if name not in app_defaults:
527 if name in ['extlibs', 'srclibs', 'patch', 'rm', 'buildjni', 'preassemble',
528 'update', 'scanignore', 'scandelete', 'gradle', 'antcommands',
531 if name in ['init', 'prebuild', 'build']:
533 if name in ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
539 def fill_build_defaults(build):
541 def get_build_type():
542 for t in ['maven', 'gradle', 'kivy']:
549 for flag, value in flag_defaults.iteritems():
553 build['type'] = get_build_type()
554 build['ndk_path'] = common.get_ndk_path(build['ndk'])
557 def split_list_values(s):
558 # Port legacy ';' separators
559 l = [v.strip() for v in s.replace(';', ',').split(',')]
560 return [v for v in l if v]
563 def get_default_app_info_list():
565 thisinfo.update(app_defaults)
567 # General defaults...
568 thisinfo['builds'] = []
569 thisinfo['comments'] = []
574 # Parse metadata for a single application.
576 # 'metafile' - the filename to read. The package id for the application comes
577 # from this filename. Pass None to get a blank entry.
579 # Returns a dictionary containing all the details of the application. There are
580 # two major kinds of information in the dictionary. Keys beginning with capital
581 # letters correspond directory to identically named keys in the metadata file.
582 # Keys beginning with lower case letters are generated in one way or another,
583 # and are not found verbatim in the metadata.
585 # Known keys not originating from the metadata are:
587 # 'builds' - a list of dictionaries containing build information
588 # for each defined build
589 # 'comments' - a list of comments from the metadata file. Each is
590 # a tuple of the form (field, comment) where field is
591 # the name of the field it preceded in the metadata
592 # file. Where field is None, the comment goes at the
593 # end of the file. Alternatively, 'build:version' is
594 # for a comment before a particular build version.
595 # 'descriptionlines' - original lines of description as formatted in the
598 def parse_txt_metadata(metafile):
603 def add_buildflag(p, thisbuild):
605 raise MetaDataException("Empty build flag at {1}"
606 .format(buildlines[0], linedesc))
609 raise MetaDataException("Invalid build flag at {0} in {1}"
610 .format(buildlines[0], linedesc))
613 raise MetaDataException("Duplicate definition on {0} in version {1} of {2}"
614 .format(pk, thisbuild['version'], linedesc))
617 if pk not in flag_defaults:
618 raise MetaDataException("Unrecognised build flag at {0} in {1}"
619 .format(p, linedesc))
622 pv = split_list_values(pv)
624 if len(pv) == 1 and pv[0] in ['main', 'yes']:
627 elif t == 'string' or t == 'script':
634 logging.debug("...ignoring bool flag %s" % p)
637 raise MetaDataException("Unrecognised build flag type '%s' at %s in %s"
640 def parse_buildline(lines):
641 value = "".join(lines)
642 parts = [p.replace("\\,", ",")
643 for p in re.split(r"(?<!\\),", value)]
645 raise MetaDataException("Invalid build format: " + value + " in " + metafile.name)
647 thisbuild['origlines'] = lines
648 thisbuild['version'] = parts[0]
649 thisbuild['vercode'] = parts[1]
650 if parts[2].startswith('!'):
651 # For backwards compatibility, handle old-style disabling,
652 # including attempting to extract the commit from the message
653 thisbuild['disable'] = parts[2][1:]
654 commit = 'unknown - see disabled'
655 index = parts[2].rfind('at ')
657 commit = parts[2][index + 3:]
658 if commit.endswith(')'):
660 thisbuild['commit'] = commit
662 thisbuild['commit'] = parts[2]
664 add_buildflag(p, thisbuild)
668 def add_comments(key):
671 for comment in curcomments:
672 thisinfo['comments'].append((key, comment))
675 thisinfo = get_default_app_info_list()
677 if not isinstance(metafile, file):
678 metafile = open(metafile, "r")
679 appid = metafile.name[9:-4]
680 thisinfo['id'] = appid
682 return appid, thisinfo
691 for line in metafile:
693 linedesc = "%s:%d" % (metafile.name, c)
694 line = line.rstrip('\r\n')
696 if not any(line.startswith(s) for s in (' ', '\t')):
697 commit = curbuild['commit'] if 'commit' in curbuild else None
698 if not commit and 'disable' not in curbuild:
699 raise MetaDataException("No commit specified for {0} in {1}"
700 .format(curbuild['version'], linedesc))
702 thisinfo['builds'].append(curbuild)
703 add_comments('build:' + curbuild['vercode'])
706 if line.endswith('\\'):
707 buildlines.append(line[:-1].lstrip())
709 buildlines.append(line.lstrip())
710 bl = ''.join(buildlines)
711 add_buildflag(bl, curbuild)
717 if line.startswith("#"):
718 curcomments.append(line)
721 field, value = line.split(':', 1)
723 raise MetaDataException("Invalid metadata in " + linedesc)
724 if field != field.strip() or value != value.strip():
725 raise MetaDataException("Extra spacing found in " + linedesc)
727 # Translate obsolete fields...
728 if field == 'Market Version':
729 field = 'Current Version'
730 if field == 'Market Version Code':
731 field = 'Current Version Code'
733 fieldtype = metafieldtype(field)
734 if fieldtype not in ['build', 'buildv2']:
736 if fieldtype == 'multiline':
740 raise MetaDataException("Unexpected text on same line as " + field + " in " + linedesc)
741 elif fieldtype == 'string':
742 thisinfo[field] = value
743 elif fieldtype == 'list':
744 thisinfo[field] = split_list_values(value)
745 elif fieldtype == 'build':
746 if value.endswith("\\"):
748 buildlines = [value[:-1]]
750 curbuild = parse_buildline([value])
751 thisinfo['builds'].append(curbuild)
752 add_comments('build:' + thisinfo['builds'][-1]['vercode'])
753 elif fieldtype == 'buildv2':
755 vv = value.split(',')
757 raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
758 .format(value, linedesc))
759 curbuild['version'] = vv[0]
760 curbuild['vercode'] = vv[1]
761 if curbuild['vercode'] in vc_seen:
762 raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
763 curbuild['vercode'], linedesc))
764 vc_seen[curbuild['vercode']] = True
767 elif fieldtype == 'obsolete':
768 pass # Just throw it away!
770 raise MetaDataException("Unrecognised field type for " + field + " in " + linedesc)
771 elif mode == 1: # Multiline field
775 thisinfo[field].append(line)
776 elif mode == 2: # Line continuation mode in Build Version
777 if line.endswith("\\"):
778 buildlines.append(line[:-1])
780 buildlines.append(line)
781 curbuild = parse_buildline(buildlines)
782 thisinfo['builds'].append(curbuild)
783 add_comments('build:' + thisinfo['builds'][-1]['vercode'])
787 # Mode at end of file should always be 0...
789 raise MetaDataException(field + " not terminated in " + metafile.name)
791 raise MetaDataException("Unterminated continuation in " + metafile.name)
793 raise MetaDataException("Unterminated build in " + metafile.name)
795 if not thisinfo['Description']:
796 thisinfo['Description'].append('No description available')
798 for build in thisinfo['builds']:
799 fill_build_defaults(build)
801 thisinfo['builds'] = sorted(thisinfo['builds'], key=lambda build: int(build['vercode']))
803 return (appid, thisinfo)
806 # Write a metadata file.
808 # 'dest' - The path to the output file
809 # 'app' - The app data
810 def write_metadata(dest, app):
812 def writecomments(key):
814 for pf, comment in app['comments']:
816 mf.write("%s\n" % comment)
819 logging.debug("...writing comments for " + (key or 'EOF'))
821 def writefield(field, value=None):
825 t = metafieldtype(field)
827 value = ','.join(value)
828 mf.write("%s:%s\n" % (field, value))
830 def writefield_nonempty(field, value=None):
834 writefield(field, value)
837 writefield_nonempty('Disabled')
838 writefield_nonempty('AntiFeatures')
839 writefield_nonempty('Provides')
840 writefield('Categories')
841 writefield('License')
842 writefield('Web Site')
843 writefield('Source Code')
844 writefield('Issue Tracker')
845 writefield_nonempty('Changelog')
846 writefield_nonempty('Donate')
847 writefield_nonempty('FlattrID')
848 writefield_nonempty('Bitcoin')
849 writefield_nonempty('Litecoin')
850 writefield_nonempty('Dogecoin')
852 writefield_nonempty('Name')
853 writefield_nonempty('Auto Name')
854 writefield('Summary')
855 writefield('Description', '')
856 for line in app['Description']:
857 mf.write("%s\n" % line)
860 if app['Requires Root']:
861 writefield('Requires Root', 'Yes')
864 writefield('Repo Type')
867 writefield('Binaries')
869 for build in app['builds']:
871 if build['version'] == "Ignore":
874 writecomments('build:' + build['vercode'])
875 mf.write("Build:%s,%s\n" % (build['version'], build['vercode']))
877 def write_builditem(key, value):
879 if key in ['version', 'vercode']:
882 if value == flag_defaults[key]:
887 logging.debug("...writing {0} : {1}".format(key, value))
888 outline = ' %s=' % key
895 outline += '&& \\\n '.join([s.lstrip() for s in value.split('&& ')])
897 outline += ','.join(value) if type(value) == list else value
902 for flag in flag_defaults:
905 write_builditem(flag, value)
908 if app['Maintainer Notes']:
909 writefield('Maintainer Notes', '')
910 for line in app['Maintainer Notes']:
911 mf.write("%s\n" % line)
915 writefield_nonempty('Archive Policy')
916 writefield('Auto Update Mode')
917 writefield('Update Check Mode')
918 writefield_nonempty('Update Check Ignore')
919 writefield_nonempty('Vercode Operation')
920 writefield_nonempty('Update Check Name')
921 writefield_nonempty('Update Check Data')
922 if app['Current Version']:
923 writefield('Current Version')
924 writefield('Current Version Code')
926 if app['No Source Since']:
927 writefield('No Source Since')