Skip to content
Closed
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
1 change: 1 addition & 0 deletions changelog/15072.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add a serializable exception record API for capturing exception data.
2 changes: 2 additions & 0 deletions src/_pytest/_code/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,6 +17,7 @@
__all__ = [
"Code",
"ExceptionInfo",
"ExceptionRecord",
"Frame",
"Source",
"Traceback",
Expand Down
82 changes: 82 additions & 0 deletions src/_pytest/_code/code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]):
Expand Down Expand Up @@ -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.

Expand Down
32 changes: 32 additions & 0 deletions testing/code/test_excinfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Loading