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
3 changes: 3 additions & 0 deletions changelog/12365.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
The ``--junitxml`` report no longer includes ANSI escape codes from
Pygments-highlighted assertion diffs in failure messages or details, or from
captured output written to ``<system-out>`` and ``<system-err>``.
19 changes: 16 additions & 3 deletions src/_pytest/junitxml.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@
_JunitDurationReport = Literal["total", "call"]
_JunitFamily = Literal["legacy", "xunit1", "xunit2"]

# SGR (Select Graphic Rendition) sequences pre-baked into assertion diffs by Pygments
# when color is enabled. These are invalid in XML and must be stripped before writing.
_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[\d;]*m")


def strip_ansi_escapes(text: str) -> str:
"""Remove ANSI SGR escape sequences from *text*."""
return _ANSI_ESCAPE_RE.sub("", text)


def bin_xml_escape(arg: object) -> str:
r"""Visually escape invalid XML characters.
Expand Down Expand Up @@ -163,6 +172,8 @@ def to_xml(self) -> ET.Element:

def _add_simple(self, tag: str, message: str, data: str | None = None) -> None:
node = ET.Element(tag, message=message)
if data is not None:
data = strip_ansi_escapes(data)
node.text = bin_xml_escape(data)
self.append(node)

Expand Down Expand Up @@ -194,7 +205,7 @@ def _prepare_content(self, content: str, header: str) -> str:

def _write_content(self, report: TestReport, content: str, jheader: str) -> None:
tag = ET.Element(jheader)
tag.text = bin_xml_escape(content)
tag.text = bin_xml_escape(strip_ansi_escapes(content))
self.append(tag)

def append_pass(self, report: TestReport) -> None:
Expand All @@ -213,7 +224,7 @@ def append_failure(self, report: TestReport) -> None:
message = reprcrash.message
else:
message = str(report.longrepr)
message = bin_xml_escape(message)
message = bin_xml_escape(strip_ansi_escapes(message))
self._add_simple("failure", message, str(report.longrepr))

def append_collect_error(self, report: CollectReport) -> None:
Expand All @@ -236,7 +247,9 @@ def append_error(self, report: TestReport) -> None:
msg = f'failed on teardown with "{reason}"'
else:
msg = f'failed on setup with "{reason}"'
self._add_simple("error", bin_xml_escape(msg), str(report.longrepr))
self._add_simple(
"error", bin_xml_escape(strip_ansi_escapes(msg)), str(report.longrepr)
)

def append_skipped(self, report: TestReport) -> None:
if hasattr(report, "wasxfail"):
Expand Down
45 changes: 43 additions & 2 deletions testing/test_junitxml.py
Original file line number Diff line number Diff line change
Expand Up @@ -1772,8 +1772,28 @@ def test_esc(my_setup):
_, dom = run_and_parse()
node = dom.get_first_by_tag("testcase")
snode = node.get_first_by_tag("error")
assert "#x1B[31mred#x1B[m" in snode["message"]
assert "#x1B[31mred#x1B[m" in snode.text
assert "error: red" in snode["message"]
assert "#x1B" not in snode["message"]
assert "error: red" in snode.text
assert "#x1B" not in snode.text


def test_ansi_escapes_stripped_from_captured_output(pytester: Pytester) -> None:
pytester.makepyfile(
"""
import sys

def test_color():
sys.stdout.write("out: \033[31mred\033[0m")
sys.stderr.write("err: \033[32mgreen\033[0m")
"""
)
xmlf = pytester.path.joinpath("junit.xml")
pytester.runpytest(f"--junitxml={xmlf}", "-o", "junit_logging=all")
text = xmlf.read_text(encoding="utf-8")
assert "#x1B" not in text
assert "out: red" in text
assert "err: green" in text


@parametrize_families
Expand Down Expand Up @@ -1860,6 +1880,27 @@ def test_no_message_quiet(pytester: Pytester) -> None:
result.stdout.no_fnmatch_line("* generated xml file: *")


def test_junitxml_strips_ansi_from_failure_details(
pytester: Pytester, monkeypatch: MonkeyPatch
) -> None:
"""ANSI escapes from Pygments-highlighted diffs must not appear in junitxml (#12365)."""
pytest.importorskip("pygments")
monkeypatch.setenv("PY_COLORS", "1")
pytester.makepyfile(
"""
def test_foo():
assert [1, 2, 3] == [3, 2, 1]
"""
)
xml_path = pytester.path.joinpath("junit.xml")
result = pytester.runpytest(f"--junitxml={xml_path}", "-vv")
assert result.ret == 1
xml_text = xml_path.read_text(encoding="utf-8")
assert "\x1b[" not in xml_text
assert "#x1B" not in xml_text
assert "assert [1, 2, 3] == [3, 2, 1]" in xml_text


@pytest.mark.parametrize(
("name", "value"),
[
Expand Down
Loading