Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }})
Expand All @@ -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
Expand Down
9 changes: 5 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
63 changes: 63 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,73 @@ 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. 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

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)
]
```

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, 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

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 -- 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
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 — 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

- **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`.
- **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**
Expand Down
8 changes: 7 additions & 1 deletion offline_debug/__init__.py
Original file line number Diff line number Diff line change
@@ -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__ = [
Expand All @@ -11,4 +16,5 @@
"load_traceback",
"parse_traceback",
"save_traceback",
"walk_exception_data",
]
Loading
Loading