-
-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Don't add fixture finalizer if the value is cached #11833
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 14 commits
baed905
3356fa7
6e5a8fe
fa9607e
5eddb50
f76a77b
537a831
d168ea4
fbe15ca
b3928bf
3fc5c55
007d24a
fa853df
73ae964
43f394f
74e0941
13d1049
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
Fix some instances where teardown of higher-scoped fixtures was not happening in the reverse order they were initialized in. |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -543,6 +543,12 @@ def getfixturevalue(self, argname: str) -> Any: | |
:raises pytest.FixtureLookupError: | ||
If the given fixture could not be found. | ||
""" | ||
# Note that in addition to the use case described in the docstring, | ||
# getfixturevalue() is also called by pytest itself during item setup to | ||
# evaluate the fixtures that are requested statically | ||
# (using function parameters, autouse, etc). | ||
# As well as called by `pytest_fixture_setup()` | ||
bluetech marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
fixturedef = self._get_active_fixturedef(argname) | ||
assert fixturedef.cached_result is not None, ( | ||
f'The fixture value for "{argname}" is not available. ' | ||
|
@@ -587,9 +593,8 @@ def _compute_fixture_value(self, fixturedef: "FixtureDef[object]") -> None: | |
"""Create a SubRequest based on "self" and call the execute method | ||
of the given FixtureDef object. | ||
|
||
This will force the FixtureDef object to throw away any previous | ||
results and compute a new fixture value, which will be stored into | ||
the FixtureDef object itself. | ||
If the FixtureDef has cached the result it will do nothing, otherwise it will | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The previous comment is wrong, however saying that if the FixtureDef has cached the result it does nothing is not right either. It registers finalizers, and recomputes if the cache key no longer matches, There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. oh, registering finalizers regardless of if the value is cached seems dumb... if it's cached then we've already registered a finalizer when we computed the value. And this can also cause bad teardown ordering: import pytest
@pytest.fixture(scope="module")
def fixture_1(request):
...
@pytest.fixture(scope="module")
def fixture_2(fixture_1):
print("setup 2")
yield
print("teardown 2")
@pytest.fixture(scope="module")
def fixture_3(fixture_1):
print("setup 3")
yield
print("teardown 3")
def test_1(fixture_2):
...
def test_2(fixture_3):
...
# this will reschedule fixture_2's finalizer in the parent fixture, causing it to be
# torn down before fixture 3
def test_3(fixture_2):
...
# trigger finalization of fixture_1, otherwise the cleanup would sequence 3&2 before 1 as normal
@pytest.mark.parametrize("fixture_1", [None], indirect=["fixture_1"])
def test_4(fixture_1):
... this prints
but if we remove But this is also a different issue+PR There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jakkdl would you mind opening a fresh issue for this case and the one you describe in #11833 (comment)? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
setup and run the fixture, cache the value, and schedule a finalizer for it. | ||
""" | ||
# prepare a subrequest object before calling fixture function | ||
# (latter managed by fixturedef) | ||
|
@@ -646,18 +651,9 @@ def _compute_fixture_value(self, fixturedef: "FixtureDef[object]") -> None: | |
subrequest = SubRequest( | ||
self, scope, param, param_index, fixturedef, _ispytest=True | ||
) | ||
try: | ||
# Call the fixture function. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This looks like it originally ensured teardown even when setup failed I suspect that we are missing a edge case tests there There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does the fixture need finalizing in case we never run But I could change it to try:
fixturedef.execute(...)
except:
self._schedule_finalizers(...)
raise or specifically schedule a finalizer where I made a comment on line 1076 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The specific schedule is slightly better, There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. On further consideration, changing that seems... bad to me? It means we'll be running the finalizer multiple times for a fixture with a cached exception, even if its setup was only run once. That is how it worked in the past though There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Apologies, what I meant is that a single schedule is better than the current mechanism Ideally this logic would move to a data structure instead of the code where it is There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Right - no yeah I agree that the current implementation is quite messy and would benefit from an overhaul, but that seems out of scope for this PR. The only real change should ™️ be that the finalizer isn't scheduled if the value is cached (regardless of if it's an exception or not). If the setup code for the fixture fails, it's catched by the new There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So is there a problem I should address, or was your comment just a musing on how this should be generally overhauled? Or do you consider that a requirement before modifying any logic? |
||
fixturedef.execute(request=subrequest) | ||
finally: | ||
self._schedule_finalizers(fixturedef, subrequest) | ||
jakkdl marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
def _schedule_finalizers( | ||
self, fixturedef: "FixtureDef[object]", subrequest: "SubRequest" | ||
) -> None: | ||
# If fixture function failed it might have registered finalizers. | ||
finalizer = functools.partial(fixturedef.finish, request=subrequest) | ||
subrequest.node.addfinalizer(finalizer) | ||
# Make sure the fixture value is cached, running it if it isn't | ||
fixturedef.execute(request=subrequest) | ||
|
||
|
||
@final | ||
|
@@ -788,21 +784,6 @@ def _format_fixturedef_line(self, fixturedef: "FixtureDef[object]") -> str: | |
def addfinalizer(self, finalizer: Callable[[], object]) -> None: | ||
self._fixturedef.addfinalizer(finalizer) | ||
|
||
def _schedule_finalizers( | ||
self, fixturedef: "FixtureDef[object]", subrequest: "SubRequest" | ||
) -> None: | ||
# If the executing fixturedef was not explicitly requested in the argument list (via | ||
# getfixturevalue inside the fixture call) then ensure this fixture def will be finished | ||
# first. | ||
if ( | ||
fixturedef.argname not in self._fixture_defs | ||
and fixturedef.argname not in self._pyfuncitem.fixturenames | ||
): | ||
fixturedef.addfinalizer( | ||
functools.partial(self._fixturedef.finish, request=self) | ||
) | ||
super()._schedule_finalizers(fixturedef, subrequest) | ||
|
||
|
||
@final | ||
class FixtureLookupError(LookupError): | ||
|
@@ -1055,12 +1036,13 @@ def finish(self, request: SubRequest) -> None: | |
raise BaseExceptionGroup(msg, exceptions[::-1]) | ||
|
||
def execute(self, request: SubRequest) -> FixtureValue: | ||
finalizer = functools.partial(self.finish, request=request) | ||
# Get required arguments and register our own finish() | ||
# with their finalization. | ||
for argname in self.argnames: | ||
fixturedef = request._get_active_fixturedef(argname) | ||
if not isinstance(fixturedef, PseudoFixtureDef): | ||
fixturedef.addfinalizer(functools.partial(self.finish, request=request)) | ||
fixturedef.addfinalizer(finalizer) | ||
|
||
my_cache_key = self.cache_key(request) | ||
if self.cached_result is not None: | ||
|
@@ -1080,7 +1062,14 @@ def execute(self, request: SubRequest) -> FixtureValue: | |
assert self.cached_result is None | ||
|
||
ihook = request.node.ihook | ||
result = ihook.pytest_fixture_setup(fixturedef=self, request=request) | ||
try: | ||
# Setup the fixture, run the code in it, and cache the value | ||
# in self.cached_result | ||
result = ihook.pytest_fixture_setup(fixturedef=self, request=request) | ||
finally: | ||
# schedule our finalizer, even if the setup failed | ||
request.node.addfinalizer(finalizer) | ||
|
||
return result | ||
|
||
def cache_key(self, request: SubRequest) -> object: | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4622,3 +4622,57 @@ def test_2(cls, request): | |
result = pytester.runpytest() | ||
assert result.ret == ExitCode.OK | ||
result.assert_outcomes(passed=3) | ||
|
||
|
||
def test_scoped_fixture_teardown_order(pytester: Pytester) -> None: | ||
""" | ||
Make sure teardowns happen in reverse order of setup with scoped fixtures, when | ||
a later test only depends on a subset of scoped fixtures. | ||
Regression test for https://github.com/pytest-dev/pytest/issues/1489 | ||
""" | ||
pytester.makepyfile( | ||
""" | ||
from typing import Generator | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When inside pytester, probably no need for type annotations, they're not enforced anyway.. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Imagine if they were though 🤩 |
||
|
||
import pytest | ||
|
||
|
||
last_executed = "" | ||
|
||
|
||
@pytest.fixture(scope="module") | ||
def fixture_1() -> Generator[None, None, None]: | ||
global last_executed | ||
assert last_executed == "" | ||
last_executed = "autouse_setup" | ||
jakkdl marked this conversation as resolved.
Show resolved
Hide resolved
|
||
yield | ||
assert last_executed == "noautouse_teardown" | ||
last_executed = "autouse_teardown" | ||
|
||
|
||
@pytest.fixture(scope="module") | ||
def fixture_2() -> Generator[None, None, None]: | ||
global last_executed | ||
assert last_executed == "autouse_setup" | ||
last_executed = "noautouse_setup" | ||
yield | ||
assert last_executed == "run_test" | ||
last_executed = "noautouse_teardown" | ||
|
||
|
||
def test_autouse_fixture_teardown_order(fixture_1: None, fixture_2: None) -> None: | ||
global last_executed | ||
assert last_executed == "noautouse_setup" | ||
last_executed = "run_test" | ||
|
||
|
||
def test_2(fixture_1: None) -> None: | ||
# this would previously queue an additional teardown of fixture_1, | ||
# despite fixture_1's value being cached, which caused fixture_1 to be | ||
# torn down before fixture_2 - violating the rule that teardowns should | ||
# happen in reverse order of setup. | ||
pass | ||
""" | ||
) | ||
result = pytester.runpytest() | ||
assert result.ret == 0 |
Uh oh!
There was an error while loading. Please reload this page.