Skip to content

Pass teardown exception to pytest_fixture_post_finalizer explicitly - #15155

Open
krishansubudhi wants to merge 2 commits into
pytest-dev:mainfrom
krishansubudhi:fix-12306-post-finalizer-exc-info
Open

krishansubudhi wants to merge 2 commits into
pytest-dev:mainfrom
krishansubudhi:fix-12306-post-finalizer-exc-info

Conversation

@krishansubudhi

@krishansubudhi krishansubudhi commented Oct 9, 2026 •

Copy link
Copy Markdown

Reworked per review: the hook now gets a teardown_exception argument instead of relying on sys.exc_info(). Also fixes #14800.

What changed and why

Up to pytest 8.0, pytest_fixture_post_finalizer was called from a finally: in FixtureDef.finish(), so a hook could inspect a failing teardown with sys.exc_info(). In 8.1, 434282e collected teardown errors into a list first. The hook then ran after the errors had already been caught, and sys.exc_info() became (None, None, None). Later, #14114 (719f1ec) made the hook the first registered finalizer so that a raising hook could not leave the fixture half torn down. That kept the hook out of reach of the active exception.

This PR calls the hook from finish() again. It runs in a finally around re-raising the collected teardown error(s), so the error (or the BaseExceptionGroup) is active while the hook runs. The cache reset moves into a nested finally, which keeps the #14114 guarantee: a raising hook still cannot stop cached_result from being cleared or the finalizers from being dropped. The existing cached_result is None early return still limits the hook to one call per teardown (#5848). The special-case lambda finalizer and its assert not self._finalizers in execute() are gone, so the source diff is net zero lines.

One behaviour difference is worth a reviewer's eye. If a fixture's teardown fails and the hook also raises, both errors are still reported, but they are chained ("During handling of the above exception, another exception occurred") instead of grouped in one BaseExceptionGroup. That is how 8.0 behaved. If you would rather keep the group, I can rework it.

test_fixture_post_finalizer_hook_exception from #14114 still passes unchanged.

How it was tested

  • New test test_fixture_post_finalizer_sees_teardown_exception in testing/python/fixtures.py. It fails on main and passes with this change. It also checks that sys.exc_info() is empty when the teardown succeeds.
  • Full suite on Python 3.11 (pytest -n 8): 4681 passed, 49 skipped, 13 xfailed, 7 xpassed. The one error came from my own -p no:cacheprovider flag, and testing/test_legacypath.py passes when run normally.
  • pre-commit run --all-files is clean (ruff, ruff-format, mypy, codespell and the rest).
  • Changelog entry changelog/12306.bugfix.rst; added myself to AUTHORS.

This PR was written with an AI coding agent and reviewed by me (Krishan); the commit carries a Co-authored-by trailer as the PR template asks.

Since pytest 8.1, a teardown error is caught and stored before
pytest_fixture_post_finalizer runs, so the hook could no longer see it
via sys.exc_info() the way it could in 8.0.

Call the hook from FixtureDef.finish() again, in a ``finally`` around
re-raising the teardown error(s), instead of registering it as the
first finalizer. The cache reset moves into a nested ``finally`` so a
raising hook still cannot leave the fixture half torn down (pytest-dev#14114),
and the existing early return still keeps the hook to one call per
teardown.

Closes pytest-dev#12306

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@psf-chronographer psf-chronographer Bot added the bot:chronographer:provided (automation) changelog entry is part of PR label Oct 9, 2026

@RonnyPfannschmidt RonnyPfannschmidt left a comment

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.

🤖 Written by Claude Opus 5.5 via Claude Code for the pytest maintainers; I prompted it, it did the work, I read it.

Thanks for digging through the history here. The timeline in the description is accurate. I'd still like a different design, because this PR repeats the mistake that the earlier changes in this area made.

The hook should not depend on sys.exc_info(), and it should not need a particular call site to see the error. 434282e broke the implicit exc_info contract by moving the call, and #14275 made the hook a finalizer. Both changes were about where the hook runs, and both broke something: #12306, then #14800. This PR moves the call site again so that the implicit state lines up. The next refactor of finish() will break it the same way.

What I'd like instead:

  • Add an explicit parameter to pytest_fixture_post_finalizer that carries the teardown error: the single exception, the BaseExceptionGroup, or None. pluggy allows hookimpls to accept a subset of the spec's arguments, so existing plugins keep working.
  • Call the hook directly from FixtureDef.finish() after the finalizers have run. Drop the lambda finalizer and the assert not self._finalizers in execute(). This part of your change is good, and it also fixes #14800 (overlaps with #14801).
  • An exception raised by the hook itself is a plugin bug and should fail hard, not be reported like a fixture teardown error (see my comment on #14114).
  • Docs and the changelog should point users to the new argument. If sys.exc_info() still happens to work, we shouldn't present it as the supported way.

One concrete bug in the current diff: when a parametrized fixture is torn down because the next test needs a different param, execute() calls self.finish(request) with the new request. With this PR the hook receives that request, so the hook for the param=1 instance sees request.param == 2. On main the hook gets the request that set the fixture up. Whatever the final design is, finish() needs to use the setup request.

Please also state in the PR whether the hook should run for a setup that failed before a result was cached. With this PR it doesn't, and #14801 tests that it does.


Generated by Claude Code

@krishansubudhi

Copy link
Copy Markdown
Author

Thanks for the thorough review. Agreed on all points. I'll rework it:
explicit teardown-exception arg on the hookspec, call from finish()
using the setup request (good catch on the param bug), let hook errors
propagate, update docs/changelog. On failed setup: I'll make the hook
run there too, consistent with #14801. Happy to coordinate with that PR
if you'd rather land one of them.

Per review, stop relying on sys.exc_info() inside the hook:

- Add a ``teardown_exception`` argument to pytest_fixture_post_finalizer:
  the single teardown exception, a BaseExceptionGroup when several
  finalizers failed, or None. Existing hookimpls that do not accept it
  keep working.
- Call the hook directly from FixtureDef.finish() after the finalizers
  have run; exceptions raised by the hook itself propagate.
- finish() now passes the request that set the fixture up, also when a
  parametrized fixture is torn down to switch to another param.
- Run the hook (once) for a setup that failed before a result was
  cached, and tear such a setup down before the next execute() (pytest-dev#14800).
@krishansubudhi

Copy link
Copy Markdown
Author

Thanks for the thorough review, reworked in f3b9ce1:

@krishansubudhi krishansubudhi changed the title Restore sys.exc_info() in pytest_fixture_post_finalizer (regression since 8.1) Pass teardown exception to pytest_fixture_post_finalizer explicitly Oct 9, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:chronographer:provided (automation) changelog entry is part of PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Internal AssertionError on stale _finalizers when a pytest_fixture_setup hookimpl raises during setup of a parametrized argument

2 participants