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
23 changes: 23 additions & 0 deletions src/pytest_reportlog/plugin.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import json
import re
from typing import Dict, Any, TextIO

from _pytest.pathlib import Path

import pytest

_ANSI_ESCAPE_SEQUENCE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")


def pytest_addoption(parser):
group = parser.getgroup("terminal reporting", "report-log plugin options")
Expand Down Expand Up @@ -69,10 +72,12 @@ def close(self):
self._file = None

def _write_json_data(self, data):
data = _strip_ansi_escape_sequences(data)
try:
json_data = json.dumps(data)
except TypeError:
data = cleanup_unserializable(data)
data = _strip_ansi_escape_sequences(data)
json_data = json.dumps(data)
self._file.write(json_data + "\n")
self._file.flush()
Expand Down Expand Up @@ -148,3 +153,21 @@ def cleanup_unserializable(d: Dict[str, Any]) -> Dict[str, Any]:
v = str(v)
result[k] = v
return result


def _strip_ansi_escape_sequences(value: Any) -> Any:
"""
Return a new value with ANSI escape sequences removed from any strings.

Report log output is intended to be machine-readable, and ANSI codes can
appear in pytest's formatted output (e.g. colored diffs).
"""
if isinstance(value, str):
return _ANSI_ESCAPE_SEQUENCE_RE.sub("", value)
if isinstance(value, dict):
return {k: _strip_ansi_escape_sequences(v) for k, v in value.items()}
if isinstance(value, list):
return [_strip_ansi_escape_sequences(v) for v in value]
if isinstance(value, tuple):
return tuple(_strip_ansi_escape_sequences(v) for v in value)
return value
34 changes: 34 additions & 0 deletions tests/test_reportlog.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ def __str__(self):

def test_subtest(pytester, tmp_path):
"""Regression test for #90."""
pytest.importorskip("pytest_subtests")
pytester.makepyfile("""
def test_foo(subtests):
with subtests.test():
Expand All @@ -196,3 +197,36 @@ def test_foo(subtests):
for line in lines:
data = json.loads(line)
assert "$report_type" in data


def test_strips_ansi_escape_sequences(testdir, tmp_path):
testdir.makeconftest(r"""
def pytest_report_to_serializable(config, report):
data = {
"$report_type": "TestReport",
"nodeid": report.nodeid,
"outcome": report.outcome,
}

if getattr(report, "when", None) == "call":
data["longreprtext"] = "\x1b[31mRED\x1b[0m"
data["sections"] = [("Captured stdout call", "\x1b[32mGREEN\x1b[0m")]

return data
""")
testdir.makepyfile("""
def test_fail():
assert 0
""")

log_file = tmp_path / "log.json"
result = testdir.runpytest("--report-log", str(log_file))
assert result.ret == pytest.ExitCode.TESTS_FAILED

json_objs = [json.loads(x) for x in log_file.read_text().splitlines()]
report = next(obj for obj in json_objs if obj.get("longreprtext"))

assert report["longreprtext"] == "RED"
assert "\x1b" not in report["longreprtext"]
assert report["sections"][0][1] == "GREEN"
assert "\x1b" not in report["sections"][0][1]