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
132 changes: 87 additions & 45 deletions src/_pytest/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from _pytest._code.code import ReprEntry
from _pytest._code.code import ReprEntryNative
from _pytest._code.code import ReprExceptionInfo
from _pytest._code.code import ExceptionChainRepr
from _pytest._code.code import ReprFileLocation
from _pytest._code.code import ReprFuncArgs
from _pytest._code.code import ReprLocals
Expand Down Expand Up @@ -161,29 +162,51 @@ def _to_json(self):
Experimental method.
"""

def disassembled_report(rep):
reprtraceback = rep.longrepr.reprtraceback.__dict__.copy()
reprcrash = rep.longrepr.reprcrash.__dict__.copy()

def _serialize_reprtraceback(reprtraceback):
data = reprtraceback.__dict__.copy()
new_entries = []
for entry in reprtraceback["reprentries"]:
for entry in data["reprentries"]:
entry_data = {
"type": type(entry).__name__,
"data": entry.__dict__.copy(),
}
# nested repr components (ReprFuncArgs, ReprLocals, ReprFileLocation)
for key, value in entry_data["data"].items():
if hasattr(value, "__dict__"):
entry_data["data"][key] = value.__dict__.copy()
new_entries.append(entry_data)
data["reprentries"] = new_entries
return data

reprtraceback["reprentries"] = new_entries
def disassembled_report(rep):
# Always include outermost exception for backwards compatibility
reprtraceback = _serialize_reprtraceback(rep.longrepr.reprtraceback)
reprcrash = rep.longrepr.reprcrash.__dict__.copy()

return {
assembled = {
"reprcrash": reprcrash,
"reprtraceback": reprtraceback,
"sections": rep.longrepr.sections,
}

# Optional: include full exception chain if available
chain = getattr(rep.longrepr, "chain", None)
if chain:
serialized_chain = []
for repr_tb, repr_crash, descr in chain:
serialized_chain.append(
{
"reprtraceback": _serialize_reprtraceback(repr_tb),
"reprcrash": (
repr_crash.__dict__.copy() if repr_crash else None
),
"descr": descr,
}
)
assembled["chain"] = serialized_chain

return assembled

d = self.__dict__.copy()
if hasattr(self.longrepr, "toterminal"):
if hasattr(self.longrepr, "reprtraceback") and hasattr(
Expand Down Expand Up @@ -217,45 +240,64 @@ def _from_json(cls, reportdict):
and "reprtraceback" in reportdict["longrepr"]
):

reprtraceback = reportdict["longrepr"]["reprtraceback"]
reprcrash = reportdict["longrepr"]["reprcrash"]

unserialized_entries = []
reprentry = None
for entry_data in reprtraceback["reprentries"]:
data = entry_data["data"]
entry_type = entry_data["type"]
if entry_type == "ReprEntry":
reprfuncargs = None
reprfileloc = None
reprlocals = None
if data["reprfuncargs"]:
reprfuncargs = ReprFuncArgs(**data["reprfuncargs"])
if data["reprfileloc"]:
reprfileloc = ReprFileLocation(**data["reprfileloc"])
if data["reprlocals"]:
reprlocals = ReprLocals(data["reprlocals"]["lines"])

reprentry = ReprEntry(
lines=data["lines"],
reprfuncargs=reprfuncargs,
reprlocals=reprlocals,
filelocrepr=reprfileloc,
style=data["style"],
def _deserialize_reprtraceback(data):
unserialized_entries = []
reprentry = None
for entry_data in data["reprentries"]:
entry = entry_data["data"]
entry_type = entry_data["type"]
if entry_type == "ReprEntry":
reprfuncargs = None
reprfileloc = None
reprlocals = None
if entry["reprfuncargs"]:
reprfuncargs = ReprFuncArgs(**entry["reprfuncargs"])
if entry["reprfileloc"]:
reprfileloc = ReprFileLocation(**entry["reprfileloc"])
if entry["reprlocals"]:
reprlocals = ReprLocals(entry["reprlocals"]["lines"])

reprentry = ReprEntry(
lines=entry["lines"],
reprfuncargs=reprfuncargs,
reprlocals=reprlocals,
filelocrepr=reprfileloc,
style=entry["style"],
)
elif entry_type == "ReprEntryNative":
reprentry = ReprEntryNative(entry["lines"])
else:
_report_unserialization_failure(entry_type, cls, reportdict)
unserialized_entries.append(reprentry)
data["reprentries"] = unserialized_entries
return ReprTraceback(**data)

reprtraceback_data = reportdict["longrepr"]["reprtraceback"]
reprcrash_data = reportdict["longrepr"]["reprcrash"]

# By default, construct a single exception representation
reprtraceback_obj = _deserialize_reprtraceback(reprtraceback_data)
reprcrash_obj = ReprFileLocation(**reprcrash_data)

longrepr_dict = reportdict["longrepr"]
if "chain" in longrepr_dict and longrepr_dict["chain"]:
# Full chained exception representation
chain_list = []
for element in longrepr_dict["chain"]:
rt_obj = _deserialize_reprtraceback(element["reprtraceback"])
rc_obj = (
ReprFileLocation(**element["reprcrash"]) if element["reprcrash"] else None
)
elif entry_type == "ReprEntryNative":
reprentry = ReprEntryNative(data["lines"])
else:
_report_unserialization_failure(entry_type, cls, reportdict)
unserialized_entries.append(reprentry)
reprtraceback["reprentries"] = unserialized_entries

exception_info = ReprExceptionInfo(
reprtraceback=ReprTraceback(**reprtraceback),
reprcrash=ReprFileLocation(**reprcrash),
)

for section in reportdict["longrepr"]["sections"]:
descr = element.get("descr")
chain_list.append((rt_obj, rc_obj, descr))
exception_info = ExceptionChainRepr(chain_list)
else:
exception_info = ReprExceptionInfo(
reprtraceback=reprtraceback_obj,
reprcrash=reprcrash_obj,
)

for section in longrepr_dict["sections"]:
exception_info.addsection(*section)
reportdict["longrepr"] = exception_info

Expand Down
67 changes: 67 additions & 0 deletions testing/test_reports_chain.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import re
import pytest


def _make_chained_test(testdir):
return testdir.makepyfile(

"""
def inner():
raise ValueError("inner value error")

