# -*- coding: utf-8 -*-
# Copyright (c) 2013-2014 Simon Jagoe
# All rights reserved.
#
# This software may be modified and distributed under the terms
# of the 3-clause BSD license. See the LICENSE.txt file for details.
from __future__ import absolute_import, unicode_literals
from fnmatch import fnmatch
import logging
import os
import sys
import traceback
from haas.exceptions import DotInModuleNameError
from haas.module_import_error import ModuleImportError
from haas.suite import find_test_cases
from haas.testing import unittest
from haas.utils import get_module_by_name
from .i_discoverer_plugin import IDiscovererPlugin
logger = logging.getLogger(__name__)
def _is_import_error_test(test):
return isinstance(test, ModuleImportError)
def _create_import_error_test(module_name):
message = 'Unable to import module {0!r}\n{1}'.format(
module_name, traceback.format_exc())
def test_error(self):
raise ImportError(message)
method_name = 'test_error'
cls = type(ModuleImportError.__name__,
(ModuleImportError, unittest.TestCase,),
{method_name: test_error})
return cls(method_name)
[docs]def get_relpath(top_level_directory, fullpath):
normalized = os.path.normpath(fullpath)
relpath = os.path.relpath(normalized, top_level_directory)
if os.path.isabs(relpath) or relpath.startswith('..'):
raise ValueError('Path not within project: {0}'.format(fullpath))
return relpath
[docs]def match_path(filename, filepath, pattern):
return fnmatch(filename, pattern)
[docs]def get_module_name(top_level_directory, filepath):
modulepath = os.path.splitext(os.path.normpath(filepath))[0]
relpath = get_relpath(top_level_directory, modulepath)
if '.' in relpath:
raise DotInModuleNameError(relpath)
return relpath.replace(os.path.sep, '.')
[docs]def assert_start_importable(top_level_directory, start_directory):
relpath = get_relpath(top_level_directory, start_directory)
path = top_level_directory
for component in relpath.split(os.path.sep):
if component == '.':
continue
path = os.path.join(path, component)
if path != top_level_directory and \
not os.path.isfile(os.path.join(path, '__init__.py')):
raise ImportError('Start directory is not importable')
[docs]def find_module_by_name(full_name):
module_name = full_name
module_attributes = []
while True:
try:
module = get_module_by_name(module_name)
except ImportError:
if '.' in module_name:
module_name, attribute = module_name.rsplit('.', 1)
module_attributes.append(attribute)
else:
raise
else:
break
return module, list(reversed(module_attributes))
[docs]def find_top_level_directory(start_directory):
"""Finds the top-level directory of a project given a start directory
inside the project.
Parameters
----------
start_directory : str
The directory in which test discovery will start.
"""
top_level = start_directory
while os.path.isfile(os.path.join(top_level, '__init__.py')):
top_level = os.path.dirname(top_level)
if top_level == os.path.dirname(top_level):
raise ValueError("Can't find top level directory")
return os.path.abspath(top_level)
[docs]def filter_test_suite(suite, filter_name):
"""Filter test cases in a test suite by a substring in the full dotted
test name.
Parameters
----------
suite : haas.suite.TestSuite
The test suite containing tests to be filtered.
filter_name : str
The substring of the full dotted name on which to filter. This
should not contain a leading or trailing dot.
"""
filtered_cases = []
for test in find_test_cases(suite):
type_ = type(test)
name = '{0}.{1}.{2}'.format(
type_.__module__, type_.__name__, test._testMethodName)
filter_internal = '.{0}.'.format(filter_name)
if _is_import_error_test(test) or \
filter_internal in name or \
name.endswith(filter_internal[:-1]):
filtered_cases.append(test)
return filtered_cases
[docs]class Discoverer(IDiscovererPlugin):
"""The ``Discoverer`` is responsible for finding tests that can be
loaded by a :class:`~haas.loader.Loader`.
"""
def __init__(self, loader, **kwargs):
super(Discoverer, self).__init__(**kwargs)
self._loader = loader
[docs] @classmethod
def from_args(cls, args, arg_prefix, loader):
"""Construct the discoverer from parsed command line arguments.
Parameters
----------
args : argparse.Namespace
The ``argparse.Namespace`` containing parsed arguments.
arg_prefix : str
The prefix used for arguments beloning solely to this plugin.
loader : haas.loader.Loader
The test loader used to construct TestCase and TestSuite instances.
"""
return cls(loader)
[docs] @classmethod
def add_parser_arguments(cls, parser, option_prefix, dest_prefix):
"""Add options for the plugin to the main argument parser.
Parameters
----------
parser : argparse.ArgumentParser
The parser to extend
option_prefix : str
The prefix that option strings added by this plugin should use.
dest_prefix : str
The prefix that ``dest`` strings for options added by this
plugin should use.
"""
[docs] def discover(self, start, top_level_directory=None, pattern='test*.py'):
"""Do test case discovery.
This is the top-level entry-point for test discovery.
If the ``start`` argument is a drectory, then ``haas`` will
discover all tests in the package contained in that directory.
If the ``start`` argument is not a directory, it is assumed to
be a package or module name and tests in the package or module
are loaded.
FIXME: This needs a better description.
Parameters
----------
start : str
The directory, package, module, class or test to load.
top_level_directory : str
The path to the top-level directoy of the project. This is
the parent directory of the project'stop-level Python
package.
pattern : str
The glob pattern to match the filenames of modules to search
for tests.
"""
logger.debug('Starting test discovery')
if os.path.isdir(start):
start_directory = start
return self.discover_by_directory(
start_directory, top_level_directory=top_level_directory,
pattern=pattern)
elif os.path.isfile(start):
start_filepath = start
return self.discover_by_file(
start_filepath, top_level_directory=top_level_directory)
else:
package_or_module = start
return self.discover_by_module(
package_or_module, top_level_directory=top_level_directory,
pattern=pattern)
[docs] def discover_by_module(self, module_name, top_level_directory=None,
pattern='test*.py'):
"""Find all tests in a package or module, or load a single test case if
a class or test inside a module was specified.
Parameters
----------
module_name : str
The dotted package name, module name or TestCase class and
test method.
top_level_directory : str
The path to the top-level directoy of the project. This is
the parent directory of the project'stop-level Python
package.
pattern : str
The glob pattern to match the filenames of modules to search
for tests.
"""
# If the top level directory is given, the module may only be
# importable with that in the path.
if top_level_directory is not None and \
top_level_directory not in sys.path:
sys.path.insert(0, top_level_directory)
logger.debug('Discovering tests by module: module_name=%r, '
'top_level_directory=%r, pattern=%r', module_name,
top_level_directory, pattern)
try:
module, case_attributes = find_module_by_name(module_name)
except ImportError:
return self.discover_filtered_tests(
module_name, top_level_directory=top_level_directory,
pattern=pattern)
dirname, basename = os.path.split(module.__file__)
basename = os.path.splitext(basename)[0]
if len(case_attributes) == 0 and basename == '__init__':
# Discover in a package
return self.discover_by_directory(
dirname, top_level_directory, pattern=pattern)
elif len(case_attributes) == 0:
# Discover all in a module
return self._loader.load_module(module)
return self.discover_single_case(module, case_attributes)
[docs] def discover_single_case(self, module, case_attributes):
"""Find and load a single TestCase or TestCase method from a module.
Parameters
----------
module : module
The imported Python module containing the TestCase to be
loaded.
case_attributes : list
A list (length 1 or 2) of str. The first component must be
the name of a TestCase subclass. The second component must
be the name of a method in the TestCase.
"""
# Find single case
case = module
loader = self._loader
for index, component in enumerate(case_attributes):
case = getattr(case, component, None)
if case is None:
return loader.create_suite()
elif loader.is_test_case(case):
rest = case_attributes[index + 1:]
if len(rest) > 1:
raise ValueError('Too many components in module path')
elif len(rest) == 1:
return loader.create_suite(
[loader.load_test(case, *rest)])
return loader.load_case(case)
# No cases matched, return empty suite
return loader.create_suite()
[docs] def discover_by_directory(self, start_directory, top_level_directory=None,
pattern='test*.py'):
"""Run test discovery in a directory.
Parameters
----------
start_directory : str
The package directory in which to start test discovery.
top_level_directory : str
The path to the top-level directoy of the project. This is
the parent directory of the project'stop-level Python
package.
pattern : str
The glob pattern to match the filenames of modules to search
for tests.
"""
start_directory = os.path.abspath(start_directory)
if top_level_directory is None:
top_level_directory = find_top_level_directory(
start_directory)
logger.debug('Discovering tests in directory: start_directory=%r, '
'top_level_directory=%r, pattern=%r', start_directory,
top_level_directory, pattern)
assert_start_importable(top_level_directory, start_directory)
if top_level_directory not in sys.path:
sys.path.insert(0, top_level_directory)
tests = self._discover_tests(
start_directory, top_level_directory, pattern)
return self._loader.create_suite(list(tests))
[docs] def discover_by_file(self, start_filepath, top_level_directory=None):
"""Run test discovery on a single file.
Parameters
----------
start_filepath : str
The module file in which to start test discovery.
top_level_directory : str
The path to the top-level directoy of the project. This is
the parent directory of the project'stop-level Python
package.
"""
start_filepath = os.path.abspath(start_filepath)
start_directory = os.path.dirname(start_filepath)
if top_level_directory is None:
top_level_directory = find_top_level_directory(
start_directory)
logger.debug('Discovering tests in file: start_filepath=%r, '
'top_level_directory=', start_filepath,
top_level_directory)
assert_start_importable(top_level_directory, start_directory)
if top_level_directory not in sys.path:
sys.path.insert(0, top_level_directory)
tests = self._load_from_file(
start_filepath, top_level_directory)
return self._loader.create_suite(list(tests))
def _load_from_file(self, filepath, top_level_directory):
module_name = get_module_name(top_level_directory, filepath)
logger.debug('Loading tests from %r', module_name)
try:
module = get_module_by_name(module_name)
except Exception:
test = _create_import_error_test(module_name)
else:
# No exceptions on module import
# Load tests
return self._loader.load_module(module)
# Create the test suite containing handled exception on import
return self._loader.create_suite((test,))
def _discover_tests(self, start_directory, top_level_directory, pattern):
for curdir, dirnames, filenames in os.walk(start_directory):
logger.debug('Discovering tests in %r', curdir)
dirnames[:] = [
dirname for dirname in dirnames
if os.path.exists(os.path.join(curdir, dirname, '__init__.py'))
]
for filename in filenames:
filepath = os.path.join(curdir, filename)
if not match_path(filename, filepath, pattern):
logger.debug('Skipping %r', filepath)
continue
try:
yield self._load_from_file(filepath, top_level_directory)
except DotInModuleNameError:
logger.info(
'Unexpected dot in module or package name: %r',
filepath)
continue
[docs] def discover_filtered_tests(self, filter_name, top_level_directory=None,
pattern='test*.py'):
"""Find all tests whose package, module, class or method names match
the ``filter_name`` string.
Parameters
----------
filter_name : str
A subsection of the full dotted test name. This can be
simply a test method name (e.g. ``test_some_method``), the
TestCase class name (e.g. ``TestMyClass``), a module name
(e.g. ``test_module``), a subpackage (e.g. ``tests``). It
may also be a dotted combination of the above
(e.g. ``TestMyClass.test_some_method``).
top_level_directory : str
The path to the top-level directoy of the project. This is
the parent directory of the project'stop-level Python
package.
pattern : str
The glob pattern to match the filenames of modules to search
for tests.
"""
if top_level_directory is None:
top_level_directory = find_top_level_directory(
os.getcwd())
logger.debug('Discovering filtered tests: filter_name=%r, '
'top_level_directory=%r, pattern=%r', top_level_directory,
top_level_directory, pattern)
suite = self.discover_by_directory(
top_level_directory, top_level_directory=top_level_directory,
pattern=pattern)
return self._loader.create_suite(
filter_test_suite(suite, filter_name=filter_name))