py-better-result 1.0.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.
- py_better_result-1.0.0/PKG-INFO +277 -0
- py_better_result-1.0.0/README.md +267 -0
- py_better_result-1.0.0/pyproject.toml +41 -0
- py_better_result-1.0.0/src/better_result/__init__.py +64 -0
- py_better_result-1.0.0/src/better_result/_codec.py +355 -0
- py_better_result-1.0.0/src/better_result/_core.py +442 -0
- py_better_result-1.0.0/src/better_result/_operations.py +457 -0
- py_better_result-1.0.0/src/better_result/py.typed +0 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: py-better-result
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A narrow, typed Result API for Python
|
|
5
|
+
Author: Tomperez98
|
|
6
|
+
Author-email: Tomperez98 <tomasperezalvarez@gmail.com>
|
|
7
|
+
Requires-Dist: typing-extensions>=4.10,<5
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# py-better-result
|
|
12
|
+
|
|
13
|
+
> Credits: [better-result.dev](https://better-result.dev)
|
|
14
|
+
|
|
15
|
+
A typed `Result[T, E]` for Python: return `Ok(value)` or `Err(error)`, compose workflows without exception-driven control flow, and keep expected failures visible to the type checker.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
from better_result import Err, Ok, Result
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class User:
|
|
25
|
+
name: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_user_id(raw: str) -> Result[int, str]:
|
|
29
|
+
if not raw.isdecimal():
|
|
30
|
+
return Err("invalid user id")
|
|
31
|
+
return Ok(int(raw))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_user(user_id: int) -> Result[User, str]:
|
|
35
|
+
if user_id == 42:
|
|
36
|
+
return Ok(User("Ada"))
|
|
37
|
+
return Err("user not found")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
result = parse_user_id("42").and_then(load_user).map(lambda user: user.name)
|
|
41
|
+
|
|
42
|
+
match result:
|
|
43
|
+
case Ok(name):
|
|
44
|
+
print(name)
|
|
45
|
+
case Err(message):
|
|
46
|
+
print(f"error: {message}")
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```text
|
|
50
|
+
Ada
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
Using [uv](https://docs.astral.sh/uv/):
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
uv add py-better-result
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
With pip:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install py-better-result
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`py-better-result` requires Python 3.12 or newer. The runtime dependency is `typing-extensions`.
|
|
68
|
+
|
|
69
|
+
## Why use a Result?
|
|
70
|
+
|
|
71
|
+
Use a `Result` when failure is an expected part of an operation—validation, a missing record, a rejected request, or a downstream service error. The error stays in the return type instead of being hidden in a broad `try`/`except` or collapsed into `None`.
|
|
72
|
+
|
|
73
|
+
- `Ok[T]` contains a successful value.
|
|
74
|
+
- `Err[E]` contains an expected error value.
|
|
75
|
+
- `Result[T, E]` is the common type for either branch.
|
|
76
|
+
- `and_then` and `map` short-circuit on the first `Err`.
|
|
77
|
+
- Exceptions raised by callbacks are not silently converted into `Err`; unexpected defects propagate.
|
|
78
|
+
- `Ok` and `Err` are frozen, unhashable dataclasses and support structural pattern matching.
|
|
79
|
+
|
|
80
|
+
## Core API
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from better_result import Err, Ok, Result, is_err, is_ok
|
|
84
|
+
|
|
85
|
+
result: Result[int, str] = Ok(2)
|
|
86
|
+
|
|
87
|
+
result.map(lambda value: value * 10) # Ok(20)
|
|
88
|
+
result.and_then(lambda value: Ok(str(value))) # Ok("2")
|
|
89
|
+
result.map_err(str.upper) # unchanged Ok(2)
|
|
90
|
+
result.unwrap_or(0) # 2
|
|
91
|
+
result.match(ok=str, err=lambda error: error) # "2"
|
|
92
|
+
|
|
93
|
+
failure: Result[int, str] = Err("offline")
|
|
94
|
+
failure.map(lambda value: value * 10) # unchanged Err("offline")
|
|
95
|
+
failure.map_err(str.upper) # Err("OFFLINE")
|
|
96
|
+
failure.unwrap_or(0) # 0
|
|
97
|
+
failure.unwrap_or_else(lambda error: len(error)) # 7
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The branch-specific values are available as `ok_value` and `err_value`. Use `isinstance`, `is_ok`, or `is_err` to narrow a `Result`:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
if is_ok(result):
|
|
104
|
+
print(result.ok_value) # int
|
|
105
|
+
elif is_err(result):
|
|
106
|
+
print(result.err_value) # str
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Choosing a combinator
|
|
110
|
+
|
|
111
|
+
| Operation | Runs when | Returns |
|
|
112
|
+
| --- | --- | --- |
|
|
113
|
+
| `map(fn)` | the result is `Ok` | a new `Result` with the mapped success value |
|
|
114
|
+
| `map_err(fn)` | the result is `Err` | a new `Result` with the mapped error |
|
|
115
|
+
| `and_then(fn)` | the result is `Ok` | the `Result` returned by the next operation |
|
|
116
|
+
| `or_else(fn)` | the result is `Err` | the `Result` returned by the recovery operation |
|
|
117
|
+
| `map_or(default, fn)` | either branch | a plain value |
|
|
118
|
+
| `map_or_else(default_fn, fn)` | either branch | a plain value |
|
|
119
|
+
| `match(ok=..., err=...)` | exactly one branch | the handler's return value |
|
|
120
|
+
| `inspect(fn)` / `inspect_err(fn)` | only the selected branch | the original `Result`, for side effects |
|
|
121
|
+
|
|
122
|
+
`unwrap()` and `expect(message)` return the success value but raise `UnwrapError` on `Err`. Their counterparts `unwrap_err()` and `expect_err()` select the error branch. Prefer `unwrap_or`, `unwrap_or_else`, or explicit matching when failure is expected.
|
|
123
|
+
|
|
124
|
+
## Async workflows
|
|
125
|
+
|
|
126
|
+
The core combinators have async forms: `map_async`, `and_then_async`, `or_else_async`, `inspect_async`, `inspect_err_async`, and `inspect_both_async`.
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
import asyncio
|
|
130
|
+
|
|
131
|
+
from better_result import Ok
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def fetch_name(user_id: int) -> Ok[str]:
|
|
135
|
+
return Ok(f"user-{user_id}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def main() -> None:
|
|
139
|
+
result = await Ok(2).and_then_async(fetch_name)
|
|
140
|
+
print(result)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
asyncio.run(main())
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
```text
|
|
147
|
+
Ok(value='user-2')
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Async callbacks are only awaited for the active branch. A failed `Result` therefore skips downstream success callbacks just like the synchronous API.
|
|
151
|
+
|
|
152
|
+
## Capture exceptions and retry operations
|
|
153
|
+
|
|
154
|
+
Use `try_result` or `try_async` at a boundary where an exception is an expected failure mode. Without a mapper, the exception itself becomes the error value; `catch` can convert it into a domain error.
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from better_result import Err, TryContext, try_result
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def read_port(context: TryContext) -> int:
|
|
161
|
+
return int("not-a-port")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
result = try_result(read_port, catch=lambda exc: {"message": str(exc)})
|
|
165
|
+
assert result == Err(
|
|
166
|
+
{"message": "invalid literal for int() with base 10: 'not-a-port'"}
|
|
167
|
+
)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`try_result` accepts `retry=<number>` for immediate synchronous retries. For asynchronous operations, `RetryPolicy` supports bounded retries, constant/linear/exponential backoff, jitter, a `should_retry` predicate, and cooperative cancellation:
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
import asyncio
|
|
174
|
+
|
|
175
|
+
from better_result import Ok, RetryPolicy, TryContext, try_async
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def fetch(context: TryContext) -> str:
|
|
179
|
+
if context.attempt < 2:
|
|
180
|
+
raise TimeoutError("temporary timeout")
|
|
181
|
+
return "response body"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def main() -> None:
|
|
185
|
+
result = await try_async(
|
|
186
|
+
fetch,
|
|
187
|
+
catch=str,
|
|
188
|
+
retry=RetryPolicy(
|
|
189
|
+
times=3, # retries after the first attempt
|
|
190
|
+
delay=0,
|
|
191
|
+
backoff="exponential",
|
|
192
|
+
should_retry=lambda error, _: "timeout" in error.lower(),
|
|
193
|
+
),
|
|
194
|
+
)
|
|
195
|
+
print(result)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
asyncio.run(main())
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
```text
|
|
202
|
+
Ok(value='response body')
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`TryContext.attempt` starts at `1`. `CancellationToken` can stop a retry wait and is passed to each async attempt through `TryContext.cancel_token`. Cancellation exceptions themselves are not swallowed.
|
|
206
|
+
|
|
207
|
+
## Collect or partition Results
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
from better_result import Err, Ok, all_results, partition_results
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
all_results([Ok(1), Ok(2)])
|
|
214
|
+
# Ok(value=(1, 2))
|
|
215
|
+
|
|
216
|
+
all_results([Ok(1), Err("database unavailable"), Ok(3)])
|
|
217
|
+
# Err(value="database unavailable")
|
|
218
|
+
|
|
219
|
+
partition_results([Ok(1), Err("bad input"), Ok(2)])
|
|
220
|
+
# ([1, 2], ["bad input"])
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
- `all_results` returns every success in a tuple, or the first error in input order.
|
|
224
|
+
- `partition_results` returns `(success_values, error_values)` while preserving the relative order of each list.
|
|
225
|
+
- `flatten_result` turns `Result[Result[T, E], F]` into `Result[T, E | F]`.
|
|
226
|
+
- `all_results_async` and `partition_results_async` accept `Result` values or awaitables, await them concurrently, and preserve input order.
|
|
227
|
+
|
|
228
|
+
## Encode and decode at boundaries
|
|
229
|
+
|
|
230
|
+
`codec` and `async_codec` turn a `Result` into a typed envelope suitable for JSON or another wire format. Each schema returns either the converted value or `SchemaFailure` with structured issues.
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
from better_result import Err, Ok, codec
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
result_codec = codec(
|
|
237
|
+
serialize_ok=lambda user: {"id": user["id"]},
|
|
238
|
+
serialize_err=lambda error: {"code": error},
|
|
239
|
+
deserialize_ok=lambda value: value["id"],
|
|
240
|
+
deserialize_err=lambda value: value["code"],
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
encoded = result_codec.serialize(Ok({"id": 42}))
|
|
244
|
+
assert encoded == Ok({"status": "ok", "value": {"id": 42}})
|
|
245
|
+
|
|
246
|
+
encoded_error = result_codec.serialize(Err("not_found"))
|
|
247
|
+
assert encoded_error == Ok({"status": "error", "error": {"code": "not_found"}})
|
|
248
|
+
|
|
249
|
+
decoded = result_codec.deserialize({"status": "ok", "value": {"id": 42}})
|
|
250
|
+
assert decoded == Ok(42)
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
A decoded wire-level error is returned as `Err` with the decoded error value. Malformed envelopes and schema rejections are returned as `Err(ResultDeserializationError)`, so the deserialization error type is `ErrOutput | ResultDeserializationError`. Serialization schema rejections are returned as `Err(ResultSerializationError)`.
|
|
254
|
+
|
|
255
|
+
Use `async_codec` when schemas are asynchronous. The `serialize_unsafe` and `deserialize_unsafe` methods unwrap codec failures and raise `UnwrapError`; they are useful only when the boundary failure is already handled elsewhere.
|
|
256
|
+
|
|
257
|
+
## Public API
|
|
258
|
+
|
|
259
|
+
The package exports:
|
|
260
|
+
|
|
261
|
+
- Core types: `Result`, `Ok`, `Err`, `UnwrapError`, `is_ok`, `is_err`
|
|
262
|
+
- Async and sync operations: `try_result`, `try_async`, `RetryPolicy`, `TryContext`, `CancellationToken`
|
|
263
|
+
- Collection operations: `all_results`, `all_results_async`, `partition_results`, `partition_results_async`, `flatten_result`
|
|
264
|
+
- Codecs: `codec`, `async_codec`, `ResultCodec`, `AsyncResultCodec`
|
|
265
|
+
- Codec types: `SchemaFailure`, `CodecIssue`, `SerializedOk`, `SerializedErr`, `SerializedResult`, `SyncSchema`, `AsyncSchema`, `ResultSerializationError`, `ResultDeserializationError`
|
|
266
|
+
|
|
267
|
+
## Development
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
uv sync
|
|
271
|
+
uv run pytest
|
|
272
|
+
uv run pytest --cov
|
|
273
|
+
uv run ruff check .
|
|
274
|
+
uv run ty check
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
The test suite includes runtime behavior and static type contracts.
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# py-better-result
|
|
2
|
+
|
|
3
|
+
> Credits: [better-result.dev](https://better-result.dev)
|
|
4
|
+
|
|
5
|
+
A typed `Result[T, E]` for Python: return `Ok(value)` or `Err(error)`, compose workflows without exception-driven control flow, and keep expected failures visible to the type checker.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from better_result import Err, Ok, Result
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class User:
|
|
15
|
+
name: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_user_id(raw: str) -> Result[int, str]:
|
|
19
|
+
if not raw.isdecimal():
|
|
20
|
+
return Err("invalid user id")
|
|
21
|
+
return Ok(int(raw))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_user(user_id: int) -> Result[User, str]:
|
|
25
|
+
if user_id == 42:
|
|
26
|
+
return Ok(User("Ada"))
|
|
27
|
+
return Err("user not found")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
result = parse_user_id("42").and_then(load_user).map(lambda user: user.name)
|
|
31
|
+
|
|
32
|
+
match result:
|
|
33
|
+
case Ok(name):
|
|
34
|
+
print(name)
|
|
35
|
+
case Err(message):
|
|
36
|
+
print(f"error: {message}")
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
Ada
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
Using [uv](https://docs.astral.sh/uv/):
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
uv add py-better-result
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
With pip:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install py-better-result
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`py-better-result` requires Python 3.12 or newer. The runtime dependency is `typing-extensions`.
|
|
58
|
+
|
|
59
|
+
## Why use a Result?
|
|
60
|
+
|
|
61
|
+
Use a `Result` when failure is an expected part of an operation—validation, a missing record, a rejected request, or a downstream service error. The error stays in the return type instead of being hidden in a broad `try`/`except` or collapsed into `None`.
|
|
62
|
+
|
|
63
|
+
- `Ok[T]` contains a successful value.
|
|
64
|
+
- `Err[E]` contains an expected error value.
|
|
65
|
+
- `Result[T, E]` is the common type for either branch.
|
|
66
|
+
- `and_then` and `map` short-circuit on the first `Err`.
|
|
67
|
+
- Exceptions raised by callbacks are not silently converted into `Err`; unexpected defects propagate.
|
|
68
|
+
- `Ok` and `Err` are frozen, unhashable dataclasses and support structural pattern matching.
|
|
69
|
+
|
|
70
|
+
## Core API
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from better_result import Err, Ok, Result, is_err, is_ok
|
|
74
|
+
|
|
75
|
+
result: Result[int, str] = Ok(2)
|
|
76
|
+
|
|
77
|
+
result.map(lambda value: value * 10) # Ok(20)
|
|
78
|
+
result.and_then(lambda value: Ok(str(value))) # Ok("2")
|
|
79
|
+
result.map_err(str.upper) # unchanged Ok(2)
|
|
80
|
+
result.unwrap_or(0) # 2
|
|
81
|
+
result.match(ok=str, err=lambda error: error) # "2"
|
|
82
|
+
|
|
83
|
+
failure: Result[int, str] = Err("offline")
|
|
84
|
+
failure.map(lambda value: value * 10) # unchanged Err("offline")
|
|
85
|
+
failure.map_err(str.upper) # Err("OFFLINE")
|
|
86
|
+
failure.unwrap_or(0) # 0
|
|
87
|
+
failure.unwrap_or_else(lambda error: len(error)) # 7
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The branch-specific values are available as `ok_value` and `err_value`. Use `isinstance`, `is_ok`, or `is_err` to narrow a `Result`:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
if is_ok(result):
|
|
94
|
+
print(result.ok_value) # int
|
|
95
|
+
elif is_err(result):
|
|
96
|
+
print(result.err_value) # str
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Choosing a combinator
|
|
100
|
+
|
|
101
|
+
| Operation | Runs when | Returns |
|
|
102
|
+
| --- | --- | --- |
|
|
103
|
+
| `map(fn)` | the result is `Ok` | a new `Result` with the mapped success value |
|
|
104
|
+
| `map_err(fn)` | the result is `Err` | a new `Result` with the mapped error |
|
|
105
|
+
| `and_then(fn)` | the result is `Ok` | the `Result` returned by the next operation |
|
|
106
|
+
| `or_else(fn)` | the result is `Err` | the `Result` returned by the recovery operation |
|
|
107
|
+
| `map_or(default, fn)` | either branch | a plain value |
|
|
108
|
+
| `map_or_else(default_fn, fn)` | either branch | a plain value |
|
|
109
|
+
| `match(ok=..., err=...)` | exactly one branch | the handler's return value |
|
|
110
|
+
| `inspect(fn)` / `inspect_err(fn)` | only the selected branch | the original `Result`, for side effects |
|
|
111
|
+
|
|
112
|
+
`unwrap()` and `expect(message)` return the success value but raise `UnwrapError` on `Err`. Their counterparts `unwrap_err()` and `expect_err()` select the error branch. Prefer `unwrap_or`, `unwrap_or_else`, or explicit matching when failure is expected.
|
|
113
|
+
|
|
114
|
+
## Async workflows
|
|
115
|
+
|
|
116
|
+
The core combinators have async forms: `map_async`, `and_then_async`, `or_else_async`, `inspect_async`, `inspect_err_async`, and `inspect_both_async`.
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
import asyncio
|
|
120
|
+
|
|
121
|
+
from better_result import Ok
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def fetch_name(user_id: int) -> Ok[str]:
|
|
125
|
+
return Ok(f"user-{user_id}")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def main() -> None:
|
|
129
|
+
result = await Ok(2).and_then_async(fetch_name)
|
|
130
|
+
print(result)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
asyncio.run(main())
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```text
|
|
137
|
+
Ok(value='user-2')
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Async callbacks are only awaited for the active branch. A failed `Result` therefore skips downstream success callbacks just like the synchronous API.
|
|
141
|
+
|
|
142
|
+
## Capture exceptions and retry operations
|
|
143
|
+
|
|
144
|
+
Use `try_result` or `try_async` at a boundary where an exception is an expected failure mode. Without a mapper, the exception itself becomes the error value; `catch` can convert it into a domain error.
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
from better_result import Err, TryContext, try_result
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def read_port(context: TryContext) -> int:
|
|
151
|
+
return int("not-a-port")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
result = try_result(read_port, catch=lambda exc: {"message": str(exc)})
|
|
155
|
+
assert result == Err(
|
|
156
|
+
{"message": "invalid literal for int() with base 10: 'not-a-port'"}
|
|
157
|
+
)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`try_result` accepts `retry=<number>` for immediate synchronous retries. For asynchronous operations, `RetryPolicy` supports bounded retries, constant/linear/exponential backoff, jitter, a `should_retry` predicate, and cooperative cancellation:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
import asyncio
|
|
164
|
+
|
|
165
|
+
from better_result import Ok, RetryPolicy, TryContext, try_async
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def fetch(context: TryContext) -> str:
|
|
169
|
+
if context.attempt < 2:
|
|
170
|
+
raise TimeoutError("temporary timeout")
|
|
171
|
+
return "response body"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def main() -> None:
|
|
175
|
+
result = await try_async(
|
|
176
|
+
fetch,
|
|
177
|
+
catch=str,
|
|
178
|
+
retry=RetryPolicy(
|
|
179
|
+
times=3, # retries after the first attempt
|
|
180
|
+
delay=0,
|
|
181
|
+
backoff="exponential",
|
|
182
|
+
should_retry=lambda error, _: "timeout" in error.lower(),
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
print(result)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
asyncio.run(main())
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
```text
|
|
192
|
+
Ok(value='response body')
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`TryContext.attempt` starts at `1`. `CancellationToken` can stop a retry wait and is passed to each async attempt through `TryContext.cancel_token`. Cancellation exceptions themselves are not swallowed.
|
|
196
|
+
|
|
197
|
+
## Collect or partition Results
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
from better_result import Err, Ok, all_results, partition_results
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
all_results([Ok(1), Ok(2)])
|
|
204
|
+
# Ok(value=(1, 2))
|
|
205
|
+
|
|
206
|
+
all_results([Ok(1), Err("database unavailable"), Ok(3)])
|
|
207
|
+
# Err(value="database unavailable")
|
|
208
|
+
|
|
209
|
+
partition_results([Ok(1), Err("bad input"), Ok(2)])
|
|
210
|
+
# ([1, 2], ["bad input"])
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
- `all_results` returns every success in a tuple, or the first error in input order.
|
|
214
|
+
- `partition_results` returns `(success_values, error_values)` while preserving the relative order of each list.
|
|
215
|
+
- `flatten_result` turns `Result[Result[T, E], F]` into `Result[T, E | F]`.
|
|
216
|
+
- `all_results_async` and `partition_results_async` accept `Result` values or awaitables, await them concurrently, and preserve input order.
|
|
217
|
+
|
|
218
|
+
## Encode and decode at boundaries
|
|
219
|
+
|
|
220
|
+
`codec` and `async_codec` turn a `Result` into a typed envelope suitable for JSON or another wire format. Each schema returns either the converted value or `SchemaFailure` with structured issues.
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
from better_result import Err, Ok, codec
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
result_codec = codec(
|
|
227
|
+
serialize_ok=lambda user: {"id": user["id"]},
|
|
228
|
+
serialize_err=lambda error: {"code": error},
|
|
229
|
+
deserialize_ok=lambda value: value["id"],
|
|
230
|
+
deserialize_err=lambda value: value["code"],
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
encoded = result_codec.serialize(Ok({"id": 42}))
|
|
234
|
+
assert encoded == Ok({"status": "ok", "value": {"id": 42}})
|
|
235
|
+
|
|
236
|
+
encoded_error = result_codec.serialize(Err("not_found"))
|
|
237
|
+
assert encoded_error == Ok({"status": "error", "error": {"code": "not_found"}})
|
|
238
|
+
|
|
239
|
+
decoded = result_codec.deserialize({"status": "ok", "value": {"id": 42}})
|
|
240
|
+
assert decoded == Ok(42)
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
A decoded wire-level error is returned as `Err` with the decoded error value. Malformed envelopes and schema rejections are returned as `Err(ResultDeserializationError)`, so the deserialization error type is `ErrOutput | ResultDeserializationError`. Serialization schema rejections are returned as `Err(ResultSerializationError)`.
|
|
244
|
+
|
|
245
|
+
Use `async_codec` when schemas are asynchronous. The `serialize_unsafe` and `deserialize_unsafe` methods unwrap codec failures and raise `UnwrapError`; they are useful only when the boundary failure is already handled elsewhere.
|
|
246
|
+
|
|
247
|
+
## Public API
|
|
248
|
+
|
|
249
|
+
The package exports:
|
|
250
|
+
|
|
251
|
+
- Core types: `Result`, `Ok`, `Err`, `UnwrapError`, `is_ok`, `is_err`
|
|
252
|
+
- Async and sync operations: `try_result`, `try_async`, `RetryPolicy`, `TryContext`, `CancellationToken`
|
|
253
|
+
- Collection operations: `all_results`, `all_results_async`, `partition_results`, `partition_results_async`, `flatten_result`
|
|
254
|
+
- Codecs: `codec`, `async_codec`, `ResultCodec`, `AsyncResultCodec`
|
|
255
|
+
- Codec types: `SchemaFailure`, `CodecIssue`, `SerializedOk`, `SerializedErr`, `SerializedResult`, `SyncSchema`, `AsyncSchema`, `ResultSerializationError`, `ResultDeserializationError`
|
|
256
|
+
|
|
257
|
+
## Development
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
uv sync
|
|
261
|
+
uv run pytest
|
|
262
|
+
uv run pytest --cov
|
|
263
|
+
uv run ruff check .
|
|
264
|
+
uv run ty check
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
The test suite includes runtime behavior and static type contracts.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "py-better-result"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "A narrow, typed Result API for Python"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{ name = "Tomperez98", email = "tomasperezalvarez@gmail.com" }]
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
dependencies = ["typing-extensions>=4.10,<5"]
|
|
9
|
+
|
|
10
|
+
[build-system]
|
|
11
|
+
requires = ["uv_build>=0.11.21,<0.12.0"]
|
|
12
|
+
build-backend = "uv_build"
|
|
13
|
+
|
|
14
|
+
# Module name differs from the project name (py-better-result vs better_result)
|
|
15
|
+
[tool.uv.build-backend]
|
|
16
|
+
module-name = "better_result"
|
|
17
|
+
|
|
18
|
+
[dependency-groups]
|
|
19
|
+
dev = [
|
|
20
|
+
"pytest>=9.1.1,<10",
|
|
21
|
+
"pytest-asyncio>=1.4.0,<2",
|
|
22
|
+
"pytest-cov>=7.0.0,<8",
|
|
23
|
+
"ruff>=0.16.6,<0.17",
|
|
24
|
+
"ty>=0.0.78,<0.1",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.ty]
|
|
28
|
+
|
|
29
|
+
[tool.ty.rules]
|
|
30
|
+
all = "error"
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
asyncio_mode = "auto"
|
|
34
|
+
|
|
35
|
+
[tool.coverage.run]
|
|
36
|
+
branch = true
|
|
37
|
+
source = ["src/better_result"]
|
|
38
|
+
|
|
39
|
+
[tool.coverage.report]
|
|
40
|
+
show_missing = true
|
|
41
|
+
fail_under = 100
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""A small, typed Result type with boundary codecs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._codec import (
|
|
6
|
+
AsyncResultCodec,
|
|
7
|
+
AsyncSchema,
|
|
8
|
+
CodecIssue,
|
|
9
|
+
ResultCodec,
|
|
10
|
+
ResultDeserializationError,
|
|
11
|
+
ResultSerializationError,
|
|
12
|
+
SchemaFailure,
|
|
13
|
+
SerializedErr,
|
|
14
|
+
SerializedOk,
|
|
15
|
+
SerializedResult,
|
|
16
|
+
SyncSchema,
|
|
17
|
+
async_codec,
|
|
18
|
+
codec,
|
|
19
|
+
)
|
|
20
|
+
from ._core import Err, Ok, Result, UnwrapError, is_err, is_ok
|
|
21
|
+
from ._operations import (
|
|
22
|
+
CancellationToken,
|
|
23
|
+
RetryPolicy,
|
|
24
|
+
TryContext,
|
|
25
|
+
all_results,
|
|
26
|
+
all_results_async,
|
|
27
|
+
flatten_result,
|
|
28
|
+
partition_results,
|
|
29
|
+
partition_results_async,
|
|
30
|
+
try_async,
|
|
31
|
+
try_result,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"AsyncResultCodec",
|
|
36
|
+
"AsyncSchema",
|
|
37
|
+
"CancellationToken",
|
|
38
|
+
"CodecIssue",
|
|
39
|
+
"Err",
|
|
40
|
+
"Ok",
|
|
41
|
+
"Result",
|
|
42
|
+
"ResultCodec",
|
|
43
|
+
"ResultDeserializationError",
|
|
44
|
+
"ResultSerializationError",
|
|
45
|
+
"RetryPolicy",
|
|
46
|
+
"SchemaFailure",
|
|
47
|
+
"SerializedErr",
|
|
48
|
+
"SerializedOk",
|
|
49
|
+
"SerializedResult",
|
|
50
|
+
"SyncSchema",
|
|
51
|
+
"TryContext",
|
|
52
|
+
"UnwrapError",
|
|
53
|
+
"all_results",
|
|
54
|
+
"all_results_async",
|
|
55
|
+
"async_codec",
|
|
56
|
+
"codec",
|
|
57
|
+
"flatten_result",
|
|
58
|
+
"is_err",
|
|
59
|
+
"is_ok",
|
|
60
|
+
"partition_results",
|
|
61
|
+
"partition_results_async",
|
|
62
|
+
"try_async",
|
|
63
|
+
"try_result",
|
|
64
|
+
]
|