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/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
|
effecton/std/__init__.py
ADDED
|
@@ -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
|