diff --git a/changelog/13453.improvement.rst b/changelog/13453.improvement.rst new file mode 100644 index 00000000000..5cc25784c94 --- /dev/null +++ b/changelog/13453.improvement.rst @@ -0,0 +1 @@ +When ``BdbQuit`` is raised during a pdb debugging session -- for example when a test directly raises it or when quitting pdb on Python versions older than 3.14 -- pytest now fails the current test and stops the entire test run. Previously the session would continue after ``BdbQuit``, silently skipping teardown. Note: on Python 3.14+ the pdb "quit" path calls ``os._exit(0)`` instead of raising ``BdbQuit``, which pytest cannot intercept -- by :user:`EternalRights` diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index 3f03cfaff77..da4dff77e90 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -252,6 +252,10 @@ def call_and_report( reraise=get_reraise_exceptions(item.config), ) report: TestReport = ihook.pytest_runtest_makereport(item=item, call=call) + # A BdbQuit (e.g. from Ctrl+D in pdb) should stop the session, not just + # fail the current test. See #13453. + if call.excinfo is not None and isinstance(call.excinfo.value, bdb.BdbQuit): + item.session.shouldstop = "Debugger quit (BdbQuit)" if log: ihook.pytest_runtest_logreport(report=report) if check_interactive_exception(call, report): diff --git a/testing/test_debugging.py b/testing/test_debugging.py index 950386a4923..07505dc0dcb 100644 --- a/testing/test_debugging.py +++ b/testing/test_debugging.py @@ -1376,10 +1376,15 @@ def do_quit(self, *args): @pytest.mark.xfail( sys.version_info >= (3, 14), - reason="C-D now quits the test session, rather than failing the test. See https://github.com/python/cpython/issues/124703", + reason=( + "Python 3.14+ pdb calls os._exit(0) on quit instead of raising " + "BdbQuit, so pytest never sees the exception and cannot set " + "shouldstop. The fix in call_and_report handles BdbQuit raised " + "directly, but os._exit cannot be intercepted." + ), ) def test_raises_bdbquit_with_eoferror(pytester: Pytester) -> None: - """It is not guaranteed that DontReadFromInput's read is called.""" + """BdbQuit from Ctrl+D / EOFError should fail the current test and stop the session.""" p1 = pytester.makepyfile( """ def input_without_read(*args, **kwargs): @@ -1389,12 +1394,15 @@ def test(monkeypatch): import builtins monkeypatch.setattr(builtins, "input", input_without_read) __import__('pdb').set_trace() + + def test_after_bdbquit(): + # This test should not run because BdbQuit stops the session. + pass """ ) result = pytester.runpytest(str(p1)) - result.assert_outcomes(failed=1) - result.stdout.fnmatch_lines(["E *BdbQuit", "*= 1 failed in*"]) - assert result.ret == 1 + result.stdout.fnmatch_lines(["E *BdbQuit", "*Interrupted: Debugger quit*"]) + assert result.ret == 2 def test_pdb_wrapper_class_is_reused(pytester: Pytester) -> None: diff --git a/testing/test_unittest.py b/testing/test_unittest.py index 20287d12cb3..5da14cbfc9a 100644 --- a/testing/test_unittest.py +++ b/testing/test_unittest.py @@ -1225,7 +1225,8 @@ def test_should_not_run(self): """ ) reprec = pytester.inline_run() - reprec.assertoutcome(failed=1, passed=1) + # BdbQuit now stops the session, so test_should_not_run doesn't run. + reprec.assertoutcome(failed=1) def test_exit_outcome(pytester: Pytester) -> None: