Skip to content

Give back the stream that was in place when capturing was suspended - #14996

Open
livingstaccato wants to merge 1 commit into
pytest-dev:mainfrom
livingstaccato:fix-capture-suspend-preserves-swapped-stream
Open

Give back the stream that was in place when capturing was suspended#14996
livingstaccato wants to merge 1 commit into
pytest-dev:mainfrom
livingstaccato:fix-capture-suspend-preserves-swapped-stream

Conversation

@livingstaccato

@livingstaccato livingstaccato commented Sep 9, 2026

Copy link
Copy Markdown

Fixes #14995.

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 startcontextlib.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 costs a test its redirect. The failure is silent, and assertions about absent output pass vacuously.

The change

def suspend(self) -> None:
    self._assert_state("suspend", ("started", "suspended"))
    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, getattr(self, "_swapped_in", self.tmpfile))
    self._state = "started"

Suspending while suspended is left alone, since what sits there then is what suspend itself installed rather than a stream worth remembering. done is unchanged and still restores the stream capture replaced, so a swap left behind by a test cannot outlive it.

Tests

Five added to testing/test_capture.py. Four sit beside test_simple_resume_suspend and drive SysCapture directly; one uses pytester to cover the log_cli path this actually reaches users through.

Three of the five fail without the change. The two that pass either way are the ones asserting existing behaviour is preserved — resume returning tmpfile when nothing swapped it, and done restoring the original stream.

Verification

Against main at 3fd8675:

baseline with this change
full suite 1 failed, 4549 passed, 51 skipped, 15 xfailed, 5 xpassed, 1 error 1 failed, 4554 passed, 51 skipped, 15 xfailed, 5 xpassed, 1 error
testing/test_capture.py + testing/logging/ 219 passed, 1 skipped, 1 xfailed unchanged
pre-commit all hooks pass

The only delta is the five added tests. The failure and error are present on main beforehand and are unrelated to capture: testing/test_doctest.py::TestDoctests::test_fixture_doctest_skip_has_line_number and testing/test_legacypath.py::test_cache_makedir.

On the "pytest owns the streams" reading

The one counter-argument is that pytest owns sys.stdout for the duration of a test, so anything swapping it underneath is unsupported and resume is entitled to reset. Three things stand against it.

contextlib.redirect_stdout is standard library. Under that reading it is unsupported inside any test run with --log-cli, which is documented nowhere and is unlikely to be the intent. No third-party code is required to hit this.

pytest's own design already separates the two operations. done restores the pre-capture stream; resume restores tmpfile. That distinction only makes sense if resume is the inverse of suspend rather than a reset — and global_and_fixture_disabled, the caller that produces this, describes itself as temporarily disabling capture.

There is precedent in the same context manager. #7148 was an imbalance in global_and_fixture_disabled — resuming capture it had not suspended — and was accepted and fixed in #7651. This is the same shape: a pair that does not restore what it displaced.

The change is four lines and moves nothing else. The full suite is unchanged apart from the tests added here, so correctness here costs nothing elsewhere.

@psf-chronographer psf-chronographer Bot added the bot:chronographer:provided (automation) changelog entry is part of PR label Sep 9, 2026
`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 pytest-dev#14995
@livingstaccato
livingstaccato force-pushed the fix-capture-suspend-preserves-swapped-stream branch from 82a62a1 to 81e4088 Compare September 9, 2026 18:16
@RonnyPfannschmidt

Copy link
Copy Markdown
Member

We should use a distinct stream instead of this ai confusion

Work on distinct streams is in progress

@livingstaccato

Copy link
Copy Markdown
Author

We should use a distinct stream instead of this ai confusion

Work on distinct streams is in progress

I appreciate you and the response, and apologize for any churn I may have caused - thanks.

I made an effort to make sure multiple LLMs agreed upon things until they nitpick. But - I'm not that familiar with the actual pytest code, so If this is the wrong code for the wrong thing and there's low value then please go ahead and close it.

@livingstaccato

livingstaccato commented Sep 9, 2026

Copy link
Copy Markdown
Author

I'll have my LLMs take another stab at it though if you want.

@RonnyPfannschmidt

Copy link
Copy Markdown
Member

please dont - i had to wast a significant portion of my vacations opensource time to throw out ill advised llm prompts

people unfamiliar with the project keep spamming us with wrong solutions one after another - its unbearable

if you want to actually contribute - actually prepare - else the llms will jsut create something that makes our work worse

and most of us have access to opus and fable from opensource maintainer grants

i'd much rater prompt myself rather than veryfying how bandyl other prompts mess up and closing them

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:chronographer:provided (automation) changelog entry is part of PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

--log-cli discards a sys.stdout swap made after capture started (redirect_stdout, CliRunner.isolation)

2 participants