Skip to main content

Ergonomic error context for Python exceptions, built on PEP 678 add_note()

Project description

Clued

CI

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() or refine() was called
  • refine() — narrow context in-place as a block progresses (e.g. track loop index, current step)
  • Async-safe — uses ContextVar for per-task isolation; works with asyncio.gather and thread pools
  • Zero dependencies — stdlib only
  • Fully typed — ships a py.typed marker, 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("processing item {item_id}", source="worker")
def process_item(item_id: str) -> None:
    ...

@clue_on_error("fetch user {user_id}")
async def fetch_user(user_id: int) -> dict:
    ...

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:

  1. A formatted string (- Clue N: msg [k=v] (file:line)) is appended via add_note(), where N is the depth index (0 = innermost).
  2. A ClueRecord is appended to exc.__clues__ for structured access.

No monkeypatching. No custom exception base class. No runtime overhead on the happy path.

Development

uv sync
uv run pytest
uv run mypy src/clued
uv run ruff check src/ tests/

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

clued-0.1.0.tar.gz (6.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

clued-0.1.0-py3-none-any.whl (7.9 kB view details)

Uploaded Python 3

File details

Details for the file clued-0.1.0.tar.gz.

File metadata

  • Download URL: clued-0.1.0.tar.gz
  • Upload date:
  • Size: 6.1 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

Hashes for clued-0.1.0.tar.gz
Algorithm Hash digest
SHA256 ef72df3445a6ad7298672c4b7498d058db439fc6bcaa8a85918788e6c0e032f9
MD5 0990aa6d35da6fd3c62ed0bd7963a279
BLAKE2b-256 b9fb6b62d515323c2c7a90e92825eadbe8450599a6a6bf27f4b462e2e1569b3c

See more details on using hashes here.

File details

Details for the file clued-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: clued-0.1.0-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

Hashes for clued-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 55b2e5091373052a12fe5ab08485dc869650a8cacb7b261c9a4b6e11b5d366cf
MD5 720ab56f7129a47a525b68a3fb7dfa7a
BLAKE2b-256 1a4b959f70591cd08df111be785ed2d572649e974b025f35d8d52aa5785d7e24

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page