clued 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
clued-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,256 @@
1
+ Metadata-Version: 2.4
2
+ Name: clued
3
+ Version: 0.1.0
4
+ Summary: Ergonomic error context for Python exceptions, built on PEP 678 add_note()
5
+ Keywords: error,exception,context,debugging,add_note,PEP678
6
+ Author: Connor Wang
7
+ Author-email: Connor Wang <wonnor.pro@gmail.com>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Typing :: Typed
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Classifier: Topic :: Software Development :: Debuggers
19
+ Requires-Python: >=3.11
20
+ Project-URL: Homepage, https://github.com/wonnor-pro/clued
21
+ Project-URL: Repository, https://github.com/wonnor-pro/clued
22
+ Project-URL: Issues, https://github.com/wonnor-pro/clued/issues
23
+ Project-URL: Changelog, https://github.com/wonnor-pro/clued/blob/main/CHANGELOG.md
24
+ Description-Content-Type: text/markdown
25
+
26
+ # Clued
27
+
28
+ [![CI](https://github.com/wonnor-pro/clued/actions/workflows/ci.yml/badge.svg)](https://github.com/wonnor-pro/clued/actions/workflows/ci.yml)
29
+
30
+ > Leave clues for your future debugging self.
31
+
32
+ `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.
33
+
34
+ ## Install
35
+
36
+ Install with pip
37
+
38
+ ```bash
39
+ pip install clued
40
+ ```
41
+
42
+ or install with uv
43
+
44
+ ```bash
45
+ uv add clued
46
+ ```
47
+
48
+ Requires Python 3.11+. Zero dependencies.
49
+
50
+ ## The Problem
51
+
52
+ When an error happens deep in your call stack, you typically get one of two outcomes:
53
+
54
+ **Option A — bare exception, no context:**
55
+
56
+ ```python
57
+ def process_order(order_id):
58
+ user = get_user(order_id) # raises ValueError("invalid id format")
59
+ charge(user)
60
+ ```
61
+
62
+ ```text
63
+ ValueError: invalid id format
64
+ File "app.py", line 2, in get_user
65
+ ```
66
+
67
+ 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.
68
+
69
+ **Option B — verbose try/except wrapping:**
70
+
71
+ ```python
72
+ def process_order(order_id):
73
+ try:
74
+ user = get_user(order_id)
75
+ except ValueError as e:
76
+ raise OrderProcessingError(
77
+ f"failed to process order {order_id}: could not fetch user"
78
+ ) from e
79
+ ```
80
+
81
+ 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.
82
+
83
+ **With `clued`:**
84
+
85
+ ```python
86
+ from clued import clue
87
+
88
+ def process_order(order_id: str, user_id: int) -> None:
89
+ with clue("processing order", order_id=order_id, user_id=user_id) as ctx:
90
+ ctx.refine(step="fetch user")
91
+ user = get_user(order_id)
92
+
93
+ ctx.refine(step="charge")
94
+ charge(user)
95
+ ```
96
+
97
+ ```text
98
+ ValueError: invalid id format
99
+ File "app.py", line 4, in get_user
100
+ - Clue 0: processing order [order_id='BAD', user_id=-1, step='fetch user'] (app.py:8)
101
+ ```
102
+
103
+ Clues nest naturally across call boundaries — each layer adds its own note, inner-to-outer:
104
+
105
+ ```python
106
+ from clued import clue
107
+
108
+ def process_order(order_id: str, user_id: int) -> None:
109
+ with clue("processing order", order_id=order_id, user_id=user_id):
110
+ charge_user(order_id, user_id)
111
+
112
+ def charge_user(order_id: str, user_id: int) -> None:
113
+ with clue("charging user", user_id=user_id) as ctx:
114
+ ctx.refine(step="fetch card")
115
+ card = get_card(user_id)
116
+
117
+ ctx.refine(step="apply charge")
118
+ apply_charge(card, order_id)
119
+ ```
120
+
121
+ ```text
122
+ ValueError: invalid card format
123
+ File "app.py", line 4, in get_card
124
+ - Clue 0: charging user [user_id=-1, step='fetch card'] (app.py:11)
125
+ - Clue 1: processing order [order_id='BAD', user_id=-1] (app.py:5)
126
+ ```
127
+
128
+ One `with` block. No custom exception class. No `raise from`. The context travels with the exception automatically.
129
+
130
+ ## Quick Start
131
+
132
+ ```python
133
+ from clued import clue, get_clues
134
+
135
+ with clue("loading config", path=path, env=env) as ctx:
136
+ ctx.refine(section="database")
137
+ load_db_config(path)
138
+ ```
139
+
140
+ 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:
141
+
142
+ ```python
143
+ except Exception as e:
144
+ for record in get_clues(e):
145
+ print(record.msg, dict(record.kv), f"{record.filename}:{record.lineno}")
146
+ ```
147
+
148
+ Nested `with clue(...)` blocks each add their own note — outermost last, so the traceback reads inner-to-outer.
149
+
150
+ ## Features
151
+
152
+ - **Structured kv** — context stored as typed key-value pairs, not just strings
153
+ - **Source location** — note includes the exact file and line where `clue()` or `refine()` was called
154
+ - **`refine()`** — narrow context in-place as a block progresses (e.g. track loop index, current step)
155
+ - **Async-safe** — uses `ContextVar` for per-task isolation; works with `asyncio.gather` and thread pools
156
+ - **Zero dependencies** — stdlib only
157
+ - **Fully typed** — ships a `py.typed` marker, passes mypy strict and pyright
158
+
159
+ ## API Reference
160
+
161
+ | Symbol | Description |
162
+ | --- | --- |
163
+ | `clue(msg, **kv)` | Context manager. Attaches context on any exception raised within the block. Yields a `ClueHandle`. |
164
+ | `ClueHandle.refine(msg=None, **kv)` | Update context in-place. `None` values delete a key. |
165
+ | `ClueHandle.reset()` | Restore to original message and clear all kv. |
166
+ | `clue_on_error(msg_template, **kv)` | Decorator. Formats `msg_template` with bound arguments and wraps the call in `clue()`. Works on async functions too. |
167
+ | `ctx(fn, *args, msg, **kv)` | Inline wrapper. Calls `fn(*args)` inside a `clue()` context. |
168
+ | `get_clues(exc)` | Returns `list[ClueRecord]` attached to the exception. |
169
+ | `get_clue_dict(exc)` | Returns a flat merged `dict` of all kv (inner clues override outer). |
170
+ | `current_clues()` | Returns the active `tuple[ClueHandle, ...]` stack for the current context (useful for logging). |
171
+
172
+ ### `ClueRecord`
173
+
174
+ ```python
175
+ @dataclass(frozen=True)
176
+ class ClueRecord:
177
+ msg: str
178
+ kv: frozenset[tuple[str, Any]]
179
+ filename: str
180
+ lineno: int
181
+ ```
182
+
183
+ ## Usage Patterns
184
+
185
+ ### Decorator
186
+
187
+ ```python
188
+ from clued import clue_on_error
189
+
190
+ @clue_on_error("processing item {item_id}", source="worker")
191
+ def process_item(item_id: str) -> None:
192
+ ...
193
+
194
+ @clue_on_error("fetch user {user_id}")
195
+ async def fetch_user(user_id: int) -> dict:
196
+ ...
197
+ ```
198
+
199
+ ### Inline wrapper
200
+
201
+ ```python
202
+ from clued import ctx
203
+
204
+ result = ctx(load_file, path, msg="loading file", path=path)
205
+ ```
206
+
207
+ ### Tracking progress in a loop
208
+
209
+ ```python
210
+ with clue("batch processing", total=len(items)) as ctx:
211
+ for i, item in enumerate(items):
212
+ ctx.refine(index=i, item_id=item["id"])
213
+ process_item(item)
214
+ ```
215
+
216
+ If the batch fails at item 42, the exception note shows exactly which item and index.
217
+
218
+ ### Logging integration
219
+
220
+ ```python
221
+ import logging
222
+ from clued import current_clues
223
+
224
+ class ClueFilter(logging.Filter):
225
+ def filter(self, record):
226
+ for handle in current_clues():
227
+ record.__dict__.update(handle.kv)
228
+ return True
229
+ ```
230
+
231
+ ## How It Works
232
+
233
+ `clued` is built on two Python stdlib primitives:
234
+
235
+ - **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.
236
+ - **`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.
237
+
238
+ When an exception exits a `clue` block, two things happen:
239
+
240
+ 1. A formatted string (`- Clue N: msg [k=v] (file:line)`) is appended via `add_note()`, where `N` is the depth index (0 = innermost).
241
+ 2. A `ClueRecord` is appended to `exc.__clues__` for structured access.
242
+
243
+ No monkeypatching. No custom exception base class. No runtime overhead on the happy path.
244
+
245
+ ## Development
246
+
247
+ ```bash
248
+ uv sync
249
+ uv run pytest
250
+ uv run mypy src/clued
251
+ uv run ruff check src/ tests/
252
+ ```
253
+
254
+ ## License
255
+
256
+ MIT
clued-0.1.0/README.md ADDED
@@ -0,0 +1,231 @@
1
+ # Clued
2
+
3
+ [![CI](https://github.com/wonnor-pro/clued/actions/workflows/ci.yml/badge.svg)](https://github.com/wonnor-pro/clued/actions/workflows/ci.yml)
4
+
5
+ > Leave clues for your future debugging self.
6
+
7
+ `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.
8
+
9
+ ## Install
10
+
11
+ Install with pip
12
+
13
+ ```bash
14
+ pip install clued
15
+ ```
16
+
17
+ or install with uv
18
+
19
+ ```bash
20
+ uv add clued
21
+ ```
22
+
23
+ Requires Python 3.11+. Zero dependencies.
24
+
25
+ ## The Problem
26
+
27
+ When an error happens deep in your call stack, you typically get one of two outcomes:
28
+
29
+ **Option A — bare exception, no context:**
30
+
31
+ ```python
32
+ def process_order(order_id):
33
+ user = get_user(order_id) # raises ValueError("invalid id format")
34
+ charge(user)
35
+ ```
36
+
37
+ ```text
38
+ ValueError: invalid id format
39
+ File "app.py", line 2, in get_user
40
+ ```
41
+
42
+ 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.
43
+
44
+ **Option B — verbose try/except wrapping:**
45
+
46
+ ```python
47
+ def process_order(order_id):
48
+ try:
49
+ user = get_user(order_id)
50
+ except ValueError as e:
51
+ raise OrderProcessingError(
52
+ f"failed to process order {order_id}: could not fetch user"
53
+ ) from e
54
+ ```
55
+
56
+ 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.
57
+
58
+ **With `clued`:**
59
+
60
+ ```python
61
+ from clued import clue
62
+
63
+ def process_order(order_id: str, user_id: int) -> None:
64
+ with clue("processing order", order_id=order_id, user_id=user_id) as ctx:
65
+ ctx.refine(step="fetch user")
66
+ user = get_user(order_id)
67
+
68
+ ctx.refine(step="charge")
69
+ charge(user)
70
+ ```
71
+
72
+ ```text
73
+ ValueError: invalid id format
74
+ File "app.py", line 4, in get_user
75
+ - Clue 0: processing order [order_id='BAD', user_id=-1, step='fetch user'] (app.py:8)
76
+ ```
77
+
78
+ Clues nest naturally across call boundaries — each layer adds its own note, inner-to-outer:
79
+
80
+ ```python
81
+ from clued import clue
82
+
83
+ def process_order(order_id: str, user_id: int) -> None:
84
+ with clue("processing order", order_id=order_id, user_id=user_id):
85
+ charge_user(order_id, user_id)
86
+
87
+ def charge_user(order_id: str, user_id: int) -> None:
88
+ with clue("charging user", user_id=user_id) as ctx:
89
+ ctx.refine(step="fetch card")
90
+ card = get_card(user_id)
91
+
92
+ ctx.refine(step="apply charge")
93
+ apply_charge(card, order_id)
94
+ ```
95
+
96
+ ```text
97
+ ValueError: invalid card format
98
+ File "app.py", line 4, in get_card
99
+ - Clue 0: charging user [user_id=-1, step='fetch card'] (app.py:11)
100
+ - Clue 1: processing order [order_id='BAD', user_id=-1] (app.py:5)
101
+ ```
102
+
103
+ One `with` block. No custom exception class. No `raise from`. The context travels with the exception automatically.
104
+
105
+ ## Quick Start
106
+
107
+ ```python
108
+ from clued import clue, get_clues
109
+
110
+ with clue("loading config", path=path, env=env) as ctx:
111
+ ctx.refine(section="database")
112
+ load_db_config(path)
113
+ ```
114
+
115
+ 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:
116
+
117
+ ```python
118
+ except Exception as e:
119
+ for record in get_clues(e):
120
+ print(record.msg, dict(record.kv), f"{record.filename}:{record.lineno}")
121
+ ```
122
+
123
+ Nested `with clue(...)` blocks each add their own note — outermost last, so the traceback reads inner-to-outer.
124
+
125
+ ## Features
126
+
127
+ - **Structured kv** — context stored as typed key-value pairs, not just strings
128
+ - **Source location** — note includes the exact file and line where `clue()` or `refine()` was called
129
+ - **`refine()`** — narrow context in-place as a block progresses (e.g. track loop index, current step)
130
+ - **Async-safe** — uses `ContextVar` for per-task isolation; works with `asyncio.gather` and thread pools
131
+ - **Zero dependencies** — stdlib only
132
+ - **Fully typed** — ships a `py.typed` marker, passes mypy strict and pyright
133
+
134
+ ## API Reference
135
+
136
+ | Symbol | Description |
137
+ | --- | --- |
138
+ | `clue(msg, **kv)` | Context manager. Attaches context on any exception raised within the block. Yields a `ClueHandle`. |
139
+ | `ClueHandle.refine(msg=None, **kv)` | Update context in-place. `None` values delete a key. |
140
+ | `ClueHandle.reset()` | Restore to original message and clear all kv. |
141
+ | `clue_on_error(msg_template, **kv)` | Decorator. Formats `msg_template` with bound arguments and wraps the call in `clue()`. Works on async functions too. |
142
+ | `ctx(fn, *args, msg, **kv)` | Inline wrapper. Calls `fn(*args)` inside a `clue()` context. |
143
+ | `get_clues(exc)` | Returns `list[ClueRecord]` attached to the exception. |
144
+ | `get_clue_dict(exc)` | Returns a flat merged `dict` of all kv (inner clues override outer). |
145
+ | `current_clues()` | Returns the active `tuple[ClueHandle, ...]` stack for the current context (useful for logging). |
146
+
147
+ ### `ClueRecord`
148
+
149
+ ```python
150
+ @dataclass(frozen=True)
151
+ class ClueRecord:
152
+ msg: str
153
+ kv: frozenset[tuple[str, Any]]
154
+ filename: str
155
+ lineno: int
156
+ ```
157
+
158
+ ## Usage Patterns
159
+
160
+ ### Decorator
161
+
162
+ ```python
163
+ from clued import clue_on_error
164
+
165
+ @clue_on_error("processing item {item_id}", source="worker")
166
+ def process_item(item_id: str) -> None:
167
+ ...
168
+
169
+ @clue_on_error("fetch user {user_id}")
170
+ async def fetch_user(user_id: int) -> dict:
171
+ ...
172
+ ```
173
+
174
+ ### Inline wrapper
175
+
176
+ ```python
177
+ from clued import ctx
178
+
179
+ result = ctx(load_file, path, msg="loading file", path=path)
180
+ ```
181
+
182
+ ### Tracking progress in a loop
183
+
184
+ ```python
185
+ with clue("batch processing", total=len(items)) as ctx:
186
+ for i, item in enumerate(items):
187
+ ctx.refine(index=i, item_id=item["id"])
188
+ process_item(item)
189
+ ```
190
+
191
+ If the batch fails at item 42, the exception note shows exactly which item and index.
192
+
193
+ ### Logging integration
194
+
195
+ ```python
196
+ import logging
197
+ from clued import current_clues
198
+
199
+ class ClueFilter(logging.Filter):
200
+ def filter(self, record):
201
+ for handle in current_clues():
202
+ record.__dict__.update(handle.kv)
203
+ return True
204
+ ```
205
+
206
+ ## How It Works
207
+
208
+ `clued` is built on two Python stdlib primitives:
209
+
210
+ - **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.
211
+ - **`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.
212
+
213
+ When an exception exits a `clue` block, two things happen:
214
+
215
+ 1. A formatted string (`- Clue N: msg [k=v] (file:line)`) is appended via `add_note()`, where `N` is the depth index (0 = innermost).
216
+ 2. A `ClueRecord` is appended to `exc.__clues__` for structured access.
217
+
218
+ No monkeypatching. No custom exception base class. No runtime overhead on the happy path.
219
+
220
+ ## Development
221
+
222
+ ```bash
223
+ uv sync
224
+ uv run pytest
225
+ uv run mypy src/clued
226
+ uv run ruff check src/ tests/
227
+ ```
228
+
229
+ ## License
230
+
231
+ MIT
@@ -0,0 +1,61 @@
1
+ [project]
2
+ name = "clued"
3
+ version = "0.1.0"
4
+ description = "Ergonomic error context for Python exceptions, built on PEP 678 add_note()"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.11"
8
+ authors = [
9
+ { name = "Connor Wang", email = "wonnor.pro@gmail.com" },
10
+ ]
11
+ keywords = ["error", "exception", "context", "debugging", "add_note", "PEP678"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Intended Audience :: Developers",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Programming Language :: Python :: 3.14",
20
+ "Typing :: Typed",
21
+ "Topic :: Software Development :: Libraries :: Python Modules",
22
+ "Topic :: Software Development :: Debuggers",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/wonnor-pro/clued"
27
+ Repository = "https://github.com/wonnor-pro/clued"
28
+ Issues = "https://github.com/wonnor-pro/clued/issues"
29
+ Changelog = "https://github.com/wonnor-pro/clued/blob/main/CHANGELOG.md"
30
+
31
+ [build-system]
32
+ requires = ["uv_build>=0.11.3,<0.12"]
33
+ build-backend = "uv_build"
34
+
35
+ [tool.uv.build-backend]
36
+ module-name = "clued"
37
+ module-root = "src"
38
+
39
+ [dependency-groups]
40
+ dev = [
41
+ "pytest>=7.0",
42
+ "pytest-asyncio>=0.21",
43
+ "mypy>=1.0",
44
+ "pyright>=1.1",
45
+ "ruff>=0.1",
46
+ ]
47
+
48
+ [tool.pytest.ini_options]
49
+ testpaths = ["tests"]
50
+ asyncio_mode = "auto"
51
+
52
+ [tool.mypy]
53
+ python_version = "3.11"
54
+ strict = true
55
+
56
+ [tool.ruff]
57
+ target-version = "py311"
58
+ line-length = 120
59
+
60
+ [tool.ruff.lint]
61
+ select = ["E", "F", "I"]
@@ -0,0 +1,17 @@
1
+ from clued._core import ClueHandle, clue
2
+ from clued._decorator import clue_on_error, clue_on_error_async
3
+ from clued._extract import current_clues, get_clue_dict, get_clues
4
+ from clued._functional import ctx
5
+ from clued._types import ClueRecord
6
+
7
+ __all__ = [
8
+ "clue",
9
+ "ClueHandle",
10
+ "ClueRecord",
11
+ "clue_on_error",
12
+ "clue_on_error_async",
13
+ "ctx",
14
+ "get_clues",
15
+ "get_clue_dict",
16
+ "current_clues",
17
+ ]
@@ -0,0 +1,97 @@
1
+ import sys
2
+ from collections.abc import Generator, Mapping
3
+ from contextlib import AbstractContextManager, contextmanager
4
+ from contextvars import ContextVar
5
+ from copy import deepcopy
6
+ from typing import Any, NamedTuple, final
7
+
8
+ from clued._types import ClueRecord
9
+
10
+ _clue_stack: ContextVar[tuple["ClueHandle", ...]] = ContextVar("clued_stack", default=())
11
+
12
+
13
+ @final
14
+ class CodeLocation(NamedTuple):
15
+ filename: str
16
+ lineno: int
17
+
18
+ @property
19
+ def code_path(self) -> str:
20
+ return f"{self.filename}:{self.lineno}"
21
+
22
+
23
+ @final
24
+ class ClueHandle:
25
+ """The object yielded by ``with clue(...) as handle``."""
26
+
27
+ __slots__ = ("msg", "kv", "_original_msg", "_loc", "_refine_loc")
28
+
29
+ def __init__(self, msg: str, kv: Mapping[str, Any], loc: CodeLocation) -> None:
30
+ self.msg = msg
31
+ self.kv: dict[str, Any] = dict(kv)
32
+ self._original_msg = msg
33
+ self._loc = loc
34
+ self._refine_loc: CodeLocation | None = None
35
+
36
+ def refine(self, msg: str | None = None, **kv: Any) -> None:
37
+ """Update context in-place. ``None`` values delete the key."""
38
+ frame = sys._getframe(1)
39
+ self._refine_loc = CodeLocation(frame.f_code.co_filename, frame.f_lineno)
40
+ del frame
41
+
42
+ if msg is not None:
43
+ self.msg = msg
44
+
45
+ for k, v in kv.items():
46
+ if v is None:
47
+ self.kv.pop(k, None)
48
+ else:
49
+ self.kv[k] = v
50
+
51
+ def reset(self) -> None:
52
+ """Restore to original msg, clear all kv, clear _refine_loc."""
53
+ self.msg = self._original_msg
54
+ self.kv.clear()
55
+ self._refine_loc = None
56
+
57
+ def _snapshot(self) -> tuple[str, dict[str, Any], CodeLocation]:
58
+ """Return current (msg, kv copy, loc) for note building."""
59
+ loc = self._refine_loc if self._refine_loc is not None else self._loc
60
+ return self.msg, dict(self.kv), loc
61
+
62
+
63
+ def _format_note(msg: str, kv: dict[str, Any], loc: CodeLocation, depth: int) -> str:
64
+ note = f"- Clue {depth}: {msg} [{', '.join(f'{k}={v!r}' for k, v in kv.items())}] ({loc.code_path})"
65
+ return note
66
+
67
+
68
+ def clue(msg: str, **kv: Any) -> AbstractContextManager[ClueHandle]:
69
+ """Context manager that attaches structured context to any exception raised within this block."""
70
+ # Capture caller location here — this function is called directly from user code.
71
+ frame = sys._getframe(1)
72
+ loc = CodeLocation(frame.f_code.co_filename, frame.f_lineno)
73
+ del frame
74
+ return _clue_cm(msg, kv, loc)
75
+
76
+
77
+ @contextmanager
78
+ def _clue_cm(msg: str, kv: Mapping[str, Any], loc: CodeLocation) -> Generator[ClueHandle, None, None]:
79
+ handle = ClueHandle(msg, kv, loc)
80
+ token = _clue_stack.set(_clue_stack.get() + (handle,))
81
+ try:
82
+ yield handle
83
+ except BaseException as exc_value:
84
+ snap_msg, snap_kv, snap_loc = handle._snapshot()
85
+ clues: list[ClueRecord] = exc_value.__dict__.setdefault("__clues__", [])
86
+ clues.append(
87
+ ClueRecord(
88
+ msg=snap_msg,
89
+ kv=frozenset((k, deepcopy(v)) for k, v in snap_kv.items()),
90
+ filename=snap_loc.filename,
91
+ lineno=snap_loc.lineno,
92
+ )
93
+ )
94
+ exc_value.add_note(_format_note(snap_msg, snap_kv, snap_loc, len(clues) - 1))
95
+ raise
96
+ finally:
97
+ _clue_stack.reset(token)
@@ -0,0 +1,49 @@
1
+ import functools
2
+ import inspect
3
+ from collections.abc import Callable, Coroutine
4
+ from typing import Any, TypeVar
5
+
6
+ from typing_extensions import ParamSpec
7
+
8
+ from clued._core import clue
9
+
10
+ T = TypeVar("T")
11
+ P = ParamSpec("P")
12
+
13
+
14
+ def clue_on_error(msg_template: str, **extra_kv: Any) -> Callable[[Callable[P, T]], Callable[P, T]]:
15
+ """Decorator that attaches a formatted clue context on error."""
16
+
17
+ def decorator(fn: Callable[P, T]) -> Callable[P, T]:
18
+ sig = inspect.signature(fn)
19
+
20
+ @functools.wraps(fn)
21
+ def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
22
+ bound = sig.bind(*args, **kwargs)
23
+ bound.apply_defaults()
24
+ formatted = msg_template.format(**bound.arguments)
25
+ with clue(formatted, **extra_kv):
26
+ return fn(*args, **kwargs)
27
+
28
+ return sync_wrapper
29
+
30
+ return decorator
31
+
32
+
33
+ def clue_on_error_async(
34
+ msg_template: str, **extra_kv: Any
35
+ ) -> Callable[[Callable[P, Coroutine[Any, Any, T]]], Callable[P, Coroutine[Any, Any, T]]]:
36
+ def decorator(fn: Callable[P, Coroutine[Any, Any, T]]) -> Callable[P, Coroutine[Any, Any, T]]:
37
+ sig = inspect.signature(fn)
38
+
39
+ @functools.wraps(fn)
40
+ async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
41
+ bound = sig.bind(*args, **kwargs)
42
+ bound.apply_defaults()
43
+ formatted = msg_template.format(**bound.arguments)
44
+ with clue(formatted, **extra_kv):
45
+ return await fn(*args, **kwargs)
46
+
47
+ return async_wrapper
48
+
49
+ return decorator
@@ -0,0 +1,22 @@
1
+ from typing import Any, cast
2
+
3
+ from clued._core import ClueHandle, _clue_stack
4
+ from clued._types import ClueRecord
5
+
6
+
7
+ def get_clues(exc: BaseException) -> list[ClueRecord]:
8
+ """Return structured clue list from exception."""
9
+ return cast(list[ClueRecord], getattr(exc, "__clues__", []))
10
+
11
+
12
+ def get_clue_dict(exc: BaseException) -> dict[str, Any]:
13
+ """Return flat merged kv dict. Inner clues override outer."""
14
+ result: dict[str, Any] = {}
15
+ for c in reversed(get_clues(exc)): # outer first, inner overrides
16
+ result.update(c.kv)
17
+ return result
18
+
19
+
20
+ def current_clues() -> tuple[ClueHandle, ...]:
21
+ """Return active clue stack from ContextVar. For logging integration."""
22
+ return _clue_stack.get()
@@ -0,0 +1,17 @@
1
+ import inspect
2
+ from typing import Any
3
+
4
+ from clued._core import clue
5
+
6
+
7
+ def ctx(fn: Any, *args: Any, msg: str, **kv: Any) -> Any:
8
+ """Inline wrapper: call fn(*args) inside a clue context."""
9
+ if inspect.iscoroutinefunction(fn):
10
+ return _async_ctx(fn, *args, msg=msg, **kv)
11
+ with clue(msg, **kv):
12
+ return fn(*args)
13
+
14
+
15
+ async def _async_ctx(fn: Any, *args: Any, msg: str, **kv: Any) -> Any:
16
+ with clue(msg, **kv):
17
+ return await fn(*args)
@@ -0,0 +1,12 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class ClueRecord:
7
+ """A single error context entry."""
8
+
9
+ msg: str
10
+ kv: frozenset[tuple[str, Any]]
11
+ filename: str
12
+ lineno: int
File without changes