Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
87453b1
fixtures, runner: Store cached fixture values in SetupState, instead …
seifertm Jul 22, 2026
9204a14
fixtures: Convert fixture results to NamedTuples
seifertm Jul 22, 2026
21565b5
fixtures: Extracted method to invalidate the cached result of a fixture.
seifertm Jul 22, 2026
8306a1f
fixtures: Don't allow setting the cached fixture value to None.
seifertm Jul 22, 2026
e09fda8
fixtures: RequestFixtureDef.cache_key returns the correct cache key.
seifertm Jul 22, 2026
ee27c17
fixtures: Move methods for manipulating fixture cache from FixtureDef…
seifertm Jul 23, 2026
7ebde2f
fixtures: Move determination of fixture value cache keys from Fixture…
seifertm Jul 23, 2026
18813a7
fixtures: Split up methods for cache population depending on whether …
seifertm Jul 23, 2026
5497a67
fixtures: Selecting the correct cache key for fixture values is now t…
seifertm Jul 23, 2026
c29c6e9
fixtures: FixtureRequest._set_cached_exception no longer requires the…
seifertm Jul 23, 2026
c5b7e16
fixtures: Rename _set_cached_result and _set_cached_exception to _cac…
seifertm Jul 23, 2026
c5d2887
fixtures: Convert FixtureRequest._cache_key into a property
seifertm Jul 23, 2026
2a28f9e
docs: Add news fragment
seifertm Jul 23, 2026
f04e086
setuponly, runner: Move track currently active fixture param as part …
seifertm Jul 24, 2026
c9555d4
fixtures: Rename cache_key to active_param or param to be more explic…
seifertm Jul 24, 2026
0269198
fixtures: Use sentinel value for non-existant fixture params when cac…
seifertm Jul 24, 2026
18f5852
fixtures, setuponly: Reuse SetupState.fixture_cache to track active p…
seifertm Jul 24, 2026
61286bd
fixtures: Address typing error for generic namedtuples in Python 3.10
seifertm Jul 24, 2026
dd68168
fixtures: Replace mentions of FixtureDef.cached_result in the docs
seifertm Jul 24, 2026
b49c266
setuponly: Add test for suppressing capture.
seifertm Jul 24, 2026
8a17fc1
setuponly: Adds assertion that the cached fixture value cannot be emp…
seifertm Jul 24, 2026
052ad16
setuponly: _show_fixture_action directly receives the fixture param r…
seifertm Jul 24, 2026
8d136d4
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 24, 2026
0e24562
changelog: Clarify wording in news fragment
seifertm Jul 24, 2026
9720a75
pyproject: Ignore additional TYPE_CHECKING expressions from coverage.
seifertm Jul 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog/14770.misc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Moved `FixtureDef.cached_result` to `SetupState`.

This is part of the effort to add thread safety in pytest.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 .+: \.\.\.$',
Expand Down
123 changes: 81 additions & 42 deletions src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -96,23 +97,32 @@
FixtureFunction = Callable[..., object]
# The type of a fixture function (type alias generic in fixture value).
_FixtureFunc = Callable[..., FixtureValue] | Callable[..., Generator[FixtureValue]]
# 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],
]
)
# Sentinel value for unset fixture params
_NO_PARAM = object()


# 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
Comment on lines +105 to +110

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code is marked as uncovered or partially covered, even though it's only used for type checking.

Do you have any suggestions how I can deal with that?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets add a # pragma: no cover on the if line.

Or better yet, update the exceptions in

pytest/pyproject.toml

Lines 476 to 487 in ef052d0

exclude_lines = [
'\#\s*pragma: no cover',
'^\s*raise NotImplementedError\b',
'^\s*return NotImplemented\b',
'^\s*assert False(,|$)',
'^\s*case unreachable:',
'^\s*assert_never\(',
'^\s*if TYPE_CHECKING:',
'^\s*(el)?if TYPE_CHECKING:',
'^\s*@overload( |$)',
'^\s*def .+: \.\.\.$',
'^\s*@pytest\.mark\.xfail',
to if TYPE_CHECKING\s.*

else:

class _FixtureResult(NamedTuple):
value: Any
param: object
exception_and_traceback: None


class _FixtureException(NamedTuple):
value: None
param: object
exception_and_traceback: tuple[BaseException, types.TracebackType | None]


_FixtureCachedResult = _FixtureResult[FixtureValue] | _FixtureException # type: ignore[type-arg]


def pytest_sessionstart(session: Session) -> None:
Expand Down Expand Up @@ -629,11 +639,44 @@ 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 = 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 fixturedef.cached_result[0]
return fixture_result.value

def _get_cached_result(
self, fixturedef: FixtureDef[FixtureValue]
) -> _FixtureCachedResult[FixtureValue] | None:
return self.session._setupstate.fixture_cache.get(fixturedef)

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._active_param, None
)

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._active_param, (exception, exception.__traceback__)
)

