diff options
Diffstat (limited to 'ext/testlib')
-rw-r--r-- | ext/testlib/__init__.py | 44 | ||||
-rw-r--r-- | ext/testlib/config.py | 687 | ||||
-rw-r--r-- | ext/testlib/fixture.py | 108 | ||||
-rw-r--r-- | ext/testlib/handlers.py | 437 | ||||
-rw-r--r-- | ext/testlib/helper.py | 460 | ||||
-rw-r--r-- | ext/testlib/loader.py | 302 | ||||
-rw-r--r-- | ext/testlib/log.py | 256 | ||||
-rw-r--r-- | ext/testlib/main.py | 328 | ||||
-rw-r--r-- | ext/testlib/query.py | 71 | ||||
-rw-r--r-- | ext/testlib/result.py | 303 | ||||
-rw-r--r-- | ext/testlib/runner.py | 216 | ||||
-rw-r--r-- | ext/testlib/sandbox.py | 193 | ||||
-rw-r--r-- | ext/testlib/state.py | 63 | ||||
-rw-r--r-- | ext/testlib/suite.py | 69 | ||||
-rw-r--r-- | ext/testlib/terminal.py | 165 | ||||
-rw-r--r-- | ext/testlib/test.py | 91 | ||||
-rw-r--r-- | ext/testlib/uid.py | 110 | ||||
-rw-r--r-- | ext/testlib/wrappers.py | 236 |
18 files changed, 4139 insertions, 0 deletions
diff --git a/ext/testlib/__init__.py b/ext/testlib/__init__.py new file mode 100644 index 000000000..893da5433 --- /dev/null +++ b/ext/testlib/__init__.py @@ -0,0 +1,44 @@ +# Copyright (c) 2017 Mark D. Hill and David A. Wood +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer; +# redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution; +# neither the name of the copyright holders nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Authors: Sean Wilson + + +from .state import * +from .runner import * +from .test import * +from .suite import * +from .loader import * +from .fixture import * +from .config import * +from main import main + +#TODO Remove this awkward bootstrap +#FIXME +from gem5 import * + +#TODO Remove this as an export, users should getcwd from os +from os import getcwd diff --git a/ext/testlib/config.py b/ext/testlib/config.py new file mode 100644 index 000000000..5e28cd0fd --- /dev/null +++ b/ext/testlib/config.py @@ -0,0 +1,687 @@ +# Copyright (c) 2017 Mark D. Hill and David A. Wood +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer; +# redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution; +# neither the name of the copyright holders nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Authors: Sean Wilson + +''' +Global configuration module which exposes two types of configuration +variables: + +1. config +2. constants (Also attached to the config variable as an attribute) + +The main motivation for this module is to have a centralized location for +defaults and configuration by command line and files for the test framework. + +A secondary goal is to reduce programming errors by providing common constant +strings and values as python attributes to simplify detection of typos. +A simple typo in a string can take a lot of debugging to uncover the issue, +attribute errors are easier to notice and most autocompletion systems detect +them. + +The config variable is initialzed by calling :func:`initialize_config`. +Before this point only ``constants`` will be availaible. This is to ensure +that library function writers never accidentally get stale config attributes. + +Program arguments/flag arguments are available from the config as attributes. +If an attribute was not set by the command line or the optional config file, +then it will fallback to the `_defaults` value, if still the value is not +found an AttributeError will be raised. + +:func define_defaults: + Provided by the config if the attribute is not found in the config or + commandline. For instance, if we are using the list command fixtures might + not be able to count on the build_dir being provided since we aren't going + to build anything. + +:var constants: + Values not directly exposed by the config, but are attached to the object + for centralized access. I.E. you can reach them with + :code:`config.constants.attribute`. These should be used for setting + common string names used across the test framework. + :code:`_defaults.build_dir = None` Once this module has been imported + constants should not be modified and their base attributes are frozen. +''' +import abc +import argparse +import copy +import os +import re + +from ConfigParser import ConfigParser +from pickle import HIGHEST_PROTOCOL as highest_pickle_protocol + +from helper import absdirpath, AttrDict, FrozenAttrDict + +class UninitialzedAttributeException(Exception): + ''' + Signals that an attribute in the config file was not initialized. + ''' + pass + +class UninitializedConfigException(Exception): + ''' + Signals that the config was not initialized before trying to access an + attribute. + ''' + pass + +class TagRegex(object): + def __init__(self, include, regex): + self.include = include + self.regex = re.compile(regex) + + def __str__(self): + type_ = 'Include' if self.include else 'Remove' + return '%10s: %s' % (type_, self.regex.pattern) + +class _Config(object): + _initialized = False + + __shared_dict = {} + + constants = AttrDict() + _defaults = AttrDict() + _config = {} + + _cli_args = {} + _post_processors = {} + + def __init__(self): + # This object will act as if it were a singleton. + self.__dict__ = self.__shared_dict + + def _init(self, parser): + self._parse_commandline_args(parser) + self._run_post_processors() + self._initialized = True + + def _init_with_dicts(self, config, defaults): + self._config = config + self._defaults = defaults + self._initialized = True + + def _add_post_processor(self, attr, post_processor): + ''' + :param attr: Attribute to pass to and recieve from the + :func:`post_processor`. + + :param post_processor: A callback functions called in a chain to + perform additional setup for a config argument. Should return a + tuple containing the new value for the config attr. + ''' + if attr not in self._post_processors: + self._post_processors[attr] = [] + self._post_processors[attr].append(post_processor) + + def _set(self, name, value): + self._config[name] = value + + def _parse_commandline_args(self, parser): + args = parser.parse_args() + + self._config_file_args = {} + + for attr in dir(args): + # Ignore non-argument attributes. + if not attr.startswith('_'): + self._config_file_args[attr] = getattr(args, attr) + self._config.update(self._config_file_args) + + def _run_post_processors(self): + for attr, callbacks in self._post_processors.items(): + newval = self._lookup_val(attr) + for callback in callbacks: + newval = callback(newval) + if newval is not None: + newval = newval[0] + self._set(attr, newval) + + + def _lookup_val(self, attr): + ''' + Get the attribute from the config or fallback to defaults. + + :returns: If the value is not stored return None. Otherwise a tuple + containing the value. + ''' + if attr in self._config: + return (self._config[attr],) + elif hasattr(self._defaults, attr): + return (getattr(self._defaults, attr),) + + def __getattr__(self, attr): + if attr in dir(super(_Config, self)): + return getattr(super(_Config, self), attr) + elif not self._initialized: + raise UninitializedConfigException( + 'Cannot directly access elements from the config before it is' + ' initialized') + else: + val = self._lookup_val(attr) + if val is not None: + return val[0] + else: + raise UninitialzedAttributeException( + '%s was not initialzed in the config.' % attr) + + def get_tags(self): + d = {typ: set(self.__getattr__(typ)) + for typ in self.constants.supported_tags} + if any(map(lambda vals: bool(vals), d.values())): + return d + else: + return {} + +def define_defaults(defaults): + ''' + Defaults are provided by the config if the attribute is not found in the + config or commandline. For instance, if we are using the list command + fixtures might not be able to count on the build_dir being provided since + we aren't going to build anything. + ''' + defaults.base_dir = os.path.abspath(os.path.join(absdirpath(__file__), + os.pardir, + os.pardir)) + defaults.result_path = os.path.join(os.getcwd(), '.testing-results') + defaults.list_only_failed = False + +def define_constants(constants): + ''' + 'constants' are values not directly exposed by the config, but are attached + to the object for centralized access. These should be used for setting + common string names used across the test framework. A simple typo in + a string can take a lot of debugging to uncover the issue, attribute errors + are easier to notice and most autocompletion systems detect them. + ''' + constants.system_out_name = 'system-out' + constants.system_err_name = 'system-err' + + constants.isa_tag_type = 'isa' + constants.x86_tag = 'X86' + constants.sparc_tag = 'SPARC' + constants.alpha_tag = 'ALPHA' + constants.riscv_tag = 'RISCV' + constants.arm_tag = 'ARM' + constants.mips_tag = 'MIPS' + constants.power_tag = 'POWER' + constants.null_tag = 'NULL' + + constants.variant_tag_type = 'variant' + constants.opt_tag = 'opt' + constants.debug_tag = 'debug' + constants.fast_tag = 'fast' + + constants.length_tag_type = 'length' + constants.quick_tag = 'quick' + constants.long_tag = 'long' + + constants.supported_tags = { + constants.isa_tag_type : ( + constants.x86_tag, + constants.sparc_tag, + constants.alpha_tag, + constants.riscv_tag, + constants.arm_tag, + constants.mips_tag, + constants.power_tag, + constants.null_tag, + ), + constants.variant_tag_type: ( + constants.opt_tag, + constants.debug_tag, + constants.fast_tag, + ), + constants.length_tag_type: ( + constants.quick_tag, + constants.long_tag, + ), + } + + constants.supported_isas = constants.supported_tags['isa'] + constants.supported_variants = constants.supported_tags['variant'] + constants.supported_lengths = constants.supported_tags['length'] + + constants.tempdir_fixture_name = 'tempdir' + constants.gem5_simulation_stderr = 'simerr' + constants.gem5_simulation_stdout = 'simout' + constants.gem5_simulation_stats = 'stats.txt' + constants.gem5_simulation_config_ini = 'config.ini' + constants.gem5_simulation_config_json = 'config.json' + constants.gem5_returncode_fixture_name = 'gem5-returncode' + constants.gem5_binary_fixture_name = 'gem5' + constants.xml_filename = 'results.xml' + constants.pickle_filename = 'results.pickle' + constants.pickle_protocol = highest_pickle_protocol + + # The root directory which all test names will be based off of. + constants.testing_base = absdirpath(os.path.join(absdirpath(__file__), + os.pardir)) + +def define_post_processors(config): + ''' + post_processors are used to do final configuration of variables. This is + useful if there is a dynamically set default, or some function that needs + to be applied after parsing in order to set a configration value. + + Post processors must accept a single argument that will either be a tuple + containing the already set config value or ``None`` if the config value + has not been set to anything. They must return the modified value in the + same format. + ''' + + def set_default_build_dir(build_dir): + ''' + Post-processor to set the default build_dir based on the base_dir. + + .. seealso :func:`~_Config._add_post_processor` + ''' + if not build_dir or build_dir[0] is None: + base_dir = config._lookup_val('base_dir')[0] + build_dir = (os.path.join(base_dir, 'build'),) + return build_dir + + def fix_verbosity_hack(verbose): + return (verbose[0].val,) + + def threads_as_int(threads): + if threads is not None: + return (int(threads[0]),) + + def test_threads_as_int(test_threads): + if test_threads is not None: + return (int(test_threads[0]),) + + def default_isa(isa): + if not isa[0]: + return [constants.supported_tags[constants.isa_tag_type]] + else: + return isa + + def default_variant(variant): + if not variant[0]: + # Default variant is only opt. No need to run tests with multiple + # different compilation targets + return [[constants.opt_tag]] + else: + return variant + + def default_length(length): + if not length[0]: + return [[constants.quick_tag]] + else: + return length + + def compile_tag_regex(positional_tags): + if not positional_tags: + return positional_tags + else: + new_positional_tags_list = [] + positional_tags = positional_tags[0] + + for flag, regex in positional_tags: + if flag == 'exclude_tags': + tag_regex = TagRegex(False, regex) + elif flag == 'include_tags': + tag_regex = TagRegex(True, regex) + else: + raise ValueError('Unsupported flag.') + new_positional_tags_list.append(tag_regex) + + return (new_positional_tags_list,) + + config._add_post_processor('build_dir', set_default_build_dir) + config._add_post_processor('verbose', fix_verbosity_hack) + config._add_post_processor('isa', default_isa) + config._add_post_processor('variant', default_variant) + config._add_post_processor('length', default_length) + config._add_post_processor('threads', threads_as_int) + config._add_post_processor('test_threads', test_threads_as_int) + config._add_post_processor(StorePositionalTagsAction.position_kword, + compile_tag_regex) +class Argument(object): + ''' + Class represents a cli argument/flag for a argparse parser. + + :attr name: The long name of this object that will be stored in the arg + output by the final parser. + ''' + def __init__(self, *flags, **kwargs): + self.flags = flags + self.kwargs = kwargs + + if len(flags) == 0: + raise ValueError("Need at least one argument.") + elif 'dest' in kwargs: + self.name = kwargs['dest'] + elif len(flags) > 1 or flags[0].startswith('-'): + for flag in flags: + if not flag.startswith('-'): + raise ValueError("invalid option string %s: must start" + "with a character '-'" % flag) + + if flag.startswith('--'): + if not hasattr(self, 'name'): + self.name = flag.lstrip('-') + + if not hasattr(self, 'name'): + self.name = flags[0].lstrip('-') + self.name = self.name.replace('-', '_') + + def add_to(self, parser): + '''Add this argument to the given parser.''' + parser.add_argument(*self.flags, **self.kwargs) + + def copy(self): + '''Copy this argument so you might modify any of its kwargs.''' + return copy.deepcopy(self) + + +class _StickyInt: + ''' + A class that is used to cheat the verbosity count incrementer by + pretending to be an int. This makes the int stay on the heap and eat other + real numbers when they are added to it. + + We use this so we can allow the verbose flag to be provided before or after + the subcommand. This likely has no utility outside of this use case. + ''' + def __init__(self, val=0): + self.val = val + self.type = int + def __add__(self, other): + self.val += other + return self + +common_args = NotImplemented + +class StorePositionAction(argparse.Action): + '''Base class for classes wishing to create namespaces where + arguments are stored in the order provided via the command line. + ''' + position_kword = 'positional' + + def __call__(self, parser, namespace, values, option_string=None): + if not self.position_kword in namespace: + setattr(namespace, self.position_kword, []) + previous = getattr(namespace, self.position_kword) + previous.append((self.dest, values)) + setattr(namespace, self.position_kword, previous) + +class StorePositionalTagsAction(StorePositionAction): + position_kword = 'tag_filters' + +def define_common_args(config): + ''' + Common args are arguments which are likely to be simular between different + subcommands, so they are available to all by placing their definitions + here. + ''' + global common_args + + # A list of common arguments/flags used across cli parsers. + common_args = [ + Argument( + 'directory', + nargs='?', + default=os.getcwd(), + help='Directory to start searching for tests in'), + Argument( + '--exclude-tags', + action=StorePositionalTagsAction, + help='A tag comparison used to select tests.'), + Argument( + '--include-tags', + action=StorePositionalTagsAction, + help='A tag comparison used to select tests.'), + Argument( + '--isa', + action='append', + default=[], + help="Only tests that are valid with one of these ISAs. " + "Comma separated."), + Argument( + '--variant', + action='append', + default=[], + help="Only tests that are valid with one of these binary variants" + "(e.g., opt, debug). Comma separated."), + Argument( + '--length', + action='append', + default=[], + help="Only tests that are one of these lengths. Comma separated."), + Argument( + '--uid', + action='store', + default=None, + help='UID of a specific test item to run.'), + Argument( + '--build-dir', + action='store', + help='Build directory for SCons'), + Argument( + '--base-dir', + action='store', + default=config._defaults.base_dir, + help='Directory to change to in order to exec scons.'), + Argument( + '-j', '--threads', + action='store', + default=1, + help='Number of threads to run SCons with.'), + Argument( + '-t', '--test-threads', + action='store', + default=1, + help='Number of threads to spawn to run concurrent tests with.'), + Argument( + '-v', + action='count', + dest='verbose', + default=_StickyInt(), + help='Increase verbosity'), + Argument( + '--config-path', + action='store', + default=os.getcwd(), + help='Path to read a testing.ini config in' + ), + Argument( + '--skip-build', + action='store_true', + default=False, + help='Skip the building component of SCons targets.' + ), + Argument( + '--result-path', + action='store', + help='The path to store results in.' + ), + ] + + # NOTE: There is a limitation which arises due to this format. If you have + # multiple arguments with the same name only the final one in the list + # will be saved. + # + # e.g. if you have a -v argument which increments verbosity level and + # a separate --verbose flag which 'store's verbosity level. the final + # one in the list will be saved. + common_args = AttrDict({arg.name:arg for arg in common_args}) + + +class ArgParser(object): + __metaclass__ = abc.ABCMeta + + def __init__(self, parser): + # Copy public methods of the parser. + for attr in dir(parser): + if not attr.startswith('_'): + setattr(self, attr, getattr(parser, attr)) + self.parser = parser + self.add_argument = self.parser.add_argument + + # Argument will be added to all parsers and subparsers. + common_args.verbose.add_to(parser) + + +class CommandParser(ArgParser): + ''' + Main parser which parses command strings and uses those to direct to + a subparser. + ''' + def __init__(self): + parser = argparse.ArgumentParser() + super(CommandParser, self).__init__(parser) + self.subparser = self.add_subparsers(dest='command') + + +class RunParser(ArgParser): + ''' + Parser for the \'run\' command. + ''' + def __init__(self, subparser): + parser = subparser.add_parser( + 'run', + help='''Run Tests.''' + ) + + super(RunParser, self).__init__(parser) + + common_args.uid.add_to(parser) + common_args.skip_build.add_to(parser) + common_args.directory.add_to(parser) + common_args.build_dir.add_to(parser) + common_args.base_dir.add_to(parser) + common_args.threads.add_to(parser) + common_args.test_threads.add_to(parser) + common_args.isa.add_to(parser) + common_args.variant.add_to(parser) + common_args.length.add_to(parser) + common_args.include_tags.add_to(parser) + common_args.exclude_tags.add_to(parser) + + +class ListParser(ArgParser): + ''' + Parser for the \'list\' command. + ''' + def __init__(self, subparser): + parser = subparser.add_parser( + 'list', + help='''List and query test metadata.''' + ) + super(ListParser, self).__init__(parser) + + Argument( + '--suites', + action='store_true', + default=False, + help='List all test suites.' + ).add_to(parser) + Argument( + '--tests', + action='store_true', + default=False, + help='List all test cases.' + ).add_to(parser) + Argument( + '--fixtures', + action='store_true', + default=False, + help='List all fixtures.' + ).add_to(parser) + Argument( + '--all-tags', + action='store_true', + default=False, + help='List all tags.' + ).add_to(parser) + Argument( + '-q', + dest='quiet', + action='store_true', + default=False, + help='Quiet output (machine readable).' + ).add_to(parser) + + common_args.directory.add_to(parser) + common_args.isa.add_to(parser) + common_args.variant.add_to(parser) + common_args.length.add_to(parser) + common_args.include_tags.add_to(parser) + common_args.exclude_tags.add_to(parser) + + +class RerunParser(ArgParser): + def __init__(self, subparser): + parser = subparser.add_parser( + 'rerun', + help='''Rerun failed tests.''' + ) + super(RerunParser, self).__init__(parser) + + common_args.skip_build.add_to(parser) + common_args.directory.add_to(parser) + common_args.build_dir.add_to(parser) + common_args.base_dir.add_to(parser) + common_args.threads.add_to(parser) + common_args.test_threads.add_to(parser) + common_args.isa.add_to(parser) + common_args.variant.add_to(parser) + common_args.length.add_to(parser) + +config = _Config() +define_constants(config.constants) + +# Constants are directly exposed and available once this module is created. +# All constants MUST be defined before this point. +config.constants = FrozenAttrDict(config.constants.__dict__) +constants = config.constants + +''' +This config object is the singleton config object available throughout the +framework. +''' +def initialize_config(): + ''' + Parse the commandline arguments and setup the config varibles. + ''' + global config + + # Setup constants and defaults + define_defaults(config._defaults) + define_post_processors(config) + define_common_args(config) + + # Setup parser and subcommands + baseparser = CommandParser() + runparser = RunParser(baseparser.subparser) + listparser = ListParser(baseparser.subparser) + rerunparser = RerunParser(baseparser.subparser) + + # Initialize the config by parsing args and running callbacks. + config._init(baseparser) diff --git a/ext/testlib/fixture.py b/ext/testlib/fixture.py new file mode 100644 index 000000000..ffff54cd8 --- /dev/null +++ b/ext/testlib/fixture.py @@ -0,0 +1,108 @@ +# Copyright (c) 2017 Mark D. Hill and David A. Wood +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer; +# redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution; +# neither the name of the copyright holders nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Authors: Sean Wilson + +import copy +import traceback + +import helper +import log + +global_fixtures = [] + +class SkipException(Exception): + def __init__(self, fixture, testitem): + self.fixture = fixture + self.testitem = testitem + + self.msg = 'Fixture "%s" raised SkipException for "%s".' % ( + fixture.name, testitem.name + ) + super(SkipException, self).__init__(self.msg) + + +class Fixture(object): + ''' + Base Class for a test Fixture. + + Fixtures are items which possibly require setup and/or tearing down after + a TestCase, TestSuite, or the Library has completed. + + Fixtures are the prefered method of carrying incremental results or + variables between TestCases in TestSuites. (Rather than using globals.) + Using fixtures rather than globals ensures that state will be maintained + when executing tests in parallel. + + .. note:: In order for Fixtures to be enumerated by the test system this + class' :code:`__new__` method must be called. + ''' + collector = helper.InstanceCollector() + + def __new__(klass, *args, **kwargs): + obj = super(Fixture, klass).__new__(klass, *args, **kwargs) + Fixture.collector.collect(obj) + return obj + + def __init__(self, name=None, **kwargs): + if name is None: + name = self.__class__.__name__ + self.name = name + + def skip(self, testitem): + raise SkipException(self.name, testitem.metadata) + + def schedule_finalized(self, schedule): + ''' + This method is called once the schedule of for tests is known. + To enable tests to use the same fixture defintion for each execution + fixtures must return a copy of themselves in this method. + + :returns: a copy of this fixture which will be setup/torndown + when the test item this object is tied to is about to execute. + ''' + return self.copy() + + def init(self, *args, **kwargs): + pass + + def setup(self, testitem): + pass + + def teardown(self, testitem): + pass + + def copy(self): + return copy.deepcopy(self) + + +def globalfixture(fixture): + ''' + Store the given fixture as a global fixture. Its setup() method + will be called before the first test is executed. + ''' + global_fixtures.append(fixture) + return fixture diff --git a/ext/testlib/handlers.py b/ext/testlib/handlers.py new file mode 100644 index 000000000..7530f9bf1 --- /dev/null +++ b/ext/testlib/handlers.py @@ -0,0 +1,437 @@ +# Copyright (c) 2017 Mark D. Hill and David A. Wood +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer; +# redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution; +# neither the name of the copyright holders nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Authors: Sean Wilson + +''' +Handlers for the testlib Log. + + +''' +from __future__ import print_function + +import multiprocessing +import os +import Queue +import sys +import threading +import time +import traceback + +import helper +import log +import result +import state +import test +import terminal + +from config import config, constants + + +class _TestStreamManager(object): + def __init__(self): + self._writers = {} + + def open_writer(self, test_result): + if test_result in self._writers: + raise ValueError('Cannot have multiple writters on a single test.') + self._writers[test_result] = _TestStreams(test_result.stdout, + test_result.stderr) + + def get_writer(self, test_result): + if test_result not in self._writers: + self.open_writer(test_result) + return self._writers[test_result] + + def close_writer(self, test_result): + if test_result in self._writers: + writer = self._writers.pop(test_result) + writer.close() + + def close(self): + for writer in self._writers.values(): + writer.close() + self._writers.clear() + +class _TestStreams(object): + def __init__(self, stdout, stderr): + helper.mkdir_p(os.path.dirname(stdout)) + helper.mkdir_p(os.path.dirname(stderr)) + self.stdout = open(stdout, 'w') + self.stderr = open(stderr, 'w') + + def close(self): + self.stdout.close() + self.stderr.close() + +class ResultHandler(log.Handler): + ''' + Log handler which listens for test results and output saving data as + it is reported. + + When the handler is closed it writes out test results in the python pickle + format. + ''' + def __init__(self, schedule, directory): + ''' + :param schedule: The entire schedule as a :class:`LoadedLibrary` + object. + + :param directory: Directory to save test stdout/stderr and aggregate + results to. + ''' + self.directory = directory + self.internal_results = result.InternalLibraryResults(schedule, + directory) + self.test_stream_manager = _TestStreamManager() + self._closed = False + + self.mapping = { + log.LibraryStatus.type_id: self.handle_library_status, + + log.SuiteResult.type_id: self.handle_suite_result, + log.TestResult.type_id: self.handle_test_result, + + log.TestStderr.type_id: self.handle_stderr, + log.TestStdout.type_id: self.handle_stdout, + } + + def handle(self, record): + if not self._closed: + self.mapping.get(record.type_id, lambda _:None)(record) + + def handle_library_status(self, record): + if record['status'] in (state.Status.Complete, state.Status.Avoided): + self.test_stream_manager.close() + + def handle_suite_result(self, record): + suite_result = self.internal_results.get_suite_result( + record['metadata'].uid) + suite_result.result = record['result'] + + def handle_test_result(self, record): + test_result = self._get_test_result(record) + test_result.result = record['result'] + + def handle_stderr(self, record): + self.test_stream_manager.get_writer( + self._get_test_result(record) + ).stderr.write(record['buffer']) + + def handle_stdout(self, record): + self.test_stream_manager.get_writer( + self._get_test_result(record) + ).stdout.write(record['buffer']) + + def _get_test_result(self, test_record): + return self.internal_results.get_test_result( + test_record['metadata'].uid, + test_record['metadata'].suite_uid) + + def _save(self): + #FIXME Hardcoded path name + result.InternalSavedResults.save( + self.internal_results, + os.path.join(self.directory, constants.pickle_filename)) + result.JUnitSavedResults.save( + self.internal_results, + os.path.join(self.directory, constants.xml_filename)) + + def close(self): + if self._closed: + return + self._closed = True + self._save() + + +#TODO Change from a handler to an internal post processor so it can be used +# to reprint results +class SummaryHandler(log.Handler): + ''' + A log handler which listens to the log for test results + and reports the aggregate results when closed. + ''' + color = terminal.get_termcap() + reset = color.Normal + colormap = { + state.Result.Errored: color.Red, + state.Result.Failed: color.Red, + state.Result.Passed: color.Green, + state.Result.Skipped: color.Cyan, + } + sep_fmtkey = 'separator' + sep_fmtstr = '{%s}' % sep_fmtkey + + def __init__(self): + self.mapping = { + log.TestResult.type_id: self.handle_testresult, + log.LibraryStatus.type_id: self.handle_library_status, + } + self._timer = helper.Timer() + self.results = [] + + def handle_library_status(self, record): + if record['status'] == state.Status.Building: + self._timer.restart() + + def handle_testresult(self, record): + result = record['result'].value + if result in (state.Result.Skipped, state.Result.Failed, + state.Result.Passed, state.Result.Errored): + self.results.append(result) + + def handle(self, record): + self.mapping.get(record.type_id, lambda _:None)(record) + + def close(self): + print(self._display_summary()) + + def _display_summary(self): + most_severe_outcome = None + outcome_fmt = ' {count} {outcome}' + strings = [] + + outcome_count = [0] * len(state.Result.enums) + for result in self.results: + outcome_count[result] += 1 + + # Iterate over enums so they are in order of severity + for outcome in state.Result.enums: + outcome = getattr(state.Result, outcome) + count = outcome_count[outcome] + if count: + strings.append(outcome_fmt.format(count=count, + outcome=state.Result.enums[outcome])) + most_severe_outcome = outcome + string = ','.join(strings) + if most_severe_outcome is None: + string = ' No testing done' + most_severe_outcome = state.Result.Passed + else: + string = ' Results:' + string + ' in {:.2} seconds '.format( + self._timer.active_time()) + string += ' ' + return terminal.insert_separator( + string, + color=self.colormap[most_severe_outcome] + self.color.Bold) + +class TerminalHandler(log.Handler): + color = terminal.get_termcap() + verbosity_mapping = { + log.LogLevel.Warn: color.Yellow, + log.LogLevel.Error: color.Red, + } + default = color.Normal + + def __init__(self, verbosity=log.LogLevel.Info, machine_only=False): + self.stream = verbosity >= log.LogLevel.Trace + self.verbosity = verbosity + self.machine_only = machine_only + self.mapping = { + log.TestResult.type_id: self.handle_testresult, + log.SuiteStatus.type_id: self.handle_suitestatus, + log.TestStatus.type_id: self.handle_teststatus, + log.TestStderr.type_id: self.handle_stderr, + log.TestStdout.type_id: self.handle_stdout, + log.TestMessage.type_id: self.handle_testmessage, + log.LibraryMessage.type_id: self.handle_librarymessage, + } + + def _display_outcome(self, name, outcome, reason=None): + print(self.color.Bold + + SummaryHandler.colormap[outcome] + + name + + ' ' + + state.Result.enums[outcome] + + SummaryHandler.reset) + + if reason is not None: + log.test_log.info('') + log.test_log.info('Reason:') + log.test_log.info(reason) + log.test_log.info(terminal.separator('-')) + + def handle_teststatus(self, record): + if record['status'] == state.Status.Running: + log.test_log.debug('Starting Test Case: %s' %\ + record['metadata'].name) + + def handle_testresult(self, record): + self._display_outcome( + 'Test: %s' % record['metadata'].name, + record['result'].value) + + def handle_suitestatus(self, record): + if record['status'] == state.Status.Running: + log.test_log.debug('Starting Test Suite: %s ' %\ + record['metadata'].name) + + def handle_stderr(self, record): + if self.stream: + print(record.data['buffer'], file=sys.stderr, end='') + + def handle_stdout(self, record): + if self.stream: + print(record.data['buffer'], file=sys.stdout, end='') + + def handle_testmessage(self, record): + if self.stream: + print(self._colorize(record['message'], record['level'])) + + def handle_librarymessage(self, record): + if not self.machine_only or record.data.get('machine_readable', False): + print(self._colorize(record['message'], record['level'], + record['bold'])) + + def _colorize(self, message, level, bold=False): + return '%s%s%s%s' % ( + self.color.Bold if bold else '', + self.verbosity_mapping.get(level, ''), + message, + self.default) + + def handle(self, record): + if record.data.get('level', self.verbosity) > self.verbosity: + return + self.mapping.get(record.type_id, lambda _:None)(record) + + def set_verbosity(self, verbosity): + self.verbosity = verbosity + + +class PrintHandler(log.Handler): + def __init__(self): + pass + + def handle(self, record): + print(str(record).rstrip()) + + def close(self): + pass + + +class MultiprocessingHandlerWrapper(log.Handler): + ''' + A handler class which forwards log records to subhandlers, enabling + logging across multiprocessing python processes. + + The 'parent' side of the handler should execute either + :func:`async_process` or :func:`process` to forward + log records to subhandlers. + ''' + def __init__(self, *subhandlers): + # Create thread to spin handing recipt of messages + # Create queue to push onto + self.queue = multiprocessing.Queue() + self.queue.cancel_join_thread() + self._shutdown = threading.Event() + + # subhandlers should be accessed with the _handler_lock + self._handler_lock = threading.Lock() + self._subhandlers = subhandlers + + def add_handler(self, handler): + self._handler_lock.acquire() + self._subhandlers = (handler, ) + self._subhandlers + self._handler_lock.release() + + def _with_handlers(self, callback): + exception = None + self._handler_lock.acquire() + for handler in self._subhandlers: + # Prevent deadlock when using this handler by delaying + # exception raise until we get a chance to unlock. + try: + callback(handler) + except Exception as e: + exception = e + break + self._handler_lock.release() + + if exception is not None: + raise exception + + def async_process(self): + self.thread = threading.Thread(target=self.process) + self.thread.daemon = True + self.thread.start() + + def process(self): + while not self._shutdown.is_set(): + try: + item = self.queue.get(timeout=0.1) + self._handle(item) + except (KeyboardInterrupt, SystemExit): + raise + except EOFError: + return + except Queue.Empty: + continue + + def _drain(self): + while True: + try: + item = self.queue.get(block=False) + self._handle(item) + except (KeyboardInterrupt, SystemExit): + raise + except EOFError: + return + except Queue.Empty: + return + + def _handle(self, record): + self._with_handlers(lambda handler: handler.handle(record)) + + def handle(self, record): + self.queue.put(record) + + def _close(self): + if hasattr(self, 'thread'): + self.thread.join() + _wrap(self._drain) + self._with_handlers(lambda handler: _wrap(handler.close)) + + # NOTE Python2 has an known bug which causes IOErrors to be raised + # if this shutdown doesn't go cleanly on both ends. + # This sleep adds some time for the sender threads on this process to + # finish pickling the object and complete shutdown after the queue is + # closed. + time.sleep(.2) + self.queue.close() + time.sleep(.2) + + def close(self): + if not self._shutdown.is_set(): + self._shutdown.set() + self._close() + + +def _wrap(callback, *args, **kwargs): + try: + callback(*args, **kwargs) + except: + traceback.print_exc() diff --git a/ext/testlib/helper.py b/ext/testlib/helper.py new file mode 100644 index 000000000..18256eab0 --- /dev/null +++ b/ext/testlib/helper.py @@ -0,0 +1,460 @@ +# Copyright (c) 2017 Mark D. Hill and David A. Wood +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer; +# redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution; +# neither the name of the copyright holders nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Authors: Sean Wilson + +''' +Helper classes for writing tests with this test library. +''' +from collections import MutableSet, OrderedDict + +import difflib +import errno +import os +import Queue +import re +import shutil +import stat +import subprocess +import tempfile +import threading +import time +import traceback + +#TODO Tear out duplicate logic from the sandbox IOManager +def log_call(logger, command, *popenargs, **kwargs): + ''' + Calls the given process and automatically logs the command and output. + + If stdout or stderr are provided output will also be piped into those + streams as well. + + :params stdout: Iterable of items to write to as we read from the + subprocess. + + :params stderr: Iterable of items to write to as we read from the + subprocess. + ''' + if isinstance(command, str): + cmdstr = command + else: + cmdstr = ' '.join(command) + + logger_callback = logger.trace + logger.trace('Logging call to command: %s' % cmdstr) + + stdout_redirect = kwargs.get('stdout', tuple()) + stderr_redirect = kwargs.get('stderr', tuple()) + + if hasattr(stdout_redirect, 'write'): + stdout_redirect = (stdout_redirect,) + if hasattr(stderr_redirect, 'write'): + stderr_redirect = (stderr_redirect,) + + kwargs['stdout'] = subprocess.PIPE + kwargs['stderr'] = subprocess.PIPE + p = subprocess.Popen(command, *popenargs, **kwargs) + + def log_output(log_callback, pipe, redirects=tuple()): + # Read iteractively, don't allow input to fill the pipe. + for line in iter(pipe.readline, ''): + for r in redirects: + r.write(line) + log_callback(line.rstrip()) + + stdout_thread = threading.Thread(target=log_output, + args=(logger_callback, p.stdout, stdout_redirect)) + stdout_thread.setDaemon(True) + stderr_thread = threading.Thread(target=log_output, + args=(logger_callback, p.stderr, stderr_redirect)) + stderr_thread.setDaemon(True) + + stdout_thread.start() + stderr_thread.start() + + retval = p.wait() + stdout_thread.join() + stderr_thread.join() + # Return the return exit code of the process. + if retval != 0: + raise subprocess.CalledProcessError(retval, cmdstr) + +# lru_cache stuff (Introduced in python 3.2+) +# Renamed and modified to cacheresult +class _HashedSeq(list): + ''' + This class guarantees that hash() will be called no more than once per + element. This is important because the cacheresult() will hash the key + multiple times on a cache miss. + + .. note:: From cpython 3.7 + ''' + + __slots__ = 'hashvalue' + + def __init__(self, tup, hash=hash): + self[:] = tup + self.hashvalue = hash(tup) + + def __hash__(self): + return self.hashvalue + +def _make_key(args, kwds, typed, + kwd_mark = (object(),), + fasttypes = {int, str, frozenset, type(None)}, + tuple=tuple, type=type, len=len): + ''' + Make a cache key from optionally typed positional and keyword arguments. + The key is constructed in a way that is flat as possible rather than as + a nested structure that would take more memory. If there is only a single + argument and its data type is known to cache its hash value, then that + argument is returned without a wrapper. This saves space and improves + lookup speed. + + .. note:: From cpython 3.7 + ''' + key = args + if kwds: + key += kwd_mark + for item in kwds.items(): + key += item + if typed: + key += tuple(type(v) for v in args) + if kwds: + key += tuple(type(v) for v in kwds.values()) + elif len(key) == 1 and type(key[0]) in fasttypes: + return key[0] + return _HashedSeq(key) + + +def cacheresult(function, typed=False): + ''' + :param typed: If typed is True, arguments of different types will be + cached separately. I.e. f(3.0) and f(3) will be treated as distinct + calls with distinct results. + + .. note:: From cpython 3.7 + ''' + sentinel = object() # unique object used to signal cache misses + make_key = _make_key # build a key from the function arguments + cache = {} + def wrapper(*args, **kwds): + # Simple caching without ordering or size limit + key = _make_key(args, kwds, typed) + result = cache.get(key, sentinel) + if result is not sentinel: + return result + result = function(*args, **kwds) + cache[key] = result + return result + return wrapper + +class OrderedSet(MutableSet): + ''' + Maintain ordering of insertion in items to the set with quick iteration. + + http://code.activestate.com/recipes/576694/ + ''' + + def __init__(self, iterable=None): + self.end = end = [] + end += [None, end, end] # sentinel node for doubly linked list + self.map = {} # key --> [key, prev, next] + if iterable is not None: + self |= iterable + + def __len__(self): + return len(self.map) + + def __contains__(self, key): + return key in self.map + + def add(self, key): + if key not in self.map: + end = self.end + curr = end[1] + curr[2] = end[1] = self.map[key] = [key, curr, end] + + def update(self, keys): + for key in keys: + self.add(key) + + def discard(self, key): + if key in self.map: + key, prev, next = self.map.pop(key) + prev[2] = next + next[1] = prev + + def __iter__(self): + end = self.end + curr = end[2] + while curr is not end: + yield curr[0] + curr = curr[2] + + def __reversed__(self): + end = self.end + curr = end[1] + while curr is not end: + yield curr[0] + curr = curr[1] + + def pop(self, last=True): + if not self: + raise KeyError('set is empty') + key = self.end[1][0] if last else self.end[2][0] + self.discard(key) + return key + + def __repr__(self): + if not self: + return '%s()' % (self.__class__.__name__,) + return '%s(%r)' % (self.__class__.__name__, list(self)) + + def __eq__(self, other): + if isinstance(other, OrderedSet): + return len(self) == len(other) and list(self) == list(other) + return set(self) == set(other) + +def absdirpath(path): + ''' + Return the directory component of the absolute path of the given path. + ''' + return os.path.dirname(os.path.abspath(path)) + +joinpath = os.path.join + +def mkdir_p(path): + ''' + Same thing as mkdir -p + + https://stackoverflow.com/a/600612 + ''' + try: + os.makedirs(path) + except OSError as exc: # Python >2.5 + if exc.errno == errno.EEXIST and os.path.isdir(path): + pass + else: + raise + + +class FrozenSetException(Exception): + '''Signals one tried to set a value in a 'frozen' object.''' + pass + + +class AttrDict(object): + '''Object which exposes its own internal dictionary through attributes.''' + def __init__(self, dict_={}): + self.update(dict_) + + def __getattr__(self, attr): + dict_ = self.__dict__ + if attr in dict_: + return dict_[attr] + raise AttributeError('Could not find %s attribute' % attr) + + def __setattr__(self, attr, val): + self.__dict__[attr] = val + + def __iter__(self): + return iter(self.__dict__) + + def __getitem__(self, item): + return self.__dict__[item] + + def update(self, items): + self.__dict__.update(items) + + +class FrozenAttrDict(AttrDict): + '''An AttrDict whose attributes cannot be modified directly.''' + __initialized = False + def __init__(self, dict_={}): + super(FrozenAttrDict, self).__init__(dict_) + self.__initialized = True + + def __setattr__(self, attr, val): + if self.__initialized: + raise FrozenSetException( + 'Cannot modify an attribute in a FozenAttrDict') + else: + super(FrozenAttrDict, self).__setattr__(attr, val) + + def update(self, items): + if self.__initialized: + raise FrozenSetException( + 'Cannot modify an attribute in a FozenAttrDict') + else: + super(FrozenAttrDict, self).update(items) + + +class InstanceCollector(object): + ''' + A class used to simplify collecting of Classes. + + >> instance_list = collector.create() + >> # Create a bunch of classes which call collector.collect(self) + >> # instance_list contains all instances created since + >> # collector.create was called + >> collector.remove(instance_list) + ''' + def __init__(self): + self.collectors = [] + + def create(self): + collection = [] + self.collectors.append(collection) + return collection + + def remove(self, collector): + self.collectors.remove(collector) + + def collect(self, instance): + for col in self.collectors: + col.append(instance) + + +def append_dictlist(dict_, key, value): + ''' + Append the `value` to a list associated with `key` in `dict_`. + If `key` doesn't exist, create a new list in the `dict_` with value in it. + ''' + list_ = dict_.get(key, []) + list_.append(value) + dict_[key] = list_ + + +class ExceptionThread(threading.Thread): + ''' + Wrapper around a python :class:`Thread` which will raise an + exception on join if the child threw an unhandled exception. + ''' + def __init__(self, *args, **kwargs): + threading.Thread.__init__(self, *args, **kwargs) + self._eq = Queue.Queue() + + def run(self, *args, **kwargs): + try: + threading.Thread.run(self, *args, **kwargs) + self._eq.put(None) + except: + tb = traceback.format_exc() + self._eq.put(tb) + + def join(self, *args, **kwargs): + threading.Thread.join(*args, **kwargs) + exception = self._eq.get() + if exception: + raise Exception(exception) + + +def _filter_file(fname, filters): + with open(fname, "r") as file_: + for line in file_: + for regex in filters: + if re.match(regex, line): + break + else: + yield line + + +def _copy_file_keep_perms(source, target): + '''Copy a file keeping the original permisions of the target.''' + st = os.stat(target) + shutil.copy2(source, target) + os.chown(target, st[stat.ST_UID], st[stat.ST_GID]) + + +def _filter_file_inplace(fname, filters): + ''' + Filter the given file writing filtered lines out to a temporary file, then + copy that tempfile back into the original file. + ''' + reenter = False + (_, tfname) = tempfile.mkstemp(text=True) + with open(tfname, 'w') as tempfile_: + for line in _filter_file(fname, filters): + tempfile_.write(line) + + # Now filtered output is into tempfile_ + _copy_file_keep_perms(tfname, fname) + + +def diff_out_file(ref_file, out_file, logger, ignore_regexes=tuple()): + '''Diff two files returning the diff as a string.''' + + if not os.path.exists(ref_file): + raise OSError("%s doesn't exist in reference directory"\ + % ref_file) + if not os.path.exists(out_file): + raise OSError("%s doesn't exist in output directory" % out_file) + + _filter_file_inplace(out_file, ignore_regexes) + _filter_file_inplace(ref_file, ignore_regexes) + + #try : + (_, tfname) = tempfile.mkstemp(text=True) + with open(tfname, 'r+') as tempfile_: + try: + log_call(logger, ['diff', out_file, ref_file], stdout=tempfile_) + except OSError: + # Likely signals that diff does not exist on this system. fallback + # to difflib + with open(out_file, 'r') as outf, open(ref_file, 'r') as reff: + diff = difflib.unified_diff(iter(reff.readline, ''), + iter(outf.readline, ''), + fromfile=ref_file, + tofile=out_file) + return ''.join(diff) + except subprocess.CalledProcessError: + tempfile_.seek(0) + return ''.join(tempfile_.readlines()) + else: + return None + +class Timer(): + def __init__(self): + self.restart() + + def restart(self): + self._start = self.timestamp() + self._stop = None + + def stop(self): + self._stop = self.timestamp() + return self._stop - self._start + + def runtime(self): + return self._stop - self._start + + def active_time(self): + return self.timestamp() - self._start + + @staticmethod + def timestamp(): + return time.time()
\ No newline at end of file diff --git a/ext/testlib/loader.py b/ext/testlib/loader.py new file mode 100644 index 000000000..e788c33a9 --- /dev/null +++ b/ext/testlib/loader.py @@ -0,0 +1,302 @@ +# Copyright (c) 2017 Mark D. Hill and David A. Wood +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer; +# redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution; +# neither the name of the copyright holders nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Authors: Sean Wilson + +''' +Contains the :class:`Loader` which is responsible for discovering and loading +tests. + +Loading typically follows the following stages. + +1. Recurse down a given directory looking for tests which match a given regex. + + The default regex used will match any python file (ending in .py) that has + a name starting or ending in test(s). If there are any additional + components of the name they must be connected with '-' or '_'. Lastly, + file names that begin with '.' will be ignored. + + The following names would match: + + - `tests.py` + - `test.py` + - `test-this.py` + - `tests-that.py` + - `these-test.py` + + These would not match: + + - `.test.py` - 'hidden' files are ignored. + - `test` - Must end in '.py' + - `test-.py` - Needs a character after the hypen. + - `testthis.py` - Needs a hypen or underscore to separate 'test' and 'this' + + +2. With all files discovered execute each file gathering its test items we + care about collecting. (`TestCase`, `TestSuite` and `Fixture` objects.) + +As a final note, :class:`TestCase` instances which are not put into +a :class:`TestSuite` by the test writer will be placed into +a :class:`TestSuite` named after the module. + +.. seealso:: :func:`load_file` +''' + +import os +import re +import sys +import traceback + +import config +import log +import suite as suite_mod +import test as test_mod +import fixture as fixture_mod +import wrappers +import uid + +class DuplicateTestItemException(Exception): + ''' + Exception indicates multiple test items with the same UID + were discovered. + ''' + pass + + +# Match filenames that either begin or end with 'test' or tests and use +# - or _ to separate additional name components. +default_filepath_regex = re.compile( + r'(((.+[_])?tests?)|(tests?([-_].+)?))\.py$') + +def default_filepath_filter(filepath): + '''The default filter applied to filepaths to marks as test sources.''' + filepath = os.path.basename(filepath) + if default_filepath_regex.match(filepath): + # Make sure doesn't start with . + return not filepath.startswith('.') + return False + +def path_as_modulename(filepath): + '''Return the given filepath as a module name.''' + # Remove the file extention (.py) + return os.path.splitext(os.path.basename(filepath))[0] + +def path_as_suitename(filepath): + return os.path.split(os.path.dirname(os.path.abspath((filepath))))[-1] + +def _assert_files_in_same_dir(files): + if __debug__: + if files: + directory = os.path.dirname(files[0]) + for f in files: + assert os.path.dirname(f) == directory + +class Loader(object): + ''' + Class for discovering tests. + + Discovered :class:`TestCase` and :class:`TestSuite` objects are wrapped by + :class:`LoadedTest` and :class:`LoadedSuite` objects respectively. + These objects provided additional methods and metadata about the loaded + objects and are the internal representation used by testlib. + + To simply discover and load all tests using the default filter create an + instance and `load_root`. + + >>> import os + >>> tl = Loader() + >>> tl.load_root(os.getcwd()) + + .. note:: If tests are not contained in a TestSuite, they will + automatically be placed into one for the module. + + .. warn:: This class is extremely thread-unsafe. + It modifies the sys path and global config. + Use with care. + ''' + def __init__(self): + self.suites = [] + self.suite_uids = {} + self.filepath_filter = default_filepath_filter + + # filepath -> Successful | Failed to load + self._files = {} + + @property + def schedule(self): + return wrappers.LoadedLibrary(self.suites, fixture_mod.global_fixtures) + + def load_schedule_for_suites(self, *uids): + files = {uid.UID.uid_to_path(id_) for id_ in uids} + for file_ in files: + self.load_file(file_) + + return wrappers.LoadedLibrary( + [self.suite_uids[id_] for id_ in uids], + fixture_mod.global_fixtures) + + def _verify_no_duplicate_suites(self, new_suites): + new_suite_uids = self.suite_uids.copy() + for suite in new_suites: + if suite.uid in new_suite_uids: + raise DuplicateTestItemException( + "More than one suite with UID '%s' was defined" %\ + suite.uid) + new_suite_uids[suite.uid] = suite + + def _verify_no_duplicate_tests_in_suites(self, new_suites): + for suite in new_suites: + test_uids = set() + for test in suite: + if test.uid in test_uids: + raise DuplicateTestItemException( + "More than one test with UID '%s' was defined" + " in suite '%s'" + % (test.uid, suite.uid)) + test_uids.add(test.uid) + + def load_root(self, root): + ''' + Load files from the given root directory which match + `self.filepath_filter`. + ''' + if __debug__: + self._loaded_a_file = True + + for directory in self._discover_files(root): + if directory: + _assert_files_in_same_dir(directory) + for f in directory: + self.load_file(f) + + def load_dir(self, directory): + for dir_ in self._discover_files(directory): + _assert_files_in_same_dir(dir_) + for f in dir_: + self.load_file(f) + + def load_file(self, path): + path = os.path.abspath(path) + + if path in self._files: + if not self._files[path]: + raise Exception('Attempted to load a file which already' + ' failed to load') + else: + log.test_log.debug('Tried to reload: %s' % path) + return + + # Create a custom dictionary for the loaded module. + newdict = { + '__builtins__':__builtins__, + '__name__': path_as_modulename(path), + '__file__': path, + } + + # Add the file's containing directory to the system path. So it can do + # relative imports naturally. + old_path = sys.path[:] + sys.path.insert(0, os.path.dirname(path)) + cwd = os.getcwd() + os.chdir(os.path.dirname(path)) + config.config.file_under_load = path + + new_tests = test_mod.TestCase.collector.create() + new_suites = suite_mod.TestSuite.collector.create() + new_fixtures = fixture_mod.Fixture.collector.create() + + def cleanup(): + config.config.file_under_load = None + sys.path[:] = old_path + os.chdir(cwd) + test_mod.TestCase.collector.remove(new_tests) + suite_mod.TestSuite.collector.remove(new_suites) + fixture_mod.Fixture.collector.remove(new_fixtures) + + try: + execfile(path, newdict, newdict) + except Exception as e: + log.test_log.debug(traceback.format_exc()) + log.test_log.warn( + 'Exception thrown while loading "%s"\n' + 'Ignoring all tests in this file.' + % (path)) + cleanup() + return + + # Create a module test suite for those not contained in a suite. + orphan_tests = set(new_tests) + for suite in new_suites: + for test in suite: + # Remove the test if it wasn't already removed. + # (Suites may contain copies of tests.) + if test in orphan_tests: + orphan_tests.remove(test) + if orphan_tests: + orphan_tests = sorted(orphan_tests, key=new_tests.index) + # FIXME Use the config based default to group all uncollected + # tests. + # NOTE: This is automatically collected (we still have the + # collector active.) + suite_mod.TestSuite(tests=orphan_tests, + name=path_as_suitename(path)) + + try: + loaded_suites = [wrappers.LoadedSuite(suite, path) + for suite in new_suites] + + self._verify_no_duplicate_suites(loaded_suites) + self._verify_no_duplicate_tests_in_suites(loaded_suites) + except Exception as e: + log.test_log.warn('%s\n' + 'Exception thrown while loading "%s"\n' + 'Ignoring all tests in this file.' + % (traceback.format_exc(), path)) + else: + log.test_log.info('Discovered %d tests and %d suites in %s' + '' % (len(new_tests), len(loaded_suites), path)) + + self.suites.extend(loaded_suites) + self.suite_uids.update({suite.uid: suite + for suite in loaded_suites}) + cleanup() + + def _discover_files(self, root): + ''' + Recurse down from the given root directory returning a list of + directories which contain a list of files matching + `self.filepath_filter`. + ''' + # Will probably want to order this traversal. + for root, dirnames, filenames in os.walk(root): + dirnames.sort() + if filenames: + filenames.sort() + filepaths = [os.path.join(root, filename) \ + for filename in filenames] + filepaths = filter(self.filepath_filter, filepaths) + if filepaths: + yield filepaths diff --git a/ext/testlib/log.py b/ext/testlib/log.py new file mode 100644 index 000000000..5ba6f5d4f --- /dev/null +++ b/ext/testlib/log.py @@ -0,0 +1,256 @@ +# Copyright (c) 2017 Mark D. Hill and David A. Wood +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer; +# redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution; +# neither the name of the copyright holders nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Authors: Sean Wilson + +''' +This module supplies the global `test_log` object which all testing +results and messages are reported through. +''' +import wrappers + + +class LogLevel(): + Fatal = 0 + Error = 1 + Warn = 2 + Info = 3 + Debug = 4 + Trace = 5 + + +class RecordTypeCounterMetaclass(type): + ''' + Record type metaclass. + + Adds a static integer value in addition to typeinfo so identifiers + are common across processes, networks and module reloads. + ''' + counter = 0 + def __init__(cls, name, bases, dct): + cls.type_id = RecordTypeCounterMetaclass.counter + RecordTypeCounterMetaclass.counter += 1 + + +class Record(object): + ''' + A generic object that is passed to the :class:`Log` and its handlers. + + ..note: Although not statically enforced, all items in the record should be + be pickleable. This enables logging accross multiple processes. + ''' + __metaclass__ = RecordTypeCounterMetaclass + + def __init__(self, **data): + self.data = data + + def __getitem__(self, item): + if item not in self.data: + raise KeyError('%s not in record %s' %\ + (item, self.__class__.__name__)) + return self.data[item] + + def __str__(self): + return str(self.data) + + +class StatusRecord(Record): + def __init__(self, obj, status): + Record.__init__(self, metadata=obj.metadata, status=status) +class ResultRecord(Record): + def __init__(self, obj, result): + Record.__init__(self, metadata=obj.metadata, result=result) +#TODO Refactor this shit... Not ideal. Should just specify attributes. +class TestStatus(StatusRecord): + pass +class SuiteStatus(StatusRecord): + pass +class LibraryStatus(StatusRecord): + pass +class TestResult(ResultRecord): + pass +class SuiteResult(ResultRecord): + pass +class LibraryResult(ResultRecord): + pass +# Test Output Types +class TestStderr(Record): + pass +class TestStdout(Record): + pass +# Message (Raw String) Types +class TestMessage(Record): + pass +class LibraryMessage(Record): + pass + + +class Log(object): + def __init__(self): + self.handlers = [] + self._opened = False # TODO Guards to methods + self._closed = False # TODO Guards to methods + + def finish_init(self): + self._opened = True + + def close(self): + self._closed = True + for handler in self.handlers: + handler.close() + + def log(self, record): + if not self._opened: + self.finish_init() + if self._closed: + raise Exception('The log has been closed' + ' and is no longer available.') + + map(lambda handler:handler.prehandle(), self.handlers) + for handler in self.handlers: + handler.handle(record) + handler.posthandle() + + def add_handler(self, handler): + if self._opened: + raise Exception('Unable to add a handler once the log is open.') + self.handlers.append(handler) + + def close_handler(self, handler): + handler.close() + self.handlers.remove(handler) + + +class Handler(object): + ''' + Empty implementation of the interface available to handlers which + is expected by the :class:`Log`. + ''' + def __init__(self): + pass + + def handle(self, record): + pass + + def close(self): + pass + + def prehandle(self): + pass + + def posthandle(self): + pass + + +class LogWrapper(object): + _result_typemap = { + wrappers.LoadedLibrary.__name__: LibraryResult, + wrappers.LoadedSuite.__name__: SuiteResult, + wrappers.LoadedTest.__name__: TestResult, + } + _status_typemap = { + wrappers.LoadedLibrary.__name__: LibraryStatus, + wrappers.LoadedSuite.__name__: SuiteStatus, + wrappers.LoadedTest.__name__: TestStatus, + } + def __init__(self, log): + self.log_obj = log + + def log(self, *args, **kwargs): + self.log_obj.log(*args, **kwargs) + + # Library Logging Methods + # TODO Replace these methods in a test/create a wrapper? + # That way they still can log like this it's just hidden that they + # capture the current test. + def message(self, message, level=LogLevel.Info, bold=False, **metadata): + self.log_obj.log(LibraryMessage(message=message, level=level, + bold=bold, **metadata)) + + def error(self, message): + self.message(message, LogLevel.Error) + + def warn(self, message): + self.message(message, LogLevel.Warn) + + def info(self, message): + self.message(message, LogLevel.Info) + + def debug(self, message): + self.message(message, LogLevel.Debug) + + def trace(self, message): + self.message(message, LogLevel.Trace) + + # Ongoing Test Logging Methods + def status_update(self, obj, status): + self.log_obj.log( + self._status_typemap[obj.__class__.__name__](obj, status)) + + def result_update(self, obj, result): + self.log_obj.log( + self._result_typemap[obj.__class__.__name__](obj, result)) + + def test_message(self, test, message, level): + self.log_obj.log(TestMessage(message=message, level=level, + test_uid=test.uid, suite_uid=test.parent_suite.uid)) + + # NOTE If performance starts to drag on logging stdout/err + # replace metadata with just test and suite uid tags. + def test_stdout(self, test, suite, buf): + self.log_obj.log(TestStdout(buffer=buf, metadata=test.metadata)) + + def test_stderr(self, test, suite, buf): + self.log_obj.log(TestStderr(buffer=buf, metadata=test.metadata)) + + def close(self): + self.log_obj.close() + +class TestLogWrapper(object): + def __init__(self, log, test, suite): + self.log_obj = log + self.test = test + + def test_message(self, message, level): + self.log_obj.test_message(test=self.test, + message=message, level=level) + + def error(self, message): + self.test_message(message, LogLevel.Error) + + def warn(self, message): + self.test_message(message, LogLevel.Warn) + + def info(self, message): + self.test_message(message, LogLevel.Info) + + def debug(self, message): + self.test_message(message, LogLevel.Debug) + + def trace(self, message): + self.test_message(message, LogLevel.Trace) + +test_log = LogWrapper(Log()) diff --git a/ext/testlib/main.py b/ext/testlib/main.py new file mode 100644 index 000000000..7e5f20851 --- /dev/null +++ b/ext/testlib/main.py @@ -0,0 +1,328 @@ +# Copyright (c) 2017 Mark D. Hill and David A. Wood +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer; +# redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution; +# neither the name of the copyright holders nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Authors: Sean Wilson + +import os +import itertools + +import config +import fixture as fixture_mod +import handlers +import loader as loader_mod +import log +import query +import result +import runner +import terminal +import uid + +def entry_message(): + log.test_log.message("Running the new gem5 testing script.") + log.test_log.message("For more information see TESTING.md.") + log.test_log.message("To see details as the testing scripts are" + " running, use the option" + " -v, -vv, or -vvv") + +class RunLogHandler(): + def __init__(self): + term_handler = handlers.TerminalHandler( + verbosity=config.config.verbose+log.LogLevel.Info + ) + summary_handler = handlers.SummaryHandler() + self.mp_handler = handlers.MultiprocessingHandlerWrapper( + summary_handler, term_handler) + self.mp_handler.async_process() + log.test_log.log_obj.add_handler(self.mp_handler) + entry_message() + + def schedule_finalized(self, test_schedule): + # Create the result handler object. + self.result_handler = handlers.ResultHandler( + test_schedule, config.config.result_path) + self.mp_handler.add_handler(self.result_handler) + + def finish_testing(self): + self.result_handler.close() + + def __enter__(self): + return self + + def __exit__(self, *args): + self.close() + return False + + def close(self): + self.mp_handler.close() + +def get_config_tags(): + return getattr(config.config, + config.StorePositionalTagsAction.position_kword) + +def filter_with_config_tags(loaded_library): + tags = get_config_tags() + final_tags = [] + regex_fmt = '^%s$' + cfg = config.config + + def _append_inc_tag_filter(name): + if hasattr(cfg, name): + tag_opts = getattr(cfg, name) + for tag in tag_opts: + final_tags.append(config.TagRegex(True, regex_fmt % tag)) + + def _append_rem_tag_filter(name): + if hasattr(cfg, name): + tag_opts = getattr(cfg, name) + for tag in cfg.constants.supported_tags[name]: + if tag not in tag_opts: + final_tags.append(config.TagRegex(False, regex_fmt % tag)) + + # Append additional tags for the isa, length, and variant options. + # They apply last (they take priority) + special_tags = ( + cfg.constants.isa_tag_type, + cfg.constants.length_tag_type, + cfg.constants.variant_tag_type + ) + + for tagname in special_tags: + _append_inc_tag_filter(tagname) + for tagname in special_tags: + _append_rem_tag_filter(tagname) + + if tags is None: + tags = tuple() + + filters = list(itertools.chain(tags, final_tags)) + string = 'Filtering suites with tags as follows:\n' + filter_string = '\t\n'.join((str(f) for f in filters)) + log.test_log.trace(string + filter_string) + + return filter_with_tags(loaded_library, filters) + + +def filter_with_tags(loaded_library, filters): + ''' + Filter logic supports two filter types: + --include-tags <regex> + --exclude-tags <regex> + + The logic maintains a `set` of test suites. + + If the regex provided with the `--include-tags` flag matches a tag of a + suite, that suite will added to the set. + + If the regex provided with the `--exclude-tags` flag matches a tag of a + suite, that suite will removed to the set. + + Suites can be added and removed multiple times. + + First Flag Special Case Logic: + If include is the first flag, start with an empty set of suites. + If exclude is the first flag, start with the set of all collected suites. + + + Let's trace out the set as we go through the flags to clarify:: + + # Say our collection of suites looks like this: set(suite_ARM64, + # suite_X86, suite_Other). + # + # Additionally, we've passed the flags in the following order: + # --include-tags "ARM64" --exclude-tags ".*" --include-tags "X86" + + # Process --include-tags "ARM64" + set(suite_ARM64) # Suite begins empty, but adds the ARM64 suite + # Process --exclude-tags ".*" + set() # Removed all suites which have tags + # Process --include-tags "X86" + set(suite_X86) + ''' + if not filters: + return + + query_runner = query.QueryRunner(loaded_library) + tags = query_runner.tags() + + if not filters[0].include: + suites = set(query_runner.suites()) + else: + suites = set() + + def exclude(excludes): + return suites - excludes + def include(includes): + return suites | includes + + for tag_regex in filters: + matched_tags = (tag for tag in tags if tag_regex.regex.search(tag)) + for tag in matched_tags: + matched_suites = set(query_runner.suites_with_tag(tag)) + suites = include(matched_suites) if tag_regex.include \ + else exclude(matched_suites) + + # Set the library's suites to only those which where accepted by our filter + loaded_library.suites = [suite for suite in loaded_library.suites + if suite in suites] + +# TODO Add results command for listing previous results. + +def load_tests(): + ''' + Create a TestLoader and load tests for the directory given by the config. + ''' + testloader = loader_mod.Loader() + log.test_log.message(terminal.separator()) + log.test_log.message('Loading Tests', bold=True) + testloader.load_root(config.config.directory) + return testloader + +def do_list(): + term_handler = handlers.TerminalHandler( + verbosity=config.config.verbose+log.LogLevel.Info, + machine_only=config.config.quiet + ) + log.test_log.log_obj.add_handler(term_handler) + + entry_message() + + test_schedule = load_tests().schedule + filter_with_config_tags(test_schedule) + + qrunner = query.QueryRunner(test_schedule) + + if config.config.suites: + qrunner.list_suites() + elif config.config.tests: + qrunner.list_tests() + elif config.config.all_tags: + qrunner.list_tags() + else: + qrunner.list_suites() + qrunner.list_tests() + qrunner.list_tags() + +def run_schedule(test_schedule, log_handler): + ''' + Test Phases + ----------- + * Test Collection + * Fixture Parameterization + * Global Fixture Setup + * Iteratevely run suites: + * Suite Fixture Setup + * Iteratively run tests: + * Test Fixture Setup + * Run Test + * Test Fixture Teardown + * Suite Fixture Teardown + * Global Fixture Teardown + ''' + + log_handler.schedule_finalized(test_schedule) + + # Iterate through all fixtures notifying them of the test schedule. + for suite in test_schedule: + copied_fixtures = [] + for fixture in suite.fixtures: + copied_fixtures.append(fixture.schedule_finalized(test_schedule)) + suite.fixtures = copied_fixtures + + for test in suite: + copied_fixtures = [] + for fixture in test.fixtures: + copied_fixtures.append(fixture.schedule_finalized( + test_schedule)) + test.fixtures = copied_fixtures + + log.test_log.message(terminal.separator()) + log.test_log.message('Running Tests from {} suites' + .format(len(test_schedule.suites)), bold=True) + log.test_log.message("Results will be stored in {}".format( + config.config.result_path)) + log.test_log.message(terminal.separator()) + + # Build global fixtures and exectute scheduled test suites. + if config.config.test_threads > 1: + library_runner = runner.LibraryParallelRunner(test_schedule) + library_runner.set_threads(config.config.test_threads) + else: + library_runner = runner.LibraryRunner(test_schedule) + library_runner.run() + + log_handler.finish_testing() + +def do_run(): + # Initialize early parts of the log. + with RunLogHandler() as log_handler: + if config.config.uid: + uid_ = uid.UID.from_uid(config.config.uid) + if isinstance(uid_, uid.TestUID): + log.test_log.error('Unable to run a standalone test.\n' + 'Gem5 expects test suites to be the smallest unit ' + ' of test.\n\n' + 'Pass a SuiteUID instead.') + return + test_schedule = loader_mod.Loader().load_schedule_for_suites(uid_) + if get_config_tags(): + log.test_log.warn( + "The '--uid' flag was supplied," + " '--include-tags' and '--exclude-tags' will be ignored." + ) + else: + test_schedule = load_tests().schedule + # Filter tests based on tags + filter_with_config_tags(test_schedule) + # Execute the tests + run_schedule(test_schedule, log_handler) + + +def do_rerun(): + # Init early parts of log + with RunLogHandler() as log_handler: + # Load previous results + results = result.InternalSavedResults.load( + os.path.join(config.config.result_path, + config.constants.pickle_filename)) + + rerun_suites = (suite.uid for suite in results if suite.unsucessful) + + # Use loader to load suites + loader = loader_mod.Loader() + test_schedule = loader.load_schedule_for_suites(*rerun_suites) + + # Execute the tests + run_schedule(test_schedule, log_handler) + +def main(): + ''' + Main entrypoint for the testlib test library. + ''' + config.initialize_config() + + # 'do' the given command. + globals()['do_'+config.config.command]() + log.test_log.close() diff --git a/ext/testlib/query.py b/ext/testlib/query.py new file mode 100644 index 000000000..c66445c44 --- /dev/null +++ b/ext/testlib/query.py @@ -0,0 +1,71 @@ +# Copyright (c) 2017 Mark D. Hill and David A. Wood +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer; +# redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution; +# neither the name of the copyright holders nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Authors: Sean Wilson + +import terminal +import log + +# TODO Refactor print logic out of this so the objects +# created are separate from print logic. +class QueryRunner(object): + def __init__(self, test_schedule): + self.schedule = test_schedule + + def tags(self): + tags = set() + for suite in self.schedule: + tags = tags | set(suite.tags) + return tags + + def suites(self): + return [suite for suite in self.schedule] + + def suites_with_tag(self, tag): + return filter(lambda suite: tag in suite.tags, self.suites()) + + def list_tests(self): + log.test_log.message(terminal.separator()) + log.test_log.message('Listing all Test Cases.', bold=True) + log.test_log.message(terminal.separator()) + for suite in self.schedule: + for test in suite: + log.test_log.message(test.uid, machine_readable=True) + + def list_suites(self): + log.test_log.message(terminal.separator()) + log.test_log.message('Listing all Test Suites.', bold=True) + log.test_log.message(terminal.separator()) + for suite in self.suites(): + log.test_log.message(suite.uid, machine_readable=True) + + def list_tags(self): + log.test_log.message(terminal.separator()) + log.test_log.message('Listing all Test Tags.', bold=True) + log.test_log.message(terminal.separator()) + + for tag in self.tags(): + log.test_log.message(tag, machine_readable=True)
\ No newline at end of file diff --git a/ext/testlib/result.py b/ext/testlib/result.py new file mode 100644 index 000000000..8e3f38d25 --- /dev/null +++ b/ext/testlib/result.py @@ -0,0 +1,303 @@ +# Copyright (c) 2017 Mark D. Hill and David A. Wood +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer; +# redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution; +# neither the name of the copyright holders nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Authors: Sean Wilson + +import os +import pickle +import xml.sax.saxutils + +from config import config +import helper +import state +import log + +def _create_uid_index(iterable): + index = {} + for item in iterable: + assert item.uid not in index + index[item.uid] = item + return index + + +class _CommonMetadataMixin: + @property + def name(self): + return self._metadata.name + @property + def uid(self): + return self._metadata.uid + @property + def result(self): + return self._metadata.result + @result.setter + def result(self, result): + self._metadata.result = result + + @property + def unsucessful(self): + return self._metadata.result.value != state.Result.Passed + + +class InternalTestResult(object, _CommonMetadataMixin): + def __init__(self, obj, suite, directory): + self._metadata = obj.metadata + self.suite = suite + + self.stderr = os.path.join( + InternalSavedResults.output_path(self.uid, suite.uid), + 'stderr' + ) + self.stdout = os.path.join( + InternalSavedResults.output_path(self.uid, suite.uid), + 'stdout' + ) + + +class InternalSuiteResult(object, _CommonMetadataMixin): + def __init__(self, obj, directory): + self._metadata = obj.metadata + self.directory = directory + self._wrap_tests(obj) + + def _wrap_tests(self, obj): + self._tests = [InternalTestResult(test, self, self.directory) + for test in obj] + self._tests_index = _create_uid_index(self._tests) + + def get_test(self, uid): + return self._tests_index[uid] + + def __iter__(self): + return iter(self._tests) + + def get_test_result(self, uid): + return self.get_test(uid) + + def aggregate_test_results(self): + results = {} + for test in self: + helper.append_dictlist(results, test.result.value, test) + return results + + +class InternalLibraryResults(object, _CommonMetadataMixin): + def __init__(self, obj, directory): + self.directory = directory + self._metadata = obj.metadata + self._wrap_suites(obj) + + def __iter__(self): + return iter(self._suites) + + def _wrap_suites(self, obj): + self._suites = [InternalSuiteResult(suite, self.directory) + for suite in obj] + self._suites_index = _create_uid_index(self._suites) + + def add_suite(self, suite): + if suite.uid in self._suites: + raise ValueError('Cannot have duplicate suite UIDs.') + self._suites[suite.uid] = suite + + def get_suite_result(self, suite_uid): + return self._suites_index[suite_uid] + + def get_test_result(self, test_uid, suite_uid): + return self.get_suite_result(suite_uid).get_test_result(test_uid) + + def aggregate_test_results(self): + results = {} + for suite in self._suites: + for test in suite: + helper.append_dictlist(results, test.result.value, test) + return results + +class InternalSavedResults: + @staticmethod + def output_path(test_uid, suite_uid, base=None): + ''' + Return the path which results for a specific test case should be + stored. + ''' + if base is None: + base = config.result_path + return os.path.join( + base, + str(suite_uid).replace(os.path.sep, '-'), + str(test_uid).replace(os.path.sep, '-')) + + @staticmethod + def save(results, path, protocol=pickle.HIGHEST_PROTOCOL): + if not os.path.exists(os.path.dirname(path)): + try: + os.makedirs(os.path.dirname(path)) + except OSError as exc: # Guard against race condition + if exc.errno != errno.EEXIST: + raise + + with open(path, 'w') as f: + pickle.dump(results, f, protocol) + + @staticmethod + def load(path): + with open(path, 'r') as f: + return pickle.load(f) + + +class XMLElement(object): + def write(self, file_): + self.begin(file_) + self.end(file_) + + def begin(self, file_): + file_.write('<') + file_.write(self.name) + for attr in self.attributes: + file_.write(' ') + attr.write(file_) + file_.write('>') + + self.body(file_) + + def body(self, file_): + for elem in self.elements: + file_.write('\n') + elem.write(file_) + file_.write('\n') + + def end(self, file_): + file_.write('</%s>' % self.name) + +class XMLAttribute(object): + def __init__(self, name, value): + self.name = name + self.value = value + + def write(self, file_): + file_.write('%s=%s' % (self.name, + xml.sax.saxutils.quoteattr(self.value))) + + +class JUnitTestSuites(XMLElement): + name = 'testsuites' + result_map = { + state.Result.Errored: 'errors', + state.Result.Failed: 'failures', + state.Result.Passed: 'tests' + } + + def __init__(self, internal_results): + results = internal_results.aggregate_test_results() + + self.attributes = [] + for result, tests in results.items(): + self.attributes.append(self.result_attribute(result, + str(len(tests)))) + + self.elements = [] + for suite in internal_results: + self.elements.append(JUnitTestSuite(suite)) + + def result_attribute(self, result, count): + return XMLAttribute(self.result_map[result], count) + +class JUnitTestSuite(JUnitTestSuites): + name = 'testsuite' + result_map = { + state.Result.Errored: 'errors', + state.Result.Failed: 'failures', + state.Result.Passed: 'tests', + state.Result.Skipped: 'skipped' + } + + def __init__(self, suite_result): + results = suite_result.aggregate_test_results() + + self.attributes = [ + XMLAttribute('name', suite_result.name) + ] + for result, tests in results.items(): + self.attributes.append(self.result_attribute(result, + str(len(tests)))) + + self.elements = [] + for test in suite_result: + self.elements.append(JUnitTestCase(test)) + + def result_attribute(self, result, count): + return XMLAttribute(self.result_map[result], count) + +class JUnitTestCase(XMLElement): + name = 'testcase' + def __init__(self, test_result): + self.attributes = [ + XMLAttribute('name', test_result.name), + # TODO JUnit expects class of test.. add as test metadata. + XMLAttribute('classname', str(test_result.uid)), + XMLAttribute('status', str(test_result.result)), + ] + + # TODO JUnit expects a message for the reason a test was + # skipped or errored, save this with the test metadata. + # http://llg.cubic.org/docs/junit/ + self.elements = [ + LargeFileElement('system-err', test_result.stderr), + LargeFileElement('system-out', test_result.stdout), + ] + +class LargeFileElement(XMLElement): + def __init__(self, name, filename): + self.name = name + self.filename = filename + self.attributes = [] + + def body(self, file_): + try: + with open(self.filename, 'r') as f: + for line in f: + file_.write(xml.sax.saxutils.escape(line)) + except IOError: + # TODO Better error logic, this is sometimes O.K. + # if there was no stdout/stderr captured for the test + # + # TODO If that was the case, the file should still be made and it + # should just be empty instead of not existing. + pass + + + +class JUnitSavedResults: + @staticmethod + def save(results, path): + ''' + Compile the internal results into JUnit format writting it to the + given file. + ''' + results = JUnitTestSuites(results) + with open(path, 'w') as f: + results.write(f) + diff --git a/ext/testlib/runner.py b/ext/testlib/runner.py new file mode 100644 index 000000000..9868cefb1 --- /dev/null +++ b/ext/testlib/runner.py @@ -0,0 +1,216 @@ +# Copyright (c) 2017 Mark D. Hill and David A. Wood +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer; +# redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution; +# neither the name of the copyright holders nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Authors: Sean Wilson + +import multiprocessing.dummy +import threading +import traceback + +import helper +import state +import log +import sandbox + +from state import Status, Result +from fixture import SkipException + +def compute_aggregate_result(iterable): + ''' + Status of the test suite by default is: + * Passed if all contained tests passed + * Errored if any contained tests errored + * Failed if no tests errored, but one or more failed. + * Skipped if all contained tests were skipped + ''' + failed = [] + skipped = [] + for testitem in iterable: + result = testitem.result + + if result.value == Result.Errored: + return Result(result.value, result.reason) + elif result.value == Result.Failed: + failed.append(result.reason) + elif result.value == result.Skipped: + skipped.append(result.reason) + if failed: + return Result(Result.Failed, failed) + elif skipped: + return Result(Result.Skipped, skipped) + else: + return Result(Result.Passed) + +class TestParameters(object): + def __init__(self, test, suite): + self.test = test + self.suite = suite + self.log = log.TestLogWrapper(log.test_log, test, suite) + + @helper.cacheresult + def _fixtures(self): + fixtures = {fixture.name:fixture for fixture in self.suite.fixtures} + for fixture in self.test.fixtures: + fixtures[fixture.name] = fixture + return fixtures + + @property + def fixtures(self): + return self._fixtures() + + +class RunnerPattern: + def __init__(self, loaded_testable): + self.testable = loaded_testable + self.builder = FixtureBuilder(self.testable.fixtures) + + def handle_error(self, trace): + self.testable.result = Result(Result.Errored, trace) + self.avoid_children(trace) + + def handle_skip(self, trace): + self.testable.result = Result(Result.Skipped, trace) + self.avoid_children(trace) + + def avoid_children(self, reason): + for testable in self.testable: + testable.result = Result(self.testable.result.value, reason) + testable.status = Status.Avoided + + def test(self): + pass + + def run(self): + avoided = False + try: + self.testable.status = Status.Building + self.builder.setup(self.testable) + except SkipException: + self.handle_skip(traceback.format_exc()) + avoided = True + except BrokenFixtureException: + self.handle_error(traceback.format_exc()) + avoided = True + else: + self.testable.status = Status.Running + self.test() + finally: + self.testable.status = Status.TearingDown + self.builder.teardown(self.testable) + + if avoided: + self.testable.status = Status.Avoided + else: + self.testable.status = Status.Complete + +class TestRunner(RunnerPattern): + def test(self): + self.sandbox_test() + + def sandbox_test(self): + try: + sandbox.Sandbox(TestParameters( + self.testable, + self.testable.parent_suite)) + except sandbox.SubprocessException: + self.testable.result = Result(Result.Failed, + traceback.format_exc()) + else: + self.testable.result = Result(Result.Passed) + + +class SuiteRunner(RunnerPattern): + def test(self): + for test in self.testable: + test.runner(test).run() + self.testable.result = compute_aggregate_result( + iter(self.testable)) + + +class LibraryRunner(SuiteRunner): + pass + + +class LibraryParallelRunner(RunnerPattern): + def set_threads(self, threads): + self.threads = threads + + def _entrypoint(self, suite): + suite.runner(suite).run() + + def test(self): + pool = multiprocessing.dummy.Pool(self.threads) + pool.map(lambda suite : suite.runner(suite).run(), self.testable) + self.testable.result = compute_aggregate_result( + iter(self.testable)) + + +class BrokenFixtureException(Exception): + def __init__(self, fixture, testitem, trace): + self.fixture = fixture + self.testitem = testitem + self.trace = trace + + self.msg = ('%s\n' + 'Exception raised building "%s" raised SkipException' + ' for "%s".' % + (trace, fixture.name, testitem.name) + ) + super(BrokenFixtureException, self).__init__(self.msg) + +class FixtureBuilder(object): + def __init__(self, fixtures): + self.fixtures = fixtures + self.built_fixtures = [] + + def setup(self, testitem): + for fixture in self.fixtures: + # Mark as built before, so if the build fails + # we still try to tear it down. + self.built_fixtures.append(fixture) + try: + fixture.setup(testitem) + except SkipException: + raise + except Exception as e: + exc = traceback.format_exc() + msg = 'Exception raised while setting up fixture for %s' %\ + testitem.uid + log.test_log.warn('%s\n%s' % (exc, msg)) + + raise BrokenFixtureException(fixture, testitem, + traceback.format_exc()) + + def teardown(self, testitem): + for fixture in self.built_fixtures: + try: + fixture.teardown(testitem) + except Exception: + # Log exception but keep cleaning up. + exc = traceback.format_exc() + msg = 'Exception raised while tearing down fixture for %s' %\ + testitem.uid + log.test_log.warn('%s\n%s' % (exc, msg)) diff --git a/ext/testlib/sandbox.py b/ext/testlib/sandbox.py new file mode 100644 index 000000000..7f8fe2d3b --- /dev/null +++ b/ext/testlib/sandbox.py @@ -0,0 +1,193 @@ +# Copyright (c) 2017 Mark D. Hill and David A. Wood +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer; +# redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution; +# neither the name of the copyright holders nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Authors: Sean Wilson + +import multiprocessing +import pdb +import os +import sys +import threading +import traceback + +import log + +pdb._Pdb = pdb.Pdb +class ForkedPdb(pdb._Pdb): + ''' + A Pdb subclass that may be used from a forked multiprocessing child + ''' + io_manager = None + def interaction(self, *args, **kwargs): + _stdin = sys.stdin + self.io_manager.restore_pipes() + try: + sys.stdin = open('/dev/stdin') + pdb._Pdb.interaction(self, *args, **kwargs) + finally: + sys.stdin = _stdin + self.io_manager.replace_pipes() + + +#TODO Refactor duplicate stdout, stderr logic +class IoManager(object): + def __init__(self, test, suite): + self.test = test + self.suite = suite + self.log = log.test_log + self._init_pipes() + + def _init_pipes(self): + self.stdout_rp, self.stdout_wp = os.pipe() + self.stderr_rp, self.stderr_wp = os.pipe() + + def close_parent_pipes(self): + os.close(self.stdout_wp) + os.close(self.stderr_wp) + + def setup(self): + self.replace_pipes() + self.fixup_pdb() + + def fixup_pdb(self): + ForkedPdb.io_manager = self + pdb.Pdb = ForkedPdb + + def replace_pipes(self): + self.old_stderr = os.dup(sys.stderr.fileno()) + self.old_stdout = os.dup(sys.stdout.fileno()) + + os.dup2(self.stderr_wp, sys.stderr.fileno()) + sys.stderr = os.fdopen(self.stderr_wp, 'w', 0) + os.dup2(self.stdout_wp, sys.stdout.fileno()) + sys.stdout = os.fdopen(self.stdout_wp, 'w', 0) + + def restore_pipes(self): + self.stderr_wp = os.dup(sys.stderr.fileno()) + self.stdout_wp = os.dup(sys.stdout.fileno()) + + os.dup2(self.old_stderr, sys.stderr.fileno()) + sys.stderr = os.fdopen(self.old_stderr, 'w', 0) + os.dup2(self.old_stdout, sys.stdout.fileno()) + sys.stdout = os.fdopen(self.old_stdout, 'w', 0) + + def start_loggers(self): + self.log_ouput() + + def log_ouput(self): + def _log_output(pipe, log_callback): + with os.fdopen(pipe, 'r') as pipe: + # Read iteractively, don't allow input to fill the pipe. + for line in iter(pipe.readline, ''): + log_callback(line) + + # Don't keep a backpointer to self in the thread. + log = self.log + test = self.test + suite = self.suite + + self.stdout_thread = threading.Thread( + target=_log_output, + args=(self.stdout_rp, + lambda buf: log.test_stdout(test, suite, buf)) + ) + self.stderr_thread = threading.Thread( + target=_log_output, + args=(self.stderr_rp, + lambda buf: log.test_stderr(test, suite, buf)) + ) + + # Daemon + Join to not lock up main thread if something breaks + # but provide consistent execution if nothing goes wrong. + self.stdout_thread.daemon = True + self.stderr_thread.daemon = True + self.stdout_thread.start() + self.stderr_thread.start() + + def join_loggers(self): + self.stdout_thread.join() + self.stderr_thread.join() + + +class SubprocessException(Exception): + def __init__(self, exception, trace): + super(SubprocessException, self).__init__(trace) + +class ExceptionProcess(multiprocessing.Process): + class Status(): + def __init__(self, exitcode, exception_tuple): + self.exitcode = exitcode + if exception_tuple is not None: + self.trace = exception_tuple[1] + self.exception = exception_tuple[0] + else: + self.exception = None + self.trace = None + + def __init__(self, *args, **kwargs): + multiprocessing.Process.__init__(self, *args, **kwargs) + self._pconn, self._cconn = multiprocessing.Pipe() + self._exception = None + + def run(self): + try: + super(ExceptionProcess, self).run() + self._cconn.send(None) + except Exception as e: + tb = traceback.format_exc() + self._cconn.send((e, tb)) + raise + + @property + def status(self): + if self._pconn.poll(): + self._exception = self._pconn.recv() + + return self.Status(self.exitcode, self._exception) + + +class Sandbox(object): + def __init__(self, test_parameters): + + self.params = test_parameters + self.io_manager = IoManager(self.params.test, self.params.suite) + + self.p = ExceptionProcess(target=self.entrypoint) + # Daemon + Join to not lock up main thread if something breaks + self.p.daemon = True + self.io_manager.start_loggers() + self.p.start() + self.io_manager.close_parent_pipes() + self.p.join() + self.io_manager.join_loggers() + + status = self.p.status + if status.exitcode: + raise SubprocessException(status.exception, status.trace) + + def entrypoint(self): + self.io_manager.setup() + self.params.test.test(self.params) diff --git a/ext/testlib/state.py b/ext/testlib/state.py new file mode 100644 index 000000000..d220bb101 --- /dev/null +++ b/ext/testlib/state.py @@ -0,0 +1,63 @@ +# Copyright (c) 2017 Mark D. Hill and David A. Wood +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer; +# redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution; +# neither the name of the copyright holders nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Authors: Sean Wilson + +class Result: + enums = ''' + NotRun + Skipped + Passed + Failed + Errored + '''.split() + for idx, enum in enumerate(enums): + locals()[enum] = idx + + @classmethod + def name(cls, enum): + return cls.enums[enum] + + def __init__(self, value, reason=None): + self.value = value + self.reason = reason + + def __str__(self): + return self.name(self.value) + +class Status: + enums = ''' + Unscheduled + Building + Running + TearingDown + Complete + Avoided + '''.split() + for idx, enum in enumerate(enums): + locals()[enum] = idx + + @classmethod + def name(cls, enum): + return cls.enums[enum] diff --git a/ext/testlib/suite.py b/ext/testlib/suite.py new file mode 100644 index 000000000..2ce817a6e --- /dev/null +++ b/ext/testlib/suite.py @@ -0,0 +1,69 @@ +# Copyright (c) 2017 Mark D. Hill and David A. Wood +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer; +# redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution; +# neither the name of the copyright holders nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Authors: Sean Wilson + + +import helper +import runner as runner_mod + +class TestSuite(object): + ''' + An object grouping a collection of tests. It provides tags which enable + filtering during list and run selection. All tests held in the suite must + have a unique name. + + ..note:: + The :func:`__new__` method enables collection of test cases, it must + be called in order for test cases to be collected. + + ..note:: + To reduce test definition boilerplate, the :func:`init` method is + forwarded all `*args` and `**kwargs`. This means derived classes can + define init without boilerplate super().__init__(*args, **kwargs). + ''' + runner = runner_mod.SuiteRunner + collector = helper.InstanceCollector() + fixtures = [] + tests = [] + tags = set() + + def __new__(klass, *args, **kwargs): + obj = super(TestSuite, klass).__new__(klass, *args, **kwargs) + TestSuite.collector.collect(obj) + return obj + + def __init__(self, name=None, fixtures=tuple(), tests=tuple(), + tags=tuple(), **kwargs): + self.fixtures = self.fixtures + list(fixtures) + self.tags = self.tags | set(tags) + self.tests = self.tests + list(tests) + if name is None: + name = self.__class__.__name__ + self.name = name + + def __iter__(self): + return iter(self.tests)
\ No newline at end of file diff --git a/ext/testlib/terminal.py b/ext/testlib/terminal.py new file mode 100644 index 000000000..3afbb42a2 --- /dev/null +++ b/ext/testlib/terminal.py @@ -0,0 +1,165 @@ +# Copyright (c) 2011 Advanced Micro Devices, Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer; +# redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution; +# neither the name of the copyright holders nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Author: Steve Reinhardt + +import sys +import fcntl +import termios +import struct + +# Intended usage example: +# +# if force_colors: +# from m5.util.terminal import termcap +# elif no_colors: +# from m5.util.terminal import no_termcap as termcap +# else: +# from m5.util.terminal import tty_termcap as termcap +# print termcap.Blue + "This could be blue!" + termcap.Normal + +# ANSI color names in index order +color_names = "Black Red Green Yellow Blue Magenta Cyan White".split() +default_separator = '=' + +# Character attribute capabilities. Note that not all terminals +# support all of these capabilities, or support them +# differently/meaningfully. For example: +# +# - In PuTTY (with the default settings), Dim has no effect, Standout +# is the same as Reverse, and Blink does not blink but switches to a +# gray background. +# +# Please feel free to add information about other terminals here. +# +capability_map = { + 'Bold': 'bold', + 'Dim': 'dim', + 'Blink': 'blink', + 'Underline': 'smul', + 'Reverse': 'rev', + 'Standout': 'smso', + 'Normal': 'sgr0' +} + +capability_names = capability_map.keys() + +def null_cap_string(s, *args): + return '' + +try: + import curses + curses.setupterm() + def cap_string(s, *args): + cap = curses.tigetstr(s) + if cap: + return curses.tparm(cap, *args) + else: + return '' +except: + cap_string = null_cap_string + +class ColorStrings(object): + def __init__(self, cap_string): + for i, c in enumerate(color_names): + setattr(self, c, cap_string('setaf', i)) + for name, cap in capability_map.iteritems(): + setattr(self, name, cap_string(cap)) + +termcap = ColorStrings(cap_string) +no_termcap = ColorStrings(null_cap_string) + +if sys.stdout.isatty(): + tty_termcap = termcap +else: + tty_termcap = no_termcap + +def get_termcap(use_colors = None): + if use_colors: + return termcap + elif use_colors is None: + # option unspecified; default behavior is to use colors iff isatty + return tty_termcap + else: + return no_termcap + +def terminal_size(): + '''Return the (width, heigth) of the terminal screen.''' + h, w, hp, wp = struct.unpack('HHHH', + fcntl.ioctl(0, termios.TIOCGWINSZ, + struct.pack('HHHH', 0, 0, 0, 0))) + return w, h + +def separator(char=default_separator, color=None): + ''' + Return a separator of the given character that is the length of the full + width of the terminal screen. + ''' + (w, h) = terminal_size() + if color: + return color + char*w + termcap.Normal + else: + return char*w + +def insert_separator(inside, char=default_separator, + min_barrier=3, color=None): + ''' + Place the given string inside of the separator. If it does not fit inside, + expand the separator to fit it with at least min_barrier. + + .. seealso:: :func:`separator` + ''' + # Use a bytearray so it's efficient to manipulate + string = bytearray(separator(char, color=color)) + + # Check if we can fit inside with at least min_barrier. + gap = (len(string) - len(inside)) - min_barrier * 2 + if gap > 0: + # We'll need to expand the string to fit us. + string.extend([ char for _ in range(-gap)]) + # Emplace inside + middle = ((len(string)-1)/2) + start_idx = middle - len(inside)/2 + string[start_idx:len(inside)+start_idx] = inside + return str(string) + + +if __name__ == '__main__': + def test_termcap(obj): + for c_name in color_names: + c_str = getattr(obj, c_name) + print c_str + c_name + obj.Normal + for attr_name in capability_names: + if attr_name == 'Normal': + continue + attr_str = getattr(obj, attr_name) + print attr_str + c_str + attr_name + " " + c_name + obj.Normal + print obj.Bold + obj.Underline + \ + c_name + "Bold Underline " + c_str + obj.Normal + + print "=== termcap enabled ===" + test_termcap(termcap) + print termcap.Normal + print "=== termcap disabled ===" + test_termcap(no_termcap) diff --git a/ext/testlib/test.py b/ext/testlib/test.py new file mode 100644 index 000000000..8e2de4946 --- /dev/null +++ b/ext/testlib/test.py @@ -0,0 +1,91 @@ +# Copyright (c) 2017 Mark D. Hill and David A. Wood +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer; +# redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution; +# neither the name of the copyright holders nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Authors: Sean Wilson + +import functools + +import helper +import runner as runner_mod + +class TestCase(object): + ''' + Base class for all tests. + + ..note:: + The :func:`__new__` method enables collection of test cases, it must + be called in order for test cases to be collected. + ''' + fixtures = [] + + # TODO, remove explicit dependency. Use the loader to set the + # default runner + runner = runner_mod.TestRunner + collector = helper.InstanceCollector() + + def __new__(cls, *args, **kwargs): + obj = super(TestCase, cls).__new__(cls, *args, **kwargs) + TestCase.collector.collect(obj) + return obj + + def __init__(self, name=None, fixtures=tuple(), **kwargs): + self.fixtures = self.fixtures + list(fixtures) + if name is None: + name = self.__class__.__name__ + self.name = name + +class TestFunction(TestCase): + ''' + TestCase implementation which uses a callable object as a test. + ''' + def __init__(self, function, name=None, **kwargs): + self.test_function = function + if name is None: + name = function.__name__ + TestCase.__init__(self, name=name, **kwargs) + + def test(self, *args, **kwargs): + self.test_function(*args, **kwargs) + +# TODO Change the decorator to make this easier to create copy tests. +# Good way to do so might be return by reference. +def testfunction(function=None, name=None, fixtures=tuple()): + ''' + A decorator used to wrap a function as a TestFunction. + ''' + def testfunctiondecorator(function): + '''Decorator used to mark a function as a test case.''' + kwargs = {} + if name is not None: + kwargs['name'] = name + if fixtures is not None: + kwargs['fixtures'] = fixtures + TestFunction(function, **kwargs) + return function + if function is not None: + return testfunctiondecorator(function) + else: + return testfunctiondecorator
\ No newline at end of file diff --git a/ext/testlib/uid.py b/ext/testlib/uid.py new file mode 100644 index 000000000..fe56252a0 --- /dev/null +++ b/ext/testlib/uid.py @@ -0,0 +1,110 @@ +# Copyright (c) 2017 Mark D. Hill and David A. Wood +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer; +# redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution; +# neither the name of the copyright holders nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Authors: Sean Wilson + +import os +import itertools + +import config + +class UID(object): + sep = ':' + type_idx, path_idx = range(2) + + def __init__(self, path, *args): + self.path = self._shorten_path(path) + self.attributes = args + + @staticmethod + def _shorten_path(path): + return os.path.relpath(path, + os.path.commonprefix((config.constants.testing_base, + path))) + + @staticmethod + def _full_path(short_path): + return os.path.join(config.constants.testing_base, short_path) + + @classmethod + def uid_to_path(cls, uid): + split_path = str(uid).split(cls.sep)[cls.path_idx] + return cls._full_path(split_path) + + @classmethod + def uid_to_class(cls, uid): + return globals()[uid.split(cls.sep)[cls.type_idx]] + + @classmethod + def from_suite(self, suite, filepath): + return SuiteUID(filepath, suite.name) + + @classmethod + def from_test(self, test, filepath): + return TestUID(filepath, test.name, test.parent_suite.name) + + @classmethod + def from_uid(cls, uid): + args = uid.split(cls.sep) + del args[cls.type_idx] + return cls.uid_to_class(uid)(*args) + + def __str__(self): + common_opts = { + self.path_idx: self.path, + self.type_idx: self.__class__.__name__ + } + return self.sep.join(itertools.chain( + [common_opts[0], common_opts[1]], + self.attributes)) + + def __hash__(self): + return hash(str(self)) + + def __eq__(self, other): + return type(self) == type(other) and str(self) == str(other) + + +class TestUID(UID): + def __init__(self, filename, test_name, suite_name): + UID.__init__(self, filename, suite_name, test_name) + + @property + def test(self): + return self.attributes[1] + + @property + def suite(self): + return self.attributes[0] + + +class SuiteUID(UID): + def __init__(self, filename, suite_name): + UID.__init__(self, filename, suite_name) + + @property + def suite(self): + return self.attributes[0] diff --git a/ext/testlib/wrappers.py b/ext/testlib/wrappers.py new file mode 100644 index 000000000..4e96f3629 --- /dev/null +++ b/ext/testlib/wrappers.py @@ -0,0 +1,236 @@ +# Copyright (c) 2017 Mark D. Hill and David A. Wood +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer; +# redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution; +# neither the name of the copyright holders nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Authors: Sean Wilson + +''' +Module contains wrappers for test items that have been +loaded by the testlib :class:`testlib.loader.Loader`. +''' +import itertools + +import log +import uid +from state import Status, Result + +class TestCaseMetadata(): + def __init__(self, name, uid, path, result, status, suite_uid): + self.name = name + self.uid = uid + self.path = path + self.status = status + self.result = result + self.suite_uid = suite_uid + + +class TestSuiteMetadata(): + def __init__(self, name, uid, tags, path, status, result): + self.name = name + self.uid = uid + self.tags = tags + self.path = path + self.status = status + self.result = result + + +class LibraryMetadata(): + def __init__(self, name, result, status): + self.name = name + self.result = result + self.status = status + + +class LoadedTestable(object): + ''' + Base class for loaded test items. + + :property:`result` and :property:`status` setters + notify testlib via the :func:`log_result` and :func:`log_status` + of the updated status. + ''' + def __init__(self, obj): + self.obj = obj + self.metadata = self._generate_metadata() + + @property + def status(self): + return self.metadata.status + + @status.setter + def status(self, status): + self.log_status(status) + self.metadata.status = status + + @property + def result(self): + return self.metadata.result + + @result.setter + def result(self, result): + self.log_result(result) + self.metadata.result = result + + @property + def uid(self): + return self.metadata.uid + + @property + def name(self): + return self.metadata.name + + @property + def fixtures(self): + return self.obj.fixtures + + @fixtures.setter + def fixtures(self, fixtures): + self.obj.fixtures = fixtures + + @property + def runner(self): + return self.obj.runner + + # TODO Change log to provide status_update, result_update for all types. + def log_status(self, status): + log.test_log.status_update(self, status) + + def log_result(self, result): + log.test_log.result_update(self, result) + + def __iter__(self): + return iter(()) + + +class LoadedTest(LoadedTestable): + def __init__(self, test_obj, loaded_suite, path): + self.parent_suite = loaded_suite + self._path = path + LoadedTestable.__init__(self, test_obj) + + def test(self, *args, **kwargs): + self.obj.test(*args, **kwargs) + + def _generate_metadata(self): + return TestCaseMetadata( **{ + 'name':self.obj.name, + 'path': self._path, + 'uid': uid.TestUID(self._path, + self.obj.name, + self.parent_suite.name), + 'status': Status.Unscheduled, + 'result': Result(Result.NotRun), + 'suite_uid': self.parent_suite.metadata.uid + }) + + +class LoadedSuite(LoadedTestable): + def __init__(self, suite_obj, path): + self._path = path + LoadedTestable.__init__(self, suite_obj) + self.tests = self._wrap_children(suite_obj) + + def _wrap_children(self, suite_obj): + return [LoadedTest(test, self, self.metadata.path) + for test in suite_obj] + + def _generate_metadata(self): + return TestSuiteMetadata( **{ + 'name': self.obj.name, + 'tags':self.obj.tags, + 'path': self._path, + 'uid': uid.SuiteUID(self._path, self.obj.name), + 'status': Status.Unscheduled, + 'result': Result(Result.NotRun) + }) + + def __iter__(self): + return iter(self.tests) + + @property + def tags(self): + return self.metadata.tags + + +class LoadedLibrary(LoadedTestable): + ''' + Wraps a collection of all loaded test suites and + provides utility functions for accessing fixtures. + ''' + def __init__(self, suites, global_fixtures): + LoadedTestable.__init__(self, suites) + self.global_fixtures = global_fixtures + + def _generate_metadata(self): + return LibraryMetadata( **{ + 'name': 'Test Library', + 'status': Status.Unscheduled, + 'result': Result(Result.NotRun) + }) + + def __iter__(self): + ''' + :returns: an iterator over contained :class:`TestSuite` objects. + ''' + return iter(self.obj) + + def all_fixture_tuples(self): + return itertools.chain( + self.global_fixtures, + *(suite.fixtures for suite in self.obj)) + + def all_fixtures(self): + ''' + :returns: an interator overall all global, suite, + and test fixtures + ''' + return itertools.chain(itertools.chain( + self.global_fixtures, + *(suite.fixtures for suite in self.obj)), + *(self.test_fixtures(suite) for suite in self.obj) + ) + + def test_fixtures(self, suite): + ''' + :returns: an interator over all fixtures of each + test contained in the given suite + ''' + return itertools.chain(*(test.fixtures for test in suite)) + + @property + def fixtures(self): + return self.global_fixtures + + @property + def uid(self): + return self.name + + @property + def suites(self): + return self.obj + + @suites.setter + def suites(self, suites): + self.obj = suites |