errpath 0.1.0__py3-none-any.whl
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.
- errpath/__init__.py +19 -0
- errpath/__main__.py +4 -0
- errpath/_pydantic.py +102 -0
- errpath/analyse.py +1035 -0
- errpath/cli.py +141 -0
- errpath/diagram.py +135 -0
- errpath/ir.py +274 -0
- errpath/machine.py +165 -0
- errpath/py.typed +0 -0
- errpath/result.py +254 -0
- errpath/review.py +517 -0
- errpath/safe.py +129 -0
- errpath-0.1.0.dist-info/METADATA +622 -0
- errpath-0.1.0.dist-info/RECORD +17 -0
- errpath-0.1.0.dist-info/WHEEL +4 -0
- errpath-0.1.0.dist-info/entry_points.txt +2 -0
- errpath-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: errpath
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Result types for Python. See every error path.
|
|
5
|
+
Project-URL: Homepage, https://github.com/jagreehal/errpath-python
|
|
6
|
+
Project-URL: Documentation, https://github.com/jagreehal/errpath-python#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/jagreehal/errpath-python
|
|
8
|
+
Project-URL: Issues, https://github.com/jagreehal/errpath-python/issues
|
|
9
|
+
Author-email: Jag Reehal <jag@jagreehal.com>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: error-handling,machinejson,railway,result,static-analysis,xstate
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# errpath
|
|
27
|
+
|
|
28
|
+
**Result types for Python. See every error path.**
|
|
29
|
+
|
|
30
|
+
errpath lets you:
|
|
31
|
+
|
|
32
|
+
- return errors as values (`Ok` / `Err`), so your type checker knows every way a function can fail
|
|
33
|
+
- keep the happy path readable: `@safe` stops at the first failure, with no `if` after every call
|
|
34
|
+
- draw every error path of a function as a diagram, straight from your code
|
|
35
|
+
- catch risky changes in pull requests: new crash points, ignored errors, changed error paths
|
|
36
|
+
|
|
37
|
+
No dependencies. Python 3.11+.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
uv add errpath # or: pip install errpath
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## The problem
|
|
46
|
+
|
|
47
|
+
A Python signature can't say what a function raises:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
def get_user(user_id: str) -> User: ... # raises UserNotFound? DbError? Nothing says.
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
user = get_user("u-2")
|
|
55
|
+
except Exception as e: # which errors? the signature doesn't say
|
|
56
|
+
...
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Your type checker can't see a `raise`, so callers guess, catch everything, or forget.
|
|
60
|
+
|
|
61
|
+
errpath puts the failure in the signature.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Return errors as values
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from dataclasses import dataclass
|
|
69
|
+
|
|
70
|
+
from errpath import Err, Ok, Result, ok
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class User:
|
|
75
|
+
id: str
|
|
76
|
+
name: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class UserNotFound(Exception):
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_user(user_id: str) -> Result[User, UserNotFound]:
|
|
84
|
+
if user_id == "u-1":
|
|
85
|
+
return Ok(User(user_id, "Alice"))
|
|
86
|
+
return Err(UserNotFound(user_id))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
result = get_user("u-2")
|
|
90
|
+
if ok(result):
|
|
91
|
+
print(result.value.name) # your editor knows this is a User
|
|
92
|
+
else:
|
|
93
|
+
print(f"no such user: {result.error}") # ...and this is a UserNotFound
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The failure is part of the return type, so you can't use the value without checking first.
|
|
97
|
+
|
|
98
|
+
`match` works too:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
match get_user("u-1"):
|
|
102
|
+
case Ok(user):
|
|
103
|
+
print(user.name)
|
|
104
|
+
case Err(error):
|
|
105
|
+
print(f"no such user: {error}")
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Many steps: an `if` after every call
|
|
111
|
+
|
|
112
|
+
Real work is several steps that can each fail. Checking every result by hand gets noisy:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
def place_order(cart: Cart, card: Card) -> Result[Receipt, PriceError | CardDeclined | DbError]:
|
|
116
|
+
total = price(cart)
|
|
117
|
+
if not ok(total):
|
|
118
|
+
return total
|
|
119
|
+
payment = charge(card, total.value)
|
|
120
|
+
if not ok(payment):
|
|
121
|
+
return payment
|
|
122
|
+
return save_order(cart, payment.value)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Ten steps would mean ten checks. That's what `@safe` is for.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## `@safe`: write the happy path, stop at the first failure
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from errpath import safe_with
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@safe_with(PriceError, CardDeclined, DbError)
|
|
136
|
+
def place_order(cart: Cart, card: Card) -> Receipt:
|
|
137
|
+
total = price(cart).unwrap() # an Err stops here...
|
|
138
|
+
payment = charge(card, total).unwrap() # ...or here...
|
|
139
|
+
return save_order(cart, payment).unwrap() # ...or here
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
result = place_order(cart, card) # Result[Receipt, PriceError | CardDeclined | DbError]
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`.unwrap()` gives you the value of an `Ok`, and raises the error of an `Err`. The decorator catches it and returns it as `Err`, so the function reads top to bottom and still returns a `Result`.
|
|
146
|
+
|
|
147
|
+
- `@safe_with(A, B)` catches only those errors, and your editor knows the result is `Result[T, A | B]`. Anything else still raises, as a bug should.
|
|
148
|
+
- `@safe` catches any `Exception`, bugs included: a typo's `KeyError` comes back as an `Err` instead of crashing. Use it deliberately, as a catch-all at an outer boundary or around code you don't own. Prefer `@safe_with`.
|
|
149
|
+
- `@safe_async` and `@safe_async_with` do the same for `async def`. They never catch `asyncio.CancelledError`.
|
|
150
|
+
|
|
151
|
+
It also wraps existing code that raises:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
import json
|
|
155
|
+
|
|
156
|
+
from errpath import safe_with
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@safe_with(json.JSONDecodeError)
|
|
160
|
+
def parse(text: str) -> dict:
|
|
161
|
+
return json.loads(text)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
parse("{oops") # Err(JSONDecodeError(...)), not a crash
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## See every way it can fail
|
|
170
|
+
|
|
171
|
+
Here's a fuller checkout ([examples/checkout.py](examples/checkout.py)):
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
@safe
|
|
175
|
+
def place_order(cart: Cart, card: Card) -> Receipt:
|
|
176
|
+
total = price(cart).unwrap()
|
|
177
|
+
if cart.has_coupon:
|
|
178
|
+
validate_coupon(cart).unwrap()
|
|
179
|
+
payment = charge(card, total).unwrap()
|
|
180
|
+
receipt = save_order(cart, payment).unwrap()
|
|
181
|
+
send_confirmation(receipt).unwrap()
|
|
182
|
+
return receipt
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
errpath diagram examples/checkout.py place_order
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
prints this diagram, which GitHub draws for you in any README, issue or PR:
|
|
190
|
+
|
|
191
|
+
```mermaid
|
|
192
|
+
flowchart LR
|
|
193
|
+
Start(["place_order"])
|
|
194
|
+
Done((Success))
|
|
195
|
+
S1["send_confirmation"]
|
|
196
|
+
S1 -->|ok| Done
|
|
197
|
+
E2[/"EmailError"/]
|
|
198
|
+
S1 -->|err| E2
|
|
199
|
+
S3["save_order"]
|
|
200
|
+
S3 -->|ok| S1
|
|
201
|
+
E4[/"DbError"/]
|
|
202
|
+
S3 -->|err| E4
|
|
203
|
+
S5["charge"]
|
|
204
|
+
S5 -->|ok| S3
|
|
205
|
+
E6[/"CardDeclined"/]
|
|
206
|
+
S5 -->|err| E6
|
|
207
|
+
D7{"if cart.has_coupon"}
|
|
208
|
+
S8["validate_coupon"]
|
|
209
|
+
S8 -->|ok| S5
|
|
210
|
+
E9[/"CouponError"/]
|
|
211
|
+
S8 -->|err| E9
|
|
212
|
+
D7 -->|yes| S8
|
|
213
|
+
D7 -->|no| S5
|
|
214
|
+
S10["price"]
|
|
215
|
+
S10 -->|ok| D7
|
|
216
|
+
E11[/"PriceError"/]
|
|
217
|
+
S10 -->|err| E11
|
|
218
|
+
Start --> S10
|
|
219
|
+
classDef error fill:#fdecea,stroke:#d93025,color:#a50e0e
|
|
220
|
+
class E2,E4,E6,E9,E11 error
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Every red box is a way `place_order` can fail. errpath reads them from the `-> Result[T, E]` annotations of the functions it calls.
|
|
224
|
+
|
|
225
|
+
It follows branches, loops, `try` / `except` / `finally` and `raise` (see [what the analyzer follows](#what-the-analyzer-follows)), and tells you about anything it can't.
|
|
226
|
+
|
|
227
|
+
For an interactive version, `errpath machine examples/checkout.py place_order` prints an [XState](https://stately.ai/docs/xstate) state machine you can open in [autovisualizer](https://auto-visualizer.vercel.app).
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## Catch risky changes in pull requests
|
|
232
|
+
|
|
233
|
+
Say a PR changes one line:
|
|
234
|
+
|
|
235
|
+
```diff
|
|
236
|
+
- send_confirmation(receipt).unwrap()
|
|
237
|
+
+ _ = send_confirmation(receipt)
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
The tests still pass. But a failed confirmation email now disappears without a trace. `errpath review` compares the error paths before and after, and posts:
|
|
241
|
+
|
|
242
|
+
> ## Error path review: high risk
|
|
243
|
+
>
|
|
244
|
+
> 1 function(s) changed, 1 new finding(s), risk=high
|
|
245
|
+
>
|
|
246
|
+
> ### New findings
|
|
247
|
+
>
|
|
248
|
+
> - `no-swallow` `_ =` drops the error from `send_confirmation` at `shop/checkout.py:72`
|
|
249
|
+
>
|
|
250
|
+
> ### `shop.checkout.place_order`
|
|
251
|
+
>
|
|
252
|
+
> - added swallow `send_confirmation`
|
|
253
|
+
> - removed step `send_confirmation`
|
|
254
|
+
>
|
|
255
|
+
> ▶ Diagram
|
|
256
|
+
|
|
257
|
+
What it flags:
|
|
258
|
+
|
|
259
|
+
| Finding | Example | Why it matters |
|
|
260
|
+
| --- | --- | --- |
|
|
261
|
+
| New crash point (`no-panic`) | `.unwrap()` outside `@safe` | raises instead of returning `Err` |
|
|
262
|
+
| Ignored error (`no-swallow`) | `_ = charge(card)`, or `charge(card)` on its own line | the failure vanishes |
|
|
263
|
+
| Changed error path | a step added, removed or moved out of a `try` | callers can now see different errors |
|
|
264
|
+
|
|
265
|
+
Risk is `low`, `moderate` or `high`.
|
|
266
|
+
|
|
267
|
+
### GitHub Action
|
|
268
|
+
|
|
269
|
+
```yaml
|
|
270
|
+
name: errpath
|
|
271
|
+
on: pull_request
|
|
272
|
+
|
|
273
|
+
permissions:
|
|
274
|
+
contents: read
|
|
275
|
+
pull-requests: write # to post the review comment
|
|
276
|
+
|
|
277
|
+
jobs:
|
|
278
|
+
review:
|
|
279
|
+
runs-on: ubuntu-latest
|
|
280
|
+
steps:
|
|
281
|
+
- uses: actions/checkout@v7
|
|
282
|
+
- uses: jagreehal/errpath-python@v0
|
|
283
|
+
with:
|
|
284
|
+
fail-on: high # never, moderate or high
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
It keeps one comment up to date per PR, annotates new findings inline, and writes the report to the job summary. Run it locally with `errpath review --base main`.
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## Turn errors into your own at the boundary
|
|
292
|
+
|
|
293
|
+
Low-level errors (`KeyError`, `DbError`) shouldn't leak out of your module. `map_err` changes the error and leaves a success alone:
|
|
294
|
+
|
|
295
|
+
```python
|
|
296
|
+
order = find_item(sku).map_err(lambda e: OrderError(f"unknown item {sku}"))
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
## With Pydantic and pydantic-ai
|
|
302
|
+
|
|
303
|
+
### Validation
|
|
304
|
+
|
|
305
|
+
Pydantic raises `ValidationError` for bad input. `@safe_with` turns that into a value, with Pydantic's field errors intact:
|
|
306
|
+
|
|
307
|
+
```python
|
|
308
|
+
from pydantic import BaseModel, ValidationError
|
|
309
|
+
|
|
310
|
+
from errpath import safe_with
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class User(BaseModel):
|
|
314
|
+
name: str
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@safe_with(ValidationError)
|
|
318
|
+
def parse_user(data: object) -> User:
|
|
319
|
+
return User.model_validate(data)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
parse_user({"name": "Ada"}) # Ok(User(name='Ada'))
|
|
323
|
+
parse_user({}) # Err(ValidationError(...)): "name: Field required"
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
Then chain it with other steps that can fail: `parse_user(data).and_then(save_user)`.
|
|
327
|
+
|
|
328
|
+
### Results inside models
|
|
329
|
+
|
|
330
|
+
A `Result` can be a field of a Pydantic model. Both sides are validated, and the JSON says which one it is:
|
|
331
|
+
|
|
332
|
+
```python
|
|
333
|
+
class Response(BaseModel):
|
|
334
|
+
result: Result[User, str]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
Response(result=Ok(User(name="Ada"))).model_dump_json()
|
|
338
|
+
# {"result": {"ok": true, "value": {"name": "Ada"}}}
|
|
339
|
+
|
|
340
|
+
Response.model_validate_json('{"result": {"ok": false, "error": "not found"}}')
|
|
341
|
+
# Response(result=Err('not found'))
|
|
342
|
+
|
|
343
|
+
Response(result=Ok("not a User")) # ValidationError
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
It also appears in `model_json_schema()`, so FastAPI and other API tools document it.
|
|
347
|
+
|
|
348
|
+
For results that travel through APIs, use a model, `str` or enum as the error type. An exception error is written as `{"type": "KeyError", "message": "..."}` but can't be read back, because an exception isn't data.
|
|
349
|
+
|
|
350
|
+
A `str` or model error can't be raised, so `.unwrap()` isn't available on `Result[User, str]`: your type checker flags it. Use `match`, `.unwrap_or(default)`, or turn the error into an exception first with `.map_err(NotFound).unwrap()`.
|
|
351
|
+
|
|
352
|
+
### pydantic-ai
|
|
353
|
+
|
|
354
|
+
Two patterns, both in [examples/pydantic_ai_agent.py](examples/pydantic_ai_agent.py):
|
|
355
|
+
|
|
356
|
+
```python
|
|
357
|
+
@agent.tool_plain(retries=2)
|
|
358
|
+
def price_of(sku: str) -> int:
|
|
359
|
+
result = find_price(sku) # your code: Result[int, UnknownItem]
|
|
360
|
+
if not ok(result):
|
|
361
|
+
raise ModelRetry(f"unknown item {sku}") # the model reads this and tries again
|
|
362
|
+
return result.value
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@safe_async_with(UsageLimitExceeded, UnexpectedModelBehavior)
|
|
366
|
+
async def ask(prompt: str) -> str: # Result[str, UsageLimitExceeded | UnexpectedModelBehavior]
|
|
367
|
+
return (await agent.run(prompt)).output
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
1. **In a tool**, turn an `Err` into `ModelRetry`. Don't return `Ok`/`Err` from a tool: pydantic-ai can't send them to the model.
|
|
371
|
+
2. **Around `agent.run`**, `@safe_async_with` gives your caller a typed `Result` instead of exceptions to remember.
|
|
372
|
+
|
|
373
|
+
The example runs offline with a scripted model. To try it with a local model:
|
|
374
|
+
|
|
375
|
+
```bash
|
|
376
|
+
OLLAMA_BASE_URL=http://localhost:11434/v1 ERRPATH_MODEL=ollama:qwen3.5:4b \
|
|
377
|
+
uv run python examples/pydantic_ai_agent.py
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
---
|
|
381
|
+
|
|
382
|
+
## Putting it together
|
|
383
|
+
|
|
384
|
+
One file: validate input, run domain steps, handle every error, see the paths. Copy it, or run it from [examples/signup.py](examples/signup.py):
|
|
385
|
+
|
|
386
|
+
```python
|
|
387
|
+
"""errpath end to end: validate input, run domain steps, handle every error, see the paths.
|
|
388
|
+
|
|
389
|
+
Run it: uv run python examples/signup.py
|
|
390
|
+
See every path: uv run errpath diagram examples/signup.py register
|
|
391
|
+
"""
|
|
392
|
+
|
|
393
|
+
from collections.abc import Mapping
|
|
394
|
+
from dataclasses import dataclass
|
|
395
|
+
|
|
396
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
397
|
+
|
|
398
|
+
from errpath import Err, Ok, Result, safe_with
|
|
399
|
+
|
|
400
|
+
# 1. Validate input. Pydantic's ValidationError becomes an Err, with its field errors.
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class SignupForm(BaseModel):
|
|
404
|
+
email: str = Field(pattern=r"^[^@\s]+@[^@\s]+$")
|
|
405
|
+
password: str = Field(min_length=8)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@safe_with(ValidationError)
|
|
409
|
+
def parse_form(data: Mapping[str, object]) -> SignupForm:
|
|
410
|
+
return SignupForm.model_validate(data)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# 2. Domain steps return Results: how each one can fail is in its signature.
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
class EmailTaken(Exception):
|
|
417
|
+
pass
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class MailerDown(Exception):
|
|
421
|
+
pass
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
@dataclass
|
|
425
|
+
class Account:
|
|
426
|
+
id: int
|
|
427
|
+
email: str
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
registered = {"taken@example.com"}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def reserve_email(email: str) -> Result[str, EmailTaken]:
|
|
434
|
+
if email in registered:
|
|
435
|
+
return Err(EmailTaken(email))
|
|
436
|
+
registered.add(email)
|
|
437
|
+
return Ok(email)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def send_welcome(account: Account) -> Result[None, MailerDown]:
|
|
441
|
+
if account.email.endswith("@offline.example"):
|
|
442
|
+
return Err(MailerDown("mail server unreachable"))
|
|
443
|
+
return Ok(None)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# 3. Compose: the happy path reads top to bottom; the first Err stops it.
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
@safe_with(ValidationError, EmailTaken, MailerDown)
|
|
450
|
+
def register(data: Mapping[str, object]) -> Account:
|
|
451
|
+
form = parse_form(data).unwrap()
|
|
452
|
+
email = reserve_email(form.email).unwrap()
|
|
453
|
+
account = Account(id=len(registered), email=email)
|
|
454
|
+
send_welcome(account).unwrap()
|
|
455
|
+
return account
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
# 4. Handle every error at the boundary, e.g. as an HTTP response.
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def handle(data: Mapping[str, object]) -> tuple[int, object]:
|
|
462
|
+
match register(data):
|
|
463
|
+
case Ok(account):
|
|
464
|
+
return 201, {"id": account.id}
|
|
465
|
+
case Err(ValidationError() as error):
|
|
466
|
+
return 422, [e["msg"] for e in error.errors()]
|
|
467
|
+
case Err(EmailTaken()):
|
|
468
|
+
return 409, "email already registered"
|
|
469
|
+
case Err(MailerDown()):
|
|
470
|
+
return 503, "try again later"
|
|
471
|
+
case Err(error):
|
|
472
|
+
raise AssertionError(f"unhandled error: {error!r}")
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
if __name__ == "__main__":
|
|
476
|
+
for data in [
|
|
477
|
+
{"email": "ada@example.com", "password": "correct horse"},
|
|
478
|
+
{"email": "not-an-email", "password": "short"},
|
|
479
|
+
{"email": "taken@example.com", "password": "correct horse"},
|
|
480
|
+
{"email": "bob@offline.example", "password": "correct horse"},
|
|
481
|
+
]:
|
|
482
|
+
print(data["email"], "->", *handle(data))
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
```bash
|
|
486
|
+
uv run python examples/signup.py
|
|
487
|
+
# ada@example.com -> 201 {'id': 2}
|
|
488
|
+
# not-an-email -> 422 [...Pydantic's field errors...]
|
|
489
|
+
# taken@example.com -> 409 email already registered
|
|
490
|
+
# bob@offline.example -> 503 try again later
|
|
491
|
+
|
|
492
|
+
uv run errpath diagram examples/signup.py register
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
```mermaid
|
|
496
|
+
flowchart LR
|
|
497
|
+
Start(["register"])
|
|
498
|
+
Done((Success))
|
|
499
|
+
S1["send_welcome"]
|
|
500
|
+
S1 -->|ok| Done
|
|
501
|
+
E2[/"MailerDown"/]
|
|
502
|
+
S1 -->|err| E2
|
|
503
|
+
S3["reserve_email"]
|
|
504
|
+
S3 -->|ok| S1
|
|
505
|
+
E4[/"EmailTaken"/]
|
|
506
|
+
S3 -->|err| E4
|
|
507
|
+
S5["parse_form"]
|
|
508
|
+
S5 -->|ok| S3
|
|
509
|
+
E6[/"ValidationError"/]
|
|
510
|
+
S5 -->|err| E6
|
|
511
|
+
Start --> S5
|
|
512
|
+
classDef error fill:#fdecea,stroke:#d93025,color:#a50e0e
|
|
513
|
+
class E2,E4,E6 error
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
Every error in `register`'s signature has a box, and `handle` has a `case` for each one.
|
|
517
|
+
|
|
518
|
+
---
|
|
519
|
+
|
|
520
|
+
## What the analyzer follows
|
|
521
|
+
|
|
522
|
+
errpath reads your code without running it. It draws:
|
|
523
|
+
|
|
524
|
+
- calls to functions that return `Result`, whether annotated `-> Result[T, E]` or decorated with `@safe` / `@safe_with(...)`
|
|
525
|
+
- `.unwrap()`, `return Ok(...)` / `return Err(...)` (including `x if cond else y`), and `raise`
|
|
526
|
+
- `if` / `elif` / `else`, `match`, `for` and `while` (with `break` / `continue`), `with`, and `await`
|
|
527
|
+
- `try` / `except` / `else` / `finally`, including `except*`: each error goes to the handler that would catch it, through any `finally`
|
|
528
|
+
|
|
529
|
+
When it sees a `Result` it can't place on the diagram, such as one inside a condition, a comprehension or another call's arguments, it says so instead of quietly drawing less:
|
|
530
|
+
|
|
531
|
+
```text
|
|
532
|
+
errpath: note: shop.py:14: Result used inside an expression; its error path isn't drawn: check(email)
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
`errpath diagram` and `errpath machine` print these notes, `errpath review` lists them (marking new ones), and `errpath ir` includes them. An `.unwrap()` in such a spot outside `@safe` is still flagged as a crash point, unless it's inside a lambda: errpath can't tell if or when a lambda runs, so that gets a note only.
|
|
536
|
+
|
|
537
|
+
It doesn't see calls made through a variable or callback (`step = check; step(x)`), or a module function called as a method on a local object (`client.charge()`).
|
|
538
|
+
|
|
539
|
+
---
|
|
540
|
+
|
|
541
|
+
## Quick reference
|
|
542
|
+
|
|
543
|
+
### Methods
|
|
544
|
+
|
|
545
|
+
| Method | On `Ok(value)` | On `Err(error)` |
|
|
546
|
+
| --- | --- | --- |
|
|
547
|
+
| `.unwrap()` | returns `value` | raises `error` |
|
|
548
|
+
| `.unwrap_or(default)` | returns `value` | returns `default` |
|
|
549
|
+
| `.map(f)` | `Ok(f(value))` | unchanged |
|
|
550
|
+
| `.map_err(f)` | unchanged | `Err(f(error))` |
|
|
551
|
+
| `.and_then(f)` | `f(value)`, a step that can fail | unchanged |
|
|
552
|
+
|
|
553
|
+
### Helpers
|
|
554
|
+
|
|
555
|
+
| Helper | Does |
|
|
556
|
+
| --- | --- |
|
|
557
|
+
| `ok(r)` | `True` for `Ok`; your editor then knows the types of `r.value` and `r.error` |
|
|
558
|
+
| `err_type(r, KeyError)` | `True` if `r` failed with a `KeyError` (or a subclass) |
|
|
559
|
+
| `traceback_of(r)` | the error's stack trace as text, for logging |
|
|
560
|
+
|
|
561
|
+
### Decorators
|
|
562
|
+
|
|
563
|
+
| Decorator | Catches | Returns |
|
|
564
|
+
| --- | --- | --- |
|
|
565
|
+
| `@safe` | any `Exception` | `Result[T, Exception]` |
|
|
566
|
+
| `@safe_with(A, B)` | only `A` and `B` | `Result[T, A \| B]` |
|
|
567
|
+
| `@safe_async`, `@safe_async_with(A, B)` | the same, for `async def` | the same, awaited |
|
|
568
|
+
|
|
569
|
+
### CLI
|
|
570
|
+
|
|
571
|
+
| Command | Prints |
|
|
572
|
+
| --- | --- |
|
|
573
|
+
| `errpath diagram <path> <function>` | Mermaid diagram |
|
|
574
|
+
| `errpath machine <path> <function>` | XState state machine (JSON) |
|
|
575
|
+
| `errpath review --base <ref>` | PR review (Markdown, or JSON with `--format json`) |
|
|
576
|
+
| `errpath ir [path]` | everything errpath found, as JSON |
|
|
577
|
+
|
|
578
|
+
---
|
|
579
|
+
|
|
580
|
+
## Common pitfalls
|
|
581
|
+
|
|
582
|
+
- **`r.is_ok()` doesn't tell your editor the types.** Python's type checkers can't narrow through a method. Use `ok(r)` or `match`.
|
|
583
|
+
- **`.unwrap()` outside `@safe` raises.** That's fine in a script, but `errpath review` flags it as a new crash point.
|
|
584
|
+
- **`@safe` also catches your bugs.** A `KeyError` from a typo becomes an `Err` like any expected failure. Name the errors you expect with `@safe_with(...)`, and keep `@safe` for a deliberate catch-all.
|
|
585
|
+
- **`.unwrap()` is for exception errors.** Python can't raise a `str` or a model, so type checkers reject `.unwrap()` on `Result[int, str]`. (Unchecked code gets a `RuntimeError` saying what to do.) Use `match`, `.unwrap_or(default)`, or `.map_err(SomeError).unwrap()`.
|
|
586
|
+
- **Don't drop a result.** `_ = charge(card)` and a bare `charge(card)` both throw the error away.
|
|
587
|
+
- **Annotate what can fail.** errpath names errors from `-> Result[T, E]` annotations. Without one, a step's error shows as `Error`.
|
|
588
|
+
- **Results can't be changed.** Build a new one instead: `Ok(new_value)`.
|
|
589
|
+
|
|
590
|
+
---
|
|
591
|
+
|
|
592
|
+
## When to use errpath
|
|
593
|
+
|
|
594
|
+
**Use it when:**
|
|
595
|
+
|
|
596
|
+
- you want the ways a function can fail to be part of its signature
|
|
597
|
+
- you want to see, and review, how errors flow through your code
|
|
598
|
+
|
|
599
|
+
**Skip it when:**
|
|
600
|
+
|
|
601
|
+
- exceptions are fine for you, as in most small scripts
|
|
602
|
+
- you want a full functional-programming toolkit (`Maybe`, `IO`, pipelines): look at [returns](https://github.com/dry-python/returns)
|
|
603
|
+
|
|
604
|
+
errpath's `Result` is deliberately small, and inspired by [safe-result](https://github.com/overflowy/safe-result). What it adds is the analysis: diagrams and PR reviews of your error paths.
|
|
605
|
+
|
|
606
|
+
---
|
|
607
|
+
|
|
608
|
+
## Development
|
|
609
|
+
|
|
610
|
+
Uses [uv](https://docs.astral.sh/uv/), [ruff](https://docs.astral.sh/ruff/) and [ty](https://docs.astral.sh/ty/).
|
|
611
|
+
|
|
612
|
+
```bash
|
|
613
|
+
uv sync # install
|
|
614
|
+
uv run pytest # test
|
|
615
|
+
uv run ruff check # lint
|
|
616
|
+
uv run ruff format # format
|
|
617
|
+
uv run ty check # type check
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
## License
|
|
621
|
+
|
|
622
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
errpath/__init__.py,sha256=WvbqGhilCQf00Ej7oSA1D_dhSn27mHggc8Tb6ZVWejM,393
|
|
2
|
+
errpath/__main__.py,sha256=nGlnYwHtgl1xY2CtdaZYgWUQfhIFVkQUeaXfVb91hGY,68
|
|
3
|
+
errpath/_pydantic.py,sha256=hLw3RdvHQzb2Ks6yXefCi4oqn_PsrWO6BwsiWUbUfIk,4125
|
|
4
|
+
errpath/analyse.py,sha256=2WPk11se6AEUYmQFiZqxMJuW1MfSmt5wwKsr2YXnZbc,38164
|
|
5
|
+
errpath/cli.py,sha256=K462V4FLyn2VGcPB9bVNylhjrbyNW9wh_doao0K01yg,4868
|
|
6
|
+
errpath/diagram.py,sha256=N-_KM-vs-8gcDaeDsS7ht2_fJK7yEaofMWws61EIfBQ,4740
|
|
7
|
+
errpath/ir.py,sha256=XyFnpn-27t1EaTm-GnAdVuZHbh1zaeQ6-cHEJ3LEo3k,6337
|
|
8
|
+
errpath/machine.py,sha256=Wxp1UAmvJ0_JZ3KdgA9_pTbdWTYFLjLldE6rH3enX_8,5749
|
|
9
|
+
errpath/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
errpath/result.py,sha256=ScsClrHIUCHaDr8EudTiV4ySeJDp5FxdCl4sCnMchow,9146
|
|
11
|
+
errpath/review.py,sha256=kH10910Ie_KcakQ3AaIR9wTkQoZ0D3UXT6qlfBZuhnU,16972
|
|
12
|
+
errpath/safe.py,sha256=e1BUoDjkKDqYAA0DONujkRN95e47Q-QcL2DlZDduxqs,4065
|
|
13
|
+
errpath-0.1.0.dist-info/METADATA,sha256=2bvmnW_jFWl4MDt8l9mVZdpeNhNFi1YPT8Ov9977Fiw,19256
|
|
14
|
+
errpath-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
15
|
+
errpath-0.1.0.dist-info/entry_points.txt,sha256=ydvR_UyPIjdHr6KAhkdQ84kB7ABdFlDObTBzWEAMSc0,45
|
|
16
|
+
errpath-0.1.0.dist-info/licenses/LICENSE,sha256=JM6lNjZlRemKaGG6uUIlEuk38DJ2HpF_Je0h-utBxvc,1067
|
|
17
|
+
errpath-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jag Reehal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|