Skip to content

Fix terminal reporter output not appearing with capture active - #13848

Open
RonnyPfannschmidt wants to merge 6 commits into
pytest-dev:mainfrom
RonnyPfannschmidt:fix-8973-terminalwriter-use-dup-if-necessary
Open

RonnyPfannschmidt wants to merge 6 commits into
pytest-dev:mainfrom
RonnyPfannschmidt:fix-8973-terminalwriter-use-dup-if-necessary

Conversation

@RonnyPfannschmidt

@RonnyPfannschmidt RonnyPfannschmidt commented Oct 26, 2025 •

Copy link
Copy Markdown
Member

Fixes #8973

Output written through the terminal reporter while capture is active — e.g. a plugin like pytest-print writing from inside a test — silently disappeared into the capture buffers. The only workaround was toggling capture off and on around each write via the private CaptureManager API, which breaks whenever capture state changes underneath.

The reporter now sidesteps capture instead of toggling it. _pytest.capture duplicates stdout's file descriptor before global capture starts and wraps it in an unbuffered text file. That file always refers to the original stdout — the same one capture restores on suspend — and stays writable no matter how capture is started, stopped or reconfigured. Writing to it neither goes through capture nor touches capture state.

  • Created in capture's pytest_load_initial_conftests: after the Windows console workaround (which replaces sys.stdout), before start_global_capturing(). Its cleanup is registered ahead of the capture manager's, so the LIFO stack closes it last.
  • get_terminal_stdout(config) is total — falls back to sys.stdout when stdout has no usable fd (in-process pytester runs), or under -p no:capture, where sys.stdout is the terminal.
  • Resolved in TerminalReporter.__init__, so plugins that subclass the reporter and register their own (pytest-sugar) inherit the fix.
  • Ordering with output that legitimately reaches the terminal (-s, capsys.disabled() — block-buffered when stdout is not a tty) is preserved by flushing the duplicated stream before each write.

Regression tests run in a subprocess: in-process pytester runs replace stdout with an object without a real fd, so they take the fallback path rather than the code under test.

Drive-by: Config.fromdictargs parses — so pytest_load_initial_conftests runs and acquires resources — but nothing unconfigures the result. Its three tests all pass capture: "no", which is why this never leaked capture's own descriptors and stayed invisible. The stdout duplicate is made regardless of capture method, so it leaked, and the resulting ResourceWarning poisoned unrelated tests. They now finalize.

🤖 Generated with Claude Code

@bluetech bluetech left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder how this compares to integrating with capture plugin e.g. using suspend_global_capture (or such)?

Comment thread src/_pytest/config/__init__.py Outdated
# File descriptor for stdout, duplicated before capture starts.
# This allows the terminal reporter to bypass pytest's output capture (#8973).
# The FD is duplicated early in _prepareconfig before any capture can start.
stdout_fd_dup_key = StashKey[int]()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems weird to me that this is in config/ when config doesn't really care about it. It should be in terminal.py or maybe capture.py if we want to take care of it "at the source".

Comment thread testing/test_terminal.py Outdated
plugin = config.pluginmanager.get_plugin("terminalprogress")
assert plugin is not None
# Use a mock file with isatty returning True
from io import StringIO

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better to have the imports at the beginning of the file if they don't need to be "lazy". Also below.

Comment thread testing/test_terminal.py Outdated
def test_plugin_registration(self, pytester: pytest.Pytester) -> None:
"""Test that the plugin is registered correctly on TTY output."""
# The plugin module should be registered as a default plugin.
with patch.object(sys.stdout, "isatty", return_value=True):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain why need to change this test?

Comment thread testing/test_terminal.py Outdated
assert "\x1b]9;4;0;\x1b\\" in mock_file.getvalue()


def test_terminal_reporter_write_with_capture(pytester: Pytester) -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test seems to pass also in main, so needs some refinement if we want to prevent regression.

@RonnyPfannschmidt
RonnyPfannschmidt force-pushed the fix-8973-terminalwriter-use-dup-if-necessary branch 2 times, most recently from 8ee87af to 5560219 Compare July 24, 2026 16:38
@psf-chronographer psf-chronographer Bot added the bot:chronographer:provided (automation) changelog entry is part of PR label Jul 24, 2026
@RonnyPfannschmidt
RonnyPfannschmidt force-pushed the fix-8973-terminalwriter-use-dup-if-necessary branch from 5560219 to 314979a Compare July 24, 2026 16:55
@RonnyPfannschmidt

Copy link
Copy Markdown
Member Author

the key detail here is that any printing pytest does is in all circumstances decoupled from capture enabling/disabling and/or breakage

