From 3c302c6c028fb1a1200d9b8e272801f8ef470373 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Thu, 25 Dec 2025 20:35:58 +0000 Subject: [PATCH] feat(reports): preserve exception chains --- src/_pytest/reports.py | 185 +++++++++++++++++++++++----------- testing/test_reports_chain.py | 78 ++++++++++++++ 2 files changed, 205 insertions(+), 58 deletions(-) create mode 100644 testing/test_reports_chain.py diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 4682d5b6ec2..047c16f2f0a 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -3,6 +3,7 @@ import py +from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ExceptionInfo from _pytest._code.code import ReprEntry from _pytest._code.code import ReprEntryNative @@ -28,6 +29,59 @@ def getslaveinfoline(node): return s +def _serialize_reprentry(entry): + data = entry.__dict__.copy() + for key, value in data.items(): + if hasattr(value, "__dict__"): + data[key] = value.__dict__.copy() + return {"type": type(entry).__name__, "data": data} + + +def _serialize_reprtraceback(reprtraceback): + data = reprtraceback.__dict__.copy() + data["reprentries"] = [ + _serialize_reprentry(entry) for entry in reprtraceback.reprentries + ] + return data + + +def _deserialize_reprentry(entry_data, report_class, reportdict): + data = entry_data["data"] + entry_type = entry_data["type"] + if entry_type == "ReprEntry": + reprfuncargs = None + reprfileloc = None + reprlocals = None + if data.get("reprfuncargs"): + reprfuncargs = ReprFuncArgs(**data["reprfuncargs"]) + if data.get("reprfileloc"): + reprfileloc = ReprFileLocation(**data["reprfileloc"]) + if data.get("reprlocals"): + reprlocals = ReprLocals(data["reprlocals"]["lines"]) + return ReprEntry( + lines=data["lines"], + reprfuncargs=reprfuncargs, + reprlocals=reprlocals, + filelocrepr=reprfileloc, + style=data["style"], + ) + if entry_type == "ReprEntryNative": + return ReprEntryNative(data["lines"]) + _report_unserialization_failure(entry_type, report_class, reportdict) + + +def _deserialize_reprtraceback(serialized, report_class, reportdict): + reprentries = [ + _deserialize_reprentry(entry_data, report_class, reportdict) + for entry_data in serialized["reprentries"] + ] + return ReprTraceback( + reprentries=reprentries, + extraline=serialized.get("extraline"), + style=serialized.get("style"), + ) + + class BaseReport: when = None # type: Optional[str] location = None @@ -162,28 +216,32 @@ def _to_json(self): """ def disassembled_report(rep): - reprtraceback = rep.longrepr.reprtraceback.__dict__.copy() - reprcrash = rep.longrepr.reprcrash.__dict__.copy() - - new_entries = [] - for entry in reprtraceback["reprentries"]: - entry_data = { - "type": type(entry).__name__, - "data": entry.__dict__.copy(), - } - for key, value in entry_data["data"].items(): - if hasattr(value, "__dict__"): - entry_data["data"][key] = value.__dict__.copy() - new_entries.append(entry_data) - - reprtraceback["reprentries"] = new_entries - - return { + longrepr = rep.longrepr + reprcrash = ( + longrepr.reprcrash.__dict__.copy() + if longrepr.reprcrash is not None + else None + ) + data = { + "reprtraceback": _serialize_reprtraceback(longrepr.reprtraceback), "reprcrash": reprcrash, - "reprtraceback": reprtraceback, - "sections": rep.longrepr.sections, + "sections": longrepr.sections, } + if isinstance(longrepr, ExceptionChainRepr): + data["chain"] = [ + { + "reprtraceback": _serialize_reprtraceback(reprtraceback), + "reprcrash": ( + reprcrash.__dict__.copy() if reprcrash is not None else None + ), + "descr": descr, + } + for reprtraceback, reprcrash, descr in longrepr.chain + ] + + return data + d = self.__dict__.copy() if hasattr(self.longrepr, "toterminal"): if hasattr(self.longrepr, "reprtraceback") and hasattr( @@ -217,46 +275,57 @@ 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"], + longrepr_data = reportdict["longrepr"] + reprtraceback_data = longrepr_data["reprtraceback"] + + def check_entry_types(entries): + for entry in entries: + entry_type = entry.get("type") + if entry_type not in ("ReprEntry", "ReprEntryNative"): + _report_unserialization_failure( + entry_type, cls, reportdict + ) + + check_entry_types(reprtraceback_data["reprentries"]) + + def build_sections(exception_repr): + for section in longrepr_data.get("sections", []): + exception_repr.addsection(*section) + return exception_repr + + if "chain" in longrepr_data: + chain = [] + for element in longrepr_data["chain"]: + element_reprtraceback = element["reprtraceback"] + check_entry_types(element_reprtraceback["reprentries"]) + chain.append( + ( + _deserialize_reprtraceback( + element_reprtraceback, cls, reportdict + ), + ReprFileLocation(**element["reprcrash"]) + if element["reprcrash"] is not None + else None, + element["descr"], + ) + ) + exception_info = build_sections(ExceptionChainRepr(chain)) + else: + reprcrash_data = longrepr_data["reprcrash"] + reprcrash = ( + ReprFileLocation(**reprcrash_data) + if reprcrash_data is not None + else None + ) + exception_info = build_sections( + ReprExceptionInfo( + reprtraceback=_deserialize_reprtraceback( + reprtraceback_data, cls, reportdict + ), + reprcrash=reprcrash, ) - 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"]: - exception_info.addsection(*section) + ) + reportdict["longrepr"] = exception_info return cls(**reportdict) diff --git a/testing/test_reports_chain.py b/testing/test_reports_chain.py new file mode 100644 index 00000000000..3c571f3ce6d --- /dev/null +++ b/testing/test_reports_chain.py @@ -0,0 +1,78 @@ +import pytest + +from _pytest._code.code import ExceptionChainRepr +from _pytest.reports import TestReport +from _pytest.reports import pytest_report_from_serializable +from _pytest.reports import pytest_report_to_serializable + + +CHAIN_TEST = """ +def fail_with_chain(): + try: + raise ValueError('inner') + except ValueError: + try: + raise RuntimeError('middle') + except RuntimeError as exc: + raise KeyError('outer') from exc + + +def test_chain_failure(): + fail_with_chain() +""" + + +def _collect_failed_call_report(testdir): + testdir.makepyfile(CHAIN_TEST) + reprec = testdir.inline_run() + reports = reprec.getreports("pytest_runtest_logreport") + return next( + rep for rep in reports if rep.when == "call" and rep.failed + ) + + +def test_report_json_roundtrip_preserves_chain(testdir): + report = _collect_failed_call_report(testdir) + + serialized = report._to_json() + reconstructed = TestReport._from_json(serialized) + + assert isinstance(reconstructed.longrepr, ExceptionChainRepr) + text = reconstructed.longreprtext + assert ( + "During handling of the above exception, another exception occurred:" in text + ) + assert ( + "The above exception was the direct cause of the following exception:" in text + ) + + +def test_report_hook_roundtrip_preserves_chain(testdir): + report = _collect_failed_call_report(testdir) + + payload = pytest_report_to_serializable(report) + reconstructed = pytest_report_from_serializable(payload) + + assert isinstance(reconstructed.longrepr, ExceptionChainRepr) + text = reconstructed.longreprtext + assert ( + "During handling of the above exception, another exception occurred:" in text + ) + assert ( + "The above exception was the direct cause of the following exception:" in text + ) + + +def test_xdist_run_preserves_chain_output(testdir): + pytest.importorskip("xdist") + + testdir.makepyfile(CHAIN_TEST) + result = testdir.runpytest("-n", "1") + + result.assert_outcomes(failed=1) + result.stdout.fnmatch_lines( + [ + "*During handling of the above exception, another exception occurred:*", + "*The above exception was the direct cause of the following exception:*", + ] + )