From 314979a98fa784553dbd3aecd8a511965694f20a Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 24 Jul 2026 17:56:37 +0200 Subject: [PATCH] Fix terminal reporter output not appearing with capture active Fixes #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 --- changelog/8973.bugfix.rst | 1 + src/_pytest/config/__init__.py | 57 ++++++++++++++++++++++++++++++++++ src/_pytest/terminal.py | 7 ++++- testing/test_terminal.py | 26 ++++++++++++++++ 4 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 changelog/8973.bugfix.rst diff --git a/changelog/8973.bugfix.rst b/changelog/8973.bugfix.rst new file mode 100644 index 00000000000..d7189be39f0 --- /dev/null +++ b/changelog/8973.bugfix.rst @@ -0,0 +1 @@ +The terminal reporter now writes to an unbuffered duplicate of stdout created before output capture can start, so output written through it (for example by plugins calling :func:`TerminalReporter.write() <_pytest.terminal.TerminalReporter.write>` from within a test) always reaches the terminal instead of disappearing into the capture buffers. diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 688cc8a9054..7ac88c36f7a 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -22,6 +22,7 @@ import importlib import importlib.metadata import inspect +import io import os import pathlib import re @@ -77,10 +78,45 @@ from _pytest.pathlib import resolve_package_path from _pytest.pathlib import safe_exists from _pytest.stash import Stash +from _pytest.stash import StashKey from _pytest.warning_types import PytestConfigWarning from _pytest.warning_types import warn_explicit_for +class _CaptureImmuneStdout(io.TextIOWrapper): + """See :data:`capture_immune_stdout_key`.""" + + #: The ``sys.stdout`` at creation time. Output that legitimately targets + #: the terminal may sit in its buffers (e.g. prints under ``-s`` or + #: ``capsys.disabled()``, block-buffered when stdout is not a tty); + #: it is pushed out before each of our writes to preserve ordering on + #: the terminal. + _original_stdout: TextIO | None = None + + def write(self, s: str) -> int: + for stdout in (self._original_stdout, sys.stdout): + if stdout is not None and stdout is not self: + try: + stdout.flush() + except (AttributeError, OSError, ValueError): + pass + return super().write(s) + + +capture_immune_stdout_key = StashKey[TextIO]() +"""An unbuffered text file over a duplicate of stdout's file descriptor, +created before output capture can start. + +Output capture redirects file descriptor 1 and/or replaces ``sys.stdout``, +so anything written through them while capture is active ends up in the +capture buffers. This file always refers to the original stdout (the same +file capture restores on suspend) and is usable at any time no matter how +capture is started, stopped, or reconfigured — writing to it neither goes +through capture nor affects it. Used by the terminal reporter (#8973). + +Owned by the config: closed by its cleanup at teardown.""" + + if TYPE_CHECKING: from _pytest.assertion.rewrite import AssertionRewritingHook from _pytest.cacheprovider import Cache @@ -402,6 +438,27 @@ def _prepareconfig( raise TypeError(msg.format(args, type(args))) initial_config = get_config(args, plugins, prog=prog) + + # Duplicate stdout early, before any output capture can start, so the + # terminal reporter can always write to the real terminal (#8973). + try: + dup_stdout_fd = os.dup(sys.stdout.fileno()) + except (AttributeError, OSError, ValueError): + # stdout has no usable file descriptor (e.g. in-process pytester + # runs); the terminal reporter falls back to sys.stdout. + pass + else: + capture_immune_stdout = _CaptureImmuneStdout( + io.FileIO(dup_stdout_fd, mode="wb", closefd=True), + encoding=getattr(sys.stdout, "encoding", None) or "utf-8", + errors=getattr(sys.stdout, "errors", None) or "replace", + # Unbuffered: writes must be visible on the terminal immediately. + write_through=True, + ) + capture_immune_stdout._original_stdout = sys.stdout + initial_config.stash[capture_immune_stdout_key] = capture_immune_stdout + initial_config.add_cleanup(capture_immune_stdout.close) + pluginmanager = initial_config.pluginmanager try: if plugins: diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index e46f8fb4c20..18afea4c379 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -41,6 +41,7 @@ import _pytest._version from _pytest.compat import running_on_ci from _pytest.config import _PluggyPlugin +from _pytest.config import capture_immune_stdout_key from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config import hookimpl @@ -289,7 +290,11 @@ def pytest_addoption(parser: Parser) -> None: def pytest_configure(config: Config) -> None: - reporter = TerminalReporter(config, sys.stdout) + # Write to the capture-immune duplicate of stdout when available (#8973), + # fall back to sys.stdout otherwise (e.g. in-process pytester runs, where + # stdout has no real file descriptor to duplicate). + file = config.stash.get(capture_immune_stdout_key, None) + reporter = TerminalReporter(config, file) config.pluginmanager.register(reporter, "terminalreporter") if config.option.debug or config.option.traceconfig: diff --git a/testing/test_terminal.py b/testing/test_terminal.py index de1dfbbda3d..09410847c17 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -3563,3 +3563,29 @@ def test_session_lifecycle( # Session finish - should remove progress. plugin.pytest_sessionfinish() assert "\x1b]9;4;0;\x1b\\" in mock_file.getvalue() + + +def test_terminalreporter_write_during_capture_reaches_terminal( + pytester: pytest.Pytester, +) -> None: + """Output written via the terminal reporter from within a test reaches + the terminal even while output capture is active (#8973). + + Runs in a subprocess: in-process runs replace stdout with an object + without a real file descriptor, so they cannot exercise the + capture-immune stdout duplicate. + """ + pytester.makepyfile( + """ + def test_foo(request): + reporter = request.config.pluginmanager.getplugin("terminalreporter") + reporter.ensure_newline() + reporter.write("MAGIC_MARKER", flush=True) + print("PLAIN_PRINT") + """ + ) + result = pytester.runpytest_subprocess() + result.assert_outcomes(passed=1) + result.stdout.fnmatch_lines(["*MAGIC_MARKER*"]) + # Regular output stays captured (the test passes, so it is never shown). + result.stdout.no_fnmatch_line("*PLAIN_PRINT*")