Skip to content

Commit c632a29

Browse files
authored
feat: add Runtime.use_effect (#5)
1 parent 1045fff commit c632a29

File tree

5 files changed

+131
-16
lines changed

5 files changed

+131
-16
lines changed

README.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,73 @@ type Try[E, R] = Effect[Never, E, R]
345345
```
346346
For effects that do not require abilities, but might fail.
347347

348+
Sometimes, instantiating abilities may itself require side-effects. For example, consider a program that requires a `Config` ability:
349+
350+
351+
```python
352+
from stateless import Depend
353+
354+
355+
class Config:
356+
...
357+
358+
359+
def main() -> Depend[Config, None]:
360+
...
361+
```
362+
363+
Now imagine that you want to provide the `Config` ability by reading from environment variables:
364+
365+
366+
```python
367+
import os
368+
369+
from stateless import Depend, depend
370+
371+
372+
class OS:
373+
environ: dict[str, str] = os.environ
374+
375+
376+
def get_config() -> Depend[OS, Config]:
377+
os = yield from depend(OS)
378+
return Config(
379+
url=os.environ['AUTH_TOKEN'],
380+
auth_token=os.environ['URL']
381+
)
382+
```
383+
384+
To supply the `Config` instance returned from `get_config`, we can use `Runtime.use_effect`:
385+
386+
387+
```python
388+
from stateless import Runtime
389+
390+
391+
Runtime().use(OS()).use_effect(get_config()).run(main())
392+
```
393+
394+
`Runtime.use_effect` assumes that all abilities required by the effect given as its argument can be provided by the runtime. If this is not the case, you'll get a type-checker error:
395+
396+
```python
397+
from stateless import Depend, Runtime
398+
399+
400+
class A:
401+
pass
402+
403+
404+
class B:
405+
pass
406+
407+
408+
def get_B() -> Depend[A, B]:
409+
...
410+
411+
Runtime().use(A()).use_effect(get_B()) # OK
412+
Runtime().use_effect(get_B()) # Type-checker error!
413+
```
414+
348415
## Error Handling
349416

350417
So far we haven't used the error type `E` for anything: We've simply parameterized it with `typing.Never`. We've claimed that this means that the effect doesn't fail. This is of course not literally true, as exceptions can still occur even if we parameterize `E` with `Never.`

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "stateless"
3-
version = "0.5.1"
3+
version = "0.5.2"
44
description = "Statically typed, purely functional effects for Python"
55
authors = ["suned <[email protected]>"]
66
readme = "README.md"

src/stateless/effect.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Contains the Effect type and core functions for working with effects."""
22

3-
from collections.abc import Generator
3+
from collections.abc import Generator, Hashable
44
from dataclasses import dataclass, field
55
from functools import lru_cache, partial, wraps
66
from types import TracebackType
@@ -9,7 +9,7 @@
99
from typing_extensions import Never, ParamSpec, TypeAlias
1010

1111
R = TypeVar("R")
12-
A = TypeVar("A")
12+
A = TypeVar("A", bound=Hashable)
1313
E = TypeVar("E", bound=Exception)
1414
P = ParamSpec("P")
1515
E2 = TypeVar("E2", bound=Exception)

src/stateless/runtime.py

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Runtime for executing effects."""
22

3+
from collections.abc import Generator, Hashable
34
from dataclasses import dataclass
45
from functools import cache
56
from typing import Generic, Literal, Tuple, Type, TypeVar, cast, overload
@@ -8,9 +9,9 @@
89
from stateless.errors import MissingAbilityError
910
from stateless.parallel import Parallel
1011

11-
A = TypeVar("A")
12-
A2 = TypeVar("A2")
13-
A3 = TypeVar("A3")
12+
A = TypeVar("A", bound=Hashable)
13+
A2 = TypeVar("A2", bound=Hashable)
14+
A3 = TypeVar("A3", bound=Hashable)
1415
R = TypeVar("R")
1516
E = TypeVar("E", bound=Exception)
1617

@@ -23,11 +24,14 @@ def _get_ability(ability_type: Type[A], abilities: Tuple[A, ...]) -> A:
2324
raise MissingAbilityError(ability_type)
2425

2526

26-
@dataclass(frozen=True)
27+
@dataclass(frozen=True, init=False)
2728
class Runtime(Generic[A]):
2829
"""A runtime for executing effects."""
2930

30-
abilities: tuple[A, ...] = ()
31+
abilities: tuple[A, ...]
32+
33+
def __init__(self, *abilities: A):
34+
object.__setattr__(self, "abilities", abilities)
3135

3236
def use(self, ability: A2) -> "Runtime[A | A2]":
3337
"""
@@ -43,7 +47,25 @@ def use(self, ability: A2) -> "Runtime[A | A2]":
4347
-------
4448
A new runtime with the ability.
4549
"""
46-
return Runtime((ability, *self.abilities))
50+
return Runtime(*(*self.abilities, ability)) # type: ignore
51+
52+
def use_effect(self, effect: Effect[A, Exception, A2]) -> "Runtime[A | A2]":
53+
"""
54+
Use an ability produced by an effect with this runtime.
55+
56+
Enables running effects that require the ability.
57+
58+
All abilities required by `effect` must be provided by the runtime.
59+
60+
Args:
61+
----
62+
effect: The effect producing an ability.
63+
64+
Returns:
65+
-------
66+
A new runtime with the ability.
67+
"""
68+
return self.use(effect) # type: ignore
4769

4870
def get_ability(self, ability_type: Type[A]) -> A:
4971
"""
@@ -61,12 +83,18 @@ def get_ability(self, ability_type: Type[A]) -> A:
6183
return _get_ability(ability_type, self.abilities) # type: ignore
6284

6385
@overload
64-
def run(self, effect: Effect[A, E, R], return_errors: Literal[False] = False) -> R:
86+
def run(
87+
self,
88+
effect: Effect[A, E, R],
89+
return_errors: Literal[False] = False,
90+
) -> R:
6591
... # pragma: no cover
6692

6793
@overload
6894
def run(
69-
self, effect: Effect[A, E, R], return_errors: Literal[True] = True
95+
self,
96+
effect: Effect[A, E, R],
97+
return_errors: Literal[True] = True,
7098
) -> R | E:
7199
... # pragma: no cover
72100

@@ -83,6 +111,20 @@ def run(self, effect: Effect[A, E, R], return_errors: bool = False) -> R | E:
83111
-------
84112
The result of the effect.
85113
"""
114+
abilities: tuple[A, ...] = ()
115+
for ability in self.abilities:
116+
if isinstance(ability, Generator):
117+
abilities = ( # pyright: ignore
118+
self._run(ability, abilities, return_errors=False),
119+
*abilities,
120+
)
121+
else:
122+
abilities = (ability, *abilities) # pyright: ignore
123+
return self._run(effect, abilities, return_errors)
124+
125+
def _run(
126+
self, effect: Effect[A, E, R], abilities: Tuple[A, ...], return_errors: bool
127+
) -> R | E:
86128
try:
87129
ability_or_error = next(effect)
88130

@@ -101,7 +143,7 @@ def run(self, effect: Effect[A, E, R], return_errors: bool = False) -> R | E:
101143
case ability_type if ability_type is Parallel:
102144
ability_or_error = effect.send(self)
103145
case ability_type:
104-
ability = self.get_ability(ability_type)
146+
ability = _get_ability(ability_type, abilities)
105147
ability_or_error = effect.send(ability)
106148
except MissingAbilityError as error:
107149
ability_or_error = effect.throw(error)

tests/test_runtime.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,11 @@ def effect() -> Depend[Super, Super]:
4646
with raises(MissingAbilityError, match="Super") as info:
4747
Runtime().run(effect()) # type: ignore
4848

49-
print(info.getrepr())
50-
51-
# test that the third frame is the yield
49+
# test that the fourth frame is the yield
5250
# expression in `effect` function above
5351
# (first is Runtime().run(..)
5452
# second is effect.throw in Runtime.run)
55-
frame = info.traceback[2]
53+
frame = info.traceback[3]
5654
assert str(frame.path) == __file__
5755
assert frame.lineno == effect.__code__.co_firstlineno
5856

@@ -98,3 +96,11 @@ def catches() -> Try[ValueError, None]:
9896

9997
with raises(ValueError, match="oops again"):
10098
Runtime().run(catches(), return_errors=True)
99+
100+
101+
def test_use_effect() -> None:
102+
def effect() -> Depend[str, bytes]:
103+
ability: str = yield str
104+
return ability.encode()
105+
106+
assert Runtime("ability").use_effect(effect()).run(depend(bytes)) == b"ability"

0 commit comments

Comments
 (0)