effecton 0.2.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.
- effecton/__init__.py +106 -0
- effecton/attempt.py +42 -0
- effecton/catch.py +36 -0
- effecton/effect.py +206 -0
- effecton/exit.py +63 -0
- effecton/gen.py +47 -0
- effecton/implicit_requirement.py +40 -0
- effecton/provide.py +36 -0
- effecton/py.typed +0 -0
- effecton/run_async.py +237 -0
- effecton/run_sync.py +199 -0
- effecton/std/__init__.py +1 -0
- effecton/std/logger.py +214 -0
- effecton/std/pretty_logger.py +119 -0
- effecton/std/scope.py +58 -0
- effecton/suspend.py +47 -0
- effecton-0.2.0.dist-info/METADATA +389 -0
- effecton-0.2.0.dist-info/RECORD +20 -0
- effecton-0.2.0.dist-info/WHEEL +4 -0
- effecton-0.2.0.dist-info/licenses/LICENSE +9 -0
effecton/__init__.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from effecton.attempt import attempt, attempt_async
|
|
2
|
+
from effecton.effect import (
|
|
3
|
+
Cause,
|
|
4
|
+
Die,
|
|
5
|
+
Effect,
|
|
6
|
+
EffectonError,
|
|
7
|
+
Fail,
|
|
8
|
+
Interrupt,
|
|
9
|
+
coroutine,
|
|
10
|
+
die,
|
|
11
|
+
fail,
|
|
12
|
+
require,
|
|
13
|
+
success,
|
|
14
|
+
sync,
|
|
15
|
+
)
|
|
16
|
+
from effecton.exit import Exit, Failure, Succeeded, UnhandledDefect
|
|
17
|
+
from effecton.gen import EffectGen, gen
|
|
18
|
+
from effecton.implicit_requirement import (
|
|
19
|
+
ImplicitRequirement,
|
|
20
|
+
provide_implicit,
|
|
21
|
+
require_implicit,
|
|
22
|
+
)
|
|
23
|
+
from effecton.run_async import run_async, run_async_coroutine, run_async_exit
|
|
24
|
+
from effecton.run_sync import (
|
|
25
|
+
AsyncEffectInSyncRun,
|
|
26
|
+
MissingRequirement,
|
|
27
|
+
run_sync,
|
|
28
|
+
run_sync_exit,
|
|
29
|
+
)
|
|
30
|
+
from effecton.std.logger import (
|
|
31
|
+
CurrentLogAnnotations,
|
|
32
|
+
CurrentLoggers,
|
|
33
|
+
CurrentLogLevel,
|
|
34
|
+
EffectonLogger,
|
|
35
|
+
LogData,
|
|
36
|
+
LogLevel,
|
|
37
|
+
MinimumLogLevel,
|
|
38
|
+
Severity,
|
|
39
|
+
annotate_logs,
|
|
40
|
+
log,
|
|
41
|
+
log_debug,
|
|
42
|
+
log_error,
|
|
43
|
+
log_fatal,
|
|
44
|
+
log_info,
|
|
45
|
+
log_trace,
|
|
46
|
+
log_warning,
|
|
47
|
+
)
|
|
48
|
+
from effecton.std.pretty_logger import PrettyFormatter, pretty_logger
|
|
49
|
+
from effecton.std.scope import Scope, acquire_and_release, add_finalizer, scoped
|
|
50
|
+
from effecton.suspend import suspend
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"AsyncEffectInSyncRun",
|
|
54
|
+
"Cause",
|
|
55
|
+
"CurrentLogAnnotations",
|
|
56
|
+
"CurrentLogLevel",
|
|
57
|
+
"CurrentLoggers",
|
|
58
|
+
"Die",
|
|
59
|
+
"Effect",
|
|
60
|
+
"EffectGen",
|
|
61
|
+
"EffectonError",
|
|
62
|
+
"EffectonLogger",
|
|
63
|
+
"Exit",
|
|
64
|
+
"Fail",
|
|
65
|
+
"Failure",
|
|
66
|
+
"ImplicitRequirement",
|
|
67
|
+
"Interrupt",
|
|
68
|
+
"LogData",
|
|
69
|
+
"LogLevel",
|
|
70
|
+
"MinimumLogLevel",
|
|
71
|
+
"MissingRequirement",
|
|
72
|
+
"PrettyFormatter",
|
|
73
|
+
"Scope",
|
|
74
|
+
"Severity",
|
|
75
|
+
"Succeeded",
|
|
76
|
+
"UnhandledDefect",
|
|
77
|
+
"acquire_and_release",
|
|
78
|
+
"add_finalizer",
|
|
79
|
+
"annotate_logs",
|
|
80
|
+
"attempt",
|
|
81
|
+
"attempt_async",
|
|
82
|
+
"coroutine",
|
|
83
|
+
"die",
|
|
84
|
+
"fail",
|
|
85
|
+
"gen",
|
|
86
|
+
"log",
|
|
87
|
+
"log_debug",
|
|
88
|
+
"log_error",
|
|
89
|
+
"log_fatal",
|
|
90
|
+
"log_info",
|
|
91
|
+
"log_trace",
|
|
92
|
+
"log_warning",
|
|
93
|
+
"pretty_logger",
|
|
94
|
+
"provide_implicit",
|
|
95
|
+
"require",
|
|
96
|
+
"require_implicit",
|
|
97
|
+
"run_async",
|
|
98
|
+
"run_async_coroutine",
|
|
99
|
+
"run_async_exit",
|
|
100
|
+
"run_sync",
|
|
101
|
+
"run_sync_exit",
|
|
102
|
+
"scoped",
|
|
103
|
+
"success",
|
|
104
|
+
"suspend",
|
|
105
|
+
"sync",
|
|
106
|
+
]
|
effecton/attempt.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from collections.abc import Awaitable, Callable
|
|
2
|
+
|
|
3
|
+
from effecton.effect import Effect, EffectonError, coroutine, fail, success
|
|
4
|
+
from effecton.suspend import suspend
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@suspend
|
|
8
|
+
def attempt[A, E: EffectonError](
|
|
9
|
+
thunk: Callable[[], A], on_error: Callable[[Exception], E]
|
|
10
|
+
) -> Effect[A, E]:
|
|
11
|
+
"""Run an exception-throwing thunk lazily, with typed failures.
|
|
12
|
+
|
|
13
|
+
The thunk runs once per run of the effect, like sync. When it raises,
|
|
14
|
+
on_error maps the exception into the typed error channel — under sync,
|
|
15
|
+
every exception becomes an uncatchable defect. To keep an unexpected
|
|
16
|
+
exception a defect, re-raise it from on_error.
|
|
17
|
+
"""
|
|
18
|
+
try:
|
|
19
|
+
return success(thunk())
|
|
20
|
+
except Exception as e:
|
|
21
|
+
return fail(on_error(e))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def attempt_async[A, E: EffectonError](
|
|
25
|
+
thunk: Callable[[], Awaitable[A]], on_error: Callable[[Exception], E]
|
|
26
|
+
) -> Effect[A, E]:
|
|
27
|
+
"""Await an exception-throwing thunk lazily, with typed failures.
|
|
28
|
+
|
|
29
|
+
The async counterpart of attempt: the thunk builds a fresh awaitable
|
|
30
|
+
once per run of the effect, like coroutine, and on_error maps an
|
|
31
|
+
exception raised by the thunk or by the await into the typed error
|
|
32
|
+
channel. Re-raise from on_error to keep an unexpected exception a
|
|
33
|
+
defect. Only the run_async family can interpret the result.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
async def go() -> Effect[A, E]:
|
|
37
|
+
try:
|
|
38
|
+
return success(await thunk())
|
|
39
|
+
except Exception as e:
|
|
40
|
+
return fail(on_error(e))
|
|
41
|
+
|
|
42
|
+
return coroutine(go).flat_map(lambda effect: effect)
|
effecton/catch.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Generic, Never, TypeVar, final
|
|
4
|
+
|
|
5
|
+
from effecton.effect import Effect, EffectonError, OnFailure, fail
|
|
6
|
+
|
|
7
|
+
# Old-style TypeVars declare the variance ty cannot infer across the
|
|
8
|
+
# Effect ↔ CatchBinder reference cycle; see the ty inference notes in
|
|
9
|
+
# AGENTS.md.
|
|
10
|
+
A = TypeVar("A", covariant=True)
|
|
11
|
+
E = TypeVar("E", bound=EffectonError, covariant=True)
|
|
12
|
+
R = TypeVar("R", covariant=True)
|
|
13
|
+
T = TypeVar("T", bound=EffectonError)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@final
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class CatchBinder(Generic[A, E, R, T]): # noqa: UP046
|
|
19
|
+
"""One step of ``effect.catch(T)(handler)``: T is bound, handler pending.
|
|
20
|
+
|
|
21
|
+
Catch particular error E, return new Effect.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
effect: Effect[A, E, R]
|
|
25
|
+
error_type: type[T]
|
|
26
|
+
|
|
27
|
+
def __call__[A2, B, E3: EffectonError, R2, R3, E2: EffectonError = Never](
|
|
28
|
+
self: CatchBinder[A2, T | E2, R2, T],
|
|
29
|
+
handler: Callable[[T], Effect[B, E3, R3]],
|
|
30
|
+
) -> Effect[A2 | B, E2 | E3, R2 | R3]:
|
|
31
|
+
error_type = self.error_type
|
|
32
|
+
|
|
33
|
+
return OnFailure[A2 | B, E2 | E3, R2 | R3](
|
|
34
|
+
self.effect,
|
|
35
|
+
lambda e: handler(e) if isinstance(e, error_type) else fail(e),
|
|
36
|
+
)
|
effecton/effect.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
from collections.abc import Awaitable, Callable, Generator
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Literal, Never, final
|
|
4
|
+
|
|
5
|
+
from typing_extensions import TypeForm
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from effecton.catch import CatchBinder
|
|
9
|
+
from effecton.provide import ProvideBinder
|
|
10
|
+
from effecton.std.scope import Scope
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class EffectonError(Exception):
|
|
15
|
+
def __str__(self) -> str:
|
|
16
|
+
# The dataclass __init__ never fills Exception.args, so the
|
|
17
|
+
# inherited __str__ renders every error as an empty string.
|
|
18
|
+
return repr(self)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@final
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Fail[E: EffectonError]:
|
|
24
|
+
error: E
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@final
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Die:
|
|
30
|
+
defect: Any
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@final
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Interrupt:
|
|
36
|
+
"""The effect was cut short by a cancellation.
|
|
37
|
+
|
|
38
|
+
Carries the BaseException that signalled it, such as
|
|
39
|
+
asyncio.CancelledError. The runner consumes the cancellation and
|
|
40
|
+
settles as Failure(Interrupt(exception)); a caller whose task should
|
|
41
|
+
stop re-raises the exception. Like Die, it is not an error: catch_all
|
|
42
|
+
and catch only handle Fail.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
exception: BaseException
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
type Cause[E: EffectonError] = Fail[E] | Die | Interrupt
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Effect[A, E: EffectonError = Never, R = Never]:
|
|
52
|
+
def flat_map[B, E2: EffectonError, R2](
|
|
53
|
+
self, f: Callable[[A], Effect[B, E2, R2]]
|
|
54
|
+
) -> Effect[B, E | E2, R | R2]:
|
|
55
|
+
return FlatMap(self, f)
|
|
56
|
+
|
|
57
|
+
def map[B](self, f: Callable[[A], B]) -> Effect[B, E, R]:
|
|
58
|
+
return self.flat_map(lambda a: Success(f(a)))
|
|
59
|
+
|
|
60
|
+
def catch_all[B, E2: EffectonError, R2](
|
|
61
|
+
self, f: Callable[[E], Effect[B, E2, R2]]
|
|
62
|
+
) -> Effect[A | B, E2, R | R2]:
|
|
63
|
+
return OnFailure[A | B, E2, R | R2](self, f)
|
|
64
|
+
|
|
65
|
+
def catch[T: EffectonError](self, error_type: type[T]) -> CatchBinder[A, E, R, T]:
|
|
66
|
+
from effecton.catch import CatchBinder
|
|
67
|
+
|
|
68
|
+
return CatchBinder(effect=self, error_type=error_type)
|
|
69
|
+
|
|
70
|
+
def on_exit[R2](self, finalizer: Effect[Any, Never, R2]) -> Effect[A, E, R | R2]:
|
|
71
|
+
return OnExit(self, finalizer)
|
|
72
|
+
|
|
73
|
+
def provide[T](self, requirement_type: TypeForm[T]) -> ProvideBinder[A, E, R, T]:
|
|
74
|
+
from effecton.provide import ProvideBinder
|
|
75
|
+
|
|
76
|
+
return ProvideBinder(effect=self, requirement_type=requirement_type)
|
|
77
|
+
|
|
78
|
+
def scoped[A2, E2: EffectonError, R2 = Never](
|
|
79
|
+
self: Effect[A2, E2, Scope | R2],
|
|
80
|
+
) -> Effect[A2, E2, R2]:
|
|
81
|
+
from effecton.std.scope import scoped
|
|
82
|
+
|
|
83
|
+
return scoped(self)
|
|
84
|
+
|
|
85
|
+
def __iter__(self) -> Generator[Effect[A, E, R], Any, A]:
|
|
86
|
+
"""Make ``x = yield from effect`` infer ``x`` as A inside @gen.
|
|
87
|
+
|
|
88
|
+
A bare ``yield`` types as the generator's single send type (Any).
|
|
89
|
+
``yield from`` takes this method's return type parameter instead,
|
|
90
|
+
so the value the interpreter sends back is typed per expression.
|
|
91
|
+
"""
|
|
92
|
+
return (yield self)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@final
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class Success[A](Effect[A]):
|
|
98
|
+
value: A
|
|
99
|
+
kind: Literal["success"] = "success"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@final
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class Sync[A](Effect[A]):
|
|
105
|
+
fn: Callable[[], A]
|
|
106
|
+
kind: Literal["sync"] = "sync"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@final
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class Coroutine[A](Effect[A]):
|
|
112
|
+
fn: Callable[[], Awaitable[A]]
|
|
113
|
+
kind: Literal["coroutine"] = "coroutine"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@final
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class FailCause[E: EffectonError](Effect[Never, E]):
|
|
119
|
+
cause: Cause[E]
|
|
120
|
+
kind: Literal["fail"] = "fail"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@final
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class FlatMap[B, E: EffectonError, R](Effect[B, E, R]):
|
|
126
|
+
first: Effect[Any, Any, Any]
|
|
127
|
+
and_then: Callable[[Any], Effect[B, E, R]]
|
|
128
|
+
kind: Literal["flat_map"] = "flat_map"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@final
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class OnFailure[A, E: EffectonError, R](Effect[A, E, R]):
|
|
134
|
+
first: Effect[A, Any, Any]
|
|
135
|
+
handler: Callable[[Any], Effect[A, E, R]]
|
|
136
|
+
kind: Literal["on_failure"] = "on_failure"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@final
|
|
140
|
+
@dataclass(frozen=True)
|
|
141
|
+
class Require[R](Effect[R, Never, R]):
|
|
142
|
+
requirement_type: TypeForm[R]
|
|
143
|
+
kind: Literal["require"] = "require"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@final
|
|
147
|
+
@dataclass(frozen=True)
|
|
148
|
+
class ProvideRequirement[A, E: EffectonError, R](Effect[A, E, R]):
|
|
149
|
+
first: Effect[A, E, Any]
|
|
150
|
+
requirement_type: TypeForm[Any]
|
|
151
|
+
requirement_impl: Any
|
|
152
|
+
kind: Literal["provide_requirement"] = "provide_requirement"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@final
|
|
156
|
+
@dataclass(frozen=True)
|
|
157
|
+
class OnExit[A, E: EffectonError, R](Effect[A, E, R]):
|
|
158
|
+
first: Effect[Any, Any, Any]
|
|
159
|
+
finalizer: Effect[Any, Any, Any]
|
|
160
|
+
kind: Literal["on_exit"] = "on_exit"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
Node = (
|
|
164
|
+
Success[Any]
|
|
165
|
+
| Sync[Any]
|
|
166
|
+
| Coroutine[Any]
|
|
167
|
+
| FailCause[Any]
|
|
168
|
+
| FlatMap[Any, Any, Any]
|
|
169
|
+
| OnFailure[Any, Any, Any]
|
|
170
|
+
| Require[Any]
|
|
171
|
+
| ProvideRequirement[Any, Any, Any]
|
|
172
|
+
| OnExit[Any, Any, Any]
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def success[A](value: A) -> Effect[A]:
|
|
177
|
+
return Success(value)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def sync[A](fn: Callable[[], A]) -> Effect[A]:
|
|
181
|
+
return Sync(fn)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def coroutine[A](fn: Callable[[], Awaitable[A]]) -> Effect[A]:
|
|
185
|
+
"""Defer an awaitable; only the run_async family can interpret it.
|
|
186
|
+
|
|
187
|
+
The thunk runs once per run of the effect and must build a fresh
|
|
188
|
+
awaitable each time, because a coroutine object can be awaited only
|
|
189
|
+
once. Every exception, whether raised by the thunk or by the await,
|
|
190
|
+
becomes a defect; use attempt_async for typed failures. Interpreting
|
|
191
|
+
the effect with run_sync_exit settles as Die(AsyncEffectInSyncRun());
|
|
192
|
+
run_sync raises it.
|
|
193
|
+
"""
|
|
194
|
+
return Coroutine(fn)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def fail[E: EffectonError](error: E) -> Effect[Never, E]:
|
|
198
|
+
return FailCause(cause=Fail(error=error))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def die(defect: Any) -> Effect[Never]: # noqa: ANN401
|
|
202
|
+
return FailCause(cause=Die(defect=defect))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def require[R](requirement_type: TypeForm[R]) -> Effect[R, Never, R]:
|
|
206
|
+
return Require(requirement_type=requirement_type)
|
effecton/exit.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any, Literal, Never, assert_never, final
|
|
3
|
+
|
|
4
|
+
from effecton.effect import Cause, Die, EffectonError, Fail, Interrupt
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@final
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Succeeded[A]:
|
|
10
|
+
value: A
|
|
11
|
+
kind: Literal["succeeded"] = "succeeded"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@final
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Failure[E: EffectonError = Never]:
|
|
17
|
+
cause: Cause[E]
|
|
18
|
+
kind: Literal["failure"] = "failure"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
type Exit[A, E: EffectonError = Never] = Succeeded[A] | Failure[E]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@final
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class UnhandledDefect(Exception):
|
|
27
|
+
"""Raised by a throwing runner for a defect that is not an exception.
|
|
28
|
+
|
|
29
|
+
Exception defects are re-raised as they are; any other value, such
|
|
30
|
+
as the argument of ``die("boom")``, is wrapped here so it can still
|
|
31
|
+
propagate through Python's exception machinery.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
defect: Any
|
|
35
|
+
|
|
36
|
+
def __str__(self) -> str:
|
|
37
|
+
return f"Unhandled defect: {self.defect!r}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def unwrap[A, E: EffectonError](exit: Exit[A, E]) -> A:
|
|
41
|
+
"""Return the value of a successful Exit, or raise its cause.
|
|
42
|
+
|
|
43
|
+
A typed failure raises the error itself, a defect re-raises the
|
|
44
|
+
exception (or UnhandledDefect for a non-exception value) and an
|
|
45
|
+
interruption re-raises the exception that signalled it.
|
|
46
|
+
"""
|
|
47
|
+
match exit:
|
|
48
|
+
case Succeeded(value):
|
|
49
|
+
return value
|
|
50
|
+
case Failure(cause):
|
|
51
|
+
match cause:
|
|
52
|
+
case Fail(error):
|
|
53
|
+
raise error
|
|
54
|
+
case Die(defect):
|
|
55
|
+
if isinstance(defect, BaseException):
|
|
56
|
+
raise defect
|
|
57
|
+
raise UnhandledDefect(defect)
|
|
58
|
+
case Interrupt(exception):
|
|
59
|
+
raise exception
|
|
60
|
+
case _:
|
|
61
|
+
assert_never(cause)
|
|
62
|
+
case _:
|
|
63
|
+
assert_never(exit)
|
effecton/gen.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
from collections.abc import Callable, Generator
|
|
3
|
+
from typing import Any, Never, cast
|
|
4
|
+
|
|
5
|
+
from effecton.effect import Effect, EffectonError, success
|
|
6
|
+
from effecton.suspend import suspend
|
|
7
|
+
|
|
8
|
+
type EffectGen[A, E: EffectonError = Never, R = Never] = Generator[
|
|
9
|
+
Effect[Any, E, R], Any, A
|
|
10
|
+
]
|
|
11
|
+
"""Return type for @gen generator functions: yields effects, returns A."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def gen[**P, A2, E2: EffectonError, R2](
|
|
15
|
+
f: Callable[P, EffectGen[A2, E2, R2]],
|
|
16
|
+
) -> Callable[P, Effect[A2, E2, R2]]:
|
|
17
|
+
"""Turn a generator function into a factory of effects.
|
|
18
|
+
|
|
19
|
+
The interpreter runs each yielded effect and sends its result back;
|
|
20
|
+
the generator's return value becomes the effect's success value.
|
|
21
|
+
Use ``x = yield from effect`` instead of ``x = yield effect`` so the
|
|
22
|
+
type of ``x`` is inferred correctly.
|
|
23
|
+
The error and requirement channels flow from the annotated yield type
|
|
24
|
+
either way.
|
|
25
|
+
|
|
26
|
+
Each run creates a fresh generator, so the returned effect is a
|
|
27
|
+
reusable value. A failing yielded effect abandons the generator:
|
|
28
|
+
``try/except`` around a ``yield`` never observes effect failures (use
|
|
29
|
+
catch_all), and ``finally`` blocks run only when the abandoned
|
|
30
|
+
generator is garbage collected.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
@functools.wraps(f)
|
|
34
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> Effect[A2, E2, R2]:
|
|
35
|
+
return suspend(lambda: _step(f(*args, **kwargs), None))
|
|
36
|
+
|
|
37
|
+
return wrapper
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _step[A2, E2: EffectonError, R2](
|
|
41
|
+
g: EffectGen[A2, E2, R2], to_send: object
|
|
42
|
+
) -> Effect[A2, E2, R2]:
|
|
43
|
+
try:
|
|
44
|
+
yielded = g.send(to_send)
|
|
45
|
+
except StopIteration as e:
|
|
46
|
+
return cast("Effect[A2, E2, R2]", success(e.value))
|
|
47
|
+
return yielded.flat_map(lambda v: _step(g, v))
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from typing import Any, Protocol, Self, cast, runtime_checkable
|
|
2
|
+
|
|
3
|
+
from effecton.effect import Effect, EffectonError, ProvideRequirement, Require
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@runtime_checkable
|
|
7
|
+
class ImplicitRequirement(Protocol):
|
|
8
|
+
"""A requirement that carries its own default value.
|
|
9
|
+
|
|
10
|
+
The default is computed once per process and shared by every later
|
|
11
|
+
interpretation, so it must be an immutable value.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def default(cls) -> Self: ...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Implicits are lazily initialized. Once initialized, the default is
|
|
19
|
+
# shared across all runs.
|
|
20
|
+
_implicit_defaults: dict[type[ImplicitRequirement], Any] = {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def resolve_default[S: ImplicitRequirement](requirement_type: type[S]) -> S:
|
|
24
|
+
if requirement_type not in _implicit_defaults:
|
|
25
|
+
_implicit_defaults[requirement_type] = requirement_type.default()
|
|
26
|
+
return cast("S", _implicit_defaults[requirement_type])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def require_implicit[S: ImplicitRequirement](
|
|
30
|
+
requirement_type: type[S],
|
|
31
|
+
) -> Effect[S]:
|
|
32
|
+
return cast("Effect[S]", Require(requirement_type=requirement_type))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def provide_implicit[S: ImplicitRequirement, A, E: EffectonError, R](
|
|
36
|
+
effect: Effect[A, E, R], value: S
|
|
37
|
+
) -> Effect[A, E, R]:
|
|
38
|
+
return ProvideRequirement(
|
|
39
|
+
first=effect, requirement_type=type(value), requirement_impl=value
|
|
40
|
+
)
|
effecton/provide.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Generic, Never, TypeVar, final
|
|
3
|
+
|
|
4
|
+
from typing_extensions import TypeForm
|
|
5
|
+
|
|
6
|
+
from effecton.effect import Effect, EffectonError, ProvideRequirement
|
|
7
|
+
|
|
8
|
+
# Old-style TypeVars declare the variance ty cannot infer across the
|
|
9
|
+
# Effect ↔ ProvideBinder reference cycle; see the ty inference notes in
|
|
10
|
+
# AGENTS.md.
|
|
11
|
+
A = TypeVar("A", covariant=True)
|
|
12
|
+
E = TypeVar("E", bound=EffectonError, covariant=True)
|
|
13
|
+
R = TypeVar("R", covariant=True)
|
|
14
|
+
T = TypeVar("T")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@final
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ProvideBinder(Generic[A, E, R, T]): # noqa: UP046
|
|
20
|
+
"""One step of ``effect.provide(T)(impl)``: T is bound, impl pending.
|
|
21
|
+
|
|
22
|
+
Calling it subtracts T from the effect's R and returns the effect
|
|
23
|
+
with the remaining requirements.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
effect: Effect[A, E, R]
|
|
27
|
+
requirement_type: TypeForm[T]
|
|
28
|
+
|
|
29
|
+
def __call__[A2, E2: EffectonError, R2 = Never](
|
|
30
|
+
self: ProvideBinder[A2, E2, T | R2, T], impl: T
|
|
31
|
+
) -> Effect[A2, E2, R2]:
|
|
32
|
+
return ProvideRequirement(
|
|
33
|
+
first=self.effect,
|
|
34
|
+
requirement_type=self.requirement_type,
|
|
35
|
+
requirement_impl=impl,
|
|
36
|
+
)
|
effecton/py.typed
ADDED
|
File without changes
|