Skip to content

Commit a55c959

Browse files
authored
Merge pull request #13728 from bluetech/terminal-tab-progress
terminal: notify terminal emulator about test session progress
2 parents 42c9fe0 + 99cd7a2 commit a55c959

File tree

5 files changed

+251
-18
lines changed

5 files changed

+251
-18
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/_io/terminalwriter.py

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -161,20 +161,23 @@ def write(self, msg: str, *, flush: bool = False, **markup: bool) -> None:
161161

162162
msg = self.markup(msg, **markup)
163163

164-
try:
165-
self._file.write(msg)
166-
except UnicodeEncodeError:
167-
# Some environments don't support printing general Unicode
168-
# strings, due to misconfiguration or otherwise; in that case,
169-
# print the string escaped to ASCII.
170-
# When the Unicode situation improves we should consider
171-
# letting the error propagate instead of masking it (see #7475
172-
# for one brief attempt).
173-
msg = msg.encode("unicode-escape").decode("ascii")
174-
self._file.write(msg)
175-
176-
if flush:
177-
self.flush()
164+
self.write_raw(msg, flush=flush)
165+
166+
def write_raw(self, msg: str, *, flush: bool = False) -> None:
167+
try:
168+
self._file.write(msg)
169+
except UnicodeEncodeError:
170+
# Some environments don't support printing general Unicode
171+
# strings, due to misconfiguration or otherwise; in that case,
172+
# print the string escaped to ASCII.
173+
# When the Unicode situation improves we should consider
174+
# letting the error propagate instead of masking it (see #7475
175+
# for one brief attempt).
176+
msg = msg.encode("unicode-escape").decode("ascii")
177+
self._file.write(msg)
178+
179+
if flush:
180+
self.flush()
178181

179182
def line(self, s: str = "", **markup: bool) -> None:
180183
self.write(s, **markup)

src/_pytest/terminal.py

Lines changed: 108 additions & 4 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
@@ -454,6 +458,14 @@ def showfspath(self, value: bool | None) -> None:
454458
def showlongtestinfo(self) -> bool:
455459
return self.config.get_verbosity(Config.VERBOSITY_TEST_CASES) > 0
456460

461+
@property
462+
def reported_progress(self) -> int:
463+
"""The amount of items reported in the progress so far.
464+
465+
:meta private:
466+
"""
467+
return len(self._progress_nodeids_reported)
468+
457469
def hasopt(self, char: str) -> bool:
458470
char = {"xfailed": "x", "skipped": "s"}.get(char, char)
459471
return char in self.reportchars
@@ -508,6 +520,9 @@ def wrap_write(
508520
def write(self, content: str, *, flush: bool = False, **markup: bool) -> None:
509521
self._tw.write(content, flush=flush, **markup)
510522

523+
def write_raw(self, content: str, *, flush: bool = False) -> None:
524+
self._tw.write_raw(content, flush=flush)
525+
511526
def flush(self) -> None:
512527
self._tw.flush()
513528

@@ -681,7 +696,7 @@ def pytest_runtest_logreport(self, report: TestReport) -> None:
681696
@property
682697
def _is_last_item(self) -> bool:
683698
assert self._session is not None
684-
return len(self._progress_nodeids_reported) == self._session.testscollected
699+
return self.reported_progress == self._session.testscollected
685700

686701
@hookimpl(wrapper=True)
687702
def pytest_runtestloop(self) -> Generator[None, object, object]:
@@ -691,7 +706,7 @@ def pytest_runtestloop(self) -> Generator[None, object, object]:
691706
if (
692707
self.config.get_verbosity(Config.VERBOSITY_TEST_CASES) <= 0
693708
and self._show_progress_info
694-
and self._progress_nodeids_reported
709+
and self.reported_progress
695710
):
696711
self._write_progress_information_filling_space()
697712

@@ -702,7 +717,7 @@ def _get_progress_information_message(self) -> str:
702717
collected = self._session.testscollected
703718
if self._show_progress_info == "count":
704719
if collected:
705-
progress = len(self._progress_nodeids_reported)
720+
progress = self.reported_progress
706721
counter_format = f"{{:{len(str(collected))}d}}"
707722
format_string = f" [{counter_format}/{{}}]"
708723
return format_string.format(progress, collected)
@@ -739,7 +754,7 @@ def _get_progress_information_message(self) -> str:
739754
)
740755
return ""
741756
if collected:
742-
return f" [{len(self._progress_nodeids_reported) * 100 // collected:3d}%]"
757+
return f" [{self.reported_progress * 100 // collected:3d}%]"
743758
return " [100%]"
744759

745760
def _write_progress_information_if_past_edge(self) -> None:
@@ -1641,3 +1656,92 @@ def _get_raw_skip_reason(report: TestReport) -> str:
16411656
elif reason == "Skipped":
16421657
reason = ""
16431658
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)