Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions offline_debug/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tool for serializing and reconstructing Python exceptions with full stack traces."""

from ._inner.load_traceback import load_traceback
from ._inner.load_traceback import load_traceback, parse_traceback
from ._inner.models import ExceptionData, FrameData
from ._inner.save_traceback import save_traceback

__all__ = ["load_traceback", "save_traceback"]
__all__ = ["ExceptionData", "FrameData", "load_traceback", "parse_traceback", "save_traceback"]
45 changes: 25 additions & 20 deletions offline_debug/_inner/load_traceback.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,18 @@
from io import BytesIO
from pathlib import Path
from types import CodeType
from typing import Never

from offline_debug._inner.c_api import (
create_frame,
link_frame,
)
from offline_debug._inner.models import (
_ExceptionData,
_FrameData,
ExceptionData,
FrameData,
)


def _reconstruct_exc_data(data: _ExceptionData) -> BaseException:
def _reconstruct_exc_data(data: ExceptionData) -> BaseException:
"""
Recursively reconstruct an exception from its serialized data.

Expand All @@ -37,26 +36,25 @@ def _reconstruct_exc_data(data: _ExceptionData) -> BaseException:
msg = f"Expected BaseException, but got {type(exc).__name__}"
raise TypeError(msg)

reconstructed_frames: list[tuple[types.FrameType, _FrameData]] = []
reconstructed_frames: list[tuple[types.FrameType, FrameData]] = []
for f_data in data.tb_frames:
code: CodeType = marshal.loads(f_data.code) # noqa: S302

# In Python 3.11 and 3.12, accessing f_locals on a frame created via
# In Python 3.11+, accessing f_locals on a frame created via
# PyFrame_New for optimized code (functions) causes a segmentation fault
# because the internal 'fast' locals array is not initialized.
# As a workaround, we create a 'non-optimized' version of the code object
# by compiling a dummy string. This ensures the bytecode is safe
# (no LOAD_FAST) while preserving metadata like name and filename.
if sys.version_info < (3, 13):
# A simple module-level code object never has fast locals.
# Since the source is empty, no optimized locals will be created.
# Instead, python will go to the unoptimized dictionary we set under frame_locals later.
unoptimized_code = compile("", code.co_filename, "exec")
code = unoptimized_code.replace(
co_name=code.co_name,
co_firstlineno=code.co_firstlineno,
co_qualname=code.co_qualname,
)
# A simple module-level code object never has fast locals.
# Since the source is empty, no optimized locals will be created.
# Instead, python will go to the unoptimized dictionary we set under frame_locals later.
unoptimized_code = compile("", code.co_filename, "exec")
code = unoptimized_code.replace(
co_name=code.co_name,
co_firstlineno=code.co_firstlineno,
co_qualname=code.co_qualname,
)

# PyFrame_New returns a new reference to a PyFrameObject.
if f_data.module_name:
Expand Down Expand Up @@ -92,17 +90,22 @@ def _reconstruct_exc_data(data: _ExceptionData) -> BaseException:
return exc


def load_traceback(file: Path | BytesIO) -> Never:
"""Load an exception and its traceback from a file and raise it."""
def parse_traceback(file: Path | BytesIO) -> ExceptionData:
if isinstance(file, Path):
with file.open("rb") as f:
data = pickle.load(f) # noqa: S301
else:
data = pickle.load(file) # noqa: S301

if not isinstance(data, _ExceptionData):
if not isinstance(data, ExceptionData):
msg = f"Expected _ExceptionData, but got {type(data).__name__}"
raise TypeError(msg)
return data


def load_traceback(file: Path | BytesIO, should_raise: bool = True) -> BaseException: # noqa: FBT001, FBT002
"""Load an exception and its traceback from a file and raise it."""
data = parse_traceback(file)

exc = _reconstruct_exc_data(data)

Expand All @@ -126,4 +129,6 @@ def load_traceback(file: Path | BytesIO) -> Never:
)

exc = exc.with_traceback(tb_chain)
raise exc
if should_raise:
raise exc
return exc
10 changes: 5 additions & 5 deletions offline_debug/_inner/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@


@dataclass
class _FrameData:
class FrameData:
"""Serialized data for a single stack frame."""

code: bytes
Expand All @@ -20,10 +20,10 @@ class _FrameData:


@dataclass
class _ExceptionData:
class ExceptionData:
"""Serialized data for an exception and its traceback."""

exc_pickle: bytes
tb_frames: list[_FrameData]
cause: _ExceptionData | None = None
context: _ExceptionData | None = None
tb_frames: list[FrameData]
cause: ExceptionData | None = None
context: ExceptionData | None = None
16 changes: 10 additions & 6 deletions offline_debug/_inner/save_traceback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, FrameData

# Internal attributes that are either unpicklable or redundant in a new process.
# We exclude these specifically because they are automatically recreated
Expand Down Expand Up @@ -41,9 +41,9 @@ def _filter_dict(d: dict) -> dict:
return result


def _serialize_exc_data(exc: BaseException) -> _ExceptionData:
def _serialize_exc_data(exc: BaseException) -> ExceptionData:
"""Recursively serialize exception data into dataclasses."""
tb_frames: list[_FrameData] = []
tb_frames: list[FrameData] = []
curr_tb = exc.__traceback__
while curr_tb:
f = curr_tb.tb_frame
Expand All @@ -58,7 +58,7 @@ def _serialize_exc_data(exc: BaseException) -> _ExceptionData:
mod_name = spec.name

