From 96ff879b959d91b34619121b0d8cf4552f19c027 Mon Sep 17 00:00:00 2001 From: Itai Elidan Date: Thu, 4 Jun 2026 23:43:07 +0300 Subject: [PATCH 1/4] improved docs for parse_traceback --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 04335ae..3fa7f94 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ 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 @@ -37,6 +39,11 @@ except Exception as e: # To debug or re-examine later: load_traceback(Path("crash_report.dump")) + +# Or just inspect the data without raising: +from offline_debug import parse_traceback +data = parse_traceback(Path("crash_report.dump")) +print(f"Number of frames: {len(data.tb_frames)}") ``` ## Technical Implementation From e352881d28dbadd57ba42e60811d9516bda39cd0 Mon Sep 17 00:00:00 2001 From: Itai Elidan Date: Fri, 5 Jun 2026 00:10:50 +0300 Subject: [PATCH 2/4] exception group support --- offline_debug/_inner/load_traceback.py | 9 ++++ offline_debug/_inner/models.py | 9 +++- offline_debug/_inner/save_traceback.py | 22 ++++++---- pyproject.toml | 2 +- tests/manual_tests/exception_group_debug.py | 46 +++++++++++++++++++++ tests/test_integration.py | 38 +++++++++++++++++ 6 files changed, 117 insertions(+), 9 deletions(-) create mode 100644 tests/manual_tests/exception_group_debug.py diff --git a/offline_debug/_inner/load_traceback.py b/offline_debug/_inner/load_traceback.py index ebc3cbe..e3c1085 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,14 @@ def _reconstruct_exc_data(data: ExceptionData) -> BaseException: msg = f"Expected BaseException, but got {type(exc).__name__}" raise TypeError(msg) + if isinstance(data, ExceptionGroupData): + inner_excs = [_reconstruct_exc_data(e) for e in data.exceptions] + # We must use derive to create a new ExceptionGroup with reconstructed inner exceptions. + # This is because the 'exceptions' attribute is read-only. + # BaseExceptionGroup is guaranteed to have 'derive' in Python 3.11+. + if hasattr(exc, "derive"): + 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..2cca0bb 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,12 +77,20 @@ def _serialize_exc_data(exc: BaseException) -> ExceptionData: RuntimeError(f"Unpicklable exception {type(exc).__name__}: {exc!s}") ) - 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, - ) + kwargs = { + "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, + } + + if isinstance(exc, BaseExceptionGroup): + return ExceptionGroupData( + **kwargs, + exceptions=[_serialize_exc_data(e) for e in exc.exceptions], + ) + + return ExceptionData(**kwargs) def save_traceback(exc: BaseException, file: Path | BytesIO | None) -> ExceptionData: 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__)] From d73515c9db710e345316c71b4c3de59494bef531 Mon Sep 17 00:00:00 2001 From: Itai Elidan Date: Fri, 5 Jun 2026 01:09:45 +0300 Subject: [PATCH 3/4] small improvment to derive docs and logic in load_traceback.py --- offline_debug/_inner/load_traceback.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/offline_debug/_inner/load_traceback.py b/offline_debug/_inner/load_traceback.py index e3c1085..daa121b 100644 --- a/offline_debug/_inner/load_traceback.py +++ b/offline_debug/_inner/load_traceback.py @@ -37,13 +37,11 @@ def _reconstruct_exc_data(data: ExceptionData) -> BaseException: msg = f"Expected BaseException, but got {type(exc).__name__}" raise TypeError(msg) - if isinstance(data, ExceptionGroupData): + 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. - # This is because the 'exceptions' attribute is read-only. - # BaseExceptionGroup is guaranteed to have 'derive' in Python 3.11+. - if hasattr(exc, "derive"): - exc = exc.derive(inner_excs) + # 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: From 7030f2416e5334f7beedf1193049b5337e087755 Mon Sep 17 00:00:00 2001 From: Itai Elidan Date: Fri, 5 Jun 2026 12:49:13 +0300 Subject: [PATCH 4/4] improved typing and documentation of exception group data and parse_traceback function --- README.md | 30 ++++++++++++++++++++------ offline_debug/__init__.py | 11 ++++++++-- offline_debug/_inner/save_traceback.py | 20 ++++++++++------- 3 files changed, 45 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 3fa7f94..2a417a0 100644 --- a/README.md +++ b/README.md @@ -24,26 +24,44 @@ exceptions look and feel genuine to debuggers and introspection tools. ## 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")) -# Or just inspect the data without raising: -from offline_debug import parse_traceback +# --- 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/save_traceback.py b/offline_debug/_inner/save_traceback.py index 2cca0bb..ae15cc5 100644 --- a/offline_debug/_inner/save_traceback.py +++ b/offline_debug/_inner/save_traceback.py @@ -77,20 +77,24 @@ def _serialize_exc_data(exc: BaseException) -> ExceptionData: RuntimeError(f"Unpicklable exception {type(exc).__name__}: {exc!s}") ) - kwargs = { - "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 = _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( - **kwargs, + exc_pickle=exc_pickle, + tb_frames=tb_frames, + cause=cause, + context=context, exceptions=[_serialize_exc_data(e) for e in exc.exceptions], ) - return ExceptionData(**kwargs) + return ExceptionData( + exc_pickle=exc_pickle, + tb_frames=tb_frames, + cause=cause, + context=context, + ) def save_traceback(exc: BaseException, file: Path | BytesIO | None) -> ExceptionData: