Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,7 @@ Thomas Grainger
Thomas Hisch
Tianyu Dongfang
Tim Hoffmann
Tim Perkins
Tim Strazny
TJ Bruno
Tobias Diez
Expand Down
9 changes: 9 additions & 0 deletions changelog/14995.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 7 additions & 1 deletion src/_pytest/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,14 +415,20 @@ 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"

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"


Expand Down
88 changes: 88 additions & 0 deletions testing/test_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -1125,10 +1125,98 @@ def test_simple_resume_suspend(self) -> None:
f"<SysCapture stdout _old=<UNSET> _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)
Expand Down