From 59bbf44d08abb254755d2ecae9de4dd4963eefa9 Mon Sep 17 00:00:00 2001 From: Muspi Merol Date: Sun, 19 Jul 2026 08:43:13 +0800 Subject: [PATCH 1/5] terminal: render file paths as OSC 8 hyperlinks Add a `--hyperlinks` option (yes/no/auto, default auto) that wraps file paths in tracebacks as clickable OSC 8 terminal hyperlinks. Auto enables them only on an interactive terminal (gated on the writer's isatty, not sys.stdout) so captured/pastebin output stays clean. Closes #13580. Co-authored-by: Macaron V1 Venti --- AUTHORS | 1 + changelog/13580.feature.rst | 1 + src/_pytest/_code/code.py | 25 ++++++++- src/_pytest/_io/terminalwriter.py | 12 +++++ src/_pytest/config/__init__.py | 9 ++++ src/_pytest/pastebin.py | 4 ++ src/_pytest/terminal.py | 10 ++++ testing/test_terminal.py | 84 +++++++++++++++++++++++++++++++ 8 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 changelog/13580.feature.rst diff --git a/AUTHORS b/AUTHORS index 33ee644ea68..f6f97e8a1b7 100644 --- a/AUTHORS +++ b/AUTHORS @@ -519,6 +519,7 @@ Xuan Luong Xuecong Liao Yannick Péroux Yao Xiao +Yihui Zhuang Yoav Caspi Yuliang Shao Yusuke Kadowaki diff --git a/changelog/13580.feature.rst b/changelog/13580.feature.rst new file mode 100644 index 00000000000..5025fb48c28 --- /dev/null +++ b/changelog/13580.feature.rst @@ -0,0 +1 @@ +File paths in tracebacks can now be rendered as clickable OSC 8 terminal hyperlinks via the new ``--hyperlinks`` option (``yes``/``no``/``auto``, default ``auto``). diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 3c453b15dd7..4e61171f086 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -1499,10 +1499,33 @@ def toterminal(self, tw: TerminalWriter) -> None: i = msg.find("\n") if i != -1: msg = msg[:i] - tw.write(self.path, bold=True, red=True) + path = _format_path_hyperlink(tw, self.path, self.lineno) + tw.write(path, bold=True, red=True) tw.line(f":{self.lineno}: {msg}") +def _format_path_hyperlink( + tw: TerminalWriter, path: str, lineno: int | None = None +) -> str: + """Wrap ``path`` in an OSC 8 terminal hyperlink when enabled. + + Uses :meth:`pathlib.Path.as_uri` so the URL is a proper ``file://`` URI: + it percent-encodes spaces and yields ``file:///C:/...`` on Windows — a + hand-rolled ``f"file://{path}"`` would misparse the drive letter or spaces + as a hostname. + """ + # getattr: mock TerminalWriters may not expose `hyperlinks`; treat absence + # as off so we never crash a caller that predates this feature. + if not getattr(tw, "hyperlinks", False): + return path + url = Path(path).absolute().as_uri() + if lineno is not None: + # `:line` is a de-facto convention (iTerm2, VSCode, kitty), not RFC 8089 — + # mainstream OSC 8 handlers split it off and jump to the line. + url = f"{url}:{lineno}" + return f"\x1b]8;;{url}\x1b\\{path}\x1b]8;;\x1b\\" + + @dataclasses.dataclass(eq=False) class ReprLocals(TerminalRepr): """Function local variables (pre-formatted).""" diff --git a/src/_pytest/_io/terminalwriter.py b/src/_pytest/_io/terminalwriter.py index 9191b4edace..4fa864b7add 100644 --- a/src/_pytest/_io/terminalwriter.py +++ b/src/_pytest/_io/terminalwriter.py @@ -88,6 +88,18 @@ def __init__(self, file: TextIO | None = None) -> None: self._current_line = "" self._terminal_width: int | None = None self.code_highlight = True + # OSC 8 hyperlinks: only meaningful when the terminal can render + # escape sequences. `create_terminal_writer` refines this from the + # `--hyperlinks` option; default False keeps output bare. + self.hyperlinks = False + + @property + def isatty(self) -> bool: + """True iff the underlying file is a tty. Unlike :attr:`hasmarkup`, + ``--color=yes`` does not force this True, so OSC 8 auto-mode can detect + non-tty sinks (e.g. the pastebin plugin's StringIO).""" + file = self._file + return bool(getattr(file, "isatty", None)) and file.isatty() @property def fullwidth(self) -> int: diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 96b3dd6a86a..63dade556dd 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -2097,6 +2097,15 @@ def create_terminal_writer( elif config.option.code_highlight == "no": tw.code_highlight = False + # auto gates on tw.isatty (the actual file, not sys.stdout) so a StringIO + # sink like the pastebin plugin stays clean; "yes" is unconditional like + # --color=yes, so callers reusing output as plain text must clear + # tw.hyperlinks themselves. Mirrors terminalprogress per #13896. + want = config.option.hyperlinks + tw.hyperlinks = want == "yes" or ( + want == "auto" and tw.hasmarkup and tw.isatty + ) + return tw diff --git a/src/_pytest/pastebin.py b/src/_pytest/pastebin.py index e6a1430220a..4e0b273e6e0 100644 --- a/src/_pytest/pastebin.py +++ b/src/_pytest/pastebin.py @@ -114,6 +114,10 @@ def pytest_terminal_summary(terminalreporter: TerminalReporter) -> None: msg = terminalreporter._getfailureheadline(rep) file = StringIO() tw = create_terminal_writer(terminalreporter.config, file) + # The captured text is pasted as plain text, so never emit OSC 8 + # hyperlinks (or any markup) into it — even with --hyperlinks=yes. + tw.hyperlinks = False + tw.hasmarkup = False rep.toterminal(tw) s = file.getvalue() assert len(s) diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index b9a65ff191e..dc8c45d4d5e 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -270,6 +270,16 @@ def pytest_addoption(parser: Parser) -> None: help="Whether code should be highlighted (only if --color is also enabled). " "Default: yes.", ) + group.addoption( + "--hyperlinks", + metavar="hyperlinks", + action="store", + dest="hyperlinks", + default="auto", + choices=["yes", "no", "auto"], + help="Render file paths as OSC 8 terminal hyperlinks (yes/no/auto). " + "Auto enables them only when the terminal supports markup. Default: auto.", + ) parser.addini( "console_output_style", diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 3053f5ef9a1..7287625fb65 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -20,6 +20,7 @@ import _pytest.config from _pytest.config import Config from _pytest.config import ExitCode +from _pytest.config import create_terminal_writer from _pytest.monkeypatch import MonkeyPatch from _pytest.pytester import Pytester from _pytest.reports import BaseReport @@ -3548,3 +3549,86 @@ def test_session_lifecycle( # Session finish - should remove progress. plugin.pytest_sessionfinish() assert "\x1b]9;4;0;\x1b\\" in mock_file.getvalue() + + +def test_hyperlinks_yes_emits_osc8(pytester: Pytester) -> None: + """--hyperlinks=yes wraps file paths in OSC 8 escape sequences.""" + pytester.makepyfile( + """ + def test_fail(): + assert 0 + """ + ) + result = pytester.runpytest("--hyperlinks=yes", "--color=yes") + # OSC 8 hyperlink wrapper: \x1b]8;;URL\x1b\\TEXT\x1b]8;;\x1b\\ + output = result.stdout.str() + assert "\x1b]8;;file://" in output + assert "\x1b\\test_hyperlinks_yes_emits_osc8.py\x1b]8;;\x1b\\" in output + + +def test_hyperlinks_yes_percent_encodes_spaces(pytester: Pytester) -> None: + """Paths with spaces are percent-encoded in the file:// URL (#13580).""" + # Put the test file in a directory whose name has a space, so the absolute + # path passed to Path.as_uri() contains a space that must be encoded. + spaced = pytester.path / "with space" + spaced.mkdir() + (spaced / "test_fail.py").write_text("def test_fail():\n assert 0\n") + result = pytester.runpytest("--hyperlinks=yes", "--color=yes", str(spaced / "test_fail.py")) + output = result.stdout.str() + # The URL must encode the space; a raw space would make the URI invalid and + # terminals would truncate the link at the space. + assert "file://" in output + assert "%20" in output + + +def test_hyperlinks_no_no_osc8(pytester: Pytester) -> None: + """--hyperlinks=no never emits OSC 8 sequences, even with color on.""" + pytester.makepyfile( + """ + def test_fail(): + assert 0 + """ + ) + result = pytester.runpytest("--hyperlinks=no", "--color=yes") + assert "\x1b]8;;" not in result.stdout.str() + + +def test_hyperlinks_auto_off_when_not_tty(pytester: Pytester) -> None: + """auto (the default) emits no OSC 8 when stdout is captured/not a tty. + + pytester.runpytest captures the subprocess stdout, so it is not a tty — + auto mode must not emit hyperlinks, keeping CI/captured output clean. + """ + pytester.makepyfile( + """ + def test_fail(): + assert 0 + """ + ) + result = pytester.runpytest("--color=yes") # --hyperlinks defaults to auto + assert "\x1b]8;;" not in result.stdout.str() + + +def test_hyperlinks_auto_off_for_non_tty_sink(pytester: Pytester) -> None: + """auto must gate on isatty, not hasmarkup — --color=yes forces hasmarkup + True even for a StringIO, so hasmarkup-only gating would leak OSC 8.""" + config = pytester.parseconfigure("--color=yes") # --hyperlinks defaults to auto + tw = create_terminal_writer(config, StringIO()) + assert tw.hasmarkup # --color=yes forces markup on + assert not tw.isatty # but StringIO is not a tty + assert not tw.hyperlinks # so auto must keep hyperlinks off + + +def test_hyperlinks_yes_forces_on(pytester: Pytester) -> None: + """--hyperlinks=yes forces on even for a non-tty sink, unlike auto. + + ``yes`` is unconditional like ``--color=yes``: a user who explicitly asks + for hyperlinks gets them regardless of isatty/hasmarkup. Callers that + capture output for plain-text reuse (the pastebin plugin) must clear + ``tw.hyperlinks`` themselves — see pastebin.py. + """ + config = pytester.parseconfigure("--hyperlinks=yes", "--color=no") + tw = create_terminal_writer(config, StringIO()) + assert not tw.isatty # StringIO is not a tty + assert not tw.hasmarkup # --color=no + assert tw.hyperlinks # yes forces on anyway From 303d72a7be70970cbb51c57d0060bf974871e855 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:44:42 +0000 Subject: [PATCH 2/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/_pytest/config/__init__.py | 4 +--- testing/test_terminal.py | 10 ++++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 63dade556dd..4913c8507e3 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -2102,9 +2102,7 @@ def create_terminal_writer( # --color=yes, so callers reusing output as plain text must clear # tw.hyperlinks themselves. Mirrors terminalprogress per #13896. want = config.option.hyperlinks - tw.hyperlinks = want == "yes" or ( - want == "auto" and tw.hasmarkup and tw.isatty - ) + tw.hyperlinks = want == "yes" or (want == "auto" and tw.hasmarkup and tw.isatty) return tw diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 7287625fb65..a5bf368c755 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -19,8 +19,8 @@ from _pytest._io.wcwidth import wcswidth import _pytest.config from _pytest.config import Config -from _pytest.config import ExitCode from _pytest.config import create_terminal_writer +from _pytest.config import ExitCode from _pytest.monkeypatch import MonkeyPatch from _pytest.pytester import Pytester from _pytest.reports import BaseReport @@ -3573,7 +3573,9 @@ def test_hyperlinks_yes_percent_encodes_spaces(pytester: Pytester) -> None: spaced = pytester.path / "with space" spaced.mkdir() (spaced / "test_fail.py").write_text("def test_fail():\n assert 0\n") - result = pytester.runpytest("--hyperlinks=yes", "--color=yes", str(spaced / "test_fail.py")) + result = pytester.runpytest( + "--hyperlinks=yes", "--color=yes", str(spaced / "test_fail.py") + ) output = result.stdout.str() # The URL must encode the space; a raw space would make the URI invalid and # terminals would truncate the link at the space. @@ -3594,7 +3596,7 @@ def test_fail(): def test_hyperlinks_auto_off_when_not_tty(pytester: Pytester) -> None: - """auto (the default) emits no OSC 8 when stdout is captured/not a tty. + """Auto (the default) emits no OSC 8 when stdout is captured/not a tty. pytester.runpytest captures the subprocess stdout, so it is not a tty — auto mode must not emit hyperlinks, keeping CI/captured output clean. @@ -3610,7 +3612,7 @@ def test_fail(): def test_hyperlinks_auto_off_for_non_tty_sink(pytester: Pytester) -> None: - """auto must gate on isatty, not hasmarkup — --color=yes forces hasmarkup + """Auto must gate on isatty, not hasmarkup — --color=yes forces hasmarkup True even for a StringIO, so hasmarkup-only gating would leak OSC 8.""" config = pytester.parseconfigure("--color=yes") # --hyperlinks defaults to auto tw = create_terminal_writer(config, StringIO()) From 5e8e8e91b8d72e325ab2b6a4672f078ef975e4a0 Mon Sep 17 00:00:00 2001 From: Muspi Merol Date: Sun, 19 Jul 2026 08:53:51 +0800 Subject: [PATCH 3/5] test: specify encoding for write_text to fix EncodingWarning on CI --- testing/test_terminal.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/testing/test_terminal.py b/testing/test_terminal.py index a5bf368c755..3596a9e6fbb 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -3572,7 +3572,9 @@ def test_hyperlinks_yes_percent_encodes_spaces(pytester: Pytester) -> None: # path passed to Path.as_uri() contains a space that must be encoded. spaced = pytester.path / "with space" spaced.mkdir() - (spaced / "test_fail.py").write_text("def test_fail():\n assert 0\n") + (spaced / "test_fail.py").write_text( + "def test_fail():\n assert 0\n", encoding="utf-8" + ) result = pytester.runpytest( "--hyperlinks=yes", "--color=yes", str(spaced / "test_fail.py") ) From 3b22873bfc29deccbccf02be826ed8c10df30882 Mon Sep 17 00:00:00 2001 From: Muspi Merol Date: Sun, 19 Jul 2026 09:19:26 +0800 Subject: [PATCH 4/5] test: cover _format_path_hyperlink lineno=None branch for codecov --- testing/test_terminal.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 3596a9e6fbb..ab714a22a6e 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -3636,3 +3636,26 @@ def test_hyperlinks_yes_forces_on(pytester: Pytester) -> None: assert not tw.isatty # StringIO is not a tty assert not tw.hasmarkup # --color=no assert tw.hyperlinks # yes forces on anyway + + +def test_format_path_hyperlink_branches() -> None: + """Cover _format_path_hyperlink's branches: disabled, lineno=None, lineno set.""" + from _pytest._code.code import _format_path_hyperlink + + class TW: + hyperlinks = False + + tw = TW() + # Disabled: bare path returned unchanged. + assert _format_path_hyperlink(tw, "test_x.py", 5) == "test_x.py" + + tw.hyperlinks = True + # lineno=None (e.g. doctest ReprFileLocation): URL has no :line suffix. + out = _format_path_hyperlink(tw, "test_x.py", None) + assert out.startswith("\x1b]8;;file://") + assert ":5" not in out + assert out.endswith("\x1b\\test_x.py\x1b]8;;\x1b\\") + # lineno set: URL carries :line. + out = _format_path_hyperlink(tw, "test_x.py", 5) + assert "\x1b]8;;file://" in out + assert ":5\x1b\\" in out From bbe8ccb2e3624a28c4b0519040eda8d0d980d9b1 Mon Sep 17 00:00:00 2001 From: Muspi Merol Date: Sun, 19 Jul 2026 09:30:15 +0800 Subject: [PATCH 5/5] test: use real TerminalWriter in _format_path_hyperlink test for mypy --- testing/test_terminal.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/testing/test_terminal.py b/testing/test_terminal.py index ab714a22a6e..09a69740e1e 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -3641,12 +3641,11 @@ def test_hyperlinks_yes_forces_on(pytester: Pytester) -> None: def test_format_path_hyperlink_branches() -> None: """Cover _format_path_hyperlink's branches: disabled, lineno=None, lineno set.""" from _pytest._code.code import _format_path_hyperlink + from _pytest._io import TerminalWriter - class TW: - hyperlinks = False - - tw = TW() + tw = TerminalWriter(file=StringIO()) # Disabled: bare path returned unchanged. + tw.hyperlinks = False assert _format_path_hyperlink(tw, "test_x.py", 5) == "test_x.py" tw.hyperlinks = True