@RonnyPfannschmidt
RonnyPfannschmidt force-pushed the fix-8973-terminalwriter-use-dup-if-necessary branch 2 times, most recently from 497c6f6 to e12bcfb Compare September 15, 2026 11:34
@RonnyPfannschmidt
RonnyPfannschmidt force-pushed the fix-8973-terminalwriter-use-dup-if-necessary branch from e12bcfb to c694bb9 Compare September 23, 2026 07:00
RonnyPfannschmidt and others added 6 commits October 4, 2026 15:02
Fixes pytest-dev#8973.

Output written through the terminal reporter while output capture is
active (e.g. by a plugin from within a test) used to disappear into the
capture buffers.

Duplicate stdout's file descriptor early in _prepareconfig, before any
capture can start, and wrap it in an unbuffered text file stored in the
config stash. The terminal reporter writes to this file, which always
refers to the original stdout no matter how capture is started, stopped,
or reconfigured - sidestepping capture entirely rather than toggling it.

The regression test runs in a subprocess: in-process pytester runs
replace stdout with an object without a real file descriptor, which
takes the sys.stdout fallback path instead of the one under test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The capture-immune duplicate of stdout that the terminal reporter writes
through was created in `_prepareconfig`, a procedural entry point rather
than a lifecycle point: before `pytest_cmdline_parse`, so before
`-p no:capture` is known, and registered on a config that a plugin's
`pytest_cmdline_parse` could replace, leaking the descriptor.

Move it to `_pytest.capture`, which is what the object exists because of
and which already keeps the same concept in `FDCaptureBase.targetfd_save`.
It is now created in capture's own `pytest_load_initial_conftests` -- after
the windows console workaround, which replaces `sys.stdout`, and before
`start_global_capturing()`, which is the whole ordering requirement. Its
cleanup is registered ahead of the capture manager's so the LIFO stack
closes it last. Under `-p no:capture` nothing is duplicated, and the
`sys.stdout` fallback is then exactly right rather than a degradation.

`get_terminal_stdout()` replaces the two separate `None` fallbacks in
`pytest_configure` and `TerminalReporter.__init__` with one total function.
`TerminalReporter.__init__` resolves it, so plugins that subclass the
reporter and register their own (pytest-sugar) inherit the fix.

Also fix a real defect in the file object: wrapping a raw `FileIO` in a
`TextIOWrapper` meant short writes were silently dropped, because
`TextIOWrapper` ignores the return value of `buffer.write()`. It now wraps a
`BufferedWriter` and flushes explicitly. The pre-write flush no longer
flushes the same stream twice in the common `-s` case.

Configs built by `Config.fromdictargs` parse -- so
`pytest_load_initial_conftests` runs and acquires resources -- but nothing
unconfigures them. Its three tests all pass `capture: "no"`, which is why
this never leaked capture's own descriptors and stayed invisible; the
duplicate is made regardless of capture method, so it leaked and the
resulting ResourceWarning poisoned unrelated tests. They now finalize.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`pytest.TerminalReporter` is autodocumented with `:members:`, which skips
members that have no docstring -- so `write`, the method this whole change
exists to make usable from inside a test, did not appear in the API docs at
all, and nothing could cross-reference it.

Document it, and say the part that is not obvious from the signature: that
it reaches the terminal under capture without suspending capture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
The entry used `:func:` against the private `_pytest.terminal` path. That is
the wrong role for a method and the wrong name for a class exported as
`pytest.TerminalReporter`, so it resolved to nothing and failed the docs
build, which runs sphinx with `-W`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
`io.FileIO` is always a plain file object. Only `open` consults
`_PyIO_get_console_type` and substitutes `_WindowsConsoleIO`, which writes
through `WriteConsoleW`; a plain `FileIO` hands UTF-8 bytes to `WriteFile`,
where the console decodes them in its active output code page -- typically
437 or 1252, not 65001. Any non-ASCII terminal output would be mojibake,
and silently so: `TerminalWriter.write_raw` falls back to an escaped ASCII
form on `UnicodeEncodeError`, and encoding to UTF-8 never raises one.

The workaround directly above already duplicates stdout with `open` for
exactly this reason, and it runs immediately before this code, so the
descriptor being duplicated here is a console one whenever it was.

CI cannot catch this: it pipes stdout, and for a pipe both spellings
produce the same plain `FileIO`. `open(fd, "wb")` still returns a
`BufferedWriter`, so the short-write property this relies on is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
The comment justified resolving the default in `__init__` by plugins
subclassing `TerminalReporter`, but the class has been `@final` since
a99ca87 -- so as written it argues from something the code next to it
forbids, and a reviewer has to work out whether the placement is wrong or
the reason is.

The placement is right; the reason was too narrow. What it buys is that
every caller leaving `file` unset gets the channel, subclass or not.
pytest-sugar is now named as what it is: an instance of that, which
subclasses anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
@RonnyPfannschmidt
RonnyPfannschmidt force-pushed the fix-8973-terminalwriter-use-dup-if-necessary branch from c694bb9 to c01977e Compare October 4, 2026 13:06

This branch has not been deployed

No deployments
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.

Terminal reporter doesn't print

2 participants