Python: Algebraic Effects via async/await
TL;DR #
The full source code can be found in this gist.
The Challenge #
We are looking to implement a library through which side-effect "requests" can be separated from their implementation.
A requirement of this implementation is that it is as type-safe as possible and
that it type-checks under mypy --strict.
Let's go.
First, Some Concepts #
from abc import ABC # abstract base classes
from typing import Generic, TypeVar # static type checking
T = TypeVar('T')
class Effect(Generic[T], ABC): ...
An Effect[T] is an abstract generic type that describes an effect that
produces a value of type T.
For example, we may describe an API to fetch tweets like so:
from dataclasses import dataclass
from typing import List
# A data type describing the contents of a Tweet
@dataclass(frozen = True)
class Tweet: ...
@dataclass(frozen = True)
class GetTweets(Effect[List[Tweet]]):
user_id: str
This type definition means: GetTweets is an Effect that produces a List of
Tweets.
We have a simple type by which we can describe effects that produce values:
Effect[T]. Now, we need something that can handle (or run) these effects in
order to produce actual values.
from abc import abstractmethod
from typing import Any
class EffectHandler(ABC):
@abstractmethod
def handle(self, effect: Effect[Any]) -> Any: ...
EffectHandler is an abstract type with an operation handle that takes an
Effect[Any] (any type of effect) and returns a value that is produced from
handling the effect.
For example, we may implement a class that is able to handle effects related to the Twitter API:
class StubTwitterHandler(EffectHandler):
def handle(self, effect: Effect[Any]) -> Any:
if isinstance(effect, GetTweets):
# Return some fake tweets
return [Tweet(), Tweet(), Tweet()]
And we may invoke the handler directly:
StubTwitterHandler().handle(GetTweets('garciat_com'))
# returns: [Tweet(), Tweet(), Tweet()]
However, if we defined a new effect for logging, this StubTwitterHandler would
not handle the effect:
@dataclass(frozen = True)
class Log(Effect[None]):
message: str
StubTwitterHandler().handle(Log('this is a message'))
# returns: None (sort of indistinguishable from a "real" None value)
Great. We now have two fundamental definitions at hand:
- An
Effect[T]describes an effect that produces values of typeT. - An
EffectHandleris able to handle/execute/run a subset of effects.
Using Generators to Emit Effects #
Python Generators allow two functions to communicate with 'message passing'. This is a good fit for our goal of separating side-effect requests from side-effects implementation.
For example:
- A generator produces an instance of
Effect[T]and then pauses execution. - The underlying 'effect system' invokes the relevant
EffectHandlerto obtain a value of typeT. - The value
Tis passed to the generator to resume its execution. - Repeat until generator terminates.
In code:
def print_user_tweets(user_id: str) -> ???:
tweets = yield GetTweets(user_id)
for tweet in tweets:
print(tweet)
return len(tweets)
def run_effects(handler: EffectHandler, generator: ???) -> ???:
# 'effect system' implementation
# Running it:
run_effects(StubTwitterHandler(), print_user_tweets('garciat_com'))
Notice that some type declarations are missing and have ??? instead. Why?
The Typing Problem With Generators #
The typing module describes the type of generators as
Generator[YieldType, SendType, ReturnType].
The problem with this definition is that there is no static relationship between
YieldType and SendType. That is, given x = yield e where e:
Effect[T],
we would like x to statically have type T.
Saying Generator[Effect[T], T, ...] would not work, because the type T is
determined at the call time of the generator function, and not by the body of
the function.
For example:
def does_not_work() -> Generator[Effect[T], T, str]:
tweets: List[Tweet]
tweets = yield GetTweets('garciat_com')
return 'bye'
mypy complains with:
error: Incompatible types in "yield" (actual type "GetTweets", expected type "Effect[T]")
error: Incompatible types in assignment (expression has type "T", variable has type "List[Tweet]")
await is like yield with ∀ #
We observed that yield's type is determined by its Generator type. await's
type, however, is universally quantified:
∀T. y : T = await (x : Awaitable[T]).
In English:
- for all type
T- given
y = await xxhas typeAwaitable[T]- and
yhas typeT
- given
Now, what is an Awaitable[T] and how do we turn our Effect[T] into one?
Awaitable[T] stands for a type that has a method
__await__(self) -> Generator[Any, None, T]. In other words, given an
aw : Awaitable[T], aw.__await__() must return a generator that eventually
terminates by returning a value of type T.
At the moment, it is not clear how we would implement the __await__ method.
However, we may start defining a type that wraps Effects into Awaitables:
from typing import Awaitable, Generator
@dataclass(frozen = True)
class EffectFuture(Generic[T], Awaitable[T]):
effect: Effect[T]
def __await__(self) -> Generator[Any, None, T]:
raise NotImplementedError
Now we are able to expose Effects as regular 'async' functions:
async def get_tweets(user_id: str) -> List[Tweet]:
return await EffectFuture(GetTweets(user_id=user_id))
This successfully type-checks with mypy --strict.
We may now revisit our untypable definitions (those with the ???) and give
them proper types:
async def print_user_tweets(user_id: str) -> int:
tweets = await get_tweets(user_id)
for tweet in tweets:
print(tweet)
return len(tweets)
def run_effects(handler: EffectHandler, awaitable: Awaitable[T]) -> T:
# 'effect system' implementation
Great. It's interesting how the print_user_tweets function is not aware of the
existence of the effects mechanism. As far as it is concerned, it is calling a
'regular async function' get_tweets to get the list of tweets.
Understanding __await__ #
First, let's take a look at what print_user_tweets actually does.
If we run this:
gen = print_user_tweets('garciat_com')
x = gen.send(None) # resume the generator
print(x)
It fails with:
Traceback (most recent call last):
File "post.py", line ?, in <module>
x = gen.send(None)
File "post.py", line ?, in print_user_tweets
tweets = await get_tweets(user_id)
File "post.py", line ?, in get_tweets
return await EffectFuture(GetTweets(user_id=user_id))
File "post.py", line ?, in __await__
raise NotImplementedError
The call graph eventually reaches the __await__ method we defined.
In order for the flow to continue, __await__ in EffectFuture[T] must return
a value of type T. Where can we get one?
Let's take a closer look at __await__'s signature:
def __await__(self) -> Generator[Any, None, T]
Remember the definition of the Generator type:
Generator[YieldType, SendType, ReturnType]
In __await__'s case, the type variables are set to:
YieldType = AnySendType = NoneReturnType = T
This means that __await__ may yield any value, but it may not receive a
value.
That's interesting. If it may not receive a value, where will it get the T
instance that it needs?
One option is using Promises. In our context, a Promise is a "box" that
starts out empty and is filled in once.
from typing import Union
class _PromiseSentinel: pass
_PROMISE_SENTINEL = _PromiseSentinel()
@dataclass
class Promise(Generic[T]):
_result: Union[_PromiseSentinel, T] = _PROMISE_SENTINEL
def complete(self, value: T) -> None:
if isinstance(self._result, _PromiseSentinel):
self._result = value
else:
raise Exception('Promise already set')
@property
def value(self) -> T:
if isinstance(self._result, _PromiseSentinel):
raise Exception('Promise is not set')
else:
return self._result
Then, we fit the Promise inside the Future:
from dataclasses import field
@dataclass(frozen = True)
class EffectFuture(Generic[T], Awaitable[T]):
effect: Effect[T]
promise: Promise[T] = field(default_factory=Promise)
def __await__(self) -> Generator[Any, None, T]:
yield self
return self.promise.value
We implement __await__ by yielding the Future itself, which contains the
Effect and the Promise. We assume that the effect loop will complete the
promise before __await__ is resumed. Finally, we read the T value out of the
Promise and return it.
If we run this again:
gen = print_user_tweets('garciat_com')
x = gen.send(None) # resume the generator
print(x)
It prints:
EffectFuture(effect=GetTweets(user_id='garciat_com'), promise=Promise(_result=<__main__._PromiseSentinel object at 0x10a326a10>))
We can then run:
x.promise.complete([Tweet(), Tweet()])
gen.send(None) # resume the generator
And we get:
Tweet()
Tweet()
Traceback (most recent call last):
File "post.py", line 102, in <module>
gen.send(None)
StopIteration: 2
This means that print_user_tweets received the list of tweets, performed its
loop, and terminated by returning 2 (the length of the list of tweets).
However, a generator's way of signaling termination is by raising
StopIteration, with a property value populated with the return value of the
generator. Here, it's 2.
Here's the full interaction:
gen = print_user_tweets('garciat_com')
x = gen.send(None) # resume the generator
x.promise.complete([Tweet(), Tweet()])
try:
gen.send(None) # resume the generator
except StopIteration as stop:
assert stop.value == 2
We are now prepared to implement run_effects!
The Effect Loop #
TODO: explain code
from typing import cast
def run_effects(handler: EffectHandler, awaitable: Awaitable[T]) -> T:
gen = cast(Generator[EffectFuture[Any], None, T], awaitable.__await__())
try:
while True:
future = gen.send(None)
future.promise.complete(handler.handle(future.effect))
except StopIteration as stop:
return cast(T, stop.value)
finally:
gen.close()
Using it:
handler = StubTwitterHandler()
awaitable = print_user_tweets('garciat_com')
print('final result:', run_effects(handler, awaitable))
Prints:
Tweet()
Tweet()
Tweet()
final result: 3
Type Safety in Handlers #
TODO: introduce Answer type
Composing Handlers #
TODO: introduce HandlerStack
Effectful Handlers #
TODO: async def handle + recursive run_effects