Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/_pytest/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]()
Copy link
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".



if TYPE_CHECKING:
from _pytest.assertion.rewrite import AssertionRewritingHook
from _pytest.cacheprovider import Cache
Expand Down Expand Up @@ -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):
Expand All @@ -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:
Expand Down
30 changes: 29 additions & 1 deletion src/_pytest/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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()
Expand Down
73 changes: 64 additions & 9 deletions testing/test_terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
from typing import Literal
from typing import NamedTuple
from unittest.mock import Mock
from unittest.mock import patch

import pluggy

Expand Down Expand Up @@ -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):
Copy link
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?

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
Copy link
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.


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"],
Expand Down Expand Up @@ -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:
Copy link
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.

"""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)
Loading