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