From bb9a2425aa2fef0b5380532fa7763791e231c5d2 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 24 Jul 2026 17:56:37 +0200 Subject: [PATCH 1/6] 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 60ba5f54a89..895a70abaa7 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -23,6 +23,7 @@ import importlib.machinery import importlib.metadata import inspect +import io import json import os import pathlib @@ -81,10 +82,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 @@ -432,6 +468,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 b77a649d28a..bbdd344a14a 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 @@ -297,7 +298,11 @@ def pytest_addoption(parser: Parser) -> None: def pytest_configure(config: Config) -> None: # Eagerly validate the value; it is only read lazily during reporting. config.getini("console_output_style") - 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 21b0557a470..878eb55d1b0 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -3729,3 +3729,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*") From 5c58236dd2573624071cf1478b25920e47accf11 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 5 Aug 2026 19:35:35 +0200 Subject: [PATCH 2/6] Move the terminal stdout duplicate into the capture plugin 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) --- src/_pytest/capture.py | 84 ++++++++++++++++++++++++++++++++++ src/_pytest/config/__init__.py | 57 ----------------------- src/_pytest/terminal.py | 14 +++--- testing/test_capture.py | 76 ++++++++++++++++++++++++++++++ testing/test_config.py | 28 +++++++++--- testing/test_terminal.py | 30 +++++++++++- 6 files changed, 217 insertions(+), 72 deletions(-) diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index b914bc2831c..74b4d8b29f0 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -41,6 +41,7 @@ from _pytest.nodes import File from _pytest.nodes import Item from _pytest.reports import CollectReport +from _pytest.stash import StashKey _CaptureMethod = Literal["fd", "sys", "no", "tee-sys"] @@ -152,6 +153,71 @@ def _reopen_stdio(f, mode): sys.stderr = _reopen_stdio(sys.stderr, "wb") +@final +class TerminalStdout(io.TextIOWrapper): + """An unbuffered text file over a duplicate of stdout's file descriptor. + + 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 is a duplicate made before capture can start: it + always refers to the original stdout -- the same file capture restores on + suspend -- and stays usable no matter how capture is started, stopped or + reconfigured. Writing to it neither goes through capture nor affects its + state (#8973). + + Owned by the config: closed by its cleanup at teardown. + """ + + def __init__(self, fd: int, *, original_stdout: TextIO) -> None: + super().__init__( + # Buffered rather than raw: a raw stream may write fewer bytes + # than asked for, and TextIOWrapper does not retry those. + io.BufferedWriter(io.FileIO(fd, mode="w", closefd=True)), + encoding=getattr(original_stdout, "encoding", None) or "utf-8", + errors=getattr(original_stdout, "errors", None) or "replace", + write_through=True, + ) + self._original_stdout = original_stdout + + def write(self, s: str) -> int: + # Output that legitimately targets the terminal may still be sitting in + # the buffers of the stream we duplicated (prints under ``-s`` or + # ``capsys.disabled()`` are block buffered when stdout is not a tty). + # Push it out first, so that the terminal keeps the order in which the + # writes were made. While capture is active this merely moves pending + # test output into the capture buffers, where it belongs. + streams = [self._original_stdout] + if sys.stdout is not self._original_stdout: + streams.append(sys.stdout) + for stream in streams: + if stream is not None and stream is not self: + try: + stream.flush() + except (AttributeError, OSError, ValueError): + pass + written = super().write(s) + # ``write_through`` only hands the text to the buffer; flush so the + # write is visible on the terminal immediately. + self.flush() + return written + + +terminal_stdout_key = StashKey[TerminalStdout]() + + +def get_terminal_stdout(config: Config) -> TextIO: + """Return the file terminal output should be written to. + + Always usable. Falls back to ``sys.stdout`` when stdout could not be + duplicated (in-process pytester runs), or when the capture plugin is + disabled (``-p no:capture``) and ``sys.stdout`` is the terminal anyway. + """ + terminal_stdout = config.stash.get(terminal_stdout_key, None) + if terminal_stdout is None: + return sys.stdout + return terminal_stdout + + @hookimpl(wrapper=True) def pytest_load_initial_conftests(early_config: Config) -> Generator[None]: ns = early_config.known_args_namespace @@ -159,6 +225,24 @@ def pytest_load_initial_conftests(early_config: Config) -> Generator[None]: _windowsconsoleio_workaround(sys.stdout) _colorama_workaround() _readline_workaround() + + # Duplicate stdout before any capture starts, so that terminal output can + # sidestep it (#8973). Must come after the windows console workaround, + # which replaces ``sys.stdout``, and before ``start_global_capturing()``. + # Registering the cleanup here -- before the capture manager's -- makes the + # LIFO cleanup stack close it last. + try: + fd = os.dup(sys.stdout.fileno()) + except (AttributeError, OSError, ValueError): + # No usable file descriptor: in-process pytester runs, or a sys.stdout + # replaced by something not backed by a file. get_terminal_stdout() + # falls back to sys.stdout. + pass + else: + terminal_stdout = TerminalStdout(fd, original_stdout=sys.stdout) + early_config.stash[terminal_stdout_key] = terminal_stdout + early_config.add_cleanup(terminal_stdout.close) + pluginmanager = early_config.pluginmanager capman = CaptureManager(ns.capture) pluginmanager.register(capman, "capturemanager") diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 895a70abaa7..60ba5f54a89 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -23,7 +23,6 @@ import importlib.machinery import importlib.metadata import inspect -import io import json import os import pathlib @@ -82,45 +81,10 @@ 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 @@ -468,27 +432,6 @@ 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 bbdd344a14a..6da06849ee3 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -39,9 +39,9 @@ from _pytest._io import TerminalWriter from _pytest._io.wcwidth import wcswidth import _pytest._version +from _pytest.capture import get_terminal_stdout 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 @@ -298,11 +298,7 @@ def pytest_addoption(parser: Parser) -> None: def pytest_configure(config: Config) -> None: # Eagerly validate the value; it is only read lazily during reporting. config.getini("console_output_style") - # 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) + reporter = TerminalReporter(config) config.pluginmanager.register(reporter, "terminalreporter") if config.option.debug or config.option.traceconfig: @@ -403,7 +399,11 @@ def __init__(self, config: Config, file: TextIO | None = None) -> None: self._known_types: list[str] | None = None self.startpath = config.invocation_params.dir if file is None: - file = sys.stdout + # The terminal channel writes past output capture (#8973); it falls + # back to sys.stdout when there is none. Resolved here rather than + # in pytest_configure so that plugins subclassing TerminalReporter + # and registering their own (pytest-sugar) get it too. + file = get_terminal_stdout(config) self._tw = _pytest.config.create_terminal_writer(config, file) self._screen_width = self._tw.fullwidth self.currentfspath: Path | str | int | None = None diff --git a/testing/test_capture.py b/testing/test_capture.py index a0a4f044d62..e2504900e92 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -1770,3 +1770,79 @@ def pytest_terminal_summary(config): match = re.search(r"^value: '(.*)'\r?$", rest, re.MULTILINE) assert match is not None assert match.group(1) == "hi" + + +class TestTerminalStdout: + """The capture-immune duplicate of stdout used for terminal output (#8973).""" + + @pytest.fixture + def terminal_stdout(self, tmp_path): + """A TerminalStdout duplicated from a real file, plus that file.""" + + def make(name: str = "out.txt"): + path = tmp_path / name + f = path.open("w", encoding="utf-8") + terminal_stdout = capture.TerminalStdout( + os.dup(f.fileno()), original_stdout=f + ) + return terminal_stdout, f, path + + return make + + def test_writes_are_unbuffered(self, terminal_stdout) -> None: + out, f, path = terminal_stdout() + with f: + try: + out.write("hello") + assert path.read_text(encoding="utf-8") == "hello" + finally: + out.close() + + def test_flushes_the_duplicated_stream_first(self, terminal_stdout) -> None: + """Output still sitting in the original stream's buffer must reach the + terminal before ours, or the two appear out of order.""" + out, f, path = terminal_stdout() + with f: + try: + f.write("buffered") + out.write("direct") + assert path.read_text(encoding="utf-8") == "buffereddirect" + finally: + out.close() + + def test_close_releases_the_duplicated_descriptor(self, terminal_stdout) -> None: + out, f, _ = terminal_stdout() + with f: + fd = out.fileno() + out.close() + assert out.closed + with pytest.raises(OSError): + os.fstat(fd) + # Closing twice is harmless -- it must not close an unrelated fd + # that has meanwhile been handed out the same number. + out.close() + # The stream we duplicated from is untouched. + assert not f.closed + + def test_falls_back_to_sys_stdout_when_absent(self, pytester: Pytester) -> None: + config = pytester.parseconfig() + # Whether one got created depends on the *outer* run's capture mode, so + # drop it explicitly rather than assume. The registered cleanup still + # holds it, so nothing leaks. + if capture.terminal_stdout_key in config.stash: + del config.stash[capture.terminal_stdout_key] + assert capture.get_terminal_stdout(config) is sys.stdout + + def test_reporter_writes_without_the_capture_plugin( + self, pytester: Pytester + ) -> None: + """With -p no:capture nothing is duplicated; sys.stdout is the terminal.""" + pytester.makepyfile(""" + def test_foo(request): + reporter = request.config.pluginmanager.getplugin("terminalreporter") + reporter.ensure_newline() + reporter.write("NOCAPTURE_MARKER", flush=True) + """) + result = pytester.runpytest_subprocess("-p", "no:capture") + result.assert_outcomes(passed=1) + result.stdout.fnmatch_lines(["*NOCAPTURE_MARKER*"]) diff --git a/testing/test_config.py b/testing/test_config.py index 282e66409f7..b5376fa87b2 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1786,11 +1786,27 @@ def cleanup_first(): class TestConfigFromdictargs: - def test_basic_behavior(self, _sys_snapshot) -> None: + @pytest.fixture + def fromdictargs(self, request: pytest.FixtureRequest): + """``Config.fromdictargs`` with teardown. + + It parses, so ``pytest_load_initial_conftests`` runs and acquires + resources (capturing, the terminal channel); nothing unconfigures the + config otherwise, and those would leak into unrelated tests. + """ + + def make(option_dict: dict[str, object], args: list[str]) -> Config: + config = Config.fromdictargs(option_dict, args) + request.addfinalizer(config._ensure_unconfigure) + return config + + return make + + def test_basic_behavior(self, _sys_snapshot, fromdictargs) -> None: option_dict = {"verbose": 444, "foo": "bar", "capture": "no"} args = ["a", "b"] - config = Config.fromdictargs(option_dict, args) + config = fromdictargs(option_dict, args) with pytest.raises(AssertionError): config.parse(["should refuse to parse again"]) assert config.option.verbose == 444 @@ -1798,18 +1814,18 @@ def test_basic_behavior(self, _sys_snapshot) -> None: assert config.option.capture == "no" assert config.args == args - def test_invocation_params_args(self, _sys_snapshot) -> None: + def test_invocation_params_args(self, _sys_snapshot, fromdictargs) -> None: """Show that fromdictargs can handle args in their "orig" format""" option_dict: dict[str, object] = {} args = ["-vvvv", "-s", "a", "b"] - config = Config.fromdictargs(option_dict, args) + config = fromdictargs(option_dict, args) assert config.args == ["a", "b"] assert config.invocation_params.args == tuple(args) assert config.option.verbose == 4 assert config.option.capture == "no" - def test_inifilename(self, tmp_path: Path) -> None: + def test_inifilename(self, tmp_path: Path, fromdictargs) -> None: d1 = tmp_path.joinpath("foo") d1.mkdir() p1 = d1.joinpath("bar.ini") @@ -1843,7 +1859,7 @@ def test_inifilename(self, tmp_path: Path) -> None: ) with MonkeyPatch.context() as mp: mp.chdir(cwd) - config = Config.fromdictargs(option_dict, []) + config = fromdictargs(option_dict, []) inipath = absolutepath(inifilename) assert config.args == [str(cwd)] diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 878eb55d1b0..122df6ffb02 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -3738,8 +3738,8 @@ def test_terminalreporter_write_during_capture_reaches_terminal( 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. + without a real file descriptor, so they take the sys.stdout fallback + instead of the terminal channel under test. """ pytester.makepyfile( """ @@ -3755,3 +3755,29 @@ def test_foo(request): 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*") + + +def test_terminalreporter_write_keeps_order_with_uncaptured_print( + pytester: pytest.Pytester, +) -> None: + """Writes through the terminal reporter interleave correctly with output + that legitimately reaches the terminal via sys.stdout (#8973). + + Under ``-s`` with a piped (non-tty) stdout, prints are block buffered, so + without flushing them first the reporter's write would overtake them. + """ + pytester.makepyfile( + """ + def test_foo(request): + reporter = request.config.pluginmanager.getplugin("terminalreporter") + print("FIRST_PRINT") + reporter.ensure_newline() + reporter.write("SECOND_WRITE\\n", flush=True) + print("THIRD_PRINT") + """ + ) + result = pytester.runpytest_subprocess("-s") + result.assert_outcomes(passed=1) + result.stdout.fnmatch_lines( + ["*FIRST_PRINT*", "*SECOND_WRITE*", "*THIRD_PRINT*"], + ) From 89255b1d185a60b3d01fb9aa7ffe531195e85ec5 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 11 Sep 2026 21:20:41 +0200 Subject: [PATCH 3/6] docs(terminal): document TerminalReporter.write `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) Co-Authored-By: Claude Code --- src/_pytest/terminal.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 6da06849ee3..61b2fe8ec5c 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -540,6 +540,18 @@ def wrap_write( self._tw.write(wrapped, flush=flush, **markup) def write(self, content: str, *, flush: bool = False, **markup: bool) -> None: + """Write content to the terminal. + + This is the supported way for a plugin to write to the terminal: it + reaches the terminal even while output capture is active, and does so + without suspending capture (:issue:`8973`). + + :param content: The text to write. + :param flush: Whether to flush the stream afterwards. + :param markup: + Markup to apply to the text, for example ``red=True`` or + ``bold=True``. An unknown markup name raises :class:`ValueError`. + """ self._tw.write(content, flush=flush, **markup) def write_raw(self, content: str, *, flush: bool = False) -> None: From 1faa296e4372d3248f61545de87fdcf02b2f5ad4 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sat, 12 Sep 2026 06:55:46 +0200 Subject: [PATCH 4/6] docs(changelog): reference TerminalReporter.write by its public name 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) Co-Authored-By: Claude Code --- changelog/8973.bugfix.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/8973.bugfix.rst b/changelog/8973.bugfix.rst index d7189be39f0..bc5b8b01239 100644 --- a/changelog/8973.bugfix.rst +++ b/changelog/8973.bugfix.rst @@ -1 +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. +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 :meth:`TerminalReporter.write() ` from within a test) always reaches the terminal instead of disappearing into the capture buffers. From 3cff275a0ba343901237b424b753da76a578c9e4 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sat, 12 Sep 2026 06:57:01 +0200 Subject: [PATCH 5/6] fix(capture): open the stdout duplicate so Windows consoles stay correct `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) Co-Authored-By: Claude Code --- src/_pytest/capture.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index 74b4d8b29f0..6806fd723b1 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -170,9 +170,15 @@ class TerminalStdout(io.TextIOWrapper): def __init__(self, fd: int, *, original_stdout: TextIO) -> None: super().__init__( - # Buffered rather than raw: a raw stream may write fewer bytes - # than asked for, and TextIOWrapper does not retry those. - io.BufferedWriter(io.FileIO(fd, mode="w", closefd=True)), + # ``open`` rather than ``io.FileIO``: only ``open`` picks + # ``_WindowsConsoleIO`` for a console descriptor, which writes + # through ``WriteConsoleW`` instead of handing UTF-8 bytes to a + # console whose code page is usually not UTF-8. This mirrors + # ``_reopen_stdio`` above, which duplicates stdout the same way + # and for the same reason. Buffered rather than raw: a raw stream + # may write fewer bytes than asked for, and ``TextIOWrapper`` does + # not retry those. + open(fd, "wb", closefd=True), encoding=getattr(original_stdout, "encoding", None) or "utf-8", errors=getattr(original_stdout, "errors", None) or "replace", write_through=True, From 9e0181de86a93eba4bd3941ed436ddc6e25edf32 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sat, 12 Sep 2026 06:57:19 +0200 Subject: [PATCH 6/6] docs(terminal): correct the rationale for resolving the file here The comment justified resolving the default in `__init__` by plugins subclassing `TerminalReporter`, but the class has been `@final` since a99ca879e -- 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) Co-Authored-By: Claude Code --- src/_pytest/terminal.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 61b2fe8ec5c..64c597f760d 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -399,10 +399,12 @@ def __init__(self, config: Config, file: TextIO | None = None) -> None: self._known_types: list[str] | None = None self.startpath = config.invocation_params.dir if file is None: - # The terminal channel writes past output capture (#8973); it falls - # back to sys.stdout when there is none. Resolved here rather than - # in pytest_configure so that plugins subclassing TerminalReporter - # and registering their own (pytest-sugar) get it too. + # The terminal channel writes past output capture (#8973); it + # falls back to sys.stdout when there is none. Resolved from the + # default rather than in pytest_configure so that every caller + # that leaves `file` unset gets it -- including plugins that + # construct a reporter of their own (pytest-sugar does, despite + # the @final above). file = get_terminal_stdout(config) self._tw = _pytest.config.create_terminal_writer(config, file) self._screen_width = self._tw.fullwidth