Skip to content
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

Don't add fixture finalizer if the value is cached #11833

Merged
merged 17 commits into from
Mar 16, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
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
1 change: 1 addition & 0 deletions changelog/1489.bugfix.rst
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.
52 changes: 20 additions & 32 deletions src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,11 @@ 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
jakkdl marked this conversation as resolved.
Show resolved Hide resolved
# evaluate the fixtures that are requested statically
# (using function parameters, autouse, etc).

fixturedef = self._get_active_fixturedef(argname)
assert fixturedef.cached_result is not None, (
f'The fixture value for "{argname}" is not available. '
Expand Down Expand Up @@ -567,9 +572,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
Copy link
Member

Choose a reason for hiding this comment

The 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,

Copy link
Member Author

Choose a reason for hiding this comment

The 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

setup 2
setup 3
teardown 2
teardown 3

but if we remove test_3 we get 2-3-3-2.

But this is also a different issue+PR

Copy link
Member

Choose a reason for hiding this comment

The 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)?

Copy link
Member Author

@jakkdl jakkdl Mar 18, 2024

Choose a reason for hiding this comment

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

Done: #12134 and #12135

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)
Expand Down Expand Up @@ -629,18 +633,8 @@ def _compute_fixture_value(self, fixturedef: "FixtureDef[object]") -> None:

# Check if a higher-level scoped fixture accesses a lower level one.
subrequest._check_scope(argname, self._scope, scope)
try:
# Call the fixture function.
Copy link
Member

Choose a reason for hiding this comment

The 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

Copy link
Member Author

@jakkdl jakkdl Feb 12, 2024

Choose a reason for hiding this comment

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

Does the fixture need finalizing in case we never run ihook.pytest_fixture_setup? I thought it was tightly coupled to the user-supplied code.

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

Copy link
Member

Choose a reason for hiding this comment

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

The specific schedule is slightly better,

Copy link
Member Author

Choose a reason for hiding this comment

The 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

Copy link
Member

Choose a reason for hiding this comment

The 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

Copy link
Member Author

Choose a reason for hiding this comment

The 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.
I'm not sure I follow your original comment in that case, the try/except that previously was on this line is now moved one step deeper into execute() - and so there should be no change wrt to behaviour of scheduling finalizer if setup fails.

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 try/finally.
If some other unrelated code in execute raises an exception the finalizer will not be scheduled, but in that case the setup has not run either.

Copy link
Member Author

Choose a reason for hiding this comment

The 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
Expand Down Expand Up @@ -768,21 +762,6 @@ def _factorytraceback(self) -> List[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):
Expand Down Expand Up @@ -1040,14 +1019,15 @@ def finish(self, request: SubRequest) -> None:
self._finalizers.clear()

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 argname != "request":
# PseudoFixtureDef is only for "request".
assert isinstance(fixturedef, FixtureDef)
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:
Expand All @@ -1057,6 +1037,7 @@ def execute(self, request: SubRequest) -> FixtureValue:
if my_cache_key is cache_key:
if self.cached_result[2] is not None:
exc = self.cached_result[2]
# this would previously trigger adding a finalizer. Should it?
raise exc
else:
result = self.cached_result[0]
Expand All @@ -1067,7 +1048,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:
Expand Down
131 changes: 131 additions & 0 deletions testing/python/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -4559,3 +4559,134 @@ def test_deduplicate_names() -> None:
assert items == ("a", "b", "c", "d")
items = deduplicate_names((*items, "g", "f", "g", "e", "b"))
assert items == ("a", "b", "c", "d", "g", "f", "e")


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
Copy link
Member

Choose a reason for hiding this comment

The 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..

Copy link
Member Author

Choose a reason for hiding this comment

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

Imagine if they were though 🤩
Idk, they could maybe help understanding the code, or if somebody copies the code out from the pytester-string to "real" code. I don't see much/any harm in leaving them anyhow


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


def test_scope_fixture_caching_1(pytester: Pytester) -> None:
Copy link
Member

Choose a reason for hiding this comment

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

Perhaps we can submit test_scope_fixture_caching_1 and test_scope_fixture_caching_2 in a separate PR, as they're nice to have anyway and will reduce the size of this PR.

Copy link
Member Author

Choose a reason for hiding this comment

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

Seems reasonable, see #12121

"""
Make sure setup and finalization is only run once when using fixture
multiple times. This might be a duplicate of another test."""
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
"""
Make sure setup and finalization is only run once when using fixture
multiple times. This might be a duplicate of another test."""
"""Make sure setup and finalization is only run once when using a fixture
multiple times."""

pytester.makepyfile(
"""
from __future__ import annotations

from typing import Generator

import pytest
executed: list[str] = []
@pytest.fixture(scope="class")
def fixture_1() -> Generator[None, None, None]:
executed.append("fix setup")
yield
executed.append("fix teardown")


class TestFixtureCaching:
def test_1(self, fixture_1: None) -> None:
assert executed == ["fix setup"]

def test_2(self, fixture_1: None) -> None:
assert executed == ["fix setup"]


def test_expected_setup_and_teardown() -> None:
assert executed == ["fix setup", "fix teardown"]
"""
)
result = pytester.runpytest()
assert result.ret == 0


def test_scope_fixture_caching_2(pytester: Pytester) -> None:
"""Make sure setup & finalization is only run once, with a cached exception."""
pytester.makepyfile(
"""
from __future__ import annotations

from typing import Generator

import pytest
executed_crash: list[str] = []


@pytest.fixture(scope="class")
def fixture_crash(request: pytest.FixtureRequest) -> None:
executed_crash.append("fix_crash setup")

def my_finalizer() -> None:
executed_crash.append("fix_crash teardown")

request.addfinalizer(my_finalizer)

raise Exception("foo")


class TestFixtureCachingException:
@pytest.mark.xfail
def test_crash_1(self, fixture_crash: None) -> None:
...

@pytest.mark.xfail
def test_crash_2(self, fixture_crash: None) -> None:
...


def test_crash_expected_setup_and_teardown() -> None:
assert executed_crash == ["fix_crash setup", "fix_crash teardown"]
"""
)
result = pytester.runpytest()
assert result.ret == 0