From 87453b11f11d18e00de4b0cbf65c6f593674b596 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Wed, 22 Jul 2026 14:27:48 +0200 Subject: [PATCH 01/25] fixtures, runner: Store cached fixture values in SetupState, instead of FixtureDef. --- src/_pytest/fixtures.py | 43 ++++++++++++++++++++++++---------------- src/_pytest/runner.py | 3 +++ src/_pytest/setuponly.py | 2 +- src/_pytest/setupplan.py | 4 ++-- 4 files changed, 32 insertions(+), 20 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 3f1964f2d06..b3f083d38d7 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -629,11 +629,12 @@ def getfixturevalue(self, argname: str) -> Any: # (using function parameters, autouse, etc). fixturedef = self._get_active_fixturedef(argname) - assert fixturedef.cached_result is not None, ( + fixture_result = fixturedef._get_cached_result(self) + assert fixture_result is not None, ( f'The fixture value for "{argname}" is not available. ' "This can happen when the fixture has already been torn down." ) - return fixturedef.cached_result[0] + return fixture_result[0] def _iter_chain(self) -> Iterator[SubRequest]: """Yield all SubRequests in the chain, from self up. @@ -1123,14 +1124,21 @@ def __init__( self.ids: Final = ids # The names requested by the fixtures. self.argnames: Final = getfuncargnames(func, name=argname) - # If the fixture was executed, the current value of the fixture. - # Can change if the fixture is executed with different parameters. - self.cached_result: _FixtureCachedResult[FixtureValue] | None = None self._finalizers: Final[list[Callable[[], object]]] = [] # only used to emit a deprecationwarning, can be removed in pytest9 self._autouse = _autouse + def _get_cached_result( + self, request: FixtureRequest + ) -> _FixtureCachedResult[FixtureValue] | None: + return request.session._setupstate.fixture_cache.get(self) + + def _set_cached_result( + self, request: FixtureRequest, value: _FixtureCachedResult[FixtureValue] | None + ) -> None: + request.session._setupstate.fixture_cache[self] = value + @property def scope(self) -> ScopeName: """Scope string, one of "function", "class", "module", "package", "session".""" @@ -1145,7 +1153,7 @@ def addfinalizer(self, finalizer: Callable[[], object]) -> None: self._finalizers.append(finalizer) def finish(self, request: SubRequest) -> None: - if self.cached_result is None: + if self._get_cached_result(request) is None: # Already finished. It is assumed that finalizers cannot be added in # this state. return @@ -1161,7 +1169,7 @@ def finish(self, request: SubRequest) -> None: # Even if finalization fails, we invalidate the cached fixture # value and remove all finalizers because they may be bound methods # which will keep instances alive. - self.cached_result = None + self._set_cached_result(request, None) self._finalizers.clear() if len(exceptions) == 1: raise exceptions[0] @@ -1188,9 +1196,9 @@ def execute(self, request: SubRequest) -> FixtureValue: requested_fixtures_that_should_finalize_us.append(fixturedef) # Check for (and return) cached value/exception. - if self.cached_result is not None: + if (fixture_result := self._get_cached_result(request)) is not None: request_cache_key = self.cache_key(request) - cache_key = self.cached_result[1] + cache_key = fixture_result[1] try: # Attempt to make a normal == check: this might fail for objects # which do not implement the standard comparison (like numpy arrays -- #6497). @@ -1200,15 +1208,15 @@ def execute(self, request: SubRequest) -> FixtureValue: cache_hit = request_cache_key is cache_key if cache_hit: - if self.cached_result[2] is not None: - exc, exc_tb = self.cached_result[2] + if fixture_result[2] is not None: + exc, exc_tb = fixture_result[2] raise exc.with_traceback(exc_tb) else: - return self.cached_result[0] + return fixture_result[0] # We have a previous but differently parametrized fixture instance # so we need to tear it down before creating a new one. self.finish(request) - assert self.cached_result is None + assert self._get_cached_result(request) is None # Add finalizer to requested fixtures we saved previously. # We make sure to do this after checking for cached value to avoid @@ -1229,7 +1237,6 @@ def execute(self, request: SubRequest) -> FixtureValue: ihook = request.node.ihook try: # Setup the fixture, run the code in it, and cache the value - # in self.cached_result. result: FixtureValue = ihook.pytest_fixture_setup( fixturedef=self, request=request ) @@ -1263,7 +1270,7 @@ def __init__(self, request: FixtureRequest) -> None: node=request.node, _ispytest=True, ) - self.cached_result = (request, [0], None) + self._set_cached_result(request, (request, [0], None)) def addfinalizer(self, finalizer: Callable[[], object]) -> None: pass @@ -1342,9 +1349,11 @@ def pytest_fixture_setup( # Don't show the fixture as the skip location, as then the user # wouldn't know which test skipped. e._use_item_location = True - fixturedef.cached_result = (None, my_cache_key, (e, e.__traceback__)) + fixturedef._set_cached_result( + request, (None, my_cache_key, (e, e.__traceback__)) + ) raise - fixturedef.cached_result = (result, my_cache_key, None) + fixturedef._set_cached_result(request, (result, my_cache_key, None)) return result diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index 3f03cfaff77..799fe8e3dcf 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -511,6 +511,9 @@ def __init__(self) -> None: tuple[OutcomeException | Exception, types.TracebackType | None] | None, ], ] = {} + # Importing the appropriate types from the fixtures module lead to circular + # imports, so we leave the cache untyped for now + self.fixture_cache = {} # type: ignore[var-annotated] def is_node_active(self, node: Node) -> bool: """Check if a node is currently active in the stack -- set up and not diff --git a/src/_pytest/setuponly.py b/src/_pytest/setuponly.py index 7e6b46bcdb4..ea67f6eb78b 100644 --- a/src/_pytest/setuponly.py +++ b/src/_pytest/setuponly.py @@ -53,7 +53,7 @@ def pytest_fixture_setup( def pytest_fixture_post_finalizer( fixturedef: FixtureDef[object], request: SubRequest ) -> None: - if fixturedef.cached_result is not None: + if fixturedef._get_cached_result(request) is not None: config = request.config if config.option.setupshow: _show_fixture_action(fixturedef, request.config, "TEARDOWN") diff --git a/src/_pytest/setupplan.py b/src/_pytest/setupplan.py index 4e124cce243..1fdd54c9b38 100644 --- a/src/_pytest/setupplan.py +++ b/src/_pytest/setupplan.py @@ -26,8 +26,8 @@ def pytest_fixture_setup( # Will return a dummy fixture if the setuponly option is provided. if request.config.option.setupplan: my_cache_key = fixturedef.cache_key(request) - fixturedef.cached_result = (None, my_cache_key, None) - return fixturedef.cached_result + fixturedef._set_cached_result(request, (None, my_cache_key, None)) + return fixturedef._get_cached_result(request) return None From 9204a14568af0e4c5ab55b9fabf5a05bd6a3cbc3 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Wed, 22 Jul 2026 15:24:09 +0200 Subject: [PATCH 02/25] fixtures: Convert fixture results to NamedTuples --- src/_pytest/fixtures.py | 49 ++++++++++++++++++++-------------------- src/_pytest/setupplan.py | 3 ++- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index b3f083d38d7..437872fbe53 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -26,6 +26,7 @@ from typing import final from typing import Generic from typing import Literal +from typing import NamedTuple from typing import NoReturn from typing import overload from typing import TYPE_CHECKING @@ -96,23 +97,22 @@ FixtureFunction = Callable[..., object] # The type of a fixture function (type alias generic in fixture value). _FixtureFunc = Callable[..., FixtureValue] | Callable[..., Generator[FixtureValue]] + + +class _FixtureResult(NamedTuple, Generic[FixtureValue]): + value: FixtureValue + cache_key: object + exception_and_traceback: None + + +class _FixtureException(NamedTuple): + value: None + cache_key: object + exception_and_traceback: tuple[BaseException, types.TracebackType | None] + + # The type of FixtureDef.cached_result (type alias generic in fixture value). -_FixtureCachedResult = ( - tuple[ - # The result. - FixtureValue, - # Cache key. - object, - None, - ] - | tuple[ - None, - # Cache key. - object, - # The exception and the original traceback. - tuple[BaseException, types.TracebackType | None], - ] -) +_FixtureCachedResult = _FixtureResult[FixtureValue] | _FixtureException def pytest_sessionstart(session: Session) -> None: @@ -634,7 +634,7 @@ def getfixturevalue(self, argname: str) -> Any: f'The fixture value for "{argname}" is not available. ' "This can happen when the fixture has already been torn down." ) - return fixture_result[0] + return fixture_result.value def _iter_chain(self) -> Iterator[SubRequest]: """Yield all SubRequests in the chain, from self up. @@ -1198,7 +1198,7 @@ def execute(self, request: SubRequest) -> FixtureValue: # Check for (and return) cached value/exception. if (fixture_result := self._get_cached_result(request)) is not None: request_cache_key = self.cache_key(request) - cache_key = fixture_result[1] + cache_key = fixture_result.cache_key try: # Attempt to make a normal == check: this might fail for objects # which do not implement the standard comparison (like numpy arrays -- #6497). @@ -1208,11 +1208,11 @@ def execute(self, request: SubRequest) -> FixtureValue: cache_hit = request_cache_key is cache_key if cache_hit: - if fixture_result[2] is not None: - exc, exc_tb = fixture_result[2] + if fixture_result.exception_and_traceback is not None: + exc, exc_tb = fixture_result.exception_and_traceback raise exc.with_traceback(exc_tb) else: - return fixture_result[0] + return fixture_result.value # We have a previous but differently parametrized fixture instance # so we need to tear it down before creating a new one. self.finish(request) @@ -1270,7 +1270,7 @@ def __init__(self, request: FixtureRequest) -> None: node=request.node, _ispytest=True, ) - self._set_cached_result(request, (request, [0], None)) + self._set_cached_result(request, _FixtureResult(request, [0], None)) def addfinalizer(self, finalizer: Callable[[], object]) -> None: pass @@ -1350,10 +1350,11 @@ def pytest_fixture_setup( # wouldn't know which test skipped. e._use_item_location = True fixturedef._set_cached_result( - request, (None, my_cache_key, (e, e.__traceback__)) + request, + _FixtureException(None, my_cache_key, (e, e.__traceback__)), ) raise - fixturedef._set_cached_result(request, (result, my_cache_key, None)) + fixturedef._set_cached_result(request, _FixtureResult(result, my_cache_key, None)) return result diff --git a/src/_pytest/setupplan.py b/src/_pytest/setupplan.py index 1fdd54c9b38..e421ae374ae 100644 --- a/src/_pytest/setupplan.py +++ b/src/_pytest/setupplan.py @@ -3,6 +3,7 @@ from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config.argparsing import Parser +from _pytest.fixtures import _FixtureResult from _pytest.fixtures import FixtureDef from _pytest.fixtures import SubRequest import pytest @@ -26,7 +27,7 @@ def pytest_fixture_setup( # Will return a dummy fixture if the setuponly option is provided. if request.config.option.setupplan: my_cache_key = fixturedef.cache_key(request) - fixturedef._set_cached_result(request, (None, my_cache_key, None)) + fixturedef._set_cached_result(request, _FixtureResult(None, my_cache_key, None)) return fixturedef._get_cached_result(request) return None From 21565b513fabefd9297a81344572e1247792e68c Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Wed, 22 Jul 2026 15:45:56 +0200 Subject: [PATCH 03/25] fixtures: Extracted method to invalidate the cached result of a fixture. --- src/_pytest/fixtures.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 437872fbe53..a16cb813b9f 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1139,6 +1139,9 @@ def _set_cached_result( ) -> None: request.session._setupstate.fixture_cache[self] = value + def _invalidate_fixture_cache(self, request: FixtureRequest) -> None: + del request.session._setupstate.fixture_cache[self] + @property def scope(self) -> ScopeName: """Scope string, one of "function", "class", "module", "package", "session".""" @@ -1169,7 +1172,7 @@ def finish(self, request: SubRequest) -> None: # Even if finalization fails, we invalidate the cached fixture # value and remove all finalizers because they may be bound methods # which will keep instances alive. - self._set_cached_result(request, None) + self._invalidate_fixture_cache(request) self._finalizers.clear() if len(exceptions) == 1: raise exceptions[0] From 8306a1fafe805801a6f70d4aac63daad2727cf93 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Wed, 22 Jul 2026 17:01:23 +0200 Subject: [PATCH 04/25] fixtures: Don't allow setting the cached fixture value to None. Use _invalidate_fixture_cache instead. --- src/_pytest/fixtures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index a16cb813b9f..bbd54d1230b 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1135,7 +1135,7 @@ def _get_cached_result( return request.session._setupstate.fixture_cache.get(self) def _set_cached_result( - self, request: FixtureRequest, value: _FixtureCachedResult[FixtureValue] | None + self, request: FixtureRequest, value: _FixtureCachedResult[FixtureValue] ) -> None: request.session._setupstate.fixture_cache[self] = value From e09fda89a7ae4cb46878a6fb95e15031d245d0f7 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Wed, 22 Jul 2026 17:19:20 +0200 Subject: [PATCH 05/25] fixtures: RequestFixtureDef.cache_key returns the correct cache key. --- src/_pytest/fixtures.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index bbd54d1230b..588859f7eaa 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1275,6 +1275,9 @@ def __init__(self, request: FixtureRequest) -> None: ) self._set_cached_result(request, _FixtureResult(request, [0], None)) + def cache_key(self, request: SubRequest) -> object: + return [0] + def addfinalizer(self, finalizer: Callable[[], object]) -> None: pass From ee27c174a87724d619b3406f816f827d4a5489eb Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Thu, 23 Jul 2026 08:08:30 +0200 Subject: [PATCH 06/25] fixtures: Move methods for manipulating fixture cache from FixtureDef to FixtureRequest. --- src/_pytest/fixtures.py | 46 +++++++++++++++++++++------------------- src/_pytest/setuponly.py | 2 +- src/_pytest/setupplan.py | 4 ++-- 3 files changed, 27 insertions(+), 25 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 588859f7eaa..5586b1d0ac7 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -629,13 +629,28 @@ def getfixturevalue(self, argname: str) -> Any: # (using function parameters, autouse, etc). fixturedef = self._get_active_fixturedef(argname) - fixture_result = fixturedef._get_cached_result(self) + fixture_result = self._get_cached_result(fixturedef) assert fixture_result is not None, ( f'The fixture value for "{argname}" is not available. ' "This can happen when the fixture has already been torn down." ) return fixture_result.value + def _get_cached_result( + self, fixturedef: FixtureDef[FixtureValue] + ) -> _FixtureCachedResult[FixtureValue] | None: + return self.session._setupstate.fixture_cache.get(fixturedef) + + def _set_cached_result( + self, + fixturedef: FixtureDef[FixtureValue], + value: _FixtureCachedResult[FixtureValue], + ) -> None: + self.session._setupstate.fixture_cache[fixturedef] = value + + def _invalidate_fixture_cache(self, fixturedef: FixtureDef[FixtureValue]) -> None: + del self.session._setupstate.fixture_cache[fixturedef] + def _iter_chain(self) -> Iterator[SubRequest]: """Yield all SubRequests in the chain, from self up. @@ -1129,19 +1144,6 @@ def __init__( # only used to emit a deprecationwarning, can be removed in pytest9 self._autouse = _autouse - def _get_cached_result( - self, request: FixtureRequest - ) -> _FixtureCachedResult[FixtureValue] | None: - return request.session._setupstate.fixture_cache.get(self) - - def _set_cached_result( - self, request: FixtureRequest, value: _FixtureCachedResult[FixtureValue] - ) -> None: - request.session._setupstate.fixture_cache[self] = value - - def _invalidate_fixture_cache(self, request: FixtureRequest) -> None: - del request.session._setupstate.fixture_cache[self] - @property def scope(self) -> ScopeName: """Scope string, one of "function", "class", "module", "package", "session".""" @@ -1156,7 +1158,7 @@ def addfinalizer(self, finalizer: Callable[[], object]) -> None: self._finalizers.append(finalizer) def finish(self, request: SubRequest) -> None: - if self._get_cached_result(request) is None: + if request._get_cached_result(self) is None: # Already finished. It is assumed that finalizers cannot be added in # this state. return @@ -1172,7 +1174,7 @@ def finish(self, request: SubRequest) -> None: # Even if finalization fails, we invalidate the cached fixture # value and remove all finalizers because they may be bound methods # which will keep instances alive. - self._invalidate_fixture_cache(request) + request._invalidate_fixture_cache(self) self._finalizers.clear() if len(exceptions) == 1: raise exceptions[0] @@ -1199,7 +1201,7 @@ def execute(self, request: SubRequest) -> FixtureValue: requested_fixtures_that_should_finalize_us.append(fixturedef) # Check for (and return) cached value/exception. - if (fixture_result := self._get_cached_result(request)) is not None: + if (fixture_result := request._get_cached_result(self)) is not None: request_cache_key = self.cache_key(request) cache_key = fixture_result.cache_key try: @@ -1219,7 +1221,7 @@ def execute(self, request: SubRequest) -> FixtureValue: # We have a previous but differently parametrized fixture instance # so we need to tear it down before creating a new one. self.finish(request) - assert self._get_cached_result(request) is None + assert request._get_cached_result(self) is None # Add finalizer to requested fixtures we saved previously. # We make sure to do this after checking for cached value to avoid @@ -1273,7 +1275,7 @@ def __init__(self, request: FixtureRequest) -> None: node=request.node, _ispytest=True, ) - self._set_cached_result(request, _FixtureResult(request, [0], None)) + request._set_cached_result(self, _FixtureResult(request, [0], None)) def cache_key(self, request: SubRequest) -> object: return [0] @@ -1355,12 +1357,12 @@ def pytest_fixture_setup( # Don't show the fixture as the skip location, as then the user # wouldn't know which test skipped. e._use_item_location = True - fixturedef._set_cached_result( - request, + request._set_cached_result( + fixturedef, _FixtureException(None, my_cache_key, (e, e.__traceback__)), ) raise - fixturedef._set_cached_result(request, _FixtureResult(result, my_cache_key, None)) + request._set_cached_result(fixturedef, _FixtureResult(result, my_cache_key, None)) return result diff --git a/src/_pytest/setuponly.py b/src/_pytest/setuponly.py index ea67f6eb78b..0cf49bfae1b 100644 --- a/src/_pytest/setuponly.py +++ b/src/_pytest/setuponly.py @@ -53,7 +53,7 @@ def pytest_fixture_setup( def pytest_fixture_post_finalizer( fixturedef: FixtureDef[object], request: SubRequest ) -> None: - if fixturedef._get_cached_result(request) is not None: + if request._get_cached_result(fixturedef) is not None: config = request.config if config.option.setupshow: _show_fixture_action(fixturedef, request.config, "TEARDOWN") diff --git a/src/_pytest/setupplan.py b/src/_pytest/setupplan.py index e421ae374ae..5c0876d1579 100644 --- a/src/_pytest/setupplan.py +++ b/src/_pytest/setupplan.py @@ -27,8 +27,8 @@ def pytest_fixture_setup( # Will return a dummy fixture if the setuponly option is provided. if request.config.option.setupplan: my_cache_key = fixturedef.cache_key(request) - fixturedef._set_cached_result(request, _FixtureResult(None, my_cache_key, None)) - return fixturedef._get_cached_result(request) + request._set_cached_result(fixturedef, _FixtureResult(None, my_cache_key, None)) + return request._get_cached_result(fixturedef) return None From 7ebde2f2f29adea14dd88acbd43bfd1050500ee7 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Thu, 23 Jul 2026 10:35:41 +0200 Subject: [PATCH 07/25] fixtures: Move determination of fixture value cache keys from FixtureDef to SubRequest --- src/_pytest/fixtures.py | 13 +++++-------- src/_pytest/setupplan.py | 2 +- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 5586b1d0ac7..179d7e5635b 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -912,6 +912,9 @@ def _format_fixturedef_line(self, fixturedef: FixtureDef[object]) -> str: def addfinalizer(self, finalizer: Callable[[], object]) -> None: self._fixturedef.addfinalizer(finalizer) + def cache_key(self) -> object: + return getattr(self, "param", None) + @final class FixtureLookupError(LookupError): @@ -1202,7 +1205,7 @@ def execute(self, request: SubRequest) -> FixtureValue: # Check for (and return) cached value/exception. if (fixture_result := request._get_cached_result(self)) is not None: - request_cache_key = self.cache_key(request) + request_cache_key = request.cache_key() cache_key = fixture_result.cache_key try: # Attempt to make a normal == check: this might fail for objects @@ -1251,9 +1254,6 @@ def execute(self, request: SubRequest) -> FixtureValue: return result - def cache_key(self, request: SubRequest) -> object: - return getattr(request, "param", None) - def __repr__(self) -> str: return f"" @@ -1277,9 +1277,6 @@ def __init__(self, request: FixtureRequest) -> None: ) request._set_cached_result(self, _FixtureResult(request, [0], None)) - def cache_key(self, request: SubRequest) -> object: - return [0] - def addfinalizer(self, finalizer: Callable[[], object]) -> None: pass @@ -1336,7 +1333,7 @@ def pytest_fixture_setup( kwargs[argname] = request.getfixturevalue(argname) fixturefunc = resolve_fixture_function(fixturedef, request) - my_cache_key = fixturedef.cache_key(request) + my_cache_key = request.cache_key() if inspect.isasyncgenfunction(fixturefunc) or inspect.iscoroutinefunction( fixturefunc diff --git a/src/_pytest/setupplan.py b/src/_pytest/setupplan.py index 5c0876d1579..23decfe5e63 100644 --- a/src/_pytest/setupplan.py +++ b/src/_pytest/setupplan.py @@ -26,7 +26,7 @@ def pytest_fixture_setup( ) -> object | None: # Will return a dummy fixture if the setuponly option is provided. if request.config.option.setupplan: - my_cache_key = fixturedef.cache_key(request) + my_cache_key = request.cache_key() request._set_cached_result(fixturedef, _FixtureResult(None, my_cache_key, None)) return request._get_cached_result(fixturedef) return None From 18813a7cb4ec17ea6dbbfb7f0a89457b185086ae Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Thu, 23 Jul 2026 10:46:15 +0200 Subject: [PATCH 08/25] fixtures: Split up methods for cache population depending on whether the result is successful or an exception. --- src/_pytest/fixtures.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 179d7e5635b..530ee4e87c6 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -644,7 +644,14 @@ def _get_cached_result( def _set_cached_result( self, fixturedef: FixtureDef[FixtureValue], - value: _FixtureCachedResult[FixtureValue], + value: _FixtureResult[FixtureValue], + ) -> None: + self.session._setupstate.fixture_cache[fixturedef] = value + + def _set_cached_exception( + self, + fixturedef: FixtureDef[FixtureValue], + value: _FixtureException, ) -> None: self.session._setupstate.fixture_cache[fixturedef] = value @@ -1354,7 +1361,7 @@ def pytest_fixture_setup( # Don't show the fixture as the skip location, as then the user # wouldn't know which test skipped. e._use_item_location = True - request._set_cached_result( + request._set_cached_exception( fixturedef, _FixtureException(None, my_cache_key, (e, e.__traceback__)), ) From 5497a67457b016616ac3a82ae49e17b6d156933d Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Thu, 23 Jul 2026 11:02:17 +0200 Subject: [PATCH 09/25] fixtures: Selecting the correct cache key for fixture values is now the responsibility of FixtureRequest. --- src/_pytest/fixtures.py | 27 +++++++++++++++------------ src/_pytest/setupplan.py | 4 +--- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 530ee4e87c6..a86d5111c99 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -644,16 +644,23 @@ def _get_cached_result( def _set_cached_result( self, fixturedef: FixtureDef[FixtureValue], - value: _FixtureResult[FixtureValue], + value: FixtureValue, # type: ignore[misc] ) -> None: - self.session._setupstate.fixture_cache[fixturedef] = value + self.session._setupstate.fixture_cache[fixturedef] = _FixtureResult( + value, self._cache_key(), None + ) def _set_cached_exception( self, fixturedef: FixtureDef[FixtureValue], - value: _FixtureException, + exception_and_traceback: tuple[BaseException, types.TracebackType | None], ) -> None: - self.session._setupstate.fixture_cache[fixturedef] = value + self.session._setupstate.fixture_cache[fixturedef] = _FixtureException( + None, self._cache_key(), exception_and_traceback + ) + + def _cache_key(self) -> object: + return getattr(self, "param", None) def _invalidate_fixture_cache(self, fixturedef: FixtureDef[FixtureValue]) -> None: del self.session._setupstate.fixture_cache[fixturedef] @@ -919,9 +926,6 @@ def _format_fixturedef_line(self, fixturedef: FixtureDef[object]) -> str: def addfinalizer(self, finalizer: Callable[[], object]) -> None: self._fixturedef.addfinalizer(finalizer) - def cache_key(self) -> object: - return getattr(self, "param", None) - @final class FixtureLookupError(LookupError): @@ -1212,7 +1216,7 @@ def execute(self, request: SubRequest) -> FixtureValue: # Check for (and return) cached value/exception. if (fixture_result := request._get_cached_result(self)) is not None: - request_cache_key = request.cache_key() + request_cache_key = request._cache_key() cache_key = fixture_result.cache_key try: # Attempt to make a normal == check: this might fail for objects @@ -1282,7 +1286,7 @@ def __init__(self, request: FixtureRequest) -> None: node=request.node, _ispytest=True, ) - request._set_cached_result(self, _FixtureResult(request, [0], None)) + request._set_cached_result(self, request) def addfinalizer(self, finalizer: Callable[[], object]) -> None: pass @@ -1340,7 +1344,6 @@ def pytest_fixture_setup( kwargs[argname] = request.getfixturevalue(argname) fixturefunc = resolve_fixture_function(fixturedef, request) - my_cache_key = request.cache_key() if inspect.isasyncgenfunction(fixturefunc) or inspect.iscoroutinefunction( fixturefunc @@ -1363,10 +1366,10 @@ def pytest_fixture_setup( e._use_item_location = True request._set_cached_exception( fixturedef, - _FixtureException(None, my_cache_key, (e, e.__traceback__)), + (e, e.__traceback__), ) raise - request._set_cached_result(fixturedef, _FixtureResult(result, my_cache_key, None)) + request._set_cached_result(fixturedef, result) return result diff --git a/src/_pytest/setupplan.py b/src/_pytest/setupplan.py index 23decfe5e63..54418cfa360 100644 --- a/src/_pytest/setupplan.py +++ b/src/_pytest/setupplan.py @@ -3,7 +3,6 @@ from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config.argparsing import Parser -from _pytest.fixtures import _FixtureResult from _pytest.fixtures import FixtureDef from _pytest.fixtures import SubRequest import pytest @@ -26,8 +25,7 @@ def pytest_fixture_setup( ) -> object | None: # Will return a dummy fixture if the setuponly option is provided. if request.config.option.setupplan: - my_cache_key = request.cache_key() - request._set_cached_result(fixturedef, _FixtureResult(None, my_cache_key, None)) + request._set_cached_result(fixturedef, None) return request._get_cached_result(fixturedef) return None From c29c6e9fc978a8f048e7070ea6ffe0ca735a7c11 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Thu, 23 Jul 2026 11:22:46 +0200 Subject: [PATCH 10/25] fixtures: FixtureRequest._set_cached_exception no longer requires the traceback --- src/_pytest/fixtures.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index a86d5111c99..307ad7664e1 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -653,10 +653,10 @@ def _set_cached_result( def _set_cached_exception( self, fixturedef: FixtureDef[FixtureValue], - exception_and_traceback: tuple[BaseException, types.TracebackType | None], + exception: BaseException, ) -> None: self.session._setupstate.fixture_cache[fixturedef] = _FixtureException( - None, self._cache_key(), exception_and_traceback + None, self._cache_key(), (exception, exception.__traceback__) ) def _cache_key(self) -> object: @@ -1366,7 +1366,7 @@ def pytest_fixture_setup( e._use_item_location = True request._set_cached_exception( fixturedef, - (e, e.__traceback__), + e, ) raise request._set_cached_result(fixturedef, result) From c5b7e16b3644e4bd583abed2f8aae399de608889 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Thu, 23 Jul 2026 11:34:00 +0200 Subject: [PATCH 11/25] fixtures: Rename _set_cached_result and _set_cached_exception to _cache_value and _cache_exception, respectively --- src/_pytest/fixtures.py | 12 +++++++----- src/_pytest/setupplan.py | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 307ad7664e1..618d718f246 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -641,20 +641,22 @@ def _get_cached_result( ) -> _FixtureCachedResult[FixtureValue] | None: return self.session._setupstate.fixture_cache.get(fixturedef) - def _set_cached_result( + def _cache_value( self, fixturedef: FixtureDef[FixtureValue], value: FixtureValue, # type: ignore[misc] ) -> None: + """Write a value into the cache for a fixture definition.""" self.session._setupstate.fixture_cache[fixturedef] = _FixtureResult( value, self._cache_key(), None ) - def _set_cached_exception( + def _cache_exception( self, fixturedef: FixtureDef[FixtureValue], exception: BaseException, ) -> None: + """Write an exception result into the cache for a fixture definition.""" self.session._setupstate.fixture_cache[fixturedef] = _FixtureException( None, self._cache_key(), (exception, exception.__traceback__) ) @@ -1286,7 +1288,7 @@ def __init__(self, request: FixtureRequest) -> None: node=request.node, _ispytest=True, ) - request._set_cached_result(self, request) + request._cache_value(self, request) def addfinalizer(self, finalizer: Callable[[], object]) -> None: pass @@ -1364,12 +1366,12 @@ def pytest_fixture_setup( # Don't show the fixture as the skip location, as then the user # wouldn't know which test skipped. e._use_item_location = True - request._set_cached_exception( + request._cache_exception( fixturedef, e, ) raise - request._set_cached_result(fixturedef, result) + request._cache_value(fixturedef, result) return result diff --git a/src/_pytest/setupplan.py b/src/_pytest/setupplan.py index 54418cfa360..156af485a3d 100644 --- a/src/_pytest/setupplan.py +++ b/src/_pytest/setupplan.py @@ -25,7 +25,7 @@ def pytest_fixture_setup( ) -> object | None: # Will return a dummy fixture if the setuponly option is provided. if request.config.option.setupplan: - request._set_cached_result(fixturedef, None) + request._cache_value(fixturedef, None) return request._get_cached_result(fixturedef) return None From c5d2887e69fa96a42ab17241dab47c0625cc1865 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Thu, 23 Jul 2026 11:47:31 +0200 Subject: [PATCH 12/25] fixtures: Convert FixtureRequest._cache_key into a property --- src/_pytest/fixtures.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 618d718f246..8da352eac02 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -648,7 +648,7 @@ def _cache_value( ) -> None: """Write a value into the cache for a fixture definition.""" self.session._setupstate.fixture_cache[fixturedef] = _FixtureResult( - value, self._cache_key(), None + value, self._cache_key, None ) def _cache_exception( @@ -658,9 +658,10 @@ def _cache_exception( ) -> None: """Write an exception result into the cache for a fixture definition.""" self.session._setupstate.fixture_cache[fixturedef] = _FixtureException( - None, self._cache_key(), (exception, exception.__traceback__) + None, self._cache_key, (exception, exception.__traceback__) ) + @property def _cache_key(self) -> object: return getattr(self, "param", None) @@ -1218,7 +1219,7 @@ def execute(self, request: SubRequest) -> FixtureValue: # Check for (and return) cached value/exception. if (fixture_result := request._get_cached_result(self)) is not None: - request_cache_key = request._cache_key() + request_cache_key = request._cache_key cache_key = fixture_result.cache_key try: # Attempt to make a normal == check: this might fail for objects From 2a28f9e9495f64bf8ac5bd0982d491b126d3742c Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Thu, 23 Jul 2026 12:18:32 +0200 Subject: [PATCH 13/25] docs: Add news fragment --- changelog/14770.misc.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/14770.misc.rst diff --git a/changelog/14770.misc.rst b/changelog/14770.misc.rst new file mode 100644 index 00000000000..f2766046650 --- /dev/null +++ b/changelog/14770.misc.rst @@ -0,0 +1 @@ +Move fixture cache from *FixtureDef* to *SetupState*. From f04e08654fe4def20cf17d51718687a923d26076 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Fri, 24 Jul 2026 10:00:50 +0200 Subject: [PATCH 14/25] setuponly, runner: Move track currently active fixture param as part of SetupState, instead of FixtureDef. --- src/_pytest/runner.py | 3 +++ src/_pytest/setuponly.py | 18 ++++++++++-------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index 799fe8e3dcf..88e38819f4d 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -514,6 +514,9 @@ def __init__(self) -> None: # Importing the appropriate types from the fixtures module lead to circular # imports, so we leave the cache untyped for now self.fixture_cache = {} # type: ignore[var-annotated] + # Tracks the currently active param per fixturedef + # This is only used during --setup-show + self.active_param_by_fixture = {} # type: ignore[var-annotated] def is_node_active(self, node: Node) -> bool: """Check if a node is currently active in the stack -- set up and not diff --git a/src/_pytest/setuponly.py b/src/_pytest/setuponly.py index 0cf49bfae1b..11ec81351c9 100644 --- a/src/_pytest/setuponly.py +++ b/src/_pytest/setuponly.py @@ -46,8 +46,8 @@ def pytest_fixture_setup( param = fixturedef.ids[request.param_index] else: param = request.param - fixturedef.cached_param = param # type: ignore[attr-defined] - _show_fixture_action(fixturedef, request.config, "SETUP") + request.session._setupstate.active_param_by_fixture[fixturedef] = param + _show_fixture_action(request, fixturedef, "SETUP") def pytest_fixture_post_finalizer( @@ -56,14 +56,15 @@ def pytest_fixture_post_finalizer( if request._get_cached_result(fixturedef) is not None: config = request.config if config.option.setupshow: - _show_fixture_action(fixturedef, request.config, "TEARDOWN") - if hasattr(fixturedef, "cached_param"): - del fixturedef.cached_param + _show_fixture_action(request, fixturedef, "TEARDOWN") + if fixturedef in request.session._setupstate.active_param_by_fixture: + del request.session._setupstate.active_param_by_fixture[fixturedef] def _show_fixture_action( - fixturedef: FixtureDef[object], config: Config, msg: str + request: SubRequest, fixturedef: FixtureDef[object], msg: str ) -> None: + config = request.config capman = config.pluginmanager.getplugin("capturemanager") if capman: capman.suspend_global_capture() @@ -82,8 +83,9 @@ def _show_fixture_action( if deps: tw.write(" (fixtures used: {})".format(", ".join(deps))) - if hasattr(fixturedef, "cached_param"): - tw.write(f"[{saferepr(fixturedef.cached_param, maxsize=42)}]") + if fixturedef in request.session._setupstate.active_param_by_fixture: + active_param = request.session._setupstate.active_param_by_fixture[fixturedef] + tw.write(f"[{saferepr(active_param, maxsize=42)}]") tw.flush() From c9555d4468e67f67989a6d004da04c9bb86361f6 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Fri, 24 Jul 2026 10:50:28 +0200 Subject: [PATCH 15/25] fixtures: Rename cache_key to active_param or param to be more explicit about the contents of the cache key. --- src/_pytest/fixtures.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 8da352eac02..a583798a115 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -101,13 +101,13 @@ class _FixtureResult(NamedTuple, Generic[FixtureValue]): value: FixtureValue - cache_key: object + param: object exception_and_traceback: None class _FixtureException(NamedTuple): value: None - cache_key: object + param: object exception_and_traceback: tuple[BaseException, types.TracebackType | None] @@ -648,7 +648,7 @@ def _cache_value( ) -> None: """Write a value into the cache for a fixture definition.""" self.session._setupstate.fixture_cache[fixturedef] = _FixtureResult( - value, self._cache_key, None + value, self._active_param, None ) def _cache_exception( @@ -658,11 +658,11 @@ def _cache_exception( ) -> None: """Write an exception result into the cache for a fixture definition.""" self.session._setupstate.fixture_cache[fixturedef] = _FixtureException( - None, self._cache_key, (exception, exception.__traceback__) + None, self._active_param, (exception, exception.__traceback__) ) @property - def _cache_key(self) -> object: + def _active_param(self) -> object: return getattr(self, "param", None) def _invalidate_fixture_cache(self, fixturedef: FixtureDef[FixtureValue]) -> None: @@ -1218,23 +1218,23 @@ def execute(self, request: SubRequest) -> FixtureValue: requested_fixtures_that_should_finalize_us.append(fixturedef) # Check for (and return) cached value/exception. - if (fixture_result := request._get_cached_result(self)) is not None: - request_cache_key = request._cache_key - cache_key = fixture_result.cache_key + if (cached_result := request._get_cached_result(self)) is not None: + active_param = request._active_param + cached_param = cached_result.param try: # Attempt to make a normal == check: this might fail for objects # which do not implement the standard comparison (like numpy arrays -- #6497). - cache_hit = bool(request_cache_key == cache_key) + cache_hit = bool(active_param == cached_param) except (ValueError, RuntimeError): # If the comparison raises, use 'is' as fallback. - cache_hit = request_cache_key is cache_key + cache_hit = active_param is cached_param if cache_hit: - if fixture_result.exception_and_traceback is not None: - exc, exc_tb = fixture_result.exception_and_traceback + if cached_result.exception_and_traceback is not None: + exc, exc_tb = cached_result.exception_and_traceback raise exc.with_traceback(exc_tb) else: - return fixture_result.value + return cached_result.value # We have a previous but differently parametrized fixture instance # so we need to tear it down before creating a new one. self.finish(request) From 026919840fe8489e5e67928b05c47232b1523003 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Fri, 24 Jul 2026 10:58:59 +0200 Subject: [PATCH 16/25] fixtures: Use sentinel value for non-existant fixture params when caching fixture results to distinguish between *no param* and *None*. --- src/_pytest/fixtures.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index a583798a115..7a129dc8ba6 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -97,6 +97,8 @@ FixtureFunction = Callable[..., object] # The type of a fixture function (type alias generic in fixture value). _FixtureFunc = Callable[..., FixtureValue] | Callable[..., Generator[FixtureValue]] +# Sentinel value for unset fixture params +_NO_PARAM = object() class _FixtureResult(NamedTuple, Generic[FixtureValue]): @@ -663,7 +665,7 @@ def _cache_exception( @property def _active_param(self) -> object: - return getattr(self, "param", None) + return getattr(self, "param", _NO_PARAM) def _invalidate_fixture_cache(self, fixturedef: FixtureDef[FixtureValue]) -> None: del self.session._setupstate.fixture_cache[fixturedef] From 18f5852d8358783dd3ca9738fd8c2392d3acad4a Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Fri, 24 Jul 2026 11:06:16 +0200 Subject: [PATCH 17/25] fixtures, setuponly: Reuse SetupState.fixture_cache to track active params for --setup-show. --- src/_pytest/runner.py | 3 --- src/_pytest/setuponly.py | 16 ++++++++++------ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index 88e38819f4d..799fe8e3dcf 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -514,9 +514,6 @@ def __init__(self) -> None: # Importing the appropriate types from the fixtures module lead to circular # imports, so we leave the cache untyped for now self.fixture_cache = {} # type: ignore[var-annotated] - # Tracks the currently active param per fixturedef - # This is only used during --setup-show - self.active_param_by_fixture = {} # type: ignore[var-annotated] def is_node_active(self, node: Node) -> bool: """Check if a node is currently active in the stack -- set up and not diff --git a/src/_pytest/setuponly.py b/src/_pytest/setuponly.py index 11ec81351c9..add2b6e7c28 100644 --- a/src/_pytest/setuponly.py +++ b/src/_pytest/setuponly.py @@ -6,6 +6,8 @@ from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config.argparsing import Parser +from _pytest.fixtures import _FixtureResult +from _pytest.fixtures import _NO_PARAM from _pytest.fixtures import FixtureDef from _pytest.fixtures import SubRequest from _pytest.scope import Scope @@ -46,7 +48,11 @@ def pytest_fixture_setup( param = fixturedef.ids[request.param_index] else: param = request.param - request.session._setupstate.active_param_by_fixture[fixturedef] = param + # Use None as a dummy value for resolving/caching the fixture + # --setup-show does not care about the actual value, only about the param + request.session._setupstate.fixture_cache[fixturedef] = _FixtureResult( + None, param, None + ) _show_fixture_action(request, fixturedef, "SETUP") @@ -57,8 +63,6 @@ def pytest_fixture_post_finalizer( config = request.config if config.option.setupshow: _show_fixture_action(request, fixturedef, "TEARDOWN") - if fixturedef in request.session._setupstate.active_param_by_fixture: - del request.session._setupstate.active_param_by_fixture[fixturedef] def _show_fixture_action( @@ -83,9 +87,9 @@ def _show_fixture_action( if deps: tw.write(" (fixtures used: {})".format(", ".join(deps))) - if fixturedef in request.session._setupstate.active_param_by_fixture: - active_param = request.session._setupstate.active_param_by_fixture[fixturedef] - tw.write(f"[{saferepr(active_param, maxsize=42)}]") + if cache_entry := request.session._setupstate.fixture_cache.get(fixturedef): + if cache_entry.param is not _NO_PARAM: + tw.write(f"[{saferepr(cache_entry.param, maxsize=42)}]") tw.flush() From 61286bd3b15755660f90ff1f84f8dc0ecdd327e2 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Fri, 24 Jul 2026 11:32:15 +0200 Subject: [PATCH 18/25] fixtures: Address typing error for generic namedtuples in Python 3.10 --- src/_pytest/fixtures.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 7a129dc8ba6..bc56d08e1e4 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -101,10 +101,19 @@ _NO_PARAM = object() -class _FixtureResult(NamedTuple, Generic[FixtureValue]): - value: FixtureValue - param: object - exception_and_traceback: None +# NamedTuples cannot take generic arguments before Python 3.11 +if TYPE_CHECKING and sys.version_info >= (3, 11): + + class _FixtureResult(NamedTuple, Generic[FixtureValue]): + value: FixtureValue + param: object + exception_and_traceback: None +else: + + class _FixtureResult(NamedTuple): + value: Any + param: object + exception_and_traceback: None class _FixtureException(NamedTuple): @@ -114,7 +123,7 @@ class _FixtureException(NamedTuple): # The type of FixtureDef.cached_result (type alias generic in fixture value). -_FixtureCachedResult = _FixtureResult[FixtureValue] | _FixtureException +_FixtureCachedResult = _FixtureResult[FixtureValue] | _FixtureException # type: ignore[type-arg] def pytest_sessionstart(session: Session) -> None: @@ -1236,7 +1245,7 @@ def execute(self, request: SubRequest) -> FixtureValue: exc, exc_tb = cached_result.exception_and_traceback raise exc.with_traceback(exc_tb) else: - return cached_result.value + return cached_result.value # type: ignore[no-any-return] # We have a previous but differently parametrized fixture instance # so we need to tear it down before creating a new one. self.finish(request) From dd68168793a7e462d6576ce2246ac8c0b5305c2f Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Fri, 24 Jul 2026 15:33:04 +0200 Subject: [PATCH 19/25] fixtures: Replace mentions of FixtureDef.cached_result in the docs --- src/_pytest/fixtures.py | 4 ++-- src/_pytest/hookspec.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index bc56d08e1e4..02be13d79ac 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -122,7 +122,6 @@ class _FixtureException(NamedTuple): exception_and_traceback: tuple[BaseException, types.TracebackType | None] -# The type of FixtureDef.cached_result (type alias generic in fixture value). _FixtureCachedResult = _FixtureResult[FixtureValue] | _FixtureException # type: ignore[type-arg] @@ -1216,7 +1215,8 @@ def execute(self, request: SubRequest) -> FixtureValue: # This needs to be done before checking if we have a cached value, since # if a dependent fixture has their cache invalidated, e.g. due to # parametrization, they finalize themselves and fixtures depending on it - # (which will likely include this fixture) setting `self.cached_result = None`. + # (which will likely include this fixture), invalidating the respective + # cached values. # See #4871 requested_fixtures_that_should_finalize_us = [] for argname in self.argnames: diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index ba7ac2b76e3..104a8d7dbe8 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -867,8 +867,8 @@ def pytest_fixture_post_finalizer( fixturedef: FixtureDef[Any], request: SubRequest ) -> None: """Called after fixture teardown, but before the cache is cleared, so - the fixture result ``fixturedef.cached_result`` is still available (not - ``None``). + the fixture result ``request._get_cached_result(fixturedef)`` is still + available (not ``None``). :param fixturedef: The fixture definition object. From b49c266ac858dd41a334591992a8d78331fd86a3 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Fri, 24 Jul 2026 15:34:17 +0200 Subject: [PATCH 20/25] setuponly: Add test for suppressing capture. --- testing/test_setuponly.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/testing/test_setuponly.py b/testing/test_setuponly.py index 1d2261f75a8..3d5dbdd0e87 100644 --- a/testing/test_setuponly.py +++ b/testing/test_setuponly.py @@ -259,6 +259,27 @@ def test_capturing(two): ) +def test_suppress_capturing(pytester: Pytester) -> None: + p = pytester.makepyfile( + """ + import pytest, sys + @pytest.fixture() + def one(): + sys.stdout.write('this should not be captured') + @pytest.fixture() + def two(one): + assert 0 + def test_capturing(two): + pass + """ + ) + + result = pytester.runpytest("--setup-only", "-s", p) + result.stdout.no_fnmatch_line( + "this should not be captured" + ) + + def test_show_fixtures_and_execute_test(pytester: Pytester) -> None: """Verify that setups are shown and tests are executed.""" p = pytester.makepyfile( From 8a17fc1dfa1eadec0503de5c7bf0708fb7e3d379 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Fri, 24 Jul 2026 15:41:46 +0200 Subject: [PATCH 21/25] setuponly: Adds assertion that the cached fixture value cannot be empty in pytest_fixture_post_finalizer. --- src/_pytest/setuponly.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/_pytest/setuponly.py b/src/_pytest/setuponly.py index add2b6e7c28..e07964c54c1 100644 --- a/src/_pytest/setuponly.py +++ b/src/_pytest/setuponly.py @@ -59,10 +59,11 @@ def pytest_fixture_setup( def pytest_fixture_post_finalizer( fixturedef: FixtureDef[object], request: SubRequest ) -> None: - if request._get_cached_result(fixturedef) is not None: - config = request.config - if config.option.setupshow: - _show_fixture_action(request, fixturedef, "TEARDOWN") + cached_result = request._get_cached_result(fixturedef) + assert cached_result is not None, "As per the definition of this hook the fixture cache should not have been cleared" + config = request.config + if config.option.setupshow: + _show_fixture_action(request, fixturedef, "TEARDOWN") def _show_fixture_action( From 052ad1691676947baa378d4e85a3f94caa52d4f3 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Fri, 24 Jul 2026 15:52:35 +0200 Subject: [PATCH 22/25] setuponly: _show_fixture_action directly receives the fixture param rather than fetching it from the cache. --- src/_pytest/setuponly.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/_pytest/setuponly.py b/src/_pytest/setuponly.py index e07964c54c1..31a021f0d87 100644 --- a/src/_pytest/setuponly.py +++ b/src/_pytest/setuponly.py @@ -53,7 +53,9 @@ def pytest_fixture_setup( request.session._setupstate.fixture_cache[fixturedef] = _FixtureResult( None, param, None ) - _show_fixture_action(request, fixturedef, "SETUP") + else: + param = _NO_PARAM + _show_fixture_action(request.config, fixturedef, param, "SETUP") def pytest_fixture_post_finalizer( @@ -63,13 +65,12 @@ def pytest_fixture_post_finalizer( assert cached_result is not None, "As per the definition of this hook the fixture cache should not have been cleared" config = request.config if config.option.setupshow: - _show_fixture_action(request, fixturedef, "TEARDOWN") + _show_fixture_action(config, fixturedef, cached_result.param, "TEARDOWN") def _show_fixture_action( - request: SubRequest, fixturedef: FixtureDef[object], msg: str + config: Config, fixturedef: FixtureDef[object], param: object, msg: str ) -> None: - config = request.config capman = config.pluginmanager.getplugin("capturemanager") if capman: capman.suspend_global_capture() @@ -88,9 +89,8 @@ def _show_fixture_action( if deps: tw.write(" (fixtures used: {})".format(", ".join(deps))) - if cache_entry := request.session._setupstate.fixture_cache.get(fixturedef): - if cache_entry.param is not _NO_PARAM: - tw.write(f"[{saferepr(cache_entry.param, maxsize=42)}]") + if param is not _NO_PARAM: + tw.write(f"[{saferepr(param, maxsize=42)}]") tw.flush() From 8d136d4f3c3db5677a2dd083fcb20c9039efb69c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:59:33 +0000 Subject: [PATCH 23/25] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/_pytest/setuponly.py | 4 +++- testing/test_setuponly.py | 4 +--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/_pytest/setuponly.py b/src/_pytest/setuponly.py index 31a021f0d87..e8c34c1d49a 100644 --- a/src/_pytest/setuponly.py +++ b/src/_pytest/setuponly.py @@ -62,7 +62,9 @@ def pytest_fixture_post_finalizer( fixturedef: FixtureDef[object], request: SubRequest ) -> None: cached_result = request._get_cached_result(fixturedef) - assert cached_result is not None, "As per the definition of this hook the fixture cache should not have been cleared" + assert cached_result is not None, ( + "As per the definition of this hook the fixture cache should not have been cleared" + ) config = request.config if config.option.setupshow: _show_fixture_action(config, fixturedef, cached_result.param, "TEARDOWN") diff --git a/testing/test_setuponly.py b/testing/test_setuponly.py index 3d5dbdd0e87..b9c39668013 100644 --- a/testing/test_setuponly.py +++ b/testing/test_setuponly.py @@ -275,9 +275,7 @@ def test_capturing(two): ) result = pytester.runpytest("--setup-only", "-s", p) - result.stdout.no_fnmatch_line( - "this should not be captured" - ) + result.stdout.no_fnmatch_line("this should not be captured") def test_show_fixtures_and_execute_test(pytester: Pytester) -> None: From 0e2456282e5eac63282c94602832d2417365c337 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Fri, 24 Jul 2026 16:14:46 +0200 Subject: [PATCH 24/25] changelog: Clarify wording in news fragment Co-authored-by: Bruno Oliveira --- changelog/14770.misc.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/changelog/14770.misc.rst b/changelog/14770.misc.rst index f2766046650..2d3d8ac9165 100644 --- a/changelog/14770.misc.rst +++ b/changelog/14770.misc.rst @@ -1 +1,3 @@ -Move fixture cache from *FixtureDef* to *SetupState*. +Moved `FixtureDef.cached_result` to `SetupState`. + +This is part of the effort to add thread safety in pytest. From 9720a751c3c7a762350de2ef90068d994ed0cda6 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Fri, 24 Jul 2026 16:38:12 +0200 Subject: [PATCH 25/25] pyproject: Ignore additional TYPE_CHECKING expressions from coverage. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a0e2c40f4d4..0489043d714 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -480,7 +480,7 @@ exclude_lines = [ '^\s*assert False(,|$)', '^\s*case unreachable:', '^\s*assert_never\(', - '^\s*if TYPE_CHECKING:', + '^\s*if TYPE_CHECKING\s*.*', '^\s*(el)?if TYPE_CHECKING:', '^\s*@overload( |$)', '^\s*def .+: \.\.\.$',