From ba9f29cd8292ad484f61a8f1d430a1fe664649b6 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 14 Nov 2025 15:09:33 +0200 Subject: [PATCH] terminal: remove OSC 9;4 terminal progress Fix #13896. --- changelog/13896.bugfix.rst | 1 + doc/en/changelog.rst | 3 + src/_pytest/terminal.py | 101 ----------------------------- testing/test_terminal.py | 128 ------------------------------------- 4 files changed, 4 insertions(+), 229 deletions(-) create mode 100644 changelog/13896.bugfix.rst diff --git a/changelog/13896.bugfix.rst b/changelog/13896.bugfix.rst new file mode 100644 index 00000000000..8aa9729824d --- /dev/null +++ b/changelog/13896.bugfix.rst @@ -0,0 +1 @@ +The terminal progress feature added in pytest 9.0.0 has been removed due to compatibility issues with some terminal emulators. diff --git a/doc/en/changelog.rst b/doc/en/changelog.rst index 85a509dff3f..66b3160fbd5 100644 --- a/doc/en/changelog.rst +++ b/doc/en/changelog.rst @@ -178,6 +178,9 @@ New features - `#13072 `_: Added support for displaying test session **progress in the terminal tab** using the `OSC 9;4; `_ ANSI sequence. + + *Note: this feature has been removed in pytest 9.0.2 due to compatibility issues.* + When pytest runs in a supported terminal emulator like ConEmu, Gnome Terminal, Ptyxis, Windows Terminal, Kitty or Ghostty, you'll see the progress in the terminal tab or window, allowing you to monitor pytest's progress at a glance. diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 4517b05bdee..83a28dfab83 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -16,7 +16,6 @@ import datetime from functools import partial import inspect -import os from pathlib import Path import platform import sys @@ -299,17 +298,6 @@ def mywriter(tags, args): config.trace.root.setprocessor("pytest:config", mywriter) - if reporter.isatty(): - # Some terminals interpret OSC 9;4 as desktop notification, - # skip on those we know (#13896). - should_skip_terminal_progress = ( - # iTerm2 (reported on version 3.6.5). - "ITERM_SESSION_ID" in os.environ - ) - if not should_skip_terminal_progress: - plugin = TerminalProgressPlugin(reporter) - config.pluginmanager.register(plugin, "terminalprogress") - def getreportopt(config: Config) -> str: reportchars: str = config.option.reportchars @@ -1679,92 +1667,3 @@ def _get_raw_skip_reason(report: TestReport) -> str: elif reason == "Skipped": reason = "" return reason - - -class TerminalProgressPlugin: - """Terminal progress reporting plugin using OSC 9;4 ANSI sequences. - - Emits OSC 9;4 sequences to indicate test progress to terminal - tabs/windows/etc. - - Not all terminal emulators support this feature. - - Ref: https://conemu.github.io/en/AnsiEscapeCodes.html#ConEmu_specific_OSC - """ - - def __init__(self, tr: TerminalReporter) -> None: - self._tr = tr - self._session: Session | None = None - self._has_failures = False - - def _emit_progress( - self, - state: Literal["remove", "normal", "error", "indeterminate", "paused"], - progress: int | None = None, - ) -> None: - """Emit OSC 9;4 sequence for indicating progress to the terminal. - - :param state: - Progress state to set. - :param progress: - Progress value 0-100. Required for "normal", optional for "error" - and "paused", otherwise ignored. - """ - assert progress is None or 0 <= progress <= 100 - - # OSC 9;4 sequence: ESC ] 9 ; 4 ; state ; progress ST - # ST can be ESC \ or BEL. ESC \ seems better supported. - match state: - case "remove": - sequence = "\x1b]9;4;0;\x1b\\" - case "normal": - assert progress is not None - sequence = f"\x1b]9;4;1;{progress}\x1b\\" - case "error": - if progress is not None: - sequence = f"\x1b]9;4;2;{progress}\x1b\\" - else: - sequence = "\x1b]9;4;2;\x1b\\" - case "indeterminate": - sequence = "\x1b]9;4;3;\x1b\\" - case "paused": - if progress is not None: - sequence = f"\x1b]9;4;4;{progress}\x1b\\" - else: - sequence = "\x1b]9;4;4;\x1b\\" - - self._tr.write_raw(sequence, flush=True) - - @hookimpl - def pytest_sessionstart(self, session: Session) -> None: - self._session = session - # Show indeterminate progress during collection. - self._emit_progress("indeterminate") - - @hookimpl - def pytest_collection_finish(self) -> None: - assert self._session is not None - if self._session.testscollected > 0: - # Switch from indeterminate to 0% progress. - self._emit_progress("normal", 0) - - @hookimpl - def pytest_runtest_logreport(self, report: TestReport) -> None: - if report.failed: - self._has_failures = True - - # Let's consider the "call" phase for progress. - if report.when != "call": - return - - # Calculate and emit progress. - assert self._session is not None - collected = self._session.testscollected - if collected > 0: - reported = self._tr.reported_progress - progress = min(reported * 100 // collected, 100) - self._emit_progress("error" if self._has_failures else "normal", progress) - - @hookimpl - def pytest_sessionfinish(self) -> None: - self._emit_progress("remove") diff --git a/testing/test_terminal.py b/testing/test_terminal.py index a981e14f0a2..cb0a2c94e9b 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -10,10 +10,7 @@ import textwrap from types import SimpleNamespace from typing import cast -from typing import Literal from typing import NamedTuple -from unittest.mock import Mock -from unittest.mock import patch import pluggy @@ -33,7 +30,6 @@ from _pytest.terminal import _get_raw_skip_reason from _pytest.terminal import _plugin_nameversions from _pytest.terminal import getreportopt -from _pytest.terminal import TerminalProgressPlugin from _pytest.terminal import TerminalReporter import pytest @@ -3408,127 +3404,3 @@ def test_x(a): r".*test_foo.py::test_x\[a::b/\] .*FAILED.*", ] ) - - -class TestTerminalProgressPlugin: - """Tests for the TerminalProgressPlugin.""" - - @pytest.fixture - def mock_file(self) -> StringIO: - return StringIO() - - @pytest.fixture - def mock_tr(self, mock_file: StringIO) -> pytest.TerminalReporter: - tr = Mock(spec=pytest.TerminalReporter) - - def write_raw(s: str, *, flush: bool = False) -> None: - mock_file.write(s) - - tr.write_raw = write_raw - tr._progress_nodeids_reported = set() - return tr - - 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 - - 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 - - def test_disabled_for_iterm2(self, pytester: pytest.Pytester, monkeypatch) -> None: - """Should not register the plugin on iTerm2 terminal since it interprets - OSC 9;4 as desktop notifications, not progress (#13896).""" - monkeypatch.setenv( - "ITERM_SESSION_ID", "w0t1p0:3DB6DF06-FE11-40C3-9A66-9E10A193A632" - ) - with patch.object(sys.stdout, "isatty", return_value=True): - config = pytester.parseconfigure() - plugin = config.pluginmanager.get_plugin("terminalprogress") - assert plugin is None - - @pytest.mark.parametrize( - ["state", "progress", "expected"], - [ - ("indeterminate", None, "\x1b]9;4;3;\x1b\\"), - ("normal", 50, "\x1b]9;4;1;50\x1b\\"), - ("error", 75, "\x1b]9;4;2;75\x1b\\"), - ("paused", None, "\x1b]9;4;4;\x1b\\"), - ("paused", 80, "\x1b]9;4;4;80\x1b\\"), - ("remove", None, "\x1b]9;4;0;\x1b\\"), - ], - ) - def test_emit_progress_sequences( - self, - mock_file: StringIO, - mock_tr: pytest.TerminalReporter, - state: Literal["remove", "normal", "error", "indeterminate", "paused"], - progress: int | None, - expected: str, - ) -> None: - """Test that progress sequences are emitted correctly.""" - plugin = TerminalProgressPlugin(mock_tr) - plugin._emit_progress(state, progress) - assert expected in mock_file.getvalue() - - def test_session_lifecycle( - self, mock_file: StringIO, mock_tr: pytest.TerminalReporter - ) -> None: - """Test progress updates during session lifecycle.""" - plugin = TerminalProgressPlugin(mock_tr) - - session = Mock(spec=pytest.Session) - session.testscollected = 3 - - # Session start - should emit indeterminate progress. - plugin.pytest_sessionstart(session) - assert "\x1b]9;4;3;\x1b\\" in mock_file.getvalue() - mock_file.truncate(0) - mock_file.seek(0) - - # Collection finish - should emit 0% progress. - plugin.pytest_collection_finish() - assert "\x1b]9;4;1;0\x1b\\" in mock_file.getvalue() - mock_file.truncate(0) - mock_file.seek(0) - - # First test - 33% progress. - report1 = pytest.TestReport( - nodeid="test_1", - location=("test.py", 0, "test_1"), - when="call", - outcome="passed", - keywords={}, - longrepr=None, - ) - mock_tr.reported_progress = 1 # type: ignore[misc] - plugin.pytest_runtest_logreport(report1) - assert "\x1b]9;4;1;33\x1b\\" in mock_file.getvalue() - mock_file.truncate(0) - mock_file.seek(0) - - # Second test with failure - 66% in error state. - report2 = pytest.TestReport( - nodeid="test_2", - location=("test.py", 1, "test_2"), - when="call", - outcome="failed", - keywords={}, - longrepr=None, - ) - mock_tr.reported_progress = 2 # type: ignore[misc] - plugin.pytest_runtest_logreport(report2) - assert "\x1b]9;4;2;66\x1b\\" in mock_file.getvalue() - mock_file.truncate(0) - mock_file.seek(0) - - # Session finish - should remove progress. - plugin.pytest_sessionfinish() - assert "\x1b]9;4;0;\x1b\\" in mock_file.getvalue()