From 55766caef48c9a637c9f3cfce9d711a792b4cd60 Mon Sep 17 00:00:00 2001 From: 0718lol Date: Tue, 3 Nov 2026 23:36:00 +0800 Subject: [PATCH] Finish teardown after finalizers raise BaseExceptionGroup Collect exception groups alongside ordinary teardown outcomes so remaining finalizers release their owners before the error is re-raised. Keep direct control-flow exception behavior unchanged. Co-authored-by: OpenAI Codex --- AUTHORS | 1 + changelog/15329.bugfix.rst | 1 + src/_pytest/runner.py | 2 +- testing/test_runner.py | 77 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 changelog/15329.bugfix.rst diff --git a/AUTHORS b/AUTHORS index 136ffd6c5ee..e9c18cb7f51 100644 --- a/AUTHORS +++ b/AUTHORS @@ -3,6 +3,7 @@ merlinux GmbH, Germany, office at merlinux eu Contributors include:: +0718lol Aaron Coleman Abdeali JK Abdelrahman Elbehery diff --git a/changelog/15329.bugfix.rst b/changelog/15329.bugfix.rst new file mode 100644 index 00000000000..ff6c24334c7 --- /dev/null +++ b/changelog/15329.bugfix.rst @@ -0,0 +1 @@ +Remaining finalizers now run when a finalizer raises a :class:`BaseExceptionGroup`, preventing function-scoped fixtures from retaining cached values across tests. diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index 629796598df..22dabd5ae2e 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -587,7 +587,7 @@ def teardown_exact(self, nextitem: Item | None) -> None: fin = finalizers.pop() try: fin() - except TEST_OUTCOME as e: + except (OutcomeException, Exception, BaseExceptionGroup) as e: these_exceptions.append(e) if len(these_exceptions) == 1: diff --git a/testing/test_runner.py b/testing/test_runner.py index 8b59a5ee8c1..6ddeda5589b 100644 --- a/testing/test_runner.py +++ b/testing/test_runner.py @@ -23,6 +23,7 @@ if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup from exceptiongroup import ExceptionGroup @@ -103,6 +104,82 @@ def fin2(): assert err1.args == ("oops1",) assert err2.args == ("oops2",) + @pytest.mark.parametrize("exception_type", [GeneratorExit, KeyboardInterrupt]) + def test_teardown_base_exception_group_runs_remaining_finalizers( + self, pytester: Pytester, exception_type: type[BaseException] + ) -> None: + item = pytester.getitem("def test_func(): pass") + mod = item.listchain()[-2] + ss = item.session._setupstate + ss.setup(item) + finalized = [] + error = BaseExceptionGroup( + "cleanup", [exception_type("base marker"), RuntimeError("ordinary marker")] + ) + + def fail(): + raise error + + ss.addfinalizer(lambda: finalized.append("module"), mod) + ss.addfinalizer(lambda: finalized.append("function"), item) + ss.addfinalizer(fail, item) + with pytest.raises(BaseExceptionGroup) as exc_info: + ss.teardown_exact(None) + assert exc_info.value is error + assert finalized == ["function", "module"] + assert not ss.stack + + @pytest.mark.parametrize("exception_type", ["CancelledError", "GeneratorExit"]) + def test_teardown_base_exception_group_does_not_reuse_fixture( + self, pytester: Pytester, exception_type: str + ) -> None: + pytester.makepyfile( + f""" + from asyncio import CancelledError + import sys + import pytest + + if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup + + events = [] + allocations = 0 + + @pytest.fixture + def owner(): + global allocations + allocations += 1 + token = allocations + events.append(("allocate", token)) + yield token + events.append(("release", token)) + + @pytest.fixture + def resource(owner, request): + yield owner + if request.node.name == "test_first": + raise BaseExceptionGroup( + "cleanup", + [{exception_type}("base marker"), RuntimeError("ordinary marker")], + ) + + def test_first(resource): + assert resource == 1 + + def test_next(resource): + assert resource == 2 + + def test_releases(): + assert events == [ + ("allocate", 1), ("release", 1), + ("allocate", 2), ("release", 2), + ] + """ + ) + result = pytester.runpytest_subprocess("-q") + result.assert_outcomes(passed=3, errors=1) + result.stdout.fnmatch_lines(["*base marker*", "*ordinary marker*"]) + def test_teardown_multiple_scopes_one_fails(self, pytester: Pytester) -> None: module_teardown = []