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.
- {offline_debug-0.2.0 → offline_debug-0.3.0}/PKG-INFO +31 -6
- {offline_debug-0.2.0 → offline_debug-0.3.0}/README.md +29 -4
- offline_debug-0.3.0/offline_debug/__init__.py +14 -0
- {offline_debug-0.2.0 → offline_debug-0.3.0}/offline_debug/_inner/load_traceback.py +32 -20
- {offline_debug-0.2.0 → offline_debug-0.3.0}/offline_debug/_inner/models.py +13 -6
- {offline_debug-0.2.0 → offline_debug-0.3.0}/offline_debug/_inner/save_traceback.py +24 -8
- {offline_debug-0.2.0 → offline_debug-0.3.0}/pyproject.toml +2 -2
- offline_debug-0.2.0/offline_debug/__init__.py +0 -6
- {offline_debug-0.2.0 → offline_debug-0.3.0}/offline_debug/_inner/__init__.py +0 -0
- {offline_debug-0.2.0 → offline_debug-0.3.0}/offline_debug/_inner/c_api/__init__.py +0 -0
- {offline_debug-0.2.0 → offline_debug-0.3.0}/offline_debug/_inner/c_api/_create_frame.py +0 -0
- {offline_debug-0.2.0 → offline_debug-0.3.0}/offline_debug/_inner/c_api/_link_frame.py +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: offline-debug
|
|
3
|
-
Version: 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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
18
|
-
|
|
16
|
+
ExceptionData,
|
|
17
|
+
ExceptionGroupData,
|
|
18
|
+
FrameData,
|
|
19
19
|
)
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
def _reconstruct_exc_data(data:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
code
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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[
|
|
28
|
-
cause:
|
|
29
|
-
context:
|
|
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
|
|
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) ->
|
|
44
|
+
def _serialize_exc_data(exc: BaseException) -> ExceptionData:
|
|
45
45
|
"""Recursively serialize exception data into dataclasses."""
|
|
46
|
-
tb_frames: list[
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
84
|
-
context=
|
|
95
|
+
cause=cause,
|
|
96
|
+
context=context,
|
|
85
97
|
)
|
|
86
98
|
|
|
87
99
|
|
|
88
|
-
def save_traceback(exc: BaseException, file: Path | BytesIO) ->
|
|
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.
|
|
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
|
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|