offline-debug 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: offline-debug
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Debug exceptions offline by saving them to a dump and raising them at a later point.
5
5
  Author: Itai Elidan
6
6
  Author-email: Itai Elidan <itaielidan@gmail.com>
7
7
  Requires-Dist: typing-extensions>=4.15.0
8
- Requires-Python: >=3.12
8
+ Requires-Python: >=3.12, <3.14
9
9
  Project-URL: Homepage, https://github.com/INTODAN/offline-debug
10
10
  Project-URL: Repository, https://github.com/INTODAN/offline-debug
11
11
  Description-Content-Type: text/markdown
@@ -31,24 +31,49 @@ exceptions look and feel genuine to debuggers and introspection tools.
31
31
  - `load_traceback(file: Path | BytesIO) -> Never`:
32
32
  Loads the serialized state, reconstructs the exception and its full traceback chain (including `__cause__` and `__context__`),
33
33
  and raises it.
34
+ - `parse_traceback(file: Path | BytesIO) -> ExceptionData`:
35
+ 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.
34
36
 
35
37
  ## Usage Example
36
38
 
37
- to get started, install with:
39
+ To get started, install with:
38
40
  `pip install offline-debug` or `uv add offline-debug`
39
41
 
40
42
  ```python
41
43
  from pathlib import Path
42
- from offline_debug import save_traceback, load_traceback
44
+ from offline_debug import save_traceback, load_traceback, parse_traceback
43
45
 
46
+ # --- Saving an exception ---
44
47
  try:
45
- # Code that might fail
46
48
  some_complex_operation()
47
49
  except Exception as e:
48
50
  save_traceback(e, Path("crash_report.dump"))
49
51
 
50
- # To debug or re-examine later:
52
+ # --- Option 1: Re-raise the exception for debugging ---
53
+ # This will look like the original crash in your debugger
51
54
  load_traceback(Path("crash_report.dump"))
55
+
56
+ # --- Option 2: Inspect data without raising ---
57
+ data = parse_traceback(Path("crash_report.dump"))
58
+ print(f"Number of frames: {len(data.tb_frames)}")
59
+ for frame in data.tb_frames:
60
+ print(f"File: {frame.code.co_filename}, Line: {frame.lineno}")
61
+ ```
62
+
63
+ ### Exception Group Support
64
+
65
+ `offline-debug` has full support for `ExceptionGroup` (Python 3.11+). When you parse a saved `ExceptionGroup`, you can access its nested exceptions:
66
+
67
+ ```python
68
+ from offline_debug import parse_traceback, ExceptionGroupData
69
+
70
+ data = parse_traceback(Path("exception_group.dump"))
71
+
72
+ if isinstance(data, ExceptionGroupData):
73
+ print(f"Group contains {len(data.exceptions)} sub-exceptions")
74
+ for sub_exc_data in data.exceptions:
75
+ # Each sub_exc_data is itself an ExceptionData object
76
+ print(f"Sub-exception frames: {len(sub_exc_data.tb_frames)}")
52
77
  ```
53
78
 
54
79
  ## Technical Implementation
@@ -19,24 +19,49 @@ exceptions look and feel genuine to debuggers and introspection tools.
19
19
  - `load_traceback(file: Path | BytesIO) -> Never`:
20
20
  Loads the serialized state, reconstructs the exception and its full traceback chain (including `__cause__` and `__context__`),
21
21
  and raises it.
22
+ - `parse_traceback(file: Path | BytesIO) -> ExceptionData`:
23
+ 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.
22
24
 
23
25
  ## Usage Example
24
26
 
25
- to get started, install with:
27
+ To get started, install with:
26
28
  `pip install offline-debug` or `uv add offline-debug`
27
29
 
