fixtures: make FixtureRequest abstract, add TopRequest subclass

Fix #11218.
This commit is contained in:
Ran Benita 2023-07-16 00:37:33 +03:00
parent 556e075d23
commit 9e164fc4fe
6 changed files with 148 additions and 96 deletions

View File

@ -0,0 +1,5 @@
(This entry is meant to assist plugins which access private pytest internals to instantiate ``FixtureRequest`` objects.)
:class:`~pytest.FixtureRequest` is now an abstract class which can't be instantiated directly.
A new concrete ``TopRequest`` subclass of ``FixtureRequest`` has been added for the ``request`` fixture in test functions,
as counterpart to the existing ``SubRequest`` subclass for the ``request`` fixture in fixture functions.

View File

@ -32,7 +32,7 @@ from _pytest.compat import safe_getattr
from _pytest.config import Config
from _pytest.config.argparsing import Parser
from _pytest.fixtures import fixture
from _pytest.fixtures import FixtureRequest
from _pytest.fixtures import TopRequest
from _pytest.nodes import Collector
from _pytest.nodes import Item
from _pytest.outcomes import OutcomeException
@ -261,7 +261,7 @@ class DoctestItem(Item):
self.runner = runner
self.dtest = dtest
self.obj = None
self.fixture_request: Optional[FixtureRequest] = None
self.fixture_request: Optional[TopRequest] = None
@classmethod
def from_parent( # type: ignore
@ -571,7 +571,7 @@ class DoctestModule(Module):
)
def _setup_fixtures(doctest_item: DoctestItem) -> FixtureRequest:
def _setup_fixtures(doctest_item: DoctestItem) -> TopRequest:
"""Used by DoctestTextfile and DoctestItem to setup fixture information."""
def func() -> None:
@ -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) # type: ignore[arg-type]
fixture_request = TopRequest(doctest_item, _ispytest=True) # type: ignore[arg-type]
fixture_request._fillfixtures()
return fixture_request

View File

