From 53b323bd73ccf136834f1db443b38dc7cfec07eb Mon Sep 17 00:00:00 2001 From: Dextheking1 Date: Wed, 23 Sep 2026 18:59:09 +0200 Subject: [PATCH 1/4] Fix fixture finalizers being skipped when setup is interrupted Record interruptions (e.g. KeyboardInterrupt) that escape pytest_fixture_setup in cached_result so FixtureDef.finish() still runs finalizers registered via request.addfinalizer() during teardown, as documented. Fixes #15067. --- changelog/15067.bugfix.rst | 4 ++++ src/_pytest/fixtures.py | 6 ++++++ testing/python/fixtures.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 changelog/15067.bugfix.rst diff --git a/changelog/15067.bugfix.rst b/changelog/15067.bugfix.rst new file mode 100644 index 00000000000..7bb7df762d8 --- /dev/null +++ b/changelog/15067.bugfix.rst @@ -0,0 +1,4 @@ +Fixed fixture finalizers registered via ``request.addfinalizer()`` being skipped +when fixture setup was interrupted (for example by ``KeyboardInterrupt`` or a +fail-fast plugin) before the fixture value was cached. The interruption is now +recorded so the registered finalizers still run during teardown, as documented. diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 30f44d44dfc..ce1cfb8155c 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1404,6 +1404,12 @@ def pytest_fixture_setup( e._use_item_location = True fixturedef.cached_result = (None, my_cache_key, (e, e.__traceback__)) raise + except BaseException as e: + # Fixture setup was interrupted (e.g. by KeyboardInterrupt or a + # fail-fast plugin). Record the failure so that finalizers already + # registered via addfinalizer() still run during teardown (#15067). + fixturedef.cached_result = (None, my_cache_key, (e, e.__traceback__)) + raise fixturedef.cached_result = (result, my_cache_key, None) return result diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index bc7b5a40cc2..712d5496677 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -1111,6 +1111,38 @@ def test_fix(myfix): mod = reprec.getcalls("pytest_runtest_setup")[0].item.module assert not mod.values + def test_request_addfinalizer_interrupted_setup( + self, pytester: Pytester + ) -> None: + """Ensure finalizers registered before fixture setup is interrupted + (e.g. by KeyboardInterrupt) still run during teardown (#15067).""" + pytester.makeconftest( + """ + from pathlib import Path + + import pytest + + marker = Path(__file__).with_name("finalizer-ran") + + + @pytest.fixture + def resource(request): + request.addfinalizer( + lambda: marker.write_text("ran", encoding="utf-8") + ) + raise KeyboardInterrupt + """ + ) + pytester.makepyfile( + """ + def test_setup(resource): + pass + """ + ) + result = pytester.runpytest_subprocess() + assert result.ret == ExitCode.INTERRUPTED + assert pytester.path.joinpath("finalizer-ran").exists() + def test_request_addfinalizer_partial_setup_failure( self, pytester: Pytester ) -> None: From 01dd864ccf2c0e05a55cdd6e169a6ab3764a8b69 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:59:48 +0000 Subject: [PATCH 2/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- testing/python/fixtures.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 712d5496677..13915723336 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -1111,9 +1111,7 @@ def test_fix(myfix): mod = reprec.getcalls("pytest_runtest_setup")[0].item.module assert not mod.values - def test_request_addfinalizer_interrupted_setup( - self, pytester: Pytester - ) -> None: + def test_request_addfinalizer_interrupted_setup(self, pytester: Pytester) -> None: """Ensure finalizers registered before fixture setup is interrupted (e.g. by KeyboardInterrupt) still run during teardown (#15067).""" pytester.makeconftest( From d7dcd8f69a5287f90faf89ba6de02f6bc402f442 Mon Sep 17 00:00:00 2001 From: Dextheking1 Date: Wed, 23 Sep 2026 19:18:23 +0200 Subject: [PATCH 3/4] Add in-process regression test for #15067 The subprocess regression test cannot be seen by coverage, so the new except BaseException branch in pytest_fixture_setup showed 0% patch coverage. This in-process variant runs the same interrupted-setup scenario in-process (with no_reraise_ctrlc=True) so coverage observes the new branch. --- testing/python/fixtures.py | 39 +++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 13915723336..d670fd6b69c 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -1111,7 +1111,9 @@ def test_fix(myfix): mod = reprec.getcalls("pytest_runtest_setup")[0].item.module assert not mod.values - def test_request_addfinalizer_interrupted_setup(self, pytester: Pytester) -> None: + def test_request_addfinalizer_interrupted_setup( + self, pytester: Pytester + ) -> None: """Ensure finalizers registered before fixture setup is interrupted (e.g. by KeyboardInterrupt) still run during teardown (#15067).""" pytester.makeconftest( @@ -1141,6 +1143,41 @@ def test_setup(resource): assert result.ret == ExitCode.INTERRUPTED assert pytester.path.joinpath("finalizer-ran").exists() + def test_request_addfinalizer_interrupted_setup_inprocess( + self, pytester: Pytester + ) -> None: + """In-process variant of test_request_addfinalizer_interrupted_setup. + + Runs the interrupted setup in-process so coverage observes the + ``except BaseException`` branch in ``pytest_fixture_setup`` (#15067). + """ + pytester.makeconftest( + """ + from pathlib import Path + + import pytest + + marker = Path(__file__).with_name("finalizer-ran-inprocess") + + + @pytest.fixture + def resource(request): + request.addfinalizer( + lambda: marker.write_text("ran", encoding="utf-8") + ) + raise KeyboardInterrupt + """ + ) + pytester.makepyfile( + """ + def test_setup(resource): + pass + """ + ) + result = pytester.runpytest_inprocess(no_reraise_ctrlc=True) + assert result.ret == ExitCode.INTERRUPTED + assert pytester.path.joinpath("finalizer-ran-inprocess").exists() + def test_request_addfinalizer_partial_setup_failure( self, pytester: Pytester ) -> None: From 9d19a5f485a7b51a7feca8e441c79eaf3624672b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:18:46 +0000 Subject: [PATCH 4/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- testing/python/fixtures.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index d670fd6b69c..e998674a6ff 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -1111,9 +1111,7 @@ def test_fix(myfix): mod = reprec.getcalls("pytest_runtest_setup")[0].item.module assert not mod.values - def test_request_addfinalizer_interrupted_setup( - self, pytester: Pytester - ) -> None: + def test_request_addfinalizer_interrupted_setup(self, pytester: Pytester) -> None: """Ensure finalizers registered before fixture setup is interrupted (e.g. by KeyboardInterrupt) still run during teardown (#15067).""" pytester.makeconftest(