From fa2c1cadd13f97b24c4990ba73e7236dcb71159e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 13:21:24 +0000 Subject: [PATCH 01/18] Fix exponential and non-terminating traversal of exception graphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `__cause__`/`__context__` form an arbitrary directed graph, not a tree, but both the save and load paths walked them recursively as if they were one. That produced two distinct failures: - Cycles never terminated. `raise e from e` makes an exception its own cause, and longer cycles are equally reachable (mutual `__cause__` links, a manually assigned `__context__`, a group whose cause is itself). Every one of these died with a RecursionError. - Shared nodes were re-expanded exponentially. `raise X from Y` sets *both* `X.__cause__` and `X.__context__` to `Y`, so the ordinary chaining idiom produces a diamond. Walking it as a tree revisits the shared node down both edges, making a chain of n chained exceptions cost 2**n: measured at 2**(n+1)-1 nodes, with a 16-link chain producing 131071 nodes and a 122 MB dump. This terminated, which is why it surfaced as a load that ran for hours rather than as an error. Both paths now memoize on identity and walk iteratively: - `_serialize_exc_data` builds each node once and links them with a worklist, so the dump mirrors the original graph — a cycle stays a cycle and a node reachable twice stays one shared node. pickle reproduces that sharing. - `_reconstruct_exc_data` builds every exception first, then wires up `__cause__`/`__context__` from the identity map. Deferring the links is what lets a cycle be restored as a genuine cycle instead of an endless chain of copies. Group members are ordered before their group, since a group can only be built once its members exist. Being iterative also removes the recursion-depth ceiling on legitimately deep cause chains, which previously failed well before any cycle was involved. Tests cover both shapes; all nine fail on the previous implementation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CJBKuuFRsXohzgomc7sLiy --- offline_debug/_inner/load_traceback.py | 109 +++++++++--- offline_debug/_inner/save_traceback.py | 77 +++++++-- pyproject.toml | 1 + tests/manual_tests/global_debug.py | 2 +- tests/test_exception_cycles.py | 223 +++++++++++++++++++++++++ 5 files changed, 368 insertions(+), 44 deletions(-) create mode 100644 tests/test_exception_cycles.py diff --git a/offline_debug/_inner/load_traceback.py b/offline_debug/_inner/load_traceback.py index 88d156a..469bed1 100644 --- a/offline_debug/_inner/load_traceback.py +++ b/offline_debug/_inner/load_traceback.py @@ -20,9 +20,9 @@ ) -def _reconstruct_exc_data(data: ExceptionData) -> BaseException: +def _reconstruct_frames(data: ExceptionData) -> types.TracebackType | None: """ - Recursively reconstruct an exception from its serialized data. + Rebuild the traceback of a single exception from its serialized frames. Note on Python Locals: Python uses two ways to store local variables: @@ -33,21 +33,6 @@ def _reconstruct_exc_data(data: ExceptionData) -> BaseException: During reconstruction, we must explicitly synchronize these because PyFrame_New does not automatically populate the "fast" locals array from a dictionary. """ - exc: BaseException = pickle.loads(data.exc_pickle) # noqa: S301 - if not isinstance(exc, BaseException): - msg = f"Expected BaseException, but got {type(exc).__name__}" - raise TypeError(msg) - - if isinstance(data, ExceptionGroupData) and isinstance(exc, BaseExceptionGroup): - inner_excs = [_reconstruct_exc_data(e) for e in data.exceptions] - # The exceptions inside the unpickled exc object have incomplete data, so - # rebuild the group around the fully reconstructed ones. We must not use - # derive() for this: its default implementation returns a plain - # ExceptionGroup, dropping the subclass type and its custom state. - exc = reconstruct_exception_group( - type(exc), exc.message, tuple(inner_excs), exc.__dict__.copy() or None - ) - reconstructed_frames: list[tuple[types.FrameType, FrameData]] = [] for f_data in data.tb_frames: code: CodeType = marshal.loads(f_data.code) # noqa: S302 @@ -84,22 +69,96 @@ def _reconstruct_exc_data(data: ExceptionData) -> BaseException: tb_next: types.TracebackType | None = None for frame, f_data in reversed(reconstructed_frames): - tb = types.TracebackType( + tb_next = types.TracebackType( tb_next=tb_next, tb_frame=frame, tb_lasti=f_data.lasti, tb_lineno=f_data.lineno, ) - tb_next = tb + return tb_next - exc = exc.with_traceback(tb_next) - if data.cause: - exc.__cause__ = _reconstruct_exc_data(data.cause) - if data.context: - exc.__context__ = _reconstruct_exc_data(data.context) +def _collect_nodes(root: ExceptionData) -> list[ExceptionData]: + """ + List every node reachable from ``root``, sub-exceptions always before their group. - return exc + A group can only be rebuilt once its members exist, whereas ``cause``/``context`` are + assigned after the fact, so ``exceptions`` is the only edge that constrains build + order. The walk is iterative and identity-memoized because the graph may contain + cycles (see :func:`_reconstruct_exc_data`). + """ + seen: set[int] = set() + order: list[ExceptionData] = [] + stack: list[tuple[ExceptionData, bool]] = [(root, False)] + + while stack: + node, children_done = stack.pop() + if children_done: + order.append(node) + continue + if id(node) in seen: + continue + seen.add(id(node)) + # Re-push the node below its children so it is emitted after them. + stack.append((node, True)) + if isinstance(node, ExceptionGroupData): + stack.extend((sub, False) for sub in node.exceptions) + stack.extend((link, False) for link in (node.cause, node.context) if link is not None) + + return order + + +def _build_exception(data: ExceptionData, built: dict[int, BaseException]) -> BaseException: + """Rebuild one exception with its traceback, taking sub-exceptions from ``built``.""" + exc: BaseException = pickle.loads(data.exc_pickle) # noqa: S301 + if not isinstance(exc, BaseException): + msg = f"Expected BaseException, but got {type(exc).__name__}" + raise TypeError(msg) + + if isinstance(data, ExceptionGroupData) and isinstance(exc, BaseExceptionGroup): + try: + inner_excs = [built[id(sub)] for sub in data.exceptions] + except KeyError: + # Only reachable from a hand-crafted dump: a group that (transitively) + # contains itself cannot be built, since its members must exist first. + msg = "Cannot reconstruct an exception group that contains itself" + raise ValueError(msg) from None + # The exceptions inside the unpickled exc object have incomplete data, so + # rebuild the group around the fully reconstructed ones. We must not use + # derive() for this: its default implementation returns a plain + # ExceptionGroup, dropping the subclass type and its custom state. + exc = reconstruct_exception_group( + type(exc), exc.message, tuple(inner_excs), exc.__dict__.copy() or None + ) + + return exc.with_traceback(_reconstruct_frames(data)) + + +def _reconstruct_exc_data(data: ExceptionData) -> BaseException: + """ + Reconstruct an exception graph, restoring shared and cyclic references. + + ``save_traceback`` records the exception graph as-is, so ``cause``/``context`` may + revisit a node or point back at it (``raise e from e``). Reconstruction therefore + runs in two passes: first every exception is built, then the ``__cause__``/ + ``__context__`` links are wired up from the identity map. Doing the links second is + what lets a cycle be restored as a genuine cycle instead of an endless chain of + copies, and it keeps a node that is reachable twice a single shared object. + """ + nodes = _collect_nodes(data) + + built: dict[int, BaseException] = {} + for node in nodes: + built[id(node)] = _build_exception(node, built) + + for node in nodes: + exc = built[id(node)] + if node.cause is not None: + exc.__cause__ = built[id(node.cause)] + if node.context is not None: + exc.__context__ = built[id(node.context)] + + return built[id(data)] def parse_traceback(file: Path | BytesIO) -> ExceptionData: diff --git a/offline_debug/_inner/save_traceback.py b/offline_debug/_inner/save_traceback.py index 46f8358..42d84ad 100644 --- a/offline_debug/_inner/save_traceback.py +++ b/offline_debug/_inner/save_traceback.py @@ -58,10 +58,10 @@ def _filter_dict(d: dict, roundtrip_cache: dict[int, str | None]) -> dict: return result -def _serialize_exc_data( +def _serialize_frames( exc: BaseException, roundtrip_cache: dict[int, str | None] -) -> ExceptionData: - """Recursively serialize exception data into dataclasses.""" +) -> list[FrameData]: + """Serialize every frame of ``exc``'s own traceback.""" tb_frames: list[FrameData] = [] curr_tb = exc.__traceback__ while curr_tb: @@ -88,7 +88,11 @@ def _serialize_exc_data( ) ) curr_tb = curr_tb.tb_next + return tb_frames + +def _pickle_exception(exc: BaseException) -> bytes: + """Pickle ``exc`` itself, falling back to a placeholder if it cannot round-trip.""" try: exc_pickle = exception_safe_dumps(exc) # A dump that cannot be loaded later is worse than a placeholder, so also @@ -99,25 +103,62 @@ def _serialize_exc_data( exc_pickle = exception_safe_dumps( RuntimeError(f"Unpicklable exception {type(exc).__name__}: {exc!s}") ) + return exc_pickle - cause = _serialize_exc_data(exc.__cause__, roundtrip_cache) if exc.__cause__ else None - context = _serialize_exc_data(exc.__context__, roundtrip_cache) if exc.__context__ else None + +def _build_exc_node(exc: BaseException, roundtrip_cache: dict[int, str | None]) -> ExceptionData: + """Build the node for a single exception, without following its links.""" + exc_pickle = _pickle_exception(exc) + tb_frames = _serialize_frames(exc, roundtrip_cache) if isinstance(exc, BaseExceptionGroup): - return ExceptionGroupData( - exc_pickle=exc_pickle, - tb_frames=tb_frames, - cause=cause, - context=context, - exceptions=[_serialize_exc_data(e, roundtrip_cache) for e in exc.exceptions], - ) + return ExceptionGroupData(exc_pickle=exc_pickle, tb_frames=tb_frames, exceptions=[]) + return ExceptionData(exc_pickle=exc_pickle, tb_frames=tb_frames) + - return ExceptionData( - exc_pickle=exc_pickle, - tb_frames=tb_frames, - cause=cause, - context=context, - ) +def _serialize_exc_data( + exc: BaseException, roundtrip_cache: dict[int, str | None] +) -> ExceptionData: + """ + Serialize an exception graph, preserving shared and cyclic references. + + ``__cause__``/``__context__`` are not guaranteed to form a tree: ``raise e from e`` + makes an exception its own cause, and the links can equally form longer cycles or + simply revisit the same exception twice. Walking them recursively therefore either + never terminates or re-serializes the same exception over and over, so we walk the + graph iteratively and memoize each exception by identity. + + Memoizing does more than break cycles: because every exception is serialized exactly + once, the resulting node graph mirrors the original one (a cycle stays a cycle, and an + exception reachable as both cause and context stays a single shared node). ``pickle`` + reproduces that sharing faithfully on load. + + Keying the memo on ``id()`` is safe because every exception we visit is reachable from + ``exc`` through strong ``__cause__``/``__context__``/``exceptions`` references, so none + of them can be collected (and have its id reused) while the walk is in progress. + """ + memo: dict[int, ExceptionData] = {} + pending: list[BaseException] = [] + + def node_for(e: BaseException) -> ExceptionData: + node = memo.get(id(e)) + if node is None: + node = memo[id(e)] = _build_exc_node(e, roundtrip_cache) + pending.append(e) + return node + + root = node_for(exc) + while pending: + curr = pending.pop() + node = memo[id(curr)] + if curr.__cause__ is not None: + node.cause = node_for(curr.__cause__) + if curr.__context__ is not None: + node.context = node_for(curr.__context__) + if isinstance(curr, BaseExceptionGroup) and isinstance(node, ExceptionGroupData): + node.exceptions = [node_for(sub) for sub in curr.exceptions] + + return root def save_traceback(exc: BaseException, file: Path | BytesIO | None) -> ExceptionData: diff --git a/pyproject.toml b/pyproject.toml index a5bfa9b..3742c94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ ignore = [ "PLW0603", # Using the global statement "TRY003", # Avoid specifying long messages outside the exception class "EM101", # Exception must not use a string literal + "TRY301", # Abstract raise to an inner function - tests must raise inline to build real tracebacks ] [build-system] diff --git a/tests/manual_tests/global_debug.py b/tests/manual_tests/global_debug.py index bdebc69..dd45010 100644 --- a/tests/manual_tests/global_debug.py +++ b/tests/manual_tests/global_debug.py @@ -7,7 +7,7 @@ if __name__ == "__main__": try: - raise ValueError("Trigger") # noqa: TRY301 + raise ValueError("Trigger") except ValueError as e: with tempfile.TemporaryDirectory() as tmpdir: dump_file = Path(tmpdir) / "exception.dump" diff --git a/tests/test_exception_cycles.py b/tests/test_exception_cycles.py new file mode 100644 index 0000000..6a13371 --- /dev/null +++ b/tests/test_exception_cycles.py @@ -0,0 +1,223 @@ +""" +Regression tests for exception graphs that are not trees. + +``__cause__``/``__context__`` form an arbitrary directed graph, not a tree: + +- ``raise e from e`` makes an exception its own cause, and cycles can equally + span several exceptions. Traversing those recursively never terminates. +- ``raise X from Y`` sets *both* ``X.__cause__`` and ``X.__context__`` to ``Y``. + Traversing that as a tree revisits the shared node down both edges, so a chain + of ``n`` chained exceptions costs ``2**n`` — it terminates, but only after + hours once ``n`` passes ~20, and it writes an equally oversized dump. + +Both are fixed by memoizing on identity, so every exception is visited once and +the saved graph mirrors the original one. +""" + +from __future__ import annotations + +import sys +from io import BytesIO +from typing import TYPE_CHECKING + +from offline_debug import ExceptionData, load_traceback, parse_traceback, save_traceback + +if TYPE_CHECKING: + from collections.abc import Iterator + +MAX_NODES_PER_LINK = 2 +DEEP_CHAIN_LINKS = 400 + + +def roundtrip(exc: BaseException) -> BaseException: + """Save an exception to a buffer and load it back without raising.""" + buffer = BytesIO() + save_traceback(exc, buffer) + buffer.seek(0) + return load_traceback(buffer, should_raise=False) + + +def walk_nodes(data: ExceptionData) -> Iterator[ExceptionData]: + """Yield every distinct node of a saved exception graph exactly once.""" + seen: set[int] = set() + stack = [data] + while stack: + node = stack.pop() + if id(node) in seen: + continue + seen.add(id(node)) + yield node + stack.extend(link for link in (node.cause, node.context) if link is not None) + + +def self_caused() -> BaseException: + """Return an exception produced by ``raise e from e``.""" + try: + try: + msg = "original" + raise ValueError(msg) + except ValueError as err: + raise err from err + except ValueError as err: + return err + + +def test_raise_from_self_saves_and_loads() -> None: + """``raise e from e`` must not recurse forever while being saved.""" + restored = roundtrip(self_caused()) + + assert isinstance(restored, ValueError) + assert str(restored) == "original" + + +def test_self_cause_stays_a_self_cause() -> None: + """The cycle is preserved, not silently cut or unrolled into copies.""" + restored = roundtrip(self_caused()) + + assert restored.__cause__ is restored + + +def test_mutual_cycle_is_preserved() -> None: + """A two-exception cycle round-trips as the same two-exception cycle.""" + try: + msg_a = "a" + raise ValueError(msg_a) + except ValueError as first: + try: + msg_b = "b" + raise TypeError(msg_b) + except TypeError as second: + first.__cause__ = second + second.__cause__ = first + original = first + + restored = roundtrip(original) + + assert isinstance(restored, ValueError) + assert isinstance(restored.__cause__, TypeError) + assert restored.__cause__.__cause__ is restored + + +def test_self_context_saves_and_loads() -> None: + """A self-referential ``__context__`` is handled just like ``__cause__``.""" + try: + msg = "ctx" + raise ValueError(msg) + except ValueError as err: + err.__context__ = err + original = err + + restored = roundtrip(original) + + assert restored.__context__ is restored + + +def chain(links: int) -> BaseException: + """Build ``links`` exceptions chained with the ordinary ``raise ... from ...``.""" + current: BaseException | None = None + for i in range(links): + try: + if current is None: + msg = "root" + raise ValueError(msg) + raise current + except BaseException as prev: # noqa: BLE001 + try: + msg = f"lvl{i}" + raise RuntimeError(msg) from prev + except RuntimeError as wrapped: + current = wrapped + assert current is not None + return current + + +def test_shared_cause_and_context_is_one_node() -> None: + """ + ``raise X from Y`` aliases cause and context; the dump must not duplicate ``Y``. + + Duplicating it is what made a chain cost ``2**n`` to save and to load. + """ + original = chain(1) + assert original.__cause__ is original.__context__ + + buffer = BytesIO() + save_traceback(original, buffer) + buffer.seek(0) + data = parse_traceback(buffer) + + assert data.cause is data.context + + restored = roundtrip(original) + assert restored.__cause__ is restored.__context__ + + +def test_chained_exceptions_stay_linear() -> None: + """A chain of ``n`` exceptions must produce ``O(n)`` nodes, not ``2**n``.""" + links = 16 + buffer = BytesIO() + save_traceback(chain(links), buffer) + buffer.seek(0) + + node_count = sum(1 for _ in walk_nodes(parse_traceback(buffer))) + + assert node_count <= links * MAX_NODES_PER_LINK + + +def test_deep_chain_is_not_recursion_bound() -> None: + """A long but acyclic cause chain must not exhaust the interpreter stack.""" + deepest: BaseException = ValueError("root") + for i in range(DEEP_CHAIN_LINKS): + nxt = RuntimeError(f"lvl{i}") + nxt.__cause__ = deepest + deepest = nxt + + original_limit = sys.getrecursionlimit() + sys.setrecursionlimit(200) + try: + restored = roundtrip(deepest) + finally: + sys.setrecursionlimit(original_limit) + + depth = 0 + curr: BaseException | None = restored + while curr is not None: + depth += 1 + curr = curr.__cause__ + assert depth == DEEP_CHAIN_LINKS + 1 + + +def test_exception_group_with_cyclic_cause() -> None: + """A group whose cause is itself round-trips with the cycle intact.""" + try: + msg = "inner" + raise ValueError(msg) + except ValueError as inner: + try: + raise ExceptionGroup("group", [inner]) + except ExceptionGroup as group: + group.__cause__ = group + original = group + + restored = roundtrip(original) + + assert isinstance(restored, ExceptionGroup) + assert restored.__cause__ is restored + assert [type(e) for e in restored.exceptions] == [ValueError] + + +def test_exception_group_member_pointing_back_at_group() -> None: + """A member whose cause is the enclosing group keeps that exact link.""" + try: + msg = "inner" + raise ValueError(msg) + except ValueError as inner: + try: + raise ExceptionGroup("group", [inner]) + except ExceptionGroup as group: + group.exceptions[0].__cause__ = group + original = group + + restored = roundtrip(original) + + assert isinstance(restored, ExceptionGroup) + assert restored.exceptions[0].__cause__ is restored From 5d13880632589258264e06ef9a29d2cea52fca8c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 13:27:58 +0000 Subject: [PATCH 02/18] Add walk_exception_data for safe traversal of saved exception graphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recording the exception graph faithfully means a dump can now contain cycles and shared nodes, which is correct but hostile to consumers that assumed a tree. A naive recursive walk of `cause`/`context` no longer terminates, and both `dataclasses.asdict()` and `json.dumps()` fail outright on a cycle — so anything projecting a dump over an API boundary needs a traversal that stops at nodes it has already seen. `walk_exception_data` yields every reachable node exactly once, covering causes, contexts and exception group members. It is iterative, so a long cause chain cannot exhaust the stack. The `id()` of the yielded nodes is the stable key callers need to emit references instead of nesting, which is what makes a cyclic graph representable in JSON at all. Documented in the README with a JSON projection example. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CJBKuuFRsXohzgomc7sLiy --- README.md | 31 ++++++++++++ offline_debug/__init__.py | 8 ++- offline_debug/_inner/models.py | 33 ++++++++++++- tests/test_exception_cycles.py | 90 +++++++++++++++++++++++++++++++++- 4 files changed, 159 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4bc9c76..18092f3 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,37 @@ if isinstance(data, ExceptionGroupData): print(f"Sub-exception frames: {len(sub_exc_data.tb_frames)}") ``` +### Cyclic and Shared Exception Graphs + +`__cause__`/`__context__` form a directed graph, not a tree. `raise X from Y` sets **both** +`X.__cause__` and `X.__context__` to `Y`, and `raise e from e` makes an exception its own cause, +so a saved graph may share nodes or contain cycles. `offline-debug` records that graph faithfully: +each exception is saved exactly once, and a cycle round-trips as a real cycle. + +This means consumers must not walk `cause`/`context` naively — a recursive walk will not terminate, +and `dataclasses.asdict()` / `json.dumps()` fail outright on a cycle. Use `walk_exception_data`, +which visits each node exactly once and never recurses: + +```python +from offline_debug import parse_traceback, walk_exception_data + +data = parse_traceback(Path("crash_report.dump")) + +# Stable ids let you emit references instead of nesting - this is what makes a +# cyclic graph representable in JSON. +ids = {id(node): i for i, node in enumerate(walk_exception_data(data))} + +payload = [ + { + "id": ids[id(node)], + "frames": len(node.tb_frames), + "cause_id": ids[id(node.cause)] if node.cause else None, + "context_id": ids[id(node.context)] if node.context else None, + } + for node in walk_exception_data(data) +] +``` + ## Technical Implementation - **True Frame Reconstruction**: Uses `ctypes` to call `PyFrame_New` from the Python C API. This creates real `frame` objects diff --git a/offline_debug/__init__.py b/offline_debug/__init__.py index 9b58173..d944dc8 100644 --- a/offline_debug/__init__.py +++ b/offline_debug/__init__.py @@ -1,7 +1,12 @@ """Tool for serializing and reconstructing Python exceptions with full stack traces.""" from ._inner.load_traceback import load_traceback, parse_traceback -from ._inner.models import ExceptionData, ExceptionGroupData, FrameData +from ._inner.models import ( + ExceptionData, + ExceptionGroupData, + FrameData, + walk_exception_data, +) from ._inner.save_traceback import save_traceback __all__ = [ @@ -11,4 +16,5 @@ "load_traceback", "parse_traceback", "save_traceback", + "walk_exception_data", ] diff --git a/offline_debug/_inner/models.py b/offline_debug/_inner/models.py index 4810b53..97203c8 100644 --- a/offline_debug/_inner/models.py +++ b/offline_debug/_inner/models.py @@ -3,7 +3,10 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Iterator @dataclass @@ -34,3 +37,31 @@ class ExceptionGroupData(ExceptionData): """Serialized data for an ExceptionGroup.""" exceptions: list[ExceptionData] + + +def walk_exception_data(data: ExceptionData) -> Iterator[ExceptionData]: + """ + Yield every exception node reachable from ``data`` exactly once. + + A saved graph is not a tree. ``raise X from Y`` aliases ``cause`` and ``context`` + onto one node, and ``raise e from e`` makes a node its own cause, so following the + links naively either visits a node repeatedly or never terminates. + + This is the traversal any consumer should use to render or re-serialize a dump: + it is iterative (so a deep chain cannot exhaust the stack) and it stops descending + as soon as it reaches a node it has already yielded. Use ``id()`` of the yielded + nodes to reference them — that identity is what encodes cycles and sharing, and it + is what a JSON projection needs in order to emit references instead of nesting. + """ + seen: set[int] = set() + stack = [data] + while stack: + node = stack.pop() + if id(node) in seen: + continue + seen.add(id(node)) + yield node + + if isinstance(node, ExceptionGroupData): + stack.extend(node.exceptions) + stack.extend(link for link in (node.cause, node.context) if link is not None) diff --git a/tests/test_exception_cycles.py b/tests/test_exception_cycles.py index 6a13371..533ac62 100644 --- a/tests/test_exception_cycles.py +++ b/tests/test_exception_cycles.py @@ -16,11 +16,19 @@ from __future__ import annotations +import json import sys from io import BytesIO from typing import TYPE_CHECKING -from offline_debug import ExceptionData, load_traceback, parse_traceback, save_traceback +from offline_debug import ( + ExceptionData, + ExceptionGroupData, + load_traceback, + parse_traceback, + save_traceback, + walk_exception_data, +) if TYPE_CHECKING: from collections.abc import Iterator @@ -221,3 +229,83 @@ def test_exception_group_member_pointing_back_at_group() -> None: assert isinstance(restored, ExceptionGroup) assert restored.exceptions[0].__cause__ is restored + + +def test_walk_visits_every_node_exactly_once() -> None: + """The public traversal terminates on a cycle and yields each node once.""" + buffer = BytesIO() + save_traceback(self_caused(), buffer) + buffer.seek(0) + data = parse_traceback(buffer) + assert data.cause is data + + visited = list(walk_exception_data(data)) + + assert visited == [data] + + +def test_walk_covers_causes_contexts_and_group_members() -> None: + """Every reachable node is reported, including exception group members.""" + try: + msg = "inner" + raise ValueError(msg) + except ValueError as inner: + try: + raise ExceptionGroup("group", [inner, TypeError("other")]) + except ExceptionGroup as group: + original = group + + buffer = BytesIO() + save_traceback(original, buffer) + buffer.seek(0) + data = parse_traceback(buffer) + + visited = list(walk_exception_data(data)) + assert data in visited + assert len({id(node) for node in visited}) == len(visited) + assert isinstance(data, ExceptionGroupData) + for member in data.exceptions: + assert any(node is member for node in visited) + + +def test_walk_enables_a_json_safe_projection() -> None: + """A cyclic graph can be projected to JSON as ids and references.""" + buffer = BytesIO() + save_traceback(self_caused(), buffer) + buffer.seek(0) + data = parse_traceback(buffer) + + ids = {id(node): i for i, node in enumerate(walk_exception_data(data))} + payload = [ + { + "id": ids[id(node)], + "cause_id": ids[id(node.cause)] if node.cause is not None else None, + } + for node in walk_exception_data(data) + ] + + # The self-cause survives as a reference rather than defeating serialization. + assert json.loads(json.dumps(payload)) == [{"id": 0, "cause_id": 0}] + + +def test_walk_is_not_recursion_bound() -> None: + """Walking a long chain must not exhaust the interpreter stack.""" + deepest: BaseException = ValueError("root") + for i in range(DEEP_CHAIN_LINKS): + nxt = RuntimeError(f"lvl{i}") + nxt.__cause__ = deepest + deepest = nxt + + buffer = BytesIO() + save_traceback(deepest, buffer) + buffer.seek(0) + data = parse_traceback(buffer) + + original_limit = sys.getrecursionlimit() + sys.setrecursionlimit(200) + try: + count = sum(1 for _ in walk_exception_data(data)) + finally: + sys.setrecursionlimit(original_limit) + + assert count == DEEP_CHAIN_LINKS + 1 From 09a7db17122158e984d93abdd699d49c98fd4726 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 13:47:44 +0000 Subject: [PATCH 03/18] Fix formatting of loaded exceptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Formatting a loaded exception failed with `RuntimeError: generator raised StopIteration` for every dump, cycles or not — so an exception restored by `load_traceback` could not be printed, logged, or rendered by anything built on `traceback`. That defeats the point of reconstructing real frames. Reconstructed frames deliberately carry a synthetic (empty) code object, because accessing `f_locals` on a frame built by `PyFrame_New` over real optimized bytecode segfaults. The traceback objects were still built with the *original* `tb_lasti`, which indexes into the *original* bytecode. Pairing the two made `traceback._get_code_position` search a two-entry `co_positions()` for instruction `lasti // 2` — measured as index 33 against 2 available entries — and `next()` raised StopIteration inside a generator. A negative `tb_lasti` is the stdlib's own signal for "no instruction position available"; it then falls back to `tb_lineno`, which is restored accurately. Since the code object is always synthetic, the original offset never applies, so this is unconditional. `FrameData.lasti` stays in the dump as original metadata, and the on-disk format is unchanged. Loaded exceptions now print with correct files, lines, functions and source lines, and chaining, self-caused cycles and exception groups all render in the standard layout. The `^^^^` column markers remain unavailable for reconstructed frames, as column spans cannot be recovered without the original code object; this is now documented. All six tests fail on the previous implementation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CJBKuuFRsXohzgomc7sLiy --- README.md | 4 + offline_debug/_inner/load_traceback.py | 18 ++- tests/test_traceback_formatting.py | 147 +++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 tests/test_traceback_formatting.py diff --git a/README.md b/README.md index 18092f3..de8d594 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,10 @@ payload = [ - **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`. +- **Line-level Position Fidelity**: Reconstructed frames carry a synthetic code object (real optimized + bytecode would segfault on `f_locals` access), so they report no bytecode offset and the stdlib resolves + positions from the restored line numbers. Tracebacks print with correct files, lines and functions; the + `^^^^` column markers are not available for reconstructed frames. - **Python 3.13 Compatibility**: Leverages PEP 667 features where `f_locals` is a write-through proxy, allowing for accurate local variable restoration. - **Support python 3.12 as well** diff --git a/offline_debug/_inner/load_traceback.py b/offline_debug/_inner/load_traceback.py index 469bed1..7c156fe 100644 --- a/offline_debug/_inner/load_traceback.py +++ b/offline_debug/_inner/load_traceback.py @@ -19,6 +19,11 @@ FrameData, ) +# Sentinel ``tb_lasti`` meaning "no bytecode offset applies to this frame". +# ``traceback._get_code_position`` returns no position for a negative offset, which +# makes the stdlib fall back to ``tb_lineno`` instead of indexing ``co_positions()``. +_NO_INSTRUCTION_OFFSET = -1 + def _reconstruct_frames(data: ExceptionData) -> types.TracebackType | None: """ @@ -72,7 +77,18 @@ def _reconstruct_frames(data: ExceptionData) -> types.TracebackType | None: tb_next = types.TracebackType( tb_next=tb_next, tb_frame=frame, - tb_lasti=f_data.lasti, + # The original ``lasti`` indexes into the original bytecode, but the frame + # above deliberately carries a synthetic (empty) code object, so the two do + # not correspond. Handing the real ``lasti`` to the stdlib makes + # ``traceback._get_code_position`` walk ``co_positions()`` of a two-entry + # code object looking for instruction ``lasti // 2``, which raises + # StopIteration inside a generator -- surfacing as ``RuntimeError: generator + # raised StopIteration`` from any attempt to format the exception. + # + # A negative ``lasti`` is the stdlib's own signal for "no instruction + # position available": it then falls back to ``tb_lineno``, which we restore + # accurately. ``FrameData.lasti`` stays in the dump as original metadata. + tb_lasti=_NO_INSTRUCTION_OFFSET, tb_lineno=f_data.lineno, ) return tb_next diff --git a/tests/test_traceback_formatting.py b/tests/test_traceback_formatting.py new file mode 100644 index 0000000..5747edb --- /dev/null +++ b/tests/test_traceback_formatting.py @@ -0,0 +1,147 @@ +""" +A loaded exception must behave like a genuine one under introspection. + +Reconstructed frames deliberately carry a *synthetic* code object, because +accessing ``f_locals`` on a frame built by ``PyFrame_New`` over real optimized +bytecode segfaults. The original ``tb_lasti`` indexes into the original +bytecode, so pairing it with that synthetic code object made +``traceback._get_code_position`` search a two-entry ``co_positions()`` for +instruction ``lasti // 2``. That raises StopIteration inside a generator, which +surfaced as ``RuntimeError: generator raised StopIteration`` from *any* attempt +to format a loaded exception -- the library's central promise. + +Reconstructed frames now report no instruction offset, so the stdlib falls back +to ``tb_lineno``, which is restored accurately. +""" + +from __future__ import annotations + +import traceback +from io import BytesIO + +from offline_debug import load_traceback, save_traceback + + +def roundtrip(exc: BaseException) -> BaseException: + """Save an exception to a buffer and load it back without raising.""" + buffer = BytesIO() + save_traceback(exc, buffer) + buffer.seek(0) + return load_traceback(buffer, should_raise=False) + + +def formatted(exc: BaseException) -> str: + """Format an exception exactly as an unhandled traceback would print.""" + return "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)) + + +def positions(exc: BaseException) -> list[tuple[str, int | None, str]]: + """Return ``(filename, lineno, function)`` for every frame of a traceback.""" + return [(f.filename, f.lineno, f.name) for f in traceback.extract_tb(exc.__traceback__)] + + +def inner() -> None: + """Raise the exception under test.""" + local_value = 42 + raise ValueError(str(local_value)) + + +def outer() -> None: + """Add a second frame to the traceback.""" + inner() + + +def make_error() -> BaseException: + """Return a ValueError carrying a two-function traceback.""" + try: + outer() + except ValueError as err: + return err + msg = "outer() did not raise" + raise AssertionError(msg) + + +def test_loaded_exception_can_be_formatted() -> None: + """Formatting a loaded exception must not raise.""" + restored = roundtrip(make_error()) + + text = formatted(restored) + + assert "ValueError: 42" in text + assert "Traceback (most recent call last):" in text + + +def test_loaded_traceback_keeps_original_frames() -> None: + """ + The original frames survive verbatim as the tail of the loaded traceback. + + ``load_traceback`` splices the reconstructed frames onto the live stack so the + exception appears to have been raised at the load site, so the loaded traceback + is longer than the original by exactly the frames of the caller. + """ + original = make_error() + expected = positions(original) + restored = roundtrip(original) + + actual = positions(restored) + + assert actual[-len(expected) :] == expected + assert [name for _, _, name in expected] == ["make_error", "outer", "inner"] + + +def test_loaded_frames_report_source_lines() -> None: + """Line numbers must be accurate enough for the stdlib to find the source.""" + restored = roundtrip(make_error()) + + text = formatted(restored) + + assert "in inner" in text + assert "raise ValueError(str(local_value))" in text + + +def test_chained_exception_keeps_cause_wording() -> None: + """A loaded chain prints the same explanatory line as the original.""" + try: + try: + outer() + except ValueError as err: + msg = "wrapper" + raise RuntimeError(msg) from err + except RuntimeError as err: + original = err + + assert "direct cause" in formatted(original) + assert "direct cause" in formatted(roundtrip(original)) + + +def test_self_caused_exception_can_be_formatted() -> None: + """``raise e from e`` must format without looping, exactly as it does natively.""" + try: + try: + msg = "original" + raise ValueError(msg) + except ValueError as err: + raise err from err + except ValueError as err: + original = err + + restored = roundtrip(original) + assert restored.__cause__ is restored + + text = formatted(restored) + + assert "ValueError: original" in text + + +def test_exception_group_can_be_formatted() -> None: + """A loaded group prints its sub-exceptions in the standard group layout.""" + try: + raise ExceptionGroup("group", [ValueError("x"), TypeError("y")]) + except ExceptionGroup as err: + original = err + + text = formatted(roundtrip(original)) + + assert "ExceptionGroup: group (2 sub-exceptions)" in text + assert "ValueError: x" in text + assert "TypeError: y" in text From 8221567ecdbd8d3a6043815e902abf7a9a1066a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 15:45:34 +0000 Subject: [PATCH 04/18] Release 0.4.0 and document dump compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New dumps are not universally readable by older releases, which is more than a patch-level change: where an exception graph genuinely contains a cycle (`raise e from e`), a pre-0.4.0 reader follows `__cause__` forever and dies with a RecursionError. An ordinary new dump still reads on an older release. The reverse direction is safe and was verified rather than assumed. The on-disk layout is unchanged — no field added, removed or renamed — so a dump written by an earlier version parses, loads and formats under this one; reconstructing the exact shape the previous `save_traceback` wrote (cause and context as separate duplicated nodes) round-trips at every depth tried. Such dumps also gain the traceback-formatting fix, having previously failed to format at all. What they do not gain is the deduplication: the duplication lives in the file, so an old five-link dump still expands to 63 nodes where a freshly saved one holds 6. Re-saving is what shrinks it. Documented in the README so the distinction is not discovered as a surprise. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CJBKuuFRsXohzgomc7sLiy --- README.md | 11 +++++++++++ pyproject.toml | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index de8d594..3a910f8 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,17 @@ payload = [ ] ``` +### Dump Compatibility + +The on-disk layout has not changed, so dumps written by earlier versions load, raise and print +normally — and they gain the traceback-formatting fix, having previously failed to format at +all. What they do not gain is the deduplication: a pre-0.4.0 dump stored a separate copy of +every shared exception, and that duplication is part of the file. Re-save to shrink it. + +New dumps are readable by 0.4.0 and later. An older release can still read a new dump of an +ordinary exception, but not one whose graph genuinely contains a cycle (`raise e from e`): the +pre-0.4.0 reader follows `__cause__` forever and dies with a `RecursionError`. + ## Technical Implementation - **True Frame Reconstruction**: Uses `ctypes` to call `PyFrame_New` from the Python C API. This creates real `frame` objects diff --git a/pyproject.toml b/pyproject.toml index 3742c94..7f1b80e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "offline-debug" -version = "0.3.1" +version = "0.4.0" description = "Debug exceptions offline by saving them to a dump and raising them at a later point." readme = "README.md" authors = [ From 59181f9a6aef24ef02b8f121c93be0fd46d5fb82 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 20:18:57 +0000 Subject: [PATCH 05/18] Cover the rejected self-containing exception group `_build_exception` raises ValueError when a group's members cannot be built before the group itself, but nothing exercised it, so coverage came in at 99.14% and the `--cov-fail-under=100` gate failed every test job. The branch is reachable rather than defensive: `save_traceback` cannot emit this shape, but a hand-crafted or corrupted dump can, and loading one hits it cleanly. Two tests now pin it -- a group listing itself as a member, and two groups listing each other. The second case is why the message changed. It also has no valid build order and reports the same error, so "contains itself" was inaccurate for it; the code comment already said "transitively", and the message now agrees. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0161wStm2zaX9LCqsMeKJaWQ --- offline_debug/_inner/load_traceback.py | 14 +++++--- tests/test_exception_cycles.py | 49 ++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/offline_debug/_inner/load_traceback.py b/offline_debug/_inner/load_traceback.py index 7c156fe..d898d4a 100644 --- a/offline_debug/_inner/load_traceback.py +++ b/offline_debug/_inner/load_traceback.py @@ -137,7 +137,7 @@ def _build_exception(data: ExceptionData, built: dict[int, BaseException]) -> Ba except KeyError: # Only reachable from a hand-crafted dump: a group that (transitively) # contains itself cannot be built, since its members must exist first. - msg = "Cannot reconstruct an exception group that contains itself" + msg = "Cannot reconstruct an exception group that transitively contains itself" raise ValueError(msg) from None # The exceptions inside the unpickled exc object have incomplete data, so # rebuild the group around the fully reconstructed ones. We must not use @@ -157,9 +157,10 @@ def _reconstruct_exc_data(data: ExceptionData) -> BaseException: ``save_traceback`` records the exception graph as-is, so ``cause``/``context`` may revisit a node or point back at it (``raise e from e``). Reconstruction therefore runs in two passes: first every exception is built, then the ``__cause__``/ - ``__context__`` links are wired up from the identity map. Doing the links second is - what lets a cycle be restored as a genuine cycle instead of an endless chain of - copies, and it keeps a node that is reachable twice a single shared object. + ``__context__`` links and ``__suppress_context__`` are restored from the identity + map. Doing the links second is what lets a cycle be restored as a genuine cycle + instead of an endless chain of copies, and it keeps a node that is reachable twice a + single shared object. """ nodes = _collect_nodes(data) @@ -173,6 +174,11 @@ def _reconstruct_exc_data(data: ExceptionData) -> BaseException: exc.__cause__ = built[id(node.cause)] if node.context is not None: exc.__context__ = built[id(node.context)] + # Assigning ``__cause__`` sets ``__suppress_context__`` as a side effect, so the + # saved value must be restored *after* the links -- and unconditionally, since + # ``raise X from None`` outside an ``except`` block suppresses a context that is + # itself ``None``, leaving no assignment to piggyback on. + exc.__suppress_context__ = node.suppress_context return built[id(data)] diff --git a/tests/test_exception_cycles.py b/tests/test_exception_cycles.py index 533ac62..8f32b65 100644 --- a/tests/test_exception_cycles.py +++ b/tests/test_exception_cycles.py @@ -17,10 +17,13 @@ from __future__ import annotations import json +import pickle import sys from io import BytesIO from typing import TYPE_CHECKING +import pytest + from offline_debug import ( ExceptionData, ExceptionGroupData, @@ -309,3 +312,49 @@ def test_walk_is_not_recursion_bound() -> None: sys.setrecursionlimit(original_limit) assert count == DEEP_CHAIN_LINKS + 1 + + +def test_group_containing_itself_is_rejected() -> None: + """ + A group that contains itself cannot be rebuilt, and must say so. + + ``save_traceback`` cannot produce this shape -- a real ``BaseExceptionGroup`` + cannot hold itself -- but a hand-crafted or corrupted dump can. Members must + exist before the group is built, so a self-membership edge has no valid build + order and is reported rather than surfacing as a bare ``KeyError``. + """ + group = ExceptionGroupData( + exc_pickle=pickle.dumps(ExceptionGroup("group", [ValueError("a")])), + tb_frames=[], + exceptions=[], + ) + group.exceptions.append(group) + + buffer = BytesIO() + pickle.dump(group, buffer) + buffer.seek(0) + + with pytest.raises(ValueError, match="contains itself"): + load_traceback(buffer, should_raise=False) + + +def test_mutually_containing_groups_are_rejected() -> None: + """Two groups holding each other have no valid build order either.""" + outer_group = ExceptionGroupData( + exc_pickle=pickle.dumps(ExceptionGroup("outer", [ValueError("a")])), + tb_frames=[], + exceptions=[], + ) + inner_group = ExceptionGroupData( + exc_pickle=pickle.dumps(ExceptionGroup("inner", [ValueError("b")])), + tb_frames=[], + exceptions=[outer_group], + ) + outer_group.exceptions.append(inner_group) + + buffer = BytesIO() + pickle.dump(outer_group, buffer) + buffer.seek(0) + + with pytest.raises(ValueError, match="contains itself"): + load_traceback(buffer, should_raise=False) From 0015c8c772dd54479b56d59d90b86842f1973c2b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 20:18:57 +0000 Subject: [PATCH 06/18] Restore __suppress_context__ across a save/load round trip `raise X from None` sets `__suppress_context__` to keep an internal failure out of a user-facing traceback. That flag was never serialized, so a loaded exception printed the context its author had explicitly suppressed. This was invisible until now: formatting a loaded exception raised `RuntimeError: generator raised StopIteration`, so nobody could see the wrong output. Fixing formatting is what surfaced it, which is why it belongs here. `__suppress_context__` is a C-level slot rather than `__dict__` state, so pickling the exception does not carry it and it has to be recorded explicitly. Restoring it has to happen after the `__cause__`/`__context__` links, because assigning `__cause__` sets the flag as a side effect -- and unconditionally, since `raise X from None` outside an `except` block suppresses a context that is itself None and so offers no assignment to piggyback on. The field is defaulted, so a dump written before it existed still loads: dataclass defaults live on the class, and the missing key resolves there. Verified in both directions -- an old dump reads as False under this code, and an older release ignores the added field in a new dump. The README's compatibility section is corrected to describe the field rather than claim the layout is unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0161wStm2zaX9LCqsMeKJaWQ --- README.md | 18 +++-- offline_debug/_inner/models.py | 4 + offline_debug/_inner/save_traceback.py | 13 +++- tests/test_traceback_formatting.py | 100 ++++++++++++++++++++++++- 4 files changed, 126 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 3a910f8..76b1ff9 100644 --- a/README.md +++ b/README.md @@ -97,14 +97,20 @@ payload = [ ### Dump Compatibility -The on-disk layout has not changed, so dumps written by earlier versions load, raise and print -normally — and they gain the traceback-formatting fix, having previously failed to format at -all. What they do not gain is the deduplication: a pre-0.4.0 dump stored a separate copy of -every shared exception, and that duplication is part of the file. Re-save to shrink it. +The on-disk layout gained one optional field in 0.4.0 (`suppress_context`, defaulting to +`False`); nothing was removed or renamed. Dumps written by earlier versions load, raise and +print normally, and they gain the traceback-formatting fix, having previously failed to format +at all. + +What an old dump cannot gain is anything the writer never recorded: it stored a separate copy +of every shared exception, and no `suppress_context` at all. So it still expands to more nodes +than a fresh save, and a `raise X from None` in it still prints the context its author +suppressed. Re-saving under 0.4.0 fixes both. New dumps are readable by 0.4.0 and later. An older release can still read a new dump of an -ordinary exception, but not one whose graph genuinely contains a cycle (`raise e from e`): the -pre-0.4.0 reader follows `__cause__` forever and dies with a `RecursionError`. +ordinary exception — it ignores the added field, so suppression is simply not restored — but +not one whose graph genuinely contains a cycle (`raise e from e`): the pre-0.4.0 reader follows +`__cause__` forever and dies with a `RecursionError`. ## Technical Implementation diff --git a/offline_debug/_inner/models.py b/offline_debug/_inner/models.py index 97203c8..65ff515 100644 --- a/offline_debug/_inner/models.py +++ b/offline_debug/_inner/models.py @@ -30,6 +30,10 @@ class ExceptionData: tb_frames: list[FrameData] cause: ExceptionData | None = None context: ExceptionData | None = None + # Whether the original exception had its context suppressed (``raise X from None``). + # Defaulted so that a dump written before this field existed reads as ``False``: + # dataclass defaults live on the class, so the missing key resolves there. + suppress_context: bool = False @dataclass(kw_only=True) diff --git a/offline_debug/_inner/save_traceback.py b/offline_debug/_inner/save_traceback.py index 42d84ad..c701300 100644 --- a/offline_debug/_inner/save_traceback.py +++ b/offline_debug/_inner/save_traceback.py @@ -112,8 +112,17 @@ def _build_exc_node(exc: BaseException, roundtrip_cache: dict[int, str | None]) tb_frames = _serialize_frames(exc, roundtrip_cache) if isinstance(exc, BaseExceptionGroup): - return ExceptionGroupData(exc_pickle=exc_pickle, tb_frames=tb_frames, exceptions=[]) - return ExceptionData(exc_pickle=exc_pickle, tb_frames=tb_frames) + return ExceptionGroupData( + exc_pickle=exc_pickle, + tb_frames=tb_frames, + suppress_context=exc.__suppress_context__, + exceptions=[], + ) + return ExceptionData( + exc_pickle=exc_pickle, + tb_frames=tb_frames, + suppress_context=exc.__suppress_context__, + ) def _serialize_exc_data( diff --git a/tests/test_traceback_formatting.py b/tests/test_traceback_formatting.py index 5747edb..759a5bf 100644 --- a/tests/test_traceback_formatting.py +++ b/tests/test_traceback_formatting.py @@ -16,10 +16,12 @@ from __future__ import annotations +import pickle import traceback from io import BytesIO +from typing import Never -from offline_debug import load_traceback, save_traceback +from offline_debug import ExceptionData, load_traceback, save_traceback def roundtrip(exc: BaseException) -> BaseException: @@ -145,3 +147,99 @@ def test_exception_group_can_be_formatted() -> None: assert "ExceptionGroup: group (2 sub-exceptions)" in text assert "ValueError: x" in text assert "TypeError: y" in text + + +def test_suppressed_context_stays_suppressed() -> None: + """``raise X from None`` must not print the context its author suppressed.""" + + def raise_suppressing() -> Never: + try: + msg = "internal detail" + raise KeyError(msg) + except KeyError: + msg = "public message" + raise ValueError(msg) from None + + try: + raise_suppressing() + except ValueError as err: + original = err + + restored = roundtrip(original) + + assert restored.__suppress_context__ is True + assert "internal detail" not in formatted(restored) + assert "During handling of the above exception" not in formatted(restored) + + +def test_implicit_context_is_still_shown() -> None: + """Restoring suppression must not suppress an ordinary implicit context.""" + + def raise_implicitly() -> Never: + try: + msg = "first failure" + raise KeyError(msg) + except KeyError: + msg = "second failure" + raise ValueError(msg) # noqa: B904 - the implicit context is the point + + try: + raise_implicitly() + except ValueError as err: + original = err + + restored = roundtrip(original) + + assert restored.__suppress_context__ is False + assert "first failure" in formatted(restored) + assert "During handling of the above exception" in formatted(restored) + + +def test_explicit_cause_keeps_suppression_flag() -> None: + """``raise X from Y`` suppresses the context while still showing the cause.""" + + def raise_from_cause() -> Never: + try: + msg = "root cause" + raise KeyError(msg) + except KeyError as err: + msg = "wrapper" + raise ValueError(msg) from err + + try: + raise_from_cause() + except ValueError as err: + original = err + + restored = roundtrip(original) + + assert restored.__suppress_context__ is True + assert "direct cause" in formatted(restored) + + +def test_suppression_survives_without_any_context() -> None: + """``raise X from None`` outside an ``except`` block has no link to piggyback on.""" + original = ValueError("standalone") + original.__suppress_context__ = True + + restored = roundtrip(original) + + assert restored.__cause__ is None + assert restored.__context__ is None + assert restored.__suppress_context__ is True + + +def test_dump_without_suppression_field_still_loads() -> None: + """A pre-0.4.0 dump has no ``suppress_context`` key and must default to False.""" + data = ExceptionData(exc_pickle=pickle.dumps(ValueError("old")), tb_frames=[]) + # Reproduce the on-disk shape of a dump written before the field existed. + del data.__dict__["suppress_context"] + assert "suppress_context" not in data.__dict__ + + buffer = BytesIO() + pickle.dump(data, buffer) + buffer.seek(0) + restored = load_traceback(buffer, should_raise=False) + + assert isinstance(restored, ValueError) + assert restored.__suppress_context__ is False From b91007b8ceda02d1a008aba07c715572345632a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 08:38:09 +0000 Subject: [PATCH 07/18] Stop CPY001 from failing lint on newer ruff `ruff check` failed with 26 errors on a clean checkout -- every one CPY001, missing-copyright-notice -- so the pre-commit ruff hook and the CI lint job were both red without a single source change causing it. The rule left preview and so joined `select = ["ALL"]`, which is picking up new rules as ruff floats on `>=0.15.8`. It does not apply here: licensing lives in the root LICENSE file rather than a per-file header, so it is ignored alongside the four rules already listed. This clears the symptom, not the cause. `select = ["ALL"]` with an unpinned ruff and no committed lockfile will do the same thing the next time a rule graduates. Bounding the dev toolchain, or committing uv.lock (currently gitignored), is the durable fix and is left as a separate decision. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0161wStm2zaX9LCqsMeKJaWQ --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 7f1b80e..bbb4173 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,9 @@ ignore = [ "D212", # Multi-line docstring summary should start at the first line "COM812", # Trailing comma missing "PLC0415", # import should be at top of file + "CPY001", # Missing copyright notice - licensing lives in the root LICENSE file, + # not in a per-file header. Reached `select = ["ALL"]` when the rule + # left preview, so it fails builds on any ruff new enough to ship it. ] [tool.ruff.lint.per-file-ignores] From 4e32cca292eee7d6e9bae3a978f28e4819de5e68 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 08:41:43 +0000 Subject: [PATCH 08/18] Lock the dev toolchain so a ruff release cannot break the build The CPY001 ignore in the previous commit fixed the symptom. The cause is that every CI step and pre-commit hook ran a bare `uv run`, which re-resolves the dev dependencies to whatever is newest: `ruff>=0.15.8` was silently supplying 0.16.4. Paired with `select = ["ALL"]`, which adopts every rule a new ruff ships, a routine upstream release turns into a red build on an unchanged commit -- which is exactly what happened. Two changes, because either alone is insufficient: - uv.lock is now tracked. It was excluded by the stock Python .gitignore template rather than by a decision here; the template's own comment calls tracking it "generally recommended". Verified that it genuinely pins: with the lock naming ruff 0.15.8 and pyproject allowing >=0.15.8, `uv sync` installs 0.15.8 rather than the newer 0.16.4. - CI passes `--locked`. Without it a bare `uv run` silently re-locks when pyproject drifts from the lockfile -- measured upgrading ruff 0.15.8 to 0.16.4 mid-run -- so tracking the file alone would not have held. With it, a stale lockfile fails loudly: "The lockfile at `uv.lock` needs to be updated, but `--locked` was provided." The pre-commit hooks deliberately keep the bare `uv run` so that changing a dependency locally does not block the commit that changes it; CI is the enforcement point. Toolchain upgrades are now an explicit `uv lock --upgrade-package ruff`, reviewable in the diff, instead of arriving unannounced. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0161wStm2zaX9LCqsMeKJaWQ --- .github/workflows/ci.yml | 8 +- .gitignore | 9 +- uv.lock | 366 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 375 insertions(+), 8 deletions(-) create mode 100644 uv.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 499a90c..11c1f0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,11 +19,11 @@ jobs: enable-cache: true python-version: "3.12" - name: Ruff Format Check - run: uv run ruff format --check . + run: uv run --locked ruff format --check . - name: Ruff Lint Check - run: uv run ruff check . + run: uv run --locked ruff check . - name: Type Check with Ty - run: uv run ty check . + run: uv run --locked ty check . test: name: Test (${{ matrix.os }}, Python ${{ matrix.python-version }}) @@ -41,7 +41,7 @@ jobs: enable-cache: true python-version: ${{ matrix.python-version }} - name: Run tests with Pytest and 100% Coverage - run: uv run python -m pytest --cov=offline_debug --cov-report=term-missing --cov-fail-under=100 + run: uv run --locked python -m pytest --cov=offline_debug --cov-report=term-missing --cov-fail-under=100 build: name: Build Check diff --git a/.gitignore b/.gitignore index dc646d1..4894fb2 100644 --- a/.gitignore +++ b/.gitignore @@ -95,10 +95,11 @@ ipython_config.py #Pipfile.lock # UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -uv.lock +# uv.lock IS tracked. The runtime dependency surface is tiny, but the dev toolchain +# is not: `select = ["ALL"]` adopts every rule a new ruff ships, so an unpinned +# linter turns a routine release into a red build with no source change. Locking +# makes toolchain upgrades an explicit commit instead of a surprise. +#uv.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..598723f --- /dev/null +++ b/uv.lock @@ -0,0 +1,366 @@ +version = 1 +revision = 3 +requires-python = ">=3.12, <3.14" + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", size = 222543, upload-time = "2026-08-06T13:47:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", size = 222905, upload-time = "2026-08-06T13:47:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", size = 254407, upload-time = "2026-08-06T13:47:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", size = 257145, upload-time = "2026-08-06T13:47:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", size = 258257, upload-time = "2026-08-06T13:47:43.527Z" }, + { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", size = 260517, upload-time = "2026-08-06T13:47:45.121Z" }, + { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", size = 254785, upload-time = "2026-08-06T13:47:46.541Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", size = 256176, upload-time = "2026-08-06T13:47:48.155Z" }, + { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", size = 254321, upload-time = "2026-08-06T13:47:49.757Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", size = 258390, upload-time = "2026-08-06T13:47:51.248Z" }, + { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", size = 253894, upload-time = "2026-08-06T13:47:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", size = 255763, upload-time = "2026-08-06T13:47:54.296Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", size = 224597, upload-time = "2026-08-06T13:47:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", size = 225135, upload-time = "2026-08-06T13:47:57.52Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", size = 224515, upload-time = "2026-08-06T13:47:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" }, + { url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" }, + { url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" }, + { url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" }, + { url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" }, + { url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/30/03b03951873a1a0ffc7e8ca0e10c15597b59e8d0e39260704cd2ea087bc4/filelock-3.32.4.tar.gz", hash = "sha256:2bde2e4cf732e0153406d8a7bc80620ecf5e621fe0d25e41143c4e3b4733ff30", size = 222126, upload-time = "2026-08-23T17:37:55.363Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/a4/9b63d595d748e3aff8812b65eacc1a2c4bd90b7c2012e08e72373b4835eb/filelock-3.32.4-py3-none-any.whl", hash = "sha256:22e58ca3b1ae3b98993b762d7338367ae64fe50252bf78d59da3bfebcdf1cedd", size = 99864, upload-time = "2026-08-23T17:37:53.913Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "offline-debug" +version = "0.4.0" +source = { editable = "." } +dependencies = [ + { name = "typing-extensions" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "rich" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [{ name = "typing-extensions", specifier = ">=4.15.0" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pre-commit", specifier = ">=4.5.1" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "rich", specifier = ">=14.3.3" }, + { name = "ruff", specifier = ">=0.15.8" }, + { name = "ty", specifier = ">=0.0.27" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/bb/ebc6636e1ae41314f796ebb7215fd28febb45f9aac72f2b04cb74b5071dc/platformdirs-4.11.4.tar.gz", hash = "sha256:f3373be828247211d0febabea97e238c3dfde8a60b3c90c32756fb52cb21556d", size = 34079, upload-time = "2026-08-24T14:53:49.676Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/be/0ff05fcd2938fb58ad9219bd54135968342d214737e012d62d43f06a2dd6/platformdirs-4.11.4-py3-none-any.whl", hash = "sha256:e34ff91a24bcddc6d939b878bdf3f5c437c9c46fe9e212b1bf455fdf1ee57586", size = 23741, upload-time = "2026-08-24T14:53:48.406Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/89/1f3e8e1fc3e97de0fa963495832f581f025f29471602a309e48808244292/pre_commit-4.6.2.tar.gz", hash = "sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441", size = 198670, upload-time = "2026-08-10T22:07:18.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e", size = 226202, upload-time = "2026-08-10T22:07:16.942Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/8f/3c92c45737f654f2488ab3662b7604a55d3d35146d37c9ce80f5c95b95a6/python_discovery-1.5.3.tar.gz", hash = "sha256:e500eb24025fb7c4876c1fdcfbafd9028a10c71b661aee38cb6fb0de594518c1", size = 82477, upload-time = "2026-08-24T14:48:46.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/12/823d9a321904ccfd2969a24b84fdfd1e6614c707ec569c62879bf1dbc6c5/python_discovery-1.5.3-py3-none-any.whl", hash = "sha256:8305296358f1aa2ed302a25b84be7df84fef8ca47c7dce2da63cb7325333044e", size = 38290, upload-time = "2026-08-24T14:48:45.305Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" }, + { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" }, + { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" }, + { url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" }, + { url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" }, + { url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" }, + { url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" }, + { url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" }, + { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" }, +] + +[[package]] +name = "ty" +version = "0.0.74" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/0f/c767853e88567a2ec7e996dd95e3105b1bc62c95d103689311ef0f4a603c/ty-0.0.74.tar.gz", hash = "sha256:da14344fc8625fc9ff359bafb856ad575636ea86d9bb6a629b146bff27b380e6", size = 6786318, upload-time = "2026-08-22T15:05:54.054Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/95/6ded58bc97885c6d88fa1f9cd815031489200738f961cbf0466663213f80/ty-0.0.74-py3-none-linux_armv6l.whl", hash = "sha256:8969ef4e508debf00cf58f9ea85a539f799b1732c59cdfcecd037630b9755b30", size = 12790043, upload-time = "2026-08-22T15:05:05.015Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8a/5e323603b6ab8731144421877ee8a0f8ac5a5511e67857127caa09f6730e/ty-0.0.74-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:51fb6cf5b98e1e1140825b2430943f78d744876a735231656eafbb4c3f7eca3c", size = 12371748, upload-time = "2026-08-22T15:05:08.609Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/ee72e08cb705281e8d8c42917dd577aa598a8a098008495fda5176ee3f6e/ty-0.0.74-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8ebe60b1f0a948c793d6c77fc9e9ddda599e4f023c04ab16e8e03bcb428c3fa0", size = 12282403, upload-time = "2026-08-22T15:05:11.448Z" }, + { url = "https://files.pythonhosted.org/packages/da/b3/fd935b694ff68bc278af50f7ad04770b36ce6306399baef7e1847b553a9d/ty-0.0.74-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa97f407a695c890a53615966a663c7d2167e2cabe88db7ca1a24d62635cdfc8", size = 12345164, upload-time = "2026-08-22T15:05:14.19Z" }, + { url = "https://files.pythonhosted.org/packages/54/5c/5b5825268e029ebb164c909780103dbbae367f069801410068bf1cef29b3/ty-0.0.74-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:673ddb733d4a0db31385ba1ed9ff1f6bd9dc5565413ce57b1ca5ac4c7803da5d", size = 12556646, upload-time = "2026-08-22T15:05:16.994Z" }, + { url = "https://files.pythonhosted.org/packages/56/e7/515914e571d62ce0101744fed3f881936eeb1b30dc37beb72b4f7ca1e289/ty-0.0.74-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1028e7c6b4f6145e9704552f43a5fffdcd51b42263ffdcd9c9677762bc395a4a", size = 13311653, upload-time = "2026-08-22T15:05:20.254Z" }, + { url = "https://files.pythonhosted.org/packages/b0/07/d1452babb6f9266c2122cabc095180b70ed306fb770b2996753814d2237d/ty-0.0.74-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79841a8890493021fb308772474983316eb91f7b56cb227a6a05a06b262a36f0", size = 13768284, upload-time = "2026-08-22T15:05:23.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/60/8d4a2fc7842a47210a1cb0a16a187d9de39ad5d509a00fb74c1c073afcde/ty-0.0.74-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94859d321f3c6a6c8f7bfc3f40e8319cda7e6e012e613440f3dfd145d5010e2e", size = 13422306, upload-time = "2026-08-22T15:05:26.248Z" }, + { url = "https://files.pythonhosted.org/packages/de/76/ebbc269a8c4efcc4d44624993bd188145f20d60ebda9680b15aaec42cc50/ty-0.0.74-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:970a8b2c09ff3be04c8a1c6767332d861be4fce85efe7bb205e4ade7c8655274", size = 12970637, upload-time = "2026-08-22T15:05:29.15Z" }, + { url = "https://files.pythonhosted.org/packages/9e/dd/b99f7236acbf856780ca1779a48143d2d9f2c24d7f531a0ce15a022b8a87/ty-0.0.74-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:795f763b3ded85574c2c2846a6fb8acf2aa76e9e83d761143e92b1f0c7ffa2cd", size = 13344891, upload-time = "2026-08-22T15:05:32.033Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/9ff7449a4c7e6428f2c6f298e74cf24b70668f29d45c249507a723ff3782/ty-0.0.74-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:dc086db5367d912c31c0cc872deb7387290e779a4b9b54fcb944673a7cd52c7b", size = 12395272, upload-time = "2026-08-22T15:05:34.702Z" }, + { url = "https://files.pythonhosted.org/packages/b1/dd/b23a5b6b35d37df89dc8dc5daa09efd9245a668b50c4c81c25de21567dc1/ty-0.0.74-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0314d7b391cf684e47c2fa093d2ce4c597cfc9b01d9a315fe204aed6359b271b", size = 12573079, upload-time = "2026-08-22T15:05:37.683Z" }, + { url = "https://files.pythonhosted.org/packages/23/c5/ccba16239d6129533c8b3603458d0f4dd2ba69478e47059073968e74261d/ty-0.0.74-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c4a45dd2e991e8bdae82ba78c8cd051b253f60bc71a6536598fa3ef580b4fc9b", size = 12832506, upload-time = "2026-08-22T15:05:40.505Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1c/2390912634dff4f341f97b397f2aee341ff062be0a66cda37d59375454f2/ty-0.0.74-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:210e2eac6b018fb934e2b8dac3956a0ba076a3fb1fa6f135058c825e5b759b81", size = 13154752, upload-time = "2026-08-22T15:05:43.355Z" }, + { url = "https://files.pythonhosted.org/packages/c4/33/a8c12188227e6f74f91853a7374e01ed81d6ad21c16c8b70e92dbebfe46a/ty-0.0.74-py3-none-win32.whl", hash = "sha256:db0bb6a8f098ef9bd1be861f73b4f7c0320d40d4c05c7ae0a8677d4e7aa4f6e5", size = 12130002, upload-time = "2026-08-22T15:05:46.058Z" }, + { url = "https://files.pythonhosted.org/packages/21/5c/064f28ccb9c234cfce5a2f7aa69a256663d5ae5bb0290b3a9706cc4d1e4c/ty-0.0.74-py3-none-win_amd64.whl", hash = "sha256:bebff181515255b3c78bd2e7693ae66fab6064ad4feea2065c68bc01022aa678", size = 12771435, upload-time = "2026-08-22T15:05:48.811Z" }, + { url = "https://files.pythonhosted.org/packages/fe/06/d6becdaca0315346c26b6df97cb0eafa81de4f870945d6989e88704374ed/ty-0.0.74-py3-none-win_arm64.whl", hash = "sha256:1a3469eaaf8c85b1c0a15bede25d36daea4b09fce1d913e965b24e24b3f1d6c6", size = 12558299, upload-time = "2026-08-22T15:05:51.543Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.7.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/60/fc54e876e34f94dd0cf0185aaecfd4bfa906653f003d9b2fb21428642fca/virtualenv-21.7.5.tar.gz", hash = "sha256:a73c4246fba3c8901ff9717399f466e00eeca5a3834981f1a6ebb4f1e94de2f8", size = 5346743, upload-time = "2026-08-25T05:39:16.14Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/d8/401141bf45637be916c86d325bd821c5838c7eff83294b934cd94e774e4f/virtualenv-21.7.5-py3-none-any.whl", hash = "sha256:e36ca889510ab6cb0b1dca93c59e5431dd4422a3c88f487358d470c90af8c07a", size = 5324697, upload-time = "2026-08-25T05:39:14.229Z" }, +] From 4b03e6ef806ed1fbd8e694333e8f3e5f1d459981 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 08:49:09 +0000 Subject: [PATCH 09/18] Remove the hidden order dependency in the f_back offset test `test_get_f_back_offset_wrong_offset_restoration` passed in a full run and failed 10 times out of 10 on its own, with `ctypes.ArgumentError: argument 2`. It called `ctypes.pythonapi.PyFrame_New` directly. That function pointer is cached and process-global, and it only carries argtypes/restype once `_get_py_frame_new` has configured it -- so the test silently depended on some earlier test in the same process having created a frame first. Running it alone, under `-k`, or under `--lf` had nothing to configure it. It was the only one of the 65 tests with such a dependency. Building the frame through the package's own `create_frame` removes the dependency instead of papering over it with an explicit warm-up call. The probe also writes eight raw bytes at a fixed offset 80 into the frame, which happens to be in bounds today (the object is 136 bytes) but is not guaranteed across versions or platforms. It now asserts the object is large enough first, so a layout change fails the assertion rather than corrupting adjacent heap memory. Verified: all 65 tests pass individually in a fresh process, in reverse order, per-file, and as a full run, on 3.12 and 3.13. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0161wStm2zaX9LCqsMeKJaWQ --- tests/_inner/c_api/test__link_frame.py | 31 ++++++++++++++++---------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/tests/_inner/c_api/test__link_frame.py b/tests/_inner/c_api/test__link_frame.py index a985ffd..155e412 100644 --- a/tests/_inner/c_api/test__link_frame.py +++ b/tests/_inner/c_api/test__link_frame.py @@ -9,6 +9,7 @@ import pytest +from offline_debug._inner.c_api import create_frame from offline_debug._inner.c_api._link_frame import _get_f_back_offset if TYPE_CHECKING: @@ -85,28 +86,34 @@ def __init__(self, val: int) -> None: def test_get_f_back_offset_wrong_offset_restoration() -> None: - """Test that it restores 0 if the offset was wrong.""" + """A candidate slot that holds 0 but is not ``f_back`` must be restored to 0.""" import offline_debug._inner.c_api._create_frame as _create_frame_module import offline_debug._inner.c_api._link_frame as _link_frame_module - tstate = ctypes.pythonapi.PyThreadState_Get() - code = compile("pass", "", "exec") - frame = ctypes.pythonapi.PyFrame_New(tstate, code, {}, {}) + # Build the frame through the package's own helper rather than calling + # ``ctypes.pythonapi.PyFrame_New`` directly. That function pointer is cached and + # process-global, and only carries argtypes/restype once ``_get_py_frame_new`` has + # configured it -- so calling it raw passed only when some earlier test in the same + # process happened to run first, and failed with ``ctypes.ArgumentError`` whenever + # this test ran alone, under ``-k``, or under ``--lf``. + frame = create_frame(code=compile("pass", "", "exec"), frame_globals={}, frame_locals={}) ptr_size = ctypes.sizeof(ctypes.c_void_p) - # Force the loop to check an offset that is 0 but NOT the real f_back. + # An arbitrary slot that is 0 but is not f_back, forcing the scan to reject it. + probe_offset = ptr_size * 10 + # The probe writes raw memory, so assert the object is actually big enough rather + # than trusting a frame layout that differs by version and platform. + assert probe_offset + ptr_size <= sys.getsizeof(frame) - # Let's try this: with ( patch.object(_create_frame_module, "_get_py_frame_new", return_value=lambda *_: frame), - patch.object(_link_frame_module, "range", return_value=[ptr_size * 10]), - ): # Offset 80 - # Ensure offset 80 is 0 - ctypes.c_ssize_t.from_address(id(frame) + ptr_size * 10).value = 0 + patch.object(_link_frame_module, "range", return_value=[probe_offset]), + ): + ctypes.c_ssize_t.from_address(id(frame) + probe_offset).value = 0 offset = _get_f_back_offset() assert offset is None - # Verify it was restored to 0 - assert ctypes.c_ssize_t.from_address(id(frame) + ptr_size * 10).value == 0 + # The scan must put the slot back so the frame's refcounts stay sane. + assert ctypes.c_ssize_t.from_address(id(frame) + probe_offset).value == 0 def test_get_f_back_offset_ctypes_error() -> None: From fc583e4a6663fe8fd790ee5633b3618515394734 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 17:58:25 +0000 Subject: [PATCH 10/18] Share the round-trip helper across test modules Three test modules carried byte-identical copies of the save-to-buffer-and-load helper, and the exception-cycle tests kept a private copy of the node traversal that the same branch had just made public as walk_exception_data. A change to the round-trip path had to be made in three places, and a regression in the public traversal would have gone unnoticed by the test that counts nodes, because it never used it. Move the helper into tests/helpers.py and count nodes with the public traversal. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0183gC2Cb37XREy7HSim1TQr --- tests/helpers.py | 15 +++++++++++++++ tests/test_exception_cycles.py | 29 ++--------------------------- tests/test_exception_fidelity.py | 11 +---------- tests/test_traceback_formatting.py | 11 ++--------- 4 files changed, 20 insertions(+), 46 deletions(-) create mode 100644 tests/helpers.py diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..feaaa1c --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,15 @@ +"""Helpers shared by the test modules.""" + +from __future__ import annotations + +from io import BytesIO + +from offline_debug import load_traceback, save_traceback + + +def roundtrip(exc: BaseException) -> BaseException: + """Save an exception to a buffer and load it back without raising.""" + buffer = BytesIO() + save_traceback(exc, buffer) + buffer.seek(0) + return load_traceback(buffer, should_raise=False) diff --git a/tests/test_exception_cycles.py b/tests/test_exception_cycles.py index 8f32b65..75d1322 100644 --- a/tests/test_exception_cycles.py +++ b/tests/test_exception_cycles.py @@ -20,47 +20,22 @@ import pickle import sys from io import BytesIO -from typing import TYPE_CHECKING import pytest from offline_debug import ( - ExceptionData, ExceptionGroupData, load_traceback, parse_traceback, save_traceback, walk_exception_data, ) - -if TYPE_CHECKING: - from collections.abc import Iterator +from tests.helpers import roundtrip MAX_NODES_PER_LINK = 2 DEEP_CHAIN_LINKS = 400 -def roundtrip(exc: BaseException) -> BaseException: - """Save an exception to a buffer and load it back without raising.""" - buffer = BytesIO() - save_traceback(exc, buffer) - buffer.seek(0) - return load_traceback(buffer, should_raise=False) - - -def walk_nodes(data: ExceptionData) -> Iterator[ExceptionData]: - """Yield every distinct node of a saved exception graph exactly once.""" - seen: set[int] = set() - stack = [data] - while stack: - node = stack.pop() - if id(node) in seen: - continue - seen.add(id(node)) - yield node - stack.extend(link for link in (node.cause, node.context) if link is not None) - - def self_caused() -> BaseException: """Return an exception produced by ``raise e from e``.""" try: @@ -169,7 +144,7 @@ def test_chained_exceptions_stay_linear() -> None: save_traceback(chain(links), buffer) buffer.seek(0) - node_count = sum(1 for _ in walk_nodes(parse_traceback(buffer))) + node_count = sum(1 for _ in walk_exception_data(parse_traceback(buffer))) assert node_count <= links * MAX_NODES_PER_LINK diff --git a/tests/test_exception_fidelity.py b/tests/test_exception_fidelity.py index c98a0e5..6e8f81c 100644 --- a/tests/test_exception_fidelity.py +++ b/tests/test_exception_fidelity.py @@ -9,23 +9,14 @@ from __future__ import annotations -from io import BytesIO from typing import Never, Self -from offline_debug import load_traceback, save_traceback +from tests.helpers import roundtrip EXPECTED_ERRNO = 2 EXPECTED_VALUE = 42 -def roundtrip(exc: BaseException) -> BaseException: - """Save an exception to a buffer and load it back without raising.""" - buffer = BytesIO() - save_traceback(exc, buffer) - buffer.seek(0) - return load_traceback(buffer, should_raise=False) - - class SetstateError(Exception): """Exception that relies on ``__setstate__`` to finish reconstruction.""" diff --git a/tests/test_traceback_formatting.py b/tests/test_traceback_formatting.py index 759a5bf..3a20873 100644 --- a/tests/test_traceback_formatting.py +++ b/tests/test_traceback_formatting.py @@ -21,15 +21,8 @@ from io import BytesIO from typing import Never -from offline_debug import ExceptionData, load_traceback, save_traceback - - -def roundtrip(exc: BaseException) -> BaseException: - """Save an exception to a buffer and load it back without raising.""" - buffer = BytesIO() - save_traceback(exc, buffer) - buffer.seek(0) - return load_traceback(buffer, should_raise=False) +from offline_debug import ExceptionData, load_traceback +from tests.helpers import roundtrip def formatted(exc: BaseException) -> str: From 2d800624cc94e4bf7a95ec10f4a31d6bf1bed3ad Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 17:59:05 +0000 Subject: [PATCH 11/18] Build exception groups after their members however they are reached The loader ordered nodes by a depth-first post-order over cause and context edges as well as member edges, but only member edges constrain build order: a group is rebuilt around already-built members, whereas the links are assigned afterwards. Whenever a group was first reached from one of its own members the group was emitted before that member, and loading failed with the misleading "transitively contains itself" error. The common unwrap idiom, `except ExceptionGroup as g: raise g.exceptions[0]` (and its except* form), saves exactly that shape, so dumps save_traceback itself wrote could not be loaded. Walk member edges alone, and queue each cause/context target as a new starting point that is taken up only once the current search has finished. The "contains itself" error is now truly reachable only from a hand-crafted dump. While there, unpickle each node once on discovery and skip the members of a group whose pickle fell back to the placeholder: the placeholder references none of them, so rebuilding their frames was pure waste. A member something else links to is still built. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0183gC2Cb37XREy7HSim1TQr --- offline_debug/_inner/load_traceback.py | 108 +++++++++++++++-------- tests/_inner/test_load_traceback.py | 74 +++++++++++++++- tests/test_exception_cycles.py | 114 +++++++++++++++++++++++++ 3 files changed, 259 insertions(+), 37 deletions(-) diff --git a/offline_debug/_inner/load_traceback.py b/offline_debug/_inner/load_traceback.py index d898d4a..acaf204 100644 --- a/offline_debug/_inner/load_traceback.py +++ b/offline_debug/_inner/load_traceback.py @@ -94,49 +94,85 @@ def _reconstruct_frames(data: ExceptionData) -> types.TracebackType | None: return tb_next -def _collect_nodes(root: ExceptionData) -> list[ExceptionData]: +def _unpickle_exception(data: ExceptionData) -> BaseException: + """Unpickle the exception object stored on ``data``.""" + exc = pickle.loads(data.exc_pickle) # noqa: S301 + if not isinstance(exc, BaseException): + msg = f"Expected BaseException, but got {type(exc).__name__}" + raise TypeError(msg) + return exc + + +def _members(data: ExceptionData, exc: BaseException) -> list[ExceptionData]: """ - List every node reachable from ``root``, sub-exceptions always before their group. + Return the sub-exceptions ``data`` has to be rebuilt around, if any. - A group can only be rebuilt once its members exist, whereas ``cause``/``context`` are - assigned after the fact, so ``exceptions`` is the only edge that constrains build - order. The walk is iterative and identity-memoized because the graph may contain - cycles (see :func:`_reconstruct_exc_data`). + A group whose own pickle fell back to the ``RuntimeError`` placeholder loads as a + plain exception that references none of its members, so rebuilding them (frames and + all) would be wasted work. They are still built if something else links to them. + """ + if isinstance(data, ExceptionGroupData) and isinstance(exc, BaseExceptionGroup): + return data.exceptions + return [] + + +def _build_order(root: ExceptionData) -> tuple[list[ExceptionData], dict[int, BaseException]]: + """ + List every node that has to be rebuilt, each group after its members. + + Only ``exceptions`` edges constrain build order: a group is rebuilt around + already-built members, whereas ``cause``/``context`` are assigned afterwards (see + :func:`_reconstruct_exc_data`). So the walk is a post-order depth-first search over + ``exceptions`` edges alone, and every ``cause``/``context`` target is queued as a new + *starting point* that is only taken up once the current search has finished. + Following a link mid-search would let a group that is first reached through one of + its own members -- ``except ExceptionGroup as g: raise g.exceptions[0]`` saves + exactly that shape -- be emitted before that member. + + Each node is unpickled exactly once, on discovery, so that the members of a group + that loaded as a placeholder can be pruned (see :func:`_members`); the unpickled + objects are returned alongside the order for the build pass. The walk is iterative + and identity-memoized because the graph may contain cycles. + + This deliberately does not reuse :func:`walk_exception_data`: that traversal reports + every node in no particular order, whereas this one prunes and orders. """ seen: set[int] = set() order: list[ExceptionData] = [] - stack: list[tuple[ExceptionData, bool]] = [(root, False)] - - while stack: - node, children_done = stack.pop() - if children_done: - order.append(node) - continue - if id(node) in seen: - continue - seen.add(id(node)) - # Re-push the node below its children so it is emitted after them. - stack.append((node, True)) - if isinstance(node, ExceptionGroupData): - stack.extend((sub, False) for sub in node.exceptions) - stack.extend((link, False) for link in (node.cause, node.context) if link is not None) - - return order - - -def _build_exception(data: ExceptionData, built: dict[int, BaseException]) -> BaseException: - """Rebuild one exception with its traceback, taking sub-exceptions from ``built``.""" - exc: BaseException = pickle.loads(data.exc_pickle) # noqa: S301 - if not isinstance(exc, BaseException): - msg = f"Expected BaseException, but got {type(exc).__name__}" - raise TypeError(msg) - + unpickled: dict[int, BaseException] = {} + starts: list[ExceptionData] = [root] + + while starts: + stack: list[tuple[ExceptionData, bool]] = [(starts.pop(), False)] + while stack: + node, members_done = stack.pop() + if members_done: + order.append(node) + continue + if id(node) in seen: + continue + seen.add(id(node)) + exc = unpickled[id(node)] = _unpickle_exception(node) + starts.extend(link for link in (node.cause, node.context) if link is not None) + # Re-push the node below its members so it is emitted after them. + stack.append((node, True)) + stack.extend((sub, False) for sub in _members(node, exc)) + + return order, unpickled + + +def _build_exception( + data: ExceptionData, exc: BaseException, built: dict[int, BaseException] +) -> BaseException: + """Give the unpickled ``exc`` its traceback, rebuilding a group around ``built`` members.""" if isinstance(data, ExceptionGroupData) and isinstance(exc, BaseExceptionGroup): try: inner_excs = [built[id(sub)] for sub in data.exceptions] except KeyError: - # Only reachable from a hand-crafted dump: a group that (transitively) - # contains itself cannot be built, since its members must exist first. + # Only reachable from a hand-crafted dump: a real group's members are fixed + # at construction, so ``save_traceback`` can never record a group that + # (transitively) contains itself -- and such a group has no valid build + # order, since its members must exist first. msg = "Cannot reconstruct an exception group that transitively contains itself" raise ValueError(msg) from None # The exceptions inside the unpickled exc object have incomplete data, so @@ -162,11 +198,11 @@ def _reconstruct_exc_data(data: ExceptionData) -> BaseException: instead of an endless chain of copies, and it keeps a node that is reachable twice a single shared object. """ - nodes = _collect_nodes(data) + nodes, unpickled = _build_order(data) built: dict[int, BaseException] = {} for node in nodes: - built[id(node)] = _build_exception(node, built) + built[id(node)] = _build_exception(node, unpickled[id(node)], built) for node in nodes: exc = built[id(node)] diff --git a/tests/_inner/test_load_traceback.py b/tests/_inner/test_load_traceback.py index 35f1e78..6f93bb0 100644 --- a/tests/_inner/test_load_traceback.py +++ b/tests/_inner/test_load_traceback.py @@ -2,17 +2,21 @@ from __future__ import annotations +import pickle from io import BytesIO from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Never import pytest from offline_debug import load_traceback, save_traceback +from tests.helpers import roundtrip if TYPE_CHECKING: import types + from offline_debug import ExceptionData + def get_frames(tb: types.TracebackType | None) -> list[types.FrameType]: """Extract all frames from a traceback.""" @@ -101,3 +105,71 @@ def test_load_traceback_should_raise_false() -> None: loaded_exc = load_traceback(buffer, should_raise=False) assert isinstance(loaded_exc, ValueError) assert str(loaded_exc) == "test_raise_false" + + +def _raise_on_load() -> Never: + msg = "cannot load" + raise TypeError(msg) + + +class LoadFailGroup(ExceptionGroup): + """Group that pickles fine but whose reconstruction fails at load time.""" + + def __reduce__(self) -> tuple: + """Reduce to a callable that raises when the pickle is loaded.""" + return (_raise_on_load, ()) + + +def record_rebuilt_exceptions(monkeypatch) -> list[str]: + """Record the type name of every exception whose traceback the loader rebuilds.""" + import offline_debug._inner.load_traceback as load_module + + rebuilt: list[str] = [] + real_reconstruct_frames = load_module._reconstruct_frames + + def recording(data: ExceptionData) -> types.TracebackType | None: + rebuilt.append(type(pickle.loads(data.exc_pickle)).__name__) # noqa: S301 + return real_reconstruct_frames(data) + + monkeypatch.setattr(load_module, "_reconstruct_frames", recording) + return rebuilt + + +def test_members_of_a_placeholder_group_are_not_rebuilt(monkeypatch) -> None: + """ + A group that loads as the placeholder references none of its members. + + Rebuilding them anyway would create and link a real frame per traceback entry + only to drop them, so the loader must not descend into them. + """ + try: + raise LoadFailGroup("group", [ValueError("a"), TypeError("b")]) + except LoadFailGroup as err: + original = err + rebuilt = record_rebuilt_exceptions(monkeypatch) + + restored = roundtrip(original) + + assert isinstance(restored, RuntimeError) + assert "Unpicklable exception LoadFailGroup" in str(restored) + assert rebuilt == ["RuntimeError"] + + +def test_placeholder_group_members_reached_by_a_link_are_still_rebuilt(monkeypatch) -> None: + """Pruning must not drop a member that something else links to.""" + try: + msg = "a" + raise ValueError(msg) + except ValueError as member: + try: + raise LoadFailGroup("group", [member, TypeError("b")]) + except LoadFailGroup as err: + err.__cause__ = member + original = err + rebuilt = record_rebuilt_exceptions(monkeypatch) + + restored = roundtrip(original) + + assert isinstance(restored, RuntimeError) + assert isinstance(restored.__cause__, ValueError) + assert sorted(rebuilt) == ["RuntimeError", "ValueError"] diff --git a/tests/test_exception_cycles.py b/tests/test_exception_cycles.py index 75d1322..ac9ff89 100644 --- a/tests/test_exception_cycles.py +++ b/tests/test_exception_cycles.py @@ -20,6 +20,7 @@ import pickle import sys from io import BytesIO +from typing import Never import pytest @@ -209,6 +210,119 @@ def test_exception_group_member_pointing_back_at_group() -> None: assert restored.exceptions[0].__cause__ is restored +def unwrap_member() -> Never: + """``except ExceptionGroup as g: raise g.exceptions[0]``, the common unwrap idiom.""" + try: + try: + msg = "inner" + raise ValueError(msg) + except ValueError as inner: + raise ExceptionGroup("group", [inner]) # noqa: B904 - wrapping is the idiom + except ExceptionGroup as group: + raise group.exceptions[0] # noqa: B904 - unwrapping is the idiom + + +def unwrapped_member() -> BaseException: + """ + Return what ``unwrap_member`` raises: a member whose ``__context__`` is its own group. + + Raising the member out of the ``except`` block records the group as the member's + context while the group still lists the member, so the exception graph's root is a + member of a group it links to. + """ + try: + unwrap_member() + except ValueError as err: + member = err + else: + msg = "unwrap_member() did not raise" + raise AssertionError(msg) + assert isinstance(member.__context__, ExceptionGroup) + assert member.__context__.exceptions[0] is member + return member + + +def test_member_raised_out_of_its_group_loads() -> None: + """ + The unwrap idiom must load, with the group rebuilt around the very same member. + + The member is the root, and the loader can only build its group after it -- however + the group is reached. + """ + restored = roundtrip(unwrapped_member()) + + assert isinstance(restored, ValueError) + assert isinstance(restored.__context__, ExceptionGroup) + assert restored.__context__.exceptions[0] is restored + + +def test_member_raised_out_of_except_star_loads() -> None: + """The ``except*`` form of the unwrap idiom saves the same member-first shape.""" + + def unwrap() -> Never: + try: + msg = "inner" + raise ExceptionGroup("group", [ValueError(msg)]) + except* ValueError as matched: + raise matched.exceptions[0] # noqa: B904 - unwrapping is the idiom + + try: + unwrap() + except ValueError as err: + original = err + assert isinstance(original.__context__, ExceptionGroup) + assert original.__context__.exceptions[0] is original + + restored = roundtrip(original) + + assert isinstance(restored, ValueError) + assert isinstance(restored.__context__, ExceptionGroup) + assert restored.__context__.exceptions[0] is restored + + +def test_wrapped_unwrapped_member_loads() -> None: + """The member-first shape must load when only a ``raise ... from`` link reaches it.""" + try: + msg = "wrapper" + raise RuntimeError(msg) from unwrapped_member() + except RuntimeError as err: + original = err + + restored = roundtrip(original) + + member = restored.__cause__ + assert isinstance(member, ValueError) + assert isinstance(member.__context__, ExceptionGroup) + assert member.__context__.exceptions[0] is member + + +def test_group_reached_through_a_member_link_is_built_after_the_root() -> None: + """ + A link from inside a group's members may lead to a group that contains the root. + + That outer group can only be built once the root is, however the link is found. + """ + try: + msg = "member" + raise ValueError(msg) + except ValueError as member: + try: + raise ExceptionGroup("inner group", [member]) + except ExceptionGroup as inner_group: + try: + raise ExceptionGroup("outer group", [inner_group]) + except ExceptionGroup as outer_group: + member.__cause__ = outer_group + original = inner_group + + restored = roundtrip(original) + + assert isinstance(restored, ExceptionGroup) + outer = restored.exceptions[0].__cause__ + assert isinstance(outer, ExceptionGroup) + assert outer.exceptions[0] is restored + + def test_walk_visits_every_node_exactly_once() -> None: """The public traversal terminates on a cycle and yields each node once.""" buffer = BytesIO() From 9e1196205e0d1eb25631b2a7ab44dc5d82f62cfa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 17:59:06 +0000 Subject: [PATCH 12/18] Compare saved exception nodes by identity ExceptionData kept the dataclass-generated structural __eq__ while the saved graph became cyclic, so comparing two parses of a `raise e from e` dump with ==, !=, or `in` recursed forever. Such graphs could not be saved at all before, so any consumer check like "re-saving is idempotent" now blew up instead of answering. Declare eq=False on both node classes: they compare and hash by identity, which matches the id()-based contract walk_exception_data already documents, and makes nodes usable directly as dict keys and set members. Say so in the README next to the asdict/json warning. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0183gC2Cb37XREy7HSim1TQr --- README.md | 7 +++++-- offline_debug/_inner/models.py | 13 ++++++++++--- tests/test_exception_cycles.py | 25 +++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 76b1ff9..ca6a3dd 100644 --- a/README.md +++ b/README.md @@ -72,8 +72,11 @@ so a saved graph may share nodes or contain cycles. `offline-debug` records that each exception is saved exactly once, and a cycle round-trips as a real cycle. This means consumers must not walk `cause`/`context` naively — a recursive walk will not terminate, -and `dataclasses.asdict()` / `json.dumps()` fail outright on a cycle. Use `walk_exception_data`, -which visits each node exactly once and never recurses: +and `dataclasses.asdict()` / `json.dumps()` fail outright on a cycle. For the same reason the data +nodes compare and hash by identity rather than by value (a structural `==` would recurse through +the cycle forever), so two dumps of the same exception are never equal; `id()` — or the node itself, +as a dict key or set member — is the stable handle. Use `walk_exception_data`, which visits each +node exactly once and never recurses: ```python from offline_debug import parse_traceback, walk_exception_data diff --git a/offline_debug/_inner/models.py b/offline_debug/_inner/models.py index 65ff515..a162bfa 100644 --- a/offline_debug/_inner/models.py +++ b/offline_debug/_inner/models.py @@ -22,9 +22,16 @@ class FrameData: module_name: str | None = None -@dataclass(kw_only=True) +@dataclass(kw_only=True, eq=False) class ExceptionData: - """Serialized data for an exception and its traceback.""" + """ + Serialized data for an exception and its traceback. + + Nodes compare and hash by identity, like the exceptions they describe: the saved + graph may contain cycles (``raise e from e``), on which a structural ``__eq__`` would + recurse forever. Two dumps of the same exception therefore never compare equal; + ``id()`` (or the node itself, as a dict key or set member) is the stable handle. + """ exc_pickle: bytes tb_frames: list[FrameData] @@ -36,7 +43,7 @@ class ExceptionData: suppress_context: bool = False -@dataclass(kw_only=True) +@dataclass(kw_only=True, eq=False) class ExceptionGroupData(ExceptionData): """Serialized data for an ExceptionGroup.""" diff --git a/tests/test_exception_cycles.py b/tests/test_exception_cycles.py index ac9ff89..df9885f 100644 --- a/tests/test_exception_cycles.py +++ b/tests/test_exception_cycles.py @@ -25,6 +25,7 @@ import pytest from offline_debug import ( + ExceptionData, ExceptionGroupData, load_traceback, parse_traceback, @@ -447,3 +448,27 @@ def test_mutually_containing_groups_are_rejected() -> None: with pytest.raises(ValueError, match="contains itself"): load_traceback(buffer, should_raise=False) + + +def test_nodes_compare_by_identity() -> None: + """ + Nodes must compare by identity, since structural equality cannot end on a cycle. + + A dataclass-generated ``__eq__`` compares fields recursively, so comparing two + parses of a ``raise e from e`` dump would recurse through ``cause`` forever. + """ + + def parsed() -> ExceptionData: + buffer = BytesIO() + save_traceback(self_caused(), buffer) + buffer.seek(0) + return parse_traceback(buffer) + + first, second = parsed(), parsed() + assert first.cause is first + + assert first == first # noqa: PLR0124 - identity is the semantics under test + assert first != second + by_node = {first: "first", second: "second"} + assert by_node[first] == "first" + assert by_node[second] == "second" From e2d13f7d5cac6ad91938749ce80085b3eb448e55 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 17:59:07 +0000 Subject: [PATCH 13/18] Map reconstructed frames to their line without columns Reconstructed frames carried tb_lasti = -1 so that the traceback module would fall back to tb_lineno instead of indexing the synthetic code object's positions. Python 3.12 prints unhandled exceptions from C, though, and that printer treats a negative offset as "columns 0 to 0", drawing a zero-width caret line, an empty line, under every reconstructed frame. The tests only went through the traceback module, which hid it. Give the synthetic code object a location table of its own instead: every instruction maps to the restored line with no column information, and the traceback entry points at a real offset. Both printers then show the right line and have no columns to draw markers from, and frame.f_lineno now reports the restored line as well. The encoder is checked against CPython's own decoder, including negative deltas and multi-byte varints. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0183gC2Cb37XREy7HSim1TQr --- README.md | 7 ++- offline_debug/_inner/load_traceback.py | 79 ++++++++++++++++++++------ tests/_inner/test_load_traceback.py | 36 ++++++++++++ tests/test_traceback_formatting.py | 62 +++++++++++++++++++- 4 files changed, 162 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index ca6a3dd..7f75c64 100644 --- a/README.md +++ b/README.md @@ -120,9 +120,10 @@ not one whose graph genuinely contains a cycle (`raise e from e`): the pre-0.4.0 - **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`. - **Line-level Position Fidelity**: Reconstructed frames carry a synthetic code object (real optimized - bytecode would segfault on `f_locals` access), so they report no bytecode offset and the stdlib resolves - positions from the restored line numbers. Tracebacks print with correct files, lines and functions; the - `^^^^` column markers are not available for reconstructed frames. + bytecode would segfault on `f_locals` access) whose location table maps every instruction to the + restored line with no column information. Tracebacks print with correct files, lines and functions + from every printer, and `frame.f_lineno` is accurate; the `^^^^` column markers are not available + for reconstructed frames. - **Python 3.13 Compatibility**: Leverages PEP 667 features where `f_locals` is a write-through proxy, allowing for accurate local variable restoration. - **Support python 3.12 as well** diff --git a/offline_debug/_inner/load_traceback.py b/offline_debug/_inner/load_traceback.py index acaf204..292815a 100644 --- a/offline_debug/_inner/load_traceback.py +++ b/offline_debug/_inner/load_traceback.py @@ -19,10 +19,59 @@ FrameData, ) -# Sentinel ``tb_lasti`` meaning "no bytecode offset applies to this frame". -# ``traceback._get_code_position`` returns no position for a negative offset, which -# makes the stdlib fall back to ``tb_lineno`` instead of indexing ``co_positions()``. -_NO_INSTRUCTION_OFFSET = -1 +# Instruction offset every reconstructed traceback entry points at. The synthetic code +# object built in ``_reconstruct_frames`` maps *all* of its instructions to the restored +# line, so any offset would resolve to the same position; the first one is always valid. +_SYNTHETIC_LASTI = 0 + +# ``co_linetable`` encoding (``Objects/locations.md`` in CPython): a sequence of entries, +# each a header byte ``0b1_CCCC_LLL`` -- location code ``CCCC`` covering ``LLL + 1`` code +# units -- followed by code-specific varints. Code 13 is "line only, no columns", followed +# by one signed varint holding the line delta from the previous entry, which for the +# first entry is relative to ``co_firstlineno``. Varints carry 6 payload bits per byte, +# least significant first, with bit 6 set on every byte but the last; signed values put +# the sign in the low bit and the magnitude above it. +_ENTRY_HEADER_FLAG = 0x80 +_NO_COLUMNS_LOCATION_CODE = 13 +_MAX_UNITS_PER_ENTRY = 8 +_CODE_UNIT_SIZE = 2 +_VARINT_PAYLOAD_BITS = 6 +_VARINT_PAYLOAD_MASK = (1 << _VARINT_PAYLOAD_BITS) - 1 +_VARINT_CONTINUATION_FLAG = 1 << _VARINT_PAYLOAD_BITS + + +def _varint(value: int) -> bytes: + """Encode an unsigned integer as a location table varint.""" + encoded = bytearray() + while value > _VARINT_PAYLOAD_MASK: + encoded.append(_VARINT_CONTINUATION_FLAG | (value & _VARINT_PAYLOAD_MASK)) + value >>= _VARINT_PAYLOAD_BITS + encoded.append(value) + return bytes(encoded) + + +def _signed_varint(value: int) -> bytes: + """Encode a signed integer as a location table varint.""" + return _varint(((-value) << 1) | 1 if value < 0 else value << 1) + + +def _line_only_linetable(code: CodeType, firstlineno: int, lineno: int) -> bytes: + """ + Build a ``co_linetable`` placing every instruction of ``code`` on ``lineno``, columnless. + + ``firstlineno`` is the ``co_firstlineno`` the table will be paired with, since the + first entry's line is stored relative to it. + """ + units = len(code.co_code) // _CODE_UNIT_SIZE + delta = lineno - firstlineno + table = bytearray() + while units > 0: + run = min(units, _MAX_UNITS_PER_ENTRY) + table.append(_ENTRY_HEADER_FLAG | (_NO_COLUMNS_LOCATION_CODE << 3) | (run - 1)) + table += _signed_varint(delta) + delta = 0 + units -= run + return bytes(table) def _reconstruct_frames(data: ExceptionData) -> types.TracebackType | None: @@ -56,6 +105,13 @@ def _reconstruct_frames(data: ExceptionData) -> types.TracebackType | None: co_name=code.co_name, co_firstlineno=code.co_firstlineno, co_qualname=code.co_qualname, + # The synthetic bytecode has nothing to do with the original, so its own + # location table (an empty module: line 1, columns 0-0) would put the frame + # on the wrong line and draw a bogus caret. Map all of it to the restored + # line with no columns instead: the stdlib then prints the right line, + # ``frame.f_lineno`` answers correctly, and neither the ``traceback`` module + # nor the C printer behind the default excepthook draws ``^^^^`` markers. + co_linetable=_line_only_linetable(unoptimized_code, code.co_firstlineno, f_data.lineno), ) # PyFrame_New returns a new reference to a PyFrameObject. @@ -77,18 +133,9 @@ def _reconstruct_frames(data: ExceptionData) -> types.TracebackType | None: tb_next = types.TracebackType( tb_next=tb_next, tb_frame=frame, - # The original ``lasti`` indexes into the original bytecode, but the frame - # above deliberately carries a synthetic (empty) code object, so the two do - # not correspond. Handing the real ``lasti`` to the stdlib makes - # ``traceback._get_code_position`` walk ``co_positions()`` of a two-entry - # code object looking for instruction ``lasti // 2``, which raises - # StopIteration inside a generator -- surfacing as ``RuntimeError: generator - # raised StopIteration`` from any attempt to format the exception. - # - # A negative ``lasti`` is the stdlib's own signal for "no instruction - # position available": it then falls back to ``tb_lineno``, which we restore - # accurately. ``FrameData.lasti`` stays in the dump as original metadata. - tb_lasti=_NO_INSTRUCTION_OFFSET, + # The original ``lasti`` indexes into the original bytecode, not the synthetic + # one above; ``FrameData.lasti`` stays in the dump as original metadata. + tb_lasti=_SYNTHETIC_LASTI, tb_lineno=f_data.lineno, ) return tb_next diff --git a/tests/_inner/test_load_traceback.py b/tests/_inner/test_load_traceback.py index 6f93bb0..8b2bb42 100644 --- a/tests/_inner/test_load_traceback.py +++ b/tests/_inner/test_load_traceback.py @@ -10,6 +10,7 @@ import pytest from offline_debug import load_traceback, save_traceback +from offline_debug._inner.load_traceback import _MAX_UNITS_PER_ENTRY, _line_only_linetable from tests.helpers import roundtrip if TYPE_CHECKING: @@ -107,6 +108,41 @@ def test_load_traceback_should_raise_false() -> None: assert str(loaded_exc) == "test_raise_false" +@pytest.mark.parametrize( + ("firstlineno", "lineno"), + [ + (1, 1), # zero delta + (10, 12), # small positive delta: one varint byte + (10, 3), # negative delta: the sign bit + (1, 100), # delta beyond one 6-bit payload: a continuation byte + (1, 70_000), # several continuation bytes + ], +) +def test_line_only_linetable_matches_cpython_decoding(firstlineno: int, lineno: int) -> None: + """CPython must decode the hand-built table as "every instruction on ``lineno``".""" + base = compile("", "", "exec") + + code = base.replace( + co_firstlineno=firstlineno, + co_linetable=_line_only_linetable(base, firstlineno, lineno), + ) + + units = len(code.co_code) // 2 + assert list(code.co_positions()) == [(lineno, lineno, None, None)] * units + assert [line for _, _, line in code.co_lines()] == [lineno] + + +def test_line_only_linetable_covers_code_longer_than_one_entry() -> None: + """A code object needing several entries still maps every instruction.""" + code = compile("\n".join(f"v{i} = {i}" for i in range(20)), "", "exec") + units = len(code.co_code) // 2 + assert units > _MAX_UNITS_PER_ENTRY + + code = code.replace(co_linetable=_line_only_linetable(code, code.co_firstlineno, 42)) + + assert list(code.co_positions()) == [(42, 42, None, None)] * units + + def _raise_on_load() -> Never: msg = "cannot load" raise TypeError(msg) diff --git a/tests/test_traceback_formatting.py b/tests/test_traceback_formatting.py index 3a20873..d20e297 100644 --- a/tests/test_traceback_formatting.py +++ b/tests/test_traceback_formatting.py @@ -10,20 +10,26 @@ surfaced as ``RuntimeError: generator raised StopIteration`` from *any* attempt to format a loaded exception -- the library's central promise. -Reconstructed frames now report no instruction offset, so the stdlib falls back -to ``tb_lineno``, which is restored accurately. +Reconstructed frames now carry a location table that maps every synthetic +instruction to the restored line without columns, so every printer -- the +``traceback`` module and the C printer behind the default excepthook alike -- +shows the right line and draws no caret markers. """ from __future__ import annotations import pickle +import sys import traceback from io import BytesIO -from typing import Never +from typing import TYPE_CHECKING, Never from offline_debug import ExceptionData, load_traceback from tests.helpers import roundtrip +if TYPE_CHECKING: + from types import TracebackType + def formatted(exc: BaseException) -> str: """Format an exception exactly as an unhandled traceback would print.""" @@ -35,6 +41,16 @@ def positions(exc: BaseException) -> list[tuple[str, int | None, str]]: return [(f.filename, f.lineno, f.name) for f in traceback.extract_tb(exc.__traceback__)] +def reconstructed_entries(exc: BaseException, count: int) -> list[TracebackType]: + """Return the last ``count`` traceback entries: the ones rebuilt from the dump.""" + entries: list[TracebackType] = [] + tb = exc.__traceback__ + while tb is not None: + entries.append(tb) + tb = tb.tb_next + return entries[-count:] + + def inner() -> None: """Raise the exception under test.""" local_value = 42 @@ -94,6 +110,46 @@ def test_loaded_frames_report_source_lines() -> None: assert "raise ValueError(str(local_value))" in text +def test_loaded_frames_carry_line_only_positions() -> None: + """ + Reconstructed code objects map every instruction to the restored line, columnless. + + That is what makes the stdlib formatter, ``frame.f_lineno`` and ``tb_lineno`` agree + while no printer has columns to draw ``^^^^`` markers from. + """ + original = make_error() + expected = positions(original) + restored = roundtrip(original) + + entries = reconstructed_entries(restored, len(expected)) + + for tb, (_, lineno, _) in zip(entries, expected, strict=True): + assert tb.tb_lasti >= 0 + assert tb.tb_lineno == lineno + assert tb.tb_frame.f_lineno == lineno + assert set(tb.tb_frame.f_code.co_positions()) == {(lineno, lineno, None, None)} + + +def test_default_excepthook_prints_no_caret_lines(capsys) -> None: + """ + The default excepthook must print reconstructed frames without stray caret lines. + + Python 3.12 prints unhandled exceptions from C rather than through the + ``traceback`` module the other tests exercise, and that printer drew a zero-width + caret line -- an empty line -- under every reconstructed frame. + """ + original = make_error() + restored = roundtrip(original) + + sys.__excepthook__(type(restored), restored, restored.__traceback__) + err = capsys.readouterr().err + + assert "ValueError: 42" in err + for filename, lineno, name in positions(original): + assert f'File "{filename}", line {lineno}, in {name}' in err + assert all(line.strip() for line in err.rstrip("\n").splitlines()) + + def test_chained_exception_keeps_cause_wording() -> None: """A loaded chain prints the same explanatory line as the original.""" try: From 5b706064ba7621ffa9cc296e256ddd681410adf2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 17:59:07 +0000 Subject: [PATCH 14/18] Probe a genuinely empty frame slot in the f_back offset test The wrong-offset test zeroed the slot at offset 80 of a live frame and left it zeroed. That slot is f_builtins on 3.12 and the strong f_funcobj reference on 3.13, so the test only passed because those fields happen to be NULL-safe at deallocation, and on 3.13 it leaked the function object. Its comment called the slot "a slot that is 0", and its size guard compared against sys.getsizeof, which includes the GC header that precedes the object. Find a slot that actually holds 0 and is not f_back, bounded by the type's __basicsize__, so the scan writes into a NULL pointer such as f_trace and restores it, leaving the frame exactly as it was. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0183gC2Cb37XREy7HSim1TQr --- tests/_inner/c_api/test__link_frame.py | 31 ++++++++++++++++++-------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/tests/_inner/c_api/test__link_frame.py b/tests/_inner/c_api/test__link_frame.py index 155e412..f5082ac 100644 --- a/tests/_inner/c_api/test__link_frame.py +++ b/tests/_inner/c_api/test__link_frame.py @@ -98,22 +98,35 @@ def test_get_f_back_offset_wrong_offset_restoration() -> None: # this test ran alone, under ``-k``, or under ``--lf``. frame = create_frame(code=compile("pass", "", "exec"), frame_globals={}, frame_locals={}) + f_back_offset = _get_f_back_offset() + assert f_back_offset is not None + _get_f_back_offset.cache_clear() + ptr_size = ctypes.sizeof(ctypes.c_void_p) - # An arbitrary slot that is 0 but is not f_back, forcing the scan to reject it. - probe_offset = ptr_size * 10 - # The probe writes raw memory, so assert the object is actually big enough rather - # than trusting a frame layout that differs by version and platform. - assert probe_offset + ptr_size <= sys.getsizeof(frame) + object_header_size = 2 * ptr_size # refcount + type pointer + # The scan writes raw memory, so the probe must be a slot that genuinely holds 0 -- + # a NULL pointer such as ``f_trace`` -- rather than a live field zeroed for the + # occasion, and it must lie inside the object's fixed part (``__basicsize__``; + # ``sys.getsizeof`` would also count the GC header that precedes the object). The + # frame layout differs by version and platform, so find such a slot instead of + # assuming one. + body = range(object_header_size, type(frame).__basicsize__ - ptr_size + 1, ptr_size) + probe_offset = next( + candidate + for candidate in body + if candidate != f_back_offset + and ctypes.c_ssize_t.from_address(id(frame) + candidate).value == 0 + ) with ( patch.object(_create_frame_module, "_get_py_frame_new", return_value=lambda *_: frame), patch.object(_link_frame_module, "range", return_value=[probe_offset]), ): - ctypes.c_ssize_t.from_address(id(frame) + probe_offset).value = 0 offset = _get_f_back_offset() - assert offset is None - # The scan must put the slot back so the frame's refcounts stay sane. - assert ctypes.c_ssize_t.from_address(id(frame) + probe_offset).value == 0 + + assert offset is None + # The scan must put the slot back so the frame's refcounts stay sane. + assert ctypes.c_ssize_t.from_address(id(frame) + probe_offset).value == 0 def test_get_f_back_offset_ctypes_error() -> None: From 20c286a4050b1dbaa778e329989f8fb7d35995d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 18:40:57 +0000 Subject: [PATCH 15/18] Pickle only the skeleton of an exception group The pickle stored for a group carried its entire member tree, although the loader discards those embedded members and rebuilds the group around the nodes saved for each of them. That duplicated every member in the dump, once per level for nested groups, and it let a single member that fails only on load replace the whole group with the placeholder RuntimeError, dropping every member that would have loaded fine. Pickle the group around a single stand-in member instead, so the dump holds the group's class, message and state and nothing else. A BaseExceptionGroup cannot be empty, and its constructor decides the resulting class from the members it is given, so the stand-in is a BaseException whenever the group is one, keeping a BaseExceptionGroup of KeyboardInterrupts from being recorded as an ExceptionGroup. The loader is unchanged, and so is the on-disk format: a group written this way is read by every earlier release, and their dumps by this one. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ArfHWPuzeVqEMipVKY1r6X --- offline_debug/_inner/save_traceback.py | 38 ++++++++++++++++++++++++-- tests/_inner/test_load_traceback.py | 31 +++++++++++++++++++++ tests/_inner/test_save_traceback.py | 21 +++++++++++++- tests/test_exception_fidelity.py | 18 ++++++++++++ 4 files changed, 105 insertions(+), 3 deletions(-) diff --git a/offline_debug/_inner/save_traceback.py b/offline_debug/_inner/save_traceback.py index c701300..ee5895c 100644 --- a/offline_debug/_inner/save_traceback.py +++ b/offline_debug/_inner/save_traceback.py @@ -6,7 +6,11 @@ from io import BytesIO from pathlib import Path -from offline_debug._inner._pickle_helpers import exception_safe_dump, exception_safe_dumps +from offline_debug._inner._pickle_helpers import ( + exception_safe_dump, + exception_safe_dumps, + reconstruct_exception_group, +) from offline_debug._inner.models import ExceptionData, ExceptionGroupData, FrameData # Internal attributes that are either unpicklable or redundant in a new process. @@ -91,10 +95,40 @@ def _serialize_frames( return tb_frames +def _member_stand_in(group: BaseExceptionGroup[BaseException]) -> BaseException: + """ + Return the single stand-in member that the pickled skeleton of ``group`` holds. + + A ``BaseExceptionGroup`` cannot be empty, so the skeleton needs one member, and it + must be as "base" as the group itself: ``BaseExceptionGroup.__new__`` turns a plain + ``BaseExceptionGroup`` holding only ``Exception`` members into an ``ExceptionGroup``, + and rejects a ``BaseException`` member inside an ``Exception`` subclass -- either + would record the wrong class in the dump. + """ + cls = Exception if isinstance(group, Exception) else BaseException + return cls("member saved as a separate node") + + +def _group_skeleton(group: BaseExceptionGroup[BaseException]) -> BaseExceptionGroup[BaseException]: + """ + Return a copy of ``group`` whose members are replaced by a single stand-in. + + Members are saved as nodes of their own and the loader rebuilds the group around + those (see ``load_traceback._build_exception``), so pickling them along with the + group would only duplicate every member -- once per level for nested groups -- and + let a single member that fails to load take the whole group down with it, since the + placeholder replaces the entire pickle. + """ + return reconstruct_exception_group( + type(group), group.message, (_member_stand_in(group),), group.__dict__.copy() or None + ) + + def _pickle_exception(exc: BaseException) -> bytes: """Pickle ``exc`` itself, falling back to a placeholder if it cannot round-trip.""" try: - exc_pickle = exception_safe_dumps(exc) + to_pickle = _group_skeleton(exc) if isinstance(exc, BaseExceptionGroup) else exc + exc_pickle = exception_safe_dumps(to_pickle) # A dump that cannot be loaded later is worse than a placeholder, so also # verify the exception survives loading (e.g. a custom __reduce__ whose # reconstruction fails only at load time). diff --git a/tests/_inner/test_load_traceback.py b/tests/_inner/test_load_traceback.py index 8b2bb42..72f98f0 100644 --- a/tests/_inner/test_load_traceback.py +++ b/tests/_inner/test_load_traceback.py @@ -156,6 +156,14 @@ def __reduce__(self) -> tuple: return (_raise_on_load, ()) +class LoadFailError(Exception): + """Exception that pickles fine but whose reconstruction fails at load time.""" + + def __reduce__(self) -> tuple: + """Reduce to a callable that raises when the pickle is loaded.""" + return (_raise_on_load, ()) + + def record_rebuilt_exceptions(monkeypatch) -> list[str]: """Record the type name of every exception whose traceback the loader rebuilds.""" import offline_debug._inner.load_traceback as load_module @@ -209,3 +217,26 @@ def test_placeholder_group_members_reached_by_a_link_are_still_rebuilt(monkeypat assert isinstance(restored, RuntimeError) assert isinstance(restored.__cause__, ValueError) assert sorted(rebuilt) == ["RuntimeError", "ValueError"] + + +def test_group_survives_a_member_that_fails_only_on_load() -> None: + """ + One member that cannot be loaded must not take its whole group down. + + The group's own pickle carries no members -- they are nodes of their own -- so a + member's load failure is confined to that member's placeholder. + """ + try: + raise ExceptionGroup("group", [LoadFailError("bad"), ValueError("good")]) + except ExceptionGroup as err: + original = err + + restored = roundtrip(original) + + assert isinstance(restored, ExceptionGroup) + assert restored.message == "group" + bad, good = restored.exceptions + assert isinstance(bad, RuntimeError) + assert "Unpicklable exception LoadFailError" in str(bad) + assert isinstance(good, ValueError) + assert str(good) == "good" diff --git a/tests/_inner/test_save_traceback.py b/tests/_inner/test_save_traceback.py index c6f90f8..1a523cf 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 ExceptionData, load_traceback, save_traceback +from offline_debug import ExceptionData, ExceptionGroupData, load_traceback, save_traceback if TYPE_CHECKING: from collections.abc import Callable @@ -238,3 +238,22 @@ def test_save_traceback_file_none() -> None: exc = ValueError("test") data = save_traceback(exc, None) assert isinstance(data, ExceptionData) + + +def test_group_pickle_holds_no_members() -> None: + """ + A group's own pickle must not carry its members. + + Members are saved as nodes of their own and the loader rebuilds the group around + those, so embedding them too would duplicate every member -- once per nesting + level -- and let a single member's load failure replace the whole group. + """ + inner = ExceptionGroup("inner", [ValueError("needle")]) + data = save_traceback(ExceptionGroup("outer", [inner]), None) + + assert isinstance(data, ExceptionGroupData) + inner_data = data.exceptions[0] + assert isinstance(inner_data, ExceptionGroupData) + assert b"needle" in inner_data.exceptions[0].exc_pickle + assert b"needle" not in inner_data.exc_pickle + assert b"needle" not in data.exc_pickle diff --git a/tests/test_exception_fidelity.py b/tests/test_exception_fidelity.py index 6e8f81c..0ddea26 100644 --- a/tests/test_exception_fidelity.py +++ b/tests/test_exception_fidelity.py @@ -175,3 +175,21 @@ def raise_coded_group() -> Never: assert isinstance(loaded_exc, CodedGroup) assert loaded_exc.code == EXPECTED_VALUE + + +def test_base_exception_group_stays_a_base_exception_group() -> None: + """ + A ``BaseExceptionGroup`` holding a ``BaseException`` must not load as ``ExceptionGroup``. + + ``BaseExceptionGroup.__new__`` downgrades a plain ``BaseExceptionGroup`` to + ``ExceptionGroup`` when every member is an ``Exception``, so the stand-in member the + saved skeleton holds must be as "base" as the group's real members. + """ + try: + raise BaseExceptionGroup("grp", [KeyboardInterrupt()]) + except BaseExceptionGroup as e: + loaded_exc = roundtrip(e) + + assert isinstance(loaded_exc, BaseExceptionGroup) + assert type(loaded_exc) is BaseExceptionGroup + assert isinstance(loaded_exc.exceptions[0], KeyboardInterrupt) From 48c22d10ebd9e872a5682b6a1890771528f82eb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 18:40:58 +0000 Subject: [PATCH 16/18] Document the depth ceiling the nested dump puts on pickle The traversal that saves and loads an exception graph is iterative, but the dump still nests every node inside the node that links to it, and pickling that nesting recurses at the C level. A cause chain of roughly 3,300 links raises RecursionError from save_traceback regardless of sys.setrecursionlimit, while the docstrings, the README and the test named for it suggested deep chains were unbounded. State the ceiling where a user would look for it, in save_traceback's docstring and the README, scope the traversal docstring to the walk, and exercise the deep-chain round trip at 1500 links: above the interpreter's default recursion limit, where the pre-0.4.0 recursive walk gave out, and well below pickle's. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ArfHWPuzeVqEMipVKY1r6X --- README.md | 5 +++++ offline_debug/_inner/models.py | 9 +++++---- offline_debug/_inner/save_traceback.py | 10 +++++++++- tests/test_exception_cycles.py | 15 ++++++++++++--- 4 files changed, 31 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 7f75c64..5c7339e 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,11 @@ payload = [ ] ``` +Chains are walked iteratively, so `sys.getrecursionlimit()` does not bound them. The dump itself +does have a ceiling: it nests every node inside the node that links to it, and pickling that +nesting is recursive at the C level, so a `__cause__`/`__context__` chain of roughly 3,000 links +is the practical maximum, past which `save_traceback` raises `RecursionError`. + ### Dump Compatibility The on-disk layout gained one optional field in 0.4.0 (`suppress_context`, defaulting to diff --git a/offline_debug/_inner/models.py b/offline_debug/_inner/models.py index a162bfa..233c8e3 100644 --- a/offline_debug/_inner/models.py +++ b/offline_debug/_inner/models.py @@ -59,10 +59,11 @@ def walk_exception_data(data: ExceptionData) -> Iterator[ExceptionData]: links naively either visits a node repeatedly or never terminates. This is the traversal any consumer should use to render or re-serialize a dump: - it is iterative (so a deep chain cannot exhaust the stack) and it stops descending - as soon as it reaches a node it has already yielded. Use ``id()`` of the yielded - nodes to reference them — that identity is what encodes cycles and sharing, and it - is what a JSON projection needs in order to emit references instead of nesting. + it is iterative (so walking a deep chain cannot exhaust the stack) and it stops + descending as soon as it reaches a node it has already yielded. Use ``id()`` of the + yielded nodes to reference them — that identity is what encodes cycles and sharing, + and it is what a JSON projection needs in order to emit references instead of + nesting. """ seen: set[int] = set() stack = [data] diff --git a/offline_debug/_inner/save_traceback.py b/offline_debug/_inner/save_traceback.py index ee5895c..427fe87 100644 --- a/offline_debug/_inner/save_traceback.py +++ b/offline_debug/_inner/save_traceback.py @@ -205,7 +205,15 @@ def node_for(e: BaseException) -> ExceptionData: def save_traceback(exc: BaseException, file: Path | BytesIO | None) -> ExceptionData: - """Serialize an exception and its traceback to a file.""" + """ + Serialize an exception and its traceback to a file. + + The exception graph is walked iteratively, so the interpreter's recursion limit does + not bound it. The dump, however, nests every node inside the node that links to it, + and pickling that nesting is recursive at the C level: a ``__cause__``/``__context__`` + chain of roughly 3,000 links is the practical ceiling, past which this raises + ``RecursionError`` regardless of ``sys.setrecursionlimit``. + """ data = _serialize_exc_data(exc, roundtrip_cache={}) if file is None: return data diff --git a/tests/test_exception_cycles.py b/tests/test_exception_cycles.py index df9885f..2186469 100644 --- a/tests/test_exception_cycles.py +++ b/tests/test_exception_cycles.py @@ -35,7 +35,10 @@ from tests.helpers import roundtrip MAX_NODES_PER_LINK = 2 -DEEP_CHAIN_LINKS = 400 +# Above the default recursion limit, which the pre-0.4.0 recursive walk could not get past, +# and below the ceiling the dump's nesting imposes on pickle (see the ``save_traceback`` +# docstring), so the round trip is exercised well past the old failure point. +DEEP_CHAIN_LINKS = 1500 def self_caused() -> BaseException: @@ -151,8 +154,14 @@ def test_chained_exceptions_stay_linear() -> None: assert node_count <= links * MAX_NODES_PER_LINK -def test_deep_chain_is_not_recursion_bound() -> None: - """A long but acyclic cause chain must not exhaust the interpreter stack.""" +def test_deep_chain_round_trips_past_the_recursion_limit() -> None: + """ + A long but acyclic cause chain must round-trip well past the interpreter's limit. + + The remaining bound is pickle's, on the depth of the nested dump, and lies far above + the limit set here (see the ``save_traceback`` docstring). + """ + assert sys.getrecursionlimit() < DEEP_CHAIN_LINKS deepest: BaseException = ValueError("root") for i in range(DEEP_CHAIN_LINKS): nxt = RuntimeError(f"lvl{i}") From af3a30799e517de3b3d0ca110a3dd8d7707208f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 18:49:48 +0000 Subject: [PATCH 17/18] Keep the deep-chain tests inside Windows' pickle ceiling Raising the deep-chain tests to 1500 links turned both Windows CI jobs red: save_traceback raised RecursionError from inside pickle on 3.12 and 3.13, while every Linux job passed. The ceiling the nested dump puts on pickle is set by CPython's C recursion limit, which is lower on Windows than on Linux, so a depth that is comfortably inside the Linux ceiling of roughly 3,300 links is already past the Windows one. Run the deep-chain round trip at 400 links again, under a recursion limit of 200 so that anything recursive on the path still fails long before the chain ends, and name that limit instead of comparing against the interpreter's default. State the platform-dependent ceiling in the save_traceback docstring and the README, with the Windows figure the CI run established. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ArfHWPuzeVqEMipVKY1r6X --- README.md | 5 +++-- offline_debug/_inner/save_traceback.py | 7 ++++--- tests/test_exception_cycles.py | 21 ++++++++++++--------- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 5c7339e..5538409 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,9 @@ payload = [ Chains are walked iteratively, so `sys.getrecursionlimit()` does not bound them. The dump itself does have a ceiling: it nests every node inside the node that links to it, and pickling that -nesting is recursive at the C level, so a `__cause__`/`__context__` chain of roughly 3,000 links -is the practical maximum, past which `save_traceback` raises `RecursionError`. +nesting is recursive at the C level, with a platform-dependent limit. A `__cause__`/`__context__` +chain of roughly 3,000 links is the practical maximum on Linux, and fewer than 1,500 on Windows, +whose C recursion limit is lower; past it `save_traceback` raises `RecursionError`. ### Dump Compatibility diff --git a/offline_debug/_inner/save_traceback.py b/offline_debug/_inner/save_traceback.py index 427fe87..d7e4d6d 100644 --- a/offline_debug/_inner/save_traceback.py +++ b/offline_debug/_inner/save_traceback.py @@ -210,9 +210,10 @@ def save_traceback(exc: BaseException, file: Path | BytesIO | None) -> Exception The exception graph is walked iteratively, so the interpreter's recursion limit does not bound it. The dump, however, nests every node inside the node that links to it, - and pickling that nesting is recursive at the C level: a ``__cause__``/``__context__`` - chain of roughly 3,000 links is the practical ceiling, past which this raises - ``RecursionError`` regardless of ``sys.setrecursionlimit``. + and pickling that nesting is recursive at the C level, with a platform-dependent + ceiling: a ``__cause__``/``__context__`` chain of roughly 3,000 links on Linux, and + fewer than 1,500 on Windows, where CPython's C recursion limit is lower. Past it this + raises ``RecursionError`` regardless of ``sys.setrecursionlimit``. """ data = _serialize_exc_data(exc, roundtrip_cache={}) if file is None: diff --git a/tests/test_exception_cycles.py b/tests/test_exception_cycles.py index 2186469..68378c3 100644 --- a/tests/test_exception_cycles.py +++ b/tests/test_exception_cycles.py @@ -35,10 +35,12 @@ from tests.helpers import roundtrip MAX_NODES_PER_LINK = 2 -# Above the default recursion limit, which the pre-0.4.0 recursive walk could not get past, -# and below the ceiling the dump's nesting imposes on pickle (see the ``save_traceback`` -# docstring), so the round trip is exercised well past the old failure point. -DEEP_CHAIN_LINKS = 1500 +# The deep-chain round trip runs under this recursion limit, so that anything recursive +# on the save or load path fails long before the chain ends, while the chain itself +# stays well inside the ceiling pickle puts on the nested dump. That ceiling is lowest on +# Windows, where 1500 links already exceed it (see the ``save_traceback`` docstring). +LOWERED_RECURSION_LIMIT = 200 +DEEP_CHAIN_LINKS = 400 def self_caused() -> BaseException: @@ -158,10 +160,11 @@ def test_deep_chain_round_trips_past_the_recursion_limit() -> None: """ A long but acyclic cause chain must round-trip well past the interpreter's limit. - The remaining bound is pickle's, on the depth of the nested dump, and lies far above - the limit set here (see the ``save_traceback`` docstring). + The remaining bound is pickle's, on the depth of the nested dump, and lies above the + chain length used here on every supported platform (see the ``save_traceback`` + docstring). """ - assert sys.getrecursionlimit() < DEEP_CHAIN_LINKS + assert LOWERED_RECURSION_LIMIT < DEEP_CHAIN_LINKS deepest: BaseException = ValueError("root") for i in range(DEEP_CHAIN_LINKS): nxt = RuntimeError(f"lvl{i}") @@ -169,7 +172,7 @@ def test_deep_chain_round_trips_past_the_recursion_limit() -> None: deepest = nxt original_limit = sys.getrecursionlimit() - sys.setrecursionlimit(200) + sys.setrecursionlimit(LOWERED_RECURSION_LIMIT) try: restored = roundtrip(deepest) finally: @@ -404,7 +407,7 @@ def test_walk_is_not_recursion_bound() -> None: data = parse_traceback(buffer) original_limit = sys.getrecursionlimit() - sys.setrecursionlimit(200) + sys.setrecursionlimit(LOWERED_RECURSION_LIMIT) try: count = sum(1 for _ in walk_exception_data(data)) finally: From eba948668765b187da7f27595052c6f27d66c1ac Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 20:06:43 +0000 Subject: [PATCH 18/18] Draw the original carets under reconstructed frames Reconstructed frames run a synthetic code object, and its location table mapped every instruction to the restored line with no columns, so no printer had anything to draw the ^^^^ markers from. The columns were never lost, though: the dump keeps the original tb_lasti on every FrameData and marshals the original code object with its location table intact, so the failing instruction's position is one co_positions() lookup away. Look that position up on load and emit it as a long-form location entry (line delta, end line delta, and the two columns stored plus one) covering every synthetic instruction. Both the traceback module and the C printer behind the default excepthook now render a loaded exception, carets and tilde anchors included, byte for byte as they render the original; the new tests assert exactly that on both printers. Old dumps gain the carets too. Fall back to the line-only entry when the dump gives nothing usable: an offset the original bytecode cannot resolve, an instruction without column information (-X no_debug_ranges), or a position on another line than the one recorded. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018UU24YJpF4m9snvpdP5NRj --- README.md | 13 +-- offline_debug/_inner/load_traceback.py | 79 +++++++++++++---- tests/_inner/test_load_traceback.py | 114 +++++++++++++++++++++---- tests/test_traceback_formatting.py | 101 +++++++++++++++++++--- 4 files changed, 258 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 5538409..921dcfe 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ whose C recursion limit is lower; past it `save_traceback` raises `RecursionErro The on-disk layout gained one optional field in 0.4.0 (`suppress_context`, defaulting to `False`); nothing was removed or renamed. Dumps written by earlier versions load, raise and print normally, and they gain the traceback-formatting fix, having previously failed to format -at all. +at all -- caret markers included, since the instruction offset those need was always recorded. What an old dump cannot gain is anything the writer never recorded: it stored a separate copy of every shared exception, and no `suppress_context` at all. So it still expands to more nodes @@ -125,11 +125,12 @@ not one whose graph genuinely contains a cycle (`raise e from e`): the pre-0.4.0 - **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`. -- **Line-level Position Fidelity**: Reconstructed frames carry a synthetic code object (real optimized - bytecode would segfault on `f_locals` access) whose location table maps every instruction to the - restored line with no column information. Tracebacks print with correct files, lines and functions - from every printer, and `frame.f_lineno` is accurate; the `^^^^` column markers are not available - for reconstructed frames. +- **Position Fidelity**: Reconstructed frames carry a synthetic code object (real optimized bytecode + would segfault on `f_locals` access) whose location table maps every instruction to the position of + the original failing instruction: its line and, when the original code recorded them, its columns. + Tracebacks print with correct files, lines and functions from every printer, `frame.f_lineno` is + accurate, and the `^^^^` markers under the failing expression match the original's. Only code that + carried no column information (e.g. run with `-X no_debug_ranges`) maps to its line alone. - **Python 3.13 Compatibility**: Leverages PEP 667 features where `f_locals` is a write-through proxy, allowing for accurate local variable restoration. - **Support python 3.12 as well** diff --git a/offline_debug/_inner/load_traceback.py b/offline_debug/_inner/load_traceback.py index 292815a..105c35a 100644 --- a/offline_debug/_inner/load_traceback.py +++ b/offline_debug/_inner/load_traceback.py @@ -5,6 +5,7 @@ import sys import types from io import BytesIO +from itertools import islice from pathlib import Path from types import CodeType @@ -21,18 +22,21 @@ # Instruction offset every reconstructed traceback entry points at. The synthetic code # object built in ``_reconstruct_frames`` maps *all* of its instructions to the restored -# line, so any offset would resolve to the same position; the first one is always valid. +# position, so any offset would resolve to the same one; the first is always valid. _SYNTHETIC_LASTI = 0 # ``co_linetable`` encoding (``Objects/locations.md`` in CPython): a sequence of entries, # each a header byte ``0b1_CCCC_LLL`` -- location code ``CCCC`` covering ``LLL + 1`` code # units -- followed by code-specific varints. Code 13 is "line only, no columns", followed # by one signed varint holding the line delta from the previous entry, which for the -# first entry is relative to ``co_firstlineno``. Varints carry 6 payload bits per byte, -# least significant first, with bit 6 set on every byte but the last; signed values put -# the sign in the low bit and the magnitude above it. +# first entry is relative to ``co_firstlineno``. Code 14 is the long form: the same line +# delta, then the end line as an unsigned delta from that line, then the start and end +# columns, each stored plus one so that zero can mean "no column". Varints carry 6 +# payload bits per byte, least significant first, with bit 6 set on every byte but the +# last; signed values put the sign in the low bit and the magnitude above it. _ENTRY_HEADER_FLAG = 0x80 _NO_COLUMNS_LOCATION_CODE = 13 +_LONG_LOCATION_CODE = 14 _MAX_UNITS_PER_ENTRY = 8 _CODE_UNIT_SIZE = 2 _VARINT_PAYLOAD_BITS = 6 @@ -55,25 +59,67 @@ def _signed_varint(value: int) -> bytes: return _varint(((-value) << 1) | 1 if value < 0 else value << 1) -def _line_only_linetable(code: CodeType, firstlineno: int, lineno: int) -> bytes: +# A source position as ``co_positions()`` reports it: start line, end line, and the +# start and end columns, which are ``None`` when the code carries no column information. +_Position = tuple[int, int, int | None, int | None] + + +def _location_table(code: CodeType, firstlineno: int, position: _Position) -> bytes: """ - Build a ``co_linetable`` placing every instruction of ``code`` on ``lineno``, columnless. + Build a ``co_linetable`` placing every instruction of ``code`` at ``position``. ``firstlineno`` is the ``co_firstlineno`` the table will be paired with, since the - first entry's line is stored relative to it. + first entry's line is stored relative to it. A position without columns produces + line-only entries; one with columns produces long-form entries, which is what lets + the traceback printers draw ``^^^^`` markers under the failing expression. """ + lineno, end_lineno, col, end_col = position units = len(code.co_code) // _CODE_UNIT_SIZE delta = lineno - firstlineno table = bytearray() while units > 0: run = min(units, _MAX_UNITS_PER_ENTRY) - table.append(_ENTRY_HEADER_FLAG | (_NO_COLUMNS_LOCATION_CODE << 3) | (run - 1)) - table += _signed_varint(delta) + if col is None or end_col is None: + table.append(_ENTRY_HEADER_FLAG | (_NO_COLUMNS_LOCATION_CODE << 3) | (run - 1)) + table += _signed_varint(delta) + else: + table.append(_ENTRY_HEADER_FLAG | (_LONG_LOCATION_CODE << 3) | (run - 1)) + table += _signed_varint(delta) + table += _varint(end_lineno - lineno) + _varint(col + 1) + _varint(end_col + 1) delta = 0 units -= run return bytes(table) +def _original_position(code: CodeType, f_data: FrameData) -> _Position: + """ + Return the source position of the instruction the original traceback entry pointed at. + + ``FrameData.lasti`` indexes the original bytecode, and the original code object still + carries its location table, so the failing expression's columns are recoverable even + though the reconstructed frame runs synthetic bytecode. Falls back to the restored + line alone, columnless, when the dump gives nothing usable: a negative or out-of-range + offset, an instruction without column information (for example under + ``-X no_debug_ranges``), or a position that disagrees with the recorded line. + """ + line_only: _Position = (f_data.lineno, f_data.lineno, None, None) + if f_data.lasti < 0: + return line_only + position = next(islice(code.co_positions(), f_data.lasti // _CODE_UNIT_SIZE, None), None) + if position is None: + return line_only + line, end_line, col, end_col = position + if ( + line != f_data.lineno + or end_line is None + or end_line < line + or col is None + or end_col is None + ): + return line_only + return (line, end_line, col, end_col) + + def _reconstruct_frames(data: ExceptionData) -> types.TracebackType | None: """ Rebuild the traceback of a single exception from its serialized frames. @@ -107,11 +153,14 @@ def _reconstruct_frames(data: ExceptionData) -> types.TracebackType | None: co_qualname=code.co_qualname, # The synthetic bytecode has nothing to do with the original, so its own # location table (an empty module: line 1, columns 0-0) would put the frame - # on the wrong line and draw a bogus caret. Map all of it to the restored - # line with no columns instead: the stdlib then prints the right line, - # ``frame.f_lineno`` answers correctly, and neither the ``traceback`` module - # nor the C printer behind the default excepthook draws ``^^^^`` markers. - co_linetable=_line_only_linetable(unoptimized_code, code.co_firstlineno, f_data.lineno), + # on the wrong line and draw a bogus caret. Map all of it to the position of + # the original failing instruction instead: the stdlib then prints the right + # line, ``frame.f_lineno`` answers correctly, and both the ``traceback`` + # module and the C printer behind the default excepthook draw the same + # ``^^^^`` markers they would for the original exception. + co_linetable=_location_table( + unoptimized_code, code.co_firstlineno, _original_position(code, f_data) + ), ) # PyFrame_New returns a new reference to a PyFrameObject. @@ -134,7 +183,7 @@ def _reconstruct_frames(data: ExceptionData) -> types.TracebackType | None: tb_next=tb_next, tb_frame=frame, # The original ``lasti`` indexes into the original bytecode, not the synthetic - # one above; ``FrameData.lasti`` stays in the dump as original metadata. + # one above; it was consumed by ``_original_position`` instead. tb_lasti=_SYNTHETIC_LASTI, tb_lineno=f_data.lineno, ) diff --git a/tests/_inner/test_load_traceback.py b/tests/_inner/test_load_traceback.py index 72f98f0..e5059ac 100644 --- a/tests/_inner/test_load_traceback.py +++ b/tests/_inner/test_load_traceback.py @@ -9,8 +9,12 @@ import pytest -from offline_debug import load_traceback, save_traceback -from offline_debug._inner.load_traceback import _MAX_UNITS_PER_ENTRY, _line_only_linetable +from offline_debug import FrameData, load_traceback, save_traceback +from offline_debug._inner.load_traceback import ( + _MAX_UNITS_PER_ENTRY, + _location_table, + _original_position, +) from tests.helpers import roundtrip if TYPE_CHECKING: @@ -109,38 +113,116 @@ def test_load_traceback_should_raise_false() -> None: @pytest.mark.parametrize( - ("firstlineno", "lineno"), + ("firstlineno", "position"), [ - (1, 1), # zero delta - (10, 12), # small positive delta: one varint byte - (10, 3), # negative delta: the sign bit - (1, 100), # delta beyond one 6-bit payload: a continuation byte - (1, 70_000), # several continuation bytes + (1, (1, 1, None, None)), # zero delta, no columns + (10, (12, 12, None, None)), # small positive delta: one varint byte + (10, (3, 3, None, None)), # negative delta: the sign bit + (1, (100, 100, None, None)), # delta beyond one 6-bit payload: a continuation byte + (1, (70_000, 70_000, None, None)), # several continuation bytes + (1, (1, 1, 0, 0)), # columns: the zero column must survive the plus-one encoding + (5, (8, 8, 4, 20)), # an ordinary single-line expression + (5, (8, 11, 4, 1)), # a multi-line expression: end line delta, end column below start + (10, (3, 3, 100, 250)), # negative line delta with multi-byte columns ], ) -def test_line_only_linetable_matches_cpython_decoding(firstlineno: int, lineno: int) -> None: - """CPython must decode the hand-built table as "every instruction on ``lineno``".""" +def test_location_table_matches_cpython_decoding( + firstlineno: int, position: tuple[int, int, int | None, int | None] +) -> None: + """CPython must decode the hand-built table as "every instruction at ``position``".""" base = compile("", "", "exec") code = base.replace( co_firstlineno=firstlineno, - co_linetable=_line_only_linetable(base, firstlineno, lineno), + co_linetable=_location_table(base, firstlineno, position), ) units = len(code.co_code) // 2 - assert list(code.co_positions()) == [(lineno, lineno, None, None)] * units - assert [line for _, _, line in code.co_lines()] == [lineno] + assert list(code.co_positions()) == [position] * units + assert [line for _, _, line in code.co_lines()] == [position[0]] -def test_line_only_linetable_covers_code_longer_than_one_entry() -> None: +@pytest.mark.parametrize("position", [(42, 42, None, None), (42, 43, 7, 9)]) +def test_location_table_covers_code_longer_than_one_entry( + position: tuple[int, int, int | None, int | None], +) -> None: """A code object needing several entries still maps every instruction.""" code = compile("\n".join(f"v{i} = {i}" for i in range(20)), "", "exec") units = len(code.co_code) // 2 assert units > _MAX_UNITS_PER_ENTRY - code = code.replace(co_linetable=_line_only_linetable(code, code.co_firstlineno, 42)) + code = code.replace(co_linetable=_location_table(code, code.co_firstlineno, position)) + + assert list(code.co_positions()) == [position] * units + + +def _frame_data(lasti: int, lineno: int) -> FrameData: + """Build the parts of a ``FrameData`` that ``_original_position`` reads.""" + return FrameData(code=b"", globals={}, locals={}, lasti=lasti, lineno=lineno, stack_depth=1) + + +def _divide(numerator: int, denominator: int) -> float: + return numerator / denominator + + +def _failing_instruction() -> tuple[types.CodeType, int, int]: + """Return ``_divide``'s code with the ``lasti`` and line of its division.""" + try: + _divide(1, 0) + except ZeroDivisionError as err: + tb = err.__traceback__ + assert tb is not None + tb = tb.tb_next + assert tb is not None + return tb.tb_frame.f_code, tb.tb_lasti, tb.tb_lineno + msg = "_divide() did not raise" + raise AssertionError(msg) + + +def test_original_position_recovers_the_columns_of_the_failing_instruction() -> None: + """The saved ``lasti`` picks the original instruction, columns included.""" + code, lasti, lineno = _failing_instruction() + source = " return numerator / denominator" + + line, end_line, col, end_col = _original_position(code, _frame_data(lasti, lineno)) - assert list(code.co_positions()) == [(42, 42, None, None)] * units + assert (line, end_line) == (lineno, lineno) + assert source[col:end_col] == "numerator / denominator" + + +@pytest.mark.parametrize("lasti", [-1, 10_000]) +def test_original_position_falls_back_for_an_unusable_offset(lasti: int) -> None: + """An offset the original bytecode cannot resolve yields the line alone.""" + code, _, lineno = _failing_instruction() + + assert _original_position(code, _frame_data(lasti, lineno)) == (lineno, lineno, None, None) + + +def test_original_position_falls_back_when_the_instruction_has_no_columns() -> None: + """Code without column information (``-X no_debug_ranges``) yields the line alone.""" + code, lasti, lineno = _failing_instruction() + columnless = code.replace( + co_linetable=_location_table(code, code.co_firstlineno, (lineno, lineno, None, None)) + ) + + assert _original_position(columnless, _frame_data(lasti, lineno)) == ( + lineno, + lineno, + None, + None, + ) + + +def test_original_position_falls_back_when_the_line_disagrees() -> None: + """A position on another line than the one recorded is not trusted.""" + code, lasti, lineno = _failing_instruction() + + assert _original_position(code, _frame_data(lasti, lineno + 1)) == ( + lineno + 1, + lineno + 1, + None, + None, + ) def _raise_on_load() -> Never: diff --git a/tests/test_traceback_formatting.py b/tests/test_traceback_formatting.py index d20e297..24a88d5 100644 --- a/tests/test_traceback_formatting.py +++ b/tests/test_traceback_formatting.py @@ -11,9 +11,10 @@ to format a loaded exception -- the library's central promise. Reconstructed frames now carry a location table that maps every synthetic -instruction to the restored line without columns, so every printer -- the -``traceback`` module and the C printer behind the default excepthook alike -- -shows the right line and draws no caret markers. +instruction to the position of the original failing instruction -- its line +and, when the original code recorded them, its columns -- so every printer, the +``traceback`` module and the C printer behind the default excepthook alike, +shows the right line and draws the same ``^^^^`` markers as for the original. """ from __future__ import annotations @@ -22,6 +23,7 @@ import sys import traceback from io import BytesIO +from itertools import islice from typing import TYPE_CHECKING, Never from offline_debug import ExceptionData, load_traceback @@ -110,24 +112,99 @@ def test_loaded_frames_report_source_lines() -> None: assert "raise ValueError(str(local_value))" in text -def test_loaded_frames_carry_line_only_positions() -> None: +def original_entries(exc: BaseException) -> list[TracebackType]: + """Return every traceback entry of an exception that was never saved.""" + entries: list[TracebackType] = [] + tb = exc.__traceback__ + while tb is not None: + entries.append(tb) + tb = tb.tb_next + return entries + + +def instruction_position( + tb: TracebackType, +) -> tuple[int | None, int | None, int | None, int | None]: + """Return the source position of the instruction a traceback entry points at.""" + return next(islice(tb.tb_frame.f_code.co_positions(), tb.tb_lasti // 2, None)) + + +def test_loaded_frames_carry_the_original_positions() -> None: """ - Reconstructed code objects map every instruction to the restored line, columnless. + Reconstructed code objects map every instruction to the original failing position. - That is what makes the stdlib formatter, ``frame.f_lineno`` and ``tb_lineno`` agree - while no printer has columns to draw ``^^^^`` markers from. + That is what makes the stdlib formatter, ``frame.f_lineno`` and ``tb_lineno`` agree, + and gives every printer the columns to draw the same ``^^^^`` markers as the original. """ original = make_error() - expected = positions(original) + expected = original_entries(original) restored = roundtrip(original) entries = reconstructed_entries(restored, len(expected)) - for tb, (_, lineno, _) in zip(entries, expected, strict=True): + for tb, original_tb in zip(entries, expected, strict=True): + position = instruction_position(original_tb) + assert position[2] is not None, "the fixture must carry column information" assert tb.tb_lasti >= 0 - assert tb.tb_lineno == lineno - assert tb.tb_frame.f_lineno == lineno - assert set(tb.tb_frame.f_code.co_positions()) == {(lineno, lineno, None, None)} + assert tb.tb_lineno == original_tb.tb_lineno + assert tb.tb_frame.f_lineno == original_tb.tb_lineno + assert set(tb.tb_frame.f_code.co_positions()) == {position} + + +def divide(numerator: int, denominator: int) -> float: + """Fail inside a sub-expression, which native tracebacks mark with carets.""" + return numerator / denominator + 1 + + +def compute() -> float: + """Call ``divide`` from inside a larger expression, so the call site gets carets too.""" + return 3 + divide(1, 0) + + +def make_caret_error() -> BaseException: + """Return a ZeroDivisionError whose native traceback draws caret lines.""" + try: + compute() + except ZeroDivisionError as err: + return err + msg = "compute() did not raise" + raise AssertionError(msg) + + +def frames_of(text: str) -> str: + """Drop the header line, leaving the frames and the final exception line.""" + header = "Traceback (most recent call last):\n" + assert text.startswith(header) + return text.removeprefix(header) + + +def test_loaded_frames_print_the_same_carets_as_the_original() -> None: + """ + The ``traceback`` module must mark the failing expression exactly as it did natively. + + The loaded traceback is the original one spliced onto the loader's own frames, so + the original's rendering must be a suffix of the loaded one, caret lines included. + """ + original = make_caret_error() + native = formatted(original) + assert "^" in native, "the fixture must produce caret lines natively" + + restored = roundtrip(original) + + assert formatted(restored).endswith(frames_of(native)) + + +def test_default_excepthook_prints_the_same_carets_as_the_original(capsys) -> None: + """The C printer behind the default excepthook must draw the same carets too.""" + original = make_caret_error() + sys.__excepthook__(type(original), original, original.__traceback__) + native = capsys.readouterr().err + assert "^" in native, "the fixture must produce caret lines natively" + + restored = roundtrip(original) + sys.__excepthook__(type(restored), restored, restored.__traceback__) + + assert capsys.readouterr().err.endswith(frames_of(native)) def test_default_excepthook_prints_no_caret_lines(capsys) -> None: