You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
If a pytest_fixture_setup hook implementation raises (including raising Skipped via pytest.skip()), the FixtureDef for the parametrized argument is left with a stale entry in self._finalizers. Every subsequent parameter for that same argument then fails during setup with an internal AssertionError at _pytest/fixtures.py:1221 instead of running.
The first parameter reports correctly. Only the ones after it break, so the damage is order-dependent and the reported failures point at the wrong parameters.
This is a regression in 9.1.0. 9.0.0 and 8.4.x are fine, and it still reproduces on main.
Minimal reproducer
No third-party plugins required.
conftest.py:
importpytest@pytest.hookimpl(tryfirst=True)defpytest_fixture_setup(fixturedef, request):
"""Resolve a fixture by name from inside pytest_fixture_setup, based on the param value. This is what pytest-lazy-fixtures does to implement lf()."""param=getattr(request, "param", None)
ifisinstance(param, str) andparam.startswith("fixture:"):
request.param=request.getfixturevalue(param[len("fixture:"):])
Actual on 9.1.0, 9.1.1 and main: 1 skipped, 2 errors, both errors being
# Register the pytest_fixture_post_finalizer as the first finalizer,
# which is executed last.
> assert not self._finalizers
^^^^^^^^^^^^^^^^^^^^
E AssertionError
.../_pytest/fixtures.py:1221: AssertionError
pytest.skip() is not special here — replacing it with raise RuntimeError("boom")
produces the same internal AssertionError on the following parameters (the
first parameter then reports the RuntimeError correctly, as it should).
Version bisect
pytest
result
8.3.5
2 passed, 1 skipped
8.4.2
2 passed, 1 skipped
9.0.0
2 passed, 1 skipped
9.1.0
1 skipped, 2 errors
9.1.1
1 skipped, 2 errors
main (9.2.0.dev133+g56b196e92)
1 skipped, 2 errors
Cause
Two commits that both landed in 9.1.0 encode contradictory assumptions about when _finalizers may be populated.
96728d56 ("fix pytest_fixture_post_finalizer sometimes called multiple times", #5848) added an early return to FixtureDef.finish() (_pytest/fixtures.py:1146):
deffinish(self, request: SubRequest) ->None:
ifself.cached_resultisNone:
# Already finished. It is assumed that finalizers cannot be added in# this state.return
719f1ec9 ("improve handling of pytest_fixture_post_finalizer raising", #14114) then made FixtureDef.execute() add a finalizer in exactly that state — cached_result is still None at this point, because the hook that sets it has
not been called yet (_pytest/fixtures.py:1219):
# Register the pytest_fixture_post_finalizer as the first finalizer,# which is executed last.assertnotself._finalizersself.addfinalizer(
lambda: request.node.ihook.pytest_fixture_post_finalizer(
fixturedef=self, request=request
)
)
ihook=request.node.ihooktry:
result: FixtureValue=ihook.pytest_fixture_setup(
fixturedef=self, request=request
)
finally:
# Schedule our finalizer, even if the setup failed.request.node.addfinalizer(finalizer)
So the stated assumption in finish() — "finalizers cannot be added in this state" — is violated by execute() itself, on every fixture setup.
The failure sequence, confirmed by tracing addfinalizer / finish / execute on the val fixturedef:
execute() for param 1: _finalizers is empty, assert passes, the pytest_fixture_post_finalizer lambda is appended → len(_finalizers) == 1, cached_result still None.
ihook.pytest_fixture_setup(...) raises. cached_result is never assigned.
The finally correctly schedules finish on the node.
At teardown, that scheduled finish(request) runs — and hits the early
return, because cached_result is None. _finalizers is not drained.
execute() for param 2: _finalizers still has 1 entry and cached_result
is None, so the cache branch is skipped and assert not self._finalizers
trips.
Trace output for the reproducer above, with FixtureDef.addfinalizer, FixtureDef.finish, FixtureDef.execute and SetupState.addfinalizer
monkeypatched to log calls for argname == "val":
[execute] val: _finalizers=0 cached=False
[addfinalizer] val -> now 1
[SetupState.addfinalizer] node=test_thing[fixture:derived] fin=<bound method finish of <FixtureDef argname='val' ...>>
[finish] val (had 1 finalizers) <-- early-returns, does not drain
[execute] val: _finalizers=1 cached=False <-- assert trips
#14777 is the same family of bug — a setup failure before cached_result is set
leaves a stale finalizer and the next execute() trips the same assert — but its
fix does not cover this case. That fix moves resolve_fixture_function() and the
async check inside the try/except TEST_OUTCOME in the default pytest_fixture_setup implementation, so the failure gets cached. Here the
exception comes from a different hookimpl that runs before the default
implementation (tryfirst), so the default implementation never executes and
nothing caches the failure. Verified: the reproducer above still fails on main,
which already contains that work.
Suggested fixes
Either would resolve it; the first seems closer to the intent of both commits.
Make finish()'s early return conditional on there being nothing to do, e.g. if self.cached_result is None and not self._finalizers: return, so a
fixture whose setup failed still gets drained. This keeps Fixture with dependencies executes pytest_fixture_post_finalizer multiple times #5848's
"post_finalizer not called twice" property, since the guard still short-
circuits the genuinely-already-finished case.
Register the pytest_fixture_post_finalizer finalizer only after cached_result has been set, restoring the invariant that finish()
documents.
Either way it would be worth turning the assert not self._finalizers into a
real error path or dropping it, since as written any hook-raised setup failure
converts into an internal assertion with no indication of the real cause.
Real-world impact
Hit via pytest-lazy-fixtures, which implements lf() by resolving fixtures
inside pytest_fixture_setup exactly as the reproducer does. Any @pytest.mark.parametrize list that mixes lf(...) with other values breaks
under 9.1.x as soon as the lazy fixture's dependency chain skips — which is the
normal case for integration tests that skip when a backing service is absent. In
our suite that turned 16 tests into internal AssertionErrors, attributed to
parameters that had nothing to do with the unavailable service.
Environment
pytest 9.1.1
Python 3.11.10
platform darwin
plugins: none required for the reproducer
Problem
If a
pytest_fixture_setuphook implementation raises (including raisingSkippedviapytest.skip()), theFixtureDeffor the parametrized argument is left with a stale entry inself._finalizers. Every subsequent parameter for that same argument then fails during setup with an internalAssertionErrorat_pytest/fixtures.py:1221instead of running.The first parameter reports correctly. Only the ones after it break, so the damage is order-dependent and the reported failures point at the wrong parameters.
This is a regression in 9.1.0. 9.0.0 and 8.4.x are fine, and it still reproduces on
main.Minimal reproducer
No third-party plugins required.
conftest.py:test_pure.py:Run
pytest.Expected (and what 8.4.2 / 9.0.0 do):
2 passed, 1 skipped.Actual on 9.1.0, 9.1.1 and
main:1 skipped, 2 errors, both errors beingpytest.skip()is not special here — replacing it withraise RuntimeError("boom")produces the same internal
AssertionErroron the following parameters (thefirst parameter then reports the
RuntimeErrorcorrectly, as it should).Version bisect
Cause
Two commits that both landed in 9.1.0 encode contradictory assumptions about when
_finalizersmay be populated.96728d56("fixpytest_fixture_post_finalizersometimes called multiple times", #5848) added an early return toFixtureDef.finish()(_pytest/fixtures.py:1146):719f1ec9("improve handling ofpytest_fixture_post_finalizerraising",#14114) then made
FixtureDef.execute()add a finalizer in exactly that state —cached_resultis stillNoneat this point, because the hook that sets it hasnot been called yet (
_pytest/fixtures.py:1219):So the stated assumption in
finish()— "finalizers cannot be added in this state" — is violated byexecute()itself, on every fixture setup.The failure sequence, confirmed by tracing
addfinalizer/finish/executeon thevalfixturedef:execute()for param 1:_finalizersis empty, assert passes, thepytest_fixture_post_finalizerlambda is appended →len(_finalizers) == 1,cached_resultstillNone.ihook.pytest_fixture_setup(...)raises.cached_resultis never assigned.The
finallycorrectly schedulesfinishon the node.finish(request)runs — and hits the earlyreturn, because
cached_result is None._finalizersis not drained.execute()for param 2:_finalizersstill has 1 entry andcached_resultis
None, so the cache branch is skipped andassert not self._finalizerstrips.
Trace output for the reproducer above, with
FixtureDef.addfinalizer,FixtureDef.finish,FixtureDef.executeandSetupState.addfinalizermonkeypatched to log calls for
argname == "val":Relationship to #14775 / #14777
#14777 is the same family of bug — a setup failure before
cached_resultis setleaves a stale finalizer and the next
execute()trips the same assert — but itsfix does not cover this case. That fix moves
resolve_fixture_function()and theasync check inside the
try/except TEST_OUTCOMEin the defaultpytest_fixture_setupimplementation, so the failure gets cached. Here theexception comes from a different hookimpl that runs before the default
implementation (
tryfirst), so the default implementation never executes andnothing caches the failure. Verified: the reproducer above still fails on
main,which already contains that work.
Suggested fixes
Either would resolve it; the first seems closer to the intent of both commits.
finish()'s early return conditional on there being nothing to do, e.g.if self.cached_result is None and not self._finalizers: return, so afixture whose setup failed still gets drained. This keeps Fixture with dependencies executes
pytest_fixture_post_finalizermultiple times #5848's"post_finalizer not called twice" property, since the guard still short-
circuits the genuinely-already-finished case.
pytest_fixture_post_finalizerfinalizer only aftercached_resulthas been set, restoring the invariant thatfinish()documents.
Either way it would be worth turning the
assert not self._finalizersinto areal error path or dropping it, since as written any hook-raised setup failure
converts into an internal assertion with no indication of the real cause.
Real-world impact
Hit via
pytest-lazy-fixtures, which implementslf()by resolving fixturesinside
pytest_fixture_setupexactly as the reproducer does. Any@pytest.mark.parametrizelist that mixeslf(...)with other values breaksunder 9.1.x as soon as the lazy fixture's dependency chain skips — which is the
normal case for integration tests that skip when a backing service is absent. In
our suite that turned 16 tests into internal
AssertionErrors, attributed toparameters that had nothing to do with the unavailable service.
Environment