effecton 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.
effecton/__init__.py ADDED
@@ -0,0 +1,91 @@
1
+ from effecton.attempt import attempt
2
+ from effecton.effect import (
3
+ Cause,
4
+ Die,
5
+ Effect,
6
+ EffectonError,
7
+ Fail,
8
+ die,
9
+ fail,
10
+ require,
11
+ success,
12
+ sync,
13
+ )
14
+ from effecton.exit import Exit, Failure, Succeeded
15
+ from effecton.gen import EffectGen, gen
16
+ from effecton.implicit_requirement import (
17
+ ImplicitRequirement,
18
+ provide_implicit,
19
+ require_implicit,
20
+ )
21
+ from effecton.provide import RequirementProvider
22
+ from effecton.run_sync import MissingRequirement, run_sync
23
+ from effecton.std.logger import (
24
+ CurrentLogAnnotations,
25
+ CurrentLoggers,
26
+ CurrentLogLevel,
27
+ EffectonLogger,
28
+ LogData,
29
+ LogLevel,
30
+ MinimumLogLevel,
31
+ Severity,
32
+ annotate_logs,
33
+ log,
34
+ log_debug,
35
+ log_error,
36
+ log_fatal,
37
+ log_info,
38
+ log_trace,
39
+ log_warning,
40
+ )
41
+ from effecton.std.pretty_logger import PrettyFormatter, pretty_logger
42
+ from effecton.std.scope import Scope, acquire_and_release, add_finalizer, scoped
43
+ from effecton.suspend import suspend
44
+
45
+ __all__ = [
46
+ "Cause",
47
+ "CurrentLogAnnotations",
48
+ "CurrentLogLevel",
49
+ "CurrentLoggers",
50
+ "Die",
51
+ "Effect",
52
+ "EffectGen",
53
+ "EffectonError",
54
+ "EffectonLogger",
55
+ "Exit",
56
+ "Fail",
57
+ "Failure",
58
+ "ImplicitRequirement",
59
+ "LogData",
60
+ "LogLevel",
61
+ "MinimumLogLevel",
62
+ "MissingRequirement",
63
+ "PrettyFormatter",
64
+ "RequirementProvider",
65
+ "Scope",
66
+ "Severity",
67
+ "Succeeded",
68
+ "acquire_and_release",
69
+ "add_finalizer",
70
+ "annotate_logs",
71
+ "attempt",
72
+ "die",
73
+ "fail",
74
+ "gen",
75
+ "log",
76
+ "log_debug",
77
+ "log_error",
78
+ "log_fatal",
79
+ "log_info",
80
+ "log_trace",
81
+ "log_warning",
82
+ "pretty_logger",
83
+ "provide_implicit",
84
+ "require",
85
+ "require_implicit",
86
+ "run_sync",
87
+ "scoped",
88
+ "success",
89
+ "suspend",
90
+ "sync",
91
+ ]
effecton/attempt.py ADDED
@@ -0,0 +1,24 @@
1
+ from collections.abc import Callable
2
+
3
+ from effecton.effect import Effect, EffectonError, fail, success
4
+ from effecton.suspend import suspend
5
+
6
+
7
+ def attempt[A, E: EffectonError](
8
+ thunk: Callable[[], A], on_error: Callable[[Exception], E]
9
+ ) -> Effect[A, E]:
10
+ """Run an exception-throwing thunk lazily, with typed failures.
11
+
12
+ The thunk runs once per run of the effect, like sync. When it raises,
13
+ on_error maps the exception into the typed error channel — under sync,
14
+ every exception becomes an uncatchable defect. To keep an unexpected
15
+ exception a defect, re-raise it from on_error.
16
+ """
17
+
18
+ def go() -> Effect[A, E]:
19
+ try:
20
+ return success(thunk())
21
+ except Exception as e:
22
+ return fail(on_error(e))
23
+
24
+ return suspend(go)
effecton/effect.py ADDED
@@ -0,0 +1,155 @@
1
+ from collections.abc import Callable, Generator
2
+ from dataclasses import dataclass
3
+ from typing import Any, Generic, Literal, Never, TypeVar, final
4
+
5
+ from typing_extensions import TypeForm
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class EffectonError(Exception):
10
+ def __str__(self) -> str:
11
+ # The dataclass __init__ never fills Exception.args, so the
12
+ # inherited __str__ renders every error as an empty string.
13
+ return repr(self)
14
+
15
+
16
+ @final
17
+ @dataclass(frozen=True)
18
+ class Fail[E: EffectonError]:
19
+ error: E
20
+
21
+
22
+ @final
23
+ @dataclass(frozen=True)
24
+ class Die:
25
+ defect: Any
26
+
27
+
28
+ type Cause[E] = Fail[E] | Die
29
+
30
+ # Variance is declared through old-style TypeVars: mypy's PEP 695
31
+ # inference wrongly makes A, E, and R invariant once the class has a
32
+ # third type parameter. This might be unnecessary once we switch to ty.
33
+ A = TypeVar("A", covariant=True)
34
+ E = TypeVar("E", bound=EffectonError, covariant=True, default=Never)
35
+ R = TypeVar("R", covariant=True, default=Never)
36
+
37
+
38
+ class Effect(Generic[A, E, R]):
39
+ def flat_map[B, E2: EffectonError, R2](
40
+ self, f: Callable[[A], Effect[B, E2, R2]]
41
+ ) -> Effect[B, E | E2, R | R2]:
42
+ return FlatMap(self, f)
43
+
44
+ def map[B](self, f: Callable[[A], B]) -> Effect[B, E, R]:
45
+ return self.flat_map(lambda a: Success(f(a))) # type: ignore[misc]
46
+
47
+ def catch_all[B, E2: EffectonError, R2](
48
+ self, f: Callable[[E], Effect[B, E2, R2]]
49
+ ) -> Effect[A | B, E2, R | R2]:
50
+ return OnFailure(self, f)
51
+
52
+ def on_exit[R2](self, finalizer: Effect[Any, Never, R2]) -> Effect[A, E, R | R2]:
53
+ return OnExit(self, finalizer)
54
+
55
+ def __iter__(self) -> Generator[Effect[A, E, R], A, A]:
56
+ """Make ``x = yield from effect`` infer ``x`` as A inside @gen.
57
+
58
+ A bare ``yield`` types as the generator's single send type (Any).
59
+ ``yield from`` takes this method's return type parameter instead,
60
+ so the value the interpreter sends back is typed per expression.
61
+ """
62
+ return (yield self)
63
+
64
+
65
+ @final
66
+ @dataclass(frozen=True)
67
+ class Success[A](Effect[A]):
68
+ value: A
69
+ kind: Literal["success"] = "success"
70
+
71
+
72
+ @final
73
+ @dataclass(frozen=True)
74
+ class Sync[A](Effect[A]):
75
+ fn: Callable[[], A]
76
+ kind: Literal["sync"] = "sync"
77
+
78
+
79
+ @final
80
+ @dataclass(frozen=True)
81
+ class FailCause[E: EffectonError](Effect[Never, E]):
82
+ cause: Cause[E]
83
+ kind: Literal["fail"] = "fail"
84
+
85
+
86
+ @final
87
+ @dataclass(frozen=True)
88
+ class FlatMap[B, E: EffectonError, R](Effect[B, E, R]):
89
+ first: Effect[Any, Any, Any]
90
+ and_then: Callable[[Any], Effect[B, E, R]]
91
+ kind: Literal["flat_map"] = "flat_map"
92
+
93
+
94
+ @final
95
+ @dataclass(frozen=True)
96
+ class OnFailure[A, E: EffectonError, R](Effect[A, E, R]):
97
+ first: Effect[A, Any, Any]
98
+ handler: Callable[[Any], Effect[A, E, R]]
99
+ kind: Literal["on_failure"] = "on_failure"
100
+
101
+
102
+ @final
103
+ @dataclass(frozen=True)
104
+ class Require[R](Effect[R, Never, R]):
105
+ requirement_type: TypeForm[R]
106
+ kind: Literal["require"] = "require"
107
+
108
+
109
+ @final
110
+ @dataclass(frozen=True)
111
+ class ProvideRequirement[A, E: EffectonError, R](Effect[A, E, R]):
112
+ first: Effect[A, E, Any]
113
+ requirement_type: TypeForm[Any]
114
+ requirement_impl: Any
115
+ kind: Literal["provide_requirement"] = "provide_requirement"
116
+
117
+
118
+ @final
119
+ @dataclass(frozen=True)
120
+ class OnExit[A, E: EffectonError, R](Effect[A, E, R]):
121
+ first: Effect[Any, Any, Any]
122
+ finalizer: Effect[Any, Any, Any]
123
+ kind: Literal["on_exit"] = "on_exit"
124
+
125
+
126
+ Node = (
127
+ Success[Any]
128
+ | Sync[Any]
129
+ | FailCause[Any]
130
+ | FlatMap[Any, Any, Any]
131
+ | OnFailure[Any, Any, Any]
132
+ | Require[Any]
133
+ | ProvideRequirement[Any, Any, Any]
134
+ | OnExit[Any, Any, Any]
135
+ )
136
+
137
+
138
+ def success[A](value: A) -> Effect[A]:
139
+ return Success(value)
140
+
141
+
142
+ def sync[A](fn: Callable[[], A]) -> Effect[A]:
143
+ return Sync(fn)
144
+
145
+
146
+ def fail[E: EffectonError](error: E) -> Effect[Never, E]:
147
+ return FailCause(cause=Fail(error=error))
148
+
149
+
150
+ def die(defect: Any) -> Effect[Never]: # noqa: ANN401
151
+ return FailCause(cause=Die(defect=defect))
152
+
153
+
154
+ def require[R](requirement_type: TypeForm[R]) -> Effect[R, Never, R]:
155
+ return Require(requirement_type=requirement_type)
effecton/exit.py ADDED
@@ -0,0 +1,21 @@
1
+ from dataclasses import dataclass
2
+ from typing import Literal, Never, final
3
+
4
+ from effecton.effect import Cause, EffectonError
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]
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,48 @@
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
+ Extend it explicitly — ``class Foo(ImplicitRequirement)`` — so mypy
11
+ rejects the definition site when ``default()`` is missing or
12
+ mis-typed. As a Protocol, any class with a matching ``default()``
13
+ classmethod also qualifies structurally, but explicit inheritance is
14
+ the convention. Requiring one never enters the R channel: the
15
+ interpreter falls back to the memoized default on a miss, and
16
+ ordinary provision still overrides it.
17
+
18
+ The default is computed once per process and shared by every later
19
+ interpretation, so it must be an immutable value.
20
+ """
21
+
22
+ @classmethod
23
+ def default(cls) -> Self: ...
24
+
25
+
26
+ # Implicits are lazily initialized. Once initialized, the default is
27
+ # shared across all run_sync executions.
28
+ _implicit_defaults: dict[type[ImplicitRequirement], Any] = {}
29
+
30
+
31
+ def resolve_default[S: ImplicitRequirement](requirement_type: type[S]) -> S:
32
+ if requirement_type not in _implicit_defaults:
33
+ _implicit_defaults[requirement_type] = requirement_type.default()
34
+ return cast("S", _implicit_defaults[requirement_type])
35
+
36
+
37
+ def require_implicit[S: ImplicitRequirement](
38
+ requirement_type: type[S],
39
+ ) -> Effect[S]:
40
+ return cast("Effect[S]", Require(requirement_type=requirement_type))
41
+
42
+
43
+ def provide_implicit[S: ImplicitRequirement, A, E: EffectonError, R](
44
+ effect: Effect[A, E, R], value: S
45
+ ) -> Effect[A, E, R]:
46
+ return ProvideRequirement(
47
+ first=effect, requirement_type=type(value), requirement_impl=value
48
+ )
effecton/provide.py ADDED
@@ -0,0 +1,60 @@
1
+ from dataclasses import dataclass
2
+ from typing import TYPE_CHECKING, Any, Never, cast, final
3
+
4
+ from typing_extensions import TypeForm
5
+
6
+ from effecton.effect import Effect, EffectonError, ProvideRequirement
7
+
8
+ if TYPE_CHECKING:
9
+ from effecton.std.scope import Scope
10
+
11
+
12
+ @final
13
+ @dataclass(frozen=True)
14
+ class RequirementProvider[R = Never]:
15
+ """Accumulates provided requirements, then discharges them in one apply.
16
+
17
+ mypy limitations dictate this design: all requirements must be
18
+ provided at once; otherwise the remaining requirements are not
19
+ subtracted correctly and collapse to object.
20
+ """
21
+
22
+ _links: tuple[tuple[TypeForm[Any], Any], ...] = ()
23
+
24
+ def and_provide[R2](
25
+ self, requirement_type: TypeForm[R2]
26
+ ) -> ChainedRequirementBinder[R, R2]:
27
+ return ChainedRequirementBinder(requirement_type=requirement_type, _rest=self)
28
+
29
+ def and_scoped[A, E: EffectonError](
30
+ self, effect: Effect[A, E, Scope | R]
31
+ ) -> Effect[A, E]:
32
+ """Discharge the chain plus a Scope created fresh per interpretation."""
33
+ from effecton.std.scope import scoped
34
+
35
+ return self.apply(scoped(effect))
36
+
37
+ def apply[A, E: EffectonError](self, effect: Effect[A, E, R]) -> Effect[A, E]:
38
+ result: Effect[A, E, Any] = effect
39
+ for requirement_type, requirement_impl in self._links:
40
+ result = ProvideRequirement(
41
+ first=result,
42
+ requirement_type=requirement_type,
43
+ requirement_impl=requirement_impl,
44
+ )
45
+ return cast("Effect[A, E]", result)
46
+
47
+
48
+ @final
49
+ @dataclass(frozen=True)
50
+ class ChainedRequirementBinder[R, R2]:
51
+ requirement_type: TypeForm[R2]
52
+ _rest: RequirementProvider[Any]
53
+
54
+ def __call__(self, requirement_impl: R2) -> RequirementProvider[R | R2]:
55
+ return cast(
56
+ "RequirementProvider[R | R2]",
57
+ RequirementProvider(
58
+ _links=(*self._rest._links, (self.requirement_type, requirement_impl))
59
+ ),
60
+ )
effecton/py.typed ADDED
File without changes
effecton/run_sync.py ADDED
@@ -0,0 +1,161 @@
1
+ from collections.abc import Callable
2
+ from dataclasses import dataclass
3
+ from typing import Any, assert_never, final
4
+
5
+ from typing_extensions import TypeForm
6
+
7
+ from effecton.effect import (
8
+ Die,
9
+ Effect,
10
+ EffectonError,
11
+ FailCause,
12
+ FlatMap,
13
+ Node,
14
+ OnExit,
15
+ OnFailure,
16
+ ProvideRequirement,
17
+ Require,
18
+ Success,
19
+ Sync,
20
+ )
21
+ from effecton.exit import Exit, Failure, Succeeded
22
+ from effecton.implicit_requirement import ImplicitRequirement, resolve_default
23
+
24
+
25
+ @final
26
+ @dataclass(frozen=True)
27
+ class MissingRequirement:
28
+ """Defect raised when an unprovided requirement is requested at runtime.
29
+
30
+ Unreachable through fully typed code.
31
+ """
32
+
33
+ requirement_type: TypeForm[Any]
34
+
35
+
36
+ @final
37
+ @dataclass(frozen=True)
38
+ class RestoreEnv:
39
+ """Interpreter stack frame delimiting a ProvideRequirement scope."""
40
+
41
+ env: dict[TypeForm[Any], Any]
42
+
43
+
44
+ @final
45
+ @dataclass(frozen=True)
46
+ class OnExitFrame:
47
+ finalizer: Effect[Any, Any, Any]
48
+
49
+
50
+ Frame = FlatMap[Any, Any, Any] | OnFailure[Any, Any, Any] | RestoreEnv | OnExitFrame
51
+
52
+
53
+ def run_sync[A, E: EffectonError](effect: Effect[A, E]) -> Exit[A, E]:
54
+ stack: list[Frame] = []
55
+ env: dict[TypeForm[Any], Any] = {}
56
+ current: Node = effect # type: ignore[assignment]
57
+
58
+ while True:
59
+ match current:
60
+ case Success(value):
61
+ while stack:
62
+ item = stack.pop()
63
+
64
+ match item:
65
+ case RestoreEnv():
66
+ env = item.env
67
+ case OnExitFrame(finalizer):
68
+ current = finalizer.flat_map(_resume(current)) # type: ignore[assignment]
69
+ break
70
+ case FlatMap():
71
+ current = _run_fn_or_die(item.and_then, value)
72
+ break
73
+ case OnFailure():
74
+ continue
75
+ case _:
76
+ assert_never(item)
77
+ else:
78
+ return Succeeded(value=value)
79
+
80
+ case FailCause(cause):
81
+ while stack:
82
+ item = stack.pop()
83
+
84
+ match item:
85
+ case RestoreEnv():
86
+ env = item.env
87
+ case OnExitFrame(finalizer):
88
+ current = finalizer.flat_map(_resume(current)) # type: ignore[assignment]
89
+ break
90
+ case FlatMap():
91
+ continue
92
+ case OnFailure():
93
+ if not isinstance(cause, Die):
94
+ current = _run_fn_or_die(item.handler, cause.error)
95
+ break
96
+ case _:
97
+ assert_never(item)
98
+ else:
99
+ return Failure(cause=cause)
100
+
101
+ case FlatMap(first):
102
+ stack.append(current)
103
+ current = first # type: ignore[assignment]
104
+
105
+ case OnFailure(first):
106
+ stack.append(current)
107
+ current = first # type: ignore[assignment]
108
+
109
+ case Sync(fn):
110
+ try:
111
+ current = Success(fn())
112
+ except Exception as e:
113
+ current = FailCause(cause=Die(defect=e))
114
+
115
+ case Require(requirement_type):
116
+ if requirement_type in env:
117
+ current = Success(env[requirement_type])
118
+ else:
119
+ current = _default_or_die(requirement_type)
120
+
121
+ case ProvideRequirement(first, requirement_type, requirement_impl):
122
+ stack.append(RestoreEnv(env))
123
+ env = {**env, requirement_type: requirement_impl}
124
+ current = first # type: ignore[assignment]
125
+
126
+ case OnExit(first, finalizer):
127
+ stack.append(OnExitFrame(finalizer))
128
+ current = first # type: ignore[assignment]
129
+
130
+ case _:
131
+ assert_never(current)
132
+
133
+
134
+ def _default_or_die(requirement_type: TypeForm[Any]) -> Node:
135
+ if (
136
+ isinstance(requirement_type, type)
137
+ and issubclass(requirement_type, ImplicitRequirement)
138
+ # Exclude the protocol class itself
139
+ and not getattr(requirement_type, "_is_protocol", False)
140
+ ):
141
+ try:
142
+ return Success(resolve_default(requirement_type))
143
+ except Exception as e:
144
+ return FailCause(cause=Die(defect=e))
145
+
146
+ return FailCause(cause=Die(defect=MissingRequirement(requirement_type)))
147
+
148
+
149
+ def _run_fn_or_die(f: Callable[[Any], Effect[Any, Any]], value: object) -> Node:
150
+ try:
151
+ return f(value) # type: ignore[return-value]
152
+ except Exception as e:
153
+ return FailCause(cause=Die(defect=e))
154
+
155
+
156
+ # Captures the current outcome by closure.
157
+ def _resume(outcome: Node) -> Callable[[Any], Effect[Any, Any, Any]]:
158
+ def resume(_: object) -> Effect[Any, Any, Any]:
159
+ return outcome
160
+
161
+ return resume
@@ -0,0 +1 @@
1
+ """Standard-library modules built on the effecton kernel."""