Skip to content

Commit e96f81f

Browse files
committed
fixtures: deprecate calling request.getfixturevalue() during teardown
on a fixture that hasn't already been requested. Fix #12882.
1 parent 7320e9e commit e96f81f

6 files changed

Lines changed: 175 additions & 2 deletions

File tree

changelog/12882.deprecation.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Calling :meth:`request.getfixturevalue() <pytest.FixtureRequest.getfixturevalue>` during teardown to request a fixture that was not already requested is now deprecated and will become an error in pytest 10.
2+
3+
See :ref:`dynamic-fixture-request-during-teardown` for details.

doc/en/deprecations.rst

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,29 @@ Below is a complete list of all pytest features which are considered deprecated.
1515
:class:`~pytest.PytestWarning` or subclasses, which can be filtered using :ref:`standard warning filters <warnings>`.
1616

1717

18+
.. _dynamic-fixture-request-during-teardown:
19+
20+
``request.getfixturevalue()`` during fixture teardown
21+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
22+
23+
.. deprecated:: 9.1
24+
25+
Calling :meth:`request.getfixturevalue() <pytest.FixtureRequest.getfixturevalue>`
26+
during teardown to request a fixture that was not already requested is deprecated.
27+
28+
This pattern is brittle because teardown runs after pytest has started unwinding active scopes.
29+
Depending on the requested fixture's scope and the current teardown order, the lookup may appear
30+
to work, or it may fail.
31+
32+
In pytest 10, first-time fixture requests made during teardown will become an error.
33+
If teardown logic needs another fixture, request it before teardown begins, either by
34+
declaring it in the fixture signature or by calling ``request.getfixturevalue()`` before
35+
the fixture yields.
36+
37+
Fixtures that were already requested before teardown started are unaffected and may still
38+
be retrieved while they remain active, though this is discouraged.
39+
40+
1841
.. _config-inicfg:
1942

2043
``config.inicfg``

src/_pytest/deprecated.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,14 @@
6767
"See https://docs.pytest.org/en/stable/deprecations.html#config-inicfg"
6868
)
6969

70+
FIXTURE_GETFIXTUREVALUE_DURING_TEARDOWN = UnformattedWarning(
71+
PytestRemovedIn10Warning,
72+
'Calling request.getfixturevalue("{argname}") during teardown is deprecated.\n'
73+
"Please request the fixture before teardown begins, either by declaring it in the fixture signature "
74+
"or by calling request.getfixturevalue() before the fixture yields.\n"
75+
"See https://docs.pytest.org/en/stable/deprecations.html#dynamic-fixture-request-during-teardown",
76+
)
77+
7078
# You want to make some `__init__` or function "private".
7179
#
7280
# def my_private_function(some, args):

src/_pytest/fixtures.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
from _pytest.config import ExitCode
5555
from _pytest.config.argparsing import Parser
5656
from _pytest.deprecated import check_ispytest
57+
from _pytest.deprecated import FIXTURE_GETFIXTUREVALUE_DURING_TEARDOWN
5758
from _pytest.deprecated import YIELD_FIXTURE
5859
from _pytest.main import Session
5960
from _pytest.mark import ParameterSet
@@ -507,6 +508,16 @@ def raiseerror(self, msg: str | None) -> NoReturn:
507508
"""
508509
raise FixtureLookupError(None, self, msg)
509510

511+
def _raise_teardown_lookup_error(self, argname: str) -> NoReturn:
512+
msg = (
513+
f'The fixture value for "{argname}" is not available during teardown '
514+
"because it was not previously requested.\n"
515+
"Only fixtures that were already active can be retrieved during teardown.\n"
516+
"Request the fixture before teardown begins by declaring it in the fixture "
517+
"signature or by calling request.getfixturevalue() before the fixture yields."
518+
)
519+
raise FixtureLookupError(argname, self, msg)
520+
510521
def getfixturevalue(self, argname: str) -> Any:
511522
"""Dynamically run a named fixture function.
512523
@@ -516,8 +527,12 @@ def getfixturevalue(self, argname: str) -> Any:
516527
or test function body.
517528
518529
This method can be used during the test setup phase or the test run
519-
phase, but during the test teardown phase a fixture's value may not
520-
be available.
530+
phase. Avoid using it during the teardown phase.
531+
532+
.. versionchanged:: 9.1
533+
Calling ``request.getfixturevalue()`` during teardown to request a
534+
fixture that was not already requested
535+
:ref:`is deprecated <dynamic-fixture-request-during-teardown>`.
521536
522537
:param argname:
523538
The fixture name.
@@ -616,6 +631,16 @@ def _get_active_fixturedef(self, argname: str) -> FixtureDef[object]:
616631
self, scope, param, param_index, fixturedef, _ispytest=True
617632
)
618633

