1841 lines
70 KiB
Python
1841 lines
70 KiB
Python
"""Python test discovery, setup and run of test functions."""
|
|
import dataclasses
|
|
import enum
|
|
import fnmatch
|
|
import inspect
|
|
import itertools
|
|
import os
|
|
import sys
|
|
import types
|
|
import warnings
|
|
from collections import Counter
|
|
from collections import defaultdict
|
|
from functools import partial
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from typing import Callable
|
|
from typing import Dict
|
|
from typing import Generator
|
|
from typing import Iterable
|
|
from typing import Iterator
|
|
from typing import List
|
|
from typing import Mapping
|
|
from typing import Optional
|
|
from typing import Pattern
|
|
from typing import Sequence
|
|
from typing import Set
|
|
from typing import Tuple
|
|
from typing import TYPE_CHECKING
|
|
from typing import Union
|
|
|
|
import _pytest
|
|
from _pytest import fixtures
|
|
from _pytest import nodes
|
|
from _pytest._code import filter_traceback
|
|
from _pytest._code import getfslineno
|
|
from _pytest._code.code import ExceptionInfo
|
|
from _pytest._code.code import TerminalRepr
|
|
from _pytest._code.code import Traceback
|
|
from _pytest._io import TerminalWriter
|
|
from _pytest._io.saferepr import saferepr
|
|
from _pytest.compat import ascii_escaped
|
|
from _pytest.compat import assert_never
|
|
from _pytest.compat import final
|
|
from _pytest.compat import get_default_arg_names
|
|
from _pytest.compat import get_real_func
|
|
from _pytest.compat import getimfunc
|
|
from _pytest.compat import getlocation
|
|
from _pytest.compat import is_async_function
|
|
from _pytest.compat import is_generator
|
|
from _pytest.compat import LEGACY_PATH
|
|
from _pytest.compat import NOTSET
|
|
from _pytest.compat import safe_getattr
|
|
from _pytest.compat import safe_isclass
|
|
from _pytest.compat import STRING_TYPES
|
|
from _pytest.config import Config
|
|
from _pytest.config import ExitCode
|
|
from _pytest.config import hookimpl
|
|
from _pytest.config.argparsing import Parser
|
|
from _pytest.deprecated import check_ispytest
|
|
from _pytest.deprecated import INSTANCE_COLLECTOR
|
|
from _pytest.deprecated import NOSE_SUPPORT_METHOD
|
|
from _pytest.fixtures import FuncFixtureInfo
|
|
from _pytest.main import Session
|
|
from _pytest.mark import MARK_GEN
|
|
from _pytest.mark import ParameterSet
|
|
from _pytest.mark.structures import get_unpacked_marks
|
|
from _pytest.mark.structures import Mark
|
|
from _pytest.mark.structures import MarkDecorator
|
|
from _pytest.mark.structures import normalize_mark_list
|
|
from _pytest.outcomes import fail
|
|
from _pytest.outcomes import skip
|
|
from _pytest.pathlib import bestrelpath
|
|
from _pytest.pathlib import fnmatch_ex
|
|
from _pytest.pathlib import import_path
|
|
from _pytest.pathlib import ImportPathMismatchError
|
|
from _pytest.pathlib import parts
|
|
from _pytest.pathlib import visit
|
|
from _pytest.scope import Scope
|
|
from _pytest.warning_types import PytestCollectionWarning
|
|
from _pytest.warning_types import PytestReturnNotNoneWarning
|
|
from _pytest.warning_types import PytestUnhandledCoroutineWarning
|
|
|
|
if TYPE_CHECKING:
|
|
from typing_extensions import Literal
|
|
|
|
from _pytest.scope import _ScopeName
|
|
|
|
|
|
_PYTEST_DIR = Path(_pytest.__file__).parent
|
|
|
|
|
|
def pytest_addoption(parser: Parser) -> None:
|
|
group = parser.getgroup("general")
|
|
group.addoption(
|
|
"--fixtures",
|
|
"--funcargs",
|
|
action="store_true",
|
|
dest="showfixtures",
|
|
default=False,
|
|
help="Show available fixtures, sorted by plugin appearance "
|
|
"(fixtures with leading '_' are only shown with '-v')",
|
|
)
|
|
group.addoption(
|
|
"--fixtures-per-test",
|
|
action="store_true",
|
|
dest="show_fixtures_per_test",
|
|
default=False,
|
|
help="Show fixtures per test",
|
|
)
|
|
parser.addini(
|
|
"python_files",
|
|
type="args",
|
|
# NOTE: default is also used in AssertionRewritingHook.
|
|
default=["test_*.py", "*_test.py"],
|
|
help="Glob-style file patterns for Python test module discovery",
|
|
)
|
|
parser.addini(
|
|
"python_classes",
|
|
type="args",
|
|
default=["Test"],
|
|
help="Prefixes or glob names for Python test class discovery",
|
|
)
|
|
parser.addini(
|
|
"python_functions",
|
|
type="args",
|
|
default=["test"],
|
|
help="Prefixes or glob names for Python test function and method discovery",
|
|
)
|
|
parser.addini(
|
|
"disable_test_id_escaping_and_forfeit_all_rights_to_community_support",
|
|
type="bool",
|
|
default=False,
|
|
help="Disable string escape non-ASCII characters, might cause unwanted "
|
|
"side effects(use at your own risk)",
|
|
)
|
|
|
|
|
|
def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]:
|
|
if config.option.showfixtures:
|
|
showfixtures(config)
|
|
return 0
|
|
if config.option.show_fixtures_per_test:
|
|
show_fixtures_per_test(config)
|
|
return 0
|
|
return None
|
|
|
|
|
|
def pytest_generate_tests(metafunc: "Metafunc") -> None:
|
|
for marker in metafunc.definition.iter_markers(name="parametrize"):
|
|
metafunc.parametrize(*marker.args, **marker.kwargs, _param_mark=marker)
|
|
|
|
|
|
def pytest_configure(config: Config) -> None:
|
|
config.addinivalue_line(
|
|
"markers",
|
|
"parametrize(argnames, argvalues): call a test function multiple "
|
|
"times passing in different arguments in turn. argvalues generally "
|
|
"needs to be a list of values if argnames specifies only one name "
|
|
"or a list of tuples of values if argnames specifies multiple names. "
|
|
"Example: @parametrize('arg1', [1,2]) would lead to two calls of the "
|
|
"decorated test function, one with arg1=1 and another with arg1=2."
|
|
"see https://docs.pytest.org/en/stable/how-to/parametrize.html for more info "
|
|
"and examples.",
|
|
)
|
|
config.addinivalue_line(
|
|
"markers",
|
|
"usefixtures(fixturename1, fixturename2, ...): mark tests as needing "
|
|
"all of the specified fixtures. see "
|
|
"https://docs.pytest.org/en/stable/explanation/fixtures.html#usefixtures ",
|
|
)
|
|
|
|
|
|
def async_warn_and_skip(nodeid: str) -> None:
|
|
msg = "async def functions are not natively supported and have been skipped.\n"
|
|
msg += (
|
|
"You need to install a suitable plugin for your async framework, for example:\n"
|
|
)
|
|
msg += " - anyio\n"
|
|
msg += " - pytest-asyncio\n"
|
|
msg += " - pytest-tornasync\n"
|
|
msg += " - pytest-trio\n"
|
|
msg += " - pytest-twisted"
|
|
warnings.warn(PytestUnhandledCoroutineWarning(msg.format(nodeid)))
|
|
skip(reason="async def function and no async plugin installed (see warnings)")
|
|
|
|
|
|
@hookimpl(trylast=True)
|
|
def pytest_pyfunc_call(pyfuncitem: "Function") -> Optional[object]:
|
|
testfunction = pyfuncitem.obj
|
|
if is_async_function(testfunction):
|
|
async_warn_and_skip(pyfuncitem.nodeid)
|
|
funcargs = pyfuncitem.funcargs
|
|
testargs = {arg: funcargs[arg] for arg in pyfuncitem._fixtureinfo.argnames}
|
|
result = testfunction(**testargs)
|
|
if hasattr(result, "__await__") or hasattr(result, "__aiter__"):
|
|
async_warn_and_skip(pyfuncitem.nodeid)
|
|
elif result is not None:
|
|
warnings.warn(
|
|
PytestReturnNotNoneWarning(
|
|
f"Expected None, but {pyfuncitem.nodeid} returned {result!r}, which will be an error in a "
|
|
"future version of pytest. Did you mean to use `assert` instead of `return`?"
|
|
)
|
|
)
|
|
return True
|
|
|
|
|
|
def pytest_collect_file(file_path: Path, parent: nodes.Collector) -> Optional["Module"]:
|
|
if file_path.suffix == ".py":
|
|
if not parent.session.isinitpath(file_path):
|
|
if not path_matches_patterns(
|
|
file_path, parent.config.getini("python_files") + ["__init__.py"]
|
|
):
|
|
return None
|
|
ihook = parent.session.gethookproxy(file_path)
|
|
module: Module = ihook.pytest_pycollect_makemodule(
|
|
module_path=file_path, parent=parent
|
|
)
|
|
return module
|
|
return None
|
|
|
|
|
|
def path_matches_patterns(path: Path, patterns: Iterable[str]) -> bool:
|
|
"""Return whether path matches any of the patterns in the list of globs given."""
|
|
return any(fnmatch_ex(pattern, path) for pattern in patterns)
|
|
|
|
|
|
def pytest_pycollect_makemodule(module_path: Path, parent) -> "Module":
|
|
if module_path.name == "__init__.py":
|
|
pkg: Package = Package.from_parent(parent, path=module_path)
|
|
return pkg
|
|
mod: Module = Module.from_parent(parent, path=module_path)
|
|
return mod
|
|
|
|
|
|
@hookimpl(trylast=True)
|
|
def pytest_pycollect_makeitem(
|
|
collector: Union["Module", "Class"], name: str, obj: object
|
|
) -> Union[None, nodes.Item, nodes.Collector, List[Union[nodes.Item, nodes.Collector]]]:
|
|
assert isinstance(collector, (Class, Module)), type(collector)
|
|
# Nothing was collected elsewhere, let's do it here.
|
|
if safe_isclass(obj):
|
|
if collector.istestclass(obj, name):
|
|
klass: Class = Class.from_parent(collector, name=name, obj=obj)
|
|
return klass
|
|
elif collector.istestfunction(obj, name):
|
|
# mock seems to store unbound methods (issue473), normalize it.
|
|
obj = getattr(obj, "__func__", obj)
|
|
# We need to try and unwrap the function if it's a functools.partial
|
|
# or a functools.wrapped.
|
|
# We mustn't if it's been wrapped with mock.patch (python 2 only).
|
|
if not (inspect.isfunction(obj) or inspect.isfunction(get_real_func(obj))):
|
|
filename, lineno = getfslineno(obj)
|
|
warnings.warn_explicit(
|
|
message=PytestCollectionWarning(
|
|
"cannot collect %r because it is not a function." % name
|
|
),
|
|
category=None,
|
|
filename=str(filename),
|
|
lineno=lineno + 1,
|
|
)
|
|
elif getattr(obj, "__test__", True):
|
|
if is_generator(obj):
|
|
res: Function = Function.from_parent(collector, name=name)
|
|
reason = "yield tests were removed in pytest 4.0 - {name} will be ignored".format(
|
|
name=name
|
|
)
|
|
res.add_marker(MARK_GEN.xfail(run=False, reason=reason))
|
|
res.warn(PytestCollectionWarning(reason))
|
|
return res
|
|
else:
|
|
return list(collector._genfunctions(name, obj))
|
|
return None
|
|
|
|
|
|
class PyobjMixin(nodes.Node):
|
|
"""this mix-in inherits from Node to carry over the typing information
|
|
|
|
as its intended to always mix in before a node
|
|
its position in the mro is unaffected"""
|
|
|
|
_ALLOW_MARKERS = True
|
|
|
|
@property
|
|
def module(self):
|
|
"""Python module object this node was collected from (can be None)."""
|
|
node = self.getparent(Module)
|
|
return node.obj if node is not None else None
|
|
|
|
@property
|
|
def cls(self):
|
|
"""Python class object this node was collected from (can be None)."""
|
|
node = self.getparent(Class)
|
|
return node.obj if node is not None else None
|
|
|
|
@property
|
|
def instance(self):
|
|
"""Python instance object the function is bound to.
|
|
|
|
Returns None if not a test method, e.g. for a standalone test function,
|
|
a staticmethod, a class or a module.
|
|
"""
|
|
node = self.getparent(Function)
|
|
return getattr(node.obj, "__self__", None) if node is not None else None
|
|
|
|
@property
|
|
def obj(self):
|
|
"""Underlying Python object."""
|
|
obj = getattr(self, "_obj", None)
|
|
if obj is None:
|
|
self._obj = obj = self._getobj()
|
|
# XXX evil hack
|
|
# used to avoid Function marker duplication
|
|
if self._ALLOW_MARKERS:
|
|
self.own_markers.extend(get_unpacked_marks(self.obj))
|
|
# This assumes that `obj` is called before there is a chance
|
|
# to add custom keys to `self.keywords`, so no fear of overriding.
|
|
self.keywords.update((mark.name, mark) for mark in self.own_markers)
|
|
return obj
|
|
|
|
@obj.setter
|
|
def obj(self, value):
|
|
self._obj = value
|
|
|
|
def _getobj(self):
|
|
"""Get the underlying Python object. May be overwritten by subclasses."""
|
|
# TODO: Improve the type of `parent` such that assert/ignore aren't needed.
|
|
assert self.parent is not None
|
|
obj = self.parent.obj # type: ignore[attr-defined]
|
|
return getattr(obj, self.name)
|
|
|
|
def getmodpath(self, stopatmodule: bool = True, includemodule: bool = False) -> str:
|
|
"""Return Python path relative to the containing module."""
|
|
chain = self.listchain()
|
|
chain.reverse()
|
|
parts = []
|
|
for node in chain:
|
|
name = node.name
|
|
if isinstance(node, Module):
|
|
name = os.path.splitext(name)[0]
|
|
if stopatmodule:
|
|
if includemodule:
|
|
parts.append(name)
|
|
break
|
|
parts.append(name)
|
|
parts.reverse()
|
|
return ".".join(parts)
|
|
|
|
def reportinfo(self) -> Tuple[Union["os.PathLike[str]", str], Optional[int], str]:
|
|
# XXX caching?
|
|
obj = self.obj
|
|
compat_co_firstlineno = getattr(obj, "compat_co_firstlineno", None)
|
|
if isinstance(compat_co_firstlineno, int):
|
|
# nose compatibility
|
|
file_path = sys.modules[obj.__module__].__file__
|
|
assert file_path is not None
|
|
if file_path.endswith(".pyc"):
|
|
file_path = file_path[:-1]
|
|
path: Union["os.PathLike[str]", str] = file_path
|
|
lineno = compat_co_firstlineno
|
|
else:
|
|
path, lineno = getfslineno(obj)
|
|
modpath = self.getmodpath()
|
|
assert isinstance(lineno, int)
|
|
return path, lineno, modpath
|
|
|
|
|
|
# As an optimization, these builtin attribute names are pre-ignored when
|
|
# iterating over an object during collection -- the pytest_pycollect_makeitem
|
|
# hook is not called for them.
|
|
# fmt: off
|
|
class _EmptyClass: pass # noqa: E701
|
|
IGNORED_ATTRIBUTES = frozenset.union( # noqa: E305
|
|
frozenset(),
|
|
# Module.
|
|
dir(types.ModuleType("empty_module")),
|
|
# Some extra module attributes the above doesn't catch.
|
|
{"__builtins__", "__file__", "__cached__"},
|
|
# Class.
|
|
dir(_EmptyClass),
|
|
# Instance.
|
|
dir(_EmptyClass()),
|
|
)
|
|
del _EmptyClass
|
|
# fmt: on
|
|
|
|
|
|
class PyCollector(PyobjMixin, nodes.Collector):
|
|
def funcnamefilter(self, name: str) -> bool:
|
|
return self._matches_prefix_or_glob_option("python_functions", name)
|
|
|
|
def isnosetest(self, obj: object) -> bool:
|
|
"""Look for the __test__ attribute, which is applied by the
|
|
@nose.tools.istest decorator.
|
|
"""
|
|
# We explicitly check for "is True" here to not mistakenly treat
|
|
# classes with a custom __getattr__ returning something truthy (like a
|
|
# function) as test classes.
|
|
return safe_getattr(obj, "__test__", False) is True
|
|
|
|
def classnamefilter(self, name: str) -> bool:
|
|
return self._matches_prefix_or_glob_option("python_classes", name)
|
|
|
|
def istestfunction(self, obj: object, name: str) -> bool:
|
|
if self.funcnamefilter(name) or self.isnosetest(obj):
|
|
if isinstance(obj, (staticmethod, classmethod)):
|
|
# staticmethods and classmethods need to be unwrapped.
|
|
obj = safe_getattr(obj, "__func__", False)
|
|
return callable(obj) and fixtures.getfixturemarker(obj) is None
|
|
else:
|
|
return False
|
|
|
|
def istestclass(self, obj: object, name: str) -> bool:
|
|
return self.classnamefilter(name) or self.isnosetest(obj)
|
|
|
|
def _matches_prefix_or_glob_option(self, option_name: str, name: str) -> bool:
|
|
"""Check if the given name matches the prefix or glob-pattern defined
|
|
in ini configuration."""
|
|
for option in self.config.getini(option_name):
|
|
if name.startswith(option):
|
|
return True
|
|
# Check that name looks like a glob-string before calling fnmatch
|
|
# because this is called for every name in each collected module,
|
|
# and fnmatch is somewhat expensive to call.
|
|
elif ("*" in option or "?" in option or "[" in option) and fnmatch.fnmatch(
|
|
name, option
|
|
):
|
|
return True
|
|
return False
|
|
|
|
def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]:
|
|
if not getattr(self.obj, "__test__", True):
|
|
return []
|
|
|
|
# Avoid random getattrs and peek in the __dict__ instead.
|
|
dicts = [getattr(self.obj, "__dict__", {})]
|
|
if isinstance(self.obj, type):
|
|
for basecls in self.obj.__mro__:
|
|
dicts.append(basecls.__dict__)
|
|
|
|
# In each class, nodes should be definition ordered.
|
|
# __dict__ is definition ordered.
|
|
seen: Set[str] = set()
|
|
dict_values: List[List[Union[nodes.Item, nodes.Collector]]] = []
|
|
ihook = self.ihook
|
|
for dic in dicts:
|
|
values: List[Union[nodes.Item, nodes.Collector]] = []
|
|
# Note: seems like the dict can change during iteration -
|
|
# be careful not to remove the list() without consideration.
|
|
for name, obj in list(dic.items()):
|
|
if name in IGNORED_ATTRIBUTES:
|
|
continue
|
|
if name in seen:
|
|
continue
|
|
seen.add(name)
|
|
res = ihook.pytest_pycollect_makeitem(
|
|
collector=self, name=name, obj=obj
|
|
)
|
|
if res is None:
|
|
continue
|
|
elif isinstance(res, list):
|
|
values.extend(res)
|
|
else:
|
|
values.append(res)
|
|
dict_values.append(values)
|
|
|
|
# Between classes in the class hierarchy, reverse-MRO order -- nodes
|
|
# inherited from base classes should come before subclasses.
|
|
result = []
|
|
for values in reversed(dict_values):
|
|
result.extend(values)
|
|
return result
|
|
|
|
def _genfunctions(self, name: str, funcobj) -> Iterator["Function"]:
|
|
modulecol = self.getparent(Module)
|
|
assert modulecol is not None
|
|
module = modulecol.obj
|
|
clscol = self.getparent(Class)
|
|
cls = clscol and clscol.obj or None
|
|
|
|
definition = FunctionDefinition.from_parent(self, name=name, callobj=funcobj)
|
|
fixtureinfo = definition._fixtureinfo
|
|
|
|
# pytest_generate_tests impls call metafunc.parametrize() which fills
|
|
# metafunc._calls, the outcome of the hook.
|
|
metafunc = Metafunc(
|
|
definition=definition,
|
|
fixtureinfo=fixtureinfo,
|
|
config=self.config,
|
|
cls=cls,
|
|
module=module,
|
|
_ispytest=True,
|
|
)
|
|
methods = []
|
|
if hasattr(module, "pytest_generate_tests"):
|
|
methods.append(module.pytest_generate_tests)
|
|
if cls is not None and hasattr(cls, "pytest_generate_tests"):
|
|
methods.append(cls().pytest_generate_tests)
|
|
self.ihook.pytest_generate_tests.call_extra(methods, dict(metafunc=metafunc))
|
|
|
|
if not metafunc._calls:
|
|
yield Function.from_parent(self, name=name, fixtureinfo=fixtureinfo)
|
|
else:
|
|
# Add funcargs() as fixturedefs to fixtureinfo.arg2fixturedefs.
|
|
fm = self.session._fixturemanager
|
|
fixtures.add_funcarg_pseudo_fixture_def(self, metafunc, fm)
|
|
|
|
# Add_funcarg_pseudo_fixture_def may have shadowed some fixtures
|
|
# with direct parametrization, so make sure we update what the
|
|
# function really needs.
|
|
fixtureinfo.prune_dependency_tree()
|
|
|
|
for callspec in metafunc._calls:
|
|
subname = f"{name}[{callspec.id}]"
|
|
yield Function.from_parent(
|
|
self,
|
|
name=subname,
|
|
callspec=callspec,
|
|
fixtureinfo=fixtureinfo,
|
|
keywords={callspec.id: True},
|
|
originalname=name,
|
|
)
|
|
|
|
|
|
class Module(nodes.File, PyCollector):
|
|
"""Collector for test classes and functions."""
|
|
|
|
def _getobj(self):
|
|
return self._importtestmodule()
|
|
|
|
def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]:
|
|
self._inject_setup_module_fixture()
|
|
self._inject_setup_function_fixture()
|
|
self.session._fixturemanager.parsefactories(self)
|
|
return super().collect()
|
|
|
|
def _inject_setup_module_fixture(self) -> None:
|
|
"""Inject a hidden autouse, module scoped fixture into the collected module object
|
|
that invokes setUpModule/tearDownModule if either or both are available.
|
|
|
|
Using a fixture to invoke this methods ensures we play nicely and unsurprisingly with
|
|
other fixtures (#517).
|
|
"""
|
|
has_nose = self.config.pluginmanager.has_plugin("nose")
|
|
setup_module = _get_first_non_fixture_func(
|
|
self.obj, ("setUpModule", "setup_module")
|
|
)
|
|
if setup_module is None and has_nose:
|
|
# The name "setup" is too common - only treat as fixture if callable.
|
|
setup_module = _get_first_non_fixture_func(self.obj, ("setup",))
|
|
if not callable(setup_module):
|
|
setup_module = None
|
|
teardown_module = _get_first_non_fixture_func(
|
|
self.obj, ("tearDownModule", "teardown_module")
|
|
)
|
|
if teardown_module is None and has_nose:
|
|
teardown_module = _get_first_non_fixture_func(self.obj, ("teardown",))
|
|
# Same as "setup" above - only treat as fixture if callable.
|
|
if not callable(teardown_module):
|
|
teardown_module = None
|
|
|
|
if setup_module is None and teardown_module is None:
|
|
return
|
|
|
|
@fixtures.fixture(
|
|
autouse=True,
|
|
scope="module",
|
|
# Use a unique name to speed up lookup.
|
|
name=f"_xunit_setup_module_fixture_{self.obj.__name__}",
|
|
)
|
|
def xunit_setup_module_fixture(request) -> Generator[None, None, None]:
|
|
if setup_module is not None:
|
|
_call_with_optional_argument(setup_module, request.module)
|
|
yield
|
|
if teardown_module is not None:
|
|
_call_with_optional_argument(teardown_module, request.module)
|
|
|
|
self.obj.__pytest_setup_module = xunit_setup_module_fixture
|
|
|
|
def _inject_setup_function_fixture(self) -> None:
|
|
"""Inject a hidden autouse, function scoped fixture into the collected module object
|
|
that invokes setup_function/teardown_function if either or both are available.
|
|
|
|
Using a fixture to invoke this methods ensures we play nicely and unsurprisingly with
|
|
other fixtures (#517).
|
|
"""
|
|
setup_function = _get_first_non_fixture_func(self.obj, ("setup_function",))
|
|
teardown_function = _get_first_non_fixture_func(
|
|
self.obj, ("teardown_function",)
|
|
)
|
|
if setup_function is None and teardown_function is None:
|
|
return
|
|
|
|
@fixtures.fixture(
|
|
autouse=True,
|
|
scope="function",
|
|
# Use a unique name to speed up lookup.
|
|
name=f"_xunit_setup_function_fixture_{self.obj.__name__}",
|
|
)
|
|
def xunit_setup_function_fixture(request) -> Generator[None, None, None]:
|
|
if request.instance is not None:
|
|
# in this case we are bound to an instance, so we need to let
|
|
# setup_method handle this
|
|
yield
|
|
return
|
|
if setup_function is not None:
|
|
_call_with_optional_argument(setup_function, request.function)
|
|
yield
|
|
if teardown_function is not None:
|
|
_call_with_optional_argument(teardown_function, request.function)
|
|
|
|
self.obj.__pytest_setup_function = xunit_setup_function_fixture
|
|
|
|
def _importtestmodule(self):
|
|
# We assume we are only called once per module.
|
|
importmode = self.config.getoption("--import-mode")
|
|
try:
|
|
mod = import_path(self.path, mode=importmode, root=self.config.rootpath)
|
|
except SyntaxError as e:
|
|
raise self.CollectError(
|
|
ExceptionInfo.from_current().getrepr(style="short")
|
|
) from e
|
|
except ImportPathMismatchError as e:
|
|
raise self.CollectError(
|
|
"import file mismatch:\n"
|
|
"imported module %r has this __file__ attribute:\n"
|
|
" %s\n"
|
|
"which is not the same as the test file we want to collect:\n"
|
|
" %s\n"
|
|
"HINT: remove __pycache__ / .pyc files and/or use a "
|
|
"unique basename for your test file modules" % e.args
|
|
) from e
|
|
except ImportError as e:
|
|
exc_info = ExceptionInfo.from_current()
|
|
if self.config.getoption("verbose") < 2:
|
|
exc_info.traceback = exc_info.traceback.filter(filter_traceback)
|
|
exc_repr = (
|
|
exc_info.getrepr(style="short")
|
|
if exc_info.traceback
|
|
else exc_info.exconly()
|
|
)
|
|
formatted_tb = str(exc_repr)
|
|
raise self.CollectError(
|
|
"ImportError while importing test module '{path}'.\n"
|
|
"Hint: make sure your test modules/packages have valid Python names.\n"
|
|
"Traceback:\n"
|
|
"{traceback}".format(path=self.path, traceback=formatted_tb)
|
|
) from e
|
|
except skip.Exception as e:
|
|
if e.allow_module_level:
|
|
raise
|
|
raise self.CollectError(
|
|
"Using pytest.skip outside of a test will skip the entire module. "
|
|
"If that's your intention, pass `allow_module_level=True`. "
|
|
"If you want to skip a specific test or an entire class, "
|
|
"use the @pytest.mark.skip or @pytest.mark.skipif decorators."
|
|
) from e
|
|
self.config.pluginmanager.consider_module(mod)
|
|
return mod
|
|
|
|
|
|
class Package(Module):
|
|
def __init__(
|
|
self,
|
|
fspath: Optional[LEGACY_PATH],
|
|
parent: nodes.Collector,
|
|
# NOTE: following args are unused:
|
|
config=None,
|
|
session=None,
|
|
nodeid=None,
|
|
path: Optional[Path] = None,
|
|
) -> None:
|
|
# NOTE: Could be just the following, but kept as-is for compat.
|
|
# nodes.FSCollector.__init__(self, fspath, parent=parent)
|
|
session = parent.session
|
|
nodes.FSCollector.__init__(
|
|
self,
|
|
fspath=fspath,
|
|
path=path,
|
|
parent=parent,
|
|
config=config,
|
|
session=session,
|
|
nodeid=nodeid,
|
|
)
|
|
self.name = self.path.parent.name
|
|
|
|
def setup(self) -> None:
|
|
# Not using fixtures to call setup_module here because autouse fixtures
|
|
# from packages are not called automatically (#4085).
|
|
setup_module = _get_first_non_fixture_func(
|
|
self.obj, ("setUpModule", "setup_module")
|
|
)
|
|
if setup_module is not None:
|
|
_call_with_optional_argument(setup_module, self.obj)
|
|
|
|
teardown_module = _get_first_non_fixture_func(
|
|
self.obj, ("tearDownModule", "teardown_module")
|
|
)
|
|
if teardown_module is not None:
|
|
func = partial(_call_with_optional_argument, teardown_module, self.obj)
|
|
self.addfinalizer(func)
|
|
|
|
def _recurse(self, direntry: "os.DirEntry[str]") -> bool:
|
|
if direntry.name == "__pycache__":
|
|
return False
|
|
fspath = Path(direntry.path)
|
|
ihook = self.session.gethookproxy(fspath.parent)
|
|
if ihook.pytest_ignore_collect(collection_path=fspath, config=self.config):
|
|
return False
|
|
return True
|
|
|
|
def _collectfile(
|
|
self, fspath: Path, handle_dupes: bool = True
|
|
) -> Sequence[nodes.Collector]:
|
|
assert (
|
|
fspath.is_file()
|
|
), "{!r} is not a file (isdir={!r}, exists={!r}, islink={!r})".format(
|
|
fspath, fspath.is_dir(), fspath.exists(), fspath.is_symlink()
|
|
)
|
|
ihook = self.session.gethookproxy(fspath)
|
|
if not self.session.isinitpath(fspath):
|
|
if ihook.pytest_ignore_collect(collection_path=fspath, config=self.config):
|
|
return ()
|
|
|
|
if handle_dupes:
|
|
keepduplicates = self.config.getoption("keepduplicates")
|
|
if not keepduplicates:
|
|
duplicate_paths = self.config.pluginmanager._duplicatepaths
|
|
if fspath in duplicate_paths:
|
|
return ()
|
|
else:
|
|
duplicate_paths.add(fspath)
|
|
|
|
return ihook.pytest_collect_file(file_path=fspath, parent=self) # type: ignore[no-any-return]
|
|
|
|
def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]:
|
|
this_path = self.path.parent
|
|
|
|
# Always collect the __init__ first.
|
|
if path_matches_patterns(self.path, self.config.getini("python_files")):
|
|
yield Module.from_parent(self, path=self.path)
|
|
|
|
pkg_prefixes: Set[Path] = set()
|
|
for direntry in visit(str(this_path), recurse=self._recurse):
|
|
path = Path(direntry.path)
|
|
|
|
# We will visit our own __init__.py file, in which case we skip it.
|
|
if direntry.is_file():
|
|
if direntry.name == "__init__.py" and path.parent == this_path:
|
|
continue
|
|
|
|
parts_ = parts(direntry.path)
|
|
if any(
|
|
str(pkg_prefix) in parts_ and pkg_prefix / "__init__.py" != path
|
|
for pkg_prefix in pkg_prefixes
|
|
):
|
|
continue
|
|
|
|
if direntry.is_file():
|
|
yield from self._collectfile(path)
|
|
elif not direntry.is_dir():
|
|
# Broken symlink or invalid/missing file.
|
|
continue
|
|
elif path.joinpath("__init__.py").is_file():
|
|
pkg_prefixes.add(path)
|
|
|
|
|
|
def _call_with_optional_argument(func, arg) -> None:
|
|
"""Call the given function with the given argument if func accepts one argument, otherwise
|
|
calls func without arguments."""
|
|
arg_count = func.__code__.co_argcount
|
|
if inspect.ismethod(func):
|
|
arg_count -= 1
|
|
if arg_count:
|
|
func(arg)
|
|
else:
|
|
func()
|
|
|
|
|
|
def _get_first_non_fixture_func(obj: object, names: Iterable[str]) -> Optional[object]:
|
|
"""Return the attribute from the given object to be used as a setup/teardown
|
|
xunit-style function, but only if not marked as a fixture to avoid calling it twice.
|
|
"""
|
|
for name in names:
|
|
meth: Optional[object] = getattr(obj, name, None)
|
|
if meth is not None and fixtures.getfixturemarker(meth) is None:
|
|
return meth
|
|
return None
|
|
|
|
|
|
class Class(PyCollector):
|
|
"""Collector for test methods."""
|
|
|
|
@classmethod
|
|
def from_parent(cls, parent, *, name, obj=None, **kw):
|
|
"""The public constructor."""
|
|
return super().from_parent(name=name, parent=parent, **kw)
|
|
|
|
def newinstance(self):
|
|
return self.obj()
|
|
|
|
def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]:
|
|
if not safe_getattr(self.obj, "__test__", True):
|
|
return []
|
|
if hasinit(self.obj):
|
|
assert self.parent is not None
|
|
self.warn(
|
|
PytestCollectionWarning(
|
|
"cannot collect test class %r because it has a "
|
|
"__init__ constructor (from: %s)"
|
|
% (self.obj.__name__, self.parent.nodeid)
|
|
)
|
|
)
|
|
return []
|
|
elif hasnew(self.obj):
|
|
assert self.parent is not None
|
|
self.warn(
|
|
PytestCollectionWarning(
|
|
"cannot collect test class %r because it has a "
|
|
"__new__ constructor (from: %s)"
|
|
% (self.obj.__name__, self.parent.nodeid)
|
|
)
|
|
)
|
|
return []
|
|
|
|
self._inject_setup_class_fixture()
|
|
self._inject_setup_method_fixture()
|
|
|
|
self.session._fixturemanager.parsefactories(self.newinstance(), self.nodeid)
|
|
|
|
return super().collect()
|
|
|
|
def _inject_setup_class_fixture(self) -> None:
|
|
"""Inject a hidden autouse, class scoped fixture into the collected class object
|
|
that invokes setup_class/teardown_class if either or both are available.
|
|
|
|
Using a fixture to invoke this methods ensures we play nicely and unsurprisingly with
|
|
other fixtures (#517).
|
|
"""
|
|
setup_class = _get_first_non_fixture_func(self.obj, ("setup_class",))
|
|
teardown_class = _get_first_non_fixture_func(self.obj, ("teardown_class",))
|
|
if setup_class is None and teardown_class is None:
|
|
return
|
|
|
|
@fixtures.fixture(
|
|
autouse=True,
|
|
scope="class",
|
|
# Use a unique name to speed up lookup.
|
|
name=f"_xunit_setup_class_fixture_{self.obj.__qualname__}",
|
|
)
|
|
def xunit_setup_class_fixture(cls) -> Generator[None, None, None]:
|
|
if setup_class is not None:
|
|
func = getimfunc(setup_class)
|
|
_call_with_optional_argument(func, self.obj)
|
|
yield
|
|
if teardown_class is not None:
|
|
func = getimfunc(teardown_class)
|
|
_call_with_optional_argument(func, self.obj)
|
|
|
|
self.obj.__pytest_setup_class = xunit_setup_class_fixture
|
|
|
|
def _inject_setup_method_fixture(self) -> None:
|
|
"""Inject a hidden autouse, function scoped fixture into the collected class object
|
|
that invokes setup_method/teardown_method if either or both are available.
|
|
|
|
Using a fixture to invoke these methods ensures we play nicely and unsurprisingly with
|
|
other fixtures (#517).
|
|
"""
|
|
has_nose = self.config.pluginmanager.has_plugin("nose")
|
|
setup_name = "setup_method"
|
|
setup_method = _get_first_non_fixture_func(self.obj, (setup_name,))
|
|
emit_nose_setup_warning = False
|
|
if setup_method is None and has_nose:
|
|
setup_name = "setup"
|
|
emit_nose_setup_warning = True
|
|
setup_method = _get_first_non_fixture_func(self.obj, (setup_name,))
|
|
teardown_name = "teardown_method"
|
|
teardown_method = _get_first_non_fixture_func(self.obj, (teardown_name,))
|
|
emit_nose_teardown_warning = False
|
|
if teardown_method is None and has_nose:
|
|
teardown_name = "teardown"
|
|
emit_nose_teardown_warning = True
|
|
teardown_method = _get_first_non_fixture_func(self.obj, (teardown_name,))
|
|
if setup_method is None and teardown_method is None:
|
|
return
|
|
|
|
@fixtures.fixture(
|
|
autouse=True,
|
|
scope="function",
|
|
# Use a unique name to speed up lookup.
|
|
name=f"_xunit_setup_method_fixture_{self.obj.__qualname__}",
|
|
)
|
|
def xunit_setup_method_fixture(self, request) -> Generator[None, None, None]:
|
|
method = request.function
|
|
if setup_method is not None:
|
|
func = getattr(self, setup_name)
|
|
_call_with_optional_argument(func, method)
|
|
if emit_nose_setup_warning:
|
|
warnings.warn(
|
|
NOSE_SUPPORT_METHOD.format(
|
|
nodeid=request.node.nodeid, method="setup"
|
|
),
|
|
stacklevel=2,
|
|
)
|
|
yield
|
|
if teardown_method is not None:
|
|
func = getattr(self, teardown_name)
|
|
_call_with_optional_argument(func, method)
|
|
if emit_nose_teardown_warning:
|
|
warnings.warn(
|
|
NOSE_SUPPORT_METHOD.format(
|
|
nodeid=request.node.nodeid, method="teardown"
|
|
),
|
|
stacklevel=2,
|
|
)
|
|
|
|
self.obj.__pytest_setup_method = xunit_setup_method_fixture
|
|
|
|
|
|
class InstanceDummy:
|
|
"""Instance used to be a node type between Class and Function. It has been
|
|
removed in pytest 7.0. Some plugins exist which reference `pytest.Instance`
|
|
only to ignore it; this dummy class keeps them working. This will be removed
|
|
in pytest 8."""
|
|
|
|
|
|
def __getattr__(name: str) -> object:
|
|
if name == "Instance":
|
|
warnings.warn(INSTANCE_COLLECTOR, 2)
|
|
return InstanceDummy
|
|
raise AttributeError(f"module {__name__} has no attribute {name}")
|
|
|
|
|
|
def hasinit(obj: object) -> bool:
|
|
init: object = getattr(obj, "__init__", None)
|
|
if init:
|
|
return init != object.__init__
|
|
return False
|
|
|
|
|
|
def hasnew(obj: object) -> bool:
|
|
new: object = getattr(obj, "__new__", None)
|
|
if new:
|
|
return new != object.__new__
|
|
return False
|
|
|
|
|
|
@final
|
|
@dataclasses.dataclass(frozen=True)
|
|
class IdMaker:
|
|
"""Make IDs for a parametrization."""
|
|
|
|
__slots__ = (
|
|
"argnames",
|
|
"parametersets",
|
|
"idfn",
|
|
"ids",
|
|
"config",
|
|
"nodeid",
|
|
"func_name",
|
|
)
|
|
|
|
# The argnames of the parametrization.
|
|
argnames: Sequence[str]
|
|
# The ParameterSets of the parametrization.
|
|
parametersets: Sequence[ParameterSet]
|
|
# Optionally, a user-provided callable to make IDs for parameters in a
|
|
# ParameterSet.
|
|
idfn: Optional[Callable[[Any], Optional[object]]]
|
|
# Optionally, explicit IDs for ParameterSets by index.
|
|
ids: Optional[Sequence[Optional[object]]]
|
|
# Optionally, the pytest config.
|
|
# Used for controlling ASCII escaping, and for calling the
|
|
# :hook:`pytest_make_parametrize_id` hook.
|
|
config: Optional[Config]
|
|
# Optionally, the ID of the node being parametrized.
|
|
# Used only for clearer error messages.
|
|
nodeid: Optional[str]
|
|
# Optionally, the ID of the function being parametrized.
|
|
# Used only for clearer error messages.
|
|
func_name: Optional[str]
|
|
|
|
def make_unique_parameterset_ids(self) -> List[str]:
|
|
"""Make a unique identifier for each ParameterSet, that may be used to
|
|
identify the parametrization in a node ID.
|
|
|
|
Format is <prm_1_token>-...-<prm_n_token>[counter], where prm_x_token is
|
|
- user-provided id, if given
|
|
- else an id derived from the value, applicable for certain types
|
|
- else <argname><parameterset index>
|
|
The counter suffix is appended only in case a string wouldn't be unique
|
|
otherwise.
|
|
"""
|
|
resolved_ids = list(self._resolve_ids())
|
|
# All IDs must be unique!
|
|
if len(resolved_ids) != len(set(resolved_ids)):
|
|
# Record the number of occurrences of each ID.
|
|
id_counts = Counter(resolved_ids)
|
|
# Map the ID to its next suffix.
|
|
id_suffixes: Dict[str, int] = defaultdict(int)
|
|
# Suffix non-unique IDs to make them unique.
|
|
for index, id in enumerate(resolved_ids):
|
|
if id_counts[id] > 1:
|
|
resolved_ids[index] = f"{id}{id_suffixes[id]}"
|
|
id_suffixes[id] += 1
|
|
return resolved_ids
|
|
|
|
def _resolve_ids(self) -> Iterable[str]:
|
|
"""Resolve IDs for all ParameterSets (may contain duplicates)."""
|
|
for idx, parameterset in enumerate(self.parametersets):
|
|
if parameterset.id is not None:
|
|
# ID provided directly - pytest.param(..., id="...")
|
|
yield parameterset.id
|
|
elif self.ids and idx < len(self.ids) and self.ids[idx] is not None:
|
|
# ID provided in the IDs list - parametrize(..., ids=[...]).
|
|
yield self._idval_from_value_required(self.ids[idx], idx)
|
|
else:
|
|
# ID not provided - generate it.
|
|
yield "-".join(
|
|
self._idval(val, argname, idx)
|
|
for val, argname in zip(parameterset.values, self.argnames)
|
|
)
|
|
|
|
def _idval(self, val: object, argname: str, idx: int) -> str:
|
|
"""Make an ID for a parameter in a ParameterSet."""
|
|
idval = self._idval_from_function(val, argname, idx)
|
|
if idval is not None:
|
|
return idval
|
|
idval = self._idval_from_hook(val, argname)
|
|
if idval is not None:
|
|
return idval
|
|
idval = self._idval_from_value(val)
|
|
if idval is not None:
|
|
return idval
|
|
return self._idval_from_argname(argname, idx)
|
|
|
|
def _idval_from_function(
|
|
self, val: object, argname: str, idx: int
|
|
) -> Optional[str]:
|
|
"""Try to make an ID for a parameter in a ParameterSet using the
|
|
user-provided id callable, if given."""
|
|
if self.idfn is None:
|
|
return None
|
|
try:
|
|
id = self.idfn(val)
|
|
except Exception as e:
|
|
prefix = f"{self.nodeid}: " if self.nodeid is not None else ""
|
|
msg = "error raised while trying to determine id of parameter '{}' at position {}"
|
|
msg = prefix + msg.format(argname, idx)
|
|
raise ValueError(msg) from e
|
|
if id is None:
|
|
return None
|
|
return self._idval_from_value(id)
|
|
|
|
def _idval_from_hook(self, val: object, argname: str) -> Optional[str]:
|
|
"""Try to make an ID for a parameter in a ParameterSet by calling the
|
|
:hook:`pytest_make_parametrize_id` hook."""
|
|
if self.config:
|
|
id: Optional[str] = self.config.hook.pytest_make_parametrize_id(
|
|
config=self.config, val=val, argname=argname
|
|
)
|
|
return id
|
|
return None
|
|
|
|
def _idval_from_value(self, val: object) -> Optional[str]:
|
|
"""Try to make an ID for a parameter in a ParameterSet from its value,
|
|
if the value type is supported."""
|
|
if isinstance(val, STRING_TYPES):
|
|
return _ascii_escaped_by_config(val, self.config)
|
|
elif val is None or isinstance(val, (float, int, bool, complex)):
|
|
return str(val)
|
|
elif isinstance(val, Pattern):
|
|
return ascii_escaped(val.pattern)
|
|
elif val is NOTSET:
|
|
# Fallback to default. Note that NOTSET is an enum.Enum.
|
|
pass
|
|
elif isinstance(val, enum.Enum):
|
|
return str(val)
|
|
elif isinstance(getattr(val, "__name__", None), str):
|
|
# Name of a class, function, module, etc.
|
|
name: str = getattr(val, "__name__")
|
|
return name
|
|
return None
|
|
|
|
def _idval_from_value_required(self, val: object, idx: int) -> str:
|
|
"""Like _idval_from_value(), but fails if the type is not supported."""
|
|
id = self._idval_from_value(val)
|
|
if id is not None:
|
|
return id
|
|
|
|
# Fail.
|
|
if self.func_name is not None:
|
|
prefix = f"In {self.func_name}: "
|
|
elif self.nodeid is not None:
|
|
prefix = f"In {self.nodeid}: "
|
|
else:
|
|
prefix = ""
|
|
msg = (
|
|
f"{prefix}ids contains unsupported value {saferepr(val)} (type: {type(val)!r}) at index {idx}. "
|
|
"Supported types are: str, bytes, int, float, complex, bool, enum, regex or anything with a __name__."
|
|
)
|
|
fail(msg, pytrace=False)
|
|
|
|
@staticmethod
|
|
def _idval_from_argname(argname: str, idx: int) -> str:
|
|
"""Make an ID for a parameter in a ParameterSet from the argument name
|
|
and the index of the ParameterSet."""
|
|
return str(argname) + str(idx)
|
|
|
|
|
|
@final
|
|
@dataclasses.dataclass(frozen=True)
|
|
class CallSpec2:
|
|
"""A planned parameterized invocation of a test function.
|
|
|
|
Calculated during collection for a given test function's Metafunc.
|
|
Once collection is over, each callspec is turned into a single Item
|
|
and stored in item.callspec.
|
|
"""
|
|
|
|
# arg name -> arg value which will be passed to the parametrized test
|
|
# function (direct parameterization).
|
|
funcargs: Dict[str, object] = dataclasses.field(default_factory=dict)
|
|
# arg name -> arg value which will be passed to a fixture of the same name
|
|
# (indirect parametrization).
|
|
params: Dict[str, object] = dataclasses.field(default_factory=dict)
|
|
# arg name -> arg index.
|
|
indices: Dict[str, int] = dataclasses.field(default_factory=dict)
|
|
# Used for sorting parametrized resources.
|
|
_arg2scope: Dict[str, Scope] = dataclasses.field(default_factory=dict)
|
|
# Parts which will be added to the item's name in `[..]` separated by "-".
|
|
_idlist: List[str] = dataclasses.field(default_factory=list)
|
|
# Marks which will be applied to the item.
|
|
marks: List[Mark] = dataclasses.field(default_factory=list)
|
|
|
|
def setmulti(
|
|
self,
|
|
*,
|
|
valtypes: Mapping[str, "Literal['params', 'funcargs']"],
|
|
argnames: Iterable[str],
|
|
valset: Iterable[object],
|
|
id: str,
|
|
marks: Iterable[Union[Mark, MarkDecorator]],
|
|
scope: Scope,
|
|
param_index: int,
|
|
) -> "CallSpec2":
|
|
funcargs = self.funcargs.copy()
|
|
params = self.params.copy()
|
|
indices = self.indices.copy()
|
|
arg2scope = self._arg2scope.copy()
|
|
for arg, val in zip(argnames, valset):
|
|
if arg in params or arg in funcargs:
|
|
raise ValueError(f"duplicate {arg!r}")
|
|
valtype_for_arg = valtypes[arg]
|
|
if valtype_for_arg == "params":
|
|
params[arg] = val
|
|
elif valtype_for_arg == "funcargs":
|
|
funcargs[arg] = val
|
|
else:
|
|
assert_never(valtype_for_arg)
|
|
indices[arg] = param_index
|
|
arg2scope[arg] = scope
|
|
return CallSpec2(
|
|
funcargs=funcargs,
|
|
params=params,
|
|
indices=indices,
|
|
_arg2scope=arg2scope,
|
|
_idlist=[*self._idlist, id],
|
|
marks=[*self.marks, *normalize_mark_list(marks)],
|
|
)
|
|
|
|
def getparam(self, name: str) -> object:
|
|
try:
|
|
return self.params[name]
|
|
except KeyError as e:
|
|
raise ValueError(name) from e
|
|
|
|
@property
|
|
def id(self) -> str:
|
|
return "-".join(self._idlist)
|
|
|
|
|
|
@final
|
|
class Metafunc:
|
|
"""Objects passed to the :hook:`pytest_generate_tests` hook.
|
|
|
|
They help to inspect a test function and to generate tests according to
|
|
test configuration or values specified in the class or module where a
|
|
test function is defined.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
definition: "FunctionDefinition",
|
|
fixtureinfo: fixtures.FuncFixtureInfo,
|
|
config: Config,
|
|
cls=None,
|
|
module=None,
|
|
*,
|
|
_ispytest: bool = False,
|
|
) -> None:
|
|
check_ispytest(_ispytest)
|
|
|
|
#: Access to the underlying :class:`_pytest.python.FunctionDefinition`.
|
|
self.definition = definition
|
|
|
|
#: Access to the :class:`pytest.Config` object for the test session.
|
|
self.config = config
|
|
|
|
#: The module object where the test function is defined in.
|
|
self.module = module
|
|
|
|
#: Underlying Python test function.
|
|
self.function = definition.obj
|
|
|
|
#: Set of fixture names required by the test function.
|
|
self.fixturenames = fixtureinfo.names_closure
|
|
|
|
#: Class object where the test function is defined in or ``None``.
|
|
self.cls = cls
|
|
|
|
self._arg2fixturedefs = fixtureinfo.name2fixturedefs
|
|
|
|
# Result of parametrize().
|
|
self._calls: List[CallSpec2] = []
|
|
|
|
def parametrize(
|
|
self,
|
|
argnames: Union[str, Sequence[str]],
|
|
argvalues: Iterable[Union[ParameterSet, Sequence[object], object]],
|
|
indirect: Union[bool, Sequence[str]] = False,
|
|
ids: Optional[
|
|
Union[Iterable[Optional[object]], Callable[[Any], Optional[object]]]
|
|
] = None,
|
|
scope: "Optional[_ScopeName]" = None,
|
|
*,
|
|
_param_mark: Optional[Mark] = None,
|
|
) -> None:
|
|
"""Add new invocations to the underlying test function using the list
|
|
of argvalues for the given argnames. Parametrization is performed
|
|
during the collection phase. If you need to setup expensive resources
|
|
see about setting indirect to do it rather than at test setup time.
|
|
|
|
Can be called multiple times, in which case each call parametrizes all
|
|
previous parametrizations, e.g.
|
|
|
|
::
|
|
|
|
unparametrized: t
|
|
parametrize ["x", "y"]: t[x], t[y]
|
|
parametrize [1, 2]: t[x-1], t[x-2], t[y-1], t[y-2]
|
|
|
|
:param argnames:
|
|
A comma-separated string denoting one or more argument names, or
|
|
a list/tuple of argument strings.
|
|
|
|
:param argvalues:
|
|
The list of argvalues determines how often a test is invoked with
|
|
different argument values.
|
|
|
|
If only one argname was specified argvalues is a list of values.
|
|
If N argnames were specified, argvalues must be a list of
|
|
N-tuples, where each tuple-element specifies a value for its
|
|
respective argname.
|
|
|
|
:param indirect:
|
|
A list of arguments' names (subset of argnames) or a boolean.
|
|
If True the list contains all names from the argnames. Each
|
|
argvalue corresponding to an argname in this list will
|
|
be passed as request.param to its respective argname fixture
|
|
function so that it can perform more expensive setups during the
|
|
setup phase of a test rather than at collection time.
|
|
|
|
:param ids:
|
|
Sequence of (or generator for) ids for ``argvalues``,
|
|
or a callable to return part of the id for each argvalue.
|
|
|
|
With sequences (and generators like ``itertools.count()``) the
|
|
returned ids should be of type ``string``, ``int``, ``float``,
|
|
``bool``, or ``None``.
|
|
They are mapped to the corresponding index in ``argvalues``.
|
|
``None`` means to use the auto-generated id.
|
|
|
|
If it is a callable it will be called for each entry in
|
|
``argvalues``, and the return value is used as part of the
|
|
auto-generated id for the whole set (where parts are joined with
|
|
dashes ("-")).
|
|
This is useful to provide more specific ids for certain items, e.g.
|
|
dates. Returning ``None`` will use an auto-generated id.
|
|
|
|
If no ids are provided they will be generated automatically from
|
|
the argvalues.
|
|
|
|
:param scope:
|
|
If specified it denotes the scope of the parameters.
|
|
The scope is used for grouping tests by parameter instances.
|
|
It will also override any fixture-function defined scope, allowing
|
|
to set a dynamic scope using test context or configuration.
|
|
"""
|
|
argnames, parametersets = ParameterSet._for_parametrize(
|
|
argnames,
|
|
argvalues,
|
|
self.function,
|
|
self.config,
|
|
nodeid=self.definition.nodeid,
|
|
)
|
|
del argvalues
|
|
|
|
if "request" in argnames:
|
|
fail(
|
|
"'request' is a reserved name and cannot be used in @pytest.mark.parametrize",
|
|
pytrace=False,
|
|
)
|
|
|
|
if scope is not None:
|
|
scope_ = Scope.from_user(
|
|
scope, descr=f"parametrize() call in {self.function.__name__}"
|
|
)
|
|
else:
|
|
scope_ = _find_parametrized_scope(argnames, self._arg2fixturedefs, indirect)
|
|
|
|
self._validate_if_using_arg_names(argnames, indirect)
|
|
|
|
arg_values_types = self._resolve_arg_value_types(argnames, indirect)
|
|
|
|
# Use any already (possibly) generated ids with parametrize Marks.
|
|
if _param_mark and _param_mark._param_ids_from:
|
|
generated_ids = _param_mark._param_ids_from._param_ids_generated
|
|
if generated_ids is not None:
|
|
ids = generated_ids
|
|
|
|
ids = self._resolve_parameter_set_ids(
|
|
argnames, ids, parametersets, nodeid=self.definition.nodeid
|
|
)
|
|
|
|
# Store used (possibly generated) ids with parametrize Marks.
|
|
if _param_mark and _param_mark._param_ids_from and generated_ids is None:
|
|
object.__setattr__(_param_mark._param_ids_from, "_param_ids_generated", ids)
|
|
|
|
# Create the new calls: if we are parametrize() multiple times (by applying the decorator
|
|
# more than once) then we accumulate those calls generating the cartesian product
|
|
# of all calls.
|
|
newcalls = []
|
|
for callspec in self._calls or [CallSpec2()]:
|
|
for param_index, (param_id, param_set) in enumerate(
|
|
zip(ids, parametersets)
|
|
):
|
|
newcallspec = callspec.setmulti(
|
|
valtypes=arg_values_types,
|
|
argnames=argnames,
|
|
valset=param_set.values,
|
|
id=param_id,
|
|
marks=param_set.marks,
|
|
scope=scope_,
|
|
param_index=param_index,
|
|
)
|
|
newcalls.append(newcallspec)
|
|
self._calls = newcalls
|
|
|
|
def _resolve_parameter_set_ids(
|
|
self,
|
|
argnames: Sequence[str],
|
|
ids: Optional[
|
|
Union[Iterable[Optional[object]], Callable[[Any], Optional[object]]]
|
|
],
|
|
parametersets: Sequence[ParameterSet],
|
|
nodeid: str,
|
|
) -> List[str]:
|
|
"""Resolve the actual ids for the given parameter sets.
|
|
|
|
:param argnames:
|
|
Argument names passed to ``parametrize()``.
|
|
:param ids:
|
|
The `ids` parameter of the ``parametrize()`` call (see docs).
|
|
:param parametersets:
|
|
The parameter sets, each containing a set of values corresponding
|
|
to ``argnames``.
|
|
:param nodeid str:
|
|
The nodeid of the definition item that generated this
|
|
parametrization.
|
|
:returns:
|
|
List with ids for each parameter set given.
|
|
"""
|
|
if ids is None:
|
|
idfn = None
|
|
ids_ = None
|
|
elif callable(ids):
|
|
idfn = ids
|
|
ids_ = None
|
|
else:
|
|
idfn = None
|
|
ids_ = self._validate_ids(ids, parametersets, self.function.__name__)
|
|
id_maker = IdMaker(
|
|
argnames,
|
|
parametersets,
|
|
idfn,
|
|
ids_,
|
|
self.config,
|
|
nodeid=nodeid,
|
|
func_name=self.function.__name__,
|
|
)
|
|
return id_maker.make_unique_parameterset_ids()
|
|
|
|
def _validate_ids(
|
|
self,
|
|
ids: Iterable[Optional[object]],
|
|
parametersets: Sequence[ParameterSet],
|
|
func_name: str,
|
|
) -> List[Optional[object]]:
|
|
try:
|
|
num_ids = len(ids) # type: ignore[arg-type]
|
|
except TypeError:
|
|
try:
|
|
iter(ids)
|
|
except TypeError as e:
|
|
raise TypeError("ids must be a callable or an iterable") from e
|
|
num_ids = len(parametersets)
|
|
|
|
# num_ids == 0 is a special case: https://github.com/pytest-dev/pytest/issues/1849
|
|
if num_ids != len(parametersets) and num_ids != 0:
|
|
msg = "In {}: {} parameter sets specified, with different number of ids: {}"
|
|
fail(msg.format(func_name, len(parametersets), num_ids), pytrace=False)
|
|
|
|
return list(itertools.islice(ids, num_ids))
|
|
|
|
def _resolve_arg_value_types(
|
|
self,
|
|
argnames: Sequence[str],
|
|
indirect: Union[bool, Sequence[str]],
|
|
) -> Dict[str, "Literal['params', 'funcargs']"]:
|
|
"""Resolve if each parametrized argument must be considered a
|
|
parameter to a fixture or a "funcarg" to the function, based on the
|
|
``indirect`` parameter of the parametrized() call.
|
|
|
|
:param List[str] argnames: List of argument names passed to ``parametrize()``.
|
|
:param indirect: Same as the ``indirect`` parameter of ``parametrize()``.
|
|
:rtype: Dict[str, str]
|
|
A dict mapping each arg name to either:
|
|
* "params" if the argname should be the parameter of a fixture of the same name.
|
|
* "funcargs" if the argname should be a parameter to the parametrized test function.
|
|
"""
|
|
if isinstance(indirect, bool):
|
|
valtypes: Dict[str, Literal["params", "funcargs"]] = dict.fromkeys(
|
|
argnames, "params" if indirect else "funcargs"
|
|
)
|
|
elif isinstance(indirect, Sequence):
|
|
valtypes = dict.fromkeys(argnames, "funcargs")
|
|
for arg in indirect:
|
|
if arg not in argnames:
|
|
fail(
|
|
"In {}: indirect fixture '{}' doesn't exist".format(
|
|
self.function.__name__, arg
|
|
),
|
|
pytrace=False,
|
|
)
|
|
valtypes[arg] = "params"
|
|
else:
|
|
fail(
|
|
"In {func}: expected Sequence or boolean for indirect, got {type}".format(
|
|
type=type(indirect).__name__, func=self.function.__name__
|
|
),
|
|
pytrace=False,
|
|
)
|
|
return valtypes
|
|
|
|
def _validate_if_using_arg_names(
|
|
self,
|
|
argnames: Sequence[str],
|
|
indirect: Union[bool, Sequence[str]],
|
|
) -> None:
|
|
"""Check if all argnames are being used, by default values, or directly/indirectly.
|
|
|
|
:param List[str] argnames: List of argument names passed to ``parametrize()``.
|
|
:param indirect: Same as the ``indirect`` parameter of ``parametrize()``.
|
|
:raises ValueError: If validation fails.
|
|
"""
|
|
default_arg_names = set(get_default_arg_names(self.function))
|
|
func_name = self.function.__name__
|
|
for arg in argnames:
|
|
if arg not in self.fixturenames:
|
|
if arg in default_arg_names:
|
|
fail(
|
|
"In {}: function already takes an argument '{}' with a default value".format(
|
|
func_name, arg
|
|
),
|
|
pytrace=False,
|
|
)
|
|
else:
|
|
if isinstance(indirect, Sequence):
|
|
name = "fixture" if arg in indirect else "argument"
|
|
else:
|
|
name = "fixture" if indirect else "argument"
|
|
fail(
|
|
f"In {func_name}: function uses no {name} '{arg}'",
|
|
pytrace=False,
|
|
)
|
|
|
|
|
|
def _find_parametrized_scope(
|
|
argnames: Sequence[str],
|
|
arg2fixturedefs: Mapping[str, Sequence[fixtures.FixtureDef[object]]],
|
|
indirect: Union[bool, Sequence[str]],
|
|
) -> Scope:
|
|
"""Find the most appropriate scope for a parametrized call based on its arguments.
|
|
|
|
When there's at least one direct argument, always use "function" scope.
|
|
|
|
When a test function is parametrized and all its arguments are indirect
|
|
(e.g. fixtures), return the most narrow scope based on the fixtures used.
|
|
|
|
Related to issue #1832, based on code posted by @Kingdread.
|
|
"""
|
|
if isinstance(indirect, Sequence):
|
|
all_arguments_are_fixtures = len(indirect) == len(argnames)
|
|
else:
|
|
all_arguments_are_fixtures = bool(indirect)
|
|
|
|
if all_arguments_are_fixtures:
|
|
fixturedefs = arg2fixturedefs or {}
|
|
used_scopes = [
|
|
fixturedef[0]._scope
|
|
for name, fixturedef in fixturedefs.items()
|
|
if name in argnames
|
|
]
|
|
# Takes the most narrow scope from used fixtures.
|
|
return min(used_scopes, default=Scope.Function)
|
|
|
|
return Scope.Function
|
|
|
|
|
|
def _ascii_escaped_by_config(val: Union[str, bytes], config: Optional[Config]) -> str:
|
|
if config is None:
|
|
escape_option = False
|
|
else:
|
|
escape_option = config.getini(
|
|
"disable_test_id_escaping_and_forfeit_all_rights_to_community_support"
|
|
)
|
|
# TODO: If escaping is turned off and the user passes bytes,
|
|
# will return a bytes. For now we ignore this but the
|
|
# code *probably* doesn't handle this case.
|
|
return val if escape_option else ascii_escaped(val) # type: ignore
|
|
|
|
|
|
def _pretty_fixture_path(func) -> str:
|
|
cwd = Path.cwd()
|
|
loc = Path(getlocation(func, str(cwd)))
|
|
prefix = Path("...", "_pytest")
|
|
try:
|
|
return str(prefix / loc.relative_to(_PYTEST_DIR))
|
|
except ValueError:
|
|
return bestrelpath(cwd, loc)
|
|
|
|
|
|
def show_fixtures_per_test(config):
|
|
from _pytest.main import wrap_session
|
|
|
|
return wrap_session(config, _show_fixtures_per_test)
|
|
|
|
|
|
def _show_fixtures_per_test(config: Config, session: Session) -> None:
|
|
import _pytest.config
|
|
|
|
session.perform_collect()
|
|
curdir = Path.cwd()
|
|
tw = _pytest.config.create_terminal_writer(config)
|
|
verbose = config.getvalue("verbose")
|
|
|
|
def get_best_relpath(func) -> str:
|
|
loc = getlocation(func, str(curdir))
|
|
return bestrelpath(curdir, Path(loc))
|
|
|
|
def write_fixture(fixture_def: fixtures.FixtureDef[object]) -> None:
|
|
argname = fixture_def.argname
|
|
if verbose <= 0 and argname.startswith("_"):
|
|
return
|
|
prettypath = _pretty_fixture_path(fixture_def.func)
|
|
tw.write(f"{argname}", green=True)
|
|
tw.write(f" -- {prettypath}", yellow=True)
|
|
tw.write("\n")
|
|
fixture_doc = inspect.getdoc(fixture_def.func)
|
|
if fixture_doc:
|
|
write_docstring(
|
|
tw, fixture_doc.split("\n\n")[0] if verbose <= 0 else fixture_doc
|
|
)
|
|
else:
|
|
tw.line(" no docstring available", red=True)
|
|
|
|
def write_item(item: nodes.Item) -> None:
|
|
# Not all items have _fixtureinfo attribute.
|
|
info: Optional[FuncFixtureInfo] = getattr(item, "_fixtureinfo", None)
|
|
if info is None or not info.name2fixturedefs:
|
|
# This test item does not use any fixtures.
|
|
return
|
|
tw.line()
|
|
tw.sep("-", f"fixtures used by {item.name}")
|
|
# TODO: Fix this type ignore.
|
|
tw.sep("-", f"({get_best_relpath(item.function)})") # type: ignore[attr-defined]
|
|
# dict key not used in loop but needed for sorting.
|
|
for _, fixturedefs in sorted(info.name2fixturedefs.items()):
|
|
assert fixturedefs is not None
|
|
if not fixturedefs:
|
|
continue
|
|
# Last item is expected to be the one used by the test item.
|
|
write_fixture(fixturedefs[-1])
|
|
|
|
for session_item in session.items:
|
|
write_item(session_item)
|
|
|
|
|
|
def showfixtures(config: Config) -> Union[int, ExitCode]:
|
|
from _pytest.main import wrap_session
|
|
|
|
return wrap_session(config, _showfixtures_main)
|
|
|
|
|
|
def _showfixtures_main(config: Config, session: Session) -> None:
|
|
import _pytest.config
|
|
|
|
session.perform_collect()
|
|
curdir = Path.cwd()
|
|
tw = _pytest.config.create_terminal_writer(config)
|
|
verbose = config.getvalue("verbose")
|
|
|
|
fm = session._fixturemanager
|
|
|
|
available = []
|
|
seen: Set[Tuple[str, str]] = set()
|
|
|
|
for argname, fixturedefs in fm._arg2fixturedefs.items():
|
|
assert fixturedefs is not None
|
|
if not fixturedefs:
|
|
continue
|
|
for fixturedef in fixturedefs:
|
|
loc = getlocation(fixturedef.func, str(curdir))
|
|
if (fixturedef.argname, loc) in seen:
|
|
continue
|
|
seen.add((fixturedef.argname, loc))
|
|
available.append(
|
|
(
|
|
len(fixturedef.baseid),
|
|
fixturedef.func.__module__,
|
|
_pretty_fixture_path(fixturedef.func),
|
|
fixturedef.argname,
|
|
fixturedef,
|
|
)
|
|
)
|
|
|
|
available.sort()
|
|
currentmodule = None
|
|
for baseid, module, prettypath, argname, fixturedef in available:
|
|
if currentmodule != module:
|
|
if not module.startswith("_pytest."):
|
|
tw.line()
|
|
tw.sep("-", f"fixtures defined from {module}")
|
|
currentmodule = module
|
|
if verbose <= 0 and argname.startswith("_"):
|
|
continue
|
|
tw.write(f"{argname}", green=True)
|
|
if fixturedef.scope != "function":
|
|
tw.write(" [%s scope]" % fixturedef.scope, cyan=True)
|
|
tw.write(f" -- {prettypath}", yellow=True)
|
|
tw.write("\n")
|
|
doc = inspect.getdoc(fixturedef.func)
|
|
if doc:
|
|
write_docstring(tw, doc.split("\n\n")[0] if verbose <= 0 else doc)
|
|
else:
|
|
tw.line(" no docstring available", red=True)
|
|
tw.line()
|
|
|
|
|
|
def write_docstring(tw: TerminalWriter, doc: str, indent: str = " ") -> None:
|
|
for line in doc.split("\n"):
|
|
tw.line(indent + line)
|
|
|
|
|
|
class Function(PyobjMixin, nodes.Item):
|
|
"""An Item responsible for setting up and executing a Python test function.
|
|
|
|
:param name:
|
|
The full function name, including any decorations like those
|
|
added by parametrization (``my_func[my_param]``).
|
|
:param parent:
|
|
The parent Node.
|
|
:param config:
|
|
The pytest Config object.
|
|
:param callspec:
|
|
If given, this is function has been parametrized and the callspec contains
|
|
meta information about the parametrization.
|
|
:param callobj:
|
|
If given, the object which will be called when the Function is invoked,
|
|
otherwise the callobj will be obtained from ``parent`` using ``originalname``.
|
|
:param keywords:
|
|
Keywords bound to the function object for "-k" matching.
|
|
:param session:
|
|
The pytest Session object.
|
|
:param fixtureinfo:
|
|
Fixture information already resolved at this fixture node..
|
|
:param originalname:
|
|
The attribute name to use for accessing the underlying function object.
|
|
Defaults to ``name``. Set this if name is different from the original name,
|
|
for example when it contains decorations like those added by parametrization
|
|
(``my_func[my_param]``).
|
|
"""
|
|
|
|
# Disable since functions handle it themselves.
|
|
_ALLOW_MARKERS = False
|
|
|
|
def __init__(
|
|
self,
|
|
name: str,
|
|
parent,
|
|
config: Optional[Config] = None,
|
|
callspec: Optional[CallSpec2] = None,
|
|
callobj=NOTSET,
|
|
keywords: Optional[Mapping[str, Any]] = None,
|
|
session: Optional[Session] = None,
|
|
fixtureinfo: Optional[FuncFixtureInfo] = None,
|
|
originalname: Optional[str] = None,
|
|
) -> None:
|
|
super().__init__(name, parent, config=config, session=session)
|
|
|
|
if callobj is not NOTSET:
|
|
self.obj = callobj
|
|
|
|
#: Original function name, without any decorations (for example
|
|
#: parametrization adds a ``"[...]"`` suffix to function names), used to access
|
|
#: the underlying function object from ``parent`` (in case ``callobj`` is not given
|
|
#: explicitly).
|
|
#:
|
|
#: .. versionadded:: 3.0
|
|
self.originalname = originalname or name
|
|
|
|
# Note: when FunctionDefinition is introduced, we should change ``originalname``
|
|
# to a readonly property that returns FunctionDefinition.name.
|
|
|
|
self.own_markers.extend(get_unpacked_marks(self.obj))
|
|
if callspec:
|
|
self.callspec = callspec
|
|
self.own_markers.extend(callspec.marks)
|
|
|
|
# todo: this is a hell of a hack
|
|
# https://github.com/pytest-dev/pytest/issues/4569
|
|
# Note: the order of the updates is important here; indicates what
|
|
# takes priority (ctor argument over function attributes over markers).
|
|
# Take own_markers only; NodeKeywords handles parent traversal on its own.
|
|
self.keywords.update((mark.name, mark) for mark in self.own_markers)
|
|
self.keywords.update(self.obj.__dict__)
|
|
if keywords:
|
|
self.keywords.update(keywords)
|
|
|
|
if fixtureinfo is None:
|
|
fixtureinfo = self.session._fixturemanager.getfixtureinfo(
|
|
self, self.obj, self.cls, funcargs=True
|
|
)
|
|
self._fixtureinfo: FuncFixtureInfo = fixtureinfo
|
|
self.fixturenames = fixtureinfo.names_closure
|
|
self._initrequest()
|
|
|
|
@classmethod
|
|
def from_parent(cls, parent, **kw): # todo: determine sound type limitations
|
|
"""The public constructor."""
|
|
return super().from_parent(parent=parent, **kw)
|
|
|
|
def _initrequest(self) -> None:
|
|
self.funcargs: Dict[str, object] = {}
|
|
self._request = fixtures.FixtureRequest(self, _ispytest=True)
|
|
|
|
@property
|
|
def function(self):
|
|
"""Underlying python 'function' object."""
|
|
return getimfunc(self.obj)
|
|
|
|
def _getobj(self):
|
|
assert self.parent is not None
|
|
if isinstance(self.parent, Class):
|
|
# Each Function gets a fresh class instance.
|
|
parent_obj = self.parent.newinstance()
|
|
else:
|
|
parent_obj = self.parent.obj # type: ignore[attr-defined]
|
|
return getattr(parent_obj, self.originalname)
|
|
|
|
@property
|
|
def _pyfuncitem(self):
|
|
"""(compatonly) for code expecting pytest-2.2 style request objects."""
|
|
return self
|
|
|
|
def runtest(self) -> None:
|
|
"""Execute the underlying test function."""
|
|
self.ihook.pytest_pyfunc_call(pyfuncitem=self)
|
|
|
|
def setup(self) -> None:
|
|
self._request._fillfixtures()
|
|
|
|
def _traceback_filter(self, excinfo: ExceptionInfo[BaseException]) -> Traceback:
|
|
if hasattr(self, "_obj") and not self.config.getoption("fulltrace", False):
|
|
code = _pytest._code.Code.from_function(get_real_func(self.obj))
|
|
path, firstlineno = code.path, code.firstlineno
|
|
traceback = excinfo.traceback
|
|
ntraceback = traceback.cut(path=path, firstlineno=firstlineno)
|
|
if ntraceback == traceback:
|
|
ntraceback = ntraceback.cut(path=path)
|
|
if ntraceback == traceback:
|
|
ntraceback = ntraceback.filter(filter_traceback)
|
|
if not ntraceback:
|
|
ntraceback = traceback
|
|
ntraceback = ntraceback.filter(excinfo)
|
|
|
|
# issue364: mark all but first and last frames to
|
|
# only show a single-line message for each frame.
|
|
if self.config.getoption("tbstyle", "auto") == "auto":
|
|
if len(ntraceback) > 2:
|
|
ntraceback = Traceback(
|
|
entry
|
|
if i == 0 or i == len(ntraceback) - 1
|
|
else entry.with_repr_style("short")
|
|
for i, entry in enumerate(ntraceback)
|
|
)
|
|
|
|
return ntraceback
|
|
return excinfo.traceback
|
|
|
|
# TODO: Type ignored -- breaks Liskov Substitution.
|
|
def repr_failure( # type: ignore[override]
|
|
self,
|
|
excinfo: ExceptionInfo[BaseException],
|
|
) -> Union[str, TerminalRepr]:
|
|
style = self.config.getoption("tbstyle", "auto")
|
|
if style == "auto":
|
|
style = "long"
|
|
return self._repr_failure_py(excinfo, style=style)
|
|
|
|
|
|
class FunctionDefinition(Function):
|
|
"""
|
|
This class is a step gap solution until we evolve to have actual function definition nodes
|
|
and manage to get rid of ``metafunc``.
|
|
"""
|
|
|
|
def runtest(self) -> None:
|
|
raise RuntimeError("function definitions are not supposed to be run as tests")
|
|
|
|
setup = runtest
|