diff --git a/offline_debug/__init__.py b/offline_debug/__init__.py index ca4b6bb..5289e70 100644 --- a/offline_debug/__init__.py +++ b/offline_debug/__init__.py @@ -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"] diff --git a/offline_debug/_inner/load_traceback.py b/offline_debug/_inner/load_traceback.py index 4fdf0e3..ebc3cbe 100644 --- a/offline_debug/_inner/load_traceback.py +++ b/offline_debug/_inner/load_traceback.py @@ -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. @@ -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: @@ -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) @@ -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 diff --git a/offline_debug/_inner/models.py b/offline_debug/_inner/models.py index 8999761..3a567c7 100644 --- a/offline_debug/_inner/models.py +++ b/offline_debug/_inner/models.py @@ -7,7 +7,7 @@ @dataclass -class _FrameData: +class FrameData: """Serialized data for a single stack frame.""" code: bytes @@ -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 diff --git a/offline_debug/_inner/save_traceback.py b/offline_debug/_inner/save_traceback.py index f623fab..6bf184f 100644 --- a/offline_debug/_inner/save_traceback.py +++ b/offline_debug/_inner/save_traceback.py @@ -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 @@ -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 @@ -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), @@ -77,7 +77,7 @@ 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, @@ -85,9 +85,12 @@ def _serialize_exc_data(exc: BaseException) -> _ExceptionData: ) -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) @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 8408381..c3d8925 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/tests/_inner/test_load_traceback.py b/tests/_inner/test_load_traceback.py index 0826fc5..35f1e78 100644 --- a/tests/_inner/test_load_traceback.py +++ b/tests/_inner/test_load_traceback.py @@ -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 @@ -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 @@ -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=[], ) @@ -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") @@ -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={}, @@ -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" diff --git a/tests/_inner/test_save_traceback.py b/tests/_inner/test_save_traceback.py index 2244729..fa5b8f4 100644 --- a/tests/_inner/test_save_traceback.py +++ b/tests/_inner/test_save_traceback.py @@ -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 @@ -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)