From 338851a980a1255d7ff3799e13fa35200f7392e0 Mon Sep 17 00:00:00 2001 From: Will Toohey Date: Wed, 30 Jul 2025 10:51:23 +1000 Subject: [PATCH] runner: correctly cleanup item _request/funcargs if an exception was reraised during call (e.g. KeyboardInterrupt) In my test suite, I have some objects that rely on garbage collection to be cleaned up correctly. This is not amazing, but it's how the code is structured for now. If I interrupt tests with Ctrl+C, or by manually raising KeyboardInterrupt in a test, these objects are not cleaned up any more. call_and_report re-raises Exit and KeyboardInterrupt, which breaks the cleanup logic in runtestprotocol that unsets the item funcargs (which is where my objects end up living as references, as they're passed in as fixtures). By just wrapping the entire block with try: ... finally: ..., cleanup works again as expected. Co-authored-by: Ran Benita --- changelog/13626.bugfix.rst | 2 ++ src/_pytest/runner.py | 36 +++++++++++++++++++----------------- testing/test_runner.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 17 deletions(-) create mode 100644 changelog/13626.bugfix.rst diff --git a/changelog/13626.bugfix.rst b/changelog/13626.bugfix.rst new file mode 100644 index 00000000000..e58c76749fa --- /dev/null +++ b/changelog/13626.bugfix.rst @@ -0,0 +1,2 @@ +Fixed function-scoped fixture values being kept alive after a test was interrupted by ``KeyboardInterrupt`` or early exit, +allowing them to potentially be released more promptly. diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index 18d3591abfe..d9209befd48 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -128,23 +128,25 @@ def runtestprotocol( # This only happens if the item is re-run, as is done by # pytest-rerunfailures. item._initrequest() # type: ignore[attr-defined] - rep = call_and_report(item, "setup", log) - reports = [rep] - if rep.passed: - if item.config.getoption("setupshow", False): - show_test_item(item) - if not item.config.getoption("setuponly", False): - reports.append(call_and_report(item, "call", log)) - # If the session is about to fail or stop, teardown everything - this is - # necessary to correctly report fixture teardown errors (see #11706) - if item.session.shouldfail or item.session.shouldstop: - nextitem = None - reports.append(call_and_report(item, "teardown", log, nextitem=nextitem)) - # After all teardown hooks have been called - # want funcargs and request info to go away. - if hasrequest: - item._request = False # type: ignore[attr-defined] - item.funcargs = None # type: ignore[attr-defined] + try: + rep = call_and_report(item, "setup", log) + reports = [rep] + if rep.passed: + if item.config.getoption("setupshow", False): + show_test_item(item) + if not item.config.getoption("setuponly", False): + reports.append(call_and_report(item, "call", log)) + # If the session is about to fail or stop, teardown everything - this is + # necessary to correctly report fixture teardown errors (see #11706) + if item.session.shouldfail or item.session.shouldstop: + nextitem = None + reports.append(call_and_report(item, "teardown", log, nextitem=nextitem)) + finally: + # After all teardown hooks have been called (or an exception was reraised) + # want funcargs and request info to go away. + if hasrequest: + item._request = False # type: ignore[attr-defined] + item.funcargs = None # type: ignore[attr-defined] return reports diff --git a/testing/test_runner.py b/testing/test_runner.py index 3cb3c3a3841..3cf6be69de9 100644 --- a/testing/test_runner.py +++ b/testing/test_runner.py @@ -7,6 +7,7 @@ from pathlib import Path import sys import types +from typing import cast from _pytest import outcomes from _pytest import reports @@ -494,6 +495,37 @@ def test_func(): else: assert False, "did not raise" + def test_keyboardinterrupt_clears_request_and_funcargs( + self, pytester: Pytester + ) -> None: + """Ensure that an item's fixtures are cleared quickly even if exiting + early due to a keyboard interrupt (#13626).""" + item = pytester.getitem( + """ + import pytest + + @pytest.fixture + def resource(): + return object() + + def test_func(resource): + raise KeyboardInterrupt("fake") + """ + ) + assert isinstance(item, pytest.Function) + assert item._request + assert item.funcargs == {} + + try: + runner.runtestprotocol(item, log=False) + except KeyboardInterrupt: + pass + else: + assert False, "did not raise" + + assert not cast(object, item._request) + assert not item.funcargs + class TestSessionReports: def test_collect_result(self, pytester: Pytester) -> None: