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
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pprint import pformat
|
|
6
|
+
from typing import assert_never, final
|
|
7
|
+
|
|
8
|
+
from effecton.std.logger import (
|
|
9
|
+
EffectonLogger,
|
|
10
|
+
LogData,
|
|
11
|
+
LogLevel,
|
|
12
|
+
Severity,
|
|
13
|
+
_to_python_logger_level,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
_ANSI_RESET = "\x1b[0m"
|
|
17
|
+
_ANSI_DIM = "\x1b[2m"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _severity_ansi(level: Severity) -> str:
|
|
21
|
+
match level:
|
|
22
|
+
case LogLevel.TRACE:
|
|
23
|
+
return "\x1b[90m"
|
|
24
|
+
case LogLevel.DEBUG:
|
|
25
|
+
return "\x1b[34m"
|
|
26
|
+
case LogLevel.INFO:
|
|
27
|
+
return "\x1b[32m"
|
|
28
|
+
case LogLevel.WARN:
|
|
29
|
+
return "\x1b[33m"
|
|
30
|
+
case LogLevel.ERROR:
|
|
31
|
+
return "\x1b[31m"
|
|
32
|
+
case LogLevel.FATAL:
|
|
33
|
+
return "\x1b[41;97m"
|
|
34
|
+
case _:
|
|
35
|
+
assert_never(level)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _severity_for_levelno(levelno: int) -> Severity:
|
|
39
|
+
"""Nearest effecton severity for a stdlib level.
|
|
40
|
+
|
|
41
|
+
The inverse of _to_python_logger_level.
|
|
42
|
+
"""
|
|
43
|
+
if levelno >= logging.CRITICAL:
|
|
44
|
+
return LogLevel.FATAL
|
|
45
|
+
if levelno >= logging.ERROR:
|
|
46
|
+
return LogLevel.ERROR
|
|
47
|
+
if levelno >= logging.WARNING:
|
|
48
|
+
return LogLevel.WARN
|
|
49
|
+
if levelno >= logging.INFO:
|
|
50
|
+
return LogLevel.INFO
|
|
51
|
+
if levelno >= logging.DEBUG:
|
|
52
|
+
return LogLevel.DEBUG
|
|
53
|
+
return LogLevel.TRACE
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@final
|
|
57
|
+
class PrettyFormatter(logging.Formatter):
|
|
58
|
+
"""Formats records as ``[HH:MM:SS.mmm] LEVEL message``.
|
|
59
|
+
|
|
60
|
+
Levels render in effecton's severity vocabulary (WARN, FATAL) with a
|
|
61
|
+
color per level; colors=None detects whether stderr is a terminal.
|
|
62
|
+
effecton annotations travel on the record as the effecton_annotations
|
|
63
|
+
attribute (through ``extra``) and each renders as one indented
|
|
64
|
+
``key: value`` line.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, *, colors: bool | None = None) -> None:
|
|
68
|
+
super().__init__()
|
|
69
|
+
self._colors = colors
|
|
70
|
+
|
|
71
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
72
|
+
use_color = sys.stderr.isatty() if self._colors is None else self._colors
|
|
73
|
+
|
|
74
|
+
def paint(code: str, text: str) -> str:
|
|
75
|
+
return f"{code}{text}{_ANSI_RESET}" if use_color else text
|
|
76
|
+
|
|
77
|
+
severity = _severity_for_levelno(record.levelno)
|
|
78
|
+
date = datetime.fromtimestamp(record.created)
|
|
79
|
+
stamp = f"{date:%H:%M:%S}.{int(record.msecs):03d}"
|
|
80
|
+
header = (
|
|
81
|
+
f"{paint(_ANSI_DIM, f'[{stamp}]')} "
|
|
82
|
+
f"{paint(_severity_ansi(severity), severity.name)}"
|
|
83
|
+
)
|
|
84
|
+
first, *rest = record.getMessage().split("\n")
|
|
85
|
+
lines = [f"{header} {first}"]
|
|
86
|
+
lines += [f" {line}" for line in rest]
|
|
87
|
+
annotations = record.__dict__.get("effecton_annotations")
|
|
88
|
+
if isinstance(annotations, Mapping):
|
|
89
|
+
lines += [
|
|
90
|
+
f" {paint(_ANSI_DIM, f'{key}:')} {value}"
|
|
91
|
+
for key, value in annotations.items()
|
|
92
|
+
]
|
|
93
|
+
if record.exc_info:
|
|
94
|
+
lines.append(self.formatException(record.exc_info))
|
|
95
|
+
return "\n".join(lines)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
_pretty_python_logger = logging.getLogger("effecton.pretty")
|
|
99
|
+
# effecton owns filtering, so this logger must pass everything.
|
|
100
|
+
_pretty_python_logger.setLevel(1)
|
|
101
|
+
_pretty_python_logger.propagate = False
|
|
102
|
+
_pretty_handler = logging.StreamHandler()
|
|
103
|
+
_pretty_handler.setFormatter(PrettyFormatter())
|
|
104
|
+
_pretty_python_logger.addHandler(_pretty_handler)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _pretty_log(options: LogData) -> None:
|
|
108
|
+
text = " ".join(
|
|
109
|
+
part if isinstance(part, str) else pformat(part, width=80, sort_dicts=False)
|
|
110
|
+
for part in options.message
|
|
111
|
+
)
|
|
112
|
+
_pretty_python_logger.log(
|
|
113
|
+
_to_python_logger_level(options.log_level),
|
|
114
|
+
text,
|
|
115
|
+
extra={"effecton_annotations": options.annotations},
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
pretty_logger = EffectonLogger(log=_pretty_log)
|
effecton/std/scope.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from functools import reduce
|
|
4
|
+
from typing import Any, Never, final
|
|
5
|
+
|
|
6
|
+
from effecton.effect import Effect, EffectonError, ProvideRequirement, require, success
|
|
7
|
+
from effecton.suspend import suspend
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@final
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Scope:
|
|
13
|
+
_finalizers: list[Effect[Any]] = field(default_factory=list)
|
|
14
|
+
|
|
15
|
+
def add_finalizer(self, finalizer: Effect[Any]) -> None:
|
|
16
|
+
self._finalizers.append(finalizer)
|
|
17
|
+
|
|
18
|
+
@suspend
|
|
19
|
+
def close(self) -> Effect[None]:
|
|
20
|
+
# Draining makes a second close a no-op; folding with on_exit
|
|
21
|
+
# keeps a dying finalizer from skipping earlier-registered ones.
|
|
22
|
+
finalizers = list(self._finalizers)
|
|
23
|
+
self._finalizers.clear()
|
|
24
|
+
return reduce(
|
|
25
|
+
lambda acc, f: acc.on_exit(f),
|
|
26
|
+
reversed(finalizers),
|
|
27
|
+
success(None),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def add_finalizer(finalizer: Effect[Any]) -> Effect[None, Never, Scope]:
|
|
32
|
+
return require(Scope).map(lambda s: s.add_finalizer(finalizer))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@suspend
|
|
36
|
+
def scoped[A, E: EffectonError, R = Never](
|
|
37
|
+
effect: Effect[A, E, Scope | R],
|
|
38
|
+
) -> Effect[A, E, R]:
|
|
39
|
+
scope = Scope()
|
|
40
|
+
|
|
41
|
+
return ProvideRequirement(
|
|
42
|
+
first=effect, requirement_type=Scope, requirement_impl=scope
|
|
43
|
+
).on_exit(scope.close())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def acquire_and_release[A, E: EffectonError, R](
|
|
47
|
+
acquire: Effect[A, E, R], release: Callable[[A], Effect[Any]]
|
|
48
|
+
) -> Effect[A, E, R | Scope]:
|
|
49
|
+
"""Acquire a resource whose release the enclosing scope guarantees.
|
|
50
|
+
|
|
51
|
+
The release is registered only after acquire succeeds; a failed or
|
|
52
|
+
dying acquire registers nothing. suspend defers the release effect's
|
|
53
|
+
construction, so a release function that raises does so at close time
|
|
54
|
+
as a finalizer defect that cannot skip other finalizers.
|
|
55
|
+
"""
|
|
56
|
+
return acquire.flat_map(
|
|
57
|
+
lambda a: add_finalizer(suspend(lambda: release(a))).map(lambda _: a)
|
|
58
|
+
)
|
effecton/suspend.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Lazily defer effect construction.
|
|
2
|
+
|
|
3
|
+
``suspend`` has two forms, resolved by the callable's signature. The
|
|
4
|
+
thunk form, ``suspend(thunk)``, returns an ``Effect`` that rebuilds the
|
|
5
|
+
thunk's effect on every interpretation. The decorator form, ``@suspend``
|
|
6
|
+
on a function that takes arguments, wraps it so each call captures its
|
|
7
|
+
arguments and defers the body the same way: it runs only when the
|
|
8
|
+
returned effect is interpreted. A zero-argument function resolves to the
|
|
9
|
+
thunk form, so decorating one yields the effect itself.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from functools import wraps
|
|
14
|
+
from inspect import signature
|
|
15
|
+
from typing import Any, overload
|
|
16
|
+
|
|
17
|
+
from effecton.effect import Effect, EffectonError, sync
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@overload
|
|
21
|
+
def suspend[A, E: EffectonError, R](
|
|
22
|
+
f: Callable[[], Effect[A, E, R]],
|
|
23
|
+
) -> Effect[A, E, R]: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@overload
|
|
27
|
+
def suspend[**P, A, E: EffectonError, R](
|
|
28
|
+
f: Callable[P, Effect[A, E, R]],
|
|
29
|
+
) -> Callable[P, Effect[A, E, R]]: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def suspend(f: Callable[..., Effect[Any, Any, Any]]) -> Any:
|
|
33
|
+
def defer(thunk: Callable[[], Effect[Any, Any, Any]]) -> Effect[Any, Any, Any]:
|
|
34
|
+
return sync(thunk).flat_map(lambda x: x)
|
|
35
|
+
|
|
36
|
+
# bind() succeeds exactly when f is callable with no arguments, which
|
|
37
|
+
# mirrors the static dispatch: such callables match the thunk overload.
|
|
38
|
+
try:
|
|
39
|
+
signature(f).bind()
|
|
40
|
+
except TypeError:
|
|
41
|
+
|
|
42
|
+
@wraps(f)
|
|
43
|
+
def wrapper(*args: object, **kwargs: object) -> Effect[Any, Any, Any]:
|
|
44
|
+
return defer(lambda: f(*args, **kwargs))
|
|
45
|
+
|
|
46
|
+
return wrapper
|
|
47
|
+
return defer(f)
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: effecton
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A typed effect system for Python, inspired by Effect-TS
|
|
5
|
+
Project-URL: Repository, https://github.com/krzkaczor/effecton
|
|
6
|
+
Author-email: Krzysztof Kaczor <chris@kaczor.io>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: dependency-injection,effect-system,effect-ts,functional,typed
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.14
|
|
14
|
+
Requires-Dist: typing-extensions>=4.16.0
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Effecton
|
|
18
|
+
|
|
19
|
+
[](https://pypi.org/project/effecton/)
|
|
20
|
+
[](https://discord.gg/fNhY7AxMyh)
|
|
21
|
+
|
|
22
|
+
A typed effect system for Python, inspired by [Effect-TS](https://effect.website/). Early stage and experimental.
|
|
23
|
+
|
|
24
|
+
An `Effect[A, E, R]` is a description of a computation that succeeds with `A`, fails with a typed error `E` and requires `R` dependencies.
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import final
|
|
29
|
+
|
|
30
|
+
import effecton as E
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Custom errors extend EffectonError and are final: one leaf class per cause
|
|
34
|
+
@final
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class SecretInvalidError(E.EffectonError):
|
|
37
|
+
actual: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# signature means that it succeeds with str, fails with SecretInvalidError or HttpError and it requires HttpClient
|
|
41
|
+
@E.gen
|
|
42
|
+
def check_secret() -> E.EffectGen[
|
|
43
|
+
str, SecretInvalidError | HttpError, HttpClient.Protocol
|
|
44
|
+
]:
|
|
45
|
+
http = yield from E.require(HttpClient.Protocol) # requires HttpClient.Protocol
|
|
46
|
+
|
|
47
|
+
secret = yield from http.get_text(
|
|
48
|
+
"https://example.com/secret"
|
|
49
|
+
) # secret is a str; HttpError joins the error channel
|
|
50
|
+
if secret != "hunter2":
|
|
51
|
+
yield from E.fail(
|
|
52
|
+
SecretInvalidError(secret)
|
|
53
|
+
) # SecretInvalidError joins the error channel
|
|
54
|
+
return secret
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# program can be executed only after its requirements are provided
|
|
58
|
+
program = check_secret().provide(HttpClient.Protocol)(HttpClient.Live())
|
|
59
|
+
|
|
60
|
+
match E.run_sync_exit(program):
|
|
61
|
+
case E.Succeeded(value):
|
|
62
|
+
print(value) # "hunter2"
|
|
63
|
+
case E.Failure(cause):
|
|
64
|
+
print(cause) # Fail(SecretInvalidError(...)) or Fail(HttpStatusError(...))
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Installation
|
|
68
|
+
|
|
69
|
+
Requires Python 3.14 or later.
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
uv add effecton
|
|
73
|
+
# or
|
|
74
|
+
pip install effecton
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Features
|
|
78
|
+
|
|
79
|
+
* *Type-safe errors* -- stop guessing what a given function throws; implement surgical error handling to build reliable systems.
|
|
80
|
+
* *Dependency injection* -- with requirements, dependencies become visible. In tests, another implementation can be trivially injected. Forgetting to do so is a type error.
|
|
81
|
+
* *Finalizers* -- granular resource management.
|
|
82
|
+
* *Ergonomic* -- generator-based syntax with `@E.gen` and functional-style `flat_map`, `map`, and friends.
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
## Motivation
|
|
86
|
+
|
|
87
|
+
Effect based systems provide programmers with building blocks that might be difficult at first but yield benefits in the future. Handling edge cases and thorough testing might be optional in the prototype stage but becomes critical in production.
|
|
88
|
+
|
|
89
|
+
Furthermore, *agents love* strict type systems and building blocks.
|
|
90
|
+
|
|
91
|
+
*Full example*: [skills-cli](https://github.com/krzkaczor/effecton/tree/main/packages/examples/skills-cli), a small CLI for installing agent skills built entirely on effecton services.
|
|
92
|
+
|
|
93
|
+
## Overview
|
|
94
|
+
|
|
95
|
+
### Building effects
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
E.success(21).map(lambda x: x * 2) # Effect[int]
|
|
99
|
+
|
|
100
|
+
E.sync(lambda: print("hi")) # Effect[None] — defers a side effect until the effect runs
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# Custom errors extend EffectonError and are final: one leaf class per cause
|
|
104
|
+
@final
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class OopsError(E.EffectonError):
|
|
107
|
+
msg: str
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
E.fail(OopsError(msg="oops")) # Effect[Never, OopsError]
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`suspend` defers building an effect. The thunk form wraps one effect; as a decorator on a function with parameters, each call captures its arguments and defers the body until the effect runs:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
E.suspend(lambda: E.fail(OopsError(msg="later"))) # Effect[Never, OopsError]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@E.suspend
|
|
120
|
+
def find_user(user_id: int) -> E.Effect[str, OopsError]:
|
|
121
|
+
print("runs only when the effect is interpreted")
|
|
122
|
+
return E.success(f"user-{user_id}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
find_user(1) # Effect[str, OopsError] — nothing printed yet
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
More examples: [`test_run_sync.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/test_run_sync.py), [`test_suspend.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/test_suspend.py).
|
|
129
|
+
|
|
130
|
+
### Running effects
|
|
131
|
+
|
|
132
|
+
Effects are inert values; a runner interprets one. Each runner comes in a throwing form that returns the value and an `_exit` form that returns an `Exit`:
|
|
133
|
+
|
|
134
|
+
| Runner | Runs | Returns |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| `run_sync(effect)` | synchronously | the value, raising on failure |
|
|
137
|
+
| `run_sync_exit(effect)` | synchronously | `Exit[A, E]` |
|
|
138
|
+
| `run_async(effect)` | on a fresh asyncio loop (`asyncio.run` inside) | the value, raising on failure |
|
|
139
|
+
| `run_async_exit(effect)` | on a fresh asyncio loop (`asyncio.run` inside) | `Exit[A, E]` |
|
|
140
|
+
| `await run_async_coroutine(effect)` | inside a loop you already own | `Exit[A, E]` |
|
|
141
|
+
|
|
142
|
+
The throwing forms raise a typed failure as the error itself (every `EffectonError` is an `Exception`), re-raise an exception defect as it is, wrap any other defect in `UnhandledDefect`, and re-raise the exception carried by an interruption:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
try:
|
|
146
|
+
value = E.run_sync(effect) # A
|
|
147
|
+
except HttpStatusError as e: # a typed failure
|
|
148
|
+
...
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The `_exit` forms never raise; they return an `Exit` to match on:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
match E.run_sync_exit(effect): # Exit[A, E] = Succeeded[A] | Failure[E]
|
|
155
|
+
case E.Succeeded(value):
|
|
156
|
+
...
|
|
157
|
+
case E.Failure(cause):
|
|
158
|
+
... # cause is Fail(error) for typed failures, Die(defect) for unexpected exceptions, Interrupt(exception) for cancellations
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`run_async` and `run_async_exit` interpret the same effect under asyncio, awaiting every `coroutine` effect they reach. They own the event loop through `asyncio.run`, so they cannot be called from a running loop; `run_async_coroutine` is the coroutine underneath, for a caller that already has one:
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
exit = await E.run_async_coroutine(
|
|
165
|
+
effect
|
|
166
|
+
) # Exit[A, E], awaiting coroutine effects along the way
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Running an effect that contains a `coroutine` effect synchronously doesn't await it: `run_sync_exit` settles as `Failure(Die(AsyncEffectInSyncRun()))`, `run_sync` raises `AsyncEffectInSyncRun`, and finalizers still run in both cases.
|
|
170
|
+
|
|
171
|
+
More examples: [`test_run_sync.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/test_run_sync.py), [`test_run_async.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/test_run_async.py).
|
|
172
|
+
|
|
173
|
+
### Error handling
|
|
174
|
+
|
|
175
|
+
Use `catch_all` to handle errors:
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
p = E.fail(OopsError(msg="oops")).catch_all(
|
|
179
|
+
lambda e: E.success(f"recovered from {e.msg}")
|
|
180
|
+
) # Effect[str] — the error channel is now Never
|
|
181
|
+
|
|
182
|
+
E.run_sync(p) # "recovered from oops"
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Use `catch` to handle one error type and leave the rest in the error channel. The handler receives the narrowed error, and defects (`Die`) pass through untouched:
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
n = random.randint(1, 4)
|
|
189
|
+
|
|
190
|
+
p = E.success(n).flat_map(
|
|
191
|
+
lambda r: E.fail(FatalError()) if r == 2 else E.fail(RecoverableError())
|
|
192
|
+
) # Effect[Never, FatalError | RecoverableError]
|
|
193
|
+
|
|
194
|
+
p2 = p.catch(RecoverableError)(lambda e: E.success(42)) # Effect[int, FatalError]
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`catch` is curried like `provide`: the error class is bound first so the type checker can subtract it from the union. Catching a class the effect cannot fail with is a well-typed no-op.
|
|
198
|
+
|
|
199
|
+
Error classes are leaves: mark every error `@final` and never subclass one. `catch` matches by class, and `@final` keeps its runtime `isinstance` check and the static subtraction in agreement, because the type checker cannot distinguish a subclass from its base when subtracting from a union.
|
|
200
|
+
|
|
201
|
+
More examples: [`test_run_sync.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/test_run_sync.py).
|
|
202
|
+
|
|
203
|
+
### Requirements and providing them
|
|
204
|
+
|
|
205
|
+
`require(T)` reads a dependency and records it in the `R` channel; composing effects unions their requirements, exactly like errors. the runners only accept `Effect[A, E]`, so running an effect with unmet requirements is a type error, not a runtime surprise.
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
@dataclass(frozen=True)
|
|
209
|
+
class Db:
|
|
210
|
+
url: str
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
needs_db = E.require(Db).map(lambda db: db.url) # Effect[str, Never, Db]
|
|
214
|
+
|
|
215
|
+
program = needs_db.provide(Db)(Db("postgres://x")) # Effect[str] — runnable
|
|
216
|
+
|
|
217
|
+
E.run_sync(program) # "postgres://x"
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
`provide(T)(impl)` subtracts the provided type from `R`, so requirements can be provided one at a time, anywhere in the program — a partially provided effect is an ordinary value carrying the remainder in `R`, and the runners accept it only once `R` reaches `Never`.
|
|
221
|
+
|
|
222
|
+
More examples: [`test_run_sync.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/test_run_sync.py).
|
|
223
|
+
|
|
224
|
+
### Implicit requirements
|
|
225
|
+
|
|
226
|
+
Some dependencies, such as a logger or a log level, should work out of the box yet stay overridable. An implicit requirement is a class that extends the `ImplicitRequirement` protocol with a `default()` classmethod. Reading one with `require_implicit(X)` types as `Effect[X]`: it never enters `R`, so a program that only uses implicit requirements runs bare. If the lookup misses, the interpreter falls back to `X.default()`, computed once per process and memoized, so defaults must be immutable values.
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
@final
|
|
230
|
+
@dataclass(frozen=True)
|
|
231
|
+
class Greeting(E.ImplicitRequirement):
|
|
232
|
+
text: str
|
|
233
|
+
|
|
234
|
+
@classmethod
|
|
235
|
+
def default(cls) -> Greeting:
|
|
236
|
+
return Greeting("hello")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
E.run_sync(E.require_implicit(Greeting)) # Greeting("hello") — nothing provided
|
|
240
|
+
|
|
241
|
+
# override for a sub-effect only; the env is restored when it settles
|
|
242
|
+
E.run_sync(E.provide_implicit(E.require_implicit(Greeting), Greeting("hi")))
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
`provide_implicit(effect, value)` is keyed by `type(value)`, so mark implicit requirement classes `@final`. Overrides also compose in a `provide` chain: `effect.provide(Greeting)(Greeting("hi"))`. `require_implicit` is a separate accessor rather than an overload on `require` because the overload pair silently drops requirements from `R` in some inference positions (pinned in `test_types_implicit_requirement.py`). One footgun: the runtime check only tests that a `default` attribute exists, so a plain requirement class that defines one gets the default fallback instead of a `MissingRequirement` defect.
|
|
246
|
+
|
|
247
|
+
More examples: [`test_implicit_requirement.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/test_implicit_requirement.py).
|
|
248
|
+
|
|
249
|
+
### Resource management with `on_exit` and Scope
|
|
250
|
+
|
|
251
|
+
`on_exit` attaches a finalizer that runs when the effect settles, on success and failure alike. A `Scope` collects finalizers from a whole sub-tree: `acquire_and_release` registers a release for an acquired resource, and `.scoped()` provides the `Scope` and runs the collected finalizers in reverse order when the wrapped effect settles. It composes with `provide` chains — `program.provide(Db)(db).scoped()` — and is a no-op on effects that never acquired a `Scope`, so it can uniformly terminate a chain.
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
E.success(21).on_exit(E.log_info("done")) # finalizer runs on success and failure alike
|
|
255
|
+
|
|
256
|
+
conn = E.acquire_and_release(
|
|
257
|
+
E.sync(lambda: pool.connect()), # acquire
|
|
258
|
+
lambda c: E.sync(c.close), # release, guaranteed by the enclosing scope
|
|
259
|
+
) # Effect[Connection, Never, Scope]
|
|
260
|
+
|
|
261
|
+
program = conn.flat_map(
|
|
262
|
+
run_queries
|
|
263
|
+
).scoped() # Scope discharged; close() runs when program settles
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
A finalizer that dies doesn't skip the remaining finalizers; its defect surfaces in the final `Exit`.
|
|
267
|
+
|
|
268
|
+
More examples: [`test_scope.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_scope.py).
|
|
269
|
+
|
|
270
|
+
### Generator syntax
|
|
271
|
+
|
|
272
|
+
`@E.gen` turns a generator function into a factory of effects: the interpreter runs each yielded effect and sends its success value back into the generator, and the generator's return value becomes the effect's success value. Write `x = yield from effect`, not `x = yield effect` — `Effect.__iter__` is typed so `yield from` gives `x` the effect's success type, while a bare `yield` types as `Any`.
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
@E.gen
|
|
276
|
+
def total(n: int) -> E.EffectGen[int, OopsError]:
|
|
277
|
+
a = yield from E.success(20) # a: int — yield from types the sent-back value
|
|
278
|
+
|
|
279
|
+
if n < 0:
|
|
280
|
+
yield from E.fail(
|
|
281
|
+
OopsError(msg="negative")
|
|
282
|
+
) # OopsError joins the error channel
|
|
283
|
+
return a + n
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
E.run_sync(total(22)) # 42
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
A failing yielded effect abandons the generator, so `try/except` around a `yield` never observes effect failures — use `catch` or `catch_all` on the resulting effect instead.
|
|
290
|
+
|
|
291
|
+
More examples: [`test_gen.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/test_gen.py).
|
|
292
|
+
|
|
293
|
+
### Wrapping third party code
|
|
294
|
+
|
|
295
|
+
`attempt` runs an exception-throwing thunk lazily and maps expected exceptions into the typed error channel. Re-raise unexpected exceptions from the mapper so they stay defects:
|
|
296
|
+
|
|
297
|
+
```python
|
|
298
|
+
@final
|
|
299
|
+
@dataclass(frozen=True)
|
|
300
|
+
class InvalidJson(E.EffectonError):
|
|
301
|
+
text: str
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def parse_json(text: str) -> E.Effect[Any, InvalidJson]:
|
|
305
|
+
def to_error(e: Exception) -> InvalidJson:
|
|
306
|
+
if isinstance(e, json.JSONDecodeError):
|
|
307
|
+
return InvalidJson(text)
|
|
308
|
+
raise e # anything else stays a defect
|
|
309
|
+
|
|
310
|
+
return E.attempt(lambda: json.loads(text), to_error)
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
More examples: [`test_attempt.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/test_attempt.py).
|
|
314
|
+
|
|
315
|
+
### Wrapping async code
|
|
316
|
+
|
|
317
|
+
`coroutine` defers an awaitable the way `sync` defers a thunk: the thunk builds a fresh awaitable on every run, because a coroutine object can be awaited only once. Exceptions become defects. `attempt_async` is the `attempt` counterpart that maps expected exceptions into the error channel. Only the `run_async` family can interpret either:
|
|
318
|
+
|
|
319
|
+
```python
|
|
320
|
+
client = httpx.AsyncClient()
|
|
321
|
+
|
|
322
|
+
E.coroutine(lambda: client.get(url)) # Effect[httpx.Response] — nothing awaited yet
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def get_text(url: str) -> E.Effect[str, HttpStatusError]:
|
|
326
|
+
async def go() -> str:
|
|
327
|
+
response = await client.get(url)
|
|
328
|
+
response.raise_for_status()
|
|
329
|
+
return response.text
|
|
330
|
+
|
|
331
|
+
def to_error(e: Exception) -> HttpStatusError:
|
|
332
|
+
if isinstance(e, httpx.HTTPStatusError):
|
|
333
|
+
return HttpStatusError(url=url, status_code=e.response.status_code)
|
|
334
|
+
raise e # anything else stays a defect
|
|
335
|
+
|
|
336
|
+
return E.attempt_async(go, to_error)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
E.run_async(get_text("https://example.com")) # text, or raises HttpStatusError
|
|
340
|
+
|
|
341
|
+
E.run_async_exit(
|
|
342
|
+
get_text("https://example.com")
|
|
343
|
+
) # Succeeded(text) or Failure(Fail(HttpStatusError(...)))
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
Inside `@E.gen` bodies, `yield from E.coroutine(...)` works like any other effect; the generator itself stays synchronous. If the task running `run_async_coroutine` is cancelled, the effect unwinds with an `Interrupt` cause, which `catch_all` and `catch` skip like a `Die`: finalizers and scope releases run, and the run settles as `Failure(Interrupt(exception))`, so an `asyncio.timeout` around `run_async_coroutine` doesn't leak resources and completes normally with that `Exit`. The cancellation is consumed by `run_async_coroutine`; a caller whose task should stop re-raises the carried exception. A finalizer that is mid-await when the cancellation arrives is shielded and runs to completion, and a cancellation raised from a synchronous thunk or callback, such as a cancelled future's `result()`, unwinds the same way.
|
|
347
|
+
|
|
348
|
+
More examples: [`test_run_async.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/test_run_async.py).
|
|
349
|
+
|
|
350
|
+
## Standard library
|
|
351
|
+
|
|
352
|
+
### Logger
|
|
353
|
+
|
|
354
|
+
Effecton comes with pretty logger out of the box.
|
|
355
|
+
|
|
356
|
+
```python
|
|
357
|
+
E.run_sync(E.log_info("user created", 42)) # pretty-printed to stderr, no setup needed
|
|
358
|
+
|
|
359
|
+
program = E.annotate_logs(
|
|
360
|
+
handle_request(), request_id="r-1"
|
|
361
|
+
) # every log inside carries request_id=r-1
|
|
362
|
+
|
|
363
|
+
captured: list[E.LogData] = []
|
|
364
|
+
E.run_sync(
|
|
365
|
+
E.provide_implicit(
|
|
366
|
+
program, E.CurrentLoggers((E.EffectonLogger(log=captured.append),))
|
|
367
|
+
)
|
|
368
|
+
)
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
More examples: [`test_logger.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_logger.py), [`test_pretty_logger.py`](https://github.com/krzkaczor/effecton/blob/main/packages/effecton/src/effecton/std/test_pretty_logger.py).
|
|
372
|
+
|
|
373
|
+
## Roadmap
|
|
374
|
+
|
|
375
|
+
- [x] `ty` support
|
|
376
|
+
- [x] Support for async/sync code
|
|
377
|
+
- [ ] Retries
|
|
378
|
+
- [ ] Timeouts
|
|
379
|
+
- [ ] `Random` implicit service
|
|
380
|
+
- [ ] More examples of integrations with existing ecosystem (fastapi, pydantic etc.)
|
|
381
|
+
|
|
382
|
+
## Inspirations
|
|
383
|
+
|
|
384
|
+
* Effect-TS/ZIO
|
|
385
|
+
* stateless
|
|
386
|
+
|
|
387
|
+
## Contributing
|
|
388
|
+
|
|
389
|
+
See [CONTRIBUTING.md](https://github.com/krzkaczor/effecton/blob/main/CONTRIBUTING.md) for repo setup and development commands.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
effecton/__init__.py,sha256=1AoMW-SRutmWP1fRdcSEzEryQ81KQJx6-gc4s902Cek,2124
|
|
2
|
+
effecton/attempt.py,sha256=TBSocEZTGPInRvpLAnox9F8As9PEAJQ2Nuv1nU-IKEU,1521
|
|
3
|
+
effecton/catch.py,sha256=TpGKIZ9dd4uJOxTwyfNmcBoavuSbIysVW0K2uwPjfiw,1200
|
|
4
|
+
effecton/effect.py,sha256=jS2TQ2p9P94PIiXDU4gS-YsrTG0V26yndzTTvCnXuGE,5699
|
|
5
|
+
effecton/exit.py,sha256=BfUfcNf6K4EM4IXeBI-FNQ1TlfoZbJgU-8TgHwFaNDs,1816
|
|
6
|
+
effecton/gen.py,sha256=20GkDvF_YrACbZmNka_NnjTpQdSFpNNvQWI-YhrRSm8,1706
|
|
7
|
+
effecton/implicit_requirement.py,sha256=N0XIin5cJe3RtJL9int9_0OpJBwZwnvm48H_nX7A7Io,1279
|
|
8
|
+
effecton/provide.py,sha256=QYZffxMzatw-uXNmPd68pQLEkgt501tZlWRG-39SF4c,1134
|
|
9
|
+
effecton/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
effecton/run_async.py,sha256=43sMg4117nfZc8s4ZrrG7m1gWzA-VJw-J8Z4SJwtRzk,8772
|
|
11
|
+
effecton/run_sync.py,sha256=WZw9nGRRYZc_pI0aFBjG52IFxqaocLR6Smu0jKIuYK8,6468
|
|
12
|
+
effecton/suspend.py,sha256=Qde8d6ECfAP76iahpEwRP5XNrsnS-ZcsfcGlqobdrhA,1552
|
|
13
|
+
effecton/std/__init__.py,sha256=dxyXTqusP00HWIbwB7Y-p5KH-zhu-9cXpfW8QML07a4,61
|
|
14
|
+
effecton/std/logger.py,sha256=YEJkBIs0rZrIA2z2ymLzNR-eqiibCu4M2TdyFO98PLg,5281
|
|
15
|
+
effecton/std/pretty_logger.py,sha256=tbFpQkcMFfhDv94jxfDKkQhDJgw9Su38JVB9ptFQrM8,3699
|
|
16
|
+
effecton/std/scope.py,sha256=ypYGqBBJWv8v8mP54vPY_8j9oXDuoXfzksGTMYmrY88,1921
|
|
17
|
+
effecton-0.2.0.dist-info/METADATA,sha256=YF3lS88lCHKqe4z9mDIfRrdoqDegqXgsAhKYtdYTaW8,17073
|
|
18
|
+
effecton-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
19
|
+
effecton-0.2.0.dist-info/licenses/LICENSE,sha256=3B7uzSf5952ICD7olNeRci6CqDdhRKYoXzVXc8VnAyk,1080
|
|
20
|
+
effecton-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Krzysztof Kaczor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|