634+
if not self.session._setupstate.is_node_active(self.node):
635+
# TODO(pytest10.1): Remove the `warn` and `if` and call
636+
# _raise_teardown_lookup_error unconditionally.
637+
warnings.warn(
638+
FIXTURE_GETFIXTUREVALUE_DURING_TEARDOWN.format(argname=argname),
639+
stacklevel=3,
640+
)
641+
if subrequest.node not in self.session._setupstate.stack:
642+
self._raise_teardown_lookup_error(argname)
643+
619644
# Make sure the fixture value is cached, running it if it isn't
620645
fixturedef.execute(request=subrequest)
621646

src/_pytest/runner.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,11 @@ def __init__(self) -> None:
507507
],
508508
] = {}
509509

510+
def is_node_active(self, node: Node) -> bool:
511+
"""Check if a node is currently active in the stack -- set up and not
512+
torn down yet."""
513+
return node in self.stack
514+
510515
def setup(self, item: Item) -> None:
511516
"""Setup objects along the collector chain to the item."""
512517
needed_collectors = item.listchain()

testing/python/fixtures.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -871,6 +871,115 @@ def test_func(resource):
871871
result = pytester.runpytest()
872872
result.stdout.fnmatch_lines(["* 2 passed in *"])
873873

874+
def test_getfixturevalue_teardown_previously_requested_does_not_warn(
875+
self, pytester: Pytester
876+
) -> None:
877+
"""Test that requesting a fixture during teardown that was previously
878+
requested is OK (#12882).
879+
880+
Note: this is still kinda dubious so don't let this test lock you in to
881+
allowing this behavior forever...
882+
"""
883+
pytester.makepyfile(
884+
"""
885+
import pytest
886+
887+
@pytest.fixture
888+
def fix(request, tmp_path):
889+
yield
890+
assert request.getfixturevalue("tmp_path") == tmp_path
891+
892+
def test_it(fix):
893+
pass
894+
"""
895+
)
896+
result = pytester.runpytest("-Werror")
897+
result.assert_outcomes(passed=1)
898+
899+
def test_getfixturevalue_teardown_new_fixture_deprecated(
900+
self, pytester: Pytester
901+
) -> None:
902+
"""Test that requesting a fixture during teardown that was not
903+
previously requested raises a deprecation warning (#12882).
904+
905+
Note: this is a case that previously worked but will become a hard
906+
error after the deprecation is completed.
907+
"""
908+
pytester.makepyfile(
909+
"""
910+
import pytest
911+
912+
@pytest.fixture(scope="session")
913+
def resource():
914+
return "value"
915+
916+
@pytest.fixture
917+
def fix(request):
918+
yield
919+
with pytest.warns(
920+
pytest.PytestRemovedIn10Warning,
921+
match=r'Calling request\\.getfixturevalue\\("resource"\\) during teardown is deprecated',
922+
):
923+
assert request.getfixturevalue("resource") == "value"
924+
925+
def test_it(fix):
926+
pass
927+
"""
928+
)
929+
result = pytester.runpytest()
930+
result.assert_outcomes(passed=1)
931+
932+
def test_getfixturevalue_teardown_new_inactive_fixture_errors(
933+
self, pytester: Pytester
934+
) -> None:
935+
"""Test that requesting a fixture during teardown that was not
936+
previously requested raises an error (#12882)."""
937+
pytester.makepyfile(
938+
"""
939+
import pytest
940+
941+
@pytest.fixture
942+
def fix(request):
943+
yield
944+
request.getfixturevalue("tmp_path")
945+
946+
def test_it(fix):
947+
pass
948+
"""
949+
)
950+
result = pytester.runpytest()
951+
result.assert_outcomes(passed=1, errors=1)
952+
result.stdout.fnmatch_lines(
953+
[
954+
(
955+
'*The fixture value for "tmp_path" is not available during '
956+
"teardown because it was not previously requested.*"
957+
),
958+
]
959+
)
960+
961+
def test_getfixturevalue_teardown_new_inactive_fixture_errors_top_request(
962+
self, pytester: Pytester
963+
) -> None:
964+
"""Test that requesting a fixture during teardown that was not
965+
previously requested raises an error (tricky case) (#12882)."""
966+
pytester.makepyfile(
967+
"""
968+
def test_it(request):
969+
request.addfinalizer(lambda: request.getfixturevalue("tmp_path"))
970+
"""
971+
)
972+
result = pytester.runpytest()
973+
result.assert_outcomes(passed=1, errors=1)
974+
result.stdout.fnmatch_lines(
975+
[
976+
(
977+
'*The fixture value for "tmp_path" is not available during '
978+
"teardown because it was not previously requested.*"
979+
),
980+
]
981+
)
982+
874983
def test_getfixturevalue(self, pytester: Pytester) -> None:
875984
item = pytester.getitem(
876985
"""

0 commit comments

Comments
 (0)