Skip to content
Merged
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
33 changes: 29 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,49 @@ exceptions look and feel genuine to debuggers and introspection tools.
- `load_traceback(file: Path | BytesIO) -> Never`:
Loads the serialized state, reconstructs the exception and its full traceback chain (including `__cause__` and `__context__`),
and raises it.
- `parse_traceback(file: Path | BytesIO) -> ExceptionData`:
Loads the serialized data and returns an `ExceptionData` object. This allows for inspecting the exception, stack frames, and variables without reconstructing the full traceback or raising the exception.

## Usage Example

to get started, install with:
To get started, install with:
`pip install offline-debug` or `uv add offline-debug`

```python
from pathlib import Path
from offline_debug import save_traceback, load_traceback
from offline_debug import save_traceback, load_traceback, parse_traceback

# --- Saving an exception ---
try:
# Code that might fail
some_complex_operation()
except Exception as e:
save_traceback(e, Path("crash_report.dump"))

# To debug or re-examine later:
# --- Option 1: Re-raise the exception for debugging ---
# This will look like the original crash in your debugger
load_traceback(Path("crash_report.dump"))

# --- Option 2: Inspect data without raising ---
data = parse_traceback(Path("crash_report.dump"))
print(f"Number of frames: {len(data.tb_frames)}")
for frame in data.tb_frames:
print(f"File: {frame.code.co_filename}, Line: {frame.lineno}")
```

### Exception Group Support

`offline-debug` has full support for `ExceptionGroup` (Python 3.11+). When you parse a saved `ExceptionGroup`, you can access its nested exceptions:

```python
from offline_debug import parse_traceback, ExceptionGroupData

data = parse_traceback(Path("exception_group.dump"))

if isinstance(data, ExceptionGroupData):
print(f"Group contains {len(data.exceptions)} sub-exceptions")
for sub_exc_data in data.exceptions:
# Each sub_exc_data is itself an ExceptionData object
print(f"Sub-exception frames: {len(sub_exc_data.tb_frames)}")
```

## Technical Implementation
Expand Down
11 changes: 9 additions & 2 deletions offline_debug/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
"""Tool for serializing and reconstructing Python exceptions with full stack traces."""

from ._inner.load_traceback import load_traceback, parse_traceback
from ._inner.models import ExceptionData, FrameData
from ._inner.models import ExceptionData, ExceptionGroupData, FrameData
from ._inner.save_traceback import save_traceback

__all__ = ["ExceptionData", "FrameData", "load_traceback", "parse_traceback", "save_traceback"]
__all__ = [
"ExceptionData",
"ExceptionGroupData",
"FrameData",
"load_traceback",
"parse_traceback",
"save_traceback",
]
7 changes: 7 additions & 0 deletions offline_debug/_inner/load_traceback.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
)
from offline_debug._inner.models import (
ExceptionData,
ExceptionGroupData,
FrameData,
)

Expand All @@ -36,6 +37,12 @@ def _reconstruct_exc_data(data: ExceptionData) -> BaseException:
msg = f"Expected BaseException, but got {type(exc).__name__}"
raise TypeError(msg)

if isinstance(data, ExceptionGroupData) and isinstance(exc, BaseExceptionGroup):
inner_excs = [_reconstruct_exc_data(e) for e in data.exceptions]
# We must use derive to create a new ExceptionGroup with reconstructed inner exceptions.
# The exceptions that are inside the unpickled exc object are have incomplete data.
exc = exc.derive(inner_excs)

reconstructed_frames: list[tuple[types.FrameType, FrameData]] = []
for f_data in data.tb_frames:
code: CodeType = marshal.loads(f_data.code) # noqa: S302
Expand Down
9 changes: 8 additions & 1 deletion offline_debug/_inner/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,18 @@ class FrameData:
module_name: str | None = None


@dataclass
@dataclass(kw_only=True)
class ExceptionData:
"""Serialized data for an exception and its traceback."""

exc_pickle: bytes
tb_frames: list[FrameData]
cause: ExceptionData | None = None
context: ExceptionData | None = None


