From 06779989c1dc432b41f58793c09b93a2bb640a65 Mon Sep 17 00:00:00 2001 From: Oleksii Date: Sun, 1 Nov 2026 18:43:17 +0200 Subject: [PATCH 1/3] Always call pytest_runtest_logfinish, even on Exit/KeyboardInterrupt Exit (always) and KeyboardInterrupt (unless --pdb) are reraised out of runtestprotocol, so pytest_runtest_protocol skipped the logfinish hook and left the item's logstart unpaired. Plugins that emit output or clean up state in logfinish silently lost the last item. Wrap runtestprotocol in try/finally so logfinish is always called. Co-Authored-By: Claude Opus 5.5 --- src/_pytest/runner.py | 8 ++++++-- testing/test_runner.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index 629796598df..3ec550fe841 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -115,8 +115,12 @@ def pytest_sessionfinish(session: Session) -> None: def pytest_runtest_protocol(item: Item, nextitem: Item | None) -> bool: ihook = item.ihook ihook.pytest_runtest_logstart(nodeid=item.nodeid, location=item.location) - runtestprotocol(item, nextitem=nextitem) - ihook.pytest_runtest_logfinish(nodeid=item.nodeid, location=item.location) + try: + runtestprotocol(item, nextitem=nextitem) + finally: + # Exit and KeyboardInterrupt propagate out of runtestprotocol (see + # get_reraise_exceptions); still pair every logstart with a logfinish. + ihook.pytest_runtest_logfinish(nodeid=item.nodeid, location=item.location) return True diff --git a/testing/test_runner.py b/testing/test_runner.py index 8b59a5ee8c1..10fb1588f7a 100644 --- a/testing/test_runner.py +++ b/testing/test_runner.py @@ -339,6 +339,36 @@ def test_func(): assert rep.nodeid == "test_logstart_logfinish_hooks.py::test_func" assert rep.location == ("test_logstart_logfinish_hooks.py", 1, "test_func") + @pytest.mark.parametrize( + "raise_stmt", ["pytest.exit('stop')", "raise KeyboardInterrupt"] + ) + def test_logfinish_called_on_reraised_exception( + self, pytester: Pytester, raise_stmt: str + ) -> None: + """pytest_runtest_logfinish is still called when the test protocol is + aborted by an exception which is reraised (Exit, KeyboardInterrupt).""" + pytester.makepyfile( + f""" + import pytest + def test_func(): + {raise_stmt} + def test_not_run(): + pass + """ + ) + rec = pytester.inline_run(no_reraise_ctrlc=True) + reps = rec.getcalls("pytest_runtest_logstart pytest_runtest_logfinish") + assert [(x._name, x.nodeid) for x in reps] == [ + ( + "pytest_runtest_logstart", + "test_logfinish_called_on_reraised_exception.py::test_func", + ), + ( + "pytest_runtest_logfinish", + "test_logfinish_called_on_reraised_exception.py::test_func", + ), + ] + def test_exact_teardown_issue90(self, pytester: Pytester) -> None: rec = pytester.inline_runsource( """ From 73e5f590a87e476b0177aa998a46b6085bdd83c5 Mon Sep 17 00:00:00 2001 From: Oleksii Date: Sun, 1 Nov 2026 18:45:34 +0200 Subject: [PATCH 2/3] Add changelog entry Co-Authored-By: Claude Opus 5.5 --- changelog/15316.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/15316.bugfix.rst diff --git a/changelog/15316.bugfix.rst b/changelog/15316.bugfix.rst new file mode 100644 index 00000000000..7a12ec0c79e --- /dev/null +++ b/changelog/15316.bugfix.rst @@ -0,0 +1 @@ +:hook:`pytest_runtest_logfinish` is now called even when a test is interrupted by :func:`pytest.exit` or :class:`KeyboardInterrupt`, so it is always paired with :hook:`pytest_runtest_logstart`. From e440f4c9cb04c317dded6879e8603163a7fbe037 Mon Sep 17 00:00:00 2001 From: Oleksii Date: Sun, 1 Nov 2026 19:04:14 +0200 Subject: [PATCH 3/3] Add myself to AUTHORS Co-Authored-By: Claude Opus 5.5 --- AUTHORS | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS b/AUTHORS index 136ffd6c5ee..ec70ec8adba 100644 --- a/AUTHORS +++ b/AUTHORS @@ -375,6 +375,7 @@ Nipunn Koorapati Oleg Pidsadnyi Oleg Sushchenko Oleksandr Zavertniev +Oleksii Ovdiienko Olga Matoula Oliver Bestwalter Olivier Grisel