tb_frames.append(
_FrameData(
FrameData(
code=marshal.dumps(f.f_code),
globals=_filter_dict(f.f_globals),
locals=_filter_dict(f.f_locals),
Expand All @@ -77,17 +77,20 @@ def _serialize_exc_data(exc: BaseException) -> _ExceptionData:
RuntimeError(f"Unpicklable exception {type(exc).__name__}: {exc!s}")
)

return _ExceptionData(
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,
)


def save_traceback(exc: BaseException, file: Path | BytesIO) -> None:
def save_traceback(exc: BaseException, file: Path | BytesIO | None) -> ExceptionData:
"""Serialize an exception and its traceback to a file."""
data = _serialize_exc_data(exc)
if file is None:
return data

if isinstance(file, Path):
with file.open("wb") as f:
pickle.dump(data, f)
Expand All @@ -96,3 +99,4 @@ def save_traceback(exc: BaseException, file: Path | BytesIO) -> None:
else:
msg = f"Unexpected type for file {type(file).__name__}"
raise TypeError(msg)
return data
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
[project]
name = "offline-debug"
version = "0.2.0"
version = "0.2.1"
description = "Debug exceptions offline by saving them to a dump and raising them at a later point."
readme = "README.md"
authors = [
{ name = "Itai Elidan", email = "itaielidan@gmail.com" }
]
requires-python = ">=3.12"
requires-python = ">=3.12, <3.14"
dependencies = [
"typing-extensions>=4.15.0",
]
Expand Down
35 changes: 19 additions & 16 deletions tests/_inner/test_load_traceback.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@

from __future__ import annotations

from io import BytesIO
from pathlib import Path
from typing import TYPE_CHECKING

import pytest

from offline_debug import load_traceback
from offline_debug import load_traceback, save_traceback

if TYPE_CHECKING:
import types
Expand All @@ -23,16 +24,6 @@ def get_frames(tb: types.TracebackType | None) -> list[types.FrameType]:
return frames


def test_typing_never() -> None:
"""Test that load_traceback is correctly annotated with Never."""
import typing

from offline_debug import load_traceback

load_traceback_annotations = typing.get_type_hints(load_traceback)
assert load_traceback_annotations["return"] is typing.Never


def test_load_invalid_object(tmp_path: Path) -> None:
"""Test that load_traceback raises TypeError when loading an invalid object."""
import pickle
Expand All @@ -56,9 +47,9 @@ def test_reconstruct_invalid_exception_type() -> None:
import pickle

from offline_debug._inner.load_traceback import _reconstruct_exc_data
from offline_debug._inner.models import _ExceptionData
from offline_debug._inner.models import ExceptionData

data = _ExceptionData(
data = ExceptionData(
exc_pickle=pickle.dumps("not an exception"),
tb_frames=[],
)
Expand All @@ -71,7 +62,7 @@ def test_reconstruct_invalid_frame_type(monkeypatch) -> None:
"""Test that _reconstruct_exc_data raises TypeError when frame creation fails."""
import offline_debug._inner.c_api._create_frame as _create_frame_module
from offline_debug._inner.load_traceback import _reconstruct_exc_data
from offline_debug._inner.models import _ExceptionData, _FrameData
from offline_debug._inner.models import ExceptionData, FrameData

# Mock _get_py_frame_new to return a function that returns something that is not a FrameType
monkeypatch.setattr(_create_frame_module, "_get_py_frame_new", lambda: lambda *_: "not a frame")
Expand All @@ -82,10 +73,10 @@ def test_reconstruct_invalid_frame_type(monkeypatch) -> None:
def dummy() -> None:
pass

data = _ExceptionData(
data = ExceptionData(
exc_pickle=pickle.dumps(ValueError("test")),
tb_frames=[
_FrameData(
FrameData(
code=marshal.dumps(dummy.__code__),
globals={},
locals={},
Expand All @@ -98,3 +89,15 @@ def dummy() -> None:

with pytest.raises(TypeError, match=r"Expected types.FrameType, but got str"):
_reconstruct_exc_data(data)


def test_load_traceback_should_raise_false() -> None:
"""Test load_traceback when should_raise is False."""
buffer = BytesIO()
exc = ValueError("test_raise_false")
save_traceback(exc, buffer)
buffer.seek(0)

loaded_exc = load_traceback(buffer, should_raise=False)
assert isinstance(loaded_exc, ValueError)
assert str(loaded_exc) == "test_raise_false"
9 changes: 8 additions & 1 deletion tests/_inner/test_save_traceback.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import pytest

from offline_debug import load_traceback, save_traceback
from offline_debug import ExceptionData, load_traceback, save_traceback

if TYPE_CHECKING:
from collections.abc import Callable
Expand Down Expand Up @@ -162,3 +162,10 @@ def raise_unpicklable() -> Never:

with pytest.raises(RuntimeError, match="Unpicklable exception UnpicklableError: Unpicklable"):
load_traceback(dump_file)


def test_save_traceback_file_none() -> None:
"""Test save_traceback when file is None."""
exc = ValueError("test")
data = save_traceback(exc, None)
assert isinstance(data, ExceptionData)
Loading