@dataclass(kw_only=True)
class ExceptionGroupData(ExceptionData):
"""Serialized data for an ExceptionGroup."""

exceptions: list[ExceptionData]
18 changes: 15 additions & 3 deletions offline_debug/_inner/save_traceback.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from io import BytesIO
from pathlib import Path

from offline_debug._inner.models import ExceptionData, FrameData
from offline_debug._inner.models import ExceptionData, ExceptionGroupData, FrameData

# Internal attributes that are either unpicklable or redundant in a new process.
# We exclude these specifically because they are automatically recreated
Expand Down Expand Up @@ -77,11 +77,23 @@ def _serialize_exc_data(exc: BaseException) -> ExceptionData:
RuntimeError(f"Unpicklable exception {type(exc).__name__}: {exc!s}")
)

cause = _serialize_exc_data(exc.__cause__) if exc.__cause__ else None
context = _serialize_exc_data(exc.__context__) if exc.__context__ else None

if isinstance(exc, BaseExceptionGroup):
return ExceptionGroupData(
exc_pickle=exc_pickle,
tb_frames=tb_frames,
cause=cause,
context=context,
exceptions=[_serialize_exc_data(e) for e in exc.exceptions],
)

return ExceptionData(
exc_pickle=exc_pickle,
tb_frames=tb_frames,
cause=_serialize_exc_data(exc.__cause__) if exc.__cause__ else None,
context=_serialize_exc_data(exc.__context__) if exc.__context__ else None,
cause=cause,
context=context,
)


Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "offline-debug"
version = "0.2.1"
version = "0.3.0"
description = "Debug exceptions offline by saving them to a dump and raising them at a later point."
readme = "README.md"
authors = [
Expand Down
46 changes: 46 additions & 0 deletions tests/manual_tests/exception_group_debug.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Intended for manual use, run the test in debug mode and view the exceptions."""

from __future__ import annotations

import sys
import tempfile
from pathlib import Path

from rich import traceback
from rich.console import Console

from offline_debug import load_traceback, parse_traceback, save_traceback

global_variable = 1

console = Console(force_terminal=True)
traceback.install(show_locals=True, console=console)


def failure() -> None:
"""Raise a nested exception for testing."""
_python_version = sys.version

def exception_raising_func() -> None:
local = "local"
msg = f"exception {local}"
raise ValueError(msg)

with tempfile.TemporaryDirectory() as tmpdir:
dump_file = Path(tmpdir) / "exception.dump"
try:
exception_raising_func()
except ValueError as e:
exception_group = ExceptionGroup(
"exception group", [e, RuntimeError("another exception")]
)
save_traceback(exception_group, dump_file)

global global_variable
global_variable += 1
_exception_data = parse_traceback(dump_file)
load_traceback(dump_file)


if __name__ == "__main__":
failure()
38 changes: 38 additions & 0 deletions tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,41 @@ def func_with_locals() -> Never:

assert f.f_locals.get("var_a") == "hello"
assert f.f_locals.get("var_b") == expected_val


def test_exception_group_stack(tmp_path: Path) -> None:
"""Test serialization of ExceptionGroup."""
dump_file = tmp_path / "eg.dump"

def fail_1() -> Never:
msg = "Error 1"
raise ValueError(msg)

def fail_2() -> Never:
msg = "Error 2"
raise TypeError(msg)

try:
try:
fail_1()
except ValueError as e1:
try:
fail_2()
except TypeError as e2:
raise ExceptionGroup("Group error", [e1, e2]) from None
except ExceptionGroup as eg:
save_traceback(eg, dump_file)

with pytest.raises(ExceptionGroup, match="Group error") as exc_info:
load_traceback(dump_file)

reconstructed_eg = exc_info.value
expected_exceptions_count = 2
assert len(reconstructed_eg.exceptions) == expected_exceptions_count

e1, e2 = reconstructed_eg.exceptions
assert isinstance(e1, ValueError)
assert isinstance(e2, TypeError)

assert "fail_1" in [f.f_code.co_name for f in get_frames(e1.__traceback__)]
assert "fail_2" in [f.f_code.co_name for f in get_frames(e2.__traceback__)]
Loading