From 97333d7ee5aebc0ac7ef46eda855b1606bed2b70 Mon Sep 17 00:00:00 2001 From: Emerson Gray Date: Thu, 25 Dec 2025 20:17:28 +0000 Subject: [PATCH] reports: serialize/deserialize chained exceptions for xdist reports; add tests for round-trip and xdist output --- src/_pytest/reports.py | 132 ++++++++++++++++++++++------------ testing/test_reports_chain.py | 67 +++++++++++++++++ 2 files changed, 154 insertions(+), 45 deletions(-) create mode 100644 testing/test_reports_chain.py diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 4682d5b6ec2..c799db4034c 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -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 @@ -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( @@ -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 diff --git a/testing/test_reports_chain.py b/testing/test_reports_chain.py new file mode 100644 index 00000000000..a0a6f131a3c --- /dev/null +++ b/testing/test_reports_chain.py @@ -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:*", + ])