@ -1,3 +1,4 @@
import abc
import dataclasses
import functools
import inspect
@ -340,26 +341,32 @@ class FuncFixtureInfo:
self.names_closure[:] = sorted(closure, key=self.names_closure.index)
class FixtureRequest:
"""A request for a fixture from a test or fixture function.
class FixtureRequest(abc.ABC):
"""The type of the ``request`` fixture.
A request object gives access to the requesting test context and has
an optional ``param`` attribute in case the fixture is parametrized
indirectly.
A request object gives access to the requesting test context and has a
``param`` attribute in case the fixture is parametrized.
"""
def __init__(self, pyfuncitem: "Function", *, _ispytest: bool = False) -> None:
def __init__(
self,
pyfuncitem: "Function",
fixturename: Optional[str],
arg2fixturedefs: Dict[str, Sequence["FixtureDef[Any]"]],
arg2index: Dict[str, int],
fixture_defs: Dict[str, "FixtureDef[Any]"],
*,
_ispytest: bool = False,
) -> None:
check_ispytest(_ispytest)
#: 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.fixturename: Final = fixturename
self._pyfuncitem: Final = pyfuncitem
# 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()
self._arg2fixturedefs: Final = arg2fixturedefs
# 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.
@ -369,10 +376,10 @@ class FixtureRequest:
# 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._arg2index: Final = arg2index
# The evaluated argnames so far, mapping to the FixtureDef they resolved
# to.
self._fixture_defs: Dict[str, FixtureDef[Any]] = {}
self._fixture_defs: Final = fixture_defs
# 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
@ -383,6 +390,15 @@ class FixtureRequest:
# for now just using Any.
self.param: Any
@property
def _fixturemanager(self) -> "FixtureManager":
return self._pyfuncitem.session._fixturemanager
@property
@abc.abstractmethod
def _scope(self) -> Scope:
raise NotImplementedError()
@property
def scope(self) -> _ScopeName:
"""Scope string, one of "function", "class", "module", "package", "session"."""
@ -396,25 +412,10 @@ class FixtureRequest:
return result
@property
@abc.abstractmethod
def node(self):
"""Underlying collection node (depends on current request scope)."""
scope = self._scope
if scope is Scope.Function:
# This might also be a non-function Item despite its attribute name.
node: Optional[Union[nodes.Item, nodes.Collector]] = self._pyfuncitem
elif scope is Scope.Package:
# FIXME: _fixturedef is not defined on FixtureRequest (this class),
# but on SubRequest (a subclass).
node = get_scope_package(self._pyfuncitem, self._fixturedef) # type: ignore[attr-defined]
else:
node = get_scope_node(self._pyfuncitem, scope)
if node is None and scope is Scope.Class:
# Fallback to function item itself.
node = self._pyfuncitem
assert node, 'Could not obtain a node for scope "{}" for function {!r}'.format(
scope, self._pyfuncitem
)
return node
raise NotImplementedError()
def _getnextfixturedef(self, argname: str) -> "FixtureDef[Any]":
fixturedefs = self._arg2fixturedefs.get(argname, None)
@ -500,11 +501,11 @@ class FixtureRequest:
"""Pytest session object."""
return self._pyfuncitem.session # type: ignore[no-any-return]
@abc.abstractmethod
def addfinalizer(self, finalizer: Callable[[], object]) -> None:
"""Add finalizer/teardown function to be called without arguments after
the last test within the requesting test context finished execution."""
# XXX usually this method is shadowed by fixturedef specific ones.
self.node.addfinalizer(finalizer)
raise NotImplementedError()
def applymarker(self, marker: Union[str, MarkDecorator]) -> None:
"""Apply a marker to a single test function invocation.
@ -525,13 +526,6 @@ class FixtureRequest:
"""
raise self._fixturemanager.FixtureLookupError(None, self, msg)
def _fillfixtures(self) -> None:
item = self._pyfuncitem
fixturenames = getattr(item, "fixturenames", self.fixturenames)
for argname in fixturenames:
if argname not in item.funcargs:
item.funcargs[argname] = self.getfixturevalue(argname)
def getfixturevalue(self, argname: str) -> Any:
"""Dynamically run a named fixture function.
@ -665,6 +659,98 @@ class FixtureRequest:
finalizer = functools.partial(fixturedef.finish, request=subrequest)
subrequest.node.addfinalizer(finalizer)
@final
class TopRequest(FixtureRequest):
"""The type of the ``request`` fixture in a test function."""
def __init__(self, pyfuncitem: "Function", *, _ispytest: bool = False) -> None:
super().__init__(
fixturename=None,
pyfuncitem=pyfuncitem,
arg2fixturedefs=pyfuncitem._fixtureinfo.name2fixturedefs.copy(),
arg2index={},
fixture_defs={},
_ispytest=_ispytest,
)
@property
def _scope(self) -> Scope:
return Scope.Function
@property
def node(self):
return self._pyfuncitem
def __repr__(self) -> str:
return "<FixtureRequest for %r>" % (self.node)
def _fillfixtures(self) -> None:
item = self._pyfuncitem
fixturenames = getattr(item, "fixturenames", self.fixturenames)
for argname in fixturenames:
if argname not in item.funcargs:
item.funcargs[argname] = self.getfixturevalue(argname)
def addfinalizer(self, finalizer: Callable[[], object]) -> None:
self.node.addfinalizer(finalizer)
@final
class SubRequest(FixtureRequest):
"""The type of the ``request`` fixture in a fixture function requested
(transitively) by a test function."""
def __init__(
self,
request: FixtureRequest,
scope: Scope,
param: Any,
param_index: int,
fixturedef: "FixtureDef[object]",
*,
_ispytest: bool = False,
) -> None:
super().__init__(
pyfuncitem=request._pyfuncitem,
fixturename=fixturedef.argname,
fixture_defs=request._fixture_defs,
arg2fixturedefs=request._arg2fixturedefs,
arg2index=request._arg2index,
_ispytest=_ispytest,
)
self._parent_request: Final[FixtureRequest] = request
self._scope_field: Final = scope
self._fixturedef: Final = fixturedef
if param is not NOTSET:
self.param = param
self.param_index: Final = param_index
def __repr__(self) -> str:
return f"<SubRequest {self.fixturename!r} for {self._pyfuncitem!r}>"
@property
def _scope(self) -> Scope:
return self._scope_field
@property
def node(self):
scope = self._scope
if scope is Scope.Function:
# This might also be a non-function Item despite its attribute name.
node: Optional[Union[nodes.Item, nodes.Collector]] = self._pyfuncitem
elif scope is Scope.Package:
node = get_scope_package(self._pyfuncitem, self._fixturedef)
else:
node = get_scope_node(self._pyfuncitem, scope)
if node is None and scope is Scope.Class:
# Fallback to function item itself.
node = self._pyfuncitem
assert node, 'Could not obtain a node for scope "{}" for function {!r}'.format(
scope, self._pyfuncitem
)
return node
def _check_scope(
self,
argname: str,
@ -699,44 +785,7 @@ class FixtureRequest:
)
return lines
def __repr__(self) -> str:
return "<FixtureRequest for %r>" % (self.node)
@final
class SubRequest(FixtureRequest):
"""A sub request for handling getting a fixture from a test function/fixture."""
def __init__(
self,
request: "FixtureRequest",
scope: Scope,
param: Any,
param_index: int,
fixturedef: "FixtureDef[object]",
*,
_ispytest: bool = False,
) -> None:
check_ispytest(_ispytest)
self._parent_request = request
self.fixturename = fixturedef.argname
if param is not NOTSET:
self.param = param
self.param_index = param_index
self._scope = scope
self._fixturedef = fixturedef
self._pyfuncitem = request._pyfuncitem
self._fixture_defs = request._fixture_defs
self._arg2fixturedefs = request._arg2fixturedefs
self._arg2index = request._arg2index
self._fixturemanager = request._fixturemanager
def __repr__(self) -> str:
return f"<SubRequest {self.fixturename!r} for {self._pyfuncitem!r}>"
def addfinalizer(self, finalizer: Callable[[], object]) -> None:
"""Add finalizer/teardown function to be called without arguments after
the last test within the requesting test context finished execution."""
self._fixturedef.addfinalizer(finalizer)
def _schedule_finalizers(

View File

@ -1812,7 +1812,7 @@ class Function(PyobjMixin, nodes.Item):
def _initrequest(self) -> None:
self.funcargs: Dict[str, object] = {}
self._request = fixtures.FixtureRequest(self, _ispytest=True)
self._request = fixtures.TopRequest(self, _ispytest=True)
@property
def function(self):

View File

@ -4,10 +4,9 @@ import textwrap
from pathlib import Path
import pytest
from _pytest import fixtures
from _pytest.compat import getfuncargnames
from _pytest.config import ExitCode
from _pytest.fixtures import FixtureRequest
from _pytest.fixtures import TopRequest
from _pytest.monkeypatch import MonkeyPatch
from _pytest.pytester import get_public_names
from _pytest.pytester import Pytester
@ -659,7 +658,7 @@ class TestRequestBasic:
"""
)
assert isinstance(item, Function)
req = fixtures.FixtureRequest(item, _ispytest=True)
req = TopRequest(item, _ispytest=True)
assert req.function == item.obj
assert req.keywords == item.keywords
assert hasattr(req.module, "test_func")
@ -701,9 +700,7 @@ class TestRequestBasic:
(item1,) = pytester.genitems([modcol])
assert isinstance(item1, Function)
assert item1.name == "test_method"
arg2fixturedefs = fixtures.FixtureRequest(
item1, _ispytest=True
)._arg2fixturedefs
arg2fixturedefs = TopRequest(item1, _ispytest=True)._arg2fixturedefs
assert len(arg2fixturedefs) == 1
assert arg2fixturedefs["something"][0].argname == "something"
@ -969,7 +966,7 @@ class TestRequestBasic:
modcol = pytester.getmodulecol("def test_somefunc(): pass")
(item,) = pytester.genitems([modcol])
assert isinstance(item, Function)
req = fixtures.FixtureRequest(item, _ispytest=True)
req = TopRequest(item, _ispytest=True)
assert req.path == modcol.path
def test_request_fixturenames(self, pytester: Pytester) -> None:
@ -1128,7 +1125,7 @@ class TestRequestMarking:
"""
)
assert isinstance(item1, Function)
req1 = fixtures.FixtureRequest(item1, _ispytest=True)
req1 = TopRequest(item1, _ispytest=True)
assert "xfail" not in item1.keywords
req1.applymarker(pytest.mark.xfail)
assert "xfail" in item1.keywords
@ -4036,7 +4033,7 @@ class TestScopeOrdering:
)
items, _ = pytester.inline_genitems()
assert isinstance(items[0], Function)
request = FixtureRequest(items[0], _ispytest=True)
request = TopRequest(items[0], _ispytest=True)
assert request.fixturenames == "m1 f1".split()
def test_func_closure_with_native_fixtures(
@ -4085,7 +4082,7 @@ class TestScopeOrdering:
)
items, _ = pytester.inline_genitems()
assert isinstance(items[0], Function)
request = FixtureRequest(items[0], _ispytest=True)
request = TopRequest(items[0], _ispytest=True)
# order of fixtures based on their scope and position in the parameter list
assert (
request.fixturenames
@ -4113,7 +4110,7 @@ class TestScopeOrdering:
)
items, _ = pytester.inline_genitems()
assert isinstance(items[0], Function)
request = FixtureRequest(items[0], _ispytest=True)
request = TopRequest(items[0], _ispytest=True)
assert request.fixturenames == "m1 f1".split()
def test_func_closure_scopes_reordered(self, pytester: Pytester) -> None:
@ -4147,7 +4144,7 @@ class TestScopeOrdering:
)
items, _ = pytester.inline_genitems()
assert isinstance(items[0], Function)
request = FixtureRequest(items[0], _ispytest=True)
request = TopRequest(items[0], _ispytest=True)
assert request.fixturenames == "s1 m1 c1 f2 f1".split()
def test_func_closure_same_scope_closer_root_first(
@ -4190,7 +4187,7 @@ class TestScopeOrdering:
)
items, _ = pytester.inline_genitems()
assert isinstance(items[0], Function)
request = FixtureRequest(items[0], _ispytest=True)
request = TopRequest(items[0], _ispytest=True)
assert request.fixturenames == "p_sub m_conf m_sub m_test f1".split()
def test_func_closure_all_scopes_complex(self, pytester: Pytester) -> None:
@ -4235,7 +4232,7 @@ class TestScopeOrdering:
)
items, _ = pytester.inline_genitems()
assert isinstance(items[0], Function)
request = FixtureRequest(items[0], _ispytest=True)
request = TopRequest(items[0], _ispytest=True)
assert request.fixturenames == "s1 p1 m1 m2 c1 f2 f1".split()
def test_multiple_packages(self, pytester: Pytester) -> None:

View File

@ -2,6 +2,7 @@ from pathlib import Path
import pytest
from _pytest.compat import LEGACY_PATH
from _pytest.fixtures import TopRequest
from _pytest.legacypath import TempdirFactory
from _pytest.legacypath import Testdir
@ -91,7 +92,7 @@ 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)
req = TopRequest(item, _ispytest=True)
assert req.path == modcol.path
assert req.fspath == modcol.fspath # type: ignore[attr-defined]