From 81e40888aef26d2d6f68d0d26a61f0205f5997ac Mon Sep 17 00:00:00 2001 From: Tim Perkins Date: Wed, 9 Sep 2026 11:15:01 -0700 Subject: [PATCH] Give back the stream that was in place when capturing was suspended `SysCaptureBase.suspend` handed `sys.stdout` back to the stream saved before capturing started, and `resume` reinstated `tmpfile`, the stream capture installed at `start`. Neither read what was actually there, so a stream installed after `start` -- `contextlib.redirect_stdout`, `click.testing.CliRunner.isolation`, a fixture of one's own -- was discarded on the first suspend/resume cycle, and everything written afterwards went to pytest's buffer rather than to the caller that installed it. Under `log_cli = true` that is a per-record event: the live-log handler sits on the root logger and suspends capturing around every record it writes, so one log record emitted at any depth was enough to cost a test its redirect. The failure is silent, and assertions about absent output pass vacuously. `suspend` now records the stream that is in place and `resume` returns it. Suspending while suspended is left alone, since what sits there then is what `suspend` itself installed. `done` is unchanged and still restores the stream capture replaced, so a swap left behind by a test cannot outlive it. Fixes #14995 --- AUTHORS | 1 + changelog/14995.bugfix.rst | 9 ++++ src/_pytest/capture.py | 8 +++- testing/test_capture.py | 88 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 changelog/14995.bugfix.rst diff --git a/AUTHORS b/AUTHORS index daa6a471eb9..719feb51a30 100644 --- a/AUTHORS +++ b/AUTHORS @@ -486,6 +486,7 @@ Thomas Grainger Thomas Hisch Tianyu Dongfang Tim Hoffmann +Tim Perkins Tim Strazny TJ Bruno Tobias Diez diff --git a/changelog/14995.bugfix.rst b/changelog/14995.bugfix.rst new file mode 100644 index 00000000000..8b538cbf016 --- /dev/null +++ b/changelog/14995.bugfix.rst @@ -0,0 +1,9 @@ +Suspending capture no longer discards a stream installed after capturing started. + +``SysCaptureBase.resume`` reinstated the stream capture installed at ``start``, so anything +that replaced ``sys.stdout`` afterwards -- ``contextlib.redirect_stdout``, +``click.testing.CliRunner.isolation``, a fixture of one's own -- lost that stream on the first +suspend/resume cycle, and subsequent writes went to pytest's buffer instead. Under +``log_cli = true`` the live-log handler suspends capturing around every record, so a single +log record was enough. ``suspend`` now records the stream that is in place and ``resume`` +returns it; teardown is unchanged. diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index b914bc2831c..64a16752999 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -415,6 +415,12 @@ def done(self) -> None: def suspend(self) -> None: self._assert_state("suspend", ("started", "suspended")) + # Give back whatever is installed now, not `tmpfile`: something may have + # swapped the stream after `start`, and `resume` owes it that stream + # back. Suspending while suspended is legal, and what sits there then is + # what `suspend` itself installed rather than a swap to remember. + if self._state == "started": + self._swapped_in = getattr(sys, self.name) setattr(sys, self.name, self._old) self._state = "suspended" @@ -422,7 +428,7 @@ def resume(self) -> None: self._assert_state("resume", ("started", "suspended")) if self._state == "started": return - setattr(sys, self.name, self.tmpfile) + setattr(sys, self.name, getattr(self, "_swapped_in", self.tmpfile)) self._state = "started" diff --git a/testing/test_capture.py b/testing/test_capture.py index a0a4f044d62..eb818e24655 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -1125,10 +1125,98 @@ def test_simple_resume_suspend(self) -> None: f" _state='done' tmpfile={cap.syscapture.tmpfile!r}>" ) + def test_resume_gives_back_a_stream_swapped_in_after_start(self) -> None: + """`resume` owes back whatever was installed when `suspend` ran. + + Anything may swap the stream once capturing has started -- a test using + `contextlib.redirect_stdout`, `click.testing.CliRunner.isolation`, a + fixture of its own. Handing `tmpfile` back instead drops that swap, and + everything written afterwards goes to the capture buffer rather than to + the caller that installed it. + """ + cap = capture.SysCapture(1) + cap.start() + try: + swapped_in = io.StringIO() + sys.stdout = swapped_in + + cap.suspend() + cap.resume() + + assert sys.stdout is swapped_in + finally: + cap.done() + + def test_resume_gives_back_tmpfile_when_nothing_swapped_it(self) -> None: + cap = capture.SysCapture(1) + cap.start() + try: + cap.suspend() + cap.resume() + + assert sys.stdout is cap.tmpfile + finally: + cap.done() + + def test_suspending_twice_keeps_the_swapped_stream(self) -> None: + cap = capture.SysCapture(1) + cap.start() + try: + swapped_in = io.StringIO() + sys.stdout = swapped_in + + cap.suspend() + cap.suspend() + cap.resume() + + assert sys.stdout is swapped_in + finally: + cap.done() + + def test_done_restores_the_stream_capture_replaced(self) -> None: + """A swap left behind by a test does not outlive the capture.""" + original = sys.stdout + cap = capture.SysCapture(1) + cap.start() + sys.stdout = io.StringIO() + + cap.done() + + assert sys.stdout is original + def test_capfd_sys_stdout_mode(self, capfd) -> None: assert "b" not in sys.stdout.mode +def test_live_logging_does_not_cost_a_test_its_redirected_stream( + pytester: Pytester, +) -> None: + """`--log-cli` suspends capturing around every record it writes. + + The handler sits on the root logger, so a record written from anywhere + inside a redirect passes through `suspend`/`resume`; the redirect has to + survive it. + """ + pytester.makepyfile( + """ + import contextlib + import io + import logging + + def test_redirect_stdout_survives_a_log_record(): + captured = io.StringIO() + with contextlib.redirect_stdout(captured): + logging.getLogger("some.library").warning("reaches the root logger") + print("inside the redirect") + assert captured.getvalue() == "inside the redirect\\n" + """ + ) + + result = pytester.runpytest_subprocess("-o", "log_cli=true") + + result.assert_outcomes(passed=1) + + @contextlib.contextmanager def saved_fd(fd): new_fd = os.dup(fd)