pyeffect 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,138 @@
1
+ Metadata-Version: 2.3
2
+ Name: pyeffect
3
+ Version: 0.1.0
4
+ Summary: A fully typed functional core for Python: Result, Option, Effect, and composition utilities.
5
+ Author: Tomperez98
6
+ Author-email: Tomperez98 <tomasperezalvarez@gmail.com>
7
+ Requires-Python: >=3.14
8
+ Description-Content-Type: text/markdown
9
+
10
+ # pyeffect
11
+
12
+ Handle expected failures as **values** — a fully typed `Result`, `Option`,
13
+ and lazy `Effect` core for Python 3.14+.
14
+
15
+ ```python
16
+ from pyeffect import Err, Ok, Result, attempt
17
+
18
+ result: Result[int, str] = attempt(lambda: int("42")).map(lambda n: n * 2)
19
+ match result:
20
+ case Ok(value):
21
+ print(value) # 84
22
+ case Err(error):
23
+ print(f"failed: {error}")
24
+ ```
25
+
26
+ ## What it is
27
+
28
+ `pyeffect` is a functional core for Python that turns expected failure
29
+ (network errors, bad input, absence) into values you can compose, instead of
30
+ exceptions you must catch. `Result` and `Option` handle the data; `Effect`
31
+ defers side effects until you run them.
32
+
33
+ ### The one rule
34
+
35
+ > **Bugs panic, expected failures return values.**
36
+
37
+ - **Panic** when the program reaches a state that should be impossible —
38
+ `unwrap()` on `Err`/`Nothing`, a broken retry `Policy`, an over-arity
39
+ `curry`. The damage stops at the exact line.
40
+ - **Return a value** when failure is expected — network errors, bad input,
41
+ absence. The caller decides what to do with it.
42
+
43
+ ## Install
44
+
45
+ Not on PyPI yet — clone the repo and install from source:
46
+
47
+ ```bash
48
+ uv sync
49
+ ```
50
+
51
+ That installs `pyeffect` and the dev dependencies (pytest, ruff, ty) into
52
+ `.venv`.
53
+
54
+ ## A complete example
55
+
56
+ Paste this and it runs — expected output is in the comments:
57
+
58
+ ```python
59
+ from pyeffect import Effect, Err, Ok, Result, attempt, from_optional
60
+
61
+ # Expected failure is a value the caller handles.
62
+ result: Result[int, str] = attempt(lambda: int("42")).map(lambda n: n * 2)
63
+ match result:
64
+ case Ok(value):
65
+ print(value) # 84
66
+ case Err(error):
67
+ print(f"failed: {error}")
68
+
69
+ # Absence is a value too.
70
+ assert from_optional({"a": 1}.get("b")).unwrap_or(0) == 0
71
+
72
+ # Side effects are deferred until you run them.
73
+ effect = (
74
+ Effect.attempt(lambda: open("config.json").read())
75
+ .map(str.strip)
76
+ .context("while loading config")
77
+ .catch(lambda e: Effect.success("{}"))
78
+ )
79
+ print(effect.run()) # {} — the read failed, the fallback ran
80
+ ```
81
+
82
+ ## What's inside
83
+
84
+ | Module | Types |
85
+ |---|---|
86
+ | `pyeffect.result` | `Ok` / `Err` / `Result[T, E]`, `attempt`, `guard`, `traverse`, `flatten`, `transpose`, `ErrorContext` |
87
+ | `pyeffect.option` | `Some` / `Nothing` / `Option[T]`, `from_optional`, `flatten`, `transpose` |
88
+ | `pyeffect.effect` | `Effect[T, E]` — a lazy, re-runnable computation; `sequence`, `attempt`, `retry_result` |
89
+ | `pyeffect.retry` | `retry` + `Policy` — deterministic, injectable backoff |
90
+ | `pyeffect.pipe` | `pipe(value, f, g, ...)` — left-to-right threading |
91
+ | `pyeffect.compose` | `compose`, `curry`, `lift` / `lift2` / `lift3`, `identity`, `tap`, `flip`, `unpack`, `constant`, `partial` |
92
+
93
+ The combinators are methods: `Effect` carries `map`, `and_then`, `catch`,
94
+ `context`, `inspect`, `inspect_err`, `map_or`, `flatten`, `zip`, and `retry`;
95
+ every variant has the eager boolean combinators `and_` / `or_` (trailing
96
+ underscore because `and`/`or` are Python keywords), plus `Option.xor`.
97
+
98
+ `flatten` and `transpose` are module-level functions on `result` and
99
+ `option` (`from pyeffect.result import flatten, transpose`) because Python's
100
+ invariant generics make the equivalent methods untypeable at call sites.
101
+
102
+ ## `Effect` is the impurity boundary
103
+
104
+ Python is eager: every call executes immediately, so purity is lost the
105
+ moment you touch IO or time. `Effect` restores it — **constructing and
106
+ composing run nothing; `run()` / `run_result()` are the only impure
107
+ moments.** Dependencies (readers, clocks, backends) are captured in the
108
+ thunk's closure, and `sleep` is injected, so tests run with zero delay and
109
+ no real I/O.
110
+
111
+ The library is **sync-first by design**: the value layer (`Result`,
112
+ `Option`, `pipe`, `compose`) is pure and async-agnostic, and `Effect` is a
113
+ sync thunk over `Result`. An `AsyncEffect` sibling (awaitable thunks,
114
+ `asyncio.sleep`-injected retry) is planned for when network use-cases
115
+ demand it — not before.
116
+
117
+ ## Typing notes
118
+
119
+ Every combinator is checked with [`ty`](https://github.com/astral-sh/ty) as
120
+ part of the test suite (`tests/typing/` pins the contracts, including
121
+ intentional failures that must stay rejected). Known constraints of Python
122
+ generics, documented in the module docstrings:
123
+
124
+ - **No variance in PEP 695** — `Effect.success` / `Effect.failure` leave the
125
+ unbound slot as `Any`; anchor the chain with an annotation or an operation
126
+ that fixes the slot (`attempt`'s `catch`, `map_err`, `context`).
127
+ - **No higher-kinded types** — there are no generic `Functor` / `Monad`
128
+ typeclasses; each type implements `map` / `and_then` structurally.
129
+ - **Fixed arity ceilings** — `pipe` / `compose` type-check up to ten
130
+ functions, `curry` up to five; the runtimes accept more.
131
+
132
+ ## Development
133
+
134
+ ```bash
135
+ uv run pytest # runtime + doctest tests
136
+ uv run ty check src tests # typing contract; also gates the tests/typing/ fixtures
137
+ uv run ruff check src tests
138
+ ```
@@ -0,0 +1,129 @@
1
+ # pyeffect
2
+
3
+ Handle expected failures as **values** — a fully typed `Result`, `Option`,
4
+ and lazy `Effect` core for Python 3.14+.
5
+
6
+ ```python
7
+ from pyeffect import Err, Ok, Result, attempt
8
+
9
+ result: Result[int, str] = attempt(lambda: int("42")).map(lambda n: n * 2)
10
+ match result:
11
+ case Ok(value):
12
+ print(value) # 84
13
+ case Err(error):
14
+ print(f"failed: {error}")
15
+ ```
16
+
17
+ ## What it is
18
+
19
+ `pyeffect` is a functional core for Python that turns expected failure
20
+ (network errors, bad input, absence) into values you can compose, instead of
21
+ exceptions you must catch. `Result` and `Option` handle the data; `Effect`
22
+ defers side effects until you run them.
23
+
24
+ ### The one rule
25
+
26
+ > **Bugs panic, expected failures return values.**
27
+
28
+ - **Panic** when the program reaches a state that should be impossible —
29
+ `unwrap()` on `Err`/`Nothing`, a broken retry `Policy`, an over-arity
30
+ `curry`. The damage stops at the exact line.
31
+ - **Return a value** when failure is expected — network errors, bad input,
32
+ absence. The caller decides what to do with it.
33
+
34
+ ## Install
35
+
36
+ Not on PyPI yet — clone the repo and install from source:
37
+
38
+ ```bash
39
+ uv sync
40
+ ```
41
+
42
+ That installs `pyeffect` and the dev dependencies (pytest, ruff, ty) into
43
+ `.venv`.
44
+
45
+ ## A complete example
46
+
47
+ Paste this and it runs — expected output is in the comments:
48
+
49
+ ```python
50
+ from pyeffect import Effect, Err, Ok, Result, attempt, from_optional
51
+
52
+ # Expected failure is a value the caller handles.
53
+ result: Result[int, str] = attempt(lambda: int("42")).map(lambda n: n * 2)
54
+ match result:
55
+ case Ok(value):
56
+ print(value) # 84
57
+ case Err(error):
58
+ print(f"failed: {error}")
59
+
60
+ # Absence is a value too.
61
+ assert from_optional({"a": 1}.get("b")).unwrap_or(0) == 0
62
+
63
+ # Side effects are deferred until you run them.
64
+ effect = (
65
+ Effect.attempt(lambda: open("config.json").read())
66
+ .map(str.strip)
67
+ .context("while loading config")
68
+ .catch(lambda e: Effect.success("{}"))
69
+ )
70
+ print(effect.run()) # {} — the read failed, the fallback ran
71
+ ```
72
+
73
+ ## What's inside
74
+
75
+ | Module | Types |
76
+ |---|---|
77
+ | `pyeffect.result` | `Ok` / `Err` / `Result[T, E]`, `attempt`, `guard`, `traverse`, `flatten`, `transpose`, `ErrorContext` |
78
+ | `pyeffect.option` | `Some` / `Nothing` / `Option[T]`, `from_optional`, `flatten`, `transpose` |
79
+ | `pyeffect.effect` | `Effect[T, E]` — a lazy, re-runnable computation; `sequence`, `attempt`, `retry_result` |
80
+ | `pyeffect.retry` | `retry` + `Policy` — deterministic, injectable backoff |
81
+ | `pyeffect.pipe` | `pipe(value, f, g, ...)` — left-to-right threading |
82
+ | `pyeffect.compose` | `compose`, `curry`, `lift` / `lift2` / `lift3`, `identity`, `tap`, `flip`, `unpack`, `constant`, `partial` |
83
+
84
+ The combinators are methods: `Effect` carries `map`, `and_then`, `catch`,
85
+ `context`, `inspect`, `inspect_err`, `map_or`, `flatten`, `zip`, and `retry`;
86
+ every variant has the eager boolean combinators `and_` / `or_` (trailing
87
+ underscore because `and`/`or` are Python keywords), plus `Option.xor`.
88
+
89
+ `flatten` and `transpose` are module-level functions on `result` and
90
+ `option` (`from pyeffect.result import flatten, transpose`) because Python's
91
+ invariant generics make the equivalent methods untypeable at call sites.
92
+
93
+ ## `Effect` is the impurity boundary
94
+
95
+ Python is eager: every call executes immediately, so purity is lost the
96
+ moment you touch IO or time. `Effect` restores it — **constructing and
97
+ composing run nothing; `run()` / `run_result()` are the only impure
98
+ moments.** Dependencies (readers, clocks, backends) are captured in the
99
+ thunk's closure, and `sleep` is injected, so tests run with zero delay and
100
+ no real I/O.
101
+
102
+ The library is **sync-first by design**: the value layer (`Result`,
103
+ `Option`, `pipe`, `compose`) is pure and async-agnostic, and `Effect` is a
104
+ sync thunk over `Result`. An `AsyncEffect` sibling (awaitable thunks,
105
+ `asyncio.sleep`-injected retry) is planned for when network use-cases
106
+ demand it — not before.
107
+
108
+ ## Typing notes
109
+
110
+ Every combinator is checked with [`ty`](https://github.com/astral-sh/ty) as
111
+ part of the test suite (`tests/typing/` pins the contracts, including
112
+ intentional failures that must stay rejected). Known constraints of Python
113
+ generics, documented in the module docstrings:
114
+
115
+ - **No variance in PEP 695** — `Effect.success` / `Effect.failure` leave the
116
+ unbound slot as `Any`; anchor the chain with an annotation or an operation
117
+ that fixes the slot (`attempt`'s `catch`, `map_err`, `context`).
118
+ - **No higher-kinded types** — there are no generic `Functor` / `Monad`
119
+ typeclasses; each type implements `map` / `and_then` structurally.
120
+ - **Fixed arity ceilings** — `pipe` / `compose` type-check up to ten
121
+ functions, `curry` up to five; the runtimes accept more.
122
+
123
+ ## Development
124
+
125
+ ```bash
126
+ uv run pytest # runtime + doctest tests
127
+ uv run ty check src tests # typing contract; also gates the tests/typing/ fixtures
128
+ uv run ruff check src tests
129
+ ```
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "pyeffect"
3
+ version = "0.1.0"
4
+ description = "A fully typed functional core for Python: Result, Option, Effect, and composition utilities."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Tomperez98", email = "tomasperezalvarez@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.14"
10
+ dependencies = []
11
+
12
+ [build-system]
13
+ requires = ["uv_build>=0.11.21,<0.12.0"]
14
+ build-backend = "uv_build"
15
+
16
+ [dependency-groups]
17
+ dev = [
18
+ "pytest>=9.1.1",
19
+ "ruff>=0.16.5",
20
+ "ty>=0.0.77",
21
+ ]
22
+
23
+ [tool.pytest.ini_options]
24
+ testpaths = ["tests"]
@@ -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
+ ]
@@ -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