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
1 change: 1 addition & 0 deletions changelog/13896.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The terminal progress feature added in pytest 9.0.0 has been removed due to compatibility issues with some terminal emulators.
3 changes: 3 additions & 0 deletions doc/en/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,9 @@ New features


- `#13072 <https://github.com/pytest-dev/pytest/issues/13072>`_: Added support for displaying test session **progress in the terminal tab** using the `OSC 9;4; <https://conemu.github.io/en/AnsiEscapeCodes.html#ConEmu_specific_OSC>`_ 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.
Expand Down
101 changes: 0 additions & 101 deletions src/_pytest/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
import datetime
from functools import partial
import inspect
import os
from pathlib import Path
import platform
import sys
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
128 changes: 0 additions & 128 deletions testing/test_terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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()