pyeffect 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.
- pyeffect/__init__.py +69 -0
- pyeffect/compose.py +342 -0
- pyeffect/effect.py +279 -0
- pyeffect/option.py +265 -0
- pyeffect/pipe.py +150 -0
- pyeffect/py.typed +0 -0
- pyeffect/result.py +481 -0
- pyeffect/retry.py +69 -0
- pyeffect-0.1.0.dist-info/METADATA +138 -0
- pyeffect-0.1.0.dist-info/RECORD +11 -0
- pyeffect-0.1.0.dist-info/WHEEL +4 -0
pyeffect/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""pyeffect: a fully typed functional core for Python."""
|
|
2
|
+
|
|
3
|
+
from pyeffect.compose import (
|
|
4
|
+
compose,
|
|
5
|
+
constant,
|
|
6
|
+
curry,
|
|
7
|
+
flip,
|
|
8
|
+
identity,
|
|
9
|
+
lift,
|
|
10
|
+
lift2,
|
|
11
|
+
lift3,
|
|
12
|
+
partial,
|
|
13
|
+
tap,
|
|
14
|
+
unpack,
|
|
15
|
+
)
|
|
16
|
+
from pyeffect.effect import Effect, sequence
|
|
17
|
+
from pyeffect.option import (
|
|
18
|
+
Nothing,
|
|
19
|
+
Option,
|
|
20
|
+
Some,
|
|
21
|
+
UnwrapNothingError,
|
|
22
|
+
flatten,
|
|
23
|
+
from_optional,
|
|
24
|
+
)
|
|
25
|
+
from pyeffect.pipe import pipe
|
|
26
|
+
from pyeffect.result import (
|
|
27
|
+
Err,
|
|
28
|
+
ErrorContext,
|
|
29
|
+
Ok,
|
|
30
|
+
Result,
|
|
31
|
+
UnwrapError,
|
|
32
|
+
attempt,
|
|
33
|
+
guard,
|
|
34
|
+
traverse,
|
|
35
|
+
)
|
|
36
|
+
from pyeffect.retry import Policy, retry
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"Effect",
|
|
40
|
+
"Err",
|
|
41
|
+
"ErrorContext",
|
|
42
|
+
"Nothing",
|
|
43
|
+
"Ok",
|
|
44
|
+
"Option",
|
|
45
|
+
"Policy",
|
|
46
|
+
"Result",
|
|
47
|
+
"Some",
|
|
48
|
+
"UnwrapError",
|
|
49
|
+
"UnwrapNothingError",
|
|
50
|
+
"attempt",
|
|
51
|
+
"compose",
|
|
52
|
+
"constant",
|
|
53
|
+
"curry",
|
|
54
|
+
"flatten",
|
|
55
|
+
"flip",
|
|
56
|
+
"from_optional",
|
|
57
|
+
"guard",
|
|
58
|
+
"identity",
|
|
59
|
+
"lift",
|
|
60
|
+
"lift2",
|
|
61
|
+
"lift3",
|
|
62
|
+
"partial",
|
|
63
|
+
"pipe",
|
|
64
|
+
"retry",
|
|
65
|
+
"sequence",
|
|
66
|
+
"tap",
|
|
67
|
+
"traverse",
|
|
68
|
+
"unpack",
|
|
69
|
+
]
|
pyeffect/compose.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# ruff: noqa: UP047 -- PEP 695 type params on @overload are not checked by ty;
|
|
2
|
+
# classic TypeVars required. Non-overload functions below use PEP 695.
|
|
3
|
+
"""Function composition: ``compose``, ``tap``, and small combinators.
|
|
4
|
+
|
|
5
|
+
``compose`` builds a new function from existing ones, right to left::
|
|
6
|
+
|
|
7
|
+
>>> from pyeffect.compose import compose
|
|
8
|
+
>>> compose(str, lambda x: x + 1)(2)
|
|
9
|
+
'3'
|
|
10
|
+
|
|
11
|
+
``tap`` runs a side effect on a value and passes it through unchanged — a
|
|
12
|
+
convenient debug step inside a ``pipe``.
|
|
13
|
+
|
|
14
|
+
``curry`` turns a multi-argument function into nested single-argument
|
|
15
|
+
calls; ``lift``/``lift2``/``lift3`` push plain functions into the
|
|
16
|
+
``Result`` domain::
|
|
17
|
+
|
|
18
|
+
>>> from pyeffect.compose import curry, lift2
|
|
19
|
+
>>> from pyeffect.result import Ok
|
|
20
|
+
>>> curry(lambda a, b: a + b)(2)(3)
|
|
21
|
+
5
|
|
22
|
+
>>> lift2(lambda a, b: a + b)(Ok(1), Ok(2))
|
|
23
|
+
Ok(value=3)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import inspect
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
from functools import partial
|
|
31
|
+
from typing import Any, TypeVar, overload
|
|
32
|
+
|
|
33
|
+
from pyeffect.result import Err, Ok, Result
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"compose",
|
|
37
|
+
"constant",
|
|
38
|
+
"curry",
|
|
39
|
+
"flip",
|
|
40
|
+
"identity",
|
|
41
|
+
"lift",
|
|
42
|
+
"lift2",
|
|
43
|
+
"lift3",
|
|
44
|
+
"partial",
|
|
45
|
+
"tap",
|
|
46
|
+
"unpack",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
_A = TypeVar("_A")
|
|
50
|
+
_B = TypeVar("_B")
|
|
51
|
+
_C = TypeVar("_C")
|
|
52
|
+
_D = TypeVar("_D")
|
|
53
|
+
_E = TypeVar("_E")
|
|
54
|
+
_F = TypeVar("_F")
|
|
55
|
+
_G = TypeVar("_G")
|
|
56
|
+
_H = TypeVar("_H")
|
|
57
|
+
_I = TypeVar("_I")
|
|
58
|
+
_J = TypeVar("_J")
|
|
59
|
+
_K = TypeVar("_K")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@overload
|
|
63
|
+
def compose() -> Callable[[_A], _A]: ...
|
|
64
|
+
@overload
|
|
65
|
+
def compose(f: Callable[[_A], _B]) -> Callable[[_A], _B]: ...
|
|
66
|
+
@overload
|
|
67
|
+
def compose(f: Callable[[_A], _B], g: Callable[[_C], _A]) -> Callable[[_C], _B]: ...
|
|
68
|
+
@overload
|
|
69
|
+
def compose(
|
|
70
|
+
f: Callable[[_A], _B],
|
|
71
|
+
g: Callable[[_C], _A],
|
|
72
|
+
h: Callable[[_D], _C],
|
|
73
|
+
) -> Callable[[_D], _B]: ...
|
|
74
|
+
@overload
|
|
75
|
+
def compose(
|
|
76
|
+
f: Callable[[_A], _B],
|
|
77
|
+
g: Callable[[_C], _A],
|
|
78
|
+
h: Callable[[_D], _C],
|
|
79
|
+
i: Callable[[_E], _D],
|
|
80
|
+
) -> Callable[[_E], _B]: ...
|
|
81
|
+
@overload
|
|
82
|
+
def compose(
|
|
83
|
+
f: Callable[[_A], _B],
|
|
84
|
+
g: Callable[[_C], _A],
|
|
85
|
+
h: Callable[[_D], _C],
|
|
86
|
+
i: Callable[[_E], _D],
|
|
87
|
+
j: Callable[[_F], _E],
|
|
88
|
+
) -> Callable[[_F], _B]: ...
|
|
89
|
+
@overload
|
|
90
|
+
def compose(
|
|
91
|
+
f: Callable[[_A], _B],
|
|
92
|
+
g: Callable[[_C], _A],
|
|
93
|
+
h: Callable[[_D], _C],
|
|
94
|
+
i: Callable[[_E], _D],
|
|
95
|
+
j: Callable[[_F], _E],
|
|
96
|
+
k: Callable[[_G], _F],
|
|
97
|
+
) -> Callable[[_G], _B]: ...
|
|
98
|
+
@overload
|
|
99
|
+
def compose(
|
|
100
|
+
f: Callable[[_A], _B],
|
|
101
|
+
g: Callable[[_C], _A],
|
|
102
|
+
h: Callable[[_D], _C],
|
|
103
|
+
i: Callable[[_E], _D],
|
|
104
|
+
j: Callable[[_F], _E],
|
|
105
|
+
k: Callable[[_G], _F],
|
|
106
|
+
l: Callable[[_H], _G],
|
|
107
|
+
) -> Callable[[_H], _B]: ...
|
|
108
|
+
@overload
|
|
109
|
+
def compose(
|
|
110
|
+
f: Callable[[_A], _B],
|
|
111
|
+
g: Callable[[_C], _A],
|
|
112
|
+
h: Callable[[_D], _C],
|
|
113
|
+
i: Callable[[_E], _D],
|
|
114
|
+
j: Callable[[_F], _E],
|
|
115
|
+
k: Callable[[_G], _F],
|
|
116
|
+
l: Callable[[_H], _G],
|
|
117
|
+
m: Callable[[_I], _H],
|
|
118
|
+
) -> Callable[[_I], _B]: ...
|
|
119
|
+
@overload
|
|
120
|
+
def compose(
|
|
121
|
+
f: Callable[[_A], _B],
|
|
122
|
+
g: Callable[[_C], _A],
|
|
123
|
+
h: Callable[[_D], _C],
|
|
124
|
+
i: Callable[[_E], _D],
|
|
125
|
+
j: Callable[[_F], _E],
|
|
126
|
+
k: Callable[[_G], _F],
|
|
127
|
+
l: Callable[[_H], _G],
|
|
128
|
+
m: Callable[[_I], _H],
|
|
129
|
+
n: Callable[[_J], _I],
|
|
130
|
+
) -> Callable[[_J], _B]: ...
|
|
131
|
+
@overload
|
|
132
|
+
def compose(
|
|
133
|
+
f: Callable[[_A], _B],
|
|
134
|
+
g: Callable[[_C], _A],
|
|
135
|
+
h: Callable[[_D], _C],
|
|
136
|
+
i: Callable[[_E], _D],
|
|
137
|
+
j: Callable[[_F], _E],
|
|
138
|
+
k: Callable[[_G], _F],
|
|
139
|
+
l: Callable[[_H], _G],
|
|
140
|
+
m: Callable[[_I], _H],
|
|
141
|
+
n: Callable[[_J], _I],
|
|
142
|
+
o: Callable[[_K], _J],
|
|
143
|
+
) -> Callable[[_K], _B]: ...
|
|
144
|
+
def compose(*functions: Callable[[Any], Any]) -> Callable[[Any], Any]:
|
|
145
|
+
"""Compose functions right to left: ``compose(f, g)(x) == f(g(x))``.
|
|
146
|
+
|
|
147
|
+
``compose()`` (no functions) is the identity function. Up to ten
|
|
148
|
+
functions are fully type-checked; the runtime accepts any number.
|
|
149
|
+
"""
|
|
150
|
+
if not functions:
|
|
151
|
+
return identity
|
|
152
|
+
|
|
153
|
+
def composed(value: Any) -> Any:
|
|
154
|
+
for fn in reversed(functions):
|
|
155
|
+
value = fn(value)
|
|
156
|
+
return value
|
|
157
|
+
|
|
158
|
+
return composed
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def identity[A](value: A) -> A:
|
|
162
|
+
"""Return ``value`` unchanged."""
|
|
163
|
+
|
|
164
|
+
return value
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def tap[A](fn: Callable[[A], object]) -> Callable[[A], A]:
|
|
168
|
+
"""Return a function that runs ``fn`` on the value, then returns it.
|
|
169
|
+
|
|
170
|
+
The return value of ``fn`` is discarded — ``tap`` is for side effects
|
|
171
|
+
(logging, recording) inside a pipeline.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
def tapped(value: A) -> A:
|
|
175
|
+
fn(value)
|
|
176
|
+
return value
|
|
177
|
+
|
|
178
|
+
return tapped
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def constant[A](value: A) -> Callable[..., A]:
|
|
182
|
+
"""Return a function that ignores its arguments and yields ``value``."""
|
|
183
|
+
|
|
184
|
+
def const(*args: object, **kwargs: object) -> A:
|
|
185
|
+
return value
|
|
186
|
+
|
|
187
|
+
return const
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def flip[A, B, C](f: Callable[[A, B], C]) -> Callable[[B, A], C]:
|
|
191
|
+
"""Swap the first two arguments of a binary function."""
|
|
192
|
+
|
|
193
|
+
def flipped(b: B, a: A) -> C:
|
|
194
|
+
return f(a, b)
|
|
195
|
+
|
|
196
|
+
return flipped
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@overload
|
|
200
|
+
def unpack[A, R](f: Callable[[A], R]) -> Callable[[tuple[A]], R]: ...
|
|
201
|
+
@overload
|
|
202
|
+
def unpack[A, B, R](f: Callable[[A, B], R]) -> Callable[[tuple[A, B]], R]: ...
|
|
203
|
+
@overload
|
|
204
|
+
def unpack[A, B, C, R](f: Callable[[A, B, C], R]) -> Callable[[tuple[A, B, C]], R]: ...
|
|
205
|
+
def unpack(f: Callable[..., Any]) -> Callable[[tuple[Any, ...]], Any]:
|
|
206
|
+
"""Return a function that applies ``f`` to the elements of a tuple.
|
|
207
|
+
|
|
208
|
+
``unpack(f)((1, 2))`` is ``f(1, 2)``. Arities 1-3 are fully
|
|
209
|
+
type-checked; the runtime accepts any arity.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
def applied(args: tuple[Any, ...]) -> Any:
|
|
213
|
+
return f(*args)
|
|
214
|
+
|
|
215
|
+
return applied
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _positional_arity(f: Callable[..., Any]) -> int:
|
|
219
|
+
"""Count the fixed positional parameters of ``f``.
|
|
220
|
+
|
|
221
|
+
Currying must know when a call has supplied every positional argument.
|
|
222
|
+
A ``*args`` parameter makes that unknowable, and a required
|
|
223
|
+
keyword-only parameter can never be supplied positionally — both are
|
|
224
|
+
defects and crash at construction instead of misbehaving at call time.
|
|
225
|
+
"""
|
|
226
|
+
parameters = inspect.signature(f).parameters.values()
|
|
227
|
+
for parameter in parameters:
|
|
228
|
+
if parameter.kind is inspect.Parameter.VAR_POSITIONAL:
|
|
229
|
+
raise TypeError(f"curry requires a fixed arity, got variadic {f!r}")
|
|
230
|
+
if (
|
|
231
|
+
parameter.kind is inspect.Parameter.KEYWORD_ONLY
|
|
232
|
+
and parameter.default is inspect.Parameter.empty
|
|
233
|
+
):
|
|
234
|
+
raise TypeError(
|
|
235
|
+
f"curry requires positional parameters only, got required "
|
|
236
|
+
f"keyword-only {parameter.name!r} in {f!r}"
|
|
237
|
+
)
|
|
238
|
+
return sum(
|
|
239
|
+
1
|
|
240
|
+
for parameter in parameters
|
|
241
|
+
if parameter.kind
|
|
242
|
+
in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@overload
|
|
247
|
+
def curry[R](f: Callable[[], R]) -> Callable[[], R]: ...
|
|
248
|
+
@overload
|
|
249
|
+
def curry[A, R](f: Callable[[A], R]) -> Callable[[A], R]: ...
|
|
250
|
+
@overload
|
|
251
|
+
def curry[A, B, R](
|
|
252
|
+
f: Callable[[A, B], R],
|
|
253
|
+
) -> Callable[[A], Callable[[B], R]]: ...
|
|
254
|
+
@overload
|
|
255
|
+
def curry[A, B, C, R](
|
|
256
|
+
f: Callable[[A, B, C], R],
|
|
257
|
+
) -> Callable[[A], Callable[[B], Callable[[C], R]]]: ...
|
|
258
|
+
@overload
|
|
259
|
+
def curry[A, B, C, D, R](
|
|
260
|
+
f: Callable[[A, B, C, D], R],
|
|
261
|
+
) -> Callable[[A], Callable[[B], Callable[[C], Callable[[D], R]]]]: ...
|
|
262
|
+
@overload
|
|
263
|
+
def curry[A, B, C, D, E, R](
|
|
264
|
+
f: Callable[[A, B, C, D, E], R],
|
|
265
|
+
) -> Callable[[A], Callable[[B], Callable[[C], Callable[[D], Callable[[E], R]]]]]: ...
|
|
266
|
+
def curry(f: Callable[..., Any]) -> Callable[..., Any]:
|
|
267
|
+
"""Curry ``f`` so each step supplies one positional argument.
|
|
268
|
+
|
|
269
|
+
``curry(f)(a)(b)(c)`` is ``f(a, b, c)``; a step may also supply
|
|
270
|
+
several arguments at once (``curry(f)(a, b)(c)``), and the step that
|
|
271
|
+
completes the arity runs ``f`` immediately. Arities 1-5 are fully
|
|
272
|
+
type-checked; the runtime accepts any fixed arity. Variadic ``*args``
|
|
273
|
+
callables and required keyword-only parameters are rejected at
|
|
274
|
+
construction — the arity is unknowable or unreachable positionally.
|
|
275
|
+
"""
|
|
276
|
+
arity = _positional_arity(f)
|
|
277
|
+
|
|
278
|
+
def curried(*args: Any) -> Any:
|
|
279
|
+
if len(args) >= arity:
|
|
280
|
+
return f(*args)
|
|
281
|
+
return curry(partial(f, *args))
|
|
282
|
+
|
|
283
|
+
return curried
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def lift[T, U, E](
|
|
287
|
+
f: Callable[[T], U],
|
|
288
|
+
) -> Callable[[Result[T, E]], Result[U, E]]:
|
|
289
|
+
"""Lift a unary function into the ``Result`` domain.
|
|
290
|
+
|
|
291
|
+
``lift(f)(r)`` is ``r.map(f)`` as a reusable value — useful when the
|
|
292
|
+
function must be passed somewhere instead of called on a receiver.
|
|
293
|
+
"""
|
|
294
|
+
|
|
295
|
+
def lifted(result: Result[T, E]) -> Result[U, E]:
|
|
296
|
+
if isinstance(result, Ok):
|
|
297
|
+
return Ok(f(result.value))
|
|
298
|
+
return result
|
|
299
|
+
|
|
300
|
+
return lifted
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def lift2[T, U, R, E](
|
|
304
|
+
f: Callable[[T, U], R],
|
|
305
|
+
) -> Callable[[Result[T, E], Result[U, E]], Result[R, E]]:
|
|
306
|
+
"""Lift a binary function into the ``Result`` domain (applicative style).
|
|
307
|
+
|
|
308
|
+
``lift2(f)(r1, r2)`` applies ``f`` to both values, failing fast on the
|
|
309
|
+
first ``Err``.
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
def lifted(first: Result[T, E], second: Result[U, E]) -> Result[R, E]:
|
|
313
|
+
if isinstance(first, Err):
|
|
314
|
+
return first
|
|
315
|
+
if isinstance(second, Err):
|
|
316
|
+
return second
|
|
317
|
+
return Ok(f(first.value, second.value))
|
|
318
|
+
|
|
319
|
+
return lifted
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def lift3[T, U, V, R, E](
|
|
323
|
+
f: Callable[[T, U, V], R],
|
|
324
|
+
) -> Callable[[Result[T, E], Result[U, E], Result[V, E]], Result[R, E]]:
|
|
325
|
+
"""Lift a ternary function into the ``Result`` domain (applicative style).
|
|
326
|
+
|
|
327
|
+
``lift3(f)(r1, r2, r3)`` applies ``f`` to all three values, failing
|
|
328
|
+
fast on the first ``Err``.
|
|
329
|
+
"""
|
|
330
|
+
|
|
331
|
+
def lifted(
|
|
332
|
+
first: Result[T, E], second: Result[U, E], third: Result[V, E]
|
|
333
|
+
) -> Result[R, E]:
|
|
334
|
+
if isinstance(first, Err):
|
|
335
|
+
return first
|
|
336
|
+
if isinstance(second, Err):
|
|
337
|
+
return second
|
|
338
|
+
if isinstance(third, Err):
|
|
339
|
+
return third
|
|
340
|
+
return Ok(f(first.value, second.value, third.value))
|
|
341
|
+
|
|
342
|
+
return lifted
|
pyeffect/effect.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# Classic TypeVars are required for the attempt overloads: PEP 695 type
|
|
2
|
+
# params on @overload are not checked by ty (ty 0.0.77 silently skips
|
|
3
|
+
# overload checking and falls back to the implementation signature).
|
|
4
|
+
"""A lazy, fully typed ``Effect``: defer a computation, compose, then run.
|
|
5
|
+
|
|
6
|
+
An ``Effect[T, E]`` is a *description* of a computation that succeeds with
|
|
7
|
+
``T`` or fails with ``E``. Constructing and composing it runs nothing; only
|
|
8
|
+
``run``/``run_result`` execute the underlying thunk, so effects are
|
|
9
|
+
re-runnable and side effects happen exactly when you run them::
|
|
10
|
+
|
|
11
|
+
>>> from pyeffect.effect import Effect
|
|
12
|
+
>>> Effect.success(2).map(lambda x: x * 3).run()
|
|
13
|
+
6
|
|
14
|
+
>>> Effect.attempt(lambda: 1 / 0, catch=lambda e: str(e)).run_result()
|
|
15
|
+
Err(error='division by zero')
|
|
16
|
+
>>> Effect.success(1).zip(Effect.success("a")).run()
|
|
17
|
+
(1, 'a')
|
|
18
|
+
>>> Effect.failure("boom").context("while parsing").run_result()
|
|
19
|
+
Err(error=ErrorContext(message='while parsing', source='boom'))
|
|
20
|
+
|
|
21
|
+
Dependencies are captured in the thunk's closure — the idiomatic Python
|
|
22
|
+
form of dependency injection. ``and_then`` preserves the error type (Python
|
|
23
|
+
generics are invariant, so a widened error union cannot be expressed
|
|
24
|
+
honestly); combine effects with different failure types via ``map_err`` or
|
|
25
|
+
``catch`` first.
|
|
26
|
+
|
|
27
|
+
Why ``Any`` slots? :meth:`Effect.success` and :meth:`Effect.failure` leave
|
|
28
|
+
the *other* slot as ``Any`` rather than ``Never``: under Python's invariant
|
|
29
|
+
generics a ``Never`` error slot would refuse to flow into chains whose
|
|
30
|
+
error type is fixed later, so ``Effect.success(1).and_then(...)`` would not
|
|
31
|
+
type-check at all. The cost is that the unbound slot stays ``Any`` until
|
|
32
|
+
the chain is anchored — with an annotation (``effect: Effect[int, str] =
|
|
33
|
+
Effect.failure("boom")``) or with an operation that fixes the slot
|
|
34
|
+
(:meth:`Effect.attempt`'s ``catch``, ``map_err``, ``catch``). This is the
|
|
35
|
+
documented compromise of PEP 695 typing, which has no variance annotations.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import time
|
|
41
|
+
from collections.abc import Callable, Iterable
|
|
42
|
+
from typing import Any, TypeVar, overload
|
|
43
|
+
|
|
44
|
+
from pyeffect.result import Err, ErrorContext, Ok, Result, attempt
|
|
45
|
+
from pyeffect.retry import Policy
|
|
46
|
+
from pyeffect.retry import retry as retry_result
|
|
47
|
+
|
|
48
|
+
__all__ = ["Effect", "sequence"]
|
|
49
|
+
|
|
50
|
+
_AttemptT = TypeVar("_AttemptT")
|
|
51
|
+
_AttemptE = TypeVar("_AttemptE")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Effect[T, E]:
|
|
55
|
+
"""A deferred computation that succeeds with ``T`` or fails with ``E``."""
|
|
56
|
+
|
|
57
|
+
__slots__ = ("_thunk",)
|
|
58
|
+
|
|
59
|
+
def __init__(self, thunk: Callable[[], Result[T, E]]) -> None:
|
|
60
|
+
self._thunk = thunk
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def success[U](value: U) -> Effect[U, Any]:
|
|
64
|
+
"""An effect that yields ``value``.
|
|
65
|
+
|
|
66
|
+
The error slot is unbound (``Any``) so it composes into chains
|
|
67
|
+
with any error type — under Python's invariant generics a
|
|
68
|
+
``Never`` error slot would refuse to flow into typed chains.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
return Effect(lambda: Ok(value))
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def failure[V](error: V) -> Effect[Any, V]:
|
|
75
|
+
"""An effect that fails with ``error``.
|
|
76
|
+
|
|
77
|
+
The success slot is unbound (``Any``) — declare it with an
|
|
78
|
+
annotation (``effect: Effect[int, str] = Effect.failure("boom")``)
|
|
79
|
+
and :meth:`catch` recovers with full precision.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
return Effect(lambda: Err(error))
|
|
83
|
+
|
|
84
|
+
@overload
|
|
85
|
+
@staticmethod
|
|
86
|
+
def attempt(fn: Callable[[], _AttemptT]) -> Effect[_AttemptT, Exception]: ...
|
|
87
|
+
@overload
|
|
88
|
+
@staticmethod
|
|
89
|
+
def attempt(
|
|
90
|
+
fn: Callable[[], _AttemptT], *, catch: Callable[[Exception], _AttemptE]
|
|
91
|
+
) -> Effect[_AttemptT, _AttemptE]: ...
|
|
92
|
+
@staticmethod
|
|
93
|
+
def attempt(
|
|
94
|
+
fn: Callable[[], _AttemptT],
|
|
95
|
+
*,
|
|
96
|
+
catch: Callable[[Exception], Any] = lambda e: e,
|
|
97
|
+
) -> Effect[_AttemptT, Any]:
|
|
98
|
+
"""An effect that runs ``fn`` and captures its failure as a value.
|
|
99
|
+
|
|
100
|
+
The exception boundary for effects: :func:`pyeffect.result.attempt`
|
|
101
|
+
deferred until ``run``/``run_result``. Only ``Exception`` is
|
|
102
|
+
captured — ``KeyboardInterrupt`` and ``SystemExit`` are
|
|
103
|
+
bugs/interrupts and must propagate (fail fast).
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def thunk() -> Result[_AttemptT, Any]:
|
|
107
|
+
return attempt(fn, catch=catch)
|
|
108
|
+
|
|
109
|
+
return Effect(thunk)
|
|
110
|
+
|
|
111
|
+
def map[U](self, f: Callable[[T], U]) -> Effect[U, E]:
|
|
112
|
+
"""Transform the success value; a failure passes through unchanged."""
|
|
113
|
+
|
|
114
|
+
return Effect(lambda: self._thunk().map(f))
|
|
115
|
+
|
|
116
|
+
def map_err[E2](self, f: Callable[[E], E2]) -> Effect[T, E2]:
|
|
117
|
+
"""Transform the failure value; a success passes through unchanged."""
|
|
118
|
+
|
|
119
|
+
return Effect(lambda: self._thunk().map_err(f))
|
|
120
|
+
|
|
121
|
+
def inspect(self, f: Callable[[T], object]) -> Effect[T, E]:
|
|
122
|
+
"""Run ``f`` on the success value for its side effect, lazily."""
|
|
123
|
+
|
|
124
|
+
return Effect(lambda: self._thunk().inspect(f))
|
|
125
|
+
|
|
126
|
+
def inspect_err(self, f: Callable[[E], object]) -> Effect[T, E]:
|
|
127
|
+
"""Run ``f`` on the failure for its side effect, lazily."""
|
|
128
|
+
|
|
129
|
+
return Effect(lambda: self._thunk().inspect_err(f))
|
|
130
|
+
|
|
131
|
+
def and_then[U](self, f: Callable[[T], Effect[U, E]]) -> Effect[U, E]:
|
|
132
|
+
"""Chain onto the success value; a failure short-circuits."""
|
|
133
|
+
|
|
134
|
+
def thunk() -> Result[U, E]:
|
|
135
|
+
return self._thunk().and_then(lambda value: f(value).run_result())
|
|
136
|
+
|
|
137
|
+
return Effect(thunk)
|
|
138
|
+
|
|
139
|
+
def and_[U](self, other: Effect[U, E]) -> Effect[U, E]:
|
|
140
|
+
"""Run ``self``; on success run and return ``other`` (the eager ``and``)."""
|
|
141
|
+
|
|
142
|
+
return self.and_then(lambda _: other)
|
|
143
|
+
|
|
144
|
+
def flatten[U](self: Effect[Effect[U, E], E]) -> Effect[U, E]:
|
|
145
|
+
"""Collapse a nested effect: running it runs the inner effect."""
|
|
146
|
+
|
|
147
|
+
def thunk() -> Result[U, E]:
|
|
148
|
+
outer = self._thunk()
|
|
149
|
+
if isinstance(outer, Ok):
|
|
150
|
+
return outer.value.run_result()
|
|
151
|
+
return outer
|
|
152
|
+
|
|
153
|
+
return Effect(thunk)
|
|
154
|
+
|
|
155
|
+
def zip[U](self, other: Effect[U, E]) -> Effect[tuple[T, U], E]:
|
|
156
|
+
"""Pair this effect's value with ``other``'s; fail fast on the first error.
|
|
157
|
+
|
|
158
|
+
Nothing runs at construction. When the result is run, the thunks
|
|
159
|
+
execute left to right, and the second is skipped entirely if the
|
|
160
|
+
first fails.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def thunk() -> Result[tuple[T, U], E]:
|
|
164
|
+
first = self._thunk()
|
|
165
|
+
if isinstance(first, Ok):
|
|
166
|
+
second = other._thunk()
|
|
167
|
+
if isinstance(second, Ok):
|
|
168
|
+
return Ok((first.value, second.value))
|
|
169
|
+
return second
|
|
170
|
+
return first
|
|
171
|
+
|
|
172
|
+
return Effect(thunk)
|
|
173
|
+
|
|
174
|
+
def map2[U, R](self, other: Effect[U, E], f: Callable[[T, U], R]) -> Effect[R, E]:
|
|
175
|
+
"""Apply ``f`` to this effect's value and ``other``'s; fail fast.
|
|
176
|
+
|
|
177
|
+
``a.map2(b, f)`` is ``a.zip(b).map(unpack(f))``, deferred and lazy.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def thunk() -> Result[R, E]:
|
|
181
|
+
first = self._thunk()
|
|
182
|
+
if isinstance(first, Ok):
|
|
183
|
+
second = other._thunk()
|
|
184
|
+
if isinstance(second, Ok):
|
|
185
|
+
return Ok(f(first.value, second.value))
|
|
186
|
+
return second
|
|
187
|
+
return first
|
|
188
|
+
|
|
189
|
+
return Effect(thunk)
|
|
190
|
+
|
|
191
|
+
def catch[E2](self, f: Callable[[E], Effect[T, E2]]) -> Effect[T, E2]:
|
|
192
|
+
"""Recover from failure; a success passes through unchanged."""
|
|
193
|
+
|
|
194
|
+
def thunk() -> Result[T, E2]:
|
|
195
|
+
return self._thunk().or_else(lambda error: f(error).run_result())
|
|
196
|
+
|
|
197
|
+
return Effect(thunk)
|
|
198
|
+
|
|
199
|
+
def or_[F](self, other: Effect[T, F]) -> Effect[T, F]:
|
|
200
|
+
"""Run ``self``; on failure run and return ``other`` (the eager ``or``)."""
|
|
201
|
+
|
|
202
|
+
return self.catch(lambda _: other)
|
|
203
|
+
|
|
204
|
+
def map_or[R](self, default: R, f: Callable[[T], R]) -> Effect[R, E]:
|
|
205
|
+
"""Apply ``f`` to the value, or yield ``default`` on failure."""
|
|
206
|
+
|
|
207
|
+
return Effect(lambda: Ok(self._thunk().map_or(default, f)))
|
|
208
|
+
|
|
209
|
+
def map_or_else[R](
|
|
210
|
+
self, default: Callable[[E], R], f: Callable[[T], R]
|
|
211
|
+
) -> Effect[R, E]:
|
|
212
|
+
"""Apply ``f`` to the value, or ``default(error)`` on failure."""
|
|
213
|
+
|
|
214
|
+
return Effect(lambda: Ok(self._thunk().map_or_else(default, f)))
|
|
215
|
+
|
|
216
|
+
def context(self, message: str) -> Effect[T, ErrorContext]:
|
|
217
|
+
"""Attach context to the failure; a success passes through unchanged.
|
|
218
|
+
|
|
219
|
+
Mirrors :meth:`pyeffect.result.Result.context` — the error slot
|
|
220
|
+
becomes an :class:`~pyeffect.result.ErrorContext` carrying the
|
|
221
|
+
message and the original error.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
return Effect(lambda: self._thunk().context(message))
|
|
225
|
+
|
|
226
|
+
def with_context(self, f: Callable[[E], str]) -> Effect[T, ErrorContext]:
|
|
227
|
+
"""Like :meth:`context`, but the message is computed lazily on failure."""
|
|
228
|
+
|
|
229
|
+
return Effect(lambda: self._thunk().with_context(f))
|
|
230
|
+
|
|
231
|
+
def retry(
|
|
232
|
+
self,
|
|
233
|
+
policy: Policy,
|
|
234
|
+
*,
|
|
235
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
236
|
+
) -> Effect[T, E]:
|
|
237
|
+
"""Re-run this effect's thunk according to ``policy``."""
|
|
238
|
+
|
|
239
|
+
def thunk() -> Result[T, E]:
|
|
240
|
+
return retry_result(lambda _attempt: self._thunk(), policy, sleep=sleep)
|
|
241
|
+
|
|
242
|
+
return Effect(thunk)
|
|
243
|
+
|
|
244
|
+
def run_result(self) -> Result[T, E]:
|
|
245
|
+
"""Execute the effect and return its :data:`Result`."""
|
|
246
|
+
|
|
247
|
+
return self._thunk()
|
|
248
|
+
|
|
249
|
+
def run(self) -> T:
|
|
250
|
+
"""Execute the effect and return the value.
|
|
251
|
+
|
|
252
|
+
Panics with :class:`~pyeffect.result.UnwrapError` on failure — the
|
|
253
|
+
caller chose the fail-fast edge. Use :meth:`run_result` or
|
|
254
|
+
:meth:`catch` to handle failure as a value.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
return self._thunk().unwrap()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def sequence[T, E](effects: Iterable[Effect[T, E]]) -> Effect[list[T], E]:
|
|
261
|
+
"""Run a list of effects in order; fail fast on the first failure.
|
|
262
|
+
|
|
263
|
+
The effects are materialized up front, so the resulting effect is
|
|
264
|
+
re-runnable even when given a generator.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
materialized = list(effects)
|
|
268
|
+
|
|
269
|
+
def thunk() -> Result[list[T], E]:
|
|
270
|
+
values: list[T] = []
|
|
271
|
+
for effect in materialized:
|
|
272
|
+
result = effect.run_result()
|
|
273
|
+
if isinstance(result, Ok):
|
|
274
|
+
values.append(result.value)
|
|
275
|
+
else:
|
|
276
|
+
return result
|
|
277
|
+
return Ok(values)
|
|
278
|
+
|
|
279
|
+
return Effect(thunk)
|