diff --git a/changelog/15072.feature.rst b/changelog/15072.feature.rst new file mode 100644 index 00000000000..6e4802d8489 --- /dev/null +++ b/changelog/15072.feature.rst @@ -0,0 +1 @@ +Add a serializable exception record API for capturing exception data. diff --git a/src/_pytest/_code/__init__.py b/src/_pytest/_code/__init__.py index 7f67a2e3e0a..ffebb5387ff 100644 --- a/src/_pytest/_code/__init__.py +++ b/src/_pytest/_code/__init__.py @@ -4,6 +4,7 @@ from .code import Code from .code import ExceptionInfo +from .code import ExceptionRecord from .code import filter_traceback from .code import Frame from .code import getfslineno @@ -16,6 +17,7 @@ __all__ = [ "Code", "ExceptionInfo", + "ExceptionRecord", "Frame", "Source", "Traceback", diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index f6d2f27672c..b61ae3d29ae 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -23,6 +23,7 @@ from types import FrameType from types import TracebackType from typing import Any +from typing import cast from typing import ClassVar from typing import Final from typing import final @@ -496,6 +497,49 @@ def stringify_exception( E = TypeVar("E", bound=BaseException, covariant=True) +@dataclasses.dataclass(frozen=True) +class ExceptionRecord: + """Serializable snapshot of an exception and its traceback.""" + + type_name: str + type_module: str + message: str + frames: tuple[dict[str, object], ...] + cause: ExceptionRecord | None = None + context: ExceptionRecord | None = None + suppress_context: bool = False + children: tuple[ExceptionRecord, ...] = () + notes: tuple[str, ...] = () + + def to_dict(self) -> dict[str, object]: + """Return a JSON-compatible representation of this record.""" + return dataclasses.asdict(self) + + @classmethod + def from_dict(cls, data: Mapping[str, object]) -> ExceptionRecord: + """Rebuild a record produced by :meth:`to_dict`.""" + cause = data.get("cause") + context = data.get("context") + frames = data["frames"] + children = data.get("children", ()) + return cls( + type_name=str(data["type_name"]), + type_module=str(data["type_module"]), + message=str(data["message"]), + frames=tuple( + dict(frame) for frame in cast(Iterable[Mapping[str, object]], frames) + ), + cause=cls.from_dict(cause) if isinstance(cause, Mapping) else None, + context=cls.from_dict(context) if isinstance(context, Mapping) else None, + suppress_context=bool(data["suppress_context"]), + children=tuple( + cls.from_dict(child) + for child in cast(Iterable[Mapping[str, object]], children) + ), + notes=tuple(data["notes"]), # type: ignore[arg-type] + ) + + @final @dataclasses.dataclass class ExceptionInfo(Generic[E]): @@ -690,6 +734,44 @@ def _get_single_subexc( text = text.removeprefix(self._get_striptext()) return text + def record(self) -> ExceptionRecord: + """Capture this exception as immutable, serializable data.""" + + def build(exception: BaseException) -> ExceptionRecord: + frames = tuple( + { + "filename": frame.filename, + "lineno": frame.lineno, + "name": frame.name, + "line": frame.line, + } + for frame in extract_tb(exception.__traceback__) + ) + children = tuple( + build(child) + for child in getattr(exception, "exceptions", ()) + if isinstance(child, BaseException) + ) + cause = build(exception.__cause__) if exception.__cause__ else None + context = ( + build(exception.__context__) + if exception.__context__ and not exception.__suppress_context__ + else None + ) + return ExceptionRecord( + type_name=type(exception).__qualname__, + type_module=type(exception).__module__, + message=str(exception), + frames=cast(tuple[dict[str, object], ...], frames), + cause=cause, + context=context, + suppress_context=exception.__suppress_context__, + children=children, + notes=tuple(getattr(exception, "__notes__", ())), + ) + + return build(self.value) + def errisinstance(self, exc: EXCEPTION_OR_MORE) -> bool: """Return True if the exception is an instance of exc. diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 4106a8df671..e70fad6ea0e 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -69,6 +69,38 @@ def test_excinfo_from_exception_simple() -> None: assert info.type == ValueError +def test_excinfo_record_round_trip() -> None: + try: + raise ValueError("broken") + except ValueError: + info = _pytest._code.ExceptionInfo.from_current() + + record = info.record() + restored = _pytest._code.ExceptionRecord.from_dict(record.to_dict()) + + assert restored == record + assert restored.type_name == "ValueError" + assert restored.message == "broken" + assert restored.frames[-1]["name"] == "test_excinfo_record_round_trip" + + +def test_excinfo_record_preserves_exception_chain() -> None: + try: + try: + raise KeyError("missing") + except KeyError as error: + raise RuntimeError("failed") from error + except RuntimeError: + info = _pytest._code.ExceptionInfo.from_current() + + record = info.record() + + assert record.type_name == "RuntimeError" + assert record.cause is not None + assert record.cause.type_name == "KeyError" + assert record.cause.message == "'missing'" + + def test_excinfo_from_exception_missing_traceback_assertion() -> None: with pytest.raises(AssertionError, match=r"must have.*__traceback__"): _pytest._code.ExceptionInfo.from_exception(ValueError())