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..4913c8507e3 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -2097,6 +2097,13 @@ 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..09a69740e1e 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -19,6 +19,7 @@ from _pytest._io.wcwidth import wcswidth import _pytest.config from _pytest.config import Config +from _pytest.config import create_terminal_writer from _pytest.config import ExitCode from _pytest.monkeypatch import MonkeyPatch from _pytest.pytester import Pytester @@ -3548,3 +3549,112 @@ 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", encoding="utf-8" + ) + 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 + + +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 + + 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 + # 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