# Licensed under a 3-clause BSD style license - see LICENSE.rst
"""
Provide a test() function that can be called from package __init__.
"""
import os
# Make a class to wrap sys.stdout to handle requests for isatty. This is needed
# to get the right output from pytest when run from FOT MATLAB pyexec.
# Borrowed from https://github.com/pytest-dev/pytest/issues/5462
class StdOutWrapper:
def __init__(self, stdout):
self._stdout = stdout
def __getattr__(self, item):
return getattr(self._stdout, item)
def isatty(self):
return False
[docs]
class TestError(Exception):
pass
[docs]
def testr(*args, **kwargs):
r"""
Run py.test unit tests for the calling package. This just calls the ``test()``
function but includes defaults that are more appropriate for integrated package
testing using run_testr.
:param \*args: positional args to pass to pytest
:param raise_exception: test failures raise an exception (default=True)
:param package_from_dir: set package name from parent directory name (default=True)
:param verbose: run pytest in verbose (-v) mode (default=True)
:param show_output: run pytest in show output (-s) mode (default=True)
:param \*\*kwargs: additional keyword args to pass to pytest
:returns: number of test failures
"""
for kwarg in ('raise_exception', 'package_from_dir', 'verbose', 'show_output'):
kwargs.setdefault(kwarg, True)
# test() function looks up the calling stack to find the calling package name.
# It will be two levels up.
kwargs['stack_level'] = 2
return test(*args, **kwargs)
[docs]
def test(*args, **kwargs):
r"""
Run py.test unit tests for the calling package with specified
``args`` and ``kwargs``.
This temporarily changes to the directory above the installed package
directory and effectively runs ``py.test <packagename> <args> <kwargs>``.
If the kwarg ``raise_exception=True`` is provided then any test
failures will result in an exception being raised. This can be
used to make a shell-level failure.
:param \*args: positional args to pass to pytest
:param raise_exception: test failures raise an exception (default=False)
:param package_from_dir: set package name from parent directory name (default=False)
:param verbose: run pytest in verbose (-v) mode (default=False)
:param show_output: run pytest in show output (-s) mode (default=False)
:param \*\*kwargs: additional command line options to pass to pytest, where the
key is the option name (e.g. "--log-level") and the value is the option value.
This is passed to the pytest CLI as the string ``{key}={value}``.
:returns: number of test failures
"""
# Local imports so that imports only get done when really needed.
import os
import sys
import subprocess
import inspect
import pytest
import contextlib
# Copied from Ska.File to reduce import footprint and limit to only standard
# modules.
@contextlib.contextmanager
def chdir(dirname=None):
"""
Context manager to run block within `dirname` directory. The current
directory is restored even if the block raises an exception.
:param dirname: Directory name
"""
curdir = os.getcwd()
try:
if dirname is not None:
os.chdir(dirname)
yield
finally:
os.chdir(curdir)
@contextlib.contextmanager
def stdout_context():
"""
Context manager to temporarily replace sys.stdout with StdOutWrapper
"""
orig_stdout = sys.stdout
sys.stdout = StdOutWrapper(sys.stdout)
try:
yield
finally:
sys.stdout = orig_stdout
raise_exception = kwargs.pop('raise_exception', False)
package_from_dir = kwargs.pop('package_from_dir', False)
get_version = kwargs.pop('get_version', False)
with_coverage = (os.environ.get('TESTR_COVERAGE', '').lower().strip() in ['true'])
coverage_config = os.environ.get('TESTR_COVERAGE_CONFIG', 'no-coverage-config')
if with_coverage and not os.path.exists(coverage_config):
if raise_exception:
raise TestError(f'Coverage config is not found {coverage_config}')
return
if 'TESTR_PYTEST_ARGS' in os.environ:
args = args + tuple(os.environ['TESTR_PYTEST_ARGS'].split())
arg_names = [a.split('=')[0] for a in args]
if kwargs.pop('verbose', False) and '-v' not in args and '-q' not in arg_names:
args = args + ('-v',)
if kwargs.pop('show_output', False) and '-s' not in args and '--capture' not in arg_names:
args = args + ('-s',)
if 'TESTR_OUT_DIR' in os.environ and 'TESTR_FILE' in os.environ:
report_file = os.path.join(os.environ['TESTR_OUT_DIR'], f"{os.environ['TESTR_FILE']}.xml")
args += (f'--junit-xml={report_file}',)
args += ('-o', 'junit_family=xunit2')
# Disable caching of test results to prevent users trying to write into
# flight directory if tests fail running on installed package.
args = args + ('-p', 'no:cacheprovider')
pytest_ini = os.environ.get('TESTR_PYTEST_INI', None)
if pytest_ini is not None:
if os.path.exists(pytest_ini):
args += ('-c', os.path.abspath(pytest_ini))
else:
raise Exception(
f'Pytest config file does not exist (TESTR_PYTEST_INI={pytest_ini})'
)
if 'TESTR_ALLOW_HYPOTHESIS' not in os.environ:
# Disable autoload of hypothesis plugin which causes warnings due to
# trying to write to .hypothesis dir from the cwd of test runner. See
# slack #aspect-team "falling back to an in-memory database for this
# session" for more info.
args += ('-p', 'no:hypothesispytest') # current name for disabling
args += ('-p', 'no:hypothesis') # possible future name
stack_level = kwargs.pop('stack_level', 1)
calling_frame_record = inspect.stack()[stack_level] # Only works for stack-based Python
calling_func_file = calling_frame_record[1]
if package_from_dir:
# In this case it is assumed that the module which called this function is
# located in a directory named by the package that is to be tested. I.e.
# chandra_aca/test.py. However, this is NOT the actual package directory
# so we have to import the package to get its parent directory.
import importlib
package = os.path.basename(os.path.dirname(os.path.abspath(calling_func_file)))
module = importlib.import_module(package)
calling_func_file = module.__file__
calling_func_module = module.__name__
else:
# In this case the module that called this function is the package __init__.py.
# We get the module directly without doing another import.
calling_frame = calling_frame_record[0]
calling_frame_filename = calling_frame_record[1]
calling_func_name = calling_frame_record[3]
calling_func_module = calling_frame.f_globals[calling_func_name].__module__
if get_version:
return get_full_version(calling_frame.f_globals,
calling_frame_filename)
pkg_names = calling_func_module.split('.')
pkg_paths = [os.path.dirname(calling_func_file)] + ['..'] * len(pkg_names)
pkg_dir = os.path.join(*pkg_names)
# Set the rootdir to the top of the package directory. This gets used in two ways:
# 1. To set the working directory for the test run.
# 2. To set the default for the --rootdir option in the pytest call.
#
# For part 2, see https://github.com/sot/skare3/issues/1294 for context, but in
# summary pytest > 8.0.0 uses the location of a pytest.ini file to set the rootdir
# unless it is explicitly specified. This is generally NOT what we want.
rootdir = os.path.abspath(os.path.join(*pkg_paths))
kwargs.setdefault('--rootdir', rootdir)
args += tuple([f'{k}={v}' for k, v in kwargs.items()])
with chdir(rootdir):
if with_coverage:
coverage_file = os.path.join(
os.environ['TESTR_OUT_DIR'],
'.coverage'
)
cmd = [
'coverage', 'run',
f'--rcfile={coverage_config}',
f'--data-file={coverage_file}',
'-m', 'pytest', pkg_dir
] + list(args)
with stdout_context():
process = subprocess.run(cmd, stdout=sys.stdout, stderr=subprocess.STDOUT)
rc = process.returncode
else:
with stdout_context():
rc = pytest.main([pkg_dir] + list(args))
if rc and raise_exception:
raise TestError('Failed')
return bool(rc)
[docs]
def get_full_version(calling_frame_globals, calling_frame_filename):
"""
Return a full version which includes git info if the module was imported
from the git repo source directory.
"""
release_version = calling_frame_globals.get('__version__', 'unknown')
try:
from subprocess import Popen, PIPE
filedir = os.path.dirname(os.path.abspath(calling_frame_filename))
p = Popen(['git', 'rev-list', 'HEAD'],
cwd=filedir,
stdout=PIPE, stderr=PIPE, stdin=PIPE)
stdout, stderr = p.communicate()
stdout = stdout.decode('ascii')
if p.returncode == 0:
revs = stdout.split('\n')
out = release_version + '-r{}-{}'.format(len(revs), revs[0][:7])
else:
out = release_version
except Exception:
out = release_version
return out