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/run_async.py ADDED
@@ -0,0 +1,237 @@
1
+ import asyncio
2
+ from collections.abc import Awaitable, Callable
3
+ from dataclasses import dataclass
4
+ from typing import Any, assert_never, final
5
+
6
+ from typing_extensions import TypeForm
7
+
8
+ from effecton.effect import (
9
+ Coroutine,
10
+ Die,
11
+ Effect,
12
+ EffectonError,
13
+ Fail,
14
+ FailCause,
15
+ FlatMap,
16
+ Interrupt,
17
+ Node,
18
+ OnExit,
19
+ OnFailure,
20
+ ProvideRequirement,
21
+ Require,
22
+ Success,
23
+ Sync,
24
+ )
25
+ from effecton.exit import Exit, Failure, Succeeded, unwrap
26
+ from effecton.run_sync import (
27
+ Frame,
28
+ OnExitFrame,
29
+ RestoreEnv,
30
+ default_or_die,
31
+ run_fn_or_die,
32
+ )
33
+
34
+
35
+ @final
36
+ @dataclass(frozen=True)
37
+ class Finalizing:
38
+ """Interpreter stack frame delimiting a running finalizer.
39
+
40
+ Holds the outcome the finalizer interrupts, to resume once it settles.
41
+ While one is on the stack, awaits are shielded from cancellation.
42
+ """
43
+
44
+ outcome: Node
45
+
46
+
47
+ def run_async[A, E: EffectonError](effect: Effect[A, E]) -> A:
48
+ """Run an effect on a fresh asyncio loop and return its value.
49
+
50
+ Owns the event loop through asyncio.run, so it cannot be called from
51
+ a running loop; use run_async_coroutine there. A typed failure raises the
52
+ error itself, a defect re-raises the exception (or UnhandledDefect
53
+ for a non-exception value) and an interruption re-raises the
54
+ exception that signalled it. Use run_async_exit to receive the Exit
55
+ instead.
56
+ """
57
+ return unwrap(run_async_exit(effect))
58
+
59
+
60
+ def run_async_exit[A, E: EffectonError](effect: Effect[A, E]) -> Exit[A, E]:
61
+ """Run an effect on a fresh asyncio loop and return its Exit.
62
+
63
+ Owns the event loop through asyncio.run, so it cannot be called from
64
+ a running loop; use run_async_coroutine there.
65
+ """
66
+ return asyncio.run(run_async_coroutine(effect))
67
+
68
+
69
+ async def run_async_coroutine[A, E: EffectonError](effect: Effect[A, E]) -> Exit[A, E]:
70
+ """Interpret an effect under asyncio, awaiting every coroutine effect.
71
+
72
+ This is the coroutine form for a caller that already owns a loop:
73
+ pass it to asyncio.run, create_task or await it directly.
74
+
75
+ A cancellation, or any other BaseException raised by an await, a
76
+ thunk or a callback, unwinds the effect with an Interrupt cause so
77
+ finalizers run, and the run settles as Failure(Interrupt(exception)).
78
+ The cancellation is consumed: a caller whose task should stop
79
+ re-raises the carried exception. Finalizers are shielded: a
80
+ cancellation that arrives while one is awaiting is remembered, the
81
+ finalizer runs to completion, and the interruption is applied once
82
+ it settles.
83
+ """
84
+ stack: list[Frame | Finalizing] = []
85
+ env: dict[TypeForm[Any], Any] = {}
86
+ cancelled: BaseException | None = None
87
+ finalizing = 0
88
+ current: Node = effect # ty: ignore[invalid-assignment]
89
+
90
+ def unwind(e: BaseException) -> Node:
91
+ nonlocal cancelled
92
+ if isinstance(e, Exception):
93
+ return FailCause(cause=Die(defect=e))
94
+ if cancelled is None:
95
+ cancelled = e
96
+ return FailCause(cause=Interrupt(exception=e))
97
+
98
+ def guarded[**P](f: Callable[P, Node], *args: P.args, **kwargs: P.kwargs) -> Node:
99
+ try:
100
+ return f(*args, **kwargs)
101
+ except BaseException as e:
102
+ return unwind(e)
103
+
104
+ async def awaited(fn: Callable[[], Awaitable[Any]]) -> Node:
105
+ try:
106
+ return Success(await fn())
107
+ except BaseException as e:
108
+ return unwind(e)
109
+
110
+ async def awaited_uninterruptibly(fn: Callable[[], Awaitable[Any]]) -> Node:
111
+ async def run() -> Any: # noqa: ANN401
112
+ return await fn()
113
+
114
+ # The finalizer runs as its own task so a cancellation of this
115
+ # task lands on the shield instead of aborting it. It shares this
116
+ # task's Context rather than a copy, so context variables it sets
117
+ # or resets behave as if it ran inline.
118
+ task = asyncio.current_task()
119
+ inner = asyncio.get_running_loop().create_task(
120
+ run(), context=task.get_context() if task is not None else None
121
+ )
122
+
123
+ # Remember the cancellation and keep waiting for the finalizer.
124
+ while not inner.done():
125
+ try:
126
+ await asyncio.shield(inner)
127
+ except BaseException as e:
128
+ unwind(e)
129
+ return guarded(lambda: Success(inner.result()))
130
+
131
+ while True:
132
+ match current:
133
+ case Success(value):
134
+ while stack:
135
+ item = stack.pop()
136
+
137
+ match item:
138
+ case RestoreEnv():
139
+ env = item.env
140
+ case OnExitFrame(finalizer):
141
+ stack.append(Finalizing(outcome=current))
142
+ finalizing += 1
143
+ current = finalizer # ty: ignore[invalid-assignment]
144
+ break
145
+ case Finalizing(outcome):
146
+ finalizing -= 1
147
+ current = _interrupted(cancelled, finalizing) or outcome
148
+ break
149
+ case FlatMap():
150
+ current = guarded(run_fn_or_die, item.and_then, value)
151
+ break
152
+ case OnFailure():
153
+ continue
154
+ case _:
155
+ assert_never(item)
156
+ else:
157
+ return Succeeded(value=value)
158
+
159
+ case FailCause(cause):
160
+ while stack:
161
+ item = stack.pop()
162
+
163
+ match item:
164
+ case RestoreEnv():
165
+ env = item.env
166
+ case OnExitFrame(finalizer):
167
+ stack.append(Finalizing(outcome=current))
168
+ finalizing += 1
169
+ current = finalizer # ty: ignore[invalid-assignment]
170
+ break
171
+ case Finalizing():
172
+ # The finalizer died; its defect replaces the
173
+ # outcome it was finalizing.
174
+ finalizing -= 1
175
+ interrupted = _interrupted(cancelled, finalizing)
176
+ if interrupted is not None:
177
+ current = interrupted
178
+ break
179
+ case FlatMap():
180
+ continue
181
+ case OnFailure():
182
+ if isinstance(cause, Fail):
183
+ current = guarded(
184
+ run_fn_or_die, item.handler, cause.error
185
+ )
186
+ break
187
+ case _:
188
+ assert_never(item)
189
+ else:
190
+ return Failure(cause=cause)
191
+
192
+ case FlatMap(first):
193
+ stack.append(current)
194
+ current = first # ty: ignore[invalid-assignment]
195
+
196
+ case OnFailure(first):
197
+ stack.append(current)
198
+ current = first # ty: ignore[invalid-assignment]
199
+
200
+ case Sync(fn):
201
+ current = guarded(lambda: Success(fn()))
202
+
203
+ case Coroutine(fn):
204
+ current = await (
205
+ awaited_uninterruptibly(fn) if finalizing else awaited(fn)
206
+ )
207
+
208
+ case Require(requirement_type):
209
+ if requirement_type in env:
210
+ current = Success(env[requirement_type])
211
+ else:
212
+ current = guarded(lambda: default_or_die(requirement_type))
213
+
214
+ case ProvideRequirement(first, requirement_type, requirement_impl):
215
+ stack.append(RestoreEnv(env))
216
+ env = {**env, requirement_type: requirement_impl}
217
+ current = first # ty: ignore[invalid-assignment]
218
+
219
+ case OnExit(first, finalizer):
220
+ stack.append(OnExitFrame(finalizer))
221
+ current = first # ty: ignore[invalid-assignment]
222
+
223
+ case _:
224
+ assert_never(current)
225
+
226
+
227
+ def _interrupted(cancelled: BaseException | None, finalizing: int) -> Node | None:
228
+ """The node to resume with once the outermost finalizer settles.
229
+
230
+ Interruption is sticky: a cancellation noted while finalizers ran is
231
+ applied as soon as the run becomes interruptible again, overriding a
232
+ resumed success or a finalizer defect. Inside a nested finalizer it
233
+ is deferred, so the enclosing finalizer runs to completion.
234
+ """
235
+ if cancelled is None or finalizing > 0:
236
+ return None
237
+ return FailCause(cause=Interrupt(exception=cancelled))
effecton/run_sync.py ADDED
@@ -0,0 +1,199 @@
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
+ Coroutine,
9
+ Die,
10
+ Effect,
11
+ EffectonError,
12
+ Fail,
13
+ FailCause,
14
+ FlatMap,
15
+ Node,
16
+ OnExit,
17
+ OnFailure,
18
+ ProvideRequirement,
19
+ Require,
20
+ Success,
21
+ Sync,
22
+ )
23
+ from effecton.exit import Exit, Failure, Succeeded, unwrap
24
+ from effecton.implicit_requirement import ImplicitRequirement, resolve_default
25
+
26
+
27
+ @final
28
+ @dataclass(frozen=True)
29
+ class MissingRequirement(Exception):
30
+ """Defect for a requirement requested at runtime without being provided.
31
+
32
+ Unreachable through fully typed code. run_sync_exit settles as
33
+ Failure(Die(MissingRequirement(...))); run_sync raises it.
34
+ """
35
+
36
+ requirement_type: TypeForm[Any]
37
+
38
+ def __str__(self) -> str:
39
+ return f"No implementation provided for requirement {self.requirement_type!r}"
40
+
41
+
42
+ @final
43
+ @dataclass(frozen=True)
44
+ class AsyncEffectInSyncRun(Exception):
45
+ """Defect for a coroutine effect reached by a synchronous runner.
46
+
47
+ Only the run_async family can await. run_sync_exit settles as
48
+ Failure(Die(AsyncEffectInSyncRun())); run_sync raises it.
49
+ """
50
+
51
+ def __str__(self) -> str:
52
+ return "A coroutine effect cannot run synchronously; run it with run_async"
53
+
54
+
55
+ @final
56
+ @dataclass(frozen=True)
57
+ class RestoreEnv:
58
+ """Interpreter stack frame delimiting a ProvideRequirement scope."""
59
+
60
+ env: dict[TypeForm[Any], Any]
61
+
62
+
63
+ @final
64
+ @dataclass(frozen=True)
65
+ class OnExitFrame:
66
+ finalizer: Effect[Any, Any, Any]
67
+
68
+
69
+ Frame = FlatMap[Any, Any, Any] | OnFailure[Any, Any, Any] | RestoreEnv | OnExitFrame
70
+
71
+
72
+ def run_sync[A, E: EffectonError](effect: Effect[A, E]) -> A:
73
+ """Interpret an effect and return its value, raising on failure.
74
+
75
+ A typed failure raises the error itself, a defect re-raises the
76
+ exception (or UnhandledDefect for a non-exception value) and an
77
+ interruption re-raises the exception that signalled it. Use
78
+ run_sync_exit to receive the Exit instead.
79
+ """
80
+ return unwrap(run_sync_exit(effect))
81
+
82
+
83
+ def run_sync_exit[A, E: EffectonError](effect: Effect[A, E]) -> Exit[A, E]:
84
+ """Interpret an effect and return its Exit.
85
+
86
+ Coroutine effects are not awaited: reaching one settles the run as
87
+ Failure(Die(AsyncEffectInSyncRun())), and finalizers still run.
88
+ """
89
+ stack: list[Frame] = []
90
+ env: dict[TypeForm[Any], Any] = {}
91
+ current: Node = effect # ty: ignore[invalid-assignment]
92
+
93
+ while True:
94
+ match current:
95
+ case Success(value):
96
+ while stack:
97
+ item = stack.pop()
98
+
99
+ match item:
100
+ case RestoreEnv():
101
+ env = item.env
102
+ case OnExitFrame(finalizer):
103
+ current = finalizer.flat_map(resume(current)) # ty: ignore[invalid-assignment]
104
+ break
105
+ case FlatMap():
106
+ current = run_fn_or_die(item.and_then, value)
107
+ break
108
+ case OnFailure():
109
+ continue
110
+ case _:
111
+ assert_never(item)
112
+ else:
113
+ return Succeeded(value=value)
114
+
115
+ case FailCause(cause):
116
+ while stack:
117
+ item = stack.pop()
118
+
119
+ match item:
120
+ case RestoreEnv():
121
+ env = item.env
122
+ case OnExitFrame(finalizer):
123
+ current = finalizer.flat_map(resume(current)) # ty: ignore[invalid-assignment]
124
+ break
125
+ case FlatMap():
126
+ continue
127
+ case OnFailure():
128
+ if isinstance(cause, Fail):
129
+ current = run_fn_or_die(item.handler, cause.error)
130
+ break
131
+ case _:
132
+ assert_never(item)
133
+ else:
134
+ return Failure(cause=cause)
135
+
136
+ case FlatMap(first):
137
+ stack.append(current)
138
+ current = first # ty: ignore[invalid-assignment]
139
+
140
+ case OnFailure(first):
141
+ stack.append(current)
142
+ current = first # ty: ignore[invalid-assignment]
143
+
144
+ case Sync(fn):
145
+ try:
146
+ current = Success(fn())
147
+ except Exception as e:
148
+ current = FailCause(cause=Die(defect=e))
149
+
150
+ case Coroutine():
151
+ current = FailCause(cause=Die(defect=AsyncEffectInSyncRun()))
152
+
153
+ case Require(requirement_type):
154
+ if requirement_type in env:
155
+ current = Success(env[requirement_type])
156
+ else:
157
+ current = default_or_die(requirement_type)
158
+
159
+ case ProvideRequirement(first, requirement_type, requirement_impl):
160
+ stack.append(RestoreEnv(env))
161
+ env = {**env, requirement_type: requirement_impl}
162
+ current = first # ty: ignore[invalid-assignment]
163
+
164
+ case OnExit(first, finalizer):
165
+ stack.append(OnExitFrame(finalizer))
166
+ current = first # ty: ignore[invalid-assignment]
167
+
168
+ case _:
169
+ assert_never(current)
170
+
171
+
172
+ def default_or_die(requirement_type: TypeForm[Any]) -> Node:
173
+ if (
174
+ isinstance(requirement_type, type)
175
+ and issubclass(requirement_type, ImplicitRequirement)
176
+ # Exclude the protocol class itself
177
+ and not getattr(requirement_type, "_is_protocol", False)
178
+ ):
179
+ try:
180
+ return Success(resolve_default(requirement_type))
181
+ except Exception as e:
182
+ return FailCause(cause=Die(defect=e))
183
+
184
+ return FailCause(cause=Die(defect=MissingRequirement(requirement_type)))
185
+
186
+
187
+ def run_fn_or_die(f: Callable[[Any], Effect[Any, Any]], value: object) -> Node:
188
+ try:
189
+ return f(value) # ty: ignore[invalid-return-type]
190
+ except Exception as e:
191
+ return FailCause(cause=Die(defect=e))
192
+
193
+
194
+ # Captures the current outcome by closure.
195
+ def resume(outcome: Node) -> Callable[[Any], Effect[Any, Any, Any]]:
196
+ def resume(_: object) -> Effect[Any, Any, Any]:
197
+ return outcome
198
+
199
+ return resume
@@ -0,0 +1 @@
1
+ """Standard-library modules built on the effecton kernel."""
effecton/std/logger.py ADDED
@@ -0,0 +1,214 @@
1
+ import logging
2
+ from collections.abc import Callable, Mapping
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from enum import Enum
6
+ from typing import Literal, assert_never, final
7
+
8
+ from effecton.effect import Effect, EffectonError, ProvideRequirement
9
+ from effecton.gen import EffectGen, gen
10
+ from effecton.implicit_requirement import ImplicitRequirement, require_implicit
11
+
12
+
13
+ @final
14
+ class LogLevel(Enum):
15
+ ALL = "all"
16
+ TRACE = "trace"
17
+ DEBUG = "debug"
18
+ INFO = "info"
19
+ WARN = "warn"
20
+ ERROR = "error"
21
+ FATAL = "fatal"
22
+ NONE = "none"
23
+
24
+
25
+ type Severity = Literal[
26
+ LogLevel.TRACE,
27
+ LogLevel.DEBUG,
28
+ LogLevel.INFO,
29
+ LogLevel.WARN,
30
+ LogLevel.ERROR,
31
+ LogLevel.FATAL,
32
+ ]
33
+ """Levels a message can be logged at.
34
+
35
+ Excludes the ALL and NONE sentinels, which are only meaningful as a
36
+ MinimumLogLevel threshold: ALL passes everything, NONE silences everything.
37
+ """
38
+
39
+
40
+ def log_level_order(level: LogLevel) -> int:
41
+ """Position in the severity ordering; only relative order matters."""
42
+ match level:
43
+ case LogLevel.ALL:
44
+ return 0
45
+ case LogLevel.TRACE:
46
+ return 1
47
+ case LogLevel.DEBUG:
48
+ return 2
49
+ case LogLevel.INFO:
50
+ return 3
51
+ case LogLevel.WARN:
52
+ return 4
53
+ case LogLevel.ERROR:
54
+ return 5
55
+ case LogLevel.FATAL:
56
+ return 6
57
+ case LogLevel.NONE:
58
+ return 7
59
+ case _:
60
+ assert_never(level)
61
+
62
+
63
+ @final
64
+ @dataclass(frozen=True)
65
+ class LogData:
66
+ message: tuple[object, ...]
67
+ log_level: Severity
68
+ date: datetime
69
+ annotations: Mapping[str, object]
70
+
71
+
72
+ @final
73
+ @dataclass(frozen=True)
74
+ class EffectonLogger:
75
+ log: Callable[[LogData], None]
76
+
77
+
78
+ _TRACE_LEVEL = 5
79
+ logging.addLevelName(_TRACE_LEVEL, "TRACE")
80
+
81
+
82
+ def _to_python_logger_level(level: Severity) -> int:
83
+ match level:
84
+ case LogLevel.TRACE:
85
+ return _TRACE_LEVEL
86
+ case LogLevel.DEBUG:
87
+ return logging.DEBUG
88
+ case LogLevel.INFO:
89
+ return logging.INFO
90
+ case LogLevel.WARN:
91
+ return logging.WARNING
92
+ case LogLevel.ERROR:
93
+ return logging.ERROR
94
+ case LogLevel.FATAL:
95
+ return logging.CRITICAL
96
+ case _:
97
+ assert_never(level)
98
+
99
+
100
+ @final
101
+ @dataclass(frozen=True)
102
+ class CurrentLoggers(ImplicitRequirement):
103
+ loggers: tuple[EffectonLogger, ...]
104
+
105
+ @classmethod
106
+ def default(cls) -> CurrentLoggers:
107
+ from effecton.std.pretty_logger import pretty_logger
108
+
109
+ return CurrentLoggers((pretty_logger,))
110
+
111
+
112
+ @final
113
+ @dataclass(frozen=True)
114
+ class MinimumLogLevel(ImplicitRequirement):
115
+ level: LogLevel
116
+
117
+ @classmethod
118
+ def default(cls) -> MinimumLogLevel:
119
+ return MinimumLogLevel(LogLevel.INFO)
120
+
121
+
122
+ @final
123
+ @dataclass(frozen=True)
124
+ class CurrentLogLevel(ImplicitRequirement):
125
+ """The level a bare log(...) call logs at."""
126
+
127
+ level: Severity
128
+
129
+ @classmethod
130
+ def default(cls) -> CurrentLogLevel:
131
+ return CurrentLogLevel(LogLevel.INFO)
132
+
133
+
134
+ @final
135
+ @dataclass(frozen=True)
136
+ class CurrentLogAnnotations(ImplicitRequirement):
137
+ annotations: Mapping[str, object]
138
+
139
+ @classmethod
140
+ def default(cls) -> CurrentLogAnnotations:
141
+ return CurrentLogAnnotations({})
142
+
143
+
144
+ def log(*message: object) -> Effect[None]:
145
+ return _log_with_level(None, message)
146
+
147
+
148
+ def log_trace(*message: object) -> Effect[None]:
149
+ return _log_with_level(LogLevel.TRACE, message)
150
+
151
+
152
+ def log_debug(*message: object) -> Effect[None]:
153
+ return _log_with_level(LogLevel.DEBUG, message)
154
+
155
+
156
+ def log_info(*message: object) -> Effect[None]:
157
+ return _log_with_level(LogLevel.INFO, message)
158
+
159
+
160
+ def log_warning(*message: object) -> Effect[None]:
161
+ return _log_with_level(LogLevel.WARN, message)
162
+
163
+
164
+ def log_error(*message: object) -> Effect[None]:
165
+ return _log_with_level(LogLevel.ERROR, message)
166
+
167
+
168
+ def log_fatal(*message: object) -> Effect[None]:
169
+ return _log_with_level(LogLevel.FATAL, message)
170
+
171
+
172
+ def annotate_logs[A, E: EffectonError, R](
173
+ effect: Effect[A, E, R], **annotations: object
174
+ ) -> Effect[A, E, R]:
175
+ """Merge annotations into every log call inside the wrapped effect."""
176
+ return require_implicit(CurrentLogAnnotations).flat_map(
177
+ lambda current: ProvideRequirement(
178
+ first=effect,
179
+ requirement_type=CurrentLogAnnotations,
180
+ requirement_impl=CurrentLogAnnotations(
181
+ {**current.annotations, **annotations}
182
+ ),
183
+ )
184
+ )
185
+
186
+
187
+ @gen
188
+ def _log_with_level(
189
+ level: Severity | None, message: tuple[object, ...]
190
+ ) -> EffectGen[None]:
191
+ minimum = yield from require_implicit(MinimumLogLevel)
192
+ log_level = (
193
+ level
194
+ if level is not None
195
+ else (yield from require_implicit(CurrentLogLevel)).level
196
+ )
197
+
198
+ if log_level_order(log_level) < log_level_order(minimum.level):
199
+ return None
200
+
201
+ loggers = yield from require_implicit(CurrentLoggers)
202
+ annotations = yield from require_implicit(CurrentLogAnnotations)
203
+
204
+ date = datetime.now()
205
+ for logger in loggers.loggers:
206
+ logger.log(
207
+ LogData(
208
+ message=message,
209
+ log_level=log_level,
210
+ date=date,
211
+ annotations=annotations.annotations,
212
+ )
213
+ )
214
+ return None