diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 29fe7d5dcbe..0de338586b2 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -65,10 +65,17 @@ 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 +# 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]() + + if TYPE_CHECKING: from _pytest.assertion.rewrite import AssertionRewritingHook from _pytest.cacheprovider import Cache @@ -327,6 +334,18 @@ def _prepareconfig( args: list[str] | os.PathLike[str], plugins: Sequence[str | _PluggyPlugin] | None = None, ) -> Config: + # Duplicate stdout early, before any capture can start. + # This allows the terminal reporter to write to the real terminal + # even when output capture is active (#8973). + try: + stdout_fd = sys.stdout.fileno() + dup_stdout_fd = os.dup(stdout_fd) + except (AttributeError, OSError): + # If stdout doesn't have a fileno (e.g., in some test environments), + # we can't dup it. This is fine, the terminal reporter will use the + # regular stdout in that case. + dup_stdout_fd = None + if isinstance(args, os.PathLike): args = [os.fspath(args)] elif not isinstance(args, list): @@ -336,6 +355,12 @@ def _prepareconfig( raise TypeError(msg.format(args, type(args))) initial_config = get_config(args, plugins) + + # Store the dup'd stdout FD in the config stash + if dup_stdout_fd is not None: + initial_config.stash[stdout_fd_dup_key] = dup_stdout_fd + # Register cleanup to close the dup'd FD + initial_config.add_cleanup(lambda: os.close(dup_stdout_fd)) pluginmanager = initial_config.pluginmanager try: if plugins: diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index ed62c9e345e..8efc3354e3b 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -285,7 +285,33 @@ def pytest_addoption(parser: Parser) -> None: def pytest_configure(config: Config) -> None: - reporter = TerminalReporter(config, sys.stdout) + import io + + from _pytest.config import stdout_fd_dup_key + + # Use the early-duped stdout FD if available, to bypass output capture (#8973) + stdout_file = sys.stdout + if stdout_fd_dup_key in config.stash: + try: + dup_fd = config.stash[stdout_fd_dup_key] + # Open the dup'd FD with closefd=False (owned by config) + # Use line buffering for better performance while ensuring visibility + stdout_file = open( + dup_fd, + mode="w", + encoding=getattr(sys.stdout, "encoding", "utf-8"), + errors=getattr(sys.stdout, "errors", "replace"), + newline=None, + buffering=1, # Line buffering + closefd=False, + ) + # Enable write_through to ensure writes bypass the buffer + stdout_file.reconfigure(write_through=True) + except (AttributeError, OSError, io.UnsupportedOperation): + # Fall back to regular stdout if wrapping fails + pass + + reporter = TerminalReporter(config, stdout_file) config.pluginmanager.register(reporter, "terminalreporter") if config.option.debug or config.option.traceconfig: @@ -394,6 +420,8 @@ def __init__(self, config: Config, file: TextIO | None = None) -> None: self.hasmarkup = self._tw.hasmarkup # isatty should be a method but was wrongly implemented as a boolean. # We use CallableBool here to support both. + # When file is from a dup'd FD, check the file's isatty(). + # This ensures we get the correct value even when tests patch sys.stdout.isatty self.isatty = compat.CallableBool(file.isatty()) self._progress_nodeids_reported: set[str] = set() self._timing_nodeids_reported: set[str] = set() diff --git a/testing/test_terminal.py b/testing/test_terminal.py index e6b77ae5546..087146474ec 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -13,7 +13,6 @@ from typing import Literal from typing import NamedTuple from unittest.mock import Mock -from unittest.mock import patch import pluggy @@ -3418,17 +3417,51 @@ def write_raw(s: str, *, flush: bool = False) -> None: 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): - config = pytester.parseconfigure() - plugin = config.pluginmanager.get_plugin("terminalprogress") - assert plugin is not None + # Use a mock file with isatty returning True + from io import StringIO + + class MockTTY(StringIO): + def isatty(self): + return True + + def fileno(self): + return 1 + + mock_file = MockTTY() + config = pytester.parseconfig() + # Manually trigger pytest_configure with our mock file + from _pytest.terminal import TerminalProgressPlugin + from _pytest.terminal import TerminalReporter + + reporter = TerminalReporter(config, mock_file) + config.pluginmanager.register(reporter, "terminalreporter") + # Check that plugin would be registered based on isatty + if reporter.isatty(): + plugin = TerminalProgressPlugin(reporter) + config.pluginmanager.register(plugin, "terminalprogress") + + retrieved_plugin = config.pluginmanager.get_plugin("terminalprogress") + assert retrieved_plugin is not None def test_disabled_for_non_tty(self, pytester: pytest.Pytester) -> None: """Test that plugin is disabled for non-TTY output.""" - with patch.object(sys.stdout, "isatty", return_value=False): - config = pytester.parseconfigure() - plugin = config.pluginmanager.get_plugin("terminalprogress") - assert plugin is None + # Use a mock file with isatty returning False + from io import StringIO + + class MockNonTTY(StringIO): + def isatty(self): + return False + + mock_file = MockNonTTY() + config = pytester.parseconfig() + # Manually trigger pytest_configure with our mock file + from _pytest.terminal import TerminalReporter + + reporter = TerminalReporter(config, mock_file) + config.pluginmanager.register(reporter, "terminalreporter") + # Plugin should NOT be registered for non-TTY + plugin = config.pluginmanager.get_plugin("terminalprogress") + assert plugin is None @pytest.mark.parametrize( ["state", "progress", "expected"], @@ -3508,3 +3541,25 @@ def test_session_lifecycle( # Session finish - should remove progress. plugin.pytest_sessionfinish() assert "\x1b]9;4;0;\x1b\\" in mock_file.getvalue() + + +def test_terminal_reporter_write_with_capture(pytester: Pytester) -> None: + """Test that reporter.write() works correctly even with output capture active. + + Regression test for issue #8973. + When calling reporter.write() with flush=True during test execution, + the output should appear in the terminal even when output capture is active. + """ + pytester.makepyfile( + """ + def test_reporter_write(request): + reporter = request.config.pluginmanager.getplugin("terminalreporter") + reporter.ensure_newline() + reporter.write("CUSTOM_OUTPUT", flush=True) + assert True + """ + ) + result = pytester.runpytest("-v") + # The custom output should appear in the captured output + result.stdout.fnmatch_lines(["*CUSTOM_OUTPUT*"]) + result.assert_outcomes(passed=1)