Skip to content

Commit c99e5ed

Browse files
bluetechannatasio
andcommitted
terminal: notify terminal emulator about test session progress
Use OSC 9;4 ANSI sequences terminal progress to notify the terminal emulator about progress, so it can display it to the user (e.g. on the terminal tab). Fix #13072. Co-Authored-By: Anna Tasiopoulou <[email protected]>
1 parent 9f0a005 commit c99e5ed

File tree

4 files changed

+229
-0
lines changed

4 files changed

+229
-0
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Andrzej Klajnert
3939
Andrzej Ostrowski
4040
Andy Freeland
4141
Anita Hammer
42+
Anna Tasiopoulou
4243
Anthon van der Neut
4344
Anthony Shaw
4445
Anthony Sottile

changelog/13072.feature.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Added support for displaying test session progress in the terminal tab using the OSC 9;4; ANSI sequence.
2+
When pytest runs in a supported terminal emulator like ConEmu or Ptyxis,
3+
you'll see the progress in the terminal tab,
4+
allowing you to monitor pytest's progress at a glance without having to switch to the pytest tab.
5+
6+
This feature is automatically enabled when running in a compatible terminal and requires no additional configuration.

src/_pytest/terminal.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,10 @@ def mywriter(tags, args):
295295

296296
config.trace.root.setprocessor("pytest:config", mywriter)
297297

298+
if reporter.isatty():
299+
plugin = TerminalProgressPlugin(reporter)
300+
config.pluginmanager.register(plugin, "terminalprogress")
301+
298302

