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 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)