Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,7 @@ Xuan Luong
Xuecong Liao
Yannick Péroux
Yao Xiao
Yihui Zhuang
Yoav Caspi
Yuliang Shao
Yusuke Kadowaki
Expand Down
1 change: 1 addition & 0 deletions changelog/13580.feature.rst
Original file line number Diff line number Diff line change
@@ -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``).
25 changes: 24 additions & 1 deletion src/_pytest/_code/code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand Down
12 changes: 12 additions & 0 deletions src/_pytest/_io/terminalwriter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions src/_pytest/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
4 changes: 4 additions & 0 deletions src/_pytest/pastebin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions src/_pytest/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
110 changes: 110 additions & 0 deletions testing/test_terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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