299303
def getreportopt(config: Config) -> str:
300304
reportchars: str = config.option.reportchars
@@ -1652,3 +1656,91 @@ def _get_raw_skip_reason(report: TestReport) -> str:
16521656
elif reason == "Skipped":
16531657
reason = ""
16541658
return reason
1659+
1660+
1661+
class TerminalProgressPlugin:
1662+
"""Terminal progress reporting plugin using OSC 9;4 ANSI sequences.
1663+
1664+
Emits OSC 9;4 sequences to indicate test progress to terminal
1665+
tabs/windows/etc.
1666+
1667+
Not all terminal emulators support this feature.
1668+
1669+
Ref: https://conemu.github.io/en/AnsiEscapeCodes.html#ConEmu_specific_OSC
1670+
"""
1671+
1672+
def __init__(self, tr: TerminalReporter) -> None:
1673+
self._tr = tr
1674+
self._session: Session | None = None
1675+
self._has_failures = False
1676+
1677+
def _emit_progress(
1678+
self,
1679+
state: Literal["remove", "normal", "error", "indeterminate", "paused"],
1680+
progress: int | None = None,
1681+
) -> None:
1682+
"""Emit OSC 9;4 sequence for indicating progress to the terminal.
1683+
1684+
:param state:
1685+
Progress state to set.
1686+
:param progress:
1687+
Progress value 0-100. Required for "normal", optional for "error"
1688+
and "paused", otherwise ignored.
1689+
"""
1690+
assert progress is None or 0 <= progress <= 100
1691+
1692+
# OSC 9;4 sequence: ESC ] 9 ; 4 ; state ; progress ST
1693+
# ST can be ESC \ or BEL. ESC \ seems better supported.
1694+
if state == "remove":
1695+
sequence = "\x1b]9;4;0;\x1b\\"
1696+
elif state == "normal":
1697+
assert progress is not None
1698+
sequence = f"\x1b]9;4;1;{progress}\x1b\\"
1699+
elif state == "error":
1700+
if progress is not None:
1701+
sequence = f"\x1b]9;4;2;{progress}\x1b\\"
1702+
else:
1703+
sequence = "\x1b]9;4;2;\x1b\\"
1704+
elif state == "indeterminate":
1705+
sequence = "\x1b]9;4;3;\x1b\\"
1706+
elif state == "paused":
1707+
if progress is not None:
1708+
sequence = f"\x1b]9;4;4;{progress}\x1b\\"
1709+
else:
1710+
sequence = "\x1b]9;4;4;\x1b\\"
1711+
1712+
self._tr.write_raw(sequence, flush=True)
1713+
1714+
@hookimpl
1715+
def pytest_sessionstart(self, session: Session) -> None:
1716+
self._session = session
1717+
# Show indeterminate progress during collection.
1718+
self._emit_progress("indeterminate")
1719+
1720+
@hookimpl
1721+
def pytest_collection_finish(self) -> None:
1722+
assert self._session is not None
1723+
if self._session.testscollected > 0:
1724+
# Switch from indeterminate to 0% progress.
1725+
self._emit_progress("normal", 0)
1726+
1727+
@hookimpl
1728+
def pytest_runtest_logreport(self, report: TestReport) -> None:
1729+
if report.failed:
1730+
self._has_failures = True
1731+
1732+
# Let's consider the "call" phase for progress.
1733+
if report.when != "call":
1734+
return
1735+
1736+
# Calculate and emit progress.
1737+
assert self._session is not None
1738+
collected = self._session.testscollected
1739+
if collected > 0:
1740+
reported = self._tr.reported_progress
1741+
progress = min(reported * 100 // collected, 100)
1742+
self._emit_progress("error" if self._has_failures else "normal", progress)
1743+
1744+
@hookimpl
1745+
def pytest_sessionfinish(self) -> None:
1746+
self._emit_progress("remove")

testing/test_terminal.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from types import SimpleNamespace
1212
from typing import cast
1313
from typing import NamedTuple
14+
from unittest.mock import Mock
15+
from unittest.mock import patch
1416

1517
import pluggy
1618

@@ -30,6 +32,7 @@
3032
from _pytest.terminal import _get_raw_skip_reason
3133
from _pytest.terminal import _plugin_nameversions
3234
from _pytest.terminal import getreportopt
35+
from _pytest.terminal import TerminalProgressPlugin
3336
from _pytest.terminal import TerminalReporter
3437
import pytest
3538

@@ -3297,3 +3300,130 @@ def test_x(a):
32973300
r".*test_foo.py::test_x\[a::b/\] .*FAILED.*",
32983301
]
32993302
)
3303+
3304+
3305+
class TestTerminalProgressPlugin:
3306+
"""Tests for the TerminalProgressPlugin."""
3307+
3308+
@pytest.fixture
3309+
def mock_file(self) -> StringIO:
3310+
return StringIO()
3311+
3312+
@pytest.fixture
3313+
def mock_tr(self, mock_file: StringIO) -> pytest.TerminalReporter:
3314+
tr = Mock(spec=pytest.TerminalReporter)
3315+
3316+
def write_raw(s: str, *, flush: bool = False) -> None:
3317+
mock_file.write(s)
3318+
3319+
tr.write_raw = write_raw
3320+
tr._progress_nodeids_reported = set()
3321+
return tr
3322+
3323+
def test_plugin_registration(self, pytester: pytest.Pytester) -> None:
3324+
"""Test that the plugin is registered correctly on TTY output."""
3325+
# The plugin module should be registered as a default plugin.
3326+
with patch.object(sys.stdout, "isatty", return_value=True):
3327+
config = pytester.parseconfigure()
3328+
plugin = config.pluginmanager.get_plugin("terminalprogress")
3329+
assert plugin is not None
3330+
3331+
def test_disabled_for_non_tty(self, pytester: pytest.Pytester) -> None:
3332+
"""Test that plugin is disabled for non-TTY output."""
3333+
with patch.object(sys.stdout, "isatty", return_value=False):
3334+
config = pytester.parseconfigure()
3335+
plugin = config.pluginmanager.get_plugin("terminalprogress")
3336+
assert plugin is None
3337+
3338+
def test_emit_progress_sequences(
3339+
self, mock_file: StringIO, mock_tr: pytest.TerminalReporter
3340+
) -> None:
3341+
"""Test that progress sequences are emitted correctly."""
3342+
plugin = TerminalProgressPlugin(mock_tr)
3343+
3344+
plugin._emit_progress("indeterminate")
3345+
assert "\x1b]9;4;3;\x1b\\" in mock_file.getvalue()
3346+
mock_file.truncate(0)
3347+
mock_file.seek(0)
3348+
3349+
plugin._emit_progress("normal", 50)
3350+
assert "\x1b]9;4;1;50\x1b\\" in mock_file.getvalue()
3351+
mock_file.truncate(0)
3352+
mock_file.seek(0)
3353+
3354+
plugin._emit_progress("error", 75)
3355+
assert "\x1b]9;4;2;75\x1b\\" in mock_file.getvalue()
3356+
mock_file.truncate(0)
3357+
mock_file.seek(0)
3358+
3359+
# Paused state without progress.
3360+
plugin._emit_progress("paused")
3361+
assert "\x1b]9;4;4;\x1b\\" in mock_file.getvalue()
3362+
mock_file.truncate(0)
3363+
mock_file.seek(0)
3364+
3365+
# Paused state with progress.
3366+
plugin._emit_progress("paused", 80)
3367+
assert "\x1b]9;4;4;80\x1b\\" in mock_file.getvalue()
3368+
mock_file.truncate(0)
3369+
mock_file.seek(0)
3370+
3371+
plugin._emit_progress("remove")
3372+
assert "\x1b]9;4;0;\x1b\\" in mock_file.getvalue()
3373+
mock_file.truncate(0)
3374+
mock_file.seek(0)
3375+
3376+
def test_session_lifecycle(
3377+
self, mock_file: StringIO, mock_tr: pytest.TerminalReporter
3378+
) -> None:
3379+
"""Test progress updates during session lifecycle."""
3380+
plugin = TerminalProgressPlugin(mock_tr)
3381+
3382+
session = Mock(spec=pytest.Session)
3383+
session.testscollected = 3
3384+
3385+
# Session start - should emit indeterminate progress.
3386+
plugin.pytest_sessionstart(session)
3387+
assert "\x1b]9;4;3;\x1b\\" in mock_file.getvalue()
3388+
mock_file.truncate(0)
3389+
mock_file.seek(0)
3390+
3391+
# Collection finish - should emit 0% progress.
3392+
plugin.pytest_collection_finish()
3393+
assert "\x1b]9;4;1;0\x1b\\" in mock_file.getvalue()
3394+
mock_file.truncate(0)
3395+
mock_file.seek(0)
3396+
3397+
# First test - 33% progress.
3398+
report1 = pytest.TestReport(
3399+
nodeid="test_1",
3400+
location=("test.py", 0, "test_1"),
3401+
when="call",
3402+
outcome="passed",
3403+
keywords={},
3404+
longrepr=None,
3405+
)
3406+
mock_tr.reported_progress = 1 # type: ignore[misc]
3407+
plugin.pytest_runtest_logreport(report1)
3408+
assert "\x1b]9;4;1;33\x1b\\" in mock_file.getvalue()
3409+
mock_file.truncate(0)
3410+
mock_file.seek(0)
3411+
3412+
# Second test with failure - 66% in error state.
3413+
report2 = pytest.TestReport(
3414+
nodeid="test_2",
3415+
location=("test.py", 1, "test_2"),
3416+
when="call",
3417+
outcome="failed",
3418+
keywords={},
3419+
longrepr=None,
3420+
)
3421+
mock_tr.reported_progress = 2 # type: ignore[misc]
3422+
plugin.pytest_runtest_logreport(report2)
3423+
assert "\x1b]9;4;2;66\x1b\\" in mock_file.getvalue()
3424+
mock_file.truncate(0)
3425+
mock_file.seek(0)
3426+
3427+
# Session finish - should remove progress.
3428+
plugin.pytest_sessionfinish()
3429+
assert "\x1b]9;4;0;\x1b\\" in mock_file.getvalue()

0 commit comments

Comments
 (0)