Skip to content

Commit 961821f

Browse files
committed
Add async_option
1 parent 91994ed commit 961821f

File tree

5 files changed

+611
-2
lines changed

5 files changed

+611
-2
lines changed

README.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@
145145
- **seq** - a world for working with sequences.
146146
- **async_result** - an asynchronous error handling world for working
147147
with asynchronous result values.
148+
- **async_option** - an asynchronous optional world for working with
149+
asynchronous optional values.
148150
- **Mailbox Processor**: for lock free programming using the [Actor
149151
model](https://en.wikipedia.org/wiki/Actor_model).
150152
- **Cancellation Token**: for cancellation of asynchronous (and
@@ -536,6 +538,73 @@ async def outer() -> AsyncGenerator[int, int]:
536538
pinned to `Exception` i.e., `AsyncResult[TSource, Exception]`.
537539
"""
538540

541+
# %% [markdown]
542+
"""
543+
### AsyncOption
544+
545+
The `AsyncOption[T]` type is the asynchronous version of `Option`. It allows you to
546+
compose asynchronous operations that may return an optional value, using the Option type.
547+
This is particularly useful for handling optional values in asynchronous code, such as
548+
API calls that might not return a value, database queries that might not find a record,
549+
or any other I/O-bound tasks that might not produce a meaningful result.
550+
551+
Similar to the `Option` effect, AsyncOption enables short-circuiting but for asynchronous
552+
operations. If any part of the function yields `Nothing`, the function is short-circuited
553+
and the following statements will never be executed.
554+
"""
555+
556+
# %%
557+
from collections.abc import AsyncGenerator
558+
559+
from expression import Nothing, Some, effect
560+
561+
562+
@effect.async_option[int]()
563+
async def fn_option() -> AsyncGenerator[int, int]:
564+
x: int = yield 42 # Regular value
565+
y: int = yield await Some(43) # Awaitable Some value
566+
567+
# Short-circuit if condition is met
568+
if x + y > 80:
569+
z: int = yield await Nothing # This will short-circuit
570+
else:
571+
z: int = yield 44
572+
573+
yield x + y + z # Final value
574+
575+
576+
# This would be run in an async context
577+
# result = await fn_option()
578+
# assert result is Nothing
579+
580+
# %% [markdown]
581+
"""
582+
AsyncOption works well with other async functions and can be nested:
583+
"""
584+
585+
586+
# %%
587+
@effect.async_option[int]()
588+
async def inner_option(x: int) -> AsyncGenerator[int, int]:
589+
y: int = yield x + 1
590+
yield y + 1 # Final value is y + 1
591+
592+
593+
@effect.async_option[int]()
594+
async def outer_option() -> AsyncGenerator[int, int]:
595+
x: int = yield 40
596+
597+
# Call inner and await its result
598+
inner_result = await inner_option(x)
599+
y: int = yield await inner_result
600+
601+
yield y # Final value is y
602+
603+
604+
# This would be run in an async context
605+
# result = await outer_option()
606+
# assert result == Some(42) # 40 -> 41 -> 42
607+
539608
# %% [markdown]
540609
"""
541610
### Sequence

expression/core/option.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from __future__ import annotations
1010

1111
import builtins
12-
from collections.abc import Callable, Generator, Iterable
12+
from collections.abc import Awaitable, Callable, Generator, Iterable
1313
from typing import TYPE_CHECKING, Any, Literal, TypeGuard, TypeVar, cast, get_args, get_origin
1414

1515
from typing_extensions import TypeVarTuple, Unpack
@@ -42,6 +42,7 @@
4242
@tagged_union(frozen=True, order=True)
4343
class Option(
4444
Iterable[_TSourceOut],
45+
Awaitable[_TSourceOut],
4546
PipeMixin,
4647
):
4748
"""Option class."""
@@ -296,6 +297,16 @@ def __str__(self) -> str:
296297
def __repr__(self) -> str:
297298
return self.__str__()
298299

300+
def __await__(self) -> Generator[_TSourceOut, _TSourceOut, _TSourceOut]:
301+
"""Make Option awaitable by delegating to __iter__."""
302+
match self:
303+
case Option(tag="some", some=value):
304+
return value
305+
case _:
306+
raise EffectError(self)
307+
308+
yield None
309+
299310
@classmethod
300311
def __get_pydantic_core_schema__(cls, source_type: Any, handler: GetCoreSchemaHandler) -> CoreSchema:
301312
from pydantic import ValidatorFunctionWrapHandler

expression/effect/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""A collection of computational expression effects."""
22

3+
from .async_option import AsyncOptionBuilder as async_option
34
from .async_result import AsyncResultBuilder as async_result
45
from .async_result import AsyncTryBuilder as async_try
56
from .option import OptionBuilder as option
@@ -11,4 +12,4 @@
1112
seq = seq_builder
1213

1314

14-
__all__ = ["async_result", "async_try", "option", "result", "seq", "try_"]
15+
__all__ = ["async_option", "async_result", "async_try", "option", "result", "seq", "try_"]

expression/effect/async_option.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""AsyncOption builder module.
2+
3+
The AsyncOption builder allows for composing asynchronous operations that
4+
may return an optional value, using the Option type. It's similar to the Option builder but
5+
works with async operations.
6+
"""
7+
8+
from collections.abc import AsyncGenerator, Awaitable, Callable
9+
from typing import Any, ParamSpec, TypeVar
10+
11+
from expression.core import Nothing, Option, Some
12+
from expression.core.async_builder import AsyncBuilder
13+
14+
15+
_TSource = TypeVar("_TSource")
16+
_TResult = TypeVar("_TResult")
17+
_P = ParamSpec("_P")
18+
19+
20+
class AsyncOptionBuilder(AsyncBuilder[_TSource, Option[Any]]):
21+
"""AsyncOption builder.
22+
23+
The AsyncOption builder allows for composing asynchronous operations that
24+
may return an optional value, using the Option type.
25+
"""
26+
27+
async def bind(
28+
self,
29+
xs: Option[_TSource],
30+
fn: Callable[[Any], Awaitable[Option[_TResult]]],
31+
) -> Option[_TResult]:
32+
"""Bind a function to an async option value.
33+
34+
In F# computation expressions, this corresponds to ``let!`` and enables
35+
sequencing of computations.
36+
37+
Args:
38+
xs: The async option value to bind
39+
fn: The function to apply to the value if Some
40+
41+
Returns:
42+
The result of applying fn to the value if Some, otherwise Nothing
43+
"""
44+
match xs:
45+
case Option(tag="some", some=value):
46+
return await fn(value)
47+
case _:
48+
return Nothing
49+
50+
async def return_(self, x: _TSource) -> Option[_TSource]:
51+
"""Wrap a value in an async option.
52+
53+
In F# computation expressions, this corresponds to ``return`` and lifts
54+
a value into the option context.
55+
56+
Args:
57+
x: The value to wrap
58+
59+
Returns:
60+
Some containing the value
61+
"""
62+
return Some(x)
63+
64+
async def return_from(self, xs: Option[_TSource]) -> Option[_TSource]:
65+
"""Return an async option value directly.
66+
67+
In F# computation expressions, this corresponds to ``return!`` and allows
68+
returning an already wrapped value.
69+
70+
Args:
71+
xs: The async option value to return
72+
73+
Returns:
74+
The async option value unchanged
75+
"""
76+
return xs
77+
78+
async def combine(self, xs: Option[_TSource], ys: Option[_TSource]) -> Option[_TSource]:
79+
"""Combine two async option computations.
80+
81+
In F# computation expressions, this enables sequencing multiple
82+
expressions where we only care about the final result.
83+
84+
Args:
85+
xs: First async option computation
86+
ys: Second async option computation
87+
88+
Returns:
89+
The second computation if first is Some, otherwise Nothing
90+
"""
91+
match xs:
92+
case Option(tag="some"):
93+
return ys
94+
case _:
95+
return Nothing
96+
97+
async def zero(self) -> Option[Any]:
98+
"""Return the zero value for async options.
99+
100+
In F# computation expressions, this is used when no value is returned,
101+
corresponding to None in F#.
102+
103+
Returns:
104+
Nothing
105+
"""
106+
return Nothing
107+
108+
async def delay(self, fn: Callable[[], Option[_TSource]]) -> Option[_TSource]:
109+
"""Delay the computation.
110+
111+
Default implementation is to return the result of the function.
112+
"""
113+
return fn()
114+
115+
async def run(self, computation: Option[_TSource]) -> Option[_TSource]:
116+
"""Run a computation.
117+
118+
Default implementation is to return the computation as is.
119+
"""
120+
return computation
121+
122+
def __call__(
123+
self,
124+
fn: Callable[
125+
_P,
126+
AsyncGenerator[_TSource, _TSource] | AsyncGenerator[_TSource, None],
127+
],
128+
) -> Callable[_P, Awaitable[Option[_TSource]]]:
129+
"""The builder decorator."""
130+
return super().__call__(fn)
131+
132+
133+
# Create singleton instance
134+
async_option: AsyncOptionBuilder[Any] = AsyncOptionBuilder()
135+
136+
137+
__all__ = ["AsyncOptionBuilder", "async_option"]

0 commit comments

Comments
 (0)