28
30
  ```python
29
31
  from pathlib import Path
30
- from offline_debug import save_traceback, load_traceback
32
+ from offline_debug import save_traceback, load_traceback, parse_traceback
31
33
 
34
+ # --- Saving an exception ---
32
35
  try:
33
- # Code that might fail
34
36
  some_complex_operation()
35
37
  except Exception as e:
36
38
  save_traceback(e, Path("crash_report.dump"))
37
39
 
38
- # To debug or re-examine later:
40
+ # --- Option 1: Re-raise the exception for debugging ---
41
+ # This will look like the original crash in your debugger
39
42
  load_traceback(Path("crash_report.dump"))
43
+
44
+ # --- Option 2: Inspect data without raising ---
45
+ data = parse_traceback(Path("crash_report.dump"))
46
+ print(f"Number of frames: {len(data.tb_frames)}")
47
+ for frame in data.tb_frames:
48
+ print(f"File: {frame.code.co_filename}, Line: {frame.lineno}")
49
+ ```
50
+
51
+ ### Exception Group Support
52
+
53
+ `offline-debug` has full support for `ExceptionGroup` (Python 3.11+). When you parse a saved `ExceptionGroup`, you can access its nested exceptions:
54
+
55
+ ```python
56
+ from offline_debug import parse_traceback, ExceptionGroupData
57
+
58
+ data = parse_traceback(Path("exception_group.dump"))
59
+
60
+ if isinstance(data, ExceptionGroupData):
61
+ print(f"Group contains {len(data.exceptions)} sub-exceptions")
62
+ for sub_exc_data in data.exceptions:
63
+ # Each sub_exc_data is itself an ExceptionData object
64
+ print(f"Sub-exception frames: {len(sub_exc_data.tb_frames)}")
40
65
  ```
41
66
 
42
67
  ## Technical Implementation
@@ -0,0 +1,14 @@
1
+ """Tool for serializing and reconstructing Python exceptions with full stack traces."""
2
+
3
+ from ._inner.load_traceback import load_traceback, parse_traceback
4
+ from ._inner.models import ExceptionData, ExceptionGroupData, FrameData
5
+ from ._inner.save_traceback import save_traceback
6
+
7
+ __all__ = [
8
+ "ExceptionData",
9
+ "ExceptionGroupData",
10
+ "FrameData",
11
+ "load_traceback",
12
+ "parse_traceback",
13
+ "save_traceback",
14
+ ]
@@ -7,19 +7,19 @@ import types
7
7
  from io import BytesIO
8
8
  from pathlib import Path
9
9
  from types import CodeType
10
- from typing import Never
11
10
 
12
11
  from offline_debug._inner.c_api import (
13
12
  create_frame,
14
13
  link_frame,
15
14
  )
16
15
  from offline_debug._inner.models import (
17
- _ExceptionData,
18
- _FrameData,
16
+ ExceptionData,
17
+ ExceptionGroupData,
18
+ FrameData,
19
19
  )
20
20
 
21
21
 
