|
|
|
|
|
"""Python test discovery, setup and run of test functions.""" |
|
|
|
|
|
from __future__ import annotations |
|
|
|
|
|
import abc |
|
|
from collections import Counter |
|
|
from collections import defaultdict |
|
|
import dataclasses |
|
|
import enum |
|
|
import fnmatch |
|
|
from functools import partial |
|
|
import inspect |
|
|
import itertools |
|
|
import os |
|
|
from pathlib import Path |
|
|
import types |
|
|
from typing import Any |
|
|
from typing import Callable |
|
|
from typing import Dict |
|
|
from typing import final |
|
|
from typing import Generator |
|
|
from typing import Iterable |
|
|
from typing import Iterator |
|
|
from typing import Literal |
|
|
from typing import Mapping |
|
|
from typing import Pattern |
|
|
from typing import Sequence |
|
|
from typing import TYPE_CHECKING |
|
|
import warnings |
|
|
|
|
|
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.saferepr import saferepr |
|
|
from _pytest.compat import ascii_escaped |
|
|
from _pytest.compat import get_default_arg_names |
|
|
from _pytest.compat import get_real_func |
|
|
from _pytest.compat import getimfunc |
|
|
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.config import Config |
|
|
from _pytest.config import hookimpl |
|
|
from _pytest.config.argparsing import Parser |
|
|
from _pytest.deprecated import check_ispytest |
|
|
from _pytest.fixtures import FixtureDef |
|
|
from _pytest.fixtures import FixtureRequest |
|
|
from _pytest.fixtures import FuncFixtureInfo |
|
|
from _pytest.fixtures import get_scope_node |
|
|
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 fnmatch_ex |
|
|
from _pytest.pathlib import import_path |
|
|
from _pytest.pathlib import ImportPathMismatchError |
|
|
from _pytest.pathlib import scandir |
|
|
from _pytest.scope import _ScopeName |
|
|
from _pytest.scope import Scope |
|
|
from _pytest.stash import StashKey |
|
|
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 Self |
|
|
|
|
|
|
|
|
def pytest_addoption(parser: Parser) -> None: |
|
|
parser.addini( |
|
|
"python_files", |
|
|
type="args", |
|
|
|
|
|
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_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) -> object | None: |
|
|
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_directory( |
|
|
path: Path, parent: nodes.Collector |
|
|
) -> nodes.Collector | None: |
|
|
pkginit = path / "__init__.py" |
|
|
try: |
|
|
has_pkginit = pkginit.is_file() |
|
|
except PermissionError: |
|
|
|
|
|
return None |
|
|
if has_pkginit: |
|
|
return Package.from_parent(parent, path=path) |
|
|
return None |
|
|
|
|
|
|
|
|
def pytest_collect_file(file_path: Path, parent: nodes.Collector) -> Module | None: |
|
|
if file_path.suffix == ".py": |
|
|
if not parent.session.isinitpath(file_path): |
|
|
if not path_matches_patterns( |
|
|
file_path, parent.config.getini("python_files") |
|
|
): |
|
|
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: |
|
|
return Module.from_parent(parent, path=module_path) |
|
|
|
|
|
|
|
|
@hookimpl(trylast=True) |
|
|
def pytest_pycollect_makeitem( |
|
|
collector: Module | Class, name: str, obj: object |
|
|
) -> None | nodes.Item | nodes.Collector | list[nodes.Item | nodes.Collector]: |
|
|
assert isinstance(collector, (Class, Module)), type(collector) |
|
|
|
|
|
if safe_isclass(obj): |
|
|
if collector.istestclass(obj, name): |
|
|
return Class.from_parent(collector, name=name, obj=obj) |
|
|
elif collector.istestfunction(obj, name): |
|
|
|
|
|
obj = getattr(obj, "__func__", obj) |
|
|
|
|
|
|
|
|
|
|
|
if not (inspect.isfunction(obj) or inspect.isfunction(get_real_func(obj))): |
|
|
filename, lineno = getfslineno(obj) |
|
|
warnings.warn_explicit( |
|
|
message=PytestCollectionWarning( |
|
|
f"cannot collect {name!r} because it is not a function." |
|
|
), |
|
|
category=None, |
|
|
filename=str(filename), |
|
|
lineno=lineno + 1, |
|
|
) |
|
|
elif getattr(obj, "__test__", True): |
|
|
if is_generator(obj): |
|
|
res = Function.from_parent(collector, name=name) |
|
|
reason = ( |
|
|
f"yield tests were removed in pytest 4.0 - {name} will be ignored" |
|
|
) |
|
|
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 class or a module. |
|
|
""" |
|
|
|
|
|
return None |
|
|
|
|
|
@property |
|
|
def obj(self): |
|
|
"""Underlying Python object.""" |
|
|
obj = getattr(self, "_obj", None) |
|
|
if obj is None: |
|
|
self._obj = obj = self._getobj() |
|
|
|
|
|
|
|
|
if self._ALLOW_MARKERS: |
|
|
self.own_markers.extend(get_unpacked_marks(self.obj)) |
|
|
|
|
|
|
|
|
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.""" |
|
|
|
|
|
assert self.parent is not None |
|
|
obj = self.parent.obj |
|
|
return getattr(obj, self.name) |
|
|
|
|
|
def getmodpath(self, stopatmodule: bool = True, includemodule: bool = False) -> str: |
|
|
"""Return Python path relative to the containing module.""" |
|
|
parts = [] |
|
|
for node in self.iter_parents(): |
|
|
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[os.PathLike[str] | str, int | None, str]: |
|
|
|
|
|
path, lineno = getfslineno(self.obj) |
|
|
modpath = self.getmodpath() |
|
|
return path, lineno, modpath |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _EmptyClass: pass |
|
|
IGNORED_ATTRIBUTES = frozenset.union( |
|
|
frozenset(), |
|
|
|
|
|
dir(types.ModuleType("empty_module")), |
|
|
|
|
|
{"__builtins__", "__file__", "__cached__"}, |
|
|
|
|
|
dir(_EmptyClass), |
|
|
|
|
|
dir(_EmptyClass()), |
|
|
) |
|
|
del _EmptyClass |
|
|
|
|
|
|
|
|
|
|
|
class PyCollector(PyobjMixin, nodes.Collector, abc.ABC): |
|
|
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. |
|
|
""" |
|
|
|
|
|
|
|
|
|
|
|
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)): |
|
|
|
|
|
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: |
|
|
if not (self.classnamefilter(name) or self.isnosetest(obj)): |
|
|
return False |
|
|
if inspect.isabstract(obj): |
|
|
return False |
|
|
return True |
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
elif ("*" in option or "?" in option or "[" in option) and fnmatch.fnmatch( |
|
|
name, option |
|
|
): |
|
|
return True |
|
|
return False |
|
|
|
|
|
def collect(self) -> Iterable[nodes.Item | nodes.Collector]: |
|
|
if not getattr(self.obj, "__test__", True): |
|
|
return [] |
|
|
|
|
|
|
|
|
dicts = [getattr(self.obj, "__dict__", {})] |
|
|
if isinstance(self.obj, type): |
|
|
for basecls in self.obj.__mro__: |
|
|
dicts.append(basecls.__dict__) |
|
|
|
|
|
|
|
|
|
|
|
seen: set[str] = set() |
|
|
dict_values: list[list[nodes.Item | nodes.Collector]] = [] |
|
|
ihook = self.ihook |
|
|
for dic in dicts: |
|
|
values: list[nodes.Item | nodes.Collector] = [] |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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, |
|
|
) |
|
|
|
|
|
|
|
|
def importtestmodule( |
|
|
path: Path, |
|
|
config: Config, |
|
|
): |
|
|
|
|
|
importmode = config.getoption("--import-mode") |
|
|
try: |
|
|
mod = import_path( |
|
|
path, |
|
|
mode=importmode, |
|
|
root=config.rootpath, |
|
|
consider_namespace_packages=config.getini("consider_namespace_packages"), |
|
|
) |
|
|
except SyntaxError as e: |
|
|
raise nodes.Collector.CollectError( |
|
|
ExceptionInfo.from_current().getrepr(style="short") |
|
|
) from e |
|
|
except ImportPathMismatchError as e: |
|
|
raise nodes.Collector.CollectError( |
|
|
"import file mismatch:\n" |
|
|
"imported module {!r} has this __file__ attribute:\n" |
|
|
" {}\n" |
|
|
"which is not the same as the test file we want to collect:\n" |
|
|
" {}\n" |
|
|
"HINT: remove __pycache__ / .pyc files and/or use a " |
|
|
"unique basename for your test file modules".format(*e.args) |
|
|
) from e |
|
|
except ImportError as e: |
|
|
exc_info = ExceptionInfo.from_current() |
|
|
if config.get_verbosity() < 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 nodes.Collector.CollectError( |
|
|
f"ImportError while importing test module '{path}'.\n" |
|
|
"Hint: make sure your test modules/packages have valid Python names.\n" |
|
|
"Traceback:\n" |
|
|
f"{formatted_tb}" |
|
|
) from e |
|
|
except skip.Exception as e: |
|
|
if e.allow_module_level: |
|
|
raise |
|
|
raise nodes.Collector.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 |
|
|
config.pluginmanager.consider_module(mod) |
|
|
return mod |
|
|
|
|
|
|
|
|
class Module(nodes.File, PyCollector): |
|
|
"""Collector for test classes and functions in a Python module.""" |
|
|
|
|
|
def _getobj(self): |
|
|
return importtestmodule(self.path, self.config) |
|
|
|
|
|
def collect(self) -> Iterable[nodes.Item | nodes.Collector]: |
|
|
self._register_setup_module_fixture() |
|
|
self._register_setup_function_fixture() |
|
|
self.session._fixturemanager.parsefactories(self) |
|
|
return super().collect() |
|
|
|
|
|
def _register_setup_module_fixture(self) -> None: |
|
|
"""Register an autouse, module-scoped fixture for 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). |
|
|
""" |
|
|
setup_module = _get_first_non_fixture_func( |
|
|
self.obj, ("setUpModule", "setup_module") |
|
|
) |
|
|
teardown_module = _get_first_non_fixture_func( |
|
|
self.obj, ("tearDownModule", "teardown_module") |
|
|
) |
|
|
|
|
|
if setup_module is None and teardown_module is None: |
|
|
return |
|
|
|
|
|
def xunit_setup_module_fixture(request) -> Generator[None]: |
|
|
module = request.module |
|
|
if setup_module is not None: |
|
|
_call_with_optional_argument(setup_module, module) |
|
|
yield |
|
|
if teardown_module is not None: |
|
|
_call_with_optional_argument(teardown_module, module) |
|
|
|
|
|
self.session._fixturemanager._register_fixture( |
|
|
|
|
|
name=f"_xunit_setup_module_fixture_{self.obj.__name__}", |
|
|
func=xunit_setup_module_fixture, |
|
|
nodeid=self.nodeid, |
|
|
scope="module", |
|
|
autouse=True, |
|
|
) |
|
|
|
|
|
def _register_setup_function_fixture(self) -> None: |
|
|
"""Register an autouse, function-scoped fixture for 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 |
|
|
|
|
|
def xunit_setup_function_fixture(request) -> Generator[None]: |
|
|
if request.instance is not None: |
|
|
|
|
|
|
|
|
yield |
|
|
return |
|
|
function = request.function |
|
|
if setup_function is not None: |
|
|
_call_with_optional_argument(setup_function, function) |
|
|
yield |
|
|
if teardown_function is not None: |
|
|
_call_with_optional_argument(teardown_function, function) |
|
|
|
|
|
self.session._fixturemanager._register_fixture( |
|
|
|
|
|
name=f"_xunit_setup_function_fixture_{self.obj.__name__}", |
|
|
func=xunit_setup_function_fixture, |
|
|
nodeid=self.nodeid, |
|
|
scope="function", |
|
|
autouse=True, |
|
|
) |
|
|
|
|
|
|
|
|
class Package(nodes.Directory): |
|
|
"""Collector for files and directories in a Python packages -- directories |
|
|
with an `__init__.py` file. |
|
|
|
|
|
.. note:: |
|
|
|
|
|
Directories without an `__init__.py` file are instead collected by |
|
|
:class:`~pytest.Dir` by default. Both are :class:`~pytest.Directory` |
|
|
collectors. |
|
|
|
|
|
.. versionchanged:: 8.0 |
|
|
|
|
|
Now inherits from :class:`~pytest.Directory`. |
|
|
""" |
|
|
|
|
|
def __init__( |
|
|
self, |
|
|
fspath: LEGACY_PATH | None, |
|
|
parent: nodes.Collector, |
|
|
|
|
|
config=None, |
|
|
session=None, |
|
|
nodeid=None, |
|
|
path: Path | None = None, |
|
|
) -> None: |
|
|
|
|
|
|
|
|
session = parent.session |
|
|
super().__init__( |
|
|
fspath=fspath, |
|
|
path=path, |
|
|
parent=parent, |
|
|
config=config, |
|
|
session=session, |
|
|
nodeid=nodeid, |
|
|
) |
|
|
|
|
|
def setup(self) -> None: |
|
|
init_mod = importtestmodule(self.path / "__init__.py", self.config) |
|
|
|
|
|
|
|
|
|
|
|
setup_module = _get_first_non_fixture_func( |
|
|
init_mod, ("setUpModule", "setup_module") |
|
|
) |
|
|
if setup_module is not None: |
|
|
_call_with_optional_argument(setup_module, init_mod) |
|
|
|
|
|
teardown_module = _get_first_non_fixture_func( |
|
|
init_mod, ("tearDownModule", "teardown_module") |
|
|
) |
|
|
if teardown_module is not None: |
|
|
func = partial(_call_with_optional_argument, teardown_module, init_mod) |
|
|
self.addfinalizer(func) |
|
|
|
|
|
def collect(self) -> Iterable[nodes.Item | nodes.Collector]: |
|
|
|
|
|
def sort_key(entry: os.DirEntry[str]) -> object: |
|
|
return (entry.name != "__init__.py", entry.name) |
|
|
|
|
|
config = self.config |
|
|
col: nodes.Collector | None |
|
|
cols: Sequence[nodes.Collector] |
|
|
ihook = self.ihook |
|
|
for direntry in scandir(self.path, sort_key): |
|
|
if direntry.is_dir(): |
|
|
path = Path(direntry.path) |
|
|
if not self.session.isinitpath(path, with_parents=True): |
|
|
if ihook.pytest_ignore_collect(collection_path=path, config=config): |
|
|
continue |
|
|
col = ihook.pytest_collect_directory(path=path, parent=self) |
|
|
if col is not None: |
|
|
yield col |
|
|
|
|
|
elif direntry.is_file(): |
|
|
path = Path(direntry.path) |
|
|
if not self.session.isinitpath(path): |
|
|
if ihook.pytest_ignore_collect(collection_path=path, config=config): |
|
|
continue |
|
|
cols = ihook.pytest_collect_file(file_path=path, parent=self) |
|
|
yield from cols |
|
|
|
|
|
|
|
|
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]) -> object | None: |
|
|
"""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: object | None = 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 (and nested classes) in a Python class.""" |
|
|
|
|
|
@classmethod |
|
|
def from_parent(cls, parent, *, name, obj=None, **kw) -> Self: |
|
|
"""The public constructor.""" |
|
|
return super().from_parent(name=name, parent=parent, **kw) |
|
|
|
|
|
def newinstance(self): |
|
|
return self.obj() |
|
|
|
|
|
def collect(self) -> Iterable[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( |
|
|
f"cannot collect test class {self.obj.__name__!r} because it has a " |
|
|
f"__init__ constructor (from: {self.parent.nodeid})" |
|
|
) |
|
|
) |
|
|
return [] |
|
|
elif hasnew(self.obj): |
|
|
assert self.parent is not None |
|
|
self.warn( |
|
|
PytestCollectionWarning( |
|
|
f"cannot collect test class {self.obj.__name__!r} because it has a " |
|
|
f"__new__ constructor (from: {self.parent.nodeid})" |
|
|
) |
|
|
) |
|
|
return [] |
|
|
|
|
|
self._register_setup_class_fixture() |
|
|
self._register_setup_method_fixture() |
|
|
|
|
|
self.session._fixturemanager.parsefactories(self.newinstance(), self.nodeid) |
|
|
|
|
|
return super().collect() |
|
|
|
|
|
def _register_setup_class_fixture(self) -> None: |
|
|
"""Register an 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 |
|
|
|
|
|
def xunit_setup_class_fixture(request) -> Generator[None]: |
|
|
cls = request.cls |
|
|
if setup_class is not None: |
|
|
func = getimfunc(setup_class) |
|
|
_call_with_optional_argument(func, cls) |
|
|
yield |
|
|
if teardown_class is not None: |
|
|
func = getimfunc(teardown_class) |
|
|
_call_with_optional_argument(func, cls) |
|
|
|
|
|
self.session._fixturemanager._register_fixture( |
|
|
|
|
|
name=f"_xunit_setup_class_fixture_{self.obj.__qualname__}", |
|
|
func=xunit_setup_class_fixture, |
|
|
nodeid=self.nodeid, |
|
|
scope="class", |
|
|
autouse=True, |
|
|
) |
|
|
|
|
|
def _register_setup_method_fixture(self) -> None: |
|
|
"""Register an 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). |
|
|
""" |
|
|
setup_name = "setup_method" |
|
|
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,)) |
|
|
if setup_method is None and teardown_method is None: |
|
|
return |
|
|
|
|
|
def xunit_setup_method_fixture(request) -> Generator[None]: |
|
|
instance = request.instance |
|
|
method = request.function |
|
|
if setup_method is not None: |
|
|
func = getattr(instance, setup_name) |
|
|
_call_with_optional_argument(func, method) |
|
|
yield |
|
|
if teardown_method is not None: |
|
|
func = getattr(instance, teardown_name) |
|
|
_call_with_optional_argument(func, method) |
|
|
|
|
|
self.session._fixturemanager._register_fixture( |
|
|
|
|
|
name=f"_xunit_setup_method_fixture_{self.obj.__qualname__}", |
|
|
func=xunit_setup_method_fixture, |
|
|
nodeid=self.nodeid, |
|
|
scope="function", |
|
|
autouse=True, |
|
|
) |
|
|
|
|
|
|
|
|
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", |
|
|
) |
|
|
|
|
|
|
|
|
argnames: Sequence[str] |
|
|
|
|
|
parametersets: Sequence[ParameterSet] |
|
|
|
|
|
|
|
|
idfn: Callable[[Any], object | None] | None |
|
|
|
|
|
ids: Sequence[object | None] | None |
|
|
|
|
|
|
|
|
|
|
|
config: Config | None |
|
|
|
|
|
|
|
|
nodeid: str | None |
|
|
|
|
|
|
|
|
func_name: str | None |
|
|
|
|
|
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()) |
|
|
|
|
|
if len(resolved_ids) != len(set(resolved_ids)): |
|
|
|
|
|
id_counts = Counter(resolved_ids) |
|
|
|
|
|
id_suffixes: dict[str, int] = defaultdict(int) |
|
|
|
|
|
for index, id in enumerate(resolved_ids): |
|
|
if id_counts[id] > 1: |
|
|
suffix = "" |
|
|
if id and id[-1].isdigit(): |
|
|
suffix = "_" |
|
|
new_id = f"{id}{suffix}{id_suffixes[id]}" |
|
|
while new_id in set(resolved_ids): |
|
|
id_suffixes[id] += 1 |
|
|
new_id = f"{id}{suffix}{id_suffixes[id]}" |
|
|
resolved_ids[index] = new_id |
|
|
id_suffixes[id] += 1 |
|
|
assert len(resolved_ids) == len( |
|
|
set(resolved_ids) |
|
|
), f"Internal error: {resolved_ids=}" |
|
|
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: |
|
|
|
|
|
yield parameterset.id |
|
|
elif self.ids and idx < len(self.ids) and self.ids[idx] is not None: |
|
|
|
|
|
yield self._idval_from_value_required(self.ids[idx], idx) |
|
|
else: |
|
|
|
|
|
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) -> str | None: |
|
|
"""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) -> str | None: |
|
|
"""Try to make an ID for a parameter in a ParameterSet by calling the |
|
|
:hook:`pytest_make_parametrize_id` hook.""" |
|
|
if self.config: |
|
|
id: str | None = 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) -> str | None: |
|
|
"""Try to make an ID for a parameter in a ParameterSet from its value, |
|
|
if the value type is supported.""" |
|
|
if isinstance(val, (str, bytes)): |
|
|
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: |
|
|
|
|
|
pass |
|
|
elif isinstance(val, enum.Enum): |
|
|
return str(val) |
|
|
elif isinstance(getattr(val, "__name__", None), str): |
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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. |
|
|
""" |
|
|
|
|
|
|
|
|
|
|
|
params: dict[str, object] = dataclasses.field(default_factory=dict) |
|
|
|
|
|
indices: dict[str, int] = dataclasses.field(default_factory=dict) |
|
|
|
|
|
_arg2scope: Mapping[str, Scope] = dataclasses.field(default_factory=dict) |
|
|
|
|
|
_idlist: Sequence[str] = dataclasses.field(default_factory=tuple) |
|
|
|
|
|
marks: list[Mark] = dataclasses.field(default_factory=list) |
|
|
|
|
|
def setmulti( |
|
|
self, |
|
|
*, |
|
|
argnames: Iterable[str], |
|
|
valset: Iterable[object], |
|
|
id: str, |
|
|
marks: Iterable[Mark | MarkDecorator], |
|
|
scope: Scope, |
|
|
param_index: int, |
|
|
) -> CallSpec2: |
|
|
params = self.params.copy() |
|
|
indices = self.indices.copy() |
|
|
arg2scope = dict(self._arg2scope) |
|
|
for arg, val in zip(argnames, valset): |
|
|
if arg in params: |
|
|
raise ValueError(f"duplicate parametrization of {arg!r}") |
|
|
params[arg] = val |
|
|
indices[arg] = param_index |
|
|
arg2scope[arg] = scope |
|
|
return CallSpec2( |
|
|
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) |
|
|
|
|
|
|
|
|
def get_direct_param_fixture_func(request: FixtureRequest) -> Any: |
|
|
return request.param |
|
|
|
|
|
|
|
|
|
|
|
name2pseudofixturedef_key = StashKey[Dict[str, FixtureDef[Any]]]() |
|
|
|
|
|
|
|
|
@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) |
|
|
|
|
|
|
|
|
self.definition = definition |
|
|
|
|
|
|
|
|
self.config = config |
|
|
|
|
|
|
|
|
self.module = module |
|
|
|
|
|
|
|
|
self.function = definition.obj |
|
|
|
|
|
|
|
|
self.fixturenames = fixtureinfo.names_closure |
|
|
|
|
|
|
|
|
self.cls = cls |
|
|
|
|
|
self._arg2fixturedefs = fixtureinfo.name2fixturedefs |
|
|
|
|
|
|
|
|
self._calls: list[CallSpec2] = [] |
|
|
|
|
|
def parametrize( |
|
|
self, |
|
|
argnames: str | Sequence[str], |
|
|
argvalues: Iterable[ParameterSet | Sequence[object] | object], |
|
|
indirect: bool | Sequence[str] = False, |
|
|
ids: Iterable[object | None] | Callable[[Any], object | None] | None = None, |
|
|
scope: _ScopeName | None = None, |
|
|
*, |
|
|
_param_mark: Mark | None = 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 per test function (but only on different |
|
|
argument names), 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. |
|
|
:type argvalues: Iterable[_pytest.mark.structures.ParameterSet | Sequence[object] | object] |
|
|
: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) |
|
|
|
|
|
|
|
|
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 |
|
|
) |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
node = None |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if scope_ is not Scope.Function: |
|
|
collector = self.definition.parent |
|
|
assert collector is not None |
|
|
node = get_scope_node(collector, scope_) |
|
|
if node is None: |
|
|
|
|
|
|
|
|
if scope_ is Scope.Class: |
|
|
assert isinstance(collector, Module) |
|
|
node = collector |
|
|
|
|
|
|
|
|
elif scope_ is Scope.Package: |
|
|
node = collector.session |
|
|
else: |
|
|
assert False, f"Unhandled missing scope: {scope}" |
|
|
if node is None: |
|
|
name2pseudofixturedef = None |
|
|
else: |
|
|
default: dict[str, FixtureDef[Any]] = {} |
|
|
name2pseudofixturedef = node.stash.setdefault( |
|
|
name2pseudofixturedef_key, default |
|
|
) |
|
|
arg_directness = self._resolve_args_directness(argnames, indirect) |
|
|
for argname in argnames: |
|
|
if arg_directness[argname] == "indirect": |
|
|
continue |
|
|
if name2pseudofixturedef is not None and argname in name2pseudofixturedef: |
|
|
fixturedef = name2pseudofixturedef[argname] |
|
|
else: |
|
|
fixturedef = FixtureDef( |
|
|
config=self.config, |
|
|
baseid="", |
|
|
argname=argname, |
|
|
func=get_direct_param_fixture_func, |
|
|
scope=scope_, |
|
|
params=None, |
|
|
ids=None, |
|
|
_ispytest=True, |
|
|
) |
|
|
if name2pseudofixturedef is not None: |
|
|
name2pseudofixturedef[argname] = fixturedef |
|
|
self._arg2fixturedefs[argname] = [fixturedef] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
newcalls = [] |
|
|
for callspec in self._calls or [CallSpec2()]: |
|
|
for param_index, (param_id, param_set) in enumerate( |
|
|
zip(ids, parametersets) |
|
|
): |
|
|
newcallspec = callspec.setmulti( |
|
|
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: Iterable[object | None] | Callable[[Any], object | None] | None, |
|
|
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[object | None], |
|
|
parametersets: Sequence[ParameterSet], |
|
|
func_name: str, |
|
|
) -> list[object | None]: |
|
|
try: |
|
|
num_ids = len(ids) |
|
|
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) |
|
|
|
|
|
|
|
|
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_args_directness( |
|
|
self, |
|
|
argnames: Sequence[str], |
|
|
indirect: bool | Sequence[str], |
|
|
) -> dict[str, Literal["indirect", "direct"]]: |
|
|
"""Resolve if each parametrized argument must be considered an indirect |
|
|
parameter to a fixture of the same name, or a direct parameter to the |
|
|
parametrized function, based on the ``indirect`` parameter of the |
|
|
parametrized() call. |
|
|
|
|
|
:param argnames: |
|
|
List of argument names passed to ``parametrize()``. |
|
|
:param indirect: |
|
|
Same as the ``indirect`` parameter of ``parametrize()``. |
|
|
:returns |
|
|
A dict mapping each arg name to either "indirect" or "direct". |
|
|
""" |
|
|
arg_directness: dict[str, Literal["indirect", "direct"]] |
|
|
if isinstance(indirect, bool): |
|
|
arg_directness = dict.fromkeys( |
|
|
argnames, "indirect" if indirect else "direct" |
|
|
) |
|
|
elif isinstance(indirect, Sequence): |
|
|
arg_directness = dict.fromkeys(argnames, "direct") |
|
|
for arg in indirect: |
|
|
if arg not in argnames: |
|
|
fail( |
|
|
f"In {self.function.__name__}: indirect fixture '{arg}' doesn't exist", |
|
|
pytrace=False, |
|
|
) |
|
|
arg_directness[arg] = "indirect" |
|
|
else: |
|
|
fail( |
|
|
f"In {self.function.__name__}: expected Sequence or boolean" |
|
|
f" for indirect, got {type(indirect).__name__}", |
|
|
pytrace=False, |
|
|
) |
|
|
return arg_directness |
|
|
|
|
|
def _validate_if_using_arg_names( |
|
|
self, |
|
|
argnames: Sequence[str], |
|
|
indirect: 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( |
|
|
f"In {func_name}: function already takes an argument '{arg}' with a default value", |
|
|
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: 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[-1]._scope |
|
|
for name, fixturedef in fixturedefs.items() |
|
|
if name in argnames |
|
|
] |
|
|
|
|
|
return min(used_scopes, default=Scope.Function) |
|
|
|
|
|
return Scope.Function |
|
|
|
|
|
|
|
|
def _ascii_escaped_by_config(val: str | bytes, config: Config | None) -> str: |
|
|
if config is None: |
|
|
escape_option = False |
|
|
else: |
|
|
escape_option = config.getini( |
|
|
"disable_test_id_escaping_and_forfeit_all_rights_to_community_support" |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
return val if escape_option else ascii_escaped(val) |
|
|
|
|
|
|
|
|
class Function(PyobjMixin, nodes.Item): |
|
|
"""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 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]``). |
|
|
""" |
|
|
|
|
|
|
|
|
_ALLOW_MARKERS = False |
|
|
|
|
|
def __init__( |
|
|
self, |
|
|
name: str, |
|
|
parent, |
|
|
config: Config | None = None, |
|
|
callspec: CallSpec2 | None = None, |
|
|
callobj=NOTSET, |
|
|
keywords: Mapping[str, Any] | None = None, |
|
|
session: Session | None = None, |
|
|
fixtureinfo: FuncFixtureInfo | None = None, |
|
|
originalname: str | None = None, |
|
|
) -> None: |
|
|
super().__init__(name, parent, config=config, session=session) |
|
|
|
|
|
if callobj is not NOTSET: |
|
|
self._obj = callobj |
|
|
self._instance = getattr(callobj, "__self__", None) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
self.originalname = originalname or name |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
self.own_markers.extend(get_unpacked_marks(self.obj)) |
|
|
if callspec: |
|
|
self.callspec = callspec |
|
|
self.own_markers.extend(callspec.marks) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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: |
|
|
fm = self.session._fixturemanager |
|
|
fixtureinfo = fm.getfixtureinfo(self, self.obj, self.cls) |
|
|
self._fixtureinfo: FuncFixtureInfo = fixtureinfo |
|
|
self.fixturenames = fixtureinfo.names_closure |
|
|
self._initrequest() |
|
|
|
|
|
|
|
|
@classmethod |
|
|
def from_parent(cls, parent, **kw) -> Self: |
|
|
"""The public constructor.""" |
|
|
return super().from_parent(parent=parent, **kw) |
|
|
|
|
|
def _initrequest(self) -> None: |
|
|
self.funcargs: dict[str, object] = {} |
|
|
self._request = fixtures.TopRequest(self, _ispytest=True) |
|
|
|
|
|
@property |
|
|
def function(self): |
|
|
"""Underlying python 'function' object.""" |
|
|
return getimfunc(self.obj) |
|
|
|
|
|
@property |
|
|
def instance(self): |
|
|
try: |
|
|
return self._instance |
|
|
except AttributeError: |
|
|
if isinstance(self.parent, Class): |
|
|
|
|
|
self._instance = self._getinstance() |
|
|
else: |
|
|
self._instance = None |
|
|
return self._instance |
|
|
|
|
|
def _getinstance(self): |
|
|
if isinstance(self.parent, Class): |
|
|
|
|
|
return self.parent.newinstance() |
|
|
else: |
|
|
return None |
|
|
|
|
|
def _getobj(self): |
|
|
instance = self.instance |
|
|
if instance is not None: |
|
|
parent_obj = instance |
|
|
else: |
|
|
assert self.parent is not None |
|
|
parent_obj = self.parent.obj |
|
|
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) |
|
|
|
|
|
|
|
|
|
|
|
if self.config.getoption("tbstyle", "auto") == "auto": |
|
|
if len(ntraceback) > 2: |
|
|
ntraceback = Traceback( |
|
|
( |
|
|
ntraceback[0], |
|
|
*(t.with_repr_style("short") for t in ntraceback[1:-1]), |
|
|
ntraceback[-1], |
|
|
) |
|
|
) |
|
|
|
|
|
return ntraceback |
|
|
return excinfo.traceback |
|
|
|
|
|
|
|
|
def repr_failure( |
|
|
self, |
|
|
excinfo: ExceptionInfo[BaseException], |
|
|
) -> 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 stop 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 |
|
|
|