Skip to content

Commit 99cd7a2

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 <t8220147@aueb.gr>
1 parent c71920c commit 99cd7a2

File tree

4 files changed

+219
-0
lines changed

4 files changed

+219
-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: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
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.
2+
When pytest runs in a supported terminal emulator like ConEmu, Gnome Terminal, Ptyxis, Windows Terminal, Kitty or Ghostty,
3+
you'll see the progress in the terminal tab or window,
4+
allowing you to monitor pytest's progress at a glance.
5+
6+
This feature is automatically enabled when running in a TTY. It is implemented as an internal plugin. If needed, it can be disabled as follows:
7+
- On a user level, using ``-p no:terminalprogress`` on the command line or via an environment variable ``PYTEST_ADDOPTS='-p no:terminalprogress'``.
8+
- On a project configuration level, using ``addopts = "-p no:terminalprogress"``.

src/_pytest/terminal.py

Lines changed: 93 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,92 @@ 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+
match state:
1695+
case "remove":
1696+
sequence = "\x1b]9;4;0;\x1b\\"
1697+
case "normal":
1698+
assert progress is not None
1699+
sequence = f"\x1b]9;4;1;{progress}\x1b\\"
1700+
case "error":
1701+
if progress is not None:
1702+
sequence = f"\x1b]9;4;2;{progress}\x1b\\"
1703+
else:
1704+
sequence = "\x1b]9;4;2;\x1b\\"
1705+
case "indeterminate":
1706+
sequence = "\x1b]9;4;3;\x1b\\"
1707+
case "paused":
1708+
if progress is not None:
1709+
sequence = f"\x1b]9;4;4;{progress}\x1b\\"
1710+
else:
1711+
sequence = "\x1b]9;4;4;\x1b\\"
1712+
1713+
self._tr.write_raw(sequence, flush=True)
1714+
1715+
@hookimpl
1716+
def pytest_sessionstart(self, session: Session) -> None:
1717+
self._session = session
1718+
# Show indeterminate progress during collection.
1719+
self._emit_progress("indeterminate")
1720+
1721+
@hookimpl
1722+
def pytest_collection_finish(self) -> None:
1723+
assert self._session is not None
1724+
if self._session.testscollected > 0:
1725+
# Switch from indeterminate to 0% progress.
1726+
self._emit_progress("normal", 0)
1727+
1728+
@hookimpl
1729+
def pytest_runtest_logreport(self, report: TestReport) -> None:
1730+
if report.failed:
1731+
self._has_failures = True
1732+
1733+
# Let's consider the "call" phase for progress.
1734+
if report.when != "call":
1735+
return
1736+
1737+
# Calculate and emit progress.
1738+
assert self._session is not None
1739+
collected = self._session.testscollected
1740+
if collected > 0:
1741+
reported = self._tr.reported_progress
1742+
progress = min(reported * 100 // collected, 100)
1743+
self._emit_progress("error" if self._has_failures else "normal", progress)
1744+
1745+
@hookimpl
1746+
def pytest_sessionfinish(self) -> None:
1747+
self._emit_progress("remove")

testing/test_terminal.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
import textwrap
1111
from types import SimpleNamespace
1212
from typing import cast
13+
from typing import Literal
1314
from typing import NamedTuple
15+
from unittest.mock import Mock
16+
from unittest.mock import patch
1417

1518
import pluggy
1619

@@ -30,6 +33,7 @@
3033
from _pytest.terminal import _get_raw_skip_reason
3134
from _pytest.terminal import _plugin_nameversions
3235
from _pytest.terminal import getreportopt
36+
from _pytest.terminal import TerminalProgressPlugin
3337
from _pytest.terminal import TerminalReporter
3438
import pytest
3539

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

0 commit comments

Comments
 (0)