Ergonomic error context for Python exceptions, built on PEP 678 add_note()
Project description
Clued
Leave clues for your future debugging self.
clued attaches structured, human-readable context to exceptions as they propagate — without changing the exception type, wrapping in a new exception, or parsing strings later.
Install
Install with pip
pip install clued
or install with uv
uv add clued
Requires Python 3.11+. Zero dependencies.
The Problem
When an error happens deep in your call stack, you typically get one of two outcomes:
Option A — bare exception, no context:
def process_order(order_id):
user = get_user(order_id) # raises ValueError("invalid id format")
charge(user)
ValueError: invalid id format
File "app.py", line 2, in get_user
The traceback tells you where it happened, not why. You don't know which order, which user, or what the system was trying to do at a higher level.
Option B — verbose try/except wrapping:
def process_order(order_id):
try:
user = get_user(order_id)
except ValueError as e:
raise OrderProcessingError(
f"failed to process order {order_id}: could not fetch user"
) from e
This works, but it's tedious. Every layer of your call stack needs its own try/except/raise from block. Real codebases end up with dozens of custom exception classes and repetitive wrapping code. Most developers just don't bother — and then they're debugging production issues with a KeyError: 'name' and no idea which record or request caused it.
With clued:
from clued import clue
def process_order(order_id: str, user_id: int) -> None:
with clue("processing order", order_id=order_id, user_id=user_id) as ctx:
ctx.refine(step="fetch user")
user = get_user(order_id)
ctx.refine(step="charge")
charge(user)
ValueError: invalid id format
File "app.py", line 4, in get_user
- Clue 0: processing order [order_id='BAD', user_id=-1, step='fetch user'] (app.py:8)
Clues nest naturally across call boundaries — each layer adds its own note, inner-to-outer:
from clued import clue
def process_order(order_id: str, user_id: int) -> None:
with clue("processing order", order_id=order_id, user_id=user_id):
charge_user(order_id, user_id)
def charge_user(order_id: str, user_id: int) -> None:
with clue("charging user", user_id=user_id) as ctx:
ctx.refine(step="fetch card")
card = get_card(user_id)
ctx.refine(step="apply charge")
apply_charge(card, order_id)
ValueError: invalid card format
File "app.py", line 4, in get_card
- Clue 0: charging user [user_id=-1, step='fetch card'] (app.py:11)
- Clue 1: processing order [order_id='BAD', user_id=-1] (app.py:5)
One with block. No custom exception class. No raise from. The context travels with the exception automatically.
Quick Start
from clued import clue, get_clues
with clue("loading config", path=path, env=env) as ctx:
ctx.refine(section="database")
load_db_config(path)
On any exception raised inside the block, clued calls add_note() (PEP 678) to attach a formatted string, and stores a structured ClueRecord you can query in code:
except Exception as e:
for record in get_clues(e):
print(record.msg, dict(record.kv), f"{record.filename}:{record.lineno}")
Nested with clue(...) blocks each add their own note — outermost last, so the traceback reads inner-to-outer.
Features
- Structured kv — context stored as typed key-value pairs, not just strings
- Source location — note includes the exact file and line where
clue()orrefine()was called refine()— narrow context in-place as a block progresses (e.g. track loop index, current step)- Async-safe — uses
ContextVarfor per-task isolation; works withasyncio.gatherand thread pools - Zero dependencies — stdlib only
- Fully typed — ships a
py.typedmarker, passes mypy strict and pyright
API Reference
| Symbol | Description |
|---|---|
clue(msg, **kv) |
Context manager. Attaches context on any exception raised within the block. Yields a ClueHandle. |
ClueHandle.refine(msg=None, **kv) |
Update context in-place. None values delete a key. |
ClueHandle.reset() |
Restore to original message and clear all kv. |
clue_on_error(msg_template, **kv) |
Decorator. Formats msg_template with bound arguments and wraps the call in clue(). Works on async functions too. |
ctx(fn, *args, msg, **kv) |
Inline wrapper. Calls fn(*args) inside a clue() context. |
get_clues(exc) |
Returns list[ClueRecord] attached to the exception. |
get_clue_dict(exc) |
Returns a flat merged dict of all kv (inner clues override outer). |
current_clues() |
Returns the active tuple[ClueHandle, ...] stack for the current context (useful for logging). |
ClueRecord
@dataclass(frozen=True)
class ClueRecord:
msg: str
kv: frozenset[tuple[str, Any]]
filename: str
lineno: int
Usage Patterns
Decorator
from clued import clue_on_error, clue_on_error_async
@clue_on_error("processing item {item_id}", source="worker")
def process_item(item_id: str) -> None:
...
@clue_on_error_async("fetch user {user_id}")
async def fetch_user(user_id: int) -> dict[str, str]:
...
Inline wrapper
from clued import ctx
result = ctx(load_file, path, msg="loading file", path=path)
Tracking progress in a loop
with clue("batch processing", total=len(items)) as ctx:
for i, item in enumerate(items):
ctx.refine(index=i, item_id=item["id"])
process_item(item)
If the batch fails at item 42, the exception note shows exactly which item and index.
Logging integration
import logging
from clued import current_clues
class ClueFilter(logging.Filter):
def filter(self, record):
for handle in current_clues():
record.__dict__.update(handle.kv)
return True
How It Works
clued is built on two Python stdlib primitives:
- PEP 678
add_note()(Python 3.11+) — the standard way to attach strings to exceptions. Tools like pytest, rich, and IPython already render__notes__, so you get readable output for free. contextvars.ContextVar— each asyncio task and thread inherits a copy-on-write context, so nested tasks each carry their own clue stack and cannot interfere with each other.
When an exception exits a clue block, two things happen:
- A formatted string (
- Clue N: msg [k=v] (file:line)) is appended viaadd_note(), whereNis the depth index (0 = innermost). - A
ClueRecordis appended toexc.__clues__for structured access.
No monkeypatching. No custom exception base class. No runtime overhead on the happy path.
Development
# Set up or Sync python environment
make sync
# Ruff formatter
make fmt
# Ruff linter & Mypy static type check
make check
# Pytest
make test
License
MIT
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file clued-0.1.1.tar.gz.
File metadata
- Download URL: clued-0.1.1.tar.gz
- Upload date:
- Size: 6.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.3 {"installer":{"name":"uv","version":"0.11.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2bd91c2f9628683ec764d7c2d0e3543d16f8b5c4db018a5e50232069fc56d798
|
|
| MD5 |
119ee16bc3115353864f96806eb3513e
|
|
| BLAKE2b-256 |
25d30fae3a6a26c64d25dc567cdfa3ef1013277b78cc77563e3d7deb606f03f2
|
File details
Details for the file clued-0.1.1-py3-none-any.whl.
File metadata
- Download URL: clued-0.1.1-py3-none-any.whl
- Upload date:
- Size: 7.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.3 {"installer":{"name":"uv","version":"0.11.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8b952c885d2af0e7afa36e1223377dd26d213d881f50aa698cc5fdd3d32f5bd8
|
|
| MD5 |
2f636d24f77f35b1f65dcb7be14f89ac
|
|
| BLAKE2b-256 |
216963173f931935f8bd974491fe47c32c0fed3f0978017a963f826b33864225
|