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 @@ -58,6 +58,7 @@ Ariel Pillemer
Armin Rigo
Aron Coyle
Aron Curzon
Arron Zou
Arthur Richard
Ashish Kurmi
Ashley Whetter
Expand Down
1 change: 1 addition & 0 deletions changelog/12365.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed ANSI escape sequences from colored assertion diffs leaking into JUnit XML reports when running with verbosity and Pygments installed.
29 changes: 25 additions & 4 deletions src/_pytest/_code/code.py
Original file line number Diff line number Diff line change
Expand Up @@ -1030,6 +1030,16 @@ def get_exconly(
indentstr = " " * indent
# Get the real exception information out.
exlines = excinfo.exconly(tryshort=True).split("\n")
# Swap in the marked-up explanation (same text, with highlight spans)
# so terminal output can still apply Pygments later.
from _pytest.assertion.highlight import DEFERRED_HL_ATTR

tagged = getattr(excinfo.value, DEFERRED_HL_ATTR, None)
if isinstance(tagged, str):
plain = str(excinfo.value)
text = "\n".join(exlines)
if plain and plain in text:
exlines = text.replace(plain, tagged, 1).split("\n")
failindent = self.fail_marker + indentstr[1:]
for line in exlines:
lines.append(failindent + line)
Expand Down Expand Up @@ -1424,7 +1434,10 @@ def _write_entry_lines(self, tw: TerminalWriter) -> None:
# Using tw.write instead of tw.line for testing purposes due to TWMock implementation;
# lines written with TWMock.line and TWMock._write_source cannot be distinguished
# from each other, whereas lines written with TWMock.write are marked with TWMock.WRITE
for line in self.lines:
from _pytest.assertion.highlight import resolve_highlight_for_writer

resolved = resolve_highlight_for_writer("\n".join(self.lines), tw)
for line in resolved.splitlines():
tw.write(line)
tw.write("\n")
return
Expand All @@ -1450,8 +1463,12 @@ def _write_entry_lines(self, tw: TerminalWriter) -> None:
tw._write_source(source_lines, indents)

# failure lines are always completely red and bold
for line in failure_lines:
tw.line(line, bold=True, red=True)
from _pytest.assertion.highlight import resolve_highlight_for_writer

if failure_lines:
resolved = resolve_highlight_for_writer("\n".join(failure_lines), tw)
for line in resolved.splitlines():
tw.line(line, bold=True, red=True)

def toterminal(self, tw: TerminalWriter) -> None:
if self.style == "short":
Expand All @@ -1476,8 +1493,12 @@ def toterminal(self, tw: TerminalWriter) -> None:
self.reprfileloc.toterminal(tw)

def __str__(self) -> str:
from _pytest.assertion.highlight import strip_deferred_highlight

return "{}\n{}\n{}".format(
"\n".join(self.lines), self.reprlocals, self.reprfileloc
"\n".join(strip_deferred_highlight(line) for line in self.lines),
self.reprlocals,
self.reprfileloc,
)


Expand Down
11 changes: 6 additions & 5 deletions src/_pytest/assertion/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from _pytest.assertion._typing import _AssertionTextDiffStyle
from _pytest.assertion._typing import NO_TRUNCATION_BUDGET
from _pytest.assertion._typing import TruncationBudget
from _pytest.assertion.highlight import deferred_highlighter
from _pytest.assertion.rewrite import assertstate_key
from _pytest.config import Config
from _pytest.config import hookimpl
Expand Down Expand Up @@ -225,11 +226,11 @@ def pytest_sessionfinish(session: Session) -> None:
def pytest_assertrepr_compare(
config: Config, op: str, left: Any, right: Any
) -> list[str] | None:
if config.pluginmanager.has_plugin("terminalreporter"):
highlighter = config.get_terminal_writer()._highlight
else:
# Keep it plaintext when not using terminalrepoterer (#14377).
highlighter = util.dummy_highlighter
# Mark highlight spans instead of applying Pygments now. Terminal output
# resolves the markers later; JUnit XML and ``str(exc)`` stay plain (#12365).
# Never touch the terminal writer here so this works without terminalreporter
# (#14377).
highlighter = deferred_highlighter
# When truncation is going to clip the explanation downstream, cap the
# comparison helpers' formatting at what the truncator will actually pull
# (the raw limits plus the footer slack) so no effort is spent formatting
Expand Down
187 changes: 187 additions & 0 deletions src/_pytest/assertion/highlight.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,198 @@
"""Highlighting helpers for assertion explanations.

Pygments markup is applied at terminal-display time, not when the explanation
is first built. Comparison helpers wrap highlighted spans in private markers
so JUnit XML and other plain-text consumers can recover the original source
(including any escape sequences that belong to the values under test).
"""

from __future__ import annotations

import re
from typing import Literal

from _pytest.assertion._typing import _HighlightFunc


# Attribute set on AssertionError to keep the marked-up explanation after the
# public exception message has been stripped to plain text.
DEFERRED_HL_ATTR = "_pytest_deferred_hl"

# Marker protocol (SOH-delimited):
# \x01p<source>\x01 / \x01d<source>\x01 start of a python/diff span
# \x01P<source>\x01 / \x01D<source>\x01 continuation of the previous span
# A literal SOH in *source* is escaped as \x01\x02.
# Continuations exist so a multi-line ``highlighter()`` call can be split into
# lines and later re-joined, matching Pygments' whole-block colouring.
_START_CODE = {"python": "p", "diff": "d"}
_CONT_CODE = {"python": "P", "diff": "D"}
_CODE_TO_LEXER: dict[str, Literal["python", "diff"]] = {
"p": "python",
"P": "python",
"d": "diff",
"D": "diff",
}
_TAG_RE = re.compile(r"\x01([pdPD])((?:\x01\x02|[^\x01])*)\x01")


def dummy_highlighter(source: str, lexer: Literal["diff", "python"] = "python") -> str:
"""Dummy highlighter that returns the text unprocessed.

Needed for _notin_text, as the diff gets post-processed to only show the "+" part.
"""
return source


def deferred_highlighter(
source: str, lexer: Literal["diff", "python"] = "python"
) -> str:
"""Wrap *source* in deferred-highlight markers instead of applying Pygments.

Multi-line input is tagged per line (first line starts a span, the rest
continue it) so later ``splitlines()`` keeps enough information to rebuild
the original block.
"""
if not source:
return source
try:
start = _START_CODE[lexer]
cont = _CONT_CODE[lexer]
except KeyError:
raise ValueError(f"unknown lexer: {lexer!r}") from None
keep_nl = source.endswith("\n")
tagged_lines: list[str] = []
for i, line in enumerate(source.splitlines()):
code = start if i == 0 else cont
tagged_lines.append(f"\x01{code}{line.replace(chr(1), chr(1) + chr(2))}\x01")
tagged = "\n".join(tagged_lines)
if keep_nl:
tagged += "\n"
return tagged


def contains_deferred_highlight(text: str) -> bool:
"""Return whether *text* contains a highlight marker."""
return "\x01" in text and _TAG_RE.search(text) is not None


def _unescape_source(escaped: str) -> str:
return escaped.replace("\x01\x02", "\x01")


def strip_deferred_highlight(text: str) -> str:
"""Return *text* with deferred-highlight markers removed."""
if "\x01" not in text:
return text
return _TAG_RE.sub(lambda m: _unescape_source(m.group(2)), text)


def resolve_highlight(text: str, highlighter: _HighlightFunc | None) -> str:
"""Replace deferred-highlight markers in *text*.

If *highlighter* is ``None``, emit the original source of each span.
Otherwise apply ``highlighter(source, lexer)`` to each span. Consecutive
continuation lines of the same lexer are highlighted as one block so the
colours match a single original ``highlighter()`` call.
"""
if "\x01" not in text:
return text
if highlighter is None:
return strip_deferred_highlight(text)
return _resolve_with_highlighter(text, highlighter)


def _resolve_with_highlighter(text: str, highlighter: _HighlightFunc) -> str:
# Fast path: no multi-line continuation, highlight each span in place.
if "\x01P" not in text and "\x01D" not in text:
return _TAG_RE.sub(
lambda m: highlighter(
_unescape_source(m.group(2)), _CODE_TO_LEXER[m.group(1)]
),
text,
)

lines = text.splitlines(keepends=True)
out: list[str] = []
i = 0
while i < len(lines):
line = lines[i]
start = _first_tag(line)
if start is None or start.isupper():
out.append(_resolve_line(line, highlighter))
i += 1
continue
lexer = _CODE_TO_LEXER[start]
cont = _CONT_CODE[lexer]
group = [line]
i += 1
while i < len(lines) and _first_tag(lines[i]) == cont:
group.append(lines[i])
i += 1
out.append(_highlight_group(group, highlighter, lexer))
return "".join(out)


def _first_tag(line: str) -> str | None:
match = _TAG_RE.search(line)
return match.group(1) if match else None


def _resolve_line(line: str, highlighter: _HighlightFunc) -> str:
return _TAG_RE.sub(
lambda m: highlighter(_unescape_source(m.group(2)), _CODE_TO_LEXER[m.group(1)]),
line,
)


def _highlight_group(
lines: list[str], highlighter: _HighlightFunc, lexer: Literal["python", "diff"]
) -> str:
"""Highlight a start+continuation group as one Pygments input."""
prefixes: list[str] = []
bodies: list[str] = []
suffixes: list[str] = []
newlines: list[str] = []
for line in lines:
nl = "\n" if line.endswith("\n") else ""
core = line[:-1] if nl else line
match = _TAG_RE.search(core)
if match is None:
prefixes.append(core)
bodies.append("")
suffixes.append("")
newlines.append(nl)
continue
prefixes.append(core[: match.start()])
bodies.append(_unescape_source(match.group(2)))
suffixes.append(core[match.end() :])
newlines.append(nl)

highlighted = highlighter("\n".join(bodies), lexer)
hl_lines = highlighted.splitlines()
rendered: list[str] = []
for idx, (prefix, suffix, nl) in enumerate(
zip(prefixes, suffixes, newlines, strict=True)
):
hl = hl_lines[idx] if idx < len(hl_lines) else ""
rendered.append(f"{prefix}{hl}{suffix}{nl}")
if len(hl_lines) > len(lines):
prefix = prefixes[-1] if prefixes else ""
nl = newlines[-1] if newlines else "\n"
for hl in hl_lines[len(lines) :]:
rendered.append(f"{prefix}{hl}{nl}")
return "".join(rendered)


def resolve_highlight_for_writer(text: str, tw: object) -> str:
"""Resolve markers for a terminal writer, or strip them for plain output.

``tw`` is untyped so tests can pass the lightweight ``TWMock``.
"""
if not contains_deferred_highlight(text):
return text
hasmarkup = getattr(tw, "hasmarkup", False)
code_highlight = getattr(tw, "code_highlight", True)
highlight = getattr(tw, "_highlight", None)
if hasmarkup and code_highlight and highlight is not None:
return resolve_highlight(text, highlight)
return strip_deferred_highlight(text)
23 changes: 18 additions & 5 deletions src/_pytest/assertion/rewrite.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
from _pytest._io.saferepr import saferepr_unlimited
from _pytest._version import version
from _pytest.assertion import util
from _pytest.assertion.highlight import DEFERRED_HL_ATTR
from _pytest.assertion.highlight import strip_deferred_highlight
from _pytest.config import Config
from _pytest.fixtures import FixtureFunctionDefinition
from _pytest.main import Session
Expand Down Expand Up @@ -503,7 +505,20 @@ def _call_reprcompare(

def _call_assertion_pass(lineno: int, orig: str, expl: str) -> None:
if util._assertion_pass is not None:
util._assertion_pass(lineno, orig, expl)
util._assertion_pass(lineno, orig, strip_deferred_highlight(expl))


def _assertion_error(msg: str) -> AssertionError:
"""Build an AssertionError whose public message has no highlight markers.

The marked-up explanation is kept on the exception so traceback formatting
can still apply Pygments when writing to a color terminal.
"""
plain = strip_deferred_highlight(msg)
err = AssertionError(plain)
if msg != plain:
setattr(err, DEFERRED_HL_ATTR, msg)
return err


def _check_if_assertion_pass_impl() -> bool:
Expand Down Expand Up @@ -893,9 +908,8 @@ def visit_Assert(self, assert_: ast.Assert) -> list[ast.stmt]:
gluestr = "assert "
err_explanation = ast.BinOp(ast.Constant(gluestr), ast.Add(), msg)
err_msg = ast.BinOp(assertmsg, ast.Add(), err_explanation)
err_name = ast.Name("AssertionError", ast.Load())
fmt = self.helper("_format_explanation", err_msg)
exc = ast.Call(err_name, [fmt], [])
exc = self.helper("_assertion_error", fmt)
raise_ = ast.Raise(exc, None)
statements_fail = []
statements_fail.extend(self.expl_stmts)
Expand Down Expand Up @@ -943,8 +957,7 @@ def visit_Assert(self, assert_: ast.Assert) -> list[ast.stmt]:
template = ast.BinOp(assertmsg, ast.Add(), ast.Constant(explanation))
msg = self.pop_format_context(template)
fmt = self.helper("_format_explanation", msg)
err_name = ast.Name("AssertionError", ast.Load())
exc = ast.Call(err_name, [fmt], [])
exc = self.helper("_assertion_error", fmt)
raise_ = ast.Raise(exc, None)

body.append(raise_)
Expand Down
Loading
Loading