diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index 998f540f3..47ffe47e8 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -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 diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index 455ad62cc..a37f1d2ac 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -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 diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 41a6504cc..3ffbc752a 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -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] diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index e62db8c26..63c18456a 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -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() diff --git a/testing/test_legacypath.py b/testing/test_legacypath.py index fbfd88b73..b32036d14 100644 --- a/testing/test_legacypath.py +++ b/testing/test_legacypath.py @@ -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]