fixtures: expand comments and annotations on fixture internals

This commit is contained in:
Ran Benita 2023-07-10 14:50:25 +03:00
parent ecfab4dc8b
commit 01f38aca44
5 changed files with 85 additions and 35 deletions

View File

@ -68,7 +68,7 @@ def is_async_function(func: object) -> bool:
return iscoroutinefunction(func) or inspect.isasyncgenfunction(func)
def getlocation(function, curdir: str | None = None) -> str:
def getlocation(function, curdir: str | os.PathLike[str] | None = None) -> str:
function = get_real_func(function)
fn = Path(inspect.getfile(function))
lineno = function.__code__.co_firstlineno

View File

@ -582,7 +582,7 @@ def _setup_fixtures(doctest_item: DoctestItem) -> FixtureRequest:
doctest_item._fixtureinfo = fm.getfixtureinfo( # type: ignore[attr-defined]
node=doctest_item, func=func, cls=None, funcargs=False
)
fixture_request = FixtureRequest(doctest_item, _ispytest=True)
fixture_request = FixtureRequest(doctest_item, _ispytest=True) # type: ignore[arg-type]
fixture_request._fillfixtures()
return fixture_request

View File

@ -13,6 +13,7 @@ from typing import Any
from typing import Callable
from typing import cast
from typing import Dict
from typing import Final
from typing import final
from typing import Generator
from typing import Generic
@ -73,6 +74,7 @@ if TYPE_CHECKING:
from _pytest.scope import _ScopeName
from _pytest.main import Session
from _pytest.python import CallSpec2
from _pytest.python import Function
from _pytest.python import Metafunc
@ -352,17 +354,24 @@ def get_direct_param_fixture_func(request: "FixtureRequest") -> Any:
return request.param
@dataclasses.dataclass
@dataclasses.dataclass(frozen=True)
class FuncFixtureInfo:
__slots__ = ("argnames", "initialnames", "names_closure", "name2fixturedefs")
# Original function argument names.
# Original function argument names, i.e. fixture names that the function
# requests directly.
argnames: Tuple[str, ...]
# Argnames that function immediately requires. These include argnames +
# fixture names specified via usefixtures and via autouse=True in fixture
# definitions.
# Fixture names that the function immediately requires. These include
# argnames + fixture names specified via usefixtures and via autouse=True in
# fixture definitions.
initialnames: Tuple[str, ...]
# The transitive closure of the fixture names that the function requires.
# Note: can't include dynamic dependencies (`request.getfixturevalue` calls).
names_closure: List[str]
# A map from a fixture name in the transitive closure to the FixtureDefs
# matching the name which are applicable to this function.
# There may be multiple overriding fixtures with the same name. The
# sequence is ordered from furthest to closes to the function.
name2fixturedefs: Dict[str, Sequence["FixtureDef[Any]"]]
def prune_dependency_tree(self) -> None:
@ -401,17 +410,31 @@ class FixtureRequest:
indirectly.
"""
def __init__(self, pyfuncitem, *, _ispytest: bool = False) -> None:
def __init__(self, pyfuncitem: "Function", *, _ispytest: bool = False) -> None:
check_ispytest(_ispytest)
self._pyfuncitem = pyfuncitem
#: Fixture for which this request is being performed.
self.fixturename: Optional[str] = None
self._pyfuncitem = pyfuncitem
self._fixturemanager = pyfuncitem.session._fixturemanager
self._scope = Scope.Function
self._fixture_defs: Dict[str, FixtureDef[Any]] = {}
fixtureinfo: FuncFixtureInfo = pyfuncitem._fixtureinfo
self._arg2fixturedefs = fixtureinfo.name2fixturedefs.copy()
# The FixtureDefs for each fixture name requested by this item.
# Starts from the statically-known fixturedefs resolved during
# collection. Dynamically requested fixtures (using
# `request.getfixturevalue("foo")`) are added dynamically.
self._arg2fixturedefs = pyfuncitem._fixtureinfo.name2fixturedefs.copy()
# A fixture may override another fixture with the same name, e.g. a fixture
# in a module can override a fixture in a conftest, a fixture in a class can
# override a fixture in the module, and so on.
# An overriding fixture can request its own name; in this case it gets
# the value of the fixture it overrides, one level up.
# The _arg2index state keeps the current depth in the overriding chain.
# The fixturedefs list in _arg2fixturedefs for a given name is ordered from
# furthest to closest, so we use negative indexing -1, -2, ... to go from
# last to first.
self._arg2index: Dict[str, int] = {}
self._fixturemanager: FixtureManager = pyfuncitem.session._fixturemanager
# The evaluated argnames so far, mapping to the FixtureDef they resolved
# to.
self._fixture_defs: Dict[str, FixtureDef[Any]] = {}
# Notes on the type of `param`:
# -`request.param` is only defined in parametrized fixtures, and will raise
# AttributeError otherwise. Python typing has no notion of "undefined", so
@ -466,10 +489,14 @@ class FixtureRequest:
fixturedefs = self._fixturemanager.getfixturedefs(argname, parentid)
if fixturedefs is not None:
self._arg2fixturedefs[argname] = fixturedefs
# No fixtures defined with this name.
if fixturedefs is None:
raise FixtureLookupError(argname, self)
# fixturedefs list is immutable so we maintain a decreasing index.
# The are no fixtures with this name applicable for the function.
if not fixturedefs:
raise FixtureLookupError(argname, self)
index = self._arg2index.get(argname, 0) - 1
# The fixture requested its own name, but no remaining to override.
if -index > len(fixturedefs):
raise FixtureLookupError(argname, self)
self._arg2index[argname] = index
@ -503,7 +530,7 @@ class FixtureRequest:
"""Instance (can be None) on which test function was collected."""
# unittest support hack, see _pytest.unittest.TestCaseFunction.
try:
return self._pyfuncitem._testcase
return self._pyfuncitem._testcase # type: ignore[attr-defined]
except AttributeError:
function = getattr(self, "function", None)
return getattr(function, "__self__", None)
@ -513,7 +540,9 @@ class FixtureRequest:
"""Python module object where the test function was collected."""
if self.scope not in ("function", "class", "module"):
raise AttributeError(f"module not available in {self.scope}-scoped context")
return self._pyfuncitem.getparent(_pytest.python.Module).obj
mod = self._pyfuncitem.getparent(_pytest.python.Module)
assert mod is not None
return mod.obj
@property
def path(self) -> Path:
@ -829,7 +858,9 @@ class FixtureLookupError(LookupError):
if msg is None:
fm = self.request._fixturemanager
available = set()
parentid = self.request._pyfuncitem.parent.nodeid
parent = self.request._pyfuncitem.parent
assert parent is not None
parentid = parent.nodeid
for name, fixturedefs in fm._arg2fixturedefs.items():
faclist = list(fm._matchfactories(fixturedefs, parentid))
if faclist:
@ -976,15 +1007,15 @@ class FixtureDef(Generic[FixtureValue]):
# directory path relative to the rootdir.
#
# For other plugins, the baseid is the empty string (always matches).
self.baseid = baseid or ""
self.baseid: Final = baseid or ""
# Whether the fixture was found from a node or a conftest in the
# collection tree. Will be false for fixtures defined in non-conftest
# plugins.
self.has_location = baseid is not None
self.has_location: Final = baseid is not None
# The fixture factory function.
self.func = func
self.func: Final = func
# The name by which the fixture may be requested.
self.argname = argname
self.argname: Final = argname
if scope is None:
scope = Scope.Function
elif callable(scope):
@ -993,23 +1024,23 @@ class FixtureDef(Generic[FixtureValue]):
scope = Scope.from_user(
scope, descr=f"Fixture '{func.__name__}'", where=baseid
)
self._scope = scope
self._scope: Final = scope
# If the fixture is directly parametrized, the parameter values.
self.params: Optional[Sequence[object]] = params
self.params: Final = params
# If the fixture is directly parametrized, a tuple of explicit IDs to
# assign to the parameter values, or a callable to generate an ID given
# a parameter value.
self.ids = ids
self.ids: Final = ids
# The names requested by the fixtures.
self.argnames = getfuncargnames(func, name=argname, is_method=unittest)
self.argnames: Final = getfuncargnames(func, name=argname, is_method=unittest)
# Whether the fixture was collected from a unittest TestCase class.
# Note that it really only makes sense to define autouse fixtures in
# unittest TestCases.
self.unittest = unittest
self.unittest: Final = unittest
# If the fixture was executed, the current value of the fixture.
# Can change if the fixture is executed with different parameters.
self.cached_result: Optional[_FixtureCachedResult[FixtureValue]] = None
self._finalizers: List[Callable[[], object]] = []
self._finalizers: Final[List[Callable[[], object]]] = []
@property
def scope(self) -> "_ScopeName":
@ -1040,7 +1071,7 @@ class FixtureDef(Generic[FixtureValue]):
# value and remove all finalizers because they may be bound methods
# which will keep instances alive.
self.cached_result = None
self._finalizers = []
self._finalizers.clear()
def execute(self, request: SubRequest) -> FixtureValue:
# Get required arguments and register our own finish()
@ -1417,10 +1448,14 @@ class FixtureManager:
def __init__(self, session: "Session") -> None:
self.session = session
self.config: Config = session.config
self._arg2fixturedefs: Dict[str, List[FixtureDef[Any]]] = {}
self._holderobjseen: Set[object] = set()
# Maps a fixture name (argname) to all of the FixtureDefs in the test
# suite/plugins defined with this name. Populated by parsefactories().
# TODO: The order of the FixtureDefs list of each arg is significant,
# explain.
self._arg2fixturedefs: Final[Dict[str, List[FixtureDef[Any]]]] = {}
self._holderobjseen: Final[Set[object]] = set()
# A mapping from a nodeid to a list of autouse fixtures it defines.
self._nodeid_autousenames: Dict[str, List[str]] = {
self._nodeid_autousenames: Final[Dict[str, List[str]]] = {
"": self.config.getini("usefixtures"),
}
session.config.pluginmanager.register(self, "funcmanage")
@ -1699,11 +1734,16 @@ class FixtureManager:
def getfixturedefs(
self, argname: str, nodeid: str
) -> Optional[Sequence[FixtureDef[Any]]]:
"""Get a list of fixtures which are applicable to the given node id.
"""Get FixtureDefs for a fixture name which are applicable
to a given node.
:param str argname: Name of the fixture to search for.
:param str nodeid: Full node id of the requesting test.
:rtype: Sequence[FixtureDef]
Returns None if there are no fixtures at all defined with the given
name. (This is different from the case in which there are fixtures
with the given name, but none applicable to the node. In this case,
an empty result is returned).
:param argname: Name of the fixture to search for.
:param nodeid: Full node id of the requesting test.
"""
try:
fixturedefs = self._arg2fixturedefs[argname]