@property
def _active_param(self) -> object:
return getattr(self, "param", _NO_PARAM)

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.
Expand Down Expand Up @@ -1123,9 +1166,6 @@ 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]]] = []

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated to the PR, more of a question (@RonnyPfannschmidt): what about the finalizers? We likely would also want finalizers to be hosted per-thread?


# only used to emit a deprecationwarning, can be removed in pytest9
Expand All @@ -1145,7 +1185,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 request._get_cached_result(self) is None:
# Already finished. It is assumed that finalizers cannot be added in
# this state.
return
Expand All @@ -1161,7 +1201,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
request._invalidate_fixture_cache(self)
self._finalizers.clear()
if len(exceptions) == 1:
raise exceptions[0]
Expand All @@ -1175,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:
Expand All @@ -1188,27 +1229,27 @@ 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:
request_cache_key = self.cache_key(request)
cache_key = self.cached_result[1]
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 self.cached_result[2] is not None:
exc, exc_tb = self.cached_result[2]
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 self.cached_result[0]
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)
assert self.cached_result 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
Expand All @@ -1229,7 +1270,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
)
Expand All @@ -1239,9 +1279,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"<FixtureDef argname={self.argname!r} scope={self.scope!r} baseid={self.baseid!r}>"

Expand All @@ -1263,7 +1300,7 @@ def __init__(self, request: FixtureRequest) -> None:
node=request.node,
_ispytest=True,
)
self.cached_result = (request, [0], None)
request._cache_value(self, request)

def addfinalizer(self, finalizer: Callable[[], object]) -> None:
pass
Expand Down Expand Up @@ -1321,7 +1358,6 @@ def pytest_fixture_setup(
kwargs[argname] = request.getfixturevalue(argname)

fixturefunc = resolve_fixture_function(fixturedef, request)
my_cache_key = fixturedef.cache_key(request)

if inspect.isasyncgenfunction(fixturefunc) or inspect.iscoroutinefunction(
fixturefunc
Expand All @@ -1342,9 +1378,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.cached_result = (None, my_cache_key, (e, e.__traceback__))
request._cache_exception(
fixturedef,
e,
)
raise
fixturedef.cached_result = (result, my_cache_key, None)
request._cache_value(fixturedef, result)
return result


Expand Down
4 changes: 2 additions & 2 deletions src/_pytest/hookspec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/_pytest/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Comment on lines +514 to +516

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think having the type checking here is important, let's move the type definitions we need to a new fixtures_types module, containing only the type definitions, which allows us to import them here without cycles.


def is_node_active(self, node: Node) -> bool:
"""Check if a node is currently active in the stack -- set up and not
Expand Down
31 changes: 20 additions & 11 deletions src/_pytest/setuponly.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -46,23 +48,30 @@ 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")
# 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
)
else:
param = _NO_PARAM
_show_fixture_action(request.config, fixturedef, param, "SETUP")


def pytest_fixture_post_finalizer(
fixturedef: FixtureDef[object], request: SubRequest
) -> None:
if fixturedef.cached_result 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
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(config, fixturedef, cached_result.param, "TEARDOWN")


def _show_fixture_action(
fixturedef: FixtureDef[object], config: Config, msg: str
config: Config, fixturedef: FixtureDef[object], param: object, msg: str
) -> None:
capman = config.pluginmanager.getplugin("capturemanager")
if capman:
Expand All @@ -82,8 +91,8 @@ 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 param is not _NO_PARAM:
tw.write(f"[{saferepr(param, maxsize=42)}]")

tw.flush()

Expand Down
5 changes: 2 additions & 3 deletions src/_pytest/setupplan.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,8 @@ 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)
fixturedef.cached_result = (None, my_cache_key, None)
return fixturedef.cached_result
request._cache_value(fixturedef, None)
return request._get_cached_result(fixturedef)
return None


Expand Down
19 changes: 19 additions & 0 deletions testing/test_setuponly.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,25 @@ def test_capturing(two):
)


def test_suppress_capturing(pytester: Pytester) -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this test defined here?

@seifertm seifertm Jul 24, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was added to make the CI pass. The codecov job seems to fail, if the coverage of the PR is less than 100%. Apparently this also applies to files that are touched by the PR, but didn't have full coverage. This test specifically covers the negative branch of this if statement.

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(
Expand Down
Loading