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 +256 -0
- clued-0.1.0/README.md +231 -0
- clued-0.1.0/pyproject.toml +61 -0
- clued-0.1.0/src/clued/__init__.py +17 -0
- clued-0.1.0/src/clued/_core.py +97 -0
- clued-0.1.0/src/clued/_decorator.py +49 -0
- clued-0.1.0/src/clued/_extract.py +22 -0
- clued-0.1.0/src/clued/_functional.py +17 -0
- clued-0.1.0/src/clued/_types.py +12 -0
- clued-0.1.0/src/clued/py.typed +0 -0
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
|
+
[](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
|
+
[](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)
|
|
File without changes
|