22
- def _reconstruct_exc_data(data: _ExceptionData) -> BaseException:
22
+ def _reconstruct_exc_data(data: ExceptionData) -> BaseException:
23
23
  """
24
24
  Recursively reconstruct an exception from its serialized data.
25
25
 
@@ -37,26 +37,31 @@ def _reconstruct_exc_data(data: _ExceptionData) -> BaseException:
37
37
  msg = f"Expected BaseException, but got {type(exc).__name__}"
38
38
  raise TypeError(msg)
39
39
 
40
- reconstructed_frames: list[tuple[types.FrameType, _FrameData]] = []
40
+ if isinstance(data, ExceptionGroupData) and isinstance(exc, BaseExceptionGroup):
41
+ inner_excs = [_reconstruct_exc_data(e) for e in data.exceptions]
42
+ # We must use derive to create a new ExceptionGroup with reconstructed inner exceptions.
43
+ # The exceptions that are inside the unpickled exc object are have incomplete data.
44
+ exc = exc.derive(inner_excs)
45
+
46
+ reconstructed_frames: list[tuple[types.FrameType, FrameData]] = []
41
47
  for f_data in data.tb_frames:
42
48
  code: CodeType = marshal.loads(f_data.code) # noqa: S302
43
49
 
44
- # In Python 3.11 and 3.12, accessing f_locals on a frame created via
50
+ # In Python 3.11+, accessing f_locals on a frame created via
45
51
  # PyFrame_New for optimized code (functions) causes a segmentation fault
46
52
  # because the internal 'fast' locals array is not initialized.
47
53
  # As a workaround, we create a 'non-optimized' version of the code object
48
54
  # by compiling a dummy string. This ensures the bytecode is safe
49
55
  # (no LOAD_FAST) while preserving metadata like name and filename.
50
- if sys.version_info < (3, 13):
51
- # A simple module-level code object never has fast locals.
52
- # Since the source is empty, no optimized locals will be created.
53
- # Instead, python will go to the unoptimized dictionary we set under frame_locals later.
54
- unoptimized_code = compile("", code.co_filename, "exec")
55
- code = unoptimized_code.replace(
56
- co_name=code.co_name,
57
- co_firstlineno=code.co_firstlineno,
58
- co_qualname=code.co_qualname,
59
- )
56
+ # A simple module-level code object never has fast locals.
57
+ # Since the source is empty, no optimized locals will be created.
58
+ # Instead, python will go to the unoptimized dictionary we set under frame_locals later.
59
+ unoptimized_code = compile("", code.co_filename, "exec")
60
+ code = unoptimized_code.replace(
61
+ co_name=code.co_name,
62
+ co_firstlineno=code.co_firstlineno,
63
+ co_qualname=code.co_qualname,
64
+ )
60
65
 
61
66
  # PyFrame_New returns a new reference to a PyFrameObject.
62
67
  if f_data.module_name:
@@ -92,17 +97,22 @@ def _reconstruct_exc_data(data: _ExceptionData) -> BaseException:
92
97
  return exc
93
98
 
94
99
 
95
- def load_traceback(file: Path | BytesIO) -> Never:
96
- """Load an exception and its traceback from a file and raise it."""
100
+ def parse_traceback(file: Path | BytesIO) -> ExceptionData:
97
101
  if isinstance(file, Path):
98
102
  with file.open("rb") as f:
99
103
  data = pickle.load(f) # noqa: S301
100
104
  else:
101
105
  data = pickle.load(file) # noqa: S301
102
106
 
103
- if not isinstance(data, _ExceptionData):
107
+ if not isinstance(data, ExceptionData):
104
108
  msg = f"Expected _ExceptionData, but got {type(data).__name__}"
105
109
  raise TypeError(msg)
110
+ return data
111
+
112
+
113
+ def load_traceback(file: Path | BytesIO, should_raise: bool = True) -> BaseException: # noqa: FBT001, FBT002
114
+ """Load an exception and its traceback from a file and raise it."""
115
+ data = parse_traceback(file)
106
116
 
107
117
  exc = _reconstruct_exc_data(data)
108
118
 
@@ -126,4 +136,6 @@ def load_traceback(file: Path | BytesIO) -> Never:
126
136
  )
127
137
 
128
138
  exc = exc.with_traceback(tb_chain)
129
- raise exc
139
+ if should_raise:
140
+ raise exc
141
+ return exc
@@ -7,7 +7,7 @@ from typing import Any
7
7
 
8
8
 
9
9
  @dataclass
10
- class _FrameData:
10
+ class FrameData:
11
11
  """Serialized data for a single stack frame."""
12
12
 
13
13
  code: bytes
@@ -19,11 +19,18 @@ class _FrameData:
19
19
  module_name: str | None = None
20
20
 
21
21
 
22
- @dataclass
23
- class _ExceptionData:
22
+ @dataclass(kw_only=True)
23
+ class ExceptionData:
24
24
  """Serialized data for an exception and its traceback."""
25
25
 
26
26
  exc_pickle: bytes
27
- tb_frames: list[_FrameData]
28
- cause: _ExceptionData | None = None
29
- context: _ExceptionData | None = None
27
+ tb_frames: list[FrameData]
28
+ cause: ExceptionData | None = None
29
+ context: ExceptionData | None = None
30
+
31
+
32
+ @dataclass(kw_only=True)
33
+ class ExceptionGroupData(ExceptionData):
34
+ """Serialized data for an ExceptionGroup."""
35
+
36
+ exceptions: list[ExceptionData]
@@ -6,7 +6,7 @@ import types
6
6
  from io import BytesIO
7
7
  from pathlib import Path
8
8
 
9
- from offline_debug._inner.models import _ExceptionData, _FrameData
9
+ from offline_debug._inner.models import ExceptionData, ExceptionGroupData, FrameData
10
10
 
11
11
  # Internal attributes that are either unpicklable or redundant in a new process.
12
12
  # We exclude these specifically because they are automatically recreated
@@ -41,9 +41,9 @@ def _filter_dict(d: dict) -> dict:
41
41
  return result
42
42
 
43
43
 
44
- def _serialize_exc_data(exc: BaseException) -> _ExceptionData:
44
+ def _serialize_exc_data(exc: BaseException) -> ExceptionData:
45
45
  """Recursively serialize exception data into dataclasses."""
46
- tb_frames: list[_FrameData] = []
46
+ tb_frames: list[FrameData] = []
47
47
  curr_tb = exc.__traceback__
48
48
  while curr_tb:
49
49
  f = curr_tb.tb_frame
@@ -58,7 +58,7 @@ def _serialize_exc_data(exc: BaseException) -> _ExceptionData:
58
58
  mod_name = spec.name
59
59
 
60
60
  tb_frames.append(
61
- _FrameData(
61
+ FrameData(
62
62
  code=marshal.dumps(f.f_code),
63
63
  globals=_filter_dict(f.f_globals),
64
64
  locals=_filter_dict(f.f_locals),
@@ -77,17 +77,32 @@ def _serialize_exc_data(exc: BaseException) -> _ExceptionData:
77
77
  RuntimeError(f"Unpicklable exception {type(exc).__name__}: {exc!s}")
78
78
  )
79
79
 
80
- return _ExceptionData(
80
+ cause = _serialize_exc_data(exc.__cause__) if exc.__cause__ else None
81
+ context = _serialize_exc_data(exc.__context__) if exc.__context__ else None
82
+
83
+ if isinstance(exc, BaseExceptionGroup):
84
+ return ExceptionGroupData(
85
+ exc_pickle=exc_pickle,
86
+ tb_frames=tb_frames,
87
+ cause=cause,
88
+ context=context,
89
+ exceptions=[_serialize_exc_data(e) for e in exc.exceptions],
90
+ )
91
+
92
+ return ExceptionData(
81
93
  exc_pickle=exc_pickle,
82
94
  tb_frames=tb_frames,
83
- cause=_serialize_exc_data(exc.__cause__) if exc.__cause__ else None,
84
- context=_serialize_exc_data(exc.__context__) if exc.__context__ else None,
95
+ cause=cause,
96
+ context=context,
85
97
  )
86
98
 
87
99
 
88
- def save_traceback(exc: BaseException, file: Path | BytesIO) -> None:
100
+ def save_traceback(exc: BaseException, file: Path | BytesIO | None) -> ExceptionData:
89
101
  """Serialize an exception and its traceback to a file."""
90
102
  data = _serialize_exc_data(exc)
103
+ if file is None:
104
+ return data
105
+
91
106
  if isinstance(file, Path):
92
107
  with file.open("wb") as f:
93
108
  pickle.dump(data, f)
@@ -96,3 +111,4 @@ def save_traceback(exc: BaseException, file: Path | BytesIO) -> None:
96
111
  else:
97
112
  msg = f"Unexpected type for file {type(file).__name__}"
98
113
  raise TypeError(msg)
114
+ return data
@@ -1,12 +1,12 @@
1
1
  [project]
2
2
  name = "offline-debug"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Debug exceptions offline by saving them to a dump and raising them at a later point."
5
5
  readme = "README.md"
6
6
  authors = [
7
7
  { name = "Itai Elidan", email = "itaielidan@gmail.com" }
8
8
  ]
9
- requires-python = ">=3.12"
9
+ requires-python = ">=3.12, <3.14"
10
10
  dependencies = [
11
11
  "typing-extensions>=4.15.0",
12
12
  ]
@@ -1,6 +0,0 @@
1
- """Tool for serializing and reconstructing Python exceptions with full stack traces."""
2
-
3
- from ._inner.load_traceback import load_traceback
4
- from ._inner.save_traceback import save_traceback
5
-
6
- __all__ = ["load_traceback", "save_traceback"]