diff options
Diffstat (limited to 'testing/mozbase/manifestparser/manifestparser/manifestparser.py')
-rw-r--r-- | testing/mozbase/manifestparser/manifestparser/manifestparser.py | 804 |
1 files changed, 804 insertions, 0 deletions
diff --git a/testing/mozbase/manifestparser/manifestparser/manifestparser.py b/testing/mozbase/manifestparser/manifestparser/manifestparser.py new file mode 100644 index 000000000..23f14d3f8 --- /dev/null +++ b/testing/mozbase/manifestparser/manifestparser/manifestparser.py @@ -0,0 +1,804 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. + +from StringIO import StringIO +import json +import fnmatch +import os +import shutil +import sys +import types + +from .ini import read_ini +from .filters import ( + DEFAULT_FILTERS, + enabled, + exists as _exists, + filterlist, +) + +__all__ = ['ManifestParser', 'TestManifest', 'convert'] + +relpath = os.path.relpath +string = (basestring,) + + +# path normalization + +def normalize_path(path): + """normalize a relative path""" + if sys.platform.startswith('win'): + return path.replace('/', os.path.sep) + return path + + +def denormalize_path(path): + """denormalize a relative path""" + if sys.platform.startswith('win'): + return path.replace(os.path.sep, '/') + return path + + +# objects for parsing manifests + +class ManifestParser(object): + """read .ini manifests""" + + def __init__(self, manifests=(), defaults=None, strict=True, rootdir=None, + finder=None, handle_defaults=True): + """Creates a ManifestParser from the given manifest files. + + :param manifests: An iterable of file paths or file objects corresponding + to manifests. If a file path refers to a manifest file that + does not exist, an IOError is raised. + :param defaults: Variables to pre-define in the environment for evaluating + expressions in manifests. + :param strict: If False, the provided manifests may contain references to + listed (test) files that do not exist without raising an + IOError during reading, and certain errors in manifests + are not considered fatal. Those errors include duplicate + section names, redefining variables, and defining empty + variables. + :param rootdir: The directory used as the basis for conversion to and from + relative paths during manifest reading. + :param finder: If provided, this finder object will be used for filesystem + interactions. Finder objects are part of the mozpack package, + documented at + http://gecko.readthedocs.org/en/latest/python/mozpack.html#module-mozpack.files + :param handle_defaults: If not set, do not propagate manifest defaults to individual + test objects. Callers are expected to manage per-manifest + defaults themselves via the manifest_defaults member + variable in this case. + """ + self._defaults = defaults or {} + self._ancestor_defaults = {} + self.tests = [] + self.manifest_defaults = {} + self.strict = strict + self.rootdir = rootdir + self.relativeRoot = None + self.finder = finder + self._handle_defaults = handle_defaults + if manifests: + self.read(*manifests) + + def path_exists(self, path): + if self.finder: + return self.finder.get(path) is not None + return os.path.exists(path) + + # methods for reading manifests + + def _read(self, root, filename, defaults, defaults_only=False, parentmanifest=None): + """ + Internal recursive method for reading and parsing manifests. + Stores all found tests in self.tests + :param root: The base path + :param filename: File object or string path for the base manifest file + :param defaults: Options that apply to all items + :param defaults_only: If True will only gather options, not include + tests. Used for upstream parent includes + (default False) + :param parentmanifest: Filename of the parent manifest (default None) + """ + def read_file(type): + include_file = section.split(type, 1)[-1] + include_file = normalize_path(include_file) + if not os.path.isabs(include_file): + include_file = os.path.join(here, include_file) + if not self.path_exists(include_file): + message = "Included file '%s' does not exist" % include_file + if self.strict: + raise IOError(message) + else: + sys.stderr.write("%s\n" % message) + return + return include_file + + # get directory of this file if not file-like object + if isinstance(filename, string): + # If we're using mercurial as our filesystem via a finder + # during manifest reading, the getcwd() calls that happen + # with abspath calls will not be meaningful, so absolute + # paths are required. + if self.finder: + assert os.path.isabs(filename) + filename = os.path.abspath(filename) + if self.finder: + fp = self.finder.get(filename) + else: + fp = open(filename) + here = os.path.dirname(filename) + else: + fp = filename + filename = here = None + defaults['here'] = here + + # Rootdir is needed for relative path calculation. Precompute it for + # the microoptimization used below. + if self.rootdir is None: + rootdir = "" + else: + assert os.path.isabs(self.rootdir) + rootdir = self.rootdir + os.path.sep + + # read the configuration + sections = read_ini(fp=fp, variables=defaults, strict=self.strict, + handle_defaults=self._handle_defaults) + self.manifest_defaults[filename] = defaults + + parent_section_found = False + + # get the tests + for section, data in sections: + # In case of defaults only, no other section than parent: has to + # be processed. + if defaults_only and not section.startswith('parent:'): + continue + + # read the parent manifest if specified + if section.startswith('parent:'): + parent_section_found = True + + include_file = read_file('parent:') + if include_file: + self._read(root, include_file, {}, True) + continue + + # a file to include + # TODO: keep track of included file structure: + # self.manifests = {'manifest.ini': 'relative/path.ini'} + if section.startswith('include:'): + include_file = read_file('include:') + if include_file: + include_defaults = data.copy() + self._read(root, include_file, include_defaults, parentmanifest=filename) + continue + + # otherwise an item + # apply ancestor defaults, while maintaining current file priority + data = dict(self._ancestor_defaults.items() + data.items()) + + test = data + test['name'] = section + + # Will be None if the manifest being read is a file-like object. + test['manifest'] = filename + + # determine the path + path = test.get('path', section) + _relpath = path + if '://' not in path: # don't futz with URLs + path = normalize_path(path) + if here and not os.path.isabs(path): + # Profiling indicates 25% of manifest parsing is spent + # in this call to normpath, but almost all calls return + # their argument unmodified, so we avoid the call if + # '..' if not present in the path. + path = os.path.join(here, path) + if '..' in path: + path = os.path.normpath(path) + + # Microoptimization, because relpath is quite expensive. + # We know that rootdir is an absolute path or empty. If path + # starts with rootdir, then path is also absolute and the tail + # of the path is the relative path (possibly non-normalized, + # when here is unknown). + # For this to work rootdir needs to be terminated with a path + # separator, so that references to sibling directories with + # a common prefix don't get misscomputed (e.g. /root and + # /rootbeer/file). + # When the rootdir is unknown, the relpath needs to be left + # unchanged. We use an empty string as rootdir in that case, + # which leaves relpath unchanged after slicing. + if path.startswith(rootdir): + _relpath = path[len(rootdir):] + else: + _relpath = relpath(path, rootdir) + + test['path'] = path + test['relpath'] = _relpath + + if parentmanifest is not None: + # If a test was included by a parent manifest we may need to + # indicate that in the test object for the sake of identifying + # a test, particularly in the case a test file is included by + # multiple manifests. + test['ancestor-manifest'] = parentmanifest + + # append the item + self.tests.append(test) + + # if no parent: section was found for defaults-only, only read the + # defaults section of the manifest without interpreting variables + if defaults_only and not parent_section_found: + sections = read_ini(fp=fp, variables=defaults, defaults_only=True, + strict=self.strict) + (section, self._ancestor_defaults) = sections[0] + + def read(self, *filenames, **defaults): + """ + read and add manifests from file paths or file-like objects + + filenames -- file paths or file-like objects to read as manifests + defaults -- default variables + """ + + # ensure all files exist + missing = [filename for filename in filenames + if isinstance(filename, string) and not self.path_exists(filename)] + if missing: + raise IOError('Missing files: %s' % ', '.join(missing)) + + # default variables + _defaults = defaults.copy() or self._defaults.copy() + _defaults.setdefault('here', None) + + # process each file + for filename in filenames: + # set the per file defaults + defaults = _defaults.copy() + here = None + if isinstance(filename, string): + here = os.path.dirname(os.path.abspath(filename)) + defaults['here'] = here # directory of master .ini file + + if self.rootdir is None: + # set the root directory + # == the directory of the first manifest given + self.rootdir = here + + self._read(here, filename, defaults) + + # methods for querying manifests + + def query(self, *checks, **kw): + """ + general query function for tests + - checks : callable conditions to test if the test fulfills the query + """ + tests = kw.get('tests', None) + if tests is None: + tests = self.tests + retval = [] + for test in tests: + for check in checks: + if not check(test): + break + else: + retval.append(test) + return retval + + def get(self, _key=None, inverse=False, tags=None, tests=None, **kwargs): + # TODO: pass a dict instead of kwargs since you might hav + # e.g. 'inverse' as a key in the dict + + # TODO: tags should just be part of kwargs with None values + # (None == any is kinda weird, but probably still better) + + # fix up tags + if tags: + tags = set(tags) + else: + tags = set() + + # make some check functions + if inverse: + def has_tags(test): + return not tags.intersection(test.keys()) + + def dict_query(test): + for key, value in kwargs.items(): + if test.get(key) == value: + return False + return True + else: + def has_tags(test): + return tags.issubset(test.keys()) + + def dict_query(test): + for key, value in kwargs.items(): + if test.get(key) != value: + return False + return True + + # query the tests + tests = self.query(has_tags, dict_query, tests=tests) + + # if a key is given, return only a list of that key + # useful for keys like 'name' or 'path' + if _key: + return [test[_key] for test in tests] + + # return the tests + return tests + + def manifests(self, tests=None): + """ + return manifests in order in which they appear in the tests + """ + if tests is None: + # Make sure to return all the manifests, even ones without tests. + return self.manifest_defaults.keys() + + manifests = [] + for test in tests: + manifest = test.get('manifest') + if not manifest: + continue + if manifest not in manifests: + manifests.append(manifest) + return manifests + + def paths(self): + return [i['path'] for i in self.tests] + + # methods for auditing + + def missing(self, tests=None): + """ + return list of tests that do not exist on the filesystem + """ + if tests is None: + tests = self.tests + existing = list(_exists(tests, {})) + return [t for t in tests if t not in existing] + + def check_missing(self, tests=None): + missing = self.missing(tests=tests) + if missing: + missing_paths = [test['path'] for test in missing] + if self.strict: + raise IOError("Strict mode enabled, test paths must exist. " + "The following test(s) are missing: %s" % + json.dumps(missing_paths, indent=2)) + print >> sys.stderr, "Warning: The following test(s) are missing: %s" % \ + json.dumps(missing_paths, indent=2) + return missing + + def verifyDirectory(self, directories, pattern=None, extensions=None): + """ + checks what is on the filesystem vs what is in a manifest + returns a 2-tuple of sets: + (missing_from_filesystem, missing_from_manifest) + """ + + files = set([]) + if isinstance(directories, basestring): + directories = [directories] + + # get files in directories + for directory in directories: + for dirpath, dirnames, filenames in os.walk(directory, topdown=True): + + # only add files that match a pattern + if pattern: + filenames = fnmatch.filter(filenames, pattern) + + # only add files that have one of the extensions + if extensions: + filenames = [filename for filename in filenames + if os.path.splitext(filename)[-1] in extensions] + + files.update([os.path.join(dirpath, filename) for filename in filenames]) + + paths = set(self.paths()) + missing_from_filesystem = paths.difference(files) + missing_from_manifest = files.difference(paths) + return (missing_from_filesystem, missing_from_manifest) + + # methods for output + + def write(self, fp=sys.stdout, rootdir=None, + global_tags=None, global_kwargs=None, + local_tags=None, local_kwargs=None): + """ + write a manifest given a query + global and local options will be munged to do the query + globals will be written to the top of the file + locals (if given) will be written per test + """ + + # open file if `fp` given as string + close = False + if isinstance(fp, string): + fp = file(fp, 'w') + close = True + + # root directory + if rootdir is None: + rootdir = self.rootdir + + # sanitize input + global_tags = global_tags or set() + local_tags = local_tags or set() + global_kwargs = global_kwargs or {} + local_kwargs = local_kwargs or {} + + # create the query + tags = set([]) + tags.update(global_tags) + tags.update(local_tags) + kwargs = {} + kwargs.update(global_kwargs) + kwargs.update(local_kwargs) + + # get matching tests + tests = self.get(tags=tags, **kwargs) + + # print the .ini manifest + if global_tags or global_kwargs: + print >> fp, '[DEFAULT]' + for tag in global_tags: + print >> fp, '%s =' % tag + for key, value in global_kwargs.items(): + print >> fp, '%s = %s' % (key, value) + print >> fp + + for test in tests: + test = test.copy() # don't overwrite + + path = test['name'] + if not os.path.isabs(path): + path = test['path'] + if self.rootdir: + path = relpath(test['path'], self.rootdir) + path = denormalize_path(path) + print >> fp, '[%s]' % path + + # reserved keywords: + reserved = ['path', 'name', 'here', 'manifest', 'relpath', 'ancestor-manifest'] + for key in sorted(test.keys()): + if key in reserved: + continue + if key in global_kwargs: + continue + if key in global_tags and not test[key]: + continue + print >> fp, '%s = %s' % (key, test[key]) + print >> fp + + if close: + # close the created file + fp.close() + + def __str__(self): + fp = StringIO() + self.write(fp=fp) + value = fp.getvalue() + return value + + def copy(self, directory, rootdir=None, *tags, **kwargs): + """ + copy the manifests and associated tests + - directory : directory to copy to + - rootdir : root directory to copy to (if not given from manifests) + - tags : keywords the tests must have + - kwargs : key, values the tests must match + """ + # XXX note that copy does *not* filter the tests out of the + # resulting manifest; it just stupidly copies them over. + # ideally, it would reread the manifests and filter out the + # tests that don't match *tags and **kwargs + + # destination + if not os.path.exists(directory): + os.path.makedirs(directory) + else: + # sanity check + assert os.path.isdir(directory) + + # tests to copy + tests = self.get(tags=tags, **kwargs) + if not tests: + return # nothing to do! + + # root directory + if rootdir is None: + rootdir = self.rootdir + + # copy the manifests + tests + manifests = [relpath(manifest, rootdir) for manifest in self.manifests()] + for manifest in manifests: + destination = os.path.join(directory, manifest) + dirname = os.path.dirname(destination) + if not os.path.exists(dirname): + os.makedirs(dirname) + else: + # sanity check + assert os.path.isdir(dirname) + shutil.copy(os.path.join(rootdir, manifest), destination) + + missing = self.check_missing(tests) + tests = [test for test in tests if test not in missing] + for test in tests: + if os.path.isabs(test['name']): + continue + source = test['path'] + destination = os.path.join(directory, relpath(test['path'], rootdir)) + shutil.copy(source, destination) + # TODO: ensure that all of the tests are below the from_dir + + def update(self, from_dir, rootdir=None, *tags, **kwargs): + """ + update the tests as listed in a manifest from a directory + - from_dir : directory where the tests live + - rootdir : root directory to copy to (if not given from manifests) + - tags : keys the tests must have + - kwargs : key, values the tests must match + """ + + # get the tests + tests = self.get(tags=tags, **kwargs) + + # get the root directory + if not rootdir: + rootdir = self.rootdir + + # copy them! + for test in tests: + if not os.path.isabs(test['name']): + _relpath = relpath(test['path'], rootdir) + source = os.path.join(from_dir, _relpath) + if not os.path.exists(source): + message = "Missing test: '%s' does not exist!" + if self.strict: + raise IOError(message) + print >> sys.stderr, message + " Skipping." + continue + destination = os.path.join(rootdir, _relpath) + shutil.copy(source, destination) + + # directory importers + + @classmethod + def _walk_directories(cls, directories, callback, pattern=None, ignore=()): + """ + internal function to import directories + """ + + if isinstance(pattern, basestring): + patterns = [pattern] + else: + patterns = pattern + ignore = set(ignore) + + if not patterns: + def accept_filename(filename): + return True + else: + def accept_filename(filename): + for pattern in patterns: + if fnmatch.fnmatch(filename, pattern): + return True + + if not ignore: + def accept_dirname(dirname): + return True + else: + def accept_dirname(dirname): + return dirname not in ignore + + rootdirectories = directories[:] + seen_directories = set() + for rootdirectory in rootdirectories: + # let's recurse directories using list + directories = [os.path.realpath(rootdirectory)] + while directories: + directory = directories.pop(0) + if directory in seen_directories: + # eliminate possible infinite recursion due to + # symbolic links + continue + seen_directories.add(directory) + + files = [] + subdirs = [] + for name in sorted(os.listdir(directory)): + path = os.path.join(directory, name) + if os.path.isfile(path): + # os.path.isfile follow symbolic links, we don't + # need to handle them here. + if accept_filename(name): + files.append(name) + continue + elif os.path.islink(path): + # eliminate symbolic links + path = os.path.realpath(path) + + # we must have a directory here + if accept_dirname(name): + subdirs.append(name) + # this subdir is added for recursion + directories.insert(0, path) + + # here we got all subdirs and files filtered, we can + # call the callback function if directory is not empty + if subdirs or files: + callback(rootdirectory, directory, subdirs, files) + + @classmethod + def populate_directory_manifests(cls, directories, filename, pattern=None, ignore=(), + overwrite=False): + """ + walks directories and writes manifests of name `filename` in-place; + returns `cls` instance populated with the given manifests + + filename -- filename of manifests to write + pattern -- shell pattern (glob) or patterns of filenames to match + ignore -- directory names to ignore + overwrite -- whether to overwrite existing files of given name + """ + + manifest_dict = {} + + if os.path.basename(filename) != filename: + raise IOError("filename should not include directory name") + + # no need to hit directories more than once + _directories = directories + directories = [] + for directory in _directories: + if directory not in directories: + directories.append(directory) + + def callback(directory, dirpath, dirnames, filenames): + """write a manifest for each directory""" + + manifest_path = os.path.join(dirpath, filename) + if (dirnames or filenames) and not (os.path.exists(manifest_path) and overwrite): + with file(manifest_path, 'w') as manifest: + for dirname in dirnames: + print >> manifest, '[include:%s]' % os.path.join(dirname, filename) + for _filename in filenames: + print >> manifest, '[%s]' % _filename + + # add to list of manifests + manifest_dict.setdefault(directory, manifest_path) + + # walk the directories to gather files + cls._walk_directories(directories, callback, pattern=pattern, ignore=ignore) + # get manifests + manifests = [manifest_dict[directory] for directory in _directories] + + # create a `cls` instance with the manifests + return cls(manifests=manifests) + + @classmethod + def from_directories(cls, directories, pattern=None, ignore=(), write=None, relative_to=None): + """ + convert directories to a simple manifest; returns ManifestParser instance + + pattern -- shell pattern (glob) or patterns of filenames to match + ignore -- directory names to ignore + write -- filename or file-like object of manifests to write; + if `None` then a StringIO instance will be created + relative_to -- write paths relative to this path; + if false then the paths are absolute + """ + + # determine output + opened_manifest_file = None # name of opened manifest file + absolute = not relative_to # whether to output absolute path names as names + if isinstance(write, string): + opened_manifest_file = write + write = file(write, 'w') + if write is None: + write = StringIO() + + # walk the directories, generating manifests + def callback(directory, dirpath, dirnames, filenames): + + # absolute paths + filenames = [os.path.join(dirpath, filename) + for filename in filenames] + # ensure new manifest isn't added + filenames = [filename for filename in filenames + if filename != opened_manifest_file] + # normalize paths + if not absolute and relative_to: + filenames = [relpath(filename, relative_to) + for filename in filenames] + + # write to manifest + print >> write, '\n'.join(['[%s]' % denormalize_path(filename) + for filename in filenames]) + + cls._walk_directories(directories, callback, pattern=pattern, ignore=ignore) + + if opened_manifest_file: + # close file + write.close() + manifests = [opened_manifest_file] + else: + # manifests/write is a file-like object; + # rewind buffer + write.flush() + write.seek(0) + manifests = [write] + + # make a ManifestParser instance + return cls(manifests=manifests) + +convert = ManifestParser.from_directories + + +class TestManifest(ManifestParser): + """ + apply logic to manifests; this is your integration layer :) + specific harnesses may subclass from this if they need more logic + """ + + def __init__(self, *args, **kwargs): + ManifestParser.__init__(self, *args, **kwargs) + self.filters = filterlist(DEFAULT_FILTERS) + self.last_used_filters = [] + + def active_tests(self, exists=True, disabled=True, filters=None, **values): + """ + Run all applied filters on the set of tests. + + :param exists: filter out non-existing tests (default True) + :param disabled: whether to return disabled tests (default True) + :param values: keys and values to filter on (e.g. `os = linux mac`) + :param filters: list of filters to apply to the tests + :returns: list of test objects that were not filtered out + """ + tests = [i.copy() for i in self.tests] # shallow copy + + # mark all tests as passing + for test in tests: + test['expected'] = test.get('expected', 'pass') + + # make a copy so original doesn't get modified + fltrs = self.filters[:] + if exists: + if self.strict: + self.check_missing(tests) + else: + fltrs.append(_exists) + + if not disabled: + fltrs.append(enabled) + + if filters: + fltrs += filters + + self.last_used_filters = fltrs[:] + for fn in fltrs: + tests = fn(tests, values) + return list(tests) + + def test_paths(self): + return [test['path'] for test in self.active_tests()] + + def fmt_filters(self, filters=None): + filters = filters or self.last_used_filters + names = [] + for f in filters: + if isinstance(f, types.FunctionType): + names.append(f.__name__) + else: + names.append(str(f)) + return ', '.join(names) |