offline-debug 0.1.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.
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.3
2
+ Name: offline-debug
3
+ Version: 0.1.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
+ [![PyPI version](https://img.shields.io/pypi/v/offline-debug.svg)](https://pypi.org/project/offline-debug/)
16
+ [![Tests](https://github.com/INTODAN/offline-debug/actions/workflows/ci.yml/badge.svg)](https://github.com/INTODAN/offline-debug/actions)
17
+ [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/INTODAN/offline-debug)
18
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
19
+ [![Ty checked](https://img.shields.io/badge/ty-checked-blue.svg)](https://github.com/intodan/ty)
20
+
21
+ ## Overview
22
+ A Python package for high-fidelity serialization and deserialization of exceptions and their complete tracebacks. Unlike other solutions, `offline-debug` reconstructs **actual** `types.FrameType` objects using the Python C API, ensuring that re-raised exceptions look and feel genuine to debuggers and introspection tools.
23
+
24
+ ## Core Functions
25
+ - `save_traceback(exc: Exception, file_path: str)`:
26
+ Serializes an exception, its traceback, and all picklable local/global variables to a binary file.
27
+ - `load_traceback(file_path: str) -> typing.Never`:
28
+ Loads the serialized state, reconstructs the exception and its full traceback chain (including `__cause__` and `__context__`), and raises it.
29
+
30
+ ## Technical Implementation
31
+ - **True Frame Reconstruction**: Uses `ctypes` to call `PyFrame_New` from the Python C API. This creates real `frame` objects which are required for a valid `types.TracebackType`.
32
+ - **Python 3.13 Compatibility**: Leverages PEP 667 features where `f_locals` is a write-through proxy, allowing for accurate local variable restoration.
33
+ - **Robust Serialization**:
34
+ - `pickle` is used for exceptions and variables.
35
+ - `marshal` is used for code objects.
36
+ - Non-picklable items are gracefully handled by storing their `repr`.
37
+
38
+ ## Development & Tooling
39
+ - **Package Manager**: `uv`
40
+ - **Minimum Python**: 3.12
41
+ - **Testing**: `pytest`
42
+ - **Commands**:
43
+ - Add dependencies: `uv add <package>`
44
+ - Run tests: `uv run pytest`
45
+
46
+ ## Usage Example
47
+ ```python
48
+ from offline_debug import save_traceback, load_traceback
49
+
50
+ try:
51
+ # Code that might fail
52
+ some_complex_operation()
53
+ except Exception as e:
54
+ save_traceback(e, "crash_report.dump")
55
+
56
+ # To debug or re-examine later:
57
+ load_traceback("crash_report.dump")
58
+ ```
@@ -0,0 +1,46 @@
1
+ # Traceback Serializer Project (`offline-debug`)
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/offline-debug.svg)](https://pypi.org/project/offline-debug/)
4
+ [![Tests](https://github.com/INTODAN/offline-debug/actions/workflows/ci.yml/badge.svg)](https://github.com/INTODAN/offline-debug/actions)
5
+ [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/INTODAN/offline-debug)
6
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
7
+ [![Ty checked](https://img.shields.io/badge/ty-checked-blue.svg)](https://github.com/intodan/ty)
8
+
9
+ ## Overview
10
+ A Python package for high-fidelity serialization and deserialization of exceptions and their complete tracebacks. Unlike other solutions, `offline-debug` reconstructs **actual** `types.FrameType` objects using the Python C API, ensuring that re-raised exceptions look and feel genuine to debuggers and introspection tools.
11
+
12
+ ## Core Functions
13
+ - `save_traceback(exc: Exception, file_path: str)`:
14
+ Serializes an exception, its traceback, and all picklable local/global variables to a binary file.
15
+ - `load_traceback(file_path: str) -> typing.Never`:
16
+ Loads the serialized state, reconstructs the exception and its full traceback chain (including `__cause__` and `__context__`), and raises it.
17
+
18
+ ## Technical Implementation
19
+ - **True Frame Reconstruction**: Uses `ctypes` to call `PyFrame_New` from the Python C API. This creates real `frame` objects which are required for a valid `types.TracebackType`.
20
+ - **Python 3.13 Compatibility**: Leverages PEP 667 features where `f_locals` is a write-through proxy, allowing for accurate local variable restoration.
21
+ - **Robust Serialization**:
22
+ - `pickle` is used for exceptions and variables.
23
+ - `marshal` is used for code objects.
24
+ - Non-picklable items are gracefully handled by storing their `repr`.
25
+
26
+ ## Development & Tooling
27
+ - **Package Manager**: `uv`
28
+ - **Minimum Python**: 3.12
29
+ - **Testing**: `pytest`
30
+ - **Commands**:
31
+ - Add dependencies: `uv add <package>`
32
+ - Run tests: `uv run pytest`
33
+
34
+ ## Usage Example
35
+ ```python
36
+ from offline_debug import save_traceback, load_traceback
37
+
38
+ try:
39
+ # Code that might fail
40
+ some_complex_operation()
41
+ except Exception as e:
42
+ save_traceback(e, "crash_report.dump")
43
+
44
+ # To debug or re-examine later:
45
+ load_traceback("crash_report.dump")
46
+ ```
@@ -0,0 +1,5 @@
1
+ """Tool for serializing and reconstructing Python exceptions with full stack traces."""
2
+
3
+ from .serializer import load_traceback, save_traceback
4
+
5
+ __all__ = ["load_traceback", "save_traceback"]
@@ -0,0 +1,297 @@
1
+ """Functions for serializing and reconstructing exceptions with their tracebacks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ import marshal
7
+ import pickle
8
+ import sys
9
+ import types
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any, Never
13
+
14
+ # Define C API for frame creation
15
+ _py_frame_new = ctypes.pythonapi.PyFrame_New
16
+ _py_frame_new.argtypes = (
17
+ ctypes.c_void_p, # PyThreadState *tstate
18
+ ctypes.py_object, # PyCodeObject *code
19
+ ctypes.py_object, # PyObject *globals
20
+ ctypes.py_object, # PyObject *locals
21
+ )
22
+ _py_frame_new.restype = ctypes.py_object
23
+
24
+ _py_thread_state_get = ctypes.pythonapi.PyThreadState_Get
25
+ _py_thread_state_get.restype = ctypes.c_void_p
26
+
27
+ _py_incref = ctypes.pythonapi.Py_IncRef
28
+ _py_incref.argtypes = (ctypes.py_object,)
29
+
30
+ _py_decref = ctypes.pythonapi.Py_DecRef
31
+ _py_decref.argtypes = (ctypes.py_object,)
32
+
33
+
34
+ def _get_f_back_offset() -> int | None:
35
+ """Dynamically discover the memory offset of f_back in PyFrameObject."""
36
+ try:
37
+ tstate = _py_thread_state_get()
38
+ # Compile a dummy code object that we can use to create a frame.
39
+ code = compile("pass", "<discovery>", "exec")
40
+ # Create a new, detached frame object using the C API.
41
+ frame = _py_frame_new(tstate, code, {}, {})
42
+ if not isinstance(frame, types.FrameType):
43
+ return None
44
+
45
+ # We need a target frame object to point to.
46
+ target = sys._getframe() # noqa: SLF001
47
+ target_addr = id(target)
48
+
49
+ # We scan the frame object's memory for the f_back pointer.
50
+ # We cap the scan at the object's actual size to avoid out-of-bounds reads.
51
+ limit = sys.getsizeof(frame)
52
+ ptr_size = ctypes.sizeof(ctypes.c_void_p)
53
+
54
+ # We start scanning after the PyObject header (refcnt + type).
55
+ for offset in range(2 * ptr_size, limit - ptr_size + 1, ptr_size):
56
+ try:
57
+ # We use c_ssize_t to read the raw value at the offset.
58
+ current_val = ctypes.c_ssize_t.from_address(id(frame) + offset).value
59
+ # f_back is initially NULL (0) in a newly created frame.
60
+ if current_val == 0:
61
+ ctypes.c_ssize_t.from_address(id(frame) + offset).value = target_addr
62
+ # If reading f_back via Python now returns our target, we found it.
63
+ if frame.f_back is target:
64
+ # Success, but we must restore 0 so we don't mess up refcounts
65
+ # when 'frame' is eventually garbage collected.
66
+ ctypes.c_ssize_t.from_address(id(frame) + offset).value = 0
67
+ return offset
68
+ # Restore to 0 if this wasn't the correct offset.
69
+ ctypes.c_ssize_t.from_address(id(frame) + offset).value = 0
70
+ except (AttributeError, ValueError, TypeError, RuntimeError):
71
+ continue
72
+ except Exception: # noqa: BLE001
73
+ return None
74
+ return None
75
+
76
+
77
+ _F_BACK_OFFSET = _get_f_back_offset()
78
+
79
+
80
+ def _link_frame(frame: types.FrameType, back: types.FrameType) -> None:
81
+ """Link a frame to its parent frame using the discovered offset."""
82
+ if _F_BACK_OFFSET is None:
83
+ return
84
+
85
+ # In Python, setting f_back means the child frame now owns a reference
86
+ # to the parent frame. We must increment the reference count of the
87
+ # parent to reflect this.
88
+ _py_incref(back)
89
+
90
+ # Use ctypes to write the address of the back frame into the discovered offset.
91
+ ptr = ctypes.c_void_p.from_address(id(frame) + _F_BACK_OFFSET)
92
+ ptr.value = id(back)
93
+
94
+
95
+ # Internal attributes that are either unpicklable or redundant in a new process.
96
+ # We exclude these specifically because they are automatically recreated
97
+ # when the new frame is initialized or when the module is imported.
98
+ _INTERNAL_ATTRIBUTES_TO_SKIP = ("__builtins__", "__doc__", "__loader__", "__package__", "__spec__")
99
+
100
+
101
+ @dataclass
102
+ class _FrameData:
103
+ """Serialized data for a single stack frame."""
104
+
105
+ code: bytes
106
+ globals: dict[str, Any]
107
+ locals: dict[str, Any]
108
+ lasti: int
109
+ lineno: int
110
+ stack_depth: int
111
+
112
+
113
+ @dataclass
114
+ class _ExceptionData:
115
+ """Serialized data for an exception and its traceback."""
116
+
117
+ exc_pickle: bytes
118
+ tb_frames: list[_FrameData]
119
+ cause: _ExceptionData | None = None
120
+ context: _ExceptionData | None = None
121
+
122
+
123
+ def _get_stack_depth(frame: types.FrameType) -> int:
124
+ """Calculate the depth of the current stack frame."""
125
+ depth = 0
126
+ curr: types.FrameType | None = frame
127
+ while curr:
128
+ depth += 1
129
+ curr = curr.f_back
130
+ return depth
131
+
132
+
133
+ def _filter_dict(d: dict) -> dict:
134
+ """Filter dictionary to include only picklable items."""
135
+ result = {}
136
+ for k, v in d.items():
137
+ if k in _INTERNAL_ATTRIBUTES_TO_SKIP:
138
+ continue
139
+ try:
140
+ # We must verify if the value is picklable because many globals
141
+ # (like open file handles, database connections, or modules)
142
+ # cannot be saved to disk.
143
+ pickle.dumps(v)
144
+ result[k] = v
145
+ except Exception: # noqa: BLE001
146
+ result[k] = f"<unpicklable {type(v).__name__}: {v!r}>"
147
+ return result
148
+
149
+
150
+ def _serialize_exc_data(exc: BaseException) -> _ExceptionData:
151
+ """Recursively serialize exception data into dataclasses."""
152
+ tb_frames: list[_FrameData] = []
153
+ curr_tb = exc.__traceback__
154
+ while curr_tb:
155
+ f = curr_tb.tb_frame
156
+ tb_frames.append(
157
+ _FrameData(
158
+ code=marshal.dumps(f.f_code),
159
+ globals=_filter_dict(f.f_globals),
160
+ locals=_filter_dict(f.f_locals),
161
+ lasti=curr_tb.tb_lasti,
162
+ lineno=curr_tb.tb_lineno,
163
+ stack_depth=_get_stack_depth(f),
164
+ )
165
+ )
166
+ curr_tb = curr_tb.tb_next
167
+
168
+ try:
169
+ exc_pickle = pickle.dumps(exc)
170
+ except Exception: # noqa: BLE001
171
+ exc_pickle = pickle.dumps(
172
+ RuntimeError(f"Unpicklable exception {type(exc).__name__}: {exc!s}")
173
+ )
174
+
175
+ return _ExceptionData(
176
+ exc_pickle=exc_pickle,
177
+ tb_frames=tb_frames,
178
+ cause=_serialize_exc_data(exc.__cause__) if exc.__cause__ else None,
179
+ context=_serialize_exc_data(exc.__context__) if exc.__context__ else None,
180
+ )
181
+
182
+
183
+ def save_traceback(exc: BaseException, file_path: str | Path) -> None:
184
+ """Serialize an exception and its traceback to a file."""
185
+ data = _serialize_exc_data(exc)
186
+ with Path(file_path).open("wb") as f:
187
+ pickle.dump(data, f)
188
+
189
+
190
+ def _reconstruct_exc_data(data: _ExceptionData) -> BaseException:
191
+ """
192
+ Recursively reconstruct an exception from its serialized data.
193
+
194
+ Note on Python Locals:
195
+ Python uses two ways to store local variables:
196
+ 1. "Slow" locals: A dictionary used for module-level code and class definitions.
197
+ 2. "Fast" locals: A fixed-size array used for functions. This is faster than
198
+ dictionary lookups because variables are accessed by index.
199
+
200
+ During reconstruction, we must explicitly synchronize these because PyFrame_New
201
+ does not automatically populate the "fast" locals array from a dictionary.
202
+ """
203
+ exc = pickle.loads(data.exc_pickle) # noqa: S301
204
+ if not isinstance(exc, BaseException):
205
+ msg = f"Expected BaseException, but got {type(exc).__name__}"
206
+ raise TypeError(msg)
207
+
208
+ tstate = _py_thread_state_get()
209
+
210
+ reconstructed_frames: list[tuple[types.FrameType, _FrameData]] = []
211
+ prev_frame: types.FrameType | None = None
212
+ for f_data in data.tb_frames:
213
+ code = marshal.loads(f_data.code) # noqa: S302
214
+
215
+ # In Python 3.11 and 3.12, accessing f_locals on a frame created via
216
+ # PyFrame_New for optimized code (functions) causes a segmentation fault
217
+ # because the internal 'fast' locals array is not initialized.
218
+ # As a workaround, we create a 'non-optimized' version of the code object
219
+ # by compiling a dummy string. This ensures the bytecode is safe
220
+ # (no LOAD_FAST) while preserving metadata like name and filename.
221
+ if sys.version_info < (3, 13):
222
+ # A simple module-level code object never has fast locals.
223
+ dummy_code = compile("", code.co_filename, "exec")
224
+ code = dummy_code.replace(
225
+ co_name=code.co_name,
226
+ co_firstlineno=code.co_firstlineno,
227
+ co_qualname=code.co_qualname,
228
+ )
229
+
230
+ # PyFrame_New returns a new reference to a PyFrameObject.
231
+ frame = _py_frame_new(tstate, code, f_data.globals, f_data.locals)
232
+ if not isinstance(frame, types.FrameType):
233
+ msg = f"Expected types.FrameType, but got {type(frame).__name__}"
234
+ raise TypeError(msg)
235
+
236
+ # In 3.13+, PEP 667 allows safe write-through access to locals.
237
+ if sys.version_info >= (3, 13) and f_data.locals:
238
+ frame.f_locals.update(f_data.locals)
239
+
240
+ if prev_frame:
241
+ _link_frame(frame, prev_frame)
242
+
243
+ reconstructed_frames.append((frame, f_data))
244
+ prev_frame = frame
245
+
246
+ tb_next: types.TracebackType | None = None
247
+ for frame, f_data in reversed(reconstructed_frames):
248
+ tb = types.TracebackType(
249
+ tb_next=tb_next,
250
+ tb_frame=frame,
251
+ tb_lasti=f_data.lasti,
252
+ tb_lineno=f_data.lineno,
253
+ )
254
+ tb_next = tb
255
+
256
+ exc = exc.with_traceback(tb_next)
257
+
258
+ if data.cause:
259
+ exc.__cause__ = _reconstruct_exc_data(data.cause)
260
+ if data.context:
261
+ exc.__context__ = _reconstruct_exc_data(data.context)
262
+
263
+ return exc
264
+
265
+
266
+ def load_traceback(file_path: str | Path) -> Never:
267
+ """Load an exception and its traceback from a file and raise it."""
268
+ with Path(file_path).open("rb") as f:
269
+ data = pickle.load(f) # noqa: S301
270
+
271
+ if not isinstance(data, _ExceptionData):
272
+ msg = f"Expected _ExceptionData, but got {type(data).__name__}"
273
+ raise TypeError(msg)
274
+
275
+ exc = _reconstruct_exc_data(data)
276
+
277
+ current_frames: list[types.FrameType] = []
278
+ curr: types.FrameType | None = sys._getframe(1) # noqa: SLF001
279
+ while curr:
280
+ current_frames.append(curr)
281
+ curr = curr.f_back
282
+
283
+ if exc.__traceback__ and current_frames:
284
+ reconstructed_outer = exc.__traceback__.tb_frame
285
+ _link_frame(reconstructed_outer, current_frames[0])
286
+
287
+ tb_chain: types.TracebackType | None = exc.__traceback__
288
+ for frame in current_frames:
289
+ tb_chain = types.TracebackType(
290
+ tb_next=tb_chain,
291
+ tb_frame=frame,
292
+ tb_lasti=frame.f_lasti,
293
+ tb_lineno=frame.f_lineno,
294
+ )
295
+
296
+ exc = exc.with_traceback(tb_chain)
297
+ raise exc
@@ -0,0 +1,65 @@
1
+ [project]
2
+ name = "offline-debug"
3
+ version = "0.1.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 sys.version_info < \\(3, 13\\).*",
64
+ "if sys.version_info >= \\(3, 13\\).*",
65
+ ]