Skip to main content

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

Project description

Clued

status python license 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
- 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
- 0: charging user [user_id=-1, step='fetch card'] (app.py:11)
- 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_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:

  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

# 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

clued-0.1.3.tar.gz (6.2 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.3-py3-none-any.whl (8.0 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: clued-0.1.3.tar.gz
  • Upload date:
  • Size: 6.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.5 {"installer":{"name":"uv","version":"0.11.5","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.3.tar.gz
Algorithm Hash digest
SHA256 6dfeaf9e6cca2b617a48d30758ed2a49662f80821a6ef0c7a3ed3c901eecb89e
MD5 e71c25385f011447859fd1c4bc18f812
BLAKE2b-256 128557c7f22bcef2e94cfbb215c99c02381f5253ba46d4d8b2394b53ad057a32

See more details on using hashes here.

File details

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

File metadata

  • Download URL: clued-0.1.3-py3-none-any.whl
  • Upload date:
  • Size: 8.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.5 {"installer":{"name":"uv","version":"0.11.5","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.3-py3-none-any.whl
Algorithm Hash digest
SHA256 a8652889ab92442c695f43996e4e7d8d7c800ba83457d566cfc381ab0a45bec5
MD5 854c59968ea92f1f608074080de07584
BLAKE2b-256 6f99492f8751967f7e5287ac33b587dc052935c013531a815fd7f973012f1dac

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