View File

@ -699,6 +699,7 @@ class TestRequestBasic:
"""
)
(item1,) = pytester.genitems([modcol])
assert isinstance(item1, Function)
assert item1.name == "test_method"
arg2fixturedefs = fixtures.FixtureRequest(
item1, _ispytest=True
@ -967,6 +968,7 @@ class TestRequestBasic:
def test_request_getmodulepath(self, pytester: Pytester) -> None:
modcol = pytester.getmodulecol("def test_somefunc(): pass")
(item,) = pytester.genitems([modcol])
assert isinstance(item, Function)
req = fixtures.FixtureRequest(item, _ispytest=True)
assert req.path == modcol.path
@ -1125,6 +1127,7 @@ class TestRequestMarking:
pass
"""
)
assert isinstance(item1, Function)
req1 = fixtures.FixtureRequest(item1, _ispytest=True)
assert "xfail" not in item1.keywords
req1.applymarker(pytest.mark.xfail)
@ -4009,6 +4012,7 @@ class TestScopeOrdering:
"""
)
items, _ = pytester.inline_genitems()
assert isinstance(items[0], Function)
request = FixtureRequest(items[0], _ispytest=True)
assert request.fixturenames == "m1 f1".split()
@ -4057,6 +4061,7 @@ class TestScopeOrdering:
"""
)
items, _ = pytester.inline_genitems()
assert isinstance(items[0], Function)
request = FixtureRequest(items[0], _ispytest=True)
# order of fixtures based on their scope and position in the parameter list
assert (
@ -4084,6 +4089,7 @@ class TestScopeOrdering:
"""
)
items, _ = pytester.inline_genitems()
assert isinstance(items[0], Function)
request = FixtureRequest(items[0], _ispytest=True)
assert request.fixturenames == "m1 f1".split()
@ -4117,6 +4123,7 @@ class TestScopeOrdering:
"""
)
items, _ = pytester.inline_genitems()
assert isinstance(items[0], Function)
request = FixtureRequest(items[0], _ispytest=True)
assert request.fixturenames == "s1 m1 c1 f2 f1".split()
@ -4159,6 +4166,7 @@ class TestScopeOrdering:
}
)
items, _ = pytester.inline_genitems()
assert isinstance(items[0], Function)
request = FixtureRequest(items[0], _ispytest=True)
assert request.fixturenames == "p_sub m_conf m_sub m_test f1".split()
@ -4203,6 +4211,7 @@ class TestScopeOrdering:
"""
)
items, _ = pytester.inline_genitems()
assert isinstance(items[0], Function)
request = FixtureRequest(items[0], _ispytest=True)
assert request.fixturenames == "s1 p1 m1 m2 c1 f2 f1".split()

View File

@ -90,6 +90,7 @@ def test_cache_makedir(cache: pytest.Cache) -> None:
def test_fixturerequest_getmodulepath(pytester: pytest.Pytester) -> None:
modcol = pytester.getmodulecol("def test_somefunc(): pass")
(item,) = pytester.genitems([modcol])
assert isinstance(item, pytest.Function)
req = pytest.FixtureRequest(item, _ispytest=True)
assert req.path == modcol.path
assert req.fspath == modcol.fspath # type: ignore[attr-defined]