Skip to content

Commit 338851a

Browse files
monbluetech
andcommitted
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 <ran@unusedvar.com>
1 parent 5898e9c commit 338851a

File tree

3 files changed

+53
-17
lines changed

3 files changed

+53
-17
lines changed

changelog/13626.bugfix.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fixed function-scoped fixture values being kept alive after a test was interrupted by ``KeyboardInterrupt`` or early exit,
2+
allowing them to potentially be released more promptly.

src/_pytest/runner.py

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -128,23 +128,25 @@ def runtestprotocol(
128128
# This only happens if the item is re-run, as is done by
129129
# pytest-rerunfailures.
130130
item._initrequest() # type: ignore[attr-defined]
131-
rep = call_and_report(item, "setup", log)
132-
reports = [rep]
133-
if rep.passed:
134-
if item.config.getoption("setupshow", False):
135-
show_test_item(item)
136-
if not item.config.getoption("setuponly", False):
137-
reports.append(call_and_report(item, "call", log))
138-
# If the session is about to fail or stop, teardown everything - this is
139-
# necessary to correctly report fixture teardown errors (see #11706)
140-
if item.session.shouldfail or item.session.shouldstop:
141-
nextitem = None
142-
reports.append(call_and_report(item, "teardown", log, nextitem=nextitem))
143-
# After all teardown hooks have been called
144-
# want funcargs and request info to go away.
145-
if hasrequest:
146-
item._request = False # type: ignore[attr-defined]
147-
item.funcargs = None # type: ignore[attr-defined]
131+
try:
132+
rep = call_and_report(item, "setup", log)
133+
reports = [rep]
134+
if rep.passed:
135+
if item.config.getoption("setupshow", False):
136+
show_test_item(item)
137+
if not item.config.getoption("setuponly", False):
138+
reports.append(call_and_report(item, "call", log))
139+
# If the session is about to fail or stop, teardown everything - this is
140+
# necessary to correctly report fixture teardown errors (see #11706)
141+
if item.session.shouldfail or item.session.shouldstop:
142+
nextitem = None
143+
reports.append(call_and_report(item, "teardown", log, nextitem=nextitem))
144+
finally:
145+
# After all teardown hooks have been called (or an exception was reraised)
146+
# want funcargs and request info to go away.
147+
if hasrequest:
148+
item._request = False # type: ignore[attr-defined]
149+
item.funcargs = None # type: ignore[attr-defined]
148150
return reports
149151

150152

testing/test_runner.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from pathlib import Path
88
import sys
99
import types
10+
from typing import cast
1011

1112
from _pytest import outcomes
1213
from _pytest import reports
@@ -494,6 +495,37 @@ def test_func():
494495
else:
495496
assert False, "did not raise"
496497

498+
def test_keyboardinterrupt_clears_request_and_funcargs(
499+
self, pytester: Pytester
500+
) -> None:
501+
"""Ensure that an item's fixtures are cleared quickly even if exiting
502+
early due to a keyboard interrupt (#13626)."""
503+
item = pytester.getitem(
504+
"""
505+
import pytest
506+
507+
@pytest.fixture
508+
def resource():
509+
return object()
510+
511+
def test_func(resource):
512+
raise KeyboardInterrupt("fake")
513+
"""
514+
)
515+
assert isinstance(item, pytest.Function)
516+
assert item._request
517+
assert item.funcargs == {}
518+
519+
try:
520+
runner.runtestprotocol(item, log=False)
521+
except KeyboardInterrupt:
522+
pass
523+
else:
524+
assert False, "did not raise"
525+
526+
assert not cast(object, item._request)
527+
assert not item.funcargs
528+
497529

498530
class TestSessionReports:
499531
def test_collect_result(self, pytester: Pytester) -> None:

0 commit comments

Comments
 (0)