def outer():
try:
inner()
except ValueError as e:
raise RuntimeError("outer runtime error") from e

def test_chained_exception():
outer()
"""
)


def test_chained_exception_serialization_roundtrip(testdir):
_make_chained_test(testdir)
reprec = testdir.inline_run()
reports = reprec.getreports("pytest_runtest_logreport")
# setup, call, teardown
assert len(reports) == 3
rep = reports[1]
assert rep.when == "call" and rep.failed
# ensure chain marker is present in the worker-side representation
longtext = rep.longreprtext
assert (
"The above exception was the direct cause of the following exception:" in longtext
), longtext

# round-trip through pytest's JSON-serializable report format
data = rep._to_json()
newrep = type(rep)._from_json(data)

assert newrep.longreprtext == rep.longreprtext


def test_chained_exception_hooks_roundtrip(testdir, pytestconfig):
_make_chained_test(testdir)
reprec = testdir.inline_run()
rep = reprec.getreports("pytest_runtest_logreport")[1]

data = pytestconfig.hook.pytest_report_to_serializable(
config=pytestconfig, report=rep
)
assert data["_report_type"] == "TestReport"
newrep = pytestconfig.hook.pytest_report_from_serializable(
config=pytestconfig, data=data
)
assert newrep.longreprtext == rep.longreprtext


def test_chained_exception_visible_with_xdist(testdir):
xdist = pytest.importorskip("xdist")
p = _make_chained_test(testdir)
# require at least one worker
result = testdir.runpytest("-n", "1", str(p))
result.stdout.fnmatch_lines([
"*The above exception was the direct cause of the following exception:*",
])