offline-debug 0.1.0b1__tar.gz → 0.2.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/PKG-INFO +75 -0
- offline_debug-0.2.0/README.md +63 -0
- offline_debug-0.2.0/offline_debug/__init__.py +6 -0
- offline_debug-0.2.0/offline_debug/_inner/__init__.py +0 -0
- offline_debug-0.2.0/offline_debug/_inner/c_api/__init__.py +4 -0
- offline_debug-0.2.0/offline_debug/_inner/c_api/_create_frame.py +93 -0
- offline_debug-0.2.0/offline_debug/_inner/c_api/_link_frame.py +128 -0
- offline_debug-0.2.0/offline_debug/_inner/load_traceback.py +129 -0
- offline_debug-0.2.0/offline_debug/_inner/models.py +29 -0
- offline_debug-0.2.0/offline_debug/_inner/save_traceback.py +98 -0
- offline_debug-0.2.0/pyproject.toml +66 -0
- offline_debug-0.1.0b1/PKG-INFO +0 -8
- offline_debug-0.1.0b1/offline_debug/__init__.py +0 -3
- offline_debug-0.1.0b1/offline_debug/serializer.py +0 -167
- offline_debug-0.1.0b1/pyproject.toml +0 -26
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: offline-debug
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Debug exceptions offline by saving them to a dump and raising them at a later point.
|
|
5
|
+
Author: Itai Elidan
|
|
6
|
+
Author-email: Itai Elidan <itaielidan@gmail.com>
|
|
7
|
+
Requires-Dist: typing-extensions>=4.15.0
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Project-URL: Homepage, https://github.com/INTODAN/offline-debug
|
|
10
|
+
Project-URL: Repository, https://github.com/INTODAN/offline-debug
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# Traceback Serializer Project (`offline-debug`)
|
|
14
|
+
|
|
15
|
+
[](https://pypi.org/project/offline-debug/)
|
|
16
|
+
[](https://github.com/INTODAN/offline-debug/actions)
|
|
17
|
+
[](https://github.com/INTODAN/offline-debug)
|
|
18
|
+
[](https://github.com/astral-sh/ruff)
|
|
19
|
+
[](https://github.com/astral-sh/ty)
|
|
20
|
+
|
|
21
|
+
## Overview
|
|
22
|
+
|
|
23
|
+
A Python package for high-fidelity serialization and deserialization of exceptions and their complete tracebacks. Unlike other
|
|
24
|
+
solutions, `offline-debug` reconstructs **actual** `types.FrameType` objects using the Python C API, ensuring that re-raised
|
|
25
|
+
exceptions look and feel genuine to debuggers and introspection tools.
|
|
26
|
+
|
|
27
|
+
## Core Functions
|
|
28
|
+
|
|
29
|
+
- `save_traceback(exc: BaseException, file: Path | BytesIO)`:
|
|
30
|
+
Serializes an exception, its traceback, and all picklable local/global variables to a binary file or buffer.
|
|
31
|
+
- `load_traceback(file: Path | BytesIO) -> Never`:
|
|
32
|
+
Loads the serialized state, reconstructs the exception and its full traceback chain (including `__cause__` and `__context__`),
|
|
33
|
+
and raises it.
|
|
34
|
+
|
|
35
|
+
## Usage Example
|
|
36
|
+
|
|
37
|
+
to get started, install with:
|
|
38
|
+
`pip install offline-debug` or `uv add offline-debug`
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from offline_debug import save_traceback, load_traceback
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
# Code that might fail
|
|
46
|
+
some_complex_operation()
|
|
47
|
+
except Exception as e:
|
|
48
|
+
save_traceback(e, Path("crash_report.dump"))
|
|
49
|
+
|
|
50
|
+
# To debug or re-examine later:
|
|
51
|
+
load_traceback(Path("crash_report.dump"))
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Technical Implementation
|
|
55
|
+
|
|
56
|
+
- **True Frame Reconstruction**: Uses `ctypes` to call `PyFrame_New` from the Python C API. This creates real `frame` objects
|
|
57
|
+
which are required for a valid `types.TracebackType`.
|
|
58
|
+
- **Python 3.13 Compatibility**: Leverages PEP 667 features where `f_locals` is a write-through proxy, allowing for accurate local
|
|
59
|
+
variable restoration.
|
|
60
|
+
- **Support python 3.12 as well**
|
|
61
|
+
- **Robust Serialization**:
|
|
62
|
+
- `pickle` is used for exceptions and variables.
|
|
63
|
+
- `marshal` is used for code objects.
|
|
64
|
+
- Non-picklable items are gracefully handled by storing their `repr`.
|
|
65
|
+
|
|
66
|
+
## Development & Tooling
|
|
67
|
+
|
|
68
|
+
- **Package Manager**: `uv`
|
|
69
|
+
- **Minimum Python**: 3.12
|
|
70
|
+
- **Testing**: `pytest`
|
|
71
|
+
- **Commands**:
|
|
72
|
+
- Add dependencies: `uv add <package>`
|
|
73
|
+
- Run tests: `uv run pytest`
|
|
74
|
+
|
|
75
|
+
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Traceback Serializer Project (`offline-debug`)
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/offline-debug/)
|
|
4
|
+
[](https://github.com/INTODAN/offline-debug/actions)
|
|
5
|
+
[](https://github.com/INTODAN/offline-debug)
|
|
6
|
+
[](https://github.com/astral-sh/ruff)
|
|
7
|
+
[](https://github.com/astral-sh/ty)
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
A Python package for high-fidelity serialization and deserialization of exceptions and their complete tracebacks. Unlike other
|
|
12
|
+
solutions, `offline-debug` reconstructs **actual** `types.FrameType` objects using the Python C API, ensuring that re-raised
|
|
13
|
+
exceptions look and feel genuine to debuggers and introspection tools.
|
|
14
|
+
|
|
15
|
+
## Core Functions
|
|
16
|
+
|
|
17
|
+
- `save_traceback(exc: BaseException, file: Path | BytesIO)`:
|
|
18
|
+
Serializes an exception, its traceback, and all picklable local/global variables to a binary file or buffer.
|
|
19
|
+
- `load_traceback(file: Path | BytesIO) -> Never`:
|
|
20
|
+
Loads the serialized state, reconstructs the exception and its full traceback chain (including `__cause__` and `__context__`),
|
|
21
|
+
and raises it.
|
|
22
|
+
|
|
23
|
+
## Usage Example
|
|
24
|
+
|
|
25
|
+
to get started, install with:
|
|
26
|
+
`pip install offline-debug` or `uv add offline-debug`
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from offline_debug import save_traceback, load_traceback
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
# Code that might fail
|
|
34
|
+
some_complex_operation()
|
|
35
|
+
except Exception as e:
|
|
36
|
+
save_traceback(e, Path("crash_report.dump"))
|
|
37
|
+
|
|
38
|
+
# To debug or re-examine later:
|
|
39
|
+
load_traceback(Path("crash_report.dump"))
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Technical Implementation
|
|
43
|
+
|
|
44
|
+
- **True Frame Reconstruction**: Uses `ctypes` to call `PyFrame_New` from the Python C API. This creates real `frame` objects
|
|
45
|
+
which are required for a valid `types.TracebackType`.
|
|
46
|
+
- **Python 3.13 Compatibility**: Leverages PEP 667 features where `f_locals` is a write-through proxy, allowing for accurate local
|
|
47
|
+
variable restoration.
|
|
48
|
+
- **Support python 3.12 as well**
|
|
49
|
+
- **Robust Serialization**:
|
|
50
|
+
- `pickle` is used for exceptions and variables.
|
|
51
|
+
- `marshal` is used for code objects.
|
|
52
|
+
- Non-picklable items are gracefully handled by storing their `repr`.
|
|
53
|
+
|
|
54
|
+
## Development & Tooling
|
|
55
|
+
|
|
56
|
+
- **Package Manager**: `uv`
|
|
57
|
+
- **Minimum Python**: 3.12
|
|
58
|
+
- **Testing**: `pytest`
|
|
59
|
+
- **Commands**:
|
|
60
|
+
- Add dependencies: `uv add <package>`
|
|
61
|
+
- Run tests: `uv run pytest`
|
|
62
|
+
|
|
63
|
+
|
|
File without changes
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Abstraction of the CPython api for creating a traceback frame object.
|
|
3
|
+
|
|
4
|
+
The reason we have to implement it, is that cpython doesn't expose
|
|
5
|
+
the api to create a FrameType using pure python.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ctypes
|
|
11
|
+
import sys
|
|
12
|
+
from functools import cache
|
|
13
|
+
from types import CodeType, FrameType
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
import _ctypes
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@cache
|
|
21
|
+
def _get_py_frame_new() -> ctypes._NamedFuncPointer:
|
|
22
|
+
"""Get the PyFrame_New C function configured with ctypes."""
|
|
23
|
+
func: ctypes._NamedFuncPointer = ctypes.pythonapi.PyFrame_New
|
|
24
|
+
func.argtypes = (
|
|
25
|
+
ctypes.c_void_p, # PyThreadState *tstate
|
|
26
|
+
ctypes.py_object, # PyCodeObject *code
|
|
27
|
+
ctypes.py_object, # PyObject *globals
|
|
28
|
+
ctypes.py_object, # PyObject *locals
|
|
29
|
+
)
|
|
30
|
+
func.restype = ctypes.py_object
|
|
31
|
+
|
|
32
|
+
def errcheck[T](
|
|
33
|
+
result: T | None,
|
|
34
|
+
_func: _ctypes.CFuncPtr,
|
|
35
|
+
_args: tuple[Any, ...],
|
|
36
|
+
) -> T: # pragma: no cover
|
|
37
|
+
if not result:
|
|
38
|
+
msg = "failed to create a new frame while calling"
|
|
39
|
+
raise RuntimeError(msg)
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
func.errcheck = errcheck
|
|
43
|
+
return func
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@cache
|
|
47
|
+
def _get_py_thread_state_get() -> ctypes._NamedFuncPointer:
|
|
48
|
+
"""Get the PyThreadState_Get C function configured with ctypes."""
|
|
49
|
+
func = ctypes.pythonapi.PyThreadState_Get
|
|
50
|
+
func.argtypes = ()
|
|
51
|
+
func.restype = ctypes.c_void_p
|
|
52
|
+
|
|
53
|
+
def errcheck[T](
|
|
54
|
+
result: T | None, _func: _ctypes.CFuncPtr, _args: tuple[Any, ...]
|
|
55
|
+
) -> T: # pragma: no cover
|
|
56
|
+
if not result:
|
|
57
|
+
msg = "failed to get the current thread state"
|
|
58
|
+
raise RuntimeError(msg)
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
func.errcheck = errcheck
|
|
62
|
+
return func
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def create_frame(
|
|
66
|
+
code: CodeType,
|
|
67
|
+
frame_globals: dict[str, Any],
|
|
68
|
+
frame_locals: dict[str, Any],
|
|
69
|
+
thread_state: int | None = None,
|
|
70
|
+
) -> FrameType:
|
|
71
|
+
"""
|
|
72
|
+
Create a FrameType object using the received parameters.
|
|
73
|
+
|
|
74
|
+
If the thread state received is None, use the current thread state automatically.
|
|
75
|
+
"""
|
|
76
|
+
py_frame_new = _get_py_frame_new()
|
|
77
|
+
py_thread_state_get = _get_py_thread_state_get()
|
|
78
|
+
|
|
79
|
+
if thread_state is None:
|
|
80
|
+
thread_state = py_thread_state_get()
|
|
81
|
+
|
|
82
|
+
frame: FrameType = py_frame_new(thread_state, code, frame_globals, frame_locals)
|
|
83
|
+
|
|
84
|
+
# In 3.13+, PEP 667 allows safe write-through access to locals.
|
|
85
|
+
# py_frame_new ignores the frame_locals argument,
|
|
86
|
+
# and must be assigned after the frame's creation in 3.13+
|
|
87
|
+
if sys.version_info >= (3, 13) and frame_locals:
|
|
88
|
+
frame.f_locals.update(frame_locals)
|
|
89
|
+
|
|
90
|
+
if not isinstance(frame, FrameType):
|
|
91
|
+
msg = f"Expected types.FrameType, but got {type(frame).__name__}"
|
|
92
|
+
raise TypeError(msg)
|
|
93
|
+
return frame
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Link 2 frames together, so that a linked chain of frames will eventually create a traceback.
|
|
3
|
+
|
|
4
|
+
The native cpython api does not expose a way to link frames together
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ctypes
|
|
10
|
+
import sys
|
|
11
|
+
from functools import cache
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from ._create_frame import create_frame
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
import _ctypes
|
|
18
|
+
from types import FrameType
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@cache
|
|
22
|
+
def _get_py_incref() -> ctypes._NamedFuncPointer:
|
|
23
|
+
"""
|
|
24
|
+
Get the Py_IncRef C function configured with ctypes.
|
|
25
|
+
|
|
26
|
+
Py_IncRef increases the reference count of an object.
|
|
27
|
+
We call this function when we want to "own" an object,
|
|
28
|
+
meaning that the object won't be deleted while we are still using it.
|
|
29
|
+
|
|
30
|
+
In our case, we use the function to hold the f_back reference to link between frames.
|
|
31
|
+
"""
|
|
32
|
+
func: ctypes._NamedFuncPointer = ctypes.pythonapi.Py_IncRef
|
|
33
|
+
func.argtypes = (ctypes.py_object,)
|
|
34
|
+
func.restype = None
|
|
35
|
+
|
|
36
|
+
def errcheck[T](
|
|
37
|
+
result: T,
|
|
38
|
+
_func: _ctypes.CFuncPtr,
|
|
39
|
+
_args: tuple[Any, ...],
|
|
40
|
+
) -> T: # pragma: no cover
|
|
41
|
+
if result is not None:
|
|
42
|
+
msg = f"Unexpected {result=}, expected None."
|
|
43
|
+
raise TypeError(msg)
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
func.errcheck = errcheck
|
|
47
|
+
return func
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def link_frame(frame: FrameType, f_back: FrameType) -> None:
|
|
51
|
+
"""Link a frame to its parent frame using the discovered offset."""
|
|
52
|
+
_f_back_offset = _get_f_back_offset()
|
|
53
|
+
if _f_back_offset is None:
|
|
54
|
+
msg = "Failed discovering the offset for the f_back property."
|
|
55
|
+
raise RuntimeError(msg)
|
|
56
|
+
|
|
57
|
+
# In Python, setting f_back means the child frame now owns a reference
|
|
58
|
+
# to the parent frame. We must increment the reference count of the
|
|
59
|
+
# parent to reflect this.
|
|
60
|
+
py_incref = _get_py_incref()
|
|
61
|
+
py_incref(f_back)
|
|
62
|
+
|
|
63
|
+
# Use ctypes to write the address of the back frame into the discovered offset.
|
|
64
|
+
# We find the address of the f_back property, by finding the start of the frame struct,
|
|
65
|
+
# Then moving to the f_back offset in the struct.
|
|
66
|
+
ptr = ctypes.c_void_p.from_address(id(frame) + _f_back_offset)
|
|
67
|
+
ptr.value = id(f_back)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@cache
|
|
71
|
+
def _get_f_back_offset() -> int | None:
|
|
72
|
+
"""
|
|
73
|
+
Dynamically discover the memory offset of f_back in PyFrameObject.
|
|
74
|
+
|
|
75
|
+
The offset for the f_back can change between python versions and operating systems,
|
|
76
|
+
So we find the location of the f_back property dynamically.
|
|
77
|
+
The general idea of the algorithm is to create a mock frame,
|
|
78
|
+
And scanning its memory until we hit the f_back property field.
|
|
79
|
+
We know we hit the f_back property by
|
|
80
|
+
setting the value of each slot in the memeory to another frame, and checking if the
|
|
81
|
+
f_back property was populated after changing that memory location.
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
# Compile an empty code object that we can use to create a frame.
|
|
85
|
+
code = compile("pass", "<discovery>", "exec")
|
|
86
|
+
|
|
87
|
+
# Create 2 frames that we can attempt linking between to find the f_back property.
|
|
88
|
+
frame = create_frame(code=code, frame_globals={}, frame_locals={})
|
|
89
|
+
f_back_frame = create_frame(code=code, frame_globals={}, frame_locals={})
|
|
90
|
+
|
|
91
|
+
ptr_size = ctypes.sizeof(ctypes.c_void_p)
|
|
92
|
+
jump_size = ptr_size
|
|
93
|
+
|
|
94
|
+
ref_count_size = ptr_size # The amount of references to the object
|
|
95
|
+
type_object_size = ptr_size
|
|
96
|
+
frame_header_size = ref_count_size + type_object_size
|
|
97
|
+
start = frame_header_size # The header does not contain the f_back property
|
|
98
|
+
|
|
99
|
+
frame_size = sys.getsizeof(frame)
|
|
100
|
+
|
|
101
|
+
# We scan the memory ptr_size bytes forward, so we decrease it from the end.
|
|
102
|
+
# Since we want the last memory slot to be scanned as well,
|
|
103
|
+
# we add +1 because the for loop is exclusive to the end.
|
|
104
|
+
end = frame_size - ptr_size + 1
|
|
105
|
+
|
|
106
|
+
# We start scanning after the PyObject header (refcnt + type).
|
|
107
|
+
for offset in range(start, end, jump_size):
|
|
108
|
+
candidate_f_back_address = id(frame) + offset
|
|
109
|
+
candidate_f_back_ptr = ctypes.c_ssize_t.from_address(candidate_f_back_address)
|
|
110
|
+
|
|
111
|
+
# f_back is initially NULL (0) in a newly created frame,
|
|
112
|
+
# since no other frames are linked to it.
|
|
113
|
+
if candidate_f_back_ptr.value != 0:
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
candidate_f_back_ptr.value = id(f_back_frame)
|
|
117
|
+
# If reading f_back via Python now returns our target, we found it.
|
|
118
|
+
if frame.f_back is f_back_frame:
|
|
119
|
+
# Success, but we must restore 0 so we don't mess up refcounts
|
|
120
|
+
# when 'frame' is eventually garbage collected.
|
|
121
|
+
candidate_f_back_ptr.value = 0
|
|
122
|
+
return offset
|
|
123
|
+
# Restore to 0 if this wasn't the correct offset.
|
|
124
|
+
candidate_f_back_ptr.value = 0
|
|
125
|
+
|
|
126
|
+
except Exception: # noqa: BLE001
|
|
127
|
+
return None
|
|
128
|
+
return None
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Load traceback object from a dump file."""
|
|
2
|
+
|
|
3
|
+
import marshal
|
|
4
|
+
import pickle
|
|
5
|
+
import sys
|
|
6
|
+
import types
|
|
7
|
+
from io import BytesIO
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from types import CodeType
|
|
10
|
+
from typing import Never
|
|
11
|
+
|
|
12
|
+
from offline_debug._inner.c_api import (
|
|
13
|
+
create_frame,
|
|
14
|
+
link_frame,
|
|
15
|
+
)
|
|
16
|
+
from offline_debug._inner.models import (
|
|
17
|
+
_ExceptionData,
|
|
18
|
+
_FrameData,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _reconstruct_exc_data(data: _ExceptionData) -> BaseException:
|
|
23
|
+
"""
|
|
24
|
+
Recursively reconstruct an exception from its serialized data.
|
|
25
|
+
|
|
26
|
+
Note on Python Locals:
|
|
27
|
+
Python uses two ways to store local variables:
|
|
28
|
+
1. "Slow" locals: A dictionary used for module-level code and class definitions.
|
|
29
|
+
2. "Fast" locals: A fixed-size array used for functions. This is faster than
|
|
30
|
+
dictionary lookups because variables are accessed by index.
|
|
31
|
+
|
|
32
|
+
During reconstruction, we must explicitly synchronize these because PyFrame_New
|
|
33
|
+
does not automatically populate the "fast" locals array from a dictionary.
|
|
34
|
+
"""
|
|
35
|
+
exc: BaseException = pickle.loads(data.exc_pickle) # noqa: S301
|
|
36
|
+
if not isinstance(exc, BaseException):
|
|
37
|
+
msg = f"Expected BaseException, but got {type(exc).__name__}"
|
|
38
|
+
raise TypeError(msg)
|
|
39
|
+
|
|
40
|
+
reconstructed_frames: list[tuple[types.FrameType, _FrameData]] = []
|
|
41
|
+
for f_data in data.tb_frames:
|
|
42
|
+
code: CodeType = marshal.loads(f_data.code) # noqa: S302
|
|
43
|
+
|
|
44
|
+
# In Python 3.11 and 3.12, accessing f_locals on a frame created via
|
|
45
|
+
# PyFrame_New for optimized code (functions) causes a segmentation fault
|
|
46
|
+
# because the internal 'fast' locals array is not initialized.
|
|
47
|
+
# As a workaround, we create a 'non-optimized' version of the code object
|
|
48
|
+
# by compiling a dummy string. This ensures the bytecode is safe
|
|
49
|
+
# (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
|
+
)
|
|
60
|
+
|
|
61
|
+
# PyFrame_New returns a new reference to a PyFrameObject.
|
|
62
|
+
if f_data.module_name:
|
|
63
|
+
f_data.globals["__name__"] = f_data.module_name
|
|
64
|
+
|
|
65
|
+
frame: types.FrameType = create_frame(
|
|
66
|
+
code=code, frame_globals=f_data.globals, frame_locals=f_data.locals
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if reconstructed_frames:
|
|
70
|
+
# link the frame back to the previously constructed frame.
|
|
71
|
+
link_frame(frame, reconstructed_frames[-1][0])
|
|
72
|
+
|
|
73
|
+
reconstructed_frames.append((frame, f_data))
|
|
74
|
+
|
|
75
|
+
tb_next: types.TracebackType | None = None
|
|
76
|
+
for frame, f_data in reversed(reconstructed_frames):
|
|
77
|
+
tb = types.TracebackType(
|
|
78
|
+
tb_next=tb_next,
|
|
79
|
+
tb_frame=frame,
|
|
80
|
+
tb_lasti=f_data.lasti,
|
|
81
|
+
tb_lineno=f_data.lineno,
|
|
82
|
+
)
|
|
83
|
+
tb_next = tb
|
|
84
|
+
|
|
85
|
+
exc = exc.with_traceback(tb_next)
|
|
86
|
+
|
|
87
|
+
if data.cause:
|
|
88
|
+
exc.__cause__ = _reconstruct_exc_data(data.cause)
|
|
89
|
+
if data.context:
|
|
90
|
+
exc.__context__ = _reconstruct_exc_data(data.context)
|
|
91
|
+
|
|
92
|
+
return exc
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def load_traceback(file: Path | BytesIO) -> Never:
|
|
96
|
+
"""Load an exception and its traceback from a file and raise it."""
|
|
97
|
+
if isinstance(file, Path):
|
|
98
|
+
with file.open("rb") as f:
|
|
99
|
+
data = pickle.load(f) # noqa: S301
|
|
100
|
+
else:
|
|
101
|
+
data = pickle.load(file) # noqa: S301
|
|
102
|
+
|
|
103
|
+
if not isinstance(data, _ExceptionData):
|
|
104
|
+
msg = f"Expected _ExceptionData, but got {type(data).__name__}"
|
|
105
|
+
raise TypeError(msg)
|
|
106
|
+
|
|
107
|
+
exc = _reconstruct_exc_data(data)
|
|
108
|
+
|
|
109
|
+
current_frames: list[types.FrameType] = []
|
|
110
|
+
curr: types.FrameType | None = sys._getframe(1) # noqa: SLF001
|
|
111
|
+
while curr:
|
|
112
|
+
current_frames.append(curr)
|
|
113
|
+
curr = curr.f_back
|
|
114
|
+
|
|
115
|
+
if exc.__traceback__ and current_frames:
|
|
116
|
+
reconstructed_outer = exc.__traceback__.tb_frame
|
|
117
|
+
link_frame(reconstructed_outer, current_frames[0])
|
|
118
|
+
|
|
119
|
+
tb_chain: types.TracebackType | None = exc.__traceback__
|
|
120
|
+
for frame in current_frames:
|
|
121
|
+
tb_chain = types.TracebackType(
|
|
122
|
+
tb_next=tb_chain,
|
|
123
|
+
tb_frame=frame,
|
|
124
|
+
tb_lasti=frame.f_lasti,
|
|
125
|
+
tb_lineno=frame.f_lineno,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
exc = exc.with_traceback(tb_chain)
|
|
129
|
+
raise exc
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Functions for serializing and reconstructing exceptions with their tracebacks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class _FrameData:
|
|
11
|
+
"""Serialized data for a single stack frame."""
|
|
12
|
+
|
|
13
|
+
code: bytes
|
|
14
|
+
globals: dict[str, Any]
|
|
15
|
+
locals: dict[str, Any]
|
|
16
|
+
lasti: int
|
|
17
|
+
lineno: int
|
|
18
|
+
stack_depth: int
|
|
19
|
+
module_name: str | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class _ExceptionData:
|
|
24
|
+
"""Serialized data for an exception and its traceback."""
|
|
25
|
+
|
|
26
|
+
exc_pickle: bytes
|
|
27
|
+
tb_frames: list[_FrameData]
|
|
28
|
+
cause: _ExceptionData | None = None
|
|
29
|
+
context: _ExceptionData | None = None
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Save traceback to a file."""
|
|
2
|
+
|
|
3
|
+
import marshal
|
|
4
|
+
import pickle
|
|
5
|
+
import types
|
|
6
|
+
from io import BytesIO
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from offline_debug._inner.models import _ExceptionData, _FrameData
|
|
10
|
+
|
|
11
|
+
# Internal attributes that are either unpicklable or redundant in a new process.
|
|
12
|
+
# We exclude these specifically because they are automatically recreated
|
|
13
|
+
# when the new frame is initialized or when the module is imported.
|
|
14
|
+
_INTERNAL_ATTRIBUTES_TO_SKIP = ("__builtins__", "__doc__", "__loader__", "__package__", "__spec__")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_stack_depth(frame: types.FrameType) -> int:
|
|
18
|
+
"""Calculate the depth of the current stack frame."""
|
|
19
|
+
depth = 0
|
|
20
|
+
curr: types.FrameType | None = frame
|
|
21
|
+
while curr:
|
|
22
|
+
depth += 1
|
|
23
|
+
curr = curr.f_back
|
|
24
|
+
return depth
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _filter_dict(d: dict) -> dict:
|
|
28
|
+
"""Filter dictionary to include only picklable items."""
|
|
29
|
+
result = {}
|
|
30
|
+
for k, v in d.items():
|
|
31
|
+
if k in _INTERNAL_ATTRIBUTES_TO_SKIP:
|
|
32
|
+
continue
|
|
33
|
+
try:
|
|
34
|
+
# We must verify if the value is picklable because many globals
|
|
35
|
+
# (like open file handles, database connections, or modules)
|
|
36
|
+
# cannot be saved to disk.
|
|
37
|
+
pickle.dumps(v)
|
|
38
|
+
result[k] = v
|
|
39
|
+
except Exception: # noqa: BLE001
|
|
40
|
+
result[k] = f"<unpicklable {type(v).__name__}: {v!r}>"
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _serialize_exc_data(exc: BaseException) -> _ExceptionData:
|
|
45
|
+
"""Recursively serialize exception data into dataclasses."""
|
|
46
|
+
tb_frames: list[_FrameData] = []
|
|
47
|
+
curr_tb = exc.__traceback__
|
|
48
|
+
while curr_tb:
|
|
49
|
+
f = curr_tb.tb_frame
|
|
50
|
+
|
|
51
|
+
# Try to get the "real" module name. If the module was run as a script,
|
|
52
|
+
# __name__ will be "__main__", but __spec__.name might contain the
|
|
53
|
+
# actual module path if run via `python -m`.
|
|
54
|
+
mod_name = f.f_globals.get("__name__")
|
|
55
|
+
if mod_name == "__main__":
|
|
56
|
+
spec = f.f_globals.get("__spec__")
|
|
57
|
+
if spec and hasattr(spec, "name"):
|
|
58
|
+
mod_name = spec.name
|
|
59
|
+
|
|
60
|
+
tb_frames.append(
|
|
61
|
+
_FrameData(
|
|
62
|
+
code=marshal.dumps(f.f_code),
|
|
63
|
+
globals=_filter_dict(f.f_globals),
|
|
64
|
+
locals=_filter_dict(f.f_locals),
|
|
65
|
+
lasti=curr_tb.tb_lasti,
|
|
66
|
+
lineno=curr_tb.tb_lineno,
|
|
67
|
+
stack_depth=_get_stack_depth(f),
|
|
68
|
+
module_name=mod_name,
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
curr_tb = curr_tb.tb_next
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
exc_pickle = pickle.dumps(exc)
|
|
75
|
+
except Exception: # noqa: BLE001
|
|
76
|
+
exc_pickle = pickle.dumps(
|
|
77
|
+
RuntimeError(f"Unpicklable exception {type(exc).__name__}: {exc!s}")
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return _ExceptionData(
|
|
81
|
+
exc_pickle=exc_pickle,
|
|
82
|
+
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,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def save_traceback(exc: BaseException, file: Path | BytesIO) -> None:
|
|
89
|
+
"""Serialize an exception and its traceback to a file."""
|
|
90
|
+
data = _serialize_exc_data(exc)
|
|
91
|
+
if isinstance(file, Path):
|
|
92
|
+
with file.open("wb") as f:
|
|
93
|
+
pickle.dump(data, f)
|
|
94
|
+
elif isinstance(file, BytesIO):
|
|
95
|
+
pickle.dump(data, file)
|
|
96
|
+
else:
|
|
97
|
+
msg = f"Unexpected type for file {type(file).__name__}"
|
|
98
|
+
raise TypeError(msg)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "offline-debug"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Debug exceptions offline by saving them to a dump and raising them at a later point."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Itai Elidan", email = "itaielidan@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"typing-extensions>=4.15.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Homepage = "https://github.com/INTODAN/offline-debug"
|
|
16
|
+
Repository = "https://github.com/INTODAN/offline-debug"
|
|
17
|
+
|
|
18
|
+
[tool.ruff]
|
|
19
|
+
line-length = 100
|
|
20
|
+
target-version = "py312"
|
|
21
|
+
|
|
22
|
+
[tool.ruff.lint]
|
|
23
|
+
select = ["ALL"]
|
|
24
|
+
ignore = [
|
|
25
|
+
"D203", # One blank line before class
|
|
26
|
+
"D212", # Multi-line docstring summary should start at the first line
|
|
27
|
+
"COM812", # Trailing comma missing
|
|
28
|
+
"PLC0415", # import should be at top of file
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[tool.ruff.lint.per-file-ignores]
|
|
32
|
+
"tests/*" = [
|
|
33
|
+
"S101", # Use of assert detected
|
|
34
|
+
"SLF001", # Private member accessed
|
|
35
|
+
"D103", # Missing docstring in public function
|
|
36
|
+
"ANN201", # Missing return type annotation for public function
|
|
37
|
+
"ANN001", # Missing type annotation for function argument
|
|
38
|
+
"PLW0603", # Using the global statement
|
|
39
|
+
"TRY003", # Avoid specifying long messages outside the exception class
|
|
40
|
+
"EM101", # Exception must not use a string literal
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["uv_build"]
|
|
45
|
+
build-backend = "uv_build"
|
|
46
|
+
|
|
47
|
+
[tool.uv.build-backend]
|
|
48
|
+
module-root = ""
|
|
49
|
+
|
|
50
|
+
[dependency-groups]
|
|
51
|
+
dev = [
|
|
52
|
+
"pre-commit>=4.5.1",
|
|
53
|
+
"pytest>=9.0.2",
|
|
54
|
+
"pytest-cov>=7.1.0",
|
|
55
|
+
"rich>=14.3.3",
|
|
56
|
+
"ruff>=0.15.8",
|
|
57
|
+
"ty>=0.0.27",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
[tool.coverage.report]
|
|
61
|
+
exclude_lines = [
|
|
62
|
+
"pragma: no cover",
|
|
63
|
+
"if TYPE_CHECKING:",
|
|
64
|
+
"if sys.version_info < \\(3, 13\\).*",
|
|
65
|
+
"if sys.version_info >= \\(3, 13\\).*",
|
|
66
|
+
]
|
offline_debug-0.1.0b1/PKG-INFO
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.3
|
|
2
|
-
Name: offline-debug
|
|
3
|
-
Version: 0.1.0b1
|
|
4
|
-
Summary: Debug exceptions offline by saving them to a dump and raising them at a later point.
|
|
5
|
-
Author: Itai Elidan
|
|
6
|
-
Author-email: Itai Elidan <itaielidan@gmail.com>
|
|
7
|
-
Requires-Dist: typing-extensions>=4.15.0
|
|
8
|
-
Requires-Python: >=3.13
|
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
import pickle
|
|
2
|
-
import marshal
|
|
3
|
-
import types
|
|
4
|
-
import typing
|
|
5
|
-
import ctypes
|
|
6
|
-
import sys
|
|
7
|
-
|
|
8
|
-
# Define C API for frame creation
|
|
9
|
-
_py_frame_new = ctypes.pythonapi.PyFrame_New
|
|
10
|
-
_py_frame_new.argtypes = (
|
|
11
|
-
ctypes.c_void_p, # PyThreadState *tstate
|
|
12
|
-
ctypes.py_object, # PyCodeObject *code
|
|
13
|
-
ctypes.py_object, # PyObject *globals
|
|
14
|
-
ctypes.py_object, # PyObject *locals
|
|
15
|
-
)
|
|
16
|
-
_py_frame_new.restype = ctypes.py_object
|
|
17
|
-
|
|
18
|
-
_py_thread_state_get = ctypes.pythonapi.PyThreadState_Get
|
|
19
|
-
_py_thread_state_get.restype = ctypes.c_void_p
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
# Define ctypes structure to access f_back in PyFrameObject
|
|
23
|
-
class _PyObject(ctypes.Structure):
|
|
24
|
-
_fields_ = [("ob_refcnt", ctypes.c_ssize_t), ("ob_type", ctypes.c_void_p)]
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
class _PyFrameObject(ctypes.Structure):
|
|
28
|
-
_fields_ = [
|
|
29
|
-
("ob_base", _PyObject),
|
|
30
|
-
("f_back", ctypes.c_void_p), # Pointer to previous frame
|
|
31
|
-
]
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _get_stack_depth(frame):
|
|
35
|
-
depth = 0
|
|
36
|
-
while frame:
|
|
37
|
-
depth += 1
|
|
38
|
-
frame = frame.f_back
|
|
39
|
-
return depth
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _filter_dict(d: dict) -> dict:
|
|
43
|
-
"""Filter dictionary to include only picklable items."""
|
|
44
|
-
result = {}
|
|
45
|
-
for k, v in d.items():
|
|
46
|
-
if k in ("__builtins__", "__doc__", "__loader__", "__package__", "__spec__"):
|
|
47
|
-
continue
|
|
48
|
-
try:
|
|
49
|
-
pickle.dumps(v)
|
|
50
|
-
result[k] = v
|
|
51
|
-
except Exception:
|
|
52
|
-
result[k] = f"<unpicklable {type(v).__name__}: {repr(v)}>"
|
|
53
|
-
return result
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def _serialize_exc_data(exc: BaseException) -> dict:
|
|
57
|
-
tb_frames = []
|
|
58
|
-
curr_tb = exc.__traceback__
|
|
59
|
-
while curr_tb:
|
|
60
|
-
f = curr_tb.tb_frame
|
|
61
|
-
tb_frames.append(
|
|
62
|
-
{
|
|
63
|
-
"code": marshal.dumps(f.f_code),
|
|
64
|
-
"globals": _filter_dict(f.f_globals),
|
|
65
|
-
"locals": _filter_dict(f.f_locals),
|
|
66
|
-
"lasti": curr_tb.tb_lasti,
|
|
67
|
-
"lineno": curr_tb.tb_lineno,
|
|
68
|
-
"stack_depth": _get_stack_depth(f),
|
|
69
|
-
}
|
|
70
|
-
)
|
|
71
|
-
curr_tb = curr_tb.tb_next
|
|
72
|
-
|
|
73
|
-
try:
|
|
74
|
-
exc_pickle = pickle.dumps(exc)
|
|
75
|
-
except Exception:
|
|
76
|
-
exc_pickle = pickle.dumps(
|
|
77
|
-
RuntimeError(f"Unpicklable exception {type(exc).__name__}: {str(exc)}")
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
return {
|
|
81
|
-
"exc_pickle": exc_pickle,
|
|
82
|
-
"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,
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def save_traceback(exc: Exception, file_path: str):
|
|
89
|
-
"""Serialize an exception and its traceback to a file."""
|
|
90
|
-
data = _serialize_exc_data(exc)
|
|
91
|
-
with open(file_path, "wb") as f:
|
|
92
|
-
pickle.dump(data, f)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def _reconstruct_exc_data(data: dict) -> Exception:
|
|
96
|
-
exc = pickle.loads(data["exc_pickle"])
|
|
97
|
-
|
|
98
|
-
tstate = _py_thread_state_get()
|
|
99
|
-
|
|
100
|
-
reconstructed_frames = []
|
|
101
|
-
prev_frame = None
|
|
102
|
-
for f_data in data["tb_frames"]:
|
|
103
|
-
code = marshal.loads(f_data["code"])
|
|
104
|
-
|
|
105
|
-
frame = _py_frame_new(tstate, code, f_data["globals"], {})
|
|
106
|
-
|
|
107
|
-
if f_data["locals"]:
|
|
108
|
-
frame.f_locals.update(f_data["locals"])
|
|
109
|
-
|
|
110
|
-
if prev_frame:
|
|
111
|
-
frame_ptr = _PyFrameObject.from_address(id(frame))
|
|
112
|
-
frame_ptr.f_back = id(prev_frame)
|
|
113
|
-
|
|
114
|
-
reconstructed_frames.append((frame, f_data))
|
|
115
|
-
prev_frame = frame
|
|
116
|
-
|
|
117
|
-
tb_next = None
|
|
118
|
-
for frame, f_data in reversed(reconstructed_frames):
|
|
119
|
-
tb = types.TracebackType(
|
|
120
|
-
tb_next=tb_next,
|
|
121
|
-
tb_frame=frame,
|
|
122
|
-
tb_lasti=f_data["lasti"],
|
|
123
|
-
tb_lineno=f_data["lineno"],
|
|
124
|
-
)
|
|
125
|
-
tb_next = tb
|
|
126
|
-
|
|
127
|
-
exc = exc.with_traceback(tb_next)
|
|
128
|
-
|
|
129
|
-
if data["cause"]:
|
|
130
|
-
exc.__cause__ = _reconstruct_exc_data(data["cause"])
|
|
131
|
-
if data["context"]:
|
|
132
|
-
exc.__context__ = _reconstruct_exc_data(data["context"])
|
|
133
|
-
|
|
134
|
-
return exc
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def load_traceback(file_path: str) -> typing.Never:
|
|
138
|
-
"""Load an exception and its traceback from a file and raise it."""
|
|
139
|
-
with open(file_path, "rb") as f:
|
|
140
|
-
data = pickle.load(f)
|
|
141
|
-
|
|
142
|
-
exc = _reconstruct_exc_data(data)
|
|
143
|
-
|
|
144
|
-
current_frames = []
|
|
145
|
-
curr = sys._getframe(1)
|
|
146
|
-
while curr:
|
|
147
|
-
current_frames.append(curr)
|
|
148
|
-
curr = curr.f_back
|
|
149
|
-
|
|
150
|
-
if exc.__traceback__ and current_frames:
|
|
151
|
-
reconstructed_outer = exc.__traceback__.tb_frame
|
|
152
|
-
caller_frame = current_frames[0]
|
|
153
|
-
|
|
154
|
-
frame_ptr = _PyFrameObject.from_address(id(reconstructed_outer))
|
|
155
|
-
frame_ptr.f_back = id(caller_frame)
|
|
156
|
-
|
|
157
|
-
tb_chain = exc.__traceback__
|
|
158
|
-
for frame in current_frames:
|
|
159
|
-
tb_chain = types.TracebackType(
|
|
160
|
-
tb_next=tb_chain,
|
|
161
|
-
tb_frame=frame,
|
|
162
|
-
tb_lasti=frame.f_lasti,
|
|
163
|
-
tb_lineno=frame.f_lineno,
|
|
164
|
-
)
|
|
165
|
-
|
|
166
|
-
exc = exc.with_traceback(tb_chain)
|
|
167
|
-
raise exc
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
[project]
|
|
2
|
-
name = "offline-debug"
|
|
3
|
-
version = "0.1.0-beta1"
|
|
4
|
-
description = "Debug exceptions offline by saving them to a dump and raising them at a later point."
|
|
5
|
-
authors = [
|
|
6
|
-
{ name = "Itai Elidan", email = "itaielidan@gmail.com" }
|
|
7
|
-
]
|
|
8
|
-
requires-python = ">=3.13"
|
|
9
|
-
dependencies = [
|
|
10
|
-
"typing-extensions>=4.15.0",
|
|
11
|
-
]
|
|
12
|
-
|
|
13
|
-
[build-system]
|
|
14
|
-
requires = ["uv_build"]
|
|
15
|
-
build-backend = "uv_build"
|
|
16
|
-
|
|
17
|
-
[tool.uv.build-backend]
|
|
18
|
-
module-root = ""
|
|
19
|
-
|
|
20
|
-
[dependency-groups]
|
|
21
|
-
dev = [
|
|
22
|
-
"pytest>=9.0.2",
|
|
23
|
-
"pytest-cov>=7.1.0",
|
|
24
|
-
"ruff>=0.15.8",
|
|
25
|
-
"ty>=0.0.27",
|
|
26
|
-
]
|