Repository navigation
Fix terminal reporter output not appearing with capture active - #13848
RonnyPfannschmidt wants to merge 6 commits into
Conversation
bluetech
left a comment
There was a problem hiding this comment.
I wonder how this compares to integrating with capture plugin e.g. using suspend_global_capture (or such)?
| # 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]() |
There was a problem hiding this comment.
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".
| plugin = config.pluginmanager.get_plugin("terminalprogress") | ||
| assert plugin is not None | ||
| # Use a mock file with isatty returning True | ||
| from io import StringIO |
There was a problem hiding this comment.
Better to have the imports at the beginning of the file if they don't need to be "lazy". Also below.
| 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): |
There was a problem hiding this comment.
Can you explain why need to change this test?
| assert "\x1b]9;4;0;\x1b\\" in mock_file.getvalue() | ||
|
|
||
|
|
||
| def test_terminal_reporter_write_with_capture(pytester: Pytester) -> None: |
There was a problem hiding this comment.
This test seems to pass also in main, so needs some refinement if we want to prevent regression.
8ee87af to
5560219
Compare
5560219 to
314979a
Compare
|
the key detail here is that any printing pytest does is in all circumstances decoupled from capture enabling/disabling and/or breakage |
497c6f6 to
e12bcfb
Compare
e12bcfb to
c694bb9
Compare
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>
c694bb9 to
c01977e
Compare
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
CaptureManagerAPI, which breaks whenever capture state changes underneath.The reporter now sidesteps capture instead of toggling it.
_pytest.captureduplicates 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.pytest_load_initial_conftests: after the Windows console workaround (which replacessys.stdout), beforestart_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 tosys.stdoutwhen stdout has no usable fd (in-process pytester runs), or under-p no:capture, wheresys.stdoutis the terminal.TerminalReporter.__init__, so plugins that subclass the reporter and register their own (pytest-sugar) inherit the fix.-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.fromdictargsparses — sopytest_load_initial_conftestsruns and acquires resources — but nothing unconfigures the result. Its three tests all passcapture: "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 resultingResourceWarningpoisoned unrelated tests. They now finalize.🤖 Generated with Claude Code