diff --git a/README.md b/README.md index 04335ae..2a417a0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/offline_debug/__init__.py b/offline_debug/__init__.py index 5289e70..9b58173 100644 --- a/offline_debug/__init__.py +++ b/offline_debug/__init__.py @@ -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", +] diff --git a/offline_debug/_inner/load_traceback.py b/offline_debug/_inner/load_traceback.py index ebc3cbe..daa121b 100644 --- a/offline_debug/_inner/load_traceback.py +++ b/offline_debug/_inner/load_traceback.py @@ -14,6 +14,7 @@ ) from offline_debug._inner.models import ( ExceptionData, + ExceptionGroupData, FrameData, ) @@ -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 diff --git a/offline_debug/_inner/models.py b/offline_debug/_inner/models.py index 3a567c7..4810b53 100644 --- a/offline_debug/_inner/models.py +++ b/offline_debug/_inner/models.py @@ -19,7 +19,7 @@ class FrameData: module_name: str | None = None -@dataclass +@dataclass(kw_only=True) class ExceptionData: """Serialized data for an exception and its traceback.""" @@ -27,3 +27,10 @@ class ExceptionData: 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] diff --git a/offline_debug/_inner/save_traceback.py b/offline_debug/_inner/save_traceback.py index 6bf184f..ae15cc5 100644 --- a/offline_debug/_inner/save_traceback.py +++ b/offline_debug/_inner/save_traceback.py @@ -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 @@ -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, ) diff --git a/pyproject.toml b/pyproject.toml index c3d8925..427158c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/tests/manual_tests/exception_group_debug.py b/tests/manual_tests/exception_group_debug.py new file mode 100644 index 0000000..33d11b8 --- /dev/null +++ b/tests/manual_tests/exception_group_debug.py @@ -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() diff --git a/tests/test_integration.py b/tests/test_integration.py index db37807..283c2dd 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -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__)]