From faedb03a1b02cefd61af6d3660dade166be51751 Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Tue, 28 Oct 2025 21:45:33 +0100 Subject: [PATCH 01/31] first working implementation --- README.md | 36 +-- poetry.lock | 6 +- src/stateless/__init__.py | 8 +- src/stateless/abilities.py | 199 --------------- src/stateless/ability.py | 16 ++ src/stateless/async_.py | 127 +++++++++ src/stateless/console.py | 9 +- src/stateless/constants.py | 5 - src/stateless/effect.py | 92 +++---- src/stateless/errors.py | 4 + src/stateless/files.py | 5 +- src/stateless/functions.py | 19 +- src/stateless/handler.py | 64 +++++ src/stateless/need.py | 54 ++++ src/stateless/parallel.py | 509 ------------------------------------- src/stateless/time.py | 14 +- tests/test_abilities.py | 96 ++----- tests/test_console.py | 10 +- tests/test_effect.py | 39 +-- tests/test_file.py | 4 +- tests/test_need.py | 26 ++ tests/test_parallel.py | 165 ------------ tests/test_time.py | 6 +- tests/utils.py | 6 +- 24 files changed, 424 insertions(+), 1095 deletions(-) delete mode 100644 src/stateless/abilities.py create mode 100644 src/stateless/ability.py create mode 100644 src/stateless/async_.py delete mode 100644 src/stateless/constants.py create mode 100644 src/stateless/handler.py create mode 100644 src/stateless/need.py delete mode 100644 src/stateless/parallel.py create mode 100644 tests/test_need.py delete mode 100644 tests/test_parallel.py diff --git a/README.md b/README.md index 1495ac5..6a72990 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,15 @@ As a result, "business logic" code never performs side-effects, which makes it e ```python from typing import Any, Never -from stateless import Effect, depend, throws, catch, Abilities, run +from stateless import Effect, Need, need, throws, catch, run # stateless.Effect is just an alias for: # -# from typing import Type, Generator, Any +# from typing import Generator, Any +# from stateless import Ability # -# type Effect[A, E: Exception, R] = Generator[Type[A] | E, Any, R] +# type Effect[A: Ability, E: Exception, R] = Generator[A | E, Any, R] class Files: @@ -38,28 +39,26 @@ class Console: print(value) -# Effects are generators that yield "Abilities" that can be sent to the -# generator when an effect is executed. Abilities could be anything, but will often be things that -# handle side-effects. Here it's a class that can print to the console. -# In other effects systems, abilities are called "effect handlers". -def print_(value: Any) -> Effect[Console, Never, None]: - console = yield from depend(Console) # depend returns abilities +# Effects are generators that yield abilities that can handled up the call stack. +# An example ability might be `stateless.Need` that is used for type-safe dependency injection. +def print_(value: Any) -> Effect[Need[Console], Never, None]: + console = yield from need(Console) console.print(value) # Effects can yield exceptions. 'stateless.throws' will catch exceptions # for you and yield them to other functions so you can handle them with # type safety. The return type of the decorated function in this -# example is: ´Effect[Files, OSError, str]' +# example is: ´Effect[Need[Files], OSError, str]' @throws(OSError) -def read_file(path: str) -> Effect[Files, Never, str]: - files = yield from depend(Files) +def read_file(path: str) -> Effect[Need[Files], Never, str]: + files = yield from need(Files) return files.read_file(path) # Simple effects can be combined into complex ones by # depending on multiple abilities. -def print_file(path: str) -> Effect[Files | Console, Never, None]: +def print_file(path: str) -> Effect[Need[Files] | Need[Console], Never, None]: # catch will return exceptions yielded by other functions result = yield from catch(OSError)(read_file)(path) match result: @@ -70,11 +69,11 @@ def print_file(path: str) -> Effect[Files | Console, Never, None]: # Effects are run using `stateless.run`. -# Abilities are provided to effects via `Abilities.handle` +# the `Need` ability is handled using `stateless.supply` # Before an effect can be executed with `run`, it must have # all of its abilities handled. -abilities = Abilities().add(Files()).add(Console()) -effect = abilities.handle(print_file)('foo.txt') +handle = supply(Files(), Console()) +effect = handle(print_file)('foo.txt') run(effect) ``` @@ -86,10 +85,11 @@ run(effect) ```python -from typing import Any, Generator, Type +from typing import Any, Generator +from stateless import Ability -type Effect[A, E: Exception, R] = Generator[Type[A] | E, Any, R] +type Effect[A: Ability, E: Exception, R] = Generator[A | E, Any, R] ``` In other words, an `Effect` is a generator that can yield classes of type `A` or exceptions of type `E`, can be sent anything, and returns results of type `R`. Let's break that down a bit further: diff --git a/poetry.lock b/poetry.lock index 01db8b4..8a1bafe 100644 --- a/poetry.lock +++ b/poetry.lock @@ -578,14 +578,14 @@ plugins = ["importlib-metadata ; python_version < \"3.8\""] [[package]] name = "pyright" -version = "1.1.405" +version = "1.1.407" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "pyright-1.1.405-py3-none-any.whl", hash = "sha256:a2cb13700b5508ce8e5d4546034cb7ea4aedb60215c6c33f56cec7f53996035a"}, - {file = "pyright-1.1.405.tar.gz", hash = "sha256:5c2a30e1037af27eb463a1cc0b9f6d65fec48478ccf092c1ac28385a15c55763"}, + {file = "pyright-1.1.407-py3-none-any.whl", hash = "sha256:6dd419f54fcc13f03b52285796d65e639786373f433e243f8b94cf93a7444d21"}, + {file = "pyright-1.1.407.tar.gz", hash = "sha256:099674dba5c10489832d4a4b2d302636152a9a42d317986c38474c76fe562262"}, ] [package.dependencies] diff --git a/src/stateless/__init__.py b/src/stateless/__init__.py index e940100..74c947d 100644 --- a/src/stateless/__init__.py +++ b/src/stateless/__init__.py @@ -2,20 +2,22 @@ # ruff: noqa: F401 -from stateless.abilities import Abilities +from stateless.ability import Ability +from stateless.async_ import Async, Executor, fork, wait from stateless.effect import ( Depend, Effect, Success, Try, catch, - depend, memoize, run, + run_async, success, throw, throws, ) from stateless.functions import repeat, retry -from stateless.parallel import parallel, process +from stateless.handler import Handler +from stateless.need import Need, need, supply from stateless.schedule import Schedule diff --git a/src/stateless/abilities.py b/src/stateless/abilities.py deleted file mode 100644 index d2c302e..0000000 --- a/src/stateless/abilities.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Module with classes and functions for providing abililties to effects.""" - -from dataclasses import dataclass -from functools import wraps -from typing import Callable, Generic, ParamSpec, Type, TypeVar, cast, overload - -from typing_extensions import Never - -from stateless.constants import PARALLEL_SENTINEL -from stateless.effect import Depend, Effect, Success, Try, run -from stateless.errors import MissingAbilityError - -A = TypeVar("A", covariant=True) -A2 = TypeVar("A2") -R = TypeVar("R") -E = TypeVar("E", bound=Exception) -P = ParamSpec("P") - - -def _cache_key(ability_type: Type[A]) -> str: - return f"{ability_type.__module__}.{ability_type.__name__}" - - -@dataclass -class EffectAbility(Generic[A, R]): - """ - Wrapper for effects passed to Abilities.add_effect. - - Used mainly to distinguish these abilities - from other abilities at runtime using isinstance - to tell if an ability requires interpretation - to get its value. - """ - - effect: Effect[A, Exception, R] - - -@dataclass(frozen=True, init=False) -class Abilities(Generic[A]): - """Wraps ability instances and provides them to effects during effect interpretation.""" - - _ability_cache: dict[str, A] - abilities: tuple[A, ...] - - @overload - def __init__(self: "Abilities[Never]"): - ... # pragma: no cover - - @overload - def __init__(self, *abilities: A): - ... # pragma: no cover - - def __init__(self, *abilities: A): - object.__setattr__(self, "_ability_cache", {}) - object.__setattr__(self, "abilities", abilities) - - def add(self, ability: A2) -> "Abilities[A | A2]": - """ - Add an ability that can be provided during effect interpration. - - Args: - ---- - ability: The ability instance to provide - Returns: - New instance of Abilities wrapping the ability. - - """ - a = Abilities() - ability_union = (ability, *self.abilities) - object.__setattr__(a, "abilities", ability_union) - return cast(Abilities[A | A2], a) - - def add_effect( - self, - ability: Callable[P, Effect[A, Exception, R]], - *args: P.args, - **kwargs: P.kwargs, - ) -> "Abilities[A | R]": - """ - Like `Ability.add`, but for abilities that themselves require effects to provide. - - Args: - ---- - ability: Factory function for getting the effect - args: args for `ability` - kwargs: kwargs for `ability` - - """ - a = Abilities() - effect_ability = EffectAbility(self.handle(ability)(*args, **kwargs)) - ability_union = (effect_ability, *self.abilities) - object.__setattr__(a, "abilities", ability_union) - return cast(Abilities[A | R], a) - - def get_ability(self, ability_type: Type[A2]) -> A2 | None: - """ - Get a wrapped ability instance by type. - - Finds the most recently added ability that is a subtype of `ability_type` - using `isinstance`, or `None` if no such instance exists. - - Args: - ---- - ability_type: The ability type to find. - - Returns: - ------- - Most recently added instance of type `ability_type` or - `None` if no subclasses of `ability_type` are wrapped. - - """ - cache_key = _cache_key(ability_type) - if cache_key in self._ability_cache: - return self._ability_cache[cache_key] # type: ignore - for ability in self.abilities: - # run effects passed as arguments to add_effect - if isinstance(ability, EffectAbility): - ability = run(ability.effect) # pyright: ignore - if isinstance(ability, ability_type): - self._ability_cache[cache_key] = ability # type: ignore - return ability - return None - - # These overloads are to help type inference figure - # out when the error or ability types are "Never". - # Without them both mypy and pyright seem to have a hard time - # figuring this out, and often replaces them with "Unknown" - # in pyright, or "Any" in mypy, and also complaining - # that assigning the return value must be annotated. - # With these overloads things seem to work as expected. - # - # pyright complains about the order of these overloads, but - # type inference still succeeds. - @overload - def handle(self, f: Callable[P, Depend[A, R]]) -> Callable[P, Success[R]]: - ... # pragma: no cover - - @overload - def handle( # pyright: ignore - self, - f: Callable[P, Effect[A, E, R]], - ) -> Callable[P, Try[E, R]]: - ... # pragma: no cover - - @overload - def handle(self, f: Callable[P, Depend[A | A2, R]]) -> Callable[P, Depend[A2, R]]: - ... # pragma: no cover - - @overload - def handle( - self, f: Callable[P, Effect[A | A2, E, R]] - ) -> Callable[P, Effect[A2, E, R]]: - ... # pragma: no cover - - def handle( - self, f: Callable[P, Effect[A | A2, E, R] | Depend[A | A2, R]] - ) -> Callable[P, Effect[A2, E, R] | Depend[A2, R]]: - """ - Handle abilities yielded by the effect returned by `f`. - - Args: - ---- - f: The function to handle abilities for. - - Returns: - ------- - f: With its abilities handled. - - """ - - @wraps(f) - def decorator( - *args: P.args, **kwargs: P.kwargs - ) -> Effect[A2, E, R] | Depend[A2, R]: - effect = f(*args, **kwargs) - try: - ability_or_error = next(effect) - - while True: - match ability_or_error: - case Exception() as error: - ability_or_error = effect.throw(error) - case ability_type if ability_type is PARALLEL_SENTINEL: - other_abilities = yield ability_type # type: ignore - effect.send((*self.abilities, *other_abilities)) - case ability_type: - ability = self.get_ability(ability_type) - if ability is None: - # yield the ability to `run` to trigger - # missing ability error - try: - ability = yield ability_type # type: ignore - except MissingAbilityError as e: - effect.throw(e) - ability_or_error = effect.send(ability) - except StopIteration as e: - return cast(R, e.value) - - return decorator diff --git a/src/stateless/ability.py b/src/stateless/ability.py new file mode 100644 index 0000000..2e1975b --- /dev/null +++ b/src/stateless/ability.py @@ -0,0 +1,16 @@ +from typing import TypeVar, Self, Generic, Generator + +from dataclasses import dataclass + +from stateless.errors import MissingAbilityError + +T = TypeVar('T', covariant=True) + +@dataclass(frozen=True) +class Ability(Generic[T]): + def __iter__(self: Self) -> Generator[Self, T, T]: + try: + v = yield self + except MissingAbilityError: + raise MissingAbilityError(self) from None + return v diff --git a/src/stateless/async_.py b/src/stateless/async_.py new file mode 100644 index 0000000..13ab96b --- /dev/null +++ b/src/stateless/async_.py @@ -0,0 +1,127 @@ +from concurrent.futures.thread import ThreadPoolExecutor +import inspect +from typing import Awaitable, Coroutine, Any, TypeVar, overload, Generic, ParamSpec, Callable +import cloudpickle +from typing_extensions import Never +import asyncio +from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor +from dataclasses import dataclass +from functools import partial, wraps + +from stateless.ability import Ability +from stateless.effect import Depend, Effect, Success, Try, catch_all, run, throw +from stateless.need import Need, need + + +P = ParamSpec('P') +R = TypeVar('R') +A = TypeVar('A', bound=Ability) +E = TypeVar('E', bound=Exception) +B = TypeVar('B') + + +@dataclass(frozen=True) +class Task(Generic[R]): + future:asyncio.Future[bytes] | asyncio.Future[R] + + async def get_result(self) -> R: + result = await self.future + if isinstance(result, bytes): + return cloudpickle.loads(result) + return result + + +@dataclass(frozen=True) +class Async(Ability[Any]): + awaitable: Awaitable[Any] + + +# this exists only for type inference purposes, +# specifally that `Need[Executor]` can be +# eliminated by handling the need ability +# with either a Process- or ThreadPoolExecutor +@dataclass(frozen=True, init=False) +class Executor: + executor: ThreadPoolExecutor | ProcessPoolExecutor + + def __init__(self, executor: ThreadPoolExecutor | ProcessPoolExecutor | None = None): + if not executor: + executor = ThreadPoolExecutor() + + object.__setattr__(self, 'executor', executor) + + + def __enter__(self): + self.executor.__enter__() + return self + + def __exit__(self, *args, **kwargs): + self.executor.__exit__(*args, **kwargs) + + +@overload +def fork(f: Callable[P, Success[R]]) -> Callable[P, Depend[Need[Executor], Task[R]]]: ... + +@overload +def fork(f: Callable[P, Try[E, R]]) -> Callable[P, Depend[Need[Executor], Task[R]]]: ... + +@overload +def fork(f: Callable[P, Depend[Async, R]]) -> Callable[P, Depend[Need[Executor], Task[R]]]: ... + +@overload +def fork(f: Callable[P, Effect[Async, E, R]]) -> Callable[P, Depend[Need[Executor], Task[R]]]: ... + + + + + + + +def process_target(payload: bytes) -> bytes: + f, args, kwargs = cloudpickle.loads(payload) + result = run(f(*args, **kwargs)) + return cloudpickle.dumps(result) + + +def fork(f: Callable[P, Success[R] | Effect[Async, E, R]]) -> Callable[P, Depend[Need[Executor], Task[R]]]: + @wraps(f) + def decorator(*args: P.args, **kwargs: P.kwargs) -> Depend[Need[Executor], Task[R]]: + def thread_target() -> R: + result = run(f(*args, **kwargs)) + return result + + executor = yield from need(Executor) + loop = asyncio.get_running_loop() + if isinstance(executor.executor, ProcessPoolExecutor): + payload = cloudpickle.dumps((f, args, kwargs)) + future = loop.run_in_executor(executor.executor, process_target, payload) + else: + future = loop.run_in_executor(executor.executor, thread_target) + return Task(future) + + return decorator + + +@overload +def wait(target: Coroutine[Any, Any, R]) -> Depend[Async, R]: + ... + + +@overload +def wait(target: Task[R]) -> Effect[Async, E, R]: + ... + + +def wait(target: Coroutine[Any, Any, R] | Task[R]) -> Effect[Async, E, R]: + # We dont want `Async` to be generic since we don't + # want to specify handlers for e.g `Async[int]` and `Async[str]` + # separately. They should be handled by the same handler. + # Unfortunately that breaks the pattern with `Ability` + # so `v` here is `Any` + # idea: don't handle errors in target function, but require that user handles them + # in their code before forking + if isinstance(target, Task): + v = yield from Async(target.get_result()) + else: + v = yield from Async(target) + return v diff --git a/src/stateless/console.py b/src/stateless/console.py index ff5b5f6..aaf430a 100644 --- a/src/stateless/console.py +++ b/src/stateless/console.py @@ -3,6 +3,7 @@ from typing import Any from stateless.effect import Depend +from stateless.need import Need, need class Console: @@ -33,7 +34,7 @@ def input(self, prompt: str = "") -> str: return input(prompt) -def print_line(content: Any) -> Depend[Console, None]: +def print_line(content: Any) -> Depend[Need[Console], None]: """Print the given content to stdout. Args: @@ -45,11 +46,11 @@ def print_line(content: Any) -> Depend[Console, None]: A Depend that prints the given content. """ - console = yield Console + console = yield from need(Console) console.print(content) -def read_line(prompt: str = "") -> Depend[Console, str]: +def read_line(prompt: str = "") -> Depend[Need[Console], str]: """Read a line from stdin. Args: @@ -61,5 +62,5 @@ def read_line(prompt: str = "") -> Depend[Console, str]: A Depend that reads a line from stdin. """ - console: Console = yield Console + console: Console = yield from need(Console) return console.input(prompt) diff --git a/src/stateless/constants.py b/src/stateless/constants.py deleted file mode 100644 index 17b61a4..0000000 --- a/src/stateless/constants.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Constants used internally in stateless.""" - -from typing import Final - -PARALLEL_SENTINEL: Final[object] = "PARALLEL_SENTINEL" diff --git a/src/stateless/effect.py b/src/stateless/effect.py index 6620e87..d6e780d 100644 --- a/src/stateless/effect.py +++ b/src/stateless/effect.py @@ -1,66 +1,62 @@ """Contains the Effect type and core functions for working with effects.""" +from __future__ import annotations +import asyncio import sys from collections.abc import Generator from dataclasses import dataclass, field from functools import lru_cache, partial, wraps from types import TracebackType -from typing import Any, Callable, Generic, Type, TypeVar, cast, overload +from typing import TYPE_CHECKING, Any, Callable, Generic, Type, TypeVar, cast, overload from typing_extensions import Never, ParamSpec, TypeAlias -from stateless.constants import PARALLEL_SENTINEL +from stateless.ability import Ability from stateless.errors import MissingAbilityError +if TYPE_CHECKING: + from stateless.async_ import Async # pragma: no cover + R = TypeVar("R") -A = TypeVar("A") +# A is bound to Ability since if A is completely unbound +# type inference is not possible. Specifically +# type checkers can't distinguish between abilities +# and errors in Effect types. +A = TypeVar("A", bound=Ability) E = TypeVar("E", bound=Exception) P = ParamSpec("P") E2 = TypeVar("E2", bound=Exception) -Effect: TypeAlias = Generator[Type[A] | E, Any, R] -Depend: TypeAlias = Generator[Type[A], Any, R] -Success: TypeAlias = Generator[Type[Never], Any, R] -Try: TypeAlias = Generator[Type[Never] | E, Any, R] - - -class NoResultError(Exception): - """Raised when an effect has no result. - - If this error is raised to user code - it should be considered a bug in stateless. - """ +Effect: TypeAlias = Generator[A | E, Any, R] +Depend: TypeAlias = Generator[A, Any, R] +Success: TypeAlias = Generator[Never, Any, R] +Try: TypeAlias = Generator[E, Any, R] -def run(effect: Try[Exception, R]) -> R: - """ - Run an effect. +async def run_async(effect: Effect[Async, Exception, R]) -> R: + from stateless.async_ import Async - Args: - ---- - effect: The effect to run. - - Returns: - ------- - The result of running `effect`. - - """ - while True: - try: - ability_or_error = next(effect) + try: + ability_or_error = next(effect) + while True: match ability_or_error: - case sentinel if sentinel == PARALLEL_SENTINEL: - effect.send(()) + case Async(awaitable): + v = await awaitable + ability_or_error = effect.send(v) case Exception() as error: # at this point this is an exception # not handled with stateless.catch anywhere - effect.throw(error) - case ability_type: + ability_or_error = effect.throw(error) + case ability: # At this point all abilities should be handled, # so any ability request indicates a missing ability - effect.throw(MissingAbilityError(ability_type)) - except StopIteration as e: - return cast(R, e.value) + ability_or_error = effect.throw(MissingAbilityError(ability)) + except StopIteration as e: + return cast(R, e.value) + + +def run(effect: Effect[Async, Exception, R]) -> R: + return asyncio.run(run_async(effect)) @dataclass(frozen=True) @@ -158,7 +154,7 @@ def __call__( # pyright: ignore[reportOverlappingOverload] ... # pragma: no cover @overload - def __call__(self, f: Callable[P, Try[E | E2, R]]) -> Callable[P, Try[E2, R]]: + def __call__(self, f: Callable[P, Try[E | E2, R]]) -> Callable[P, Try[E2, E | R]]: ... # pragma: no cover @overload @@ -238,26 +234,6 @@ def catch_all(f: Callable[P, Effect[A, E, R]]) -> Callable[P, Depend[A, E | R]]: return Catch(Exception)(f) # type: ignore -def depend(ability: Type[A]) -> Depend[A, A]: - """ - Create an effect that yields an ability and returns the ability sent from the runtime. - - Args: - ---- - ability: The ability to yield. - - Returns: - ------- - An effect that yields the ability and returns the ability sent from the runtime. - - """ - try: - a = yield ability - except MissingAbilityError as e: - raise MissingAbilityError(*e.args) from None - return cast(A, a) - - def throws( *errors: Type[E2], ) -> Callable[[Callable[P, Effect[A, E, R]]], Callable[P, Effect[A, E | E2, R]]]: diff --git a/src/stateless/errors.py b/src/stateless/errors.py index b1af6e8..b81ac37 100644 --- a/src/stateless/errors.py +++ b/src/stateless/errors.py @@ -7,3 +7,7 @@ class MissingAbilityError(Exception): """Raised when an effect requires an ability that is not available in the runtime thats executing it.""" ability: Type[object] + + +class UnhandledAbilityError(Exception): + pass diff --git a/src/stateless/files.py b/src/stateless/files.py index 669dc39..fc021c3 100644 --- a/src/stateless/files.py +++ b/src/stateless/files.py @@ -1,6 +1,7 @@ """Files ability and ability helpers.""" from stateless.effect import Depend, throws +from stateless.need import Need, need class Files: @@ -24,7 +25,7 @@ def read_file(self, path: str) -> str: @throws(FileNotFoundError, PermissionError) -def read_file(path: str) -> Depend[Files, str]: +def read_file(path: str) -> Depend[Need[Files], str]: """ Read a file. @@ -37,5 +38,5 @@ def read_file(path: str) -> Depend[Files, str]: The contents of the file as an effect. """ - files: Files = yield Files + files: Files = yield from need(Files) return files.read_file(path) diff --git a/src/stateless/functions.py b/src/stateless/functions.py index de1dce9..c292717 100644 --- a/src/stateless/functions.py +++ b/src/stateless/functions.py @@ -3,12 +3,15 @@ from functools import wraps from typing import Callable, Generic, ParamSpec, Tuple, TypeVar +from stateless.ability import Ability +from stateless.async_ import Async from stateless.effect import Effect, catch_all, throw +from stateless.need import Need from stateless.schedule import Schedule from stateless.time import Time, sleep -A = TypeVar("A") -A2 = TypeVar("A2") +A = TypeVar("A", bound=Ability) +A2 = TypeVar("A2", bound=Ability) E = TypeVar("E", bound=Exception) R = TypeVar("R") P = ParamSpec("P") @@ -18,7 +21,7 @@ def repeat( schedule: Schedule[A], ) -> Callable[ [Callable[P, Effect[A2, E, R]]], - Callable[P, Effect[A | A2 | Time, E, Tuple[R, ...]]], + Callable[P, Effect[A | A2 | Need[Time] | Async, E, Tuple[R, ...]]], ]: """ Repeat an effect according to a schedule. @@ -38,11 +41,11 @@ def repeat( def decorator( f: Callable[P, Effect[A2, E, R]], - ) -> Callable[P, Effect[A | A2 | Time, E, Tuple[R, ...]]]: + ) -> Callable[P, Effect[A | A2 | Need[Time] | Async, E, Tuple[R, ...]]]: @wraps(f) def wrapper( *args: P.args, **kwargs: P.kwargs - ) -> Effect[A | A2 | Time, E, Tuple[R, ...]]: + ) -> Effect[A | A2 | Need[Time] | Async, E, Tuple[R, ...]]: deltas = yield from schedule results = [] for interval in deltas: @@ -70,7 +73,7 @@ def retry( schedule: Schedule[A], ) -> Callable[ [Callable[P, Effect[A2, E, R]]], - Callable[P, Effect[A | A2 | Time, RetryError[E], R]], + Callable[P, Effect[A | A2 | Need[Time] | Async, RetryError[E], R]], ]: """ Retry an effect according to a schedule. @@ -91,11 +94,11 @@ def retry( def decorator( f: Callable[P, Effect[A2, E, R]], - ) -> Callable[P, Effect[A | A2 | Time, RetryError[E], R]]: + ) -> Callable[P, Effect[A | A2 | Need[Time] | Async, RetryError[E], R]]: @wraps(f) def wrapper( *args: P.args, **kwargs: P.kwargs - ) -> Effect[A | A2 | Time, RetryError[E], R]: + ) -> Effect[A | A2 | Need[Time] | Async, RetryError[E], R]: deltas = yield from schedule errors = [] for interval in deltas: diff --git a/src/stateless/handler.py b/src/stateless/handler.py new file mode 100644 index 0000000..26e5910 --- /dev/null +++ b/src/stateless/handler.py @@ -0,0 +1,64 @@ +from dataclasses import dataclass +from functools import wraps +from typing import Any, Callable, Generic, ParamSpec, TypeVar, cast, overload + +from stateless.ability import Ability +from stateless.effect import Depend, Effect, Success, Try +from stateless.errors import UnhandledAbilityError + + +E = TypeVar('E', bound=Exception) +A = TypeVar('A', covariant=True, bound=Ability) +A2 = TypeVar('A2', bound=Ability) +R = TypeVar('R') +P = ParamSpec('P') + + +@dataclass(frozen=True) +class Handler(Generic[A]): + # Sadly, complete type safety here requires higher-kinded types. + on: Callable[[A], Any] + + + + @overload + def __call__(self, f: Callable[P, Depend[A, R]]) -> Callable[P, Success[R]]: + ... # pragma: no cover + + @overload + def __call__(self, f: Callable[P, Depend[A | A2, R]]) -> Callable[P, Depend[A2, R]]: # pyright: ignore[reportOverlappingOverload] + ... # pragma: no cover + + @overload + def __call__(self, f: Callable[P, Effect[A, E, R]]) -> Callable[P, Try[E, R]]: + ... # pragma: no cover + + @overload + def __call__(self, f: Callable[P, Effect[A2 | A, E, R]]) -> Callable[P, Effect[A2, E, R]]: + ... # pragma: no cover + + + def __call__(self, f: Callable[P, Effect[A, E, R] | Effect[A | A2, E, R]]) -> Callable[P, Try[E, R] | Effect[A2, E, R]]: + @wraps(f) + def decorator( + *args: P.args, **kwargs: P.kwargs + ) -> Effect[A2, E, R] | Depend[A2, R]: + effect = f(*args, **kwargs) + try: + ability_or_error = next(effect) + + while True: + match ability_or_error: + case Exception() as error: + value = yield error + ability_or_error = effect.send(value) + case ability: + try: + value = self.on(ability) + except UnhandledAbilityError: + # defer to handlers up the call stack + value = yield ability # type: ignore + ability_or_error = effect.send(value) + except StopIteration as e: + return cast(R, e.value) + return decorator diff --git a/src/stateless/need.py b/src/stateless/need.py new file mode 100644 index 0000000..ee91557 --- /dev/null +++ b/src/stateless/need.py @@ -0,0 +1,54 @@ +from dataclasses import dataclass +from typing import TypeVar, Type, Callable, Generic, overload, Never +from typing_extensions import ParamSpec + +from stateless.ability import Ability +from stateless.handler import Handler +from stateless.effect import Depend, Effect, Try, Success +from stateless.errors import UnhandledAbilityError + + +T = TypeVar('T', covariant=True) +T2 = TypeVar('T2') +T3 = TypeVar('T3') +R = TypeVar('R') +P = ParamSpec('P') +E = TypeVar('E', bound=Exception) +A = TypeVar('A', bound=Ability) + + +@dataclass(frozen=True) +class Need(Ability[T]): + t: Type[T] + + + +def need(t: Type[T]) -> Depend[Need[T], T]: + v = yield from Need(t) + return v + +@overload +def supply(v1: T, /) -> Handler[Need[T]]: + ... # pragma: no cover + +@overload +def supply(v1: T, v2: T2, /) -> Handler[Need[T] | Need[T2]]: + ... # pragma: no cover + +@overload +def supply(v1: T, v2: T2, v3: T3, /) -> Handler[Need[T] | Need[T2] | Need[T3]]: + ... # pragma: no cover + + +def supply(first: T, /, *rest: T2) -> Handler[Need[T] | Need[T2]]: + # TODO: combine instances with &, or come up with a better way of handling abilities + instances = (first, *rest) + + def on(ability: Need[T]) -> T: + if not isinstance(ability, Need) or not isinstance(first, ability.t): + raise UnhandledAbilityError() + for instance in instances: + if isinstance(instance, ability.t): + return instance + + return Handler(on=on) diff --git a/src/stateless/parallel.py b/src/stateless/parallel.py deleted file mode 100644 index af7d26e..0000000 --- a/src/stateless/parallel.py +++ /dev/null @@ -1,509 +0,0 @@ -"""Contains the Parallel ability and ability helpers.""" - -from dataclasses import dataclass -from functools import wraps -from multiprocessing import Manager -from multiprocessing.managers import BaseManager, PoolProxy # type: ignore -from multiprocessing.pool import ThreadPool -from types import TracebackType -from typing import ( - Callable, - Generic, - Literal, - ParamSpec, - Sequence, - Type, - TypeVar, - cast, - overload, -) - -import cloudpickle # type: ignore -from typing_extensions import Never - -from stateless.abilities import Abilities # pragma: no cover -from stateless.constants import PARALLEL_SENTINEL -from stateless.effect import Depend, Effect, Success, catch_all, run, throw -from stateless.errors import MissingAbilityError - -A = TypeVar("A") -E = TypeVar("E", bound=Exception) -R = TypeVar("R") - - -@dataclass(frozen=True) -class Task(Generic[A, E, R]): - """A task that can be run in parallel. - - Captures arguments to functions that return effects - in order that they can be run in parallel, without concerns - about serialization and thread-safety of effects. - """ - - f: Callable[..., Effect[A, E, R]] - args: tuple[object, ...] - kwargs: dict[str, object] - use_threads: bool - - -def _run_task(payload: bytes) -> bytes: - abilities, task = cast( - tuple["Abilities[Parallel]", Task[object, Exception, object]], - cloudpickle.loads(payload), - ) - ability = abilities.get_ability(Parallel) - if ability is None: - return cloudpickle.dumps(MissingAbilityError(Parallel)) # type: ignore - effect = abilities.handle(catch_all(task.f))(*task.args, **task.kwargs) - with ability: - result = run(effect) # type: ignore - return cloudpickle.dumps(result) # type: ignore - - -class SuccessTask(Task["Parallel", Never, R]): - """A task that can be run in parallel. - - Captures arguments to functions that return effects - in order that they can be run in parallel, remove concerns - about serialization and thread-safety of effects. - """ - - -class DependTask(Task[A, Never, R]): - """A task that can be run in parallel. - - Captures arguments to functions that return effects - in order that they can be run in parallel, without concerns - about serialization and thread-safety of effects. - """ - - -@dataclass(frozen=True, init=False) -class Parallel: - """The Parallel ability. - - Enables running tasks in parallel using threads and processes. - - Args: - ---- - thread_pool: The thread pool to use to run tasks in parallel. - pool: The multiprocessing pool to use to run tasks in parallel. Must be a proxy pool. - - """ - - _thread_pool: ThreadPool | None - _manager: BaseManager | None - _pool: PoolProxy | None - state: Literal["init", "entered", "exited"] = "init" - _owns_thread_pool: bool = True - _owns_process_pool: bool = True - - @property - def thread_pool(self) -> ThreadPool: - """The thread pool used to run tasks in parallel.""" - - if self._thread_pool is None: - object.__setattr__(self, "_thread_pool", ThreadPool()) - self._thread_pool.__enter__() # type: ignore - return self._thread_pool # type: ignore - - @property - def manager(self) -> BaseManager: - """The multiprocessing manager used to run tasks in parallel.""" - - if self._manager is None: - object.__setattr__(self, "_manager", Manager()) - self._manager.__enter__() # type: ignore - return self._manager # type: ignore - - @property - def pool(self) -> PoolProxy: - """The multiprocessing pool used to run tasks in parallel.""" - - if self._pool is None: - object.__setattr__(self, "_pool", self.manager.Pool()) # type: ignore - self._pool.__enter__() # type: ignore - return self._pool - - def __init__( - self, thread_pool: ThreadPool | None = None, pool: PoolProxy | None = None - ): - object.__setattr__(self, "_thread_pool", thread_pool) - object.__setattr__(self, "_manager", None) - object.__setattr__(self, "_pool", pool) - - if thread_pool is not None: - object.__setattr__(self, "_owns_thread_pool", False) - if pool is not None: - object.__setattr__(self, "_owns_process_pool", False) - - def __getstate__( - self, - ) -> tuple[ - tuple[int, Callable[..., tuple[object, ...]], tuple[object, ...]] | None, - PoolProxy, - ]: - """ - Get the state of the Parallel ability for pickling. - - Returns - ------- - tuple[tuple[int, Callable[..., tuple[object, ...]], tuple[object, ...]] | None, PoolProxy] - A tuple containing the thread pool state (or None) and the process pool proxy. - - - """ - if self._thread_pool is None: - return None, self.pool - else: - return ( - ( - self.thread_pool._processes, # type: ignore - self.thread_pool._initializer, # type: ignore - self.thread_pool._initargs, # type: ignore - ), - self.pool, - ) - - def __setstate__( - self, - state: tuple[ - tuple[int, Callable[..., tuple[object, ...]], tuple[object, ...]], PoolProxy - ], - ) -> None: - """ - Set the state of the Parallel ability from pickling. - - Args: - ---- - state: The state of the Parallel ability obtained using __getstate__. - - """ - thread_pool_args, pool = state - if thread_pool_args is None: - object.__setattr__(self, "_thread_pool", None) - else: - object.__setattr__(self, "_thread_pool", ThreadPool(*thread_pool_args)) - - object.__setattr__(self, "_pool", pool) - object.__setattr__(self, "_manager", None) - object.__setattr__(self, "state", "entered") - - def __enter__(self) -> "Parallel": - """Enter the Parallel ability context.""" - object.__setattr__(self, "state", "entered") - return self - - def __exit__( - self, - exc_type: Type[BaseException] | None, - exc_value: BaseException | None, - exc_tb: TracebackType | None, - ) -> None | bool: - """Exit the Parallel ability context.""" - - if self._manager is not None: - if self._owns_process_pool: - self._pool.__exit__(exc_type, exc_value, exc_tb) # type: ignore - self._manager.__exit__(exc_type, exc_value, exc_tb) - if self._thread_pool is not None and self._owns_thread_pool: - self._thread_pool.__exit__(exc_type, exc_value, exc_tb) - object.__setattr__(self, "_thread_pool", None) - object.__setattr__(self, "state", "exited") - - return None - - def run_thread_tasks( - self, - abilities: "Abilities[object]", - tasks: Sequence[Task[object, Exception, object]], - ) -> Sequence[object]: - """ - Run tasks in parallel using threads. - - Args: - ---- - abilities: The abilities to run the tasks with. - tasks: The tasks to run. - - Returns: - ------- - The results of the tasks. - - """ - self.thread_pool.__enter__() - - def _run_task(task: Task[object, Exception, R]) -> R | Exception: - # catch_all because all yielded errors must be returned to the - # main thread in order to be handled - effect = abilities.handle(catch_all(task.f))(*task.args, **task.kwargs) - return run(effect) - - return self.thread_pool.map(_run_task, tasks) - - def run_process_tasks( - self, - abilities: "Abilities[object]", - tasks: Sequence[Task[object, Exception, object]], - ) -> Sequence[object]: - """ - Run tasks in parallel using processes. - - Args: - ---- - abilities: The abilities to run the tasks with. - tasks: The tasks to run. - - Returns: - ------- - The results of the tasks. - - """ - payloads: list[bytes] = [cloudpickle.dumps((abilities, task)) for task in tasks] - return [ - cloudpickle.loads(result) for result in self.pool.map(_run_task, payloads) - ] - - def run( - self, - abilities: "Abilities[object]", - tasks: tuple[Task[object, Exception, object], ...], - ) -> tuple[object, ...] | Exception: - """ - Run tasks in parallel. - - Args: - ---- - abilities: The abilities to run the tasks with. - tasks: The tasks to run. - - Returns: - ------- - The results of the tasks. - - """ - if self.state == "init": - raise RuntimeError("Parallel must be used as a context manager") - if self.state == "exited": - raise RuntimeError("Parallel context manager has already exited") - thread_tasks_and_indices = [ - (i, task) for i, task in enumerate(tasks) if task.use_threads - ] - - if thread_tasks_and_indices: - thread_indices, thread_tasks = zip(*thread_tasks_and_indices) - thread_results = self.run_thread_tasks(abilities, thread_tasks) - for result in thread_results: - if isinstance(result, Exception): - return result - else: - thread_results = () - thread_indices = () - - cpu_tasks_and_indices = [ - (i, task) for i, task in enumerate(tasks) if not task.use_threads - ] - - if cpu_tasks_and_indices: - cpu_indices, cpu_tasks = zip(*cpu_tasks_and_indices) - cpu_results = self.run_process_tasks(abilities, cpu_tasks) - for result in cpu_results: - if isinstance(result, Exception): - return result - else: - cpu_results = () - cpu_indices = () - results: list[object] = [None] * len(tasks) - for i, result in zip(thread_indices, thread_results): - results[i] = result - for i, result in zip(cpu_indices, cpu_results): - results[i] = result - return tuple(results) - - -A1 = TypeVar("A1") -A2 = TypeVar("A2") -A3 = TypeVar("A3") -A4 = TypeVar("A4") -A5 = TypeVar("A5") -A6 = TypeVar("A6") -A7 = TypeVar("A7") -E1 = TypeVar("E1", bound=Exception) -E2 = TypeVar("E2", bound=Exception) -E3 = TypeVar("E3", bound=Exception) -E4 = TypeVar("E4", bound=Exception) -E5 = TypeVar("E5", bound=Exception) -E6 = TypeVar("E6", bound=Exception) -E7 = TypeVar("E7", bound=Exception) -R1 = TypeVar("R1") -R2 = TypeVar("R2") -R3 = TypeVar("R3") -R4 = TypeVar("R4") -R5 = TypeVar("R5") -R6 = TypeVar("R6") -R7 = TypeVar("R7") - - -P = ParamSpec("P") - - -# I'm not sure why this is overload is necessary, but mypy complains without it -@overload -def process( # type: ignore - f: Callable[P, Success[R]], -) -> Callable[P, SuccessTask[R]]: - ... # pragma: no cover - - -@overload -def process( - f: Callable[P, Depend[A, R]], -) -> Callable[P, DependTask[A, R]]: - ... # pragma: no cover - - -@overload -def process( - f: Callable[P, Effect[A, E, R]], -) -> Callable[P, Task[A, E, R]]: - ... # pragma: no cover - - -def process( # type: ignore - f: Callable[P, Effect[object, Exception, object]], -) -> Callable[P, Task[object, Exception, object]]: - """ - Create a task that can be run in parallel using processes. - - Args: - ---- - f: The function to capture as a task. - - Returns: - ------- - `f` decorated to return a task. - - """ - - @wraps(f) - def wrapper(*args: P.args, **kwargs: P.kwargs) -> Task[object, Exception, object]: - return Task( - f, - args, - kwargs, - use_threads=False, - ) - - return wrapper - - -@overload -def thread( # type: ignore - f: Callable[P, Success[R]], -) -> Callable[P, SuccessTask[R]]: - ... # pragma: no cover - - -@overload -def thread( - f: Callable[P, Depend[A, R]], -) -> Callable[P, DependTask[A, R]]: - ... # pragma: no cover - - -@overload -def thread( - f: Callable[P, Effect[A, E, R]], -) -> Callable[P, Task[A, E, R]]: - ... # pragma: no cover - - -def thread( # type: ignore - f: Callable[P, Effect[object, Exception, object]], -) -> Callable[P, Task[object, Exception, object]]: - """ - Create a task that can be run in parallel using threads. - - Args: - ---- - f: The function to capture as a task. - - Returns: - ------- - `f` decorated to return a task. - - """ - - @wraps(f) - def wrapper(*args: P.args, **kwargs: P.kwargs) -> Task[object, Exception, object]: - return Task( - f, - args, - kwargs, - use_threads=True, - ) - - return wrapper - - -@overload -def parallel() -> Effect[Parallel, Never, tuple[()]]: - ... # pragma: no cover - - -@overload -def parallel(t1: Task[A1, E1, R1], /) -> Effect[A1 | Parallel, E1, tuple[R1]]: - ... # pragma: no cover - - -@overload -def parallel( - t1: Task[A1, E1, R1], t2: Task[A2, E2, R2], / -) -> Effect[A1 | A2 | Parallel, E1 | E2, tuple[R1, R2]]: - ... # pragma: no cover - - -@overload -def parallel( - t1: Task[A1, E1, R1], - t2: Task[A2, E2, R2], - t3: Task[A3, E3, R3], - /, -) -> Effect[A1 | A2 | A3 | Parallel, E1 | E2 | E3, tuple[R1, R2, R3]]: - ... # pragma: no cover - - -@overload -def parallel( - *tasks: Task[A1, E1, R1], -) -> Effect[A1 | Parallel, E1, tuple[R1, ...]]: - ... # pragma: no cover - - -def parallel( # type: ignore - *tasks: Task[object, Exception, object], -) -> Effect[Parallel, Exception, tuple[object, ...]]: - """ - Run tasks in parallel. - - If any of the tasks yield an exception, the exception is yielded. - - Args: - ---- - tasks: The tasks to run. - - Returns: - ------- - The results of the tasks. - - """ - ability_instances = cast("tuple[object, ...]", (yield PARALLEL_SENTINEL)) # type: ignore - abilities = Abilities(*ability_instances) - parallel = abilities.get_ability(Parallel) - if not parallel: - raise MissingAbilityError(Parallel) - result = parallel.run(abilities, tasks) - if isinstance(result, Exception): - return (yield from throw(result)) - else: - return result diff --git a/src/stateless/time.py b/src/stateless/time.py index 88b5ae1..e7e354c 100644 --- a/src/stateless/time.py +++ b/src/stateless/time.py @@ -1,16 +1,18 @@ """Contains the Time ability and ability helpers.""" -import time +import asyncio from dataclasses import dataclass +from stateless.async_ import Async, wait from stateless.effect import Depend +from stateless.need import Need, need @dataclass(frozen=True) class Time: """The Time ability.""" - def sleep(self, seconds: float) -> None: + async def sleep(self, seconds: float) -> None: """ Sleep for a number of seconds. @@ -19,10 +21,10 @@ def sleep(self, seconds: float) -> None: seconds: The number of seconds to sleep for. """ - time.sleep(seconds) + await asyncio.sleep(seconds) -def sleep(seconds: float) -> Depend[Time, None]: +def sleep(seconds: float) -> Depend[Need[Time] | Async, None]: """ Sleep for a number of seconds. @@ -35,5 +37,5 @@ def sleep(seconds: float) -> Depend[Time, None]: An effect that sleeps for a number of seconds. """ - time_ = yield Time - time_.sleep(seconds) + time = yield from need(Time) + yield from wait(time.sleep(seconds)) diff --git a/tests/test_abilities.py b/tests/test_abilities.py index 34515ee..5af5091 100644 --- a/tests/test_abilities.py +++ b/tests/test_abilities.py @@ -1,9 +1,8 @@ from dataclasses import dataclass from pytest import raises -from stateless import Abilities, Depend, Effect, depend, parallel, process, run +from stateless import Depend, Effect, Need, need, run, supply from stateless.errors import MissingAbilityError -from stateless.parallel import Parallel from typing_extensions import Never from tests.utils import run_with_abilities @@ -25,60 +24,60 @@ class SubSub(Sub): def test_run_with_unhandled_exception() -> None: - def fails() -> Depend[str, None]: - yield str + def fails() -> Depend[Need[str], None]: + yield from need(str) raise RuntimeError("oops") e = fails() with raises(RuntimeError, match="oops"): - run_with_abilities(e, Abilities("")) + run_with_abilities(e, supply("")) def test_provide_multiple_sub_types() -> None: sub: Super = Sub() subsub: Super = SubSub() - abilities = Abilities().add(subsub).add(sub) - assert run_with_abilities(depend(Super), abilities) == Sub() - abilities = Abilities().add(sub).add(subsub) - assert run_with_abilities(depend(Super), abilities) == SubSub() + abilities = supply(subsub, sub) + assert run_with_abilities(need(Super), abilities) == SubSub() + abilities = supply(sub, subsub) + assert run_with_abilities(need(Super), abilities) == Sub() def test_missing_dependency() -> None: - def effect() -> Depend[Super, Super]: - ability: Super = yield Super + def effect() -> Depend[Need[Super], Super]: + ability: Super = yield from need(Super) return ability with raises(MissingAbilityError, match="Super") as info: run(effect()) # type: ignore - # test that the fourth frame is the yield + # test that the sixth frame is the yield # expression in `effect` function above - # (first is Runtime().run(..) + # (first is stateless.run(..) # second is effect.throw in Runtime.run) - frame = info.traceback[2] + frame = info.traceback[6] assert str(frame.path) == __file__ assert frame.lineno == effect.__code__.co_firstlineno def test_missing_dependency_with_abilities() -> None: - def effect() -> Depend[Super, Super]: - ability: Super = yield Super + def effect() -> Depend[Need[Super], Super]: + ability: Super = yield from need(Super) return ability with raises(MissingAbilityError, match="Super") as info: - run_with_abilities(effect(), Abilities()) + run(effect()) # type: ignore - frame = info.traceback[5] + frame = info.traceback[6] assert str(frame.path) == __file__ assert frame.lineno == effect.__code__.co_firstlineno def test_simple_dependency() -> None: - def effect() -> Depend[str, str]: - ability: str = yield str + def effect() -> Depend[Need[str], str]: + ability: str = yield from need(str) return ability - assert run_with_abilities(effect(), Abilities("hi!")) == "hi!" + assert run_with_abilities(effect(), supply("hi!")) == "hi!" def test_simple_failure() -> None: @@ -90,58 +89,13 @@ def effect() -> Effect[Never, ValueError, None]: run(effect()) -def test_use_effect() -> None: - def effect() -> Depend[str, bytes]: - ability: str = yield str - return ability.encode() - - abilities = Abilities().add("ability").add_effect(effect) - assert run_with_abilities(depend(bytes), abilities) == b"ability" - - -def test_multiple_abilities_with_parallel() -> None: - def f() -> Depend[str | int, tuple[str, int]]: - s = yield from depend(str) - i = yield from depend(int) - return (s, i) - - def g() -> Depend[str | int | Parallel, tuple[str, int]]: - result, *_ = yield from parallel(process(f)()) - return result - - with Parallel() as p: - outer = Abilities().add(0) - inner = Abilities().add("s").add(p) - effect = outer.handle(inner.handle(g))() - assert run(effect) == ("s", 0) - - def test_ability_order_with_multiple_abilities() -> None: - def f() -> Depend[str, str]: - result = yield from depend(str) + def f() -> Depend[Need[str], str]: + result = yield from need(str) return result - outer = Abilities().add("outer") - inner = Abilities().add("inner") + outer = supply("outer") + inner = supply("inner") - effect = outer.handle(inner.handle(f))() + effect = outer(inner(f))() assert run(effect) == "inner" - - -def test_multiple_abilities_without_direct_composition() -> None: - def f() -> Depend[str | int, tuple[str, int]]: - s = yield from depend(str) - i = yield from depend(int) - return (s, i) - - def h() -> Depend[Parallel | str | int, tuple[str, int]]: - result, *_ = yield from parallel(process(f)()) - return result - - def g() -> Depend[Parallel | str, tuple[str, int]]: - abilities = Abilities(0) - s, i = yield from abilities.handle(h)() # type: ignore - return (s, i) - - with Parallel() as p: - assert run(Abilities("s", p).handle(g)()) == ("s", 0) diff --git a/tests/test_console.py b/tests/test_console.py index 7e8a6a2..c194997 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -1,13 +1,13 @@ from unittest.mock import MagicMock, patch from pytest import CaptureFixture -from stateless import Abilities, run +from stateless import run, supply from stateless.console import Console, print_line, read_line def test_print_line(capsys: CaptureFixture[str]) -> None: - abilities = Abilities().add(Console()) - effect = abilities.handle(print_line)("hello") + handle = supply(Console()) + effect = handle(print_line)("hello") run(effect) captured = capsys.readouterr() assert captured.out == "hello\n" @@ -15,7 +15,7 @@ def test_print_line(capsys: CaptureFixture[str]) -> None: @patch("stateless.console.input", return_value="hello") def test_read_line(input_mock: MagicMock) -> None: - abilities = Abilities().add(Console()) - effect = abilities.handle(read_line)("hi!") + handle = supply(Console()) + effect = handle(read_line)("hi!") assert run(effect) == "hello" input_mock.assert_called_once_with("hi!") diff --git a/tests/test_effect.py b/tests/test_effect.py index c8164a2..cafa62b 100644 --- a/tests/test_effect.py +++ b/tests/test_effect.py @@ -3,22 +3,20 @@ from pytest import raises from stateless import ( - Abilities, Effect, Success, Try, catch, - depend, memoize, repeat, retry, run, success, + supply, throw, throws, ) -from stateless.effect import Depend, SuccessEffect -from stateless.errors import MissingAbilityError +from stateless.effect import SuccessEffect from stateless.functions import RetryError from stateless.schedule import Recurs, Spaced from stateless.time import Time @@ -27,7 +25,7 @@ class MockTime(Time): - def sleep(self, seconds: float) -> None: + async def sleep(self, seconds: float) -> None: pass @@ -98,33 +96,12 @@ def effect() -> Never: run(effect()) -def test_depend() -> None: - effect = depend(int) - assert run_with_abilities(effect, Abilities(0)) == 0 - - -def test_depend_missing_ability() -> None: - def effect() -> Depend[int, int]: - return (yield from depend(int)) - - with raises(MissingAbilityError) as info: - run(effect()) # type: ignore - - frame = info.traceback[0] - assert str(frame.path) == __file__ - assert frame.lineno == test_depend_missing_ability.__code__.co_firstlineno + 4 - - frame = info.traceback[2] - assert str(frame.path) == __file__ - assert frame.lineno == test_depend_missing_ability.__code__.co_firstlineno + 1 - - def test_repeat() -> None: @repeat(Recurs(2, Spaced(timedelta(seconds=1)))) def effect() -> Success[int]: return success(42) - assert run_with_abilities(effect(), Abilities(MockTime())) == (42, 42) + assert run_with_abilities(effect(), supply(MockTime())) == (42, 42) def test_repeat_on_error() -> None: @@ -133,7 +110,7 @@ def effect() -> Try[RuntimeError, Never]: return throw(RuntimeError("oops")) with raises(RuntimeError, match="oops"): - run_with_abilities(effect(), Abilities(MockTime())) + run_with_abilities(effect(), supply(MockTime())) def test_retry() -> None: @@ -142,7 +119,7 @@ def effect() -> Try[RuntimeError, Never]: return throw(RuntimeError("oops")) with raises(RuntimeError, match="oops"): - run_with_abilities(effect(), Abilities(MockTime())) + run_with_abilities(effect(), supply(MockTime())) def test_retry_on_eventual_success() -> None: @@ -156,7 +133,7 @@ def effect() -> Effect[Never, RuntimeError, int]: counter += 1 return throw(RuntimeError("oops")) - assert run_with_abilities(effect(), Abilities(MockTime())) == 42 + assert run_with_abilities(effect(), supply(MockTime())) == 42 def test_retry_on_failure() -> None: @@ -165,7 +142,7 @@ def effect() -> Effect[Never, RuntimeError, int]: return throw(RuntimeError("oops")) with raises(RetryError): - run_with_abilities(effect(), Abilities(MockTime())) + run_with_abilities(effect(), supply(MockTime())) def test_memoize() -> None: diff --git a/tests/test_file.py b/tests/test_file.py index 1f4d27d..dcaf823 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -1,11 +1,11 @@ from unittest.mock import mock_open, patch -from stateless import Abilities, run +from stateless import run, supply from stateless.files import Files, read_file def test_read_file() -> None: - effect = Abilities().add(Files()).handle(read_file)("hello.txt") + effect = supply(Files())(read_file)("hello.txt") with patch("builtins.open", mock_open(read_data="hello")) as open_mock: assert run(effect) == "hello" open_mock.assert_called_once_with("hello.txt") diff --git a/tests/test_need.py b/tests/test_need.py new file mode 100644 index 0000000..3fbf4da --- /dev/null +++ b/tests/test_need.py @@ -0,0 +1,26 @@ +from pytest import raises + +from stateless import need, supply, Depend, Need, run +from stateless.errors import MissingAbilityError + +from tests.utils import run_with_abilities + +def test_need() -> None: + effect = need(int) + assert run_with_abilities(effect, supply(0)) == 0 + + +def test_need_missing_ability() -> None: + def effect() -> Depend[Need[int], int]: + return (yield from need(int)) + + with raises(MissingAbilityError) as info: + run(effect()) # type: ignore + + frame = info.traceback[0] + assert str(frame.path) == __file__ + assert frame.lineno == test_need_missing_ability.__code__.co_firstlineno + 4 + + frame = info.traceback[6] + assert str(frame.path) == __file__ + assert frame.lineno == test_need_missing_ability.__code__.co_firstlineno + 1 diff --git a/tests/test_parallel.py b/tests/test_parallel.py deleted file mode 100644 index 4755c16..0000000 --- a/tests/test_parallel.py +++ /dev/null @@ -1,165 +0,0 @@ -import pickle -from multiprocessing import Manager -from multiprocessing.pool import ThreadPool -from typing import Iterator - -import cloudpickle # type: ignore -from pytest import fixture, raises -from stateless import Depend, Effect, Success, catch, success, throws -from stateless.abilities import Abilities -from stateless.errors import MissingAbilityError -from stateless.parallel import Parallel, _run_task, parallel, process, thread - -from tests.utils import run_with_abilities - - -@fixture(scope="module", name="abilities") -def abilities_fixture() -> Iterator[Abilities[Parallel]]: - with Parallel() as p: - yield Abilities().add(p) - - -def test_error_handling(abilities: Abilities[Parallel]) -> None: - @throws(ValueError) - def f() -> Success[str]: - raise ValueError("error") - - def g() -> Effect[Parallel, ValueError, tuple[str]]: - result = yield from parallel(thread(f)()) - return result - - result = run_with_abilities(catch(ValueError)(g)(), abilities) - assert isinstance(result, ValueError) - assert result.args == ("error",) - - -def test_process_error_handling(abilities: Abilities[Parallel]) -> None: - @throws(ValueError) - def f() -> Success[str]: - raise ValueError("error") - - def g() -> Effect[Parallel, ValueError, tuple[str]]: - result = yield from parallel(process(f)()) - return result - - result = run_with_abilities(catch(ValueError)(g)(), abilities) - assert isinstance(result, ValueError) - assert result.args == ("error",) - - -def test_unhandled_errors(abilities: Abilities[Parallel]) -> None: - def f() -> Success[str]: - raise ValueError("error") - - with raises(ValueError, match="error"): - effect = parallel(thread(f)()) - run_with_abilities(effect, abilities) - - -def test_pickling() -> None: - with Parallel() as p: - assert p._thread_pool is None - assert p._manager is None - assert p._pool is None - - p2 = pickle.loads(pickle.dumps(p)) - - assert p._pool is not None - assert p._manager is not None - - assert p2._thread_pool is None - assert p2._manager is None - assert p2._pool is not None - - assert p2._pool._id == p._pool._id - - p.thread_pool # initialize thread pool - p3 = pickle.loads(pickle.dumps(p)) - assert p3._thread_pool is not None - - -def test_cpu_effect(abilities: Abilities[Parallel]) -> None: - @process - def f() -> Success[str]: - return success("done") - - effect = parallel(f()) - result = run_with_abilities(effect, abilities) - assert result == ("done",) - - -def test_io_effect(abilities: Abilities[Parallel]) -> None: - @thread - def f() -> Success[str]: - return success("done") - - effect = parallel(f()) - result = run_with_abilities(effect, abilities) - assert result == ("done",) - - -def ping() -> str: - return "pong" - - -def test_yield_from_parallel(abilities: Abilities[Parallel]) -> None: - def f() -> Success[str]: - return success("done") - - def g() -> Depend[Parallel, tuple[str, str]]: - result = yield from parallel(thread(f)(), process(f)()) - return result - - result = run_with_abilities(g(), abilities) - assert result == ("done", "done") - - -def test_passed_in_resources() -> None: - with Manager() as manager, manager.Pool() as pool, ThreadPool() as thread_pool: - with Parallel(thread_pool, pool) as p: - assert p._manager is None - - # check that Parallel did not close the thread pool or pool - assert thread_pool.apply(ping) == "pong" - assert pool.apply(ping) == "pong" - - -def test_use_before_with(abilities: Abilities[Parallel]) -> None: - task = thread(success)("done") - with raises(RuntimeError, match="Parallel must be used as a context manager"): - run_with_abilities(parallel(task), Abilities(Parallel())) - - -def test_use_after_with() -> None: - with Parallel() as p: - pass - - with raises(RuntimeError, match="Parallel context manager has already exited"): - run_with_abilities(parallel(thread(success)("done")), Abilities(p)) - - -def test_run_task(abilities: Abilities[Parallel]) -> None: - def f() -> Success[str]: - return success("done") - - payload = cloudpickle.dumps((abilities, thread(f)())) - result = _run_task(payload) - assert cloudpickle.loads(result) == "done" - - -def test_run_task_missing_ability() -> None: - def f() -> Success[str]: - return success("done") - - payload = cloudpickle.dumps((Abilities(), thread(f)())) - result = _run_task(payload) - error = cloudpickle.loads(result) - assert isinstance(error, MissingAbilityError) - assert error.args == (Parallel,) - - -def test_parallel_missing_ability() -> None: - task = process(lambda: success("done!"))() - with raises(MissingAbilityError) as info: - run_with_abilities(parallel(task), Abilities()) - assert info.value.args == (Parallel,) diff --git a/tests/test_time.py b/tests/test_time.py index 7064cd2..04463b3 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -1,11 +1,11 @@ from unittest.mock import MagicMock, patch -from stateless import Abilities, run +from stateless import run, supply from stateless.time import Time, sleep -@patch("stateless.time.time.sleep") +@patch("stateless.time.asyncio.sleep") def test_sleep(sleep_mock: MagicMock) -> None: - effect = Abilities().add(Time()).handle(sleep)(1) + effect = supply(Time())(sleep)(1) run(effect) sleep_mock.assert_called_once_with(1) diff --git a/tests/utils.py b/tests/utils.py index aaacdee..e25f068 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,13 +1,13 @@ from typing import TypeVar -from stateless import Abilities, Effect, run +from stateless import Effect, Handler, run R = TypeVar("R") A = TypeVar("A") -def run_with_abilities(effect: Effect[A, Exception, R], abilities: Abilities[A]) -> R: - @abilities.handle +def run_with_abilities(effect: Effect[A, Exception, R], abilities: Handler[A]) -> R: + @abilities def main() -> Effect[A, Exception, R]: result = yield from effect return result From f3abd34b5a740097a8f26f0e9f26c900fa5a2cdb Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Tue, 28 Oct 2025 21:59:10 +0100 Subject: [PATCH 02/31] lint --- poetry.lock | 40 +++++++++++----------- pyproject.toml | 2 +- src/stateless/ability.py | 3 +- src/stateless/async_.py | 72 +++++++++++++++++++++++----------------- src/stateless/handler.py | 1 - src/stateless/need.py | 37 +++++++++++---------- 6 files changed, 84 insertions(+), 71 deletions(-) diff --git a/poetry.lock b/poetry.lock index 8a1bafe..8304bd1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -683,29 +683,31 @@ files = [ [[package]] name = "ruff" -version = "0.1.15" +version = "0.14.2" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "ruff-0.1.15-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5fe8d54df166ecc24106db7dd6a68d44852d14eb0729ea4672bb4d96c320b7df"}, - {file = "ruff-0.1.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f0bfbb53c4b4de117ac4d6ddfd33aa5fc31beeaa21d23c45c6dd249faf9126f"}, - {file = "ruff-0.1.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0d432aec35bfc0d800d4f70eba26e23a352386be3a6cf157083d18f6f5881c8"}, - {file = "ruff-0.1.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9405fa9ac0e97f35aaddf185a1be194a589424b8713e3b97b762336ec79ff807"}, - {file = "ruff-0.1.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c66ec24fe36841636e814b8f90f572a8c0cb0e54d8b5c2d0e300d28a0d7bffec"}, - {file = "ruff-0.1.15-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:6f8ad828f01e8dd32cc58bc28375150171d198491fc901f6f98d2a39ba8e3ff5"}, - {file = "ruff-0.1.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86811954eec63e9ea162af0ffa9f8d09088bab51b7438e8b6488b9401863c25e"}, - {file = "ruff-0.1.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fd4025ac5e87d9b80e1f300207eb2fd099ff8200fa2320d7dc066a3f4622dc6b"}, - {file = "ruff-0.1.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b17b93c02cdb6aeb696effecea1095ac93f3884a49a554a9afa76bb125c114c1"}, - {file = "ruff-0.1.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ddb87643be40f034e97e97f5bc2ef7ce39de20e34608f3f829db727a93fb82c5"}, - {file = "ruff-0.1.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:abf4822129ed3a5ce54383d5f0e964e7fef74a41e48eb1dfad404151efc130a2"}, - {file = "ruff-0.1.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6c629cf64bacfd136c07c78ac10a54578ec9d1bd2a9d395efbee0935868bf852"}, - {file = "ruff-0.1.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1bab866aafb53da39c2cadfb8e1c4550ac5340bb40300083eb8967ba25481447"}, - {file = "ruff-0.1.15-py3-none-win32.whl", hash = "sha256:2417e1cb6e2068389b07e6fa74c306b2810fe3ee3476d5b8a96616633f40d14f"}, - {file = "ruff-0.1.15-py3-none-win_amd64.whl", hash = "sha256:3837ac73d869efc4182d9036b1405ef4c73d9b1f88da2413875e34e0d6919587"}, - {file = "ruff-0.1.15-py3-none-win_arm64.whl", hash = "sha256:9a933dfb1c14ec7a33cceb1e49ec4a16b51ce3c20fd42663198746efc0427360"}, - {file = "ruff-0.1.15.tar.gz", hash = "sha256:f6dfa8c1b21c913c326919056c390966648b680966febcb796cc9d1aaab8564e"}, + {file = "ruff-0.14.2-py3-none-linux_armv6l.whl", hash = "sha256:7cbe4e593505bdec5884c2d0a4d791a90301bc23e49a6b1eb642dd85ef9c64f1"}, + {file = "ruff-0.14.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8d54b561729cee92f8d89c316ad7a3f9705533f5903b042399b6ae0ddfc62e11"}, + {file = "ruff-0.14.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5c8753dfa44ebb2cde10ce5b4d2ef55a41fb9d9b16732a2c5df64620dbda44a3"}, + {file = "ruff-0.14.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d0bbeffb8d9f4fccf7b5198d566d0bad99a9cb622f1fc3467af96cb8773c9e3"}, + {file = "ruff-0.14.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7047f0c5a713a401e43a88d36843d9c83a19c584e63d664474675620aaa634a8"}, + {file = "ruff-0.14.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bf8d2f9aa1602599217d82e8e0af7fd33e5878c4d98f37906b7c93f46f9a839"}, + {file = "ruff-0.14.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1c505b389e19c57a317cf4b42db824e2fca96ffb3d86766c1c9f8b96d32048a7"}, + {file = "ruff-0.14.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a307fc45ebd887b3f26b36d9326bb70bf69b01561950cdcc6c0bdf7bb8e0f7cc"}, + {file = "ruff-0.14.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:61ae91a32c853172f832c2f40bd05fd69f491db7289fb85a9b941ebdd549781a"}, + {file = "ruff-0.14.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1967e40286f63ee23c615e8e7e98098dedc7301568bd88991f6e544d8ae096"}, + {file = "ruff-0.14.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2877f02119cdebf52a632d743a2e302dea422bfae152ebe2f193d3285a3a65df"}, + {file = "ruff-0.14.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e681c5bc777de5af898decdcb6ba3321d0d466f4cb43c3e7cc2c3b4e7b843a05"}, + {file = "ruff-0.14.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e21be42d72e224736f0c992cdb9959a2fa53c7e943b97ef5d081e13170e3ffc5"}, + {file = "ruff-0.14.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b8264016f6f209fac16262882dbebf3f8be1629777cf0f37e7aff071b3e9b92e"}, + {file = "ruff-0.14.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5ca36b4cb4db3067a3b24444463ceea5565ea78b95fe9a07ca7cb7fd16948770"}, + {file = "ruff-0.14.2-py3-none-win32.whl", hash = "sha256:41775927d287685e08f48d8eb3f765625ab0b7042cc9377e20e64f4eb0056ee9"}, + {file = "ruff-0.14.2-py3-none-win_amd64.whl", hash = "sha256:0df3424aa5c3c08b34ed8ce099df1021e3adaca6e90229273496b839e5a7e1af"}, + {file = "ruff-0.14.2-py3-none-win_arm64.whl", hash = "sha256:ea9d635e83ba21569fbacda7e78afbfeb94911c9434aff06192d9bc23fd5495a"}, + {file = "ruff-0.14.2.tar.gz", hash = "sha256:98da787668f239313d9c902ca7c523fe11b8ec3f39345553a51b25abc4629c96"}, ] [[package]] @@ -846,4 +848,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "4957630d074aafc1f910f38d348dd730050db3bf68d1dad09a468e4ca6c8ddd4" +content-hash = "a7d793c47c20ac4d3e5052430b0d698262a87b591a17ec8b54652510de6b4982" diff --git a/pyproject.toml b/pyproject.toml index 6bc8f2f..5ea35f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ ipython = "^8.17.2" pytest = "^7.4.3" pyright = "^1.1.336" pre-commit = "^3.5.0" -ruff = "^0.1.6" +ruff = "^0.14.2" coverage = "^7.3.2" toml = "^0.10.2" diff --git a/src/stateless/ability.py b/src/stateless/ability.py index 2e1975b..5ec5060 100644 --- a/src/stateless/ability.py +++ b/src/stateless/ability.py @@ -1,6 +1,5 @@ -from typing import TypeVar, Self, Generic, Generator - from dataclasses import dataclass +from typing import Generator, Generic, Self, TypeVar from stateless.errors import MissingAbilityError diff --git a/src/stateless/async_.py b/src/stateless/async_.py index 13ab96b..b69767d 100644 --- a/src/stateless/async_.py +++ b/src/stateless/async_.py @@ -1,28 +1,35 @@ -from concurrent.futures.thread import ThreadPoolExecutor -import inspect -from typing import Awaitable, Coroutine, Any, TypeVar, overload, Generic, ParamSpec, Callable -import cloudpickle -from typing_extensions import Never import asyncio -from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor +from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor +from concurrent.futures.thread import ThreadPoolExecutor from dataclasses import dataclass -from functools import partial, wraps +from functools import wraps +from typing import ( + Any, + Awaitable, + Callable, + Coroutine, + Generic, + ParamSpec, + TypeVar, + overload, +) + +import cloudpickle from stateless.ability import Ability -from stateless.effect import Depend, Effect, Success, Try, catch_all, run, throw +from stateless.effect import Depend, Effect, Success, Try, run from stateless.need import Need, need - -P = ParamSpec('P') -R = TypeVar('R') -A = TypeVar('A', bound=Ability) -E = TypeVar('E', bound=Exception) -B = TypeVar('B') +P = ParamSpec("P") +R = TypeVar("R") +A = TypeVar("A", bound=Ability) +E = TypeVar("E", bound=Exception) +B = TypeVar("B") @dataclass(frozen=True) class Task(Generic[R]): - future:asyncio.Future[bytes] | asyncio.Future[R] + future: asyncio.Future[bytes] | asyncio.Future[R] async def get_result(self) -> R: result = await self.future @@ -44,12 +51,13 @@ class Async(Ability[Any]): class Executor: executor: ThreadPoolExecutor | ProcessPoolExecutor - def __init__(self, executor: ThreadPoolExecutor | ProcessPoolExecutor | None = None): + def __init__( + self, executor: ThreadPoolExecutor | ProcessPoolExecutor | None = None + ): if not executor: executor = ThreadPoolExecutor() - object.__setattr__(self, 'executor', executor) - + object.__setattr__(self, "executor", executor) def __enter__(self): self.executor.__enter__() @@ -60,21 +68,25 @@ def __exit__(self, *args, **kwargs): @overload -def fork(f: Callable[P, Success[R]]) -> Callable[P, Depend[Need[Executor], Task[R]]]: ... +def fork( + f: Callable[P, Success[R]], +) -> Callable[P, Depend[Need[Executor], Task[R]]]: ... + @overload def fork(f: Callable[P, Try[E, R]]) -> Callable[P, Depend[Need[Executor], Task[R]]]: ... -@overload -def fork(f: Callable[P, Depend[Async, R]]) -> Callable[P, Depend[Need[Executor], Task[R]]]: ... @overload -def fork(f: Callable[P, Effect[Async, E, R]]) -> Callable[P, Depend[Need[Executor], Task[R]]]: ... - - - +def fork( + f: Callable[P, Depend[Async, R]], +) -> Callable[P, Depend[Need[Executor], Task[R]]]: ... +@overload +def fork( + f: Callable[P, Effect[Async, E, R]], +) -> Callable[P, Depend[Need[Executor], Task[R]]]: ... def process_target(payload: bytes) -> bytes: @@ -83,7 +95,9 @@ def process_target(payload: bytes) -> bytes: return cloudpickle.dumps(result) -def fork(f: Callable[P, Success[R] | Effect[Async, E, R]]) -> Callable[P, Depend[Need[Executor], Task[R]]]: +def fork( + f: Callable[P, Success[R] | Effect[Async, E, R]], +) -> Callable[P, Depend[Need[Executor], Task[R]]]: @wraps(f) def decorator(*args: P.args, **kwargs: P.kwargs) -> Depend[Need[Executor], Task[R]]: def thread_target() -> R: @@ -103,13 +117,11 @@ def thread_target() -> R: @overload -def wait(target: Coroutine[Any, Any, R]) -> Depend[Async, R]: - ... +def wait(target: Coroutine[Any, Any, R]) -> Depend[Async, R]: ... @overload -def wait(target: Task[R]) -> Effect[Async, E, R]: - ... +def wait(target: Task[R]) -> Effect[Async, E, R]: ... def wait(target: Coroutine[Any, Any, R] | Task[R]) -> Effect[Async, E, R]: diff --git a/src/stateless/handler.py b/src/stateless/handler.py index 26e5910..176d089 100644 --- a/src/stateless/handler.py +++ b/src/stateless/handler.py @@ -6,7 +6,6 @@ from stateless.effect import Depend, Effect, Success, Try from stateless.errors import UnhandledAbilityError - E = TypeVar('E', bound=Exception) A = TypeVar('A', covariant=True, bound=Ability) A2 = TypeVar('A2', bound=Ability) diff --git a/src/stateless/need.py b/src/stateless/need.py index ee91557..15903fb 100644 --- a/src/stateless/need.py +++ b/src/stateless/need.py @@ -1,20 +1,20 @@ from dataclasses import dataclass -from typing import TypeVar, Type, Callable, Generic, overload, Never +from typing import Type, TypeVar, overload + from typing_extensions import ParamSpec from stateless.ability import Ability -from stateless.handler import Handler -from stateless.effect import Depend, Effect, Try, Success +from stateless.effect import Depend from stateless.errors import UnhandledAbilityError +from stateless.handler import Handler - -T = TypeVar('T', covariant=True) -T2 = TypeVar('T2') -T3 = TypeVar('T3') -R = TypeVar('R') -P = ParamSpec('P') -E = TypeVar('E', bound=Exception) -A = TypeVar('A', bound=Ability) +T = TypeVar("T", covariant=True) +T2 = TypeVar("T2") +T3 = TypeVar("T3") +R = TypeVar("R") +P = ParamSpec("P") +E = TypeVar("E", bound=Exception) +A = TypeVar("A", bound=Ability) @dataclass(frozen=True) @@ -22,22 +22,23 @@ class Need(Ability[T]): t: Type[T] - def need(t: Type[T]) -> Depend[Need[T], T]: v = yield from Need(t) return v + @overload -def supply(v1: T, /) -> Handler[Need[T]]: - ... # pragma: no cover +def supply(v1: T, /) -> Handler[Need[T]]: ... # pragma: no cover + @overload -def supply(v1: T, v2: T2, /) -> Handler[Need[T] | Need[T2]]: - ... # pragma: no cover +def supply(v1: T, v2: T2, /) -> Handler[Need[T] | Need[T2]]: ... # pragma: no cover + @overload -def supply(v1: T, v2: T2, v3: T3, /) -> Handler[Need[T] | Need[T2] | Need[T3]]: - ... # pragma: no cover +def supply( + v1: T, v2: T2, v3: T3, / +) -> Handler[Need[T] | Need[T2] | Need[T3]]: ... # pragma: no cover def supply(first: T, /, *rest: T2) -> Handler[Need[T] | Need[T2]]: From 068291075fce1d5f187b402a46d4eafcaa1f1e6f Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Tue, 28 Oct 2025 22:01:06 +0100 Subject: [PATCH 03/31] lint --- pyproject.toml | 4 ++-- src/stateless/ability.py | 3 ++- src/stateless/effect.py | 37 +++++++++++++++++-------------------- src/stateless/handler.py | 33 ++++++++++++++++++--------------- 4 files changed, 39 insertions(+), 38 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5ea35f6..281211c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,11 +34,11 @@ warn_return_any = true strict_equality = true disallow_any_generics = true -[tool.ruff] +[tool.ruff.lint] select = ["I", "F", "N", "RUF", "D"] ignore = ["D107", "D213", "D203", "D202", "D212"] -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] "tests/**/*" = ["D100", "D101", "D102", "D103", "D104", "D105", "D107"] diff --git a/src/stateless/ability.py b/src/stateless/ability.py index 5ec5060..2c5d028 100644 --- a/src/stateless/ability.py +++ b/src/stateless/ability.py @@ -3,7 +3,8 @@ from stateless.errors import MissingAbilityError -T = TypeVar('T', covariant=True) +T = TypeVar("T", covariant=True) + @dataclass(frozen=True) class Ability(Generic[T]): diff --git a/src/stateless/effect.py b/src/stateless/effect.py index d6e780d..5a1f99a 100644 --- a/src/stateless/effect.py +++ b/src/stateless/effect.py @@ -1,4 +1,5 @@ """Contains the Effect type and core functions for working with effects.""" + from __future__ import annotations import asyncio @@ -133,35 +134,33 @@ class Catch(Generic[E]): errors: tuple[Type[E], ...] @overload - def __init__(self: "Catch[Never]"): - ... # pragma: no cover + def __init__(self: "Catch[Never]"): ... # pragma: no cover @overload - def __init__(self, *errors: Type[E]): - ... # pragma: no cover + def __init__(self, *errors: Type[E]): ... # pragma: no cover def __init__(self, *errors: Type[E]): object.__setattr__(self, "errors", errors) @overload - def __call__(self, f: Callable[P, Try[E, R]]) -> Callable[P, Success[R | E]]: - ... # pragma: no cover + def __call__( + self, f: Callable[P, Try[E, R]] + ) -> Callable[P, Success[R | E]]: ... # pragma: no cover @overload def __call__( # pyright: ignore[reportOverlappingOverload] self, f: Callable[P, Effect[A, E, R]] - ) -> Callable[P, Depend[A, R | E]]: - ... # pragma: no cover + ) -> Callable[P, Depend[A, R | E]]: ... # pragma: no cover @overload - def __call__(self, f: Callable[P, Try[E | E2, R]]) -> Callable[P, Try[E2, E | R]]: - ... # pragma: no cover + def __call__( + self, f: Callable[P, Try[E | E2, R]] + ) -> Callable[P, Try[E2, E | R]]: ... # pragma: no cover @overload def __call__( self, f: Callable[P, Effect[A, E2 | E, R]] - ) -> Callable[P, Effect[A, E2, R | E]]: - ... # pragma: no cover + ) -> Callable[P, Effect[A, E2, R | E]]: ... # pragma: no cover def __call__( self, f: Callable[P, Effect[A, E2 | E, R]] @@ -195,13 +194,11 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> Depend[A, E | R]: @overload -def catch() -> Catch[Never]: - ... # pragma: no cover +def catch() -> Catch[Never]: ... # pragma: no cover @overload -def catch(*errors: Type[E]) -> Catch[E]: - ... # pragma: no cover +def catch(*errors: Type[E]) -> Catch[E]: ... # pragma: no cover def catch(*errors: Type[E]) -> Catch[E]: @@ -312,8 +309,7 @@ def throw(self, value: Exception, /) -> Type[A] | E: # type: ignore @overload def memoize( f: Callable[P, Effect[A, E, R]], -) -> Callable[P, Effect[A, E, R]]: - ... # pragma: no cover +) -> Callable[P, Effect[A, E, R]]: ... # pragma: no cover @overload @@ -321,8 +317,9 @@ def memoize( *, maxsize: int | None = None, typed: bool = False, -) -> Callable[[Callable[P, Effect[A, E, R]]], Callable[P, Effect[A, E, R]]]: - ... # pragma: no cover +) -> Callable[ + [Callable[P, Effect[A, E, R]]], Callable[P, Effect[A, E, R]] +]: ... # pragma: no cover def memoize( # type: ignore diff --git a/src/stateless/handler.py b/src/stateless/handler.py index 176d089..bf8af53 100644 --- a/src/stateless/handler.py +++ b/src/stateless/handler.py @@ -6,11 +6,11 @@ from stateless.effect import Depend, Effect, Success, Try from stateless.errors import UnhandledAbilityError -E = TypeVar('E', bound=Exception) -A = TypeVar('A', covariant=True, bound=Ability) -A2 = TypeVar('A2', bound=Ability) -R = TypeVar('R') -P = ParamSpec('P') +E = TypeVar("E", bound=Exception) +A = TypeVar("A", covariant=True, bound=Ability) +A2 = TypeVar("A2", bound=Ability) +R = TypeVar("R") +P = ParamSpec("P") @dataclass(frozen=True) @@ -18,26 +18,28 @@ class Handler(Generic[A]): # Sadly, complete type safety here requires higher-kinded types. on: Callable[[A], Any] - - @overload - def __call__(self, f: Callable[P, Depend[A, R]]) -> Callable[P, Success[R]]: - ... # pragma: no cover + def __call__( + self, f: Callable[P, Depend[A, R]] + ) -> Callable[P, Success[R]]: ... # pragma: no cover @overload def __call__(self, f: Callable[P, Depend[A | A2, R]]) -> Callable[P, Depend[A2, R]]: # pyright: ignore[reportOverlappingOverload] ... # pragma: no cover @overload - def __call__(self, f: Callable[P, Effect[A, E, R]]) -> Callable[P, Try[E, R]]: - ... # pragma: no cover + def __call__( + self, f: Callable[P, Effect[A, E, R]] + ) -> Callable[P, Try[E, R]]: ... # pragma: no cover @overload - def __call__(self, f: Callable[P, Effect[A2 | A, E, R]]) -> Callable[P, Effect[A2, E, R]]: - ... # pragma: no cover + def __call__( + self, f: Callable[P, Effect[A2 | A, E, R]] + ) -> Callable[P, Effect[A2, E, R]]: ... # pragma: no cover - - def __call__(self, f: Callable[P, Effect[A, E, R] | Effect[A | A2, E, R]]) -> Callable[P, Try[E, R] | Effect[A2, E, R]]: + def __call__( + self, f: Callable[P, Effect[A, E, R] | Effect[A | A2, E, R]] + ) -> Callable[P, Try[E, R] | Effect[A2, E, R]]: @wraps(f) def decorator( *args: P.args, **kwargs: P.kwargs @@ -60,4 +62,5 @@ def decorator( ability_or_error = effect.send(value) except StopIteration as e: return cast(R, e.value) + return decorator From 2ca0b212af1d62e90379dee7fa8003ed81d5f759 Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Wed, 29 Oct 2025 12:06:02 +0100 Subject: [PATCH 04/31] update readme --- README.md | 526 ++++++++++++++------------------------ flake.nix | 2 +- poetry.lock | 10 +- src/stateless/__init__.py | 2 +- src/stateless/ability.py | 2 - src/stateless/handler.py | 53 +++- 6 files changed, 239 insertions(+), 356 deletions(-) diff --git a/README.md b/README.md index 6a72990..f055366 100644 --- a/README.md +++ b/README.md @@ -69,18 +69,17 @@ def print_file(path: str) -> Effect[Need[Files] | Need[Console], Never, None]: # Effects are run using `stateless.run`. -# the `Need` ability is handled using `stateless.supply` +# The `Need` ability is handled using `stateless.supply`. # Before an effect can be executed with `run`, it must have # all of its abilities handled. -handle = supply(Files(), Console()) -effect = handle(print_file)('foo.txt') +effect = supply(Files(), Console())(print_file)('foo.txt') run(effect) ``` # Guide -## Effects & Abilities +## Effects & Abilities & Handlers `stateless` is a functional effect system for Python built around a pattern using [generator functions](https://docs.python.org/3/reference/datamodel.html#generator-functions). When programming with `stateless` you will describe your program's side-effects using the `stateless.Effect` type. `Effect` is in fact just a type alias for a generator: @@ -99,88 +98,230 @@ type Effect[A: Ability, E: Exception, R] = Generator[A | E, Any, R] - The type parameter `R` stands for _"Result"_. This is the type of value that an `Effect` will produce if no errors occur. - We'll see shortly why the _"send"_ type of effects must be `Any`, and how `stateless` can still provide good type inference. +Lets start by defining a simple ability. `stateless.Ability` is defined as: +```python +class Ability[R]: + ... +``` +The `R` type parameter represents the expected result type of handling the effect. For example: + +```python +from dataclasses import dataclass + +from stateless import Ability + +@dataclass +class Greet(Ability[str]): + name: str +``` + +When `Greet` inherits from `Ability[str]`, it means that when a function yields an instance of `Greet`, it expects to be sent a `str` value back. + +Let's use `Greet`: -Lets start with a very simple example of an `Effect`: ```python from typing import Never from stateless import Effect -def hello_world() -> Effect[str, Never, None]: - message = yield str - print(message) +def hello_world() -> Effect[Greet, Never, None]: + greeting = yield Greet(name="world") + print(greeting) ``` -When `hello_world` returns an `Effect[str, Never, None]`, it means that it depends on a `str` instance being sent to produce its value (`A` is parameterized with `str`). It can't fail (`E` is parameterized with `Never`), and it doesn't produce a value (`R` is parameterized with `None`). +When `hello_world` returns an `Effect[Greet, Never, None]`, it means that it depends on the `Greet` ability (`A` is parameterized with `Greet`). It can't fail (`E` is parameterized with `Never`), and it doesn't produce a value (`R` is parameterized with `None`). -To run an `Effect` that depends on abilities, you need an instance of `stateless.Abilities`. The purpose of `Abilities` is to provide abilities to effects. To achieve this, you'll primarily work with two methods of `Abilities`: `Abilities.add` and `Abilities.handle`. Let's look at their definitions: +To run an `Effect` that depends on abilities, you need to handle the abilities. Abilities are handled using `stateless.Handler`, defined as: +```python +class Handler[A: Ability]: + def __call__[**P, A2: Ability, E: Exception, R]( + self, + f: Callable[P, Effect[A | A2, E, R]] + ) -> Callable[P, Effect[A2, E, R]]: + ... +``` + +Just like the paramater `A` of `Effect`, The type parameter `A` of `Handler` stands for "Ability". This is the type of abilities that this `Handler` instance can handle. + +`Handler.__call__` is a decorator that accepts a function that returns a `stateless.Effect` that depends on abilities `A` and `A2`, and returns a new function that returns +an effect that only depends on ability `A2`. In other words, the ability `A` is handled by `Handler` and the decorated function now produces an effect that no longer depends on `A`. + +For example, we can use `Handler` to handle the `Greet` ability required by `hello_world`. `stateless.handle` is a straight-forward way to create handlers: ```python -from stateless import Effect +from stateless import handle -class Ability[A]: - def add[A2](self, ability: A2) -> Ability[A | A2]: - ... +def greet(ability: Greet) -> str: + return f"Hello, {ability.name}!" - def handle[**P, A2, E: Exception, R](self, Callable[P, Effect[A | A2, E, R]]) -> Callable[P, Effect[A2, E, R]]: - ... + +effect = handle(greet)(hello_world)() +reveal_type(effect) # revealed type is: Effect[Never, Never, None] ``` -Just like the paramater `A` of `Effect`, The type parameter `A` of `Abilities` stands for "Ability". This is the type of abilities that this `Abilities` instance can provide. -`Abilities.add` takes an instance of `A2`, an ability instance. Adding the ability enables the resulting `Abilities` instance to provide `ability` to an effect that depends on it. +> [!NOTE] +> `stateless.handle` depends on type annotations of the handler function to match abilities with handler functions. To use `stateless.handle` you must annotate the argument of the handler function with an appropriate ability. -`Abilities.handle` is a decorator that accepts a function that returns a `stateless.Effect` that depends on abilities `A` and `A2`, and returns a new function that returns -an effect that only depends on ability `A2`. In other words, the ability `A` is handled by `Abilities` and the decorated function now produces an effect that no longer depends on `A`. +We can see in the revealed type how `handle(greet)` has eliminated the `Greet` ability from the effect returned by `hello_world`, and the type is now `Never`, meaning the new effect does not require any abilities. + +To run effects you'll use `stateless.run`. Its type signature is: -For example, we can use `Abilities` to handle the `str` ability required by `hello_world`: ```python -abilities = Abilities().add('Hello world!') -effect = abilities.handle(hello_world)() -reveal_type(effect) # revealed type is: Effect[Never, Never, None] +def run[R](effect: Effect[Async, Exception, R]) -> R: + ... ``` -We can see in the revealed type how `abilities.handle` has eliminated the `str` ability from the effect returned by `hello_world`, and the type is now `Never`, meaning the new effect -does not require any abilities. +In words: the effect passed to `run` must have had all of its abilities handled (except the built-in `Async` ability. Don't worry about this for now, we'll explain it later). The result of running `effect` is the result type `R`. -To run effects you'll use `stateless.run`. Its type signature is: +If we try to do: +```python +from stateless import run + + +run(hello_world()) # type-checker error! +``` + +We'll get a type-checker error since we can't run an effect with unhandled abilities. + +Lets try this instead: + +```python +effect = handle(greet)(hello_world)() +run(effect) # outputs: Hello, world! +``` +Cool. Okay maybe not. The `hello_world` example is obviously contrived. There's no real benefit to sending `greeting` to `hello_world` via `yield` over just providing it as a regular function argument. The example is included here just to give you a rough idea of how the different pieces of `stateless` fit together. + +## Error Handling + +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.` + +Take the `Files` ability from the previous section for example. Reading from the file system can of course fail for a number of reasons, which in Python will result in a subtype of `OSError` being raised. So calling for example `print_file` might raise an exception: + +```python +from stateless import Depend + + +def f() -> Depend[Files, None]: + yield from print_file('doesnt_exist.txt') # raises FileNotFoundError +``` +So what's the point of `E`? + +The point is that programming errors can be grouped into two categories: recoverable errors and unrecoverable errors. Recoverable errors are errors that are expected, and that users of the API we are writing might want to know about. `FileNotFoundError` is an example of such an error. + +Unrecoverable errors are errors that there is no point in telling the users of your API about. + +The intended use of `E` is to model recoverable errors so that users of your API can handle them with type safety. + +Let's use `E` to model the errors of `Files.read_file`: + + +```python +from stateless import Effect, throw + + +def read_file(path: str) -> Effect[Files, OSError, str]: + files = yield Files + try: + return files.read_file(path) + except OSError as e: + return (yield from throw(e)) +``` + +The signature of `stateless.throw` is + +```python +from typing import Never + +from stateless import Effect + + +def throw[E: Exception](e: E) -> Effect[Never, E, Never]: + ... +``` +In words `throw` returns an effect that just yields `e` and never returns. Because of this signature, if you assign the result of `throw` to a variable, you have to annotate it. But there is no meaningful type +to annotate it with. So you're better off using the somewhat strange looking syntax `return (yield from throw(e))`. + +More conveniently you can use `stateless.throws` that just catches exceptions and yields them as an effect + +```python +from stateless import Depend, throws + + +@throws(OSError) +def read_file(path: str) -> Depend[Need[Files], str]: + files = yield from need(Files) + return files.read_file(path) + + +reveal_type(read_file) # revealed type is: def (str) -> Effect[Files, OSError, str] +``` +Error handling in `stateless` is done using the `stateless.catch` decorator. Its signature is: ```python -def run[R](effect: Effect[Never, Exception, R]) -> R: +from typing import Type +from stateless import Effect, Depend + + +def catch[**P, A, E: Exception, E2: Exception, R]( + *errors: Type[E] +) -> Callable[ + [Callable[P, Effect[A, E | E2, R]]], + Callable[P, Effect[A, E2, E | R]] +]: ... ``` -In words: the effect passed to `run` must have had all of its abilities handled (`A` is `Never`). The result of running `effect` is the result type `R`. +In words, the `catch` decorator catches errors of type `E` and moves the error from the error type `E` of the `Effect` produced by the decorated function, to the result type `R` of the effect of the return function. This means you can access the potential errors directly in your code: -Let's run `hello_world`: ```python -from stateless import run, Abilities +from stateless import Depend + + +def handle_errors() -> Depend[Files, str]: + result: OSError | str = yield from catch(OSError)(read_file)('foo.txt') + match result: + case OSError(): + return 'default value' + case _: + return result -abilities = Abilities().add(b'Hello world!') -effect = abilities.handle(hello_world)() -run(effect) # type-checker error! ``` -Whoops! We accidentally provided an instance of `bytes` instead of `str`, which was required by `hello_world`: Since `abilities` does not provide -an instance of `str`, `abilities.handle(hello_world)()` has type `Depend[str, None]`. Let's try again: +(You don't need to annotate the type of `result`, it can be inferred by your type checker. We do it here simply because its instructive to look at the types.) + +Consequently you can use your type checker to avoid unintentionally unhandled errors, or ignore them with type-safety as you please. + + +`catch` can also catch a subset of errors produced by effects, and pass other errors up the call stack, just like when using regular exceptions. But unlike when using regular exceptions, +your type checker can see and understand which errors are handled where: ```python -from stateless import run, Abilities +def fails_in_multiple_ways() -> Try[FileNotFoundError | PermissionError | IsADirectoryError, str]: + ... -abilities = Abilities().add('Hello world!') -effect = abilities.handle(hello_world)() -run(effect) # outputs: Hello, world! +def handle_subset_of_errors() -> Try[PermissionError, str]: + result = yield from catch(FileNotFoundError, IsADirectoryError)(fails_in_multiple_ways)() + match result: + case FileNotFoundError() | IsADirectoryError(): + return 'default value' + case _: + return result ``` -Cool. Okay maybe not. The `hello_world` example is obviously contrived. There's no real benefit to sending `message` to `hello_world` via `yield` over just providing it as a regular function argument. The example is included here just to give you a rough idea of how the different pieces of `stateless` fit together. -One thing to note is that the `A` type parameter of `Effect` and `Abilities` work together to ensure type safe dependency injection of abilities: You can't forget to provide an ability (or dependency if you will) to an effect without getting a type error when trying to run it. We'll discuss in more detail later when it makes sense to use abilities for dependency injection, and when it makes sense to use regular function arguments. +This means that: +- You can't neglect to report an error in the signature for `handle_subset_of_errors` since your type checker can tell that `yield from catch(...)(fails_in_multiple_ways)` will still yield `PermissionError` +- You can't neglect to handle errors in your code because your type checker can tell that `result` may be 2 different errors or a string. + +## Built in Abilities + +### Need Let's look at a bigger example. The main point of a purely functional effect system is to enable side-effects such as IO in a purely functional way. So let's implement some abilities for doing side-effects. @@ -191,12 +332,12 @@ class Console: def print(self, line: str) -> None: print(line) ``` -We can use `Console` with `Effect` as an ability. Recall that the _"send"_ type of `Effect` is `Any`. In order to tell our type checker that the result of yielding the `Console` class will be a `Console` instance, we can use the `stateless.depend` function. Its signature is: +We can use `Console` with `Effect` as an ability. Recall that the _"send"_ type of `Effect` is `Any`. In order to tell our type checker that the result of yielding the `Console` class will be a `Console` instance, we can use the `stateless.need` function. Its signature is: ```python from typing import Type -from stateless import Depend +from stateless import Depend, Need def depend[A](ability: Type[A]) -> Depend[A, A]: @@ -431,301 +572,8 @@ Abilities().add_effect(get_B()) # Type-checker error! (It will often make sense to use an `abc.ABC` as your ability types to enforce programming towards the interface and not the implementation. If you use `mypy` however, note that [using abstract classes where `typing.Type` is expected is a type-error](https://github.com/python/mypy/issues/4717), which will cause problems if you pass an abstract type to `depend`. We recommend disabling this check, which will also likely be the default for `mypy` in the future.) -## Error Handling - -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.` - -Take the `Files` ability from the previous section for example. Reading from the file system can of course fail for a number of reasons, which in Python will result in a subtype of `OSError` being raised. So calling for example `print_file` might raise an exception: - -```python -from stateless import Depend - - -def f() -> Depend[Files, None]: - yield from print_file('doesnt_exist.txt') # raises FileNotFoundError -``` -So what's the point of `E`? - -The point is that programming errors can be grouped into two categories: recoverable errors and unrecoverable errors. Recoverable errors are errors that are expected, and that users of the API we are writing might want to know about. `FileNotFoundError` is an example of such an error. - -Unrecoverable errors are errors that there is no point in telling the users of your API about. - -The intended use of `E` is to model recoverable errors so that users of your API can handle them with type safety. - -Let's use `E` to model the errors of `Files.read_file`: +### Async - -```python -from stateless import Effect, throw - - -def read_file(path: str) -> Effect[Files, OSError, str]: - files = yield Files - try: - return files.read_file(path) - except OSError as e: - return (yield from throw(e)) -``` - -The signature of `stateless.throw` is - -```python -from typing import Never - -from stateless import Effect - - -def throw[E: Exception](e: E) -> Effect[Never, E, Never]: - ... -``` -In words `throw` returns an effect that just yields `e` and never returns. Because of this signature, if you assign the result of `throw` to a variable, you have to annotate it. But there is no meaningful type -to annotate it with. So you're better off using the somewhat strange looking syntax `return (yield from throw(e))`. - -More conveniently you can use `stateless.throws` that just catches exceptions and yields them as an effect - -```python -from stateless import Depend, throws - - -@throws(OSError) -def read_file(path: str) -> Depend[Files, str]: - files = yield Files - return files.read_file(path) - - -reveal_type(read_file) # revealed type is: def (str) -> Effect[Files, OSError, str] -``` - -Error handling in `stateless` is done using the `stateless.catch` decorator. Its signature is: - -```python -from typing import Type -from stateless import Effect, Depend - - -def catch[**P, A, E: Exception, E2: Exception, R]( - *errors: Type[E] -) -> Callable[ - [Callable[P, Effect[A, E | E2, R]]], - Callable[P, Effect[A, E2, E | R]] -]: - ... -``` - -In words, the `catch` decorator catches errors of type `E` and moves the error from the error type `E` of the `Effect` produced by the decorated function, to the result type `R` of the effect of the return function. This means you can access the potential errors directly in your code: - - -```python -from stateless import Depend - - -def handle_errors() -> Depend[Files, str]: - result: OSError | str = yield from catch(OSError)(read_file)('foo.txt') - match result: - case OSError(): - return 'default value' - case _: - return result - -``` -(You don't need to annotate the type of `result`, it can be inferred by your type checker. We do it here simply because its instructive to look at the types.) - -Consequently you can use your type checker to avoid unintentionally unhandled errors, or ignore them with type-safety as you please. - - -`catch` can also catch a subset of errors produced by effects, and pass other errors up the call stack, just like when using regular exceptions. But unlike when using regular exceptions, -your type checker can see and understand which errors are handled where: - -```python -def fails_in_multiple_ways() -> Try[FileNotFoundError | PermissionError | IsADirectoryError, str]: - ... - -def handle_subset_of_errors() -> Try[PermissionError, str]: - result = yield from catch(FileNotFoundError, IsADirectoryError)(fails_in_multiple_ways)() - match result: - case FileNotFoundError() | IsADirectoryError(): - return 'default value' - case _: - return result -``` - -This means that: -- You can't neglect to report an error in the signature for `handle_subset_of_errors` since your type checker can tell that `yield from catch(...)(fails_in_multiple_ways)` will still yield `PermissionError` -- You can't neglect to handle errors in your code because your type checker can tell that `result` may be 2 different errors or a string. - - -`catch` is a good example of a pattern used in many places in `stateless`: using decorators to change the result of an effect. The reason for this pattern is that generators are mutable objects. - -For example, we could have defined catch like this: - - -```python -def bad_catch(effect: Effect[A, E, R]) -> Depend[A, E | R]: - ... -``` - -But with this signature, it would not be possible to implement `bad_catch` without mutating `effect` as a side-effect, since it's necessary to yield from it to implement catching. - -In general, it's not a good idea to write functions that take effects as arguments directly, because it's very easy to accidentally mutate them which would be confusing for the caller: - - -```python -def f() -> Depend[str, int]: - ... - - -def dont_do_this(e: Depend[str, int]) -> Depend[str, int]: - i = yield from e - return i - - -def this_is_confusing() -> Depend[str, tuple[int, int]]: - e = f() - r = yield from dont_do_this(e) - r_again = yield from e # e was already exhausted, so 'r_again' is None! - return (r, r_again) -``` -A better idea is to write a decorator that accepts a function that returns effects. That way there is no risk of callers passing generators and then accidentally mutating them as a side effect: - -```python -def do_this_instead[**P](f: Callable[P, Depend[str, int]]) -> Callable[P, Depend[str, int]]: - @wraps(f) - def decorator(*args: P.args, **kwargs: P.kwargs) -> Depend[str, int]: - i = yield from f(*args, **kwargs) - return i - return decorator - - -def this_is_easy(): - e = f() - r = yield from do_this_instead(f)() - r_again = yield from e - return (r, r_again) - -``` - -## Parallel Effects -Two challenges present themselves when running generator based effects in parallel: - -- Generators aren't thread-safe. -- Generators can't be pickled. - -Hence, instead of sharing effects between threads and processes to run them in parallel, `stateless` gives you tools to share _functions_ that return effects plus _arguments_ to those functions between threads and processes. - -`stateless` calls a function that returns an effect plus arguments to pass to that function a _task_, represented by the `stateless.parallel.Task` class. - -`stateless` provides two decorators for instantiating `Task` instances: `stateless.parallel.thread` and `stateless.parallel.process`. Their signatures are: - - -```python -from typing import Callable - -from stateless import Effect -from stateless.parallel import Task - - -def process[**P, A, E: Exception, R](f: Callable[P, Effect[A, E, R]]) -> Callable[P, Task[A, E, R]]: - ... - -def thread[**P, A, E: Exception, R](f: Callable[P, Effect[A, E, R]]) -> Callable[P, Task[A, E, R]]: - ... -``` -Decorating functions with `stateless.parallel.thread` indicate to `stateless` your intention for the resulting task to be run in a separate thread. Decorating functions with `stateless.parallel.process` indicate your intention for the resulting task to be run in a separate process. - -Because of the [GIL](https://en.wikipedia.org/wiki/Global_interpreter_lock), using `stateless.parallel.thread` only makes sense for functions returning effects that are [I/O bound](https://en.wikipedia.org/wiki/I/O_bound). For CPU bound effects, you will want to use `stateless.parallel.process`. - -To run effects in parallel, you use the `stateless.parallel` function. It's signature is roughly: - - -```python -from stateless import Effect -from stateless.parallel import Parallel - - -def parallel[A, E: Exception, R](*tasks: Task[A, E, R]) -> Effect[A | Parallel, E, tuple[R, ...]]: - ... -``` -(in reality `parallel` is overloaded to correctly union abilities and errors, and reflect the result types of each effect in the result type of the returned effect.) - -In words, `parallel` accepts a variable number of tasks as its argument, and returns a new effect that depends on the `stateless.parallel.Parallel` ability. When executed, the effect returned by `parallel` will run the tasks given as its arguments concurrently. - - -Here is a full example: -```python -from stateless import parallel, Success, success, Depend -from stateless.parallel import thread, process, Parallel - - -def sing() -> Success[str]: - return success("🎵") - - -def duet() -> Depend[Parallel, tuple[str, str]]: - result = yield from parallel( - thread(sing)(), - process(sing)() - ) - return result -``` -When using the `Parallel` ability, you must use it as a context manager, because it manages multiple resources to enable concurrent execution of effects: -```python -from stateless import Abilities, run -from stateless.parallel import Parallel - - -with Parallel() as ability: - effect = Abilities().add(ability).handle(duet)() - result = run(effect) - print(result) # outputs: ("🎵", "🎵") -``` - -In this example the first `sing` invocation will be run in a separate thread because its wrapped with `thread`, and the second `sing` invocation will be run in a separate process because it's wrapped by `process`. Note that although `thread` and `process` are strictly speaking decorators, they don't return `stateless.Effect` instances. For this reason, it's probably not a good idea to use them as `@thread` or `@process`, since this -reduces the re-usability of the decorated function. Use them at the call site as shown in the example instead. - - -If you need more control over the resources managed by `stateless.parallel.Parallel`, you can pass them as arguments: -```python -from multiprocessing.pool import ThreadPool -from multiprocessing import Manager - -from stateless.parallel import Parallel - - -with ( - Manager() as manager, - manager.Pool() as pool, - ThreadPool() as thread_pool, - Parallel(thread_pool, pool) as parallel -): - ... -``` -The process pool used to execute `stateless.parallel.Task` instances needs to be run with a manager because it needs to be sent to the process executing the task in case it needs to run more -effects in other processes. - -Note that if you pass in in the thread pool and proxy pool as arguments, `stateless.parallel.Parallel` will not exit them for you when it itself exits: you need to manage their state yourself. - - -You can of course subclass `stateless.parallel.Parallel` to change the interpretation of this ability (for example in tests). The two main functions you'll want to override is `run_thread_tasks` and `run_cpu_tasks`: - -```python -from stateless import Abilities, Effect, run -from stateless.parallel import Parallel, Task - - -class MockParallel(Parallel): - def __init__(self): - pass - - def run_cpu_tasks(self, - abilities: Abilities[object], - tasks: Sequence[Task[object, Exception, object]]) -> Tuple[object, ...]: - return tuple(run(abilities.handle(task.f)(*task.args, **task.kwargs) for task in tasks) - - def run_thread_tasks(self - abilities: Abilities[object], - effects: Sequence[Effect[object, Exception, object]]) -> Tuple[object, ...]: - return tuple(run(abilities.handle(task.f)(*task.args, **task.kwargs) for task in tasks) -``` ## Repeating and Retrying Effects A `stateless.Schedule` is a type with an `__iter__` method that returns an effect producing an iterator of `timedelta` instances. It's defined like: diff --git a/flake.nix b/flake.nix index d33c246..b7da232 100644 --- a/flake.nix +++ b/flake.nix @@ -12,7 +12,7 @@ { devShells.default = pkgs.mkShell { packages = [ - pkgs.python312 + pkgs.python313 pkgs.just pkgs.poetry ]; diff --git a/poetry.lock b/poetry.lock index 8304bd1..309e9ea 100644 --- a/poetry.lock +++ b/poetry.lock @@ -802,14 +802,14 @@ test = ["argcomplete (>=3.0.3)", "mypy (>=1.6.0)", "pre-commit", "pytest (>=7.0, [[package]] name = "typing-extensions" -version = "4.8.0" -description = "Backported and Experimental Type Hints for Python 3.8+" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "typing_extensions-4.8.0-py3-none-any.whl", hash = "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0"}, - {file = "typing_extensions-4.8.0.tar.gz", hash = "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef"}, + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] [[package]] diff --git a/src/stateless/__init__.py b/src/stateless/__init__.py index 74c947d..b796238 100644 --- a/src/stateless/__init__.py +++ b/src/stateless/__init__.py @@ -18,6 +18,6 @@ throws, ) from stateless.functions import repeat, retry -from stateless.handler import Handler +from stateless.handler import Handler, handle from stateless.need import Need, need, supply from stateless.schedule import Schedule diff --git a/src/stateless/ability.py b/src/stateless/ability.py index 2c5d028..f063263 100644 --- a/src/stateless/ability.py +++ b/src/stateless/ability.py @@ -1,4 +1,3 @@ -from dataclasses import dataclass from typing import Generator, Generic, Self, TypeVar from stateless.errors import MissingAbilityError @@ -6,7 +5,6 @@ T = TypeVar("T", covariant=True) -@dataclass(frozen=True) class Ability(Generic[T]): def __iter__(self: Self) -> Generator[Self, T, T]: try: diff --git a/src/stateless/handler.py b/src/stateless/handler.py index bf8af53..5578ba6 100644 --- a/src/stateless/handler.py +++ b/src/stateless/handler.py @@ -1,6 +1,15 @@ from dataclasses import dataclass from functools import wraps -from typing import Any, Callable, Generic, ParamSpec, TypeVar, cast, overload +from typing import ( + Any, + Callable, + Generic, + ParamSpec, + TypeVar, + cast, + get_type_hints, + overload, +) from stateless.ability import Ability from stateless.effect import Depend, Effect, Success, Try @@ -19,23 +28,22 @@ class Handler(Generic[A]): on: Callable[[A], Any] @overload - def __call__( - self, f: Callable[P, Depend[A, R]] - ) -> Callable[P, Success[R]]: ... # pragma: no cover + def __call__(self, f: Callable[P, Depend[A, R]]) -> Callable[P, Success[R]]: + ... # pragma: no cover @overload def __call__(self, f: Callable[P, Depend[A | A2, R]]) -> Callable[P, Depend[A2, R]]: # pyright: ignore[reportOverlappingOverload] ... # pragma: no cover @overload - def __call__( - self, f: Callable[P, Effect[A, E, R]] - ) -> Callable[P, Try[E, R]]: ... # pragma: no cover + def __call__(self, f: Callable[P, Effect[A, E, R]]) -> Callable[P, Try[E, R]]: + ... # pragma: no cover @overload def __call__( self, f: Callable[P, Effect[A2 | A, E, R]] - ) -> Callable[P, Effect[A2, E, R]]: ... # pragma: no cover + ) -> Callable[P, Effect[A2, E, R]]: + ... # pragma: no cover def __call__( self, f: Callable[P, Effect[A, E, R] | Effect[A | A2, E, R]] @@ -64,3 +72,32 @@ def decorator( return cast(R, e.value) return decorator + + +def handle(f: Callable[[A], Any]) -> Handler[A]: + d = get_type_hints(f) + if len(d) == 0: + raise ValueError(f"Handler function {f} was not annotated.") + if "return" in d: + d.pop("return") + + if len(d) == 0: + raise ValueError( + f"Not enough annotated arguments to handler function '{f}'. Expected 1, got 0. " + f"'handle' uses type annotations to match handlers with abilities, so the argument to '{f}' must " + "be annotated." + ) + if len(d) > 1: + raise ValueError( + f"Too many annotated arguments to handler function '{f}'. Expected 1, got {len(d)}. " + f"'handle' uses type annotations to match handlers with abilities, so '{f}' must have exactly " + "1 annotated argument." + ) + t = list(d.values())[0] + + def on(ability: A) -> Any: + if not isinstance(ability, t): + raise UnhandledAbilityError() + return f(ability) + + return Handler(on) From e117a5af2696363d738f2e8586bf08e1e4fbb19b Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Wed, 29 Oct 2025 13:59:13 +0100 Subject: [PATCH 05/31] update readme and allow throws to wrap functions that dont return effects --- README.md | 89 +++++++++++++++++------------------------ src/stateless/effect.py | 61 +++++++++++++++++++++------- 2 files changed, 83 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index f055366..271a538 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ run(effect) # Guide -## Effects & Abilities & Handlers +## Effects, Abilities & Handlers `stateless` is a functional effect system for Python built around a pattern using [generator functions](https://docs.python.org/3/reference/datamodel.html#generator-functions). When programming with `stateless` you will describe your program's side-effects using the `stateless.Effect` type. `Effect` is in fact just a type alias for a generator: @@ -137,6 +137,9 @@ When `hello_world` returns an `Effect[Greet, Never, None]`, it means that it dep To run an `Effect` that depends on abilities, you need to handle the abilities. Abilities are handled using `stateless.Handler`, defined as: ```python +from stateless import Ability, Effect + + class Handler[A: Ability]: def __call__[**P, A2: Ability, E: Exception, R]( self, @@ -195,78 +198,60 @@ Lets try this instead: effect = handle(greet)(hello_world)() run(effect) # outputs: Hello, world! ``` -Cool. Okay maybe not. The `hello_world` example is obviously contrived. There's no real benefit to sending `greeting` to `hello_world` via `yield` over just providing it as a regular function argument. The example is included here just to give you a rough idea of how the different pieces of `stateless` fit together. - -## Error Handling - -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.` - -Take the `Files` ability from the previous section for example. Reading from the file system can of course fail for a number of reasons, which in Python will result in a subtype of `OSError` being raised. So calling for example `print_file` might raise an exception: +`A` and `E` of `stateless.Effect` is often parameterized with `Never`, so +stateless provides type aliases for this to save you some typing: ```python -from stateless import Depend +from typing import Never + +from stateless import Ability -def f() -> Depend[Files, None]: - yield from print_file('doesnt_exist.txt') # raises FileNotFoundError +type Depend[A: Ability, R] = Effect[A, Never, R] # for effects that depend on A but don't fail +type Try[E: Exception, R] = Effect[Never, E, R] # for effects that might fail but do not need Abilities +type Success[R] = Effect[Never, Never, R] # for effects that don't fail and do not need Abilities ``` -So what's the point of `E`? -The point is that programming errors can be grouped into two categories: recoverable errors and unrecoverable errors. Recoverable errors are errors that are expected, and that users of the API we are writing might want to know about. `FileNotFoundError` is an example of such an error. +## Error Handling -Unrecoverable errors are errors that there is no point in telling the users of your API about. +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.` The intended use of `E` is to model recoverable errors so that users of your API can handle them with type safety. -Let's use `E` to model the errors of `Files.read_file`: +Let's use `E` to model the potential errors when reading a file: ```python -from stateless import Effect, throw +from stateless import Effect, throws - -def read_file(path: str) -> Effect[Files, OSError, str]: - files = yield Files - try: - return files.read_file(path) - except OSError as e: - return (yield from throw(e)) +@throws(OSError) +def read_file(path: str) -> str: + with open(path) as f: + return f.read() ``` -The signature of `stateless.throw` is +The signature of `stateless.throws` is ```python -from typing import Never +from typing import Type, Callable +from stateless import Effect, Try -from stateless import Effect - -def throw[E: Exception](e: E) -> Effect[Never, E, Never]: +def throws[E2: Exception, E: Exception, A: Ability, R]( + *errors: Type[E2], +) -> Callable[ + [Callable[P, Effect[A, E, R] | R]], + Callable[P, Effect[A, E | E2, R] | Try[E2, R]] +]: ... ``` -In words `throw` returns an effect that just yields `e` and never returns. Because of this signature, if you assign the result of `throw` to a variable, you have to annotate it. But there is no meaningful type -to annotate it with. So you're better off using the somewhat strange looking syntax `return (yield from throw(e))`. - -More conveniently you can use `stateless.throws` that just catches exceptions and yields them as an effect - -```python -from stateless import Depend, throws - - -@throws(OSError) -def read_file(path: str) -> Depend[Need[Files], str]: - files = yield from need(Files) - return files.read_file(path) - - -reveal_type(read_file) # revealed type is: def (str) -> Effect[Files, OSError, str] -``` +In words, `throws` catches exceptions of type `E2`, and yields them. Error handling in `stateless` is done using the `stateless.catch` decorator. Its signature is: ```python from typing import Type -from stateless import Effect, Depend +from stateless import Effect def catch[**P, A, E: Exception, E2: Exception, R]( @@ -282,10 +267,10 @@ In words, the `catch` decorator catches errors of type `E` and moves the error f ```python -from stateless import Depend +from stateless import Success -def handle_errors() -> Depend[Files, str]: +def handle_errors() -> Success[str]: result: OSError | str = yield from catch(OSError)(read_file)('foo.txt') match result: case OSError(): @@ -299,7 +284,7 @@ def handle_errors() -> Depend[Files, str]: Consequently you can use your type checker to avoid unintentionally unhandled errors, or ignore them with type-safety as you please. -`catch` can also catch a subset of errors produced by effects, and pass other errors up the call stack, just like when using regular exceptions. But unlike when using regular exceptions, +`catch` can also catch a subset of errors produced by effects, and pass other errors up the call stack, just like when using try/except. But unlike when using try/except, your type checker can see and understand which errors are handled where: ```python @@ -316,10 +301,10 @@ def handle_subset_of_errors() -> Try[PermissionError, str]: ``` This means that: -- You can't neglect to report an error in the signature for `handle_subset_of_errors` since your type checker can tell that `yield from catch(...)(fails_in_multiple_ways)` will still yield `PermissionError` -- You can't neglect to handle errors in your code because your type checker can tell that `result` may be 2 different errors or a string. +- You can't neglect to report an error in the signature for `handle_subset_of_errors` without a type-checker error, since your type checker can tell that `yield from catch(...)(fails_in_multiple_ways)` will still yield `PermissionError` +- You can't neglect to handle errors in your code without a type-checker error because your type checker can tell that `result` may be 2 different errors or a string. -## Built in Abilities +## Built-in Abilities ### Need diff --git a/src/stateless/effect.py b/src/stateless/effect.py index 5a1f99a..0230e8d 100644 --- a/src/stateless/effect.py +++ b/src/stateless/effect.py @@ -231,9 +231,51 @@ def catch_all(f: Callable[P, Effect[A, E, R]]) -> Callable[P, Depend[A, E | R]]: return Catch(Exception)(f) # type: ignore +@dataclass(frozen=True) +class Throws(Generic[E2]): + errors: tuple[Type[E2], ...] + + @overload + def __call__(self, f: Callable[P, Success[R]]) -> Callable[P, Try[E2, R]]: ... + + @overload + def __call__( # type: ignore + self, f: Callable[P, Depend[A, R]] + ) -> Callable[P, Effect[A, E2, R]]: ... + + @overload + def __call__(self, f: Callable[P, Try[E, R]]) -> Callable[P, Try[E | E2, R]]: ... + + @overload + def __call__( + self, f: Callable[P, Effect[A, E, R]] + ) -> Callable[P, Effect[A, E | E2, R]]: ... + + @overload + def __call__(self, f: Callable[P, R]) -> Callable[P, Try[E2, R]]: ... + + def __call__( # type: ignore + self, f: Callable[P, Effect[Ability[Any], Exception, R] | R] + ) -> Effect[Ability[Any], Exception, R]: + @wraps(f) + def decorator( + *args: P.args, **kwargs: P.kwargs + ) -> Effect[Ability[Any], Exception, R]: + try: + result = f(*args, **kwargs) + if isinstance(result, Generator): + result = yield from result + + return result # type: ignore + except self.errors as e: # pyright: ignore + return (yield from throw(e)) + + return decorator # type: ignore + + def throws( *errors: Type[E2], -) -> Callable[[Callable[P, Effect[A, E, R]]], Callable[P, Effect[A, E | E2, R]]]: +) -> Throws[E2]: """ Decorate functions returning effects by catching exceptions of a certain type and yields them as an effect. @@ -246,18 +288,7 @@ def throws( A decorator that catches exceptions of a certain type from functions returning effects and yields them as an effect. """ - - def decorator(f: Callable[P, Effect[A, E, R]]) -> Callable[P, Effect[A, E | E2, R]]: - @wraps(f) - def wrapper(*args: P.args, **kwargs: P.kwargs) -> Effect[A, E | E2, R]: - try: - return (yield from f(*args, **kwargs)) - except errors as e: # pyright: ignore - return (yield from throw(e)) - - return wrapper - - return decorator + return Throws(errors) @dataclass(frozen=True) @@ -267,7 +298,7 @@ class Memoize(Effect[A, E, R]): effect: Effect[A, E, R] _memoized_result: R | None = field(init=False, default=None) - def send(self, value: A) -> Type[A] | E: + def send(self, value: A) -> A | E: """Send a value to the effect.""" if self._memoized_result is not None: @@ -296,7 +327,7 @@ def throw( raise e else: - def throw(self, value: Exception, /) -> Type[A] | E: # type: ignore + def throw(self, value: Exception, /) -> A | E: # type: ignore """Throw an exception into the effect.""" try: From 2c95dd5061977c52a049800e0f4b308ad24cc06a Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Thu, 30 Oct 2025 22:02:35 +0100 Subject: [PATCH 06/31] improve coverage --- src/stateless/handler.py | 16 ++--- tests/test_effect.py | 8 ++- tests/{test_abilities.py => test_handler.py} | 62 +++++++++++++++++++- 3 files changed, 75 insertions(+), 11 deletions(-) rename tests/{test_abilities.py => test_handler.py} (66%) diff --git a/src/stateless/handler.py b/src/stateless/handler.py index 5578ba6..056f83d 100644 --- a/src/stateless/handler.py +++ b/src/stateless/handler.py @@ -28,22 +28,23 @@ class Handler(Generic[A]): on: Callable[[A], Any] @overload - def __call__(self, f: Callable[P, Depend[A, R]]) -> Callable[P, Success[R]]: - ... # pragma: no cover + def __call__( + self, f: Callable[P, Depend[A, R]] + ) -> Callable[P, Success[R]]: ... # pragma: no cover @overload def __call__(self, f: Callable[P, Depend[A | A2, R]]) -> Callable[P, Depend[A2, R]]: # pyright: ignore[reportOverlappingOverload] ... # pragma: no cover @overload - def __call__(self, f: Callable[P, Effect[A, E, R]]) -> Callable[P, Try[E, R]]: - ... # pragma: no cover + def __call__( + self, f: Callable[P, Effect[A, E, R]] + ) -> Callable[P, Try[E, R]]: ... # pragma: no cover @overload def __call__( self, f: Callable[P, Effect[A2 | A, E, R]] - ) -> Callable[P, Effect[A2, E, R]]: - ... # pragma: no cover + ) -> Callable[P, Effect[A2, E, R]]: ... # pragma: no cover def __call__( self, f: Callable[P, Effect[A, E, R] | Effect[A | A2, E, R]] @@ -59,8 +60,7 @@ def decorator( while True: match ability_or_error: case Exception() as error: - value = yield error - ability_or_error = effect.send(value) + yield error case ability: try: value = self.on(ability) diff --git a/tests/test_effect.py b/tests/test_effect.py index cafa62b..58dead0 100644 --- a/tests/test_effect.py +++ b/tests/test_effect.py @@ -2,6 +2,7 @@ from typing import NoReturn as Never from pytest import raises + from stateless import ( Effect, Success, @@ -18,9 +19,9 @@ ) from stateless.effect import SuccessEffect from stateless.functions import RetryError +from stateless.need import need from stateless.schedule import Recurs, Spaced from stateless.time import Time - from tests.utils import run_with_abilities @@ -198,3 +199,8 @@ def test_success_throw() -> None: effect = SuccessEffect("hi") with raises(ValueError, match="oops"): effect.throw(ValueError("oops")) + + +def test_compose_catch_and_handle() -> None: + effect = supply("value")(catch(Exception)(lambda: need(str)))() + assert run(effect) == "value" diff --git a/tests/test_abilities.py b/tests/test_handler.py similarity index 66% rename from tests/test_abilities.py rename to tests/test_handler.py index 5af5091..c96cf67 100644 --- a/tests/test_abilities.py +++ b/tests/test_handler.py @@ -1,10 +1,13 @@ from dataclasses import dataclass from pytest import raises -from stateless import Depend, Effect, Need, need, run, supply -from stateless.errors import MissingAbilityError from typing_extensions import Never +from stateless import Depend, Effect, Need, need, run, supply +from stateless.ability import Ability +from stateless.effect import catch, throws +from stateless.errors import MissingAbilityError +from stateless.handler import handle from tests.utils import run_with_abilities @@ -99,3 +102,58 @@ def f() -> Depend[Need[str], str]: effect = outer(inner(f))() assert run(effect) == "inner" + + +def test_compose_handler_with_catch() -> None: + error = ValueError() + + @throws(ValueError) + def fail() -> Never: + raise error + + effect = catch(ValueError)(supply("value")(fail))() + assert run(effect) == error + + +def test_handle() -> None: + class TestAbility(Ability[None]): + pass + + def no_annotations(_): + pass + + def only_return_annotation(_) -> None: + pass + + def two_annotations(_: TestAbility, __: str) -> None: + pass + + with raises(ValueError): + handle(no_annotations) + + with raises(ValueError): + handle(only_return_annotation) + + with raises(ValueError): + handle(two_annotations) # type: ignore + + target_ability = TestAbility() + + def f() -> Depend[TestAbility, None]: + yield target_ability + + def handle_test_ability(ability: TestAbility) -> None: + assert ability == target_ability + + effect = handle(handle_test_ability)(f)() + run(effect) + + class OtherAbility(Ability[None]): + pass + + def g() -> Depend[OtherAbility, None]: + yield OtherAbility() + + with raises(MissingAbilityError): + effect = handle(handle_test_ability)(g)() + run(effect) # type: ignore From 956ed66277b60f44623b268ccf1760ab75e81f78 Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Thu, 30 Oct 2025 22:02:51 +0100 Subject: [PATCH 07/31] ignore overloads in coverage --- pyproject.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 281211c..495d1fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,12 @@ ignore = ["D107", "D213", "D203", "D202", "D212"] "tests/**/*" = ["D100", "D101", "D102", "D103", "D104", "D105", "D107"] +[tool.coverage.report] +exclude_also = [ + "@overload" +] + + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" From 30060567a865bfff742568e43329107c0849e4c0 Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Sun, 2 Nov 2025 22:47:41 +0100 Subject: [PATCH 08/31] update readme and fix test coverage --- README.md | 352 +++++++++++++++------------------------ justfile | 3 +- pyproject.toml | 4 + src/stateless/handler.py | 17 +- src/stateless/need.py | 13 +- tests/test_async.py | 63 +++++++ 6 files changed, 217 insertions(+), 235 deletions(-) create mode 100644 tests/test_async.py diff --git a/README.md b/README.md index 271a538..f3a0983 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,8 @@ class Console: print(value) -# Effects are generators that yield abilities that can handled up the call stack. -# An example ability might be `stateless.Need` that is used for type-safe dependency injection. +# Effects are generators that yield abilities that can be handled up the call stack. +# `stateless.Need` is a built-in ability that is used for type-safe dependency injection. def print_(value: Any) -> Effect[Need[Console], Never, None]: console = yield from need(Console) console.print(value) @@ -68,11 +68,10 @@ def print_file(path: str) -> Effect[Need[Files] | Need[Console], Never, None]: yield from print_(content) -# Effects are run using `stateless.run`. +# Before an effect can be executed, all of its abilities must be handled. # The `Need` ability is handled using `stateless.supply`. -# Before an effect can be executed with `run`, it must have -# all of its abilities handled. effect = supply(Files(), Console())(print_file)('foo.txt') +# Effects are run using `stateless.run`. run(effect) ``` @@ -90,22 +89,37 @@ from stateless import Ability type Effect[A: Ability, E: Exception, R] = Generator[A | E, Any, R] ``` - In other words, an `Effect` is a generator that can yield classes of type `A` or exceptions of type `E`, can be sent anything, and returns results of type `R`. Let's break that down a bit further: +In other words, an `Effect` is a generator that can yield values of type `A` or exceptions of type `E`, can be sent anything, and returns results of type `R`. Let's break that down a bit further: -- The type parameter `A` stands for _"Ability"_. This is the type of value, or types of values, that an effect depends on in order to produce its result. +- The type parameter `A` stands for _"Ability"_. This is the type of value, or types of values, that must be handled in order the effect to produce its result. - The type parameter `E` stands for _"Error"_. This the type of errors that an effect might fail with. - The type parameter `R` stands for _"Result"_. This is the type of value that an `Effect` will produce if no errors occur. -Lets start by defining a simple ability. `stateless.Ability` is defined as: +`A` and `E` of `stateless.Effect` are often parameterized with `Never`, so +stateless provides the following type aliases to save you some typing: + +```python +from typing import Never + +from stateless import Ability, Effect + + +type Depend[A: Ability, R] = Effect[A, Never, R] # for effects that depend on A but don't fail +type Try[E: Exception, R] = Effect[Never, E, R] # for effects that might fail but do not need Abilities +type Success[R] = Effect[Never, Never, R] # for effects that don't fail and do not need Abilities +``` + + +Lets define a simple ability. `stateless.Ability` is defined as: ```python class Ability[R]: ... ``` -The `R` type parameter represents the expected result type of handling the effect. For example: +The `R` type parameter represents the expected result of handling the effect. For example: ```python from dataclasses import dataclass @@ -117,7 +131,7 @@ class Greet(Ability[str]): name: str ``` -When `Greet` inherits from `Ability[str]`, it means that when a function yields an instance of `Greet`, it expects to be sent a `str` value back. +When `Greet` inherits from `Ability[str]`, it means that when a function yields an instance of `Greet`, the function should expect that the result of handling `Greet` has type `str`. Let's use `Greet`: @@ -134,7 +148,7 @@ def hello_world() -> Effect[Greet, Never, None]: When `hello_world` returns an `Effect[Greet, Never, None]`, it means that it depends on the `Greet` ability (`A` is parameterized with `Greet`). It can't fail (`E` is parameterized with `Never`), and it doesn't produce a value (`R` is parameterized with `None`). -To run an `Effect` that depends on abilities, you need to handle the abilities. Abilities are handled using `stateless.Handler`, defined as: +To run an `Effect` that depends on abilities, you need to handle all of the abilities of that effect. Abilities are handled using `stateless.Handler`, defined as: ```python from stateless import Ability, Effect @@ -148,7 +162,7 @@ class Handler[A: Ability]: ... ``` -Just like the paramater `A` of `Effect`, The type parameter `A` of `Handler` stands for "Ability". This is the type of abilities that this `Handler` instance can handle. +Just like the parameter `A` of `Effect`, The type parameter `A` of `Handler` stands for "Ability". This is the type of abilities that this `Handler` instance can handle. `Handler.__call__` is a decorator that accepts a function that returns a `stateless.Effect` that depends on abilities `A` and `A2`, and returns a new function that returns an effect that only depends on ability `A2`. In other words, the ability `A` is handled by `Handler` and the decorated function now produces an effect that no longer depends on `A`. @@ -169,6 +183,9 @@ reveal_type(effect) # revealed type is: Effect[Never, Never, None] > [!NOTE] > `stateless.handle` depends on type annotations of the handler function to match abilities with handler functions. To use `stateless.handle` you must annotate the argument of the handler function with an appropriate ability. +> +> `stateless.handle` just uses `isinstance` to match abilities +> with handlers, so handling abilities with type parameters may not work as expected. We can see in the revealed type how `handle(greet)` has eliminated the `Greet` ability from the effect returned by `hello_world`, and the type is now `Never`, meaning the new effect does not require any abilities. @@ -198,39 +215,50 @@ Lets try this instead: effect = handle(greet)(hello_world)() run(effect) # outputs: Hello, world! ``` -`A` and `E` of `stateless.Effect` is often parameterized with `Never`, so -stateless provides type aliases for this to save you some typing: +Since we've handled the `Greet` ability for `hello_world`, we can now run the resulting effect with no type checker errors. -```python -from typing import Never -from stateless import Ability +To access the result type of an effect from another effect, use `yield from`: -type Depend[A: Ability, R] = Effect[A, Never, R] # for effects that depend on A but don't fail -type Try[E: Exception, R] = Effect[Never, E, R] # for effects that might fail but do not need Abilities -type Success[R] = Effect[Never, Never, R] # for effects that don't fail and do not need Abilities +```python +def f() -> Success[float]: ... + +def g() -> Success[float]: + number = yield from f() + return number * 2 ``` -## Error Handling +Simple effects can be combined into complex effects by depending on multiple abilities: -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.` +```python +def depend_on_some_ability() -> Depend[SomeAbility, None]: ... -The intended use of `E` is to model recoverable errors so that users of your API can handle them with type safety. +def depend_on_another_ability() -> Depend[AnotherAbility, None]: ... -Let's use `E` to model the potential errors when reading a file: +def depend_on_both_abilities() -> Depend[SomeAbility | AnotherAbility, None]: + yield from f() + yield from g() +``` +One way to think about abilities is as a generalization of exceptions: when a function needs to have an ability handled it passes the ability up the call stack until an appropriate handler is found, similar to how a raised exception travels up the call stack. In contrast with exception handling however, once the ability is handled, the result is returned to function that yielded the ability, and execution resumes. -```python -from stateless import Effect, throws +Like exceptions, abilities can be partially handled (with type-safety): -@throws(OSError) -def read_file(path: str) -> str: - with open(path) as f: - return f.read() +```python +handle: Handler[SomeAbility] = ... +effect = handle(depend_on_both_abilities)() +reveal_type(effect) # revealed type is: Depend[AnotherAbility, None] ``` -The signature of `stateless.throws` is +## Error Handling + +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.` + +The intended use of `E` is to model recoverable errors so that users of your API can handle them with type safety. + +The main way to turn exceptions into errors of effects is using `stateless.throws`. Its signature is: + ```python from typing import Type, Callable @@ -247,6 +275,20 @@ def throws[E2: Exception, E: Exception, A: Ability, R]( ``` In words, `throws` catches exceptions of type `E2`, and yields them. + +Let's use `throws` to model the potential errors when reading a file. +```python +from stateless import Effect, throws + +@throws(FileNotFoundError, PermissionError) +def read_file(path: str) -> str: + with open(path) as f: + return f.read() + + +reveal_type(read_file): # Revealed type is: Callable[[str], Try[FileNotFoundError | PermissionError, str]] +``` + Error handling in `stateless` is done using the `stateless.catch` decorator. Its signature is: ```python @@ -263,24 +305,26 @@ def catch[**P, A, E: Exception, E2: Exception, R]( ... ``` -In words, the `catch` decorator catches errors of type `E` and moves the error from the error type `E` of the `Effect` produced by the decorated function, to the result type `R` of the effect of the return function. This means you can access the potential errors directly in your code: +In words, the `catch` decorator catches errors of type `E` and moves the error from the error type `E` of the `Effect` produced by the decorated function, to the result type `R` of the effect of the return function. + +This means you can access the potential errors directly in your code: ```python +from typing import reveal_type + from stateless import Success def handle_errors() -> Success[str]: - result: OSError | str = yield from catch(OSError)(read_file)('foo.txt') + result = yield from catch(FileNotFoundError, PermissionError)(read_file)('foo.txt') + reveal_type(result) # Revealed type is: FileNotFoundError | PermissionError | str match result: - case OSError(): + case FileNotFoundError() | PermissionError(): return 'default value' case _: return result - ``` -(You don't need to annotate the type of `result`, it can be inferred by your type checker. We do it here simply because its instructive to look at the types.) - Consequently you can use your type checker to avoid unintentionally unhandled errors, or ignore them with type-safety as you please. @@ -308,125 +352,43 @@ This means that: ### Need -Let's look at a bigger example. The main point of a purely functional effect system is to enable side-effects such as IO in a purely functional way. So let's implement some abilities for doing side-effects. +`Need` is a an ability for type-safe dependency injection. By "type-safe" we mean: -We'll start with an ability we'll call `Console` for writing to the console: +- Functions with dependencies can't fail to report a dependency in its type signature without a type error. +- You can't run effects with dependencies without handling them. -```python -class Console: - def print(self, line: str) -> None: - print(line) -``` -We can use `Console` with `Effect` as an ability. Recall that the _"send"_ type of `Effect` is `Any`. In order to tell our type checker that the result of yielding the `Console` class will be a `Console` instance, we can use the `stateless.need` function. Its signature is: +`Need` is defined as: ```python from typing import Type -from stateless import Depend, Need - - -def depend[A](ability: Type[A]) -> Depend[A, A]: - ... -``` - -`stateless.Depend` is a type alias: - -```python -from typing import Never - - -type Depend[A, R] = Effect[A, Never, R] -``` -In words, `Depend` is just an effect that depends on `A` and produces no errors. - -So `depend` just yields the ability type for us, and then returns the instance that will eventually be sent from `Abilities`. - -Let's see that in action with the `Console` ability: - -```python -from stateless import Depend, depend - - -def say_hello() -> Depend[Console, None]: - console = yield from depend(Console) - console.print(f"Hello, world!") -``` - -Let's add another ability `Files` to read rom the file system: - - -```python -class Files: - def read(self, path: str) -> str: - with open(path, 'r') as f: - return f.read() -``` -Putting it all together: - -```python -from stateless import Depend - - -def print_file(path: str) -> Depend[Console | Files, None]: - files = yield from depend(Files) - console = yield from depend(Console) - - content = files.read(path) - console.print(content) -``` -Note that for the `Effect` returned by `print_file`, `A` is parameterized with `Console | Files` since `print_file` depends on both `Console` and `Files` (i.e it will yield both classes). - -`print_file` is a good demonstration of why the _"send"_ type of `Effect` must be `Any`: Since `print_file` expects to be sent instances of `Console` _or_ `File`, it's not possible for our type-checker to know on which yield which type is going to be sent, and because of the variance of `typing.Generator`, we can't write `depend` in a way that would allow us to type `Effect` with a _"send"_ type other than `Any`. - -`print_file` is also an example of how to build complex effects using functions that return simpler effects using `yield from`: - - -```python -from stateless import Depend, depend - - -def get_str() -> Depend[str, str]: - s = yield from depend(str) - return s - +from stateless import Ability -def get_int() -> Depend[str | int, tuple[str, int]]: - s = yield from get_str() - i = yield from depend(int) - return (s, i) +class Need[T](Ability[T]): + t: Type[T] ``` -you can of course run `print_file` with `Abilities` and `run`: +`T` could be anything, but will often be types that can perform side-effects. +Let's define a type we'll call `Console` for writing to the console: ```python -from stateless import Abilities, run - - -abilities = Abilities().add(Files()).add(Console()) -effect = abilities.handle(print_file)('foo.txt') -run(effect) -``` +from stateless import Depend, Need, need -`Abilities` also allows us to partially provide abilities for an effect: +class Console: + def print(self, line: str) -> None: + print(line) -```python -print_file = Abilities().add(Console()).handle(print_file) -reveal_type(print_file) # revealed type is: () -> Depend[Files, None] -print_file = Abilities().add(Files()).handle(print_file) -reveal_type(print_file) # revealed type is: () -> Depend[Never, None] +def say_hello() -> Depend[Need[Console], None]: + console = yield from need(Console) + console.print(f"Hello, world!") ``` -The first time we handle abilities of `print_file`, we only handle the `Console` ability. The result is a function that returns an effect that only depends on `Files`. -The second time we handle abilities of `print_file`, we only handle the `Files` ability. The result is a function that returns an effect that doesn't depend on any abilities. - -This feature allows you to provide some abilities locally to a part of your program, hiding implementation details from the rest of your program. - A major purpose of dependency injection is to vary the injected ability to change the behavior of the effect. For example, we -might want to change the behavior of `print_files` in tests: +might want to change the behavior of `say_hello` in tests: ```python @@ -435,130 +397,82 @@ class MockConsole(Console): pass -class MockFiles(Files): - def __init__(self, content: str) -> None: - self.content = content - - def read(self, path: str) -> str: - return self.content - - -def mock_abilities() -> Abilities[Console | Files]: +def test_handler() -> Handler[Console]: console = MockConsole() - files = MockFiles("mock content") - return Abilities().add(console).add(files)) + return supply(console) -abilities = mock_abilities() -effect = abilities.handle(print_file)('foo.txt') + +effect = test_handler()(say_hello)('foo.txt') run(effect) ``` -Our type-checker will likely infer the types `console` and `files` to be `MockConsole` and `MockFiles` respectively, So we have moved their initialization to a function with the annotated return type `Abilities[Console | Files`]. Otherwise, our type checker will not be able to infer that `abilities.handle` in fact handles the `Console` and `Files` abilities of `print_file`. - -Besides `Effect`, stateless` provides you with a few other type aliases that can save you a bit of typing. Firstly success which is just defined as: +Our type-checker will likely infer the types `console` to be `MockConsole`, so we have moved the initialization to a function with the annotated return type `Handler[Console`]. Otherwise, our type checker will not be able to infer that the handler in fact handles the `Console` ability of `say_hello`. -```python -from typing import Never - - -type Success[R] = Effect[Never, Never, R] -``` +### Async +The `Async` ability is used to run code asynchronously, either with `asyncio` or `concurrent.futures`. -for effects that don't fail and don't require abilities (can be easily instantiated using the `stateless.success` function). +to use the result of an `asyncio` coroutine, use the `stateless.wait` function: -Secondly, the `Depend` type alias, defined as: ```python -from typing import Never - -type Depend[A, R] = Effect[A, Never, R] -``` - -for effects that depend on `A` but produces no errors. +from stateless import wait, Async, Depend -Finally the `Try` type alias, defined as: +async def do_io() -> str: ... -```python -from typing import Never - - -type Try[E, R] = Effect[Never, E, R] +def use_io() -> Depend[Async, str]: + result = yield from wait(do_io()) + return result ``` -For effects that do not require abilities, but might produce errors. -Sometimes, instantiating abilities may itself require side-effects. For example, consider a program that requires a `Config` ability: +Recall that the signature of `stateless.run` is: ```python -from stateless import Depend +from stateless import Async -class Config: - ... - - -def main() -> Depend[Config, None]: - ... +def run[R](effect: Effect[Async, Exception, R]) -> R: ... ``` -Now imagine that you want to provide the `Config` ability by reading from environment variables: +The reason `run` does not need the `Async` effect handled is because `stateless` just calls `asyncio.run` to run `asyncio` coroutines when executing effects. If you want to defer execution of coroutines, for example when integrating `stateless` with another framework that manages the event loop, for example an ASGI server, you can use `stateless.run_async` defined as: ```python -import os - -from stateless import Depend, depend - - -class OS: - environ: dict[str, str] = os.environ - - -def get_config() -> Depend[OS, Config]: - os = yield from depend(OS) - return Config( - auth_token=os.environ['AUTH_TOKEN'], - url=os.environ['URL'] - ) +async def run_async[R](effect: Effect[Async, Exception, R]) -> R: ... ``` -To supply the `Config` instance returned from `get_config`, we can use `Abilities.add_effect`: - - -```python -from stateless import Abilities +(in fact `stateless.run(effect)` just calls `asyncio.run(run_async(effect))`). -Abilities().add(OS()).add_effect(get_config()) -``` +To run effects in other process/threads, use `stateless.fork`, defined as: -`Abilities.add_effect` assumes that all abilities required by the effect given as its argument can be provided by `Abilities`. If this is not the case, you'll get a type-checker error: ```python -from stateless import Depend, Abilities - +def fork[**P, R](f: Callable[P, Try[Exception, R]]) -> Callable[P, Depend[Need[Executor], Task[R]]]: ... +``` -class A: - pass +`fork` will simply call `stateless.run` in the remote process/thread, so all abilities of `f` must be handled before forking. +Moreover, all unhandled errors yielded by `f` will be raised in the remote thread, so if you want to handle errors from forked effect in the main process/thread, you need to use `stateless.catch` before forking: -class B: - pass +```python +def may_fail() -> Try[OSError, str]: ... -def get_B() -> Depend[A, B]: - ... -Abilities().add(A()).add_effect(get_B()) # OK -Abilities().add_effect(get_B()) # Type-checker error! +def run_may_fail() -> Depend[Need[Executor], str]: + task = yield from fork(catch(OSError)(may_fail))() + result = yield from wait(task) + reveal_type(result) # Revealed type is: str | OSError + match result: + case OSError(): + return 'default value' + case _: + return result ``` -(It will often make sense to use an `abc.ABC` as your ability types to enforce programming towards the interface and not the implementation. If you use `mypy` however, note that [using abstract classes where `typing.Type` is expected is a type-error](https://github.com/python/mypy/issues/4717), which will cause problems if you pass an abstract type to `depend`. We recommend disabling this check, which will also likely be the default for `mypy` in the future.) - -### Async - ## Repeating and Retrying Effects A `stateless.Schedule` is a type with an `__iter__` method that returns an effect producing an iterator of `timedelta` instances. It's defined like: diff --git a/justfile b/justfile index e92cd97..553b4d4 100644 --- a/justfile +++ b/justfile @@ -1,5 +1,6 @@ test: - coverage run --source=src -m pytest tests + coverage run -m pytest tests + coverage combine coverage report --fail-under=100 lint: diff --git a/pyproject.toml b/pyproject.toml index 495d1fd..71fa4b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,10 @@ exclude_also = [ "@overload" ] +[tool.coverage.run] +concurrency = ["multiprocessing", "thread"] +source = ["src/"] + [build-system] requires = ["poetry-core"] diff --git a/src/stateless/handler.py b/src/stateless/handler.py index 056f83d..32e28f2 100644 --- a/src/stateless/handler.py +++ b/src/stateless/handler.py @@ -25,26 +25,25 @@ @dataclass(frozen=True) class Handler(Generic[A]): # Sadly, complete type safety here requires higher-kinded types. - on: Callable[[A], Any] + handle: Callable[[A], Any] @overload - def __call__( - self, f: Callable[P, Depend[A, R]] - ) -> Callable[P, Success[R]]: ... # pragma: no cover + def __call__(self, f: Callable[P, Depend[A, R]]) -> Callable[P, Success[R]]: + ... # pragma: no cover @overload def __call__(self, f: Callable[P, Depend[A | A2, R]]) -> Callable[P, Depend[A2, R]]: # pyright: ignore[reportOverlappingOverload] ... # pragma: no cover @overload - def __call__( - self, f: Callable[P, Effect[A, E, R]] - ) -> Callable[P, Try[E, R]]: ... # pragma: no cover + def __call__(self, f: Callable[P, Effect[A, E, R]]) -> Callable[P, Try[E, R]]: + ... # pragma: no cover @overload def __call__( self, f: Callable[P, Effect[A2 | A, E, R]] - ) -> Callable[P, Effect[A2, E, R]]: ... # pragma: no cover + ) -> Callable[P, Effect[A2, E, R]]: + ... # pragma: no cover def __call__( self, f: Callable[P, Effect[A, E, R] | Effect[A | A2, E, R]] @@ -63,7 +62,7 @@ def decorator( yield error case ability: try: - value = self.on(ability) + value = self.handle(ability) except UnhandledAbilityError: # defer to handlers up the call stack value = yield ability # type: ignore diff --git a/src/stateless/need.py b/src/stateless/need.py index 15903fb..b344412 100644 --- a/src/stateless/need.py +++ b/src/stateless/need.py @@ -28,17 +28,18 @@ def need(t: Type[T]) -> Depend[Need[T], T]: @overload -def supply(v1: T, /) -> Handler[Need[T]]: ... # pragma: no cover +def supply(v1: T, /) -> Handler[Need[T]]: + ... # pragma: no cover @overload -def supply(v1: T, v2: T2, /) -> Handler[Need[T] | Need[T2]]: ... # pragma: no cover +def supply(v1: T, v2: T2, /) -> Handler[Need[T] | Need[T2]]: + ... # pragma: no cover @overload -def supply( - v1: T, v2: T2, v3: T3, / -) -> Handler[Need[T] | Need[T2] | Need[T3]]: ... # pragma: no cover +def supply(v1: T, v2: T2, v3: T3, /) -> Handler[Need[T] | Need[T2] | Need[T3]]: + ... # pragma: no cover def supply(first: T, /, *rest: T2) -> Handler[Need[T] | Need[T2]]: @@ -52,4 +53,4 @@ def on(ability: Need[T]) -> T: if isinstance(instance, ability.t): return instance - return Handler(on=on) + return Handler(handle=on) diff --git a/tests/test_async.py b/tests/test_async.py new file mode 100644 index 0000000..50e2a18 --- /dev/null +++ b/tests/test_async.py @@ -0,0 +1,63 @@ +from concurrent.futures import ProcessPoolExecutor +from threading import Event + +from stateless import ( + Async, + Depend, + Executor, + Need, + Success, + fork, + run, + success, + supply, + wait, +) + + +def say_hi() -> Success[str]: + return success("hi") + + +def fork_say_hi() -> Depend[Need[Executor] | Async, str]: + task = yield from fork(say_hi)() + value = yield from wait(task) + return value + + +def test_fork_and_wait() -> None: + with Executor() as executor: + assert run(supply(executor)(fork_say_hi)()) == "hi" + + +def test_fork_still_runs_when_not_waited() -> None: + event = Event() + + def f() -> Success[None]: + event.set() + yield from success(None) + + def g() -> Depend[Need[Executor], None]: + yield from fork(f)() + + with Executor() as executor: + effect = supply(executor)(g)() + run(effect) + + assert event.wait(timeout=1) + + +def test_wait_coroutine() -> None: + async def say_hi() -> str: + return "hi" + + def f() -> Depend[Async, str]: + value = yield from wait(say_hi()) + return value + + assert run(f()) == "hi" + + +def test_fork_with_process_executor(): + with ProcessPoolExecutor() as executor: + assert run(supply(Executor(executor))(fork_say_hi)()) == "hi" From 2ccc5c4254661886abebf2b2eaf91ed52bfc5bc8 Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Mon, 3 Nov 2025 14:35:12 +0100 Subject: [PATCH 09/31] lint --- pyproject.toml | 6 ++++ src/stateless/async_.py | 51 +++++++++++++++++++------------ src/stateless/effect.py | 61 ++++++++++++++++++++++---------------- src/stateless/functions.py | 6 ++-- src/stateless/handler.py | 18 ++++++----- src/stateless/need.py | 23 ++++++++------ src/stateless/schedule.py | 5 ++-- tests/test_async.py | 5 ++-- tests/test_effect.py | 2 +- tests/test_handler.py | 12 ++++---- tests/test_need.py | 10 +++++-- tests/utils.py | 5 ++-- 12 files changed, 124 insertions(+), 80 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 71fa4b1..7db5c3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,12 @@ warn_return_any = true strict_equality = true disallow_any_generics = true +[[tool.mypy.overrides]] +module = [ + "cloudpickle", +] +ignore_missing_imports = true + [tool.ruff.lint] select = ["I", "F", "N", "RUF", "D"] ignore = ["D107", "D213", "D203", "D202", "D212"] diff --git a/src/stateless/async_.py b/src/stateless/async_.py index b69767d..ee5987c 100644 --- a/src/stateless/async_.py +++ b/src/stateless/async_.py @@ -3,6 +3,7 @@ from concurrent.futures.thread import ThreadPoolExecutor from dataclasses import dataclass from functools import wraps +from types import TracebackType from typing import ( Any, Awaitable, @@ -11,6 +12,7 @@ Generic, ParamSpec, TypeVar, + cast, overload, ) @@ -22,7 +24,7 @@ P = ParamSpec("P") R = TypeVar("R") -A = TypeVar("A", bound=Ability) +A = TypeVar("A", bound=Ability[Any]) E = TypeVar("E", bound=Exception) B = TypeVar("B") @@ -34,7 +36,7 @@ class Task(Generic[R]): async def get_result(self) -> R: result = await self.future if isinstance(result, bytes): - return cloudpickle.loads(result) + return cloudpickle.loads(result) # type: ignore return result @@ -59,40 +61,43 @@ def __init__( object.__setattr__(self, "executor", executor) - def __enter__(self): + def __enter__(self) -> "Executor": self.executor.__enter__() return self - def __exit__(self, *args, **kwargs): - self.executor.__exit__(*args, **kwargs) + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.executor.__exit__(exc_type, exc_val, exc_tb) @overload def fork( f: Callable[P, Success[R]], -) -> Callable[P, Depend[Need[Executor], Task[R]]]: ... +) -> Callable[P, Depend[Need[Executor], Task[R]]]: + ... @overload -def fork(f: Callable[P, Try[E, R]]) -> Callable[P, Depend[Need[Executor], Task[R]]]: ... +def fork(f: Callable[P, Try[E, R]]) -> Callable[P, Depend[Need[Executor], Task[R]]]: + ... @overload def fork( f: Callable[P, Depend[Async, R]], -) -> Callable[P, Depend[Need[Executor], Task[R]]]: ... +) -> Callable[P, Depend[Need[Executor], Task[R]]]: + ... @overload def fork( f: Callable[P, Effect[Async, E, R]], -) -> Callable[P, Depend[Need[Executor], Task[R]]]: ... - - -def process_target(payload: bytes) -> bytes: - f, args, kwargs = cloudpickle.loads(payload) - result = run(f(*args, **kwargs)) - return cloudpickle.dumps(result) +) -> Callable[P, Depend[Need[Executor], Task[R]]]: + ... def fork( @@ -110,18 +115,26 @@ def thread_target() -> R: payload = cloudpickle.dumps((f, args, kwargs)) future = loop.run_in_executor(executor.executor, process_target, payload) else: - future = loop.run_in_executor(executor.executor, thread_target) + future = loop.run_in_executor(executor.executor, thread_target) # type: ignore return Task(future) return decorator +def process_target(payload: bytes) -> bytes: + f, args, kwargs = cloudpickle.loads(payload) + result = run(f(*args, **kwargs)) + return cast(bytes, cloudpickle.dumps(result)) + + @overload -def wait(target: Coroutine[Any, Any, R]) -> Depend[Async, R]: ... +def wait(target: Coroutine[Any, Any, R]) -> Depend[Async, R]: + ... @overload -def wait(target: Task[R]) -> Effect[Async, E, R]: ... +def wait(target: Task[R]) -> Effect[Async, E, R]: + ... def wait(target: Coroutine[Any, Any, R] | Task[R]) -> Effect[Async, E, R]: @@ -136,4 +149,4 @@ def wait(target: Coroutine[Any, Any, R] | Task[R]) -> Effect[Async, E, R]: v = yield from Async(target.get_result()) else: v = yield from Async(target) - return v + return cast(R, v) diff --git a/src/stateless/effect.py b/src/stateless/effect.py index 0230e8d..94dd3ac 100644 --- a/src/stateless/effect.py +++ b/src/stateless/effect.py @@ -23,7 +23,7 @@ # type inference is not possible. Specifically # type checkers can't distinguish between abilities # and errors in Effect types. -A = TypeVar("A", bound=Ability) +A = TypeVar("A", bound=Ability[Any]) E = TypeVar("E", bound=Exception) P = ParamSpec("P") E2 = TypeVar("E2", bound=Exception) @@ -134,33 +134,35 @@ class Catch(Generic[E]): errors: tuple[Type[E], ...] @overload - def __init__(self: "Catch[Never]"): ... # pragma: no cover + def __init__(self: "Catch[Never]"): + ... # pragma: no cover @overload - def __init__(self, *errors: Type[E]): ... # pragma: no cover + def __init__(self, *errors: Type[E]): + ... # pragma: no cover def __init__(self, *errors: Type[E]): object.__setattr__(self, "errors", errors) @overload - def __call__( - self, f: Callable[P, Try[E, R]] - ) -> Callable[P, Success[R | E]]: ... # pragma: no cover + def __call__(self, f: Callable[P, Try[E, R]]) -> Callable[P, Success[R | E]]: + ... # pragma: no cover @overload def __call__( # pyright: ignore[reportOverlappingOverload] self, f: Callable[P, Effect[A, E, R]] - ) -> Callable[P, Depend[A, R | E]]: ... # pragma: no cover + ) -> Callable[P, Depend[A, R | E]]: + ... # pragma: no cover @overload - def __call__( - self, f: Callable[P, Try[E | E2, R]] - ) -> Callable[P, Try[E2, E | R]]: ... # pragma: no cover + def __call__(self, f: Callable[P, Try[E | E2, R]]) -> Callable[P, Try[E2, E | R]]: + ... # pragma: no cover @overload def __call__( self, f: Callable[P, Effect[A, E2 | E, R]] - ) -> Callable[P, Effect[A, E2, R | E]]: ... # pragma: no cover + ) -> Callable[P, Effect[A, E2, R | E]]: + ... # pragma: no cover def __call__( self, f: Callable[P, Effect[A, E2 | E, R]] @@ -194,11 +196,13 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> Depend[A, E | R]: @overload -def catch() -> Catch[Never]: ... # pragma: no cover +def catch() -> Catch[Never]: + ... # pragma: no cover @overload -def catch(*errors: Type[E]) -> Catch[E]: ... # pragma: no cover +def catch(*errors: Type[E]) -> Catch[E]: + ... # pragma: no cover def catch(*errors: Type[E]) -> Catch[E]: @@ -236,23 +240,28 @@ class Throws(Generic[E2]): errors: tuple[Type[E2], ...] @overload - def __call__(self, f: Callable[P, Success[R]]) -> Callable[P, Try[E2, R]]: ... + def __call__(self, f: Callable[P, Success[R]]) -> Callable[P, Try[E2, R]]: + ... @overload def __call__( # type: ignore self, f: Callable[P, Depend[A, R]] - ) -> Callable[P, Effect[A, E2, R]]: ... + ) -> Callable[P, Effect[A, E2, R]]: + ... @overload - def __call__(self, f: Callable[P, Try[E, R]]) -> Callable[P, Try[E | E2, R]]: ... + def __call__(self, f: Callable[P, Try[E, R]]) -> Callable[P, Try[E | E2, R]]: + ... @overload - def __call__( + def __call__( # type: ignore self, f: Callable[P, Effect[A, E, R]] - ) -> Callable[P, Effect[A, E | E2, R]]: ... + ) -> Callable[P, Effect[A, E | E2, R]]: + ... @overload - def __call__(self, f: Callable[P, R]) -> Callable[P, Try[E2, R]]: ... + def __call__(self, f: Callable[P, R]) -> Callable[P, Try[E2, R]]: + ... def __call__( # type: ignore self, f: Callable[P, Effect[Ability[Any], Exception, R] | R] @@ -266,7 +275,7 @@ def decorator( if isinstance(result, Generator): result = yield from result - return result # type: ignore + return result # pyright: ignore except self.errors as e: # pyright: ignore return (yield from throw(e)) @@ -340,7 +349,8 @@ def throw(self, value: Exception, /) -> A | E: # type: ignore @overload def memoize( f: Callable[P, Effect[A, E, R]], -) -> Callable[P, Effect[A, E, R]]: ... # pragma: no cover +) -> Callable[P, Effect[A, E, R]]: + ... # pragma: no cover @overload @@ -348,12 +358,11 @@ def memoize( *, maxsize: int | None = None, typed: bool = False, -) -> Callable[ - [Callable[P, Effect[A, E, R]]], Callable[P, Effect[A, E, R]] -]: ... # pragma: no cover +) -> Callable[[Callable[P, Effect[A, E, R]]], Callable[P, Effect[A, E, R]]]: + ... # pragma: no cover -def memoize( # type: ignore +def memoize( f: Callable[P, Effect[A, E, R]] | None = None, *, maxsize: int | None = None, @@ -376,7 +385,7 @@ def memoize( # type: ignore """ if f is None: - return partial(memoize, maxsize=maxsize, typed=typed) # type: ignore + return partial(memoize, maxsize=maxsize, typed=typed) # pyright: ignore @lru_cache(maxsize=maxsize, typed=typed) @wraps(f) diff --git a/src/stateless/functions.py b/src/stateless/functions.py index c292717..2c24134 100644 --- a/src/stateless/functions.py +++ b/src/stateless/functions.py @@ -1,7 +1,7 @@ """Functions for working with effects.""" from functools import wraps -from typing import Callable, Generic, ParamSpec, Tuple, TypeVar +from typing import Any, Callable, Generic, ParamSpec, Tuple, TypeVar from stateless.ability import Ability from stateless.async_ import Async @@ -10,8 +10,8 @@ from stateless.schedule import Schedule from stateless.time import Time, sleep -A = TypeVar("A", bound=Ability) -A2 = TypeVar("A2", bound=Ability) +A = TypeVar("A", bound=Ability[Any]) +A2 = TypeVar("A2", bound=Ability[Any]) E = TypeVar("E", bound=Exception) R = TypeVar("R") P = ParamSpec("P") diff --git a/src/stateless/handler.py b/src/stateless/handler.py index 32e28f2..e910541 100644 --- a/src/stateless/handler.py +++ b/src/stateless/handler.py @@ -16,8 +16,8 @@ from stateless.errors import UnhandledAbilityError E = TypeVar("E", bound=Exception) -A = TypeVar("A", covariant=True, bound=Ability) -A2 = TypeVar("A2", bound=Ability) +A = TypeVar("A", covariant=True, bound=Ability[Any]) +A2 = TypeVar("A2", bound=Ability[Any]) R = TypeVar("R") P = ParamSpec("P") @@ -32,11 +32,13 @@ def __call__(self, f: Callable[P, Depend[A, R]]) -> Callable[P, Success[R]]: ... # pragma: no cover @overload - def __call__(self, f: Callable[P, Depend[A | A2, R]]) -> Callable[P, Depend[A2, R]]: # pyright: ignore[reportOverlappingOverload] + def __call__( # pyright: ignore[reportOverlappingOverload] + self, f: Callable[P, Effect[A, E, R]] + ) -> Callable[P, Try[E, R]]: ... # pragma: no cover @overload - def __call__(self, f: Callable[P, Effect[A, E, R]]) -> Callable[P, Try[E, R]]: + def __call__(self, f: Callable[P, Depend[A | A2, R]]) -> Callable[P, Depend[A2, R]]: ... # pragma: no cover @overload @@ -59,10 +61,10 @@ def decorator( while True: match ability_or_error: case Exception() as error: - yield error + yield error # type: ignore case ability: try: - value = self.handle(ability) + value = self.handle(ability) # type: ignore except UnhandledAbilityError: # defer to handlers up the call stack value = yield ability # type: ignore @@ -73,7 +75,7 @@ def decorator( return decorator -def handle(f: Callable[[A], Any]) -> Handler[A]: +def handle(f: Callable[[A2], Any]) -> Handler[A2]: d = get_type_hints(f) if len(d) == 0: raise ValueError(f"Handler function {f} was not annotated.") @@ -94,7 +96,7 @@ def handle(f: Callable[[A], Any]) -> Handler[A]: ) t = list(d.values())[0] - def on(ability: A) -> Any: + def on(ability: A2) -> Any: if not isinstance(ability, t): raise UnhandledAbilityError() return f(ability) diff --git a/src/stateless/need.py b/src/stateless/need.py index b344412..e3bc549 100644 --- a/src/stateless/need.py +++ b/src/stateless/need.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Type, TypeVar, overload +from typing import Any, Type, TypeVar, overload from typing_extensions import ParamSpec @@ -9,12 +9,11 @@ from stateless.handler import Handler T = TypeVar("T", covariant=True) -T2 = TypeVar("T2") -T3 = TypeVar("T3") + R = TypeVar("R") P = ParamSpec("P") E = TypeVar("E", bound=Exception) -A = TypeVar("A", bound=Ability) +A = TypeVar("A", bound=Ability[Any]) @dataclass(frozen=True) @@ -27,30 +26,36 @@ def need(t: Type[T]) -> Depend[Need[T], T]: return v +T1 = TypeVar("T1") +T2 = TypeVar("T2") +T3 = TypeVar("T3") + + @overload -def supply(v1: T, /) -> Handler[Need[T]]: +def supply(v1: T1, /) -> Handler[Need[T1]]: ... # pragma: no cover @overload -def supply(v1: T, v2: T2, /) -> Handler[Need[T] | Need[T2]]: +def supply(v1: T1, v2: T2, /) -> Handler[Need[T1] | Need[T2]]: ... # pragma: no cover @overload -def supply(v1: T, v2: T2, v3: T3, /) -> Handler[Need[T] | Need[T2] | Need[T3]]: +def supply(v1: T1, v2: T2, v3: T3, /) -> Handler[Need[T1] | Need[T2] | Need[T3]]: ... # pragma: no cover -def supply(first: T, /, *rest: T2) -> Handler[Need[T] | Need[T2]]: +def supply(first: T1, /, *rest: T2) -> Handler[Need[T1] | Need[T2]]: # pyright: ignore # TODO: combine instances with &, or come up with a better way of handling abilities instances = (first, *rest) - def on(ability: Need[T]) -> T: + def on(ability: Need[T1]) -> T1: if not isinstance(ability, Need) or not isinstance(first, ability.t): raise UnhandledAbilityError() for instance in instances: if isinstance(instance, ability.t): return instance + raise UnhandledAbilityError() return Handler(handle=on) diff --git a/src/stateless/schedule.py b/src/stateless/schedule.py index 3bf810a..c5a9458 100644 --- a/src/stateless/schedule.py +++ b/src/stateless/schedule.py @@ -3,12 +3,13 @@ import itertools from dataclasses import dataclass from datetime import timedelta -from typing import Iterator, Protocol, TypeVar +from typing import Any, Iterator, Protocol, TypeVar from typing import NoReturn as Never +from stateless.ability import Ability from stateless.effect import Depend, Success, success -A = TypeVar("A", covariant=True) +A = TypeVar("A", covariant=True, bound=Ability[Any]) class Schedule(Protocol[A]): diff --git a/tests/test_async.py b/tests/test_async.py index 50e2a18..0de72b9 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -58,6 +58,7 @@ def f() -> Depend[Async, str]: assert run(f()) == "hi" -def test_fork_with_process_executor(): +def test_fork_with_process_executor() -> None: with ProcessPoolExecutor() as executor: - assert run(supply(Executor(executor))(fork_say_hi)()) == "hi" + effect = supply(Executor(executor))(fork_say_hi)() + assert run(effect) == "hi" diff --git a/tests/test_effect.py b/tests/test_effect.py index 58dead0..695ed2c 100644 --- a/tests/test_effect.py +++ b/tests/test_effect.py @@ -2,7 +2,6 @@ from typing import NoReturn as Never from pytest import raises - from stateless import ( Effect, Success, @@ -22,6 +21,7 @@ from stateless.need import need from stateless.schedule import Recurs, Spaced from stateless.time import Time + from tests.utils import run_with_abilities diff --git a/tests/test_handler.py b/tests/test_handler.py index c96cf67..0af850f 100644 --- a/tests/test_handler.py +++ b/tests/test_handler.py @@ -1,13 +1,13 @@ from dataclasses import dataclass from pytest import raises -from typing_extensions import Never - from stateless import Depend, Effect, Need, need, run, supply from stateless.ability import Ability from stateless.effect import catch, throws from stateless.errors import MissingAbilityError from stateless.handler import handle +from typing_extensions import Never + from tests.utils import run_with_abilities @@ -119,10 +119,10 @@ def test_handle() -> None: class TestAbility(Ability[None]): pass - def no_annotations(_): + def no_annotations(_): # type: ignore pass - def only_return_annotation(_) -> None: + def only_return_annotation(_) -> None: # type: ignore pass def two_annotations(_: TestAbility, __: str) -> None: @@ -155,5 +155,5 @@ def g() -> Depend[OtherAbility, None]: yield OtherAbility() with raises(MissingAbilityError): - effect = handle(handle_test_ability)(g)() - run(effect) # type: ignore + effect_that_fails = handle(handle_test_ability)(g)() + run(effect_that_fails) # type: ignore diff --git a/tests/test_need.py b/tests/test_need.py index 3fbf4da..5aa56f8 100644 --- a/tests/test_need.py +++ b/tests/test_need.py @@ -1,10 +1,10 @@ from pytest import raises - -from stateless import need, supply, Depend, Need, run +from stateless import Depend, Need, need, run, supply from stateless.errors import MissingAbilityError from tests.utils import run_with_abilities + def test_need() -> None: effect = need(int) assert run_with_abilities(effect, supply(0)) == 0 @@ -24,3 +24,9 @@ def effect() -> Depend[Need[int], int]: frame = info.traceback[6] assert str(frame.path) == __file__ assert frame.lineno == test_need_missing_ability.__code__.co_firstlineno + 1 + + +def test_missing_ability_with_supply() -> None: + with raises(MissingAbilityError): + effect = supply("")(lambda: need(int))() + run(effect) # type: ignore diff --git a/tests/utils.py b/tests/utils.py index e25f068..75474c9 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,9 +1,10 @@ -from typing import TypeVar +from typing import Any, TypeVar from stateless import Effect, Handler, run +from stateless.ability import Ability R = TypeVar("R") -A = TypeVar("A") +A = TypeVar("A", bound=Ability[Any]) def run_with_abilities(effect: Effect[A, Exception, R], abilities: Handler[A]) -> R: From ed7908a2820a3cd30d43bb16a67b1039122b1157 Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Mon, 3 Nov 2025 20:50:46 +0100 Subject: [PATCH 10/31] lint --- src/stateless/ability.py | 5 ++ src/stateless/async_.py | 51 ++++++++++++++++- src/stateless/effect.py | 37 ++++++++++++ src/stateless/errors.py | 2 + src/stateless/handler.py | 32 ++++++++++- src/stateless/need.py | 121 +++++++++++++++++++++++++++++++++++++-- 6 files changed, 240 insertions(+), 8 deletions(-) diff --git a/src/stateless/ability.py b/src/stateless/ability.py index f063263..2c0f706 100644 --- a/src/stateless/ability.py +++ b/src/stateless/ability.py @@ -1,3 +1,5 @@ +"""Module containing the base ability type.""" + from typing import Generator, Generic, Self, TypeVar from stateless.errors import MissingAbilityError @@ -6,7 +8,10 @@ class Ability(Generic[T]): + """The base ability type.""" + def __iter__(self: Self) -> Generator[Self, T, T]: + """Depend on `self` and return the value of handling `self`.""" try: v = yield self except MissingAbilityError: diff --git a/src/stateless/async_.py b/src/stateless/async_.py index ee5987c..4e08dfc 100644 --- a/src/stateless/async_.py +++ b/src/stateless/async_.py @@ -1,6 +1,7 @@ +"""Module for asyncio integration and running effects in parallel.""" + import asyncio from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor -from concurrent.futures.thread import ThreadPoolExecutor from dataclasses import dataclass from functools import wraps from types import TracebackType @@ -31,9 +32,16 @@ @dataclass(frozen=True) class Task(Generic[R]): + """ + Represents a running task, created by `fork`. + + Wraps an asyncio future for the eventual result. + """ + future: asyncio.Future[bytes] | asyncio.Future[R] async def get_result(self) -> R: + """Get the result of this task.""" result = await self.future if isinstance(result, bytes): return cloudpickle.loads(result) # type: ignore @@ -42,6 +50,12 @@ async def get_result(self) -> R: @dataclass(frozen=True) class Async(Ability[Any]): + """ + The Async ability. + + Used for integration with asyncio. + """ + awaitable: Awaitable[Any] @@ -51,6 +65,13 @@ class Async(Ability[Any]): # with either a Process- or ThreadPoolExecutor @dataclass(frozen=True, init=False) class Executor: + """ + Wrapper for `concurrent.futures.Executor`. + + Exists mainly for improved type inference when handling + `Need[Executor]`. + """ + executor: ThreadPoolExecutor | ProcessPoolExecutor def __init__( @@ -62,6 +83,7 @@ def __init__( object.__setattr__(self, "executor", executor) def __enter__(self) -> "Executor": + """Call `__enter__` on the wrapped executor.""" self.executor.__enter__() return self @@ -71,6 +93,7 @@ def __exit__( exc_val: BaseException | None, exc_tb: TracebackType | None, ) -> None: + """Call `__exit__` on the wrapped executor.""" self.executor.__exit__(exc_type, exc_val, exc_tb) @@ -103,6 +126,18 @@ def fork( def fork( f: Callable[P, Success[R] | Effect[Async, E, R]], ) -> Callable[P, Depend[Need[Executor], Task[R]]]: + """ + Run the effect produced by `f` in another thread or process using `Executor`. + + Args: + ---- + f: Function that produces an effect + Returns: + `f` decorated so it runs in a thread or process managed + by `Executor` + + """ + @wraps(f) def decorator(*args: P.args, **kwargs: P.kwargs) -> Depend[Need[Executor], Task[R]]: def thread_target() -> R: @@ -113,7 +148,7 @@ def thread_target() -> R: loop = asyncio.get_running_loop() if isinstance(executor.executor, ProcessPoolExecutor): payload = cloudpickle.dumps((f, args, kwargs)) - future = loop.run_in_executor(executor.executor, process_target, payload) + future = loop.run_in_executor(executor.executor, _process_target, payload) else: future = loop.run_in_executor(executor.executor, thread_target) # type: ignore return Task(future) @@ -121,7 +156,7 @@ def thread_target() -> R: return decorator -def process_target(payload: bytes) -> bytes: +def _process_target(payload: bytes) -> bytes: f, args, kwargs = cloudpickle.loads(payload) result = run(f(*args, **kwargs)) return cast(bytes, cloudpickle.dumps(result)) @@ -138,6 +173,16 @@ def wait(target: Task[R]) -> Effect[Async, E, R]: def wait(target: Coroutine[Any, Any, R] | Task[R]) -> Effect[Async, E, R]: + """ + Wait for the result of `target` using the `Async` ability. + + Args: + ---- + target: The coroutine or task to wait for + Returns: + The value produced by `target`. + + """ # We dont want `Async` to be generic since we don't # want to specify handlers for e.g `Async[int]` and `Async[str]` # separately. They should be handled by the same handler. diff --git a/src/stateless/effect.py b/src/stateless/effect.py index 94dd3ac..2c984c0 100644 --- a/src/stateless/effect.py +++ b/src/stateless/effect.py @@ -35,6 +35,16 @@ async def run_async(effect: Effect[Async, Exception, R]) -> R: + """ + Run an effect asynchronously. + + Args: + ---- + effect: The effect to run + Returns: + The result of running `effect`. + + """ from stateless.async_ import Async try: @@ -57,6 +67,18 @@ async def run_async(effect: Effect[Async, Exception, R]) -> R: def run(effect: Effect[Async, Exception, R]) -> R: + """ + Run an effect. + + Args: + ---- + effect: The effect to run. + + Returns: + ------- + The result of running `effect`. + + """ return asyncio.run(run_async(effect)) @@ -237,6 +259,8 @@ def catch_all(f: Callable[P, Effect[A, E, R]]) -> Callable[P, Depend[A, E | R]]: @dataclass(frozen=True) class Throws(Generic[E2]): + """Provides improved type inference for `throws`.""" + errors: tuple[Type[E2], ...] @overload @@ -266,6 +290,19 @@ def __call__(self, f: Callable[P, R]) -> Callable[P, Try[E2, R]]: def __call__( # type: ignore self, f: Callable[P, Effect[Ability[Any], Exception, R] | R] ) -> Effect[Ability[Any], Exception, R]: + """ + Decorate `f` as to except any instance of `errors` and yield. + + Args: + ---- + f: The function to decorate. + + Returns: + ------- + `f` decorated as to except exceptions and yield them. + + """ + @wraps(f) def decorator( *args: P.args, **kwargs: P.kwargs diff --git a/src/stateless/errors.py b/src/stateless/errors.py index b81ac37..d1f1dfe 100644 --- a/src/stateless/errors.py +++ b/src/stateless/errors.py @@ -10,4 +10,6 @@ class MissingAbilityError(Exception): class UnhandledAbilityError(Exception): + """Raised when a handler is unable to handle an ability.""" + pass diff --git a/src/stateless/handler.py b/src/stateless/handler.py index e910541..c74673a 100644 --- a/src/stateless/handler.py +++ b/src/stateless/handler.py @@ -1,3 +1,5 @@ +"""Types and functions for handling abilities.""" + from dataclasses import dataclass from functools import wraps from typing import ( @@ -24,6 +26,8 @@ @dataclass(frozen=True) class Handler(Generic[A]): + """Handles abilities.""" + # Sadly, complete type safety here requires higher-kinded types. handle: Callable[[A], Any] @@ -50,6 +54,19 @@ def __call__( def __call__( self, f: Callable[P, Effect[A, E, R] | Effect[A | A2, E, R]] ) -> Callable[P, Try[E, R] | Effect[A2, E, R]]: + """ + Decorate `f` as to handle abilities yielded by `f`, or yield them if they can't be handled. + + Args: + ---- + f: Function to decorate. + + Returns: + ------- + `f` decorated as to handle its abilities. + + """ + @wraps(f) def decorator( *args: P.args, **kwargs: P.kwargs @@ -76,6 +93,19 @@ def decorator( def handle(f: Callable[[A2], Any]) -> Handler[A2]: + """ + Instantiate handler by inspecting type annotations. + + Args: + ---- + f: Function that handles an ability. Must be a unary function \ + with its argument annotated as an ability type. + + Returns: + ------- + `Handler` that handles abilities of the type `f` accepts. + + """ d = get_type_hints(f) if len(d) == 0: raise ValueError(f"Handler function {f} was not annotated.") @@ -94,7 +124,7 @@ def handle(f: Callable[[A2], Any]) -> Handler[A2]: f"'handle' uses type annotations to match handlers with abilities, so '{f}' must have exactly " "1 annotated argument." ) - t = list(d.values())[0] + t, *_ = d.values() def on(ability: A2) -> Any: if not isinstance(ability, t): diff --git a/src/stateless/need.py b/src/stateless/need.py index e3bc549..844508e 100644 --- a/src/stateless/need.py +++ b/src/stateless/need.py @@ -1,3 +1,5 @@ +"""Ability for dependency injection.""" + from dataclasses import dataclass from typing import Any, Type, TypeVar, overload @@ -18,10 +20,24 @@ @dataclass(frozen=True) class Need(Ability[T]): + """The Need ability.""" + t: Type[T] def need(t: Type[T]) -> Depend[Need[T], T]: + """ + Create an effect that uses the `Need` ability to return an instance of type `T`. + + Args: + ---- + t: The type to need. + + Returns: + ------- + An instance of `t`. + + """ v = yield from Need(t) return v @@ -29,8 +45,18 @@ def need(t: Type[T]) -> Depend[Need[T], T]: T1 = TypeVar("T1") T2 = TypeVar("T2") T3 = TypeVar("T3") - - +T4 = TypeVar("T4") +T5 = TypeVar("T5") +T6 = TypeVar("T6") +T7 = TypeVar("T7") +T8 = TypeVar("T8") +T9 = TypeVar("T9") + + +# Using overloads here since using just variadics would result +# in an inferred return type `Handler[Need[T1 | T2 | ...]] +# which would not eliminate the abilities correctly when using +# Handler.__call__ @overload def supply(v1: T1, /) -> Handler[Need[T1]]: ... # pragma: no cover @@ -46,8 +72,95 @@ def supply(v1: T1, v2: T2, v3: T3, /) -> Handler[Need[T1] | Need[T2] | Need[T3]] ... # pragma: no cover -def supply(first: T1, /, *rest: T2) -> Handler[Need[T1] | Need[T2]]: # pyright: ignore - # TODO: combine instances with &, or come up with a better way of handling abilities +@overload +def supply( + v1: T1, v2: T2, v3: T3, v4: T4, / +) -> Handler[Need[T1] | Need[T2] | Need[T3] | Need[T4]]: + ... # pragma: no cover + + +@overload +def supply( + v1: T1, v2: T2, v3: T3, v4: T4, v5: T5, / +) -> Handler[Need[T1] | Need[T2] | Need[T3] | Need[T4] | Need[T5]]: + ... # pragma: no cover + + +@overload +def supply( + v1: T1, v2: T2, v3: T3, v4: T4, v5: T5, v6: T6, / +) -> Handler[Need[T1] | Need[T2] | Need[T3] | Need[T4] | Need[T5] | Need[T6]]: + ... # pragma: no cover + + +@overload +def supply( + v1: T1, v2: T2, v3: T3, v4: T4, v5: T5, v6: T6, v7: T7, / +) -> Handler[ + Need[T1] | Need[T2] | Need[T3] | Need[T4] | Need[T5] | Need[T6] | Need[T7] +]: + ... # pragma: no cover + + +@overload +def supply( + v1: T1, v2: T2, v3: T3, v4: T4, v5: T5, v6: T6, v7: T7, v8: T8, / +) -> Handler[ + Need[T1] + | Need[T2] + | Need[T3] + | Need[T4] + | Need[T5] + | Need[T6] + | Need[T7] + | Need[T8] +]: + ... # pragma: no cover + + +@overload +def supply( + v1: T1, v2: T2, v3: T3, v4: T4, v5: T5, v6: T6, v7: T7, v8: T8, v9: T9, / +) -> Handler[ + Need[T1] + | Need[T2] + | Need[T3] + | Need[T4] + | Need[T5] + | Need[T6] + | Need[T7] + | Need[T8] + | Need[T9] +]: + ... # pragma: no cover + + +def supply( # type: ignore + first: T1, /, *rest: T2 | T3 | T4 | T5 | T6 | T7 | T8 | T9 +) -> Handler[ + Need[T1] + | Need[T2] + | Need[T3] + | Need[T4] + | Need[T5] + | Need[T6] + | Need[T7] + | Need[T8] + | Need[T9] +]: + """ + Handle a `Need` ability by supplying instances of type `T`. + + Args: + ---- + first: The first instance to supply. + rest: The remaining instances to supply, variadically. + + Returns: + ------- + `Handler` that handles `Need[T1] | Need[T2] | ... `. + + """ instances = (first, *rest) def on(ability: Need[T1]) -> T1: From 3bed9a2f1f8584b4b41a6648ec7cc0b4b5a36b30 Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Mon, 3 Nov 2025 21:22:34 +0100 Subject: [PATCH 11/31] readme another runthrough --- README.md | 107 +++++++++++++++++++++++++++----------- src/stateless/__init__.py | 2 +- 2 files changed, 78 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index f3a0983..0489032 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Programming with side-effects is hard: To reason about a unit in your code, like Programming without side-effects is _less_ hard: To reason about a unit in you code, like a function, you can focus on what _that_ function is doing, since the units it interacts with don't affect the state of the program in any way. -But of course side-effects can't be avoided, since what we ultimately care about in programming are just that: The side effects, such as printing to the console or writing to a database. +But of course side-effects can't be avoided, since what we ultimately care about in programming are the side effects, such as printing to the console or writing to a database. Functional effect systems like `stateless` aim to make programming with side-effects less hard. We do this by separating the specification of side-effects from the interpretation, such that functions that need to perform side effects do so indirectly via the effect system. @@ -58,7 +58,7 @@ def read_file(path: str) -> Effect[Need[Files], Never, str]: # Simple effects can be combined into complex ones by # depending on multiple abilities. -def print_file(path: str) -> Effect[Need[Files] | Need[Console], Never, None]: +def process_file(path: str) -> Effect[Need[Files] | Need[Console], Never, None]: # catch will return exceptions yielded by other functions result = yield from catch(OSError)(read_file)(path) match result: @@ -241,16 +241,18 @@ def depend_on_both_abilities() -> Depend[SomeAbility | AnotherAbility, None]: yield from g() ``` -One way to think about abilities is as a generalization of exceptions: when a function needs to have an ability handled it passes the ability up the call stack until an appropriate handler is found, similar to how a raised exception travels up the call stack. In contrast with exception handling however, once the ability is handled, the result is returned to function that yielded the ability, and execution resumes. +One way to think about abilities is as a generalization of exceptions: when a function needs to have an ability handled it passes the ability up the call stack until an appropriate handler is found, similar to how a raised exception travels up the call stack. In contrast with exception handling however, once the ability is handled, the result of handling the ability is returned to function that yielded it in the first place, and execution resumes. Like exceptions, abilities can be partially handled (with type-safety): ```python -handle: Handler[SomeAbility] = ... -effect = handle(depend_on_both_abilities)() -reveal_type(effect) # revealed type is: Depend[AnotherAbility, None] +handle_some_ability: Handler[SomeAbility] = ... +effect = handle_some_ability(depend_on_both_abilities)() +reveal_type(effect) # Revealed type is: Depend[AnotherAbility, None] ``` +The revealed type indicates that `handle_some_ability` has handled `SomeAbility` of `depend_on_both_abilities`, so it now only depends on `AnotherAbility`. + ## Error Handling 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.` @@ -332,13 +334,10 @@ Consequently you can use your type checker to avoid unintentionally unhandled er your type checker can see and understand which errors are handled where: ```python -def fails_in_multiple_ways() -> Try[FileNotFoundError | PermissionError | IsADirectoryError, str]: - ... - def handle_subset_of_errors() -> Try[PermissionError, str]: - result = yield from catch(FileNotFoundError, IsADirectoryError)(fails_in_multiple_ways)() + result = yield from catch(FileNotFoundError)(read_file)('foo.txt') match result: - case FileNotFoundError() | IsADirectoryError(): + case FileNotFoundError(): return 'default value' case _: return result @@ -354,8 +353,8 @@ This means that: `Need` is a an ability for type-safe dependency injection. By "type-safe" we mean: -- Functions with dependencies can't fail to report a dependency in its type signature without a type error. -- You can't run effects with dependencies without handling them. +- Functions with dependencies can't fail to report a dependency in its type signature without a type-checker error. +- You can't run effects with dependencies without handling them without a type-checker error. `Need` is defined as: @@ -380,6 +379,12 @@ from stateless import Depend, Need, need class Console: def print(self, line: str) -> None: print(line) +``` + +`Need` is used by calling the `need` function: + +```python +from stateless import Need, need def say_hello() -> Depend[Need[Console], None]: @@ -406,7 +411,7 @@ effect = test_handler()(say_hello)('foo.txt') run(effect) ``` -Our type-checker will likely infer the types `console` to be `MockConsole`, so we have moved the initialization to a function with the annotated return type `Handler[Console`]. Otherwise, our type checker will not be able to infer that the handler in fact handles the `Console` ability of `say_hello`. +Our type-checker will likely infer the type of`console` to be `MockConsole`, so we have moved the initialization to a function with the annotated return type `Handler[Console`]. Otherwise, our type checker will not be able to infer that the handler in fact handles the `Console` ability of `say_hello`. ### Async The `Async` ability is used to run code asynchronously, either with `asyncio` or `concurrent.futures`. @@ -436,14 +441,16 @@ from stateless import Async def run[R](effect: Effect[Async, Exception, R]) -> R: ... ``` -The reason `run` does not need the `Async` effect handled is because `stateless` just calls `asyncio.run` to run `asyncio` coroutines when executing effects. If you want to defer execution of coroutines, for example when integrating `stateless` with another framework that manages the event loop, for example an ASGI server, you can use `stateless.run_async` defined as: +`stateless` has another run function, `run_async`. that gives us a hint how this works: ```python -async def run_async[R](effect: Effect[Async, Exception, R]) -> R: ... +from stateless import Async + +async def run_async(effect: Effect[Async, Exception, R]) -> R: ... ``` -(in fact `stateless.run(effect)` just calls `asyncio.run(run_async(effect))`). +`run_async` simply awaits `asyncio` coroutines yielded by effects. The reason `stateless.run` does not need the `Async` effect handled is because `stateless.run` just calls `asyncio.run(run_async(effect))`. To run effects in other process/threads, use `stateless.fork`, defined as: @@ -453,6 +460,46 @@ To run effects in other process/threads, use `stateless.fork`, defined as: def fork[**P, R](f: Callable[P, Try[Exception, R]]) -> Callable[P, Depend[Need[Executor], Task[R]]]: ... ``` +`stateless.Task` is a type that represents an effect executing in a another process or thread. You can access the result of a task by using `stateless.wait`: + + +```python +from stateless import fork, wait, Success, Depend, Need, Executor + + +def do_something() -> Success[float]: ... + + +def do_something_async() -> Depend[Need[Executor] | Async, float]: + task = yield from fork(do_something)() + result = yield from wait(task) + return result +``` + +`stateless.Executor` is simply a wrapper for `concurrent.futures.Executor`. It exists solely to allow you to handle the `Need[Executor]` ability with type safety: + +```python +from stateless import Executor, supply, run + + +with Executor() as executor: + effect = supply(executor)(do_something_async)() + run(effect) +``` + +If you want fine-grained control over the executor being used, pass it to `stateless.Executor`: + +```python +from concurrent.futures import ProcessPoolExecutor + +from stateless import Executor, supply, run + + +with ProcessPoolExecutor as pool: + effect = supply(Executor(pool))(do_something_async)() + run(effect) +``` + `fork` will simply call `stateless.run` in the remote process/thread, so all abilities of `f` must be handled before forking. Moreover, all unhandled errors yielded by `f` will be raised in the remote thread, so if you want to handle errors from forked effect in the main process/thread, you need to use `stateless.catch` before forking: @@ -481,10 +528,10 @@ A `stateless.Schedule` is a type with an `__iter__` method that returns an effec from typing import Protocol, Iterator from datetime import timedelta -from stateless import Depend +from stateless import Depend, Ability -class Schedule[A](Protocol): +class Schedule[A: Ability](Protocol): def __iter__(self) -> Depend[A, Iterator[timedelta]]: ... ``` @@ -497,7 +544,7 @@ Schedules can be used with the `repeat` decorator, which takes schedule as its f ```python from datetime import timedelta -from stateless import repeat, success, Success, Abilities, run +from stateless import repeat, success, Success, supply, run from stateless.schedule import Recurs, Spaced from stateless.time import Time @@ -506,11 +553,11 @@ from stateless.time import Time def f() -> Success[str]: return success("hi!") -effect = Abilities().add(Time()).handle(f)() +effect = supply(Time())(f)() result = run(effect) print(run) # outputs: ("hi!", "hi!") ``` -Effects created through repeat depends on the `Time` ability from `stateless.time` because it needs to sleep between each execution of the effect. +Effects created through repeat depends on the `Need[stateless.Time]` because it needs to sleep between each execution of the effect. Schedules are a good example of a pattern used a lot in `stateless`: Classes with an `__iter__` method that returns effects. @@ -526,13 +573,13 @@ def this_works() -> Success[timedelta]: For example, `repeat` needs to yield from the schedule given as its argument to repeat the decorated function. If the schedule was just a generator it would only be possible to yield from the schedule the first time `f` in this example was called. -`stateless.retry` is like `repeat`, except that it returns succesfully +`stateless.retry` is like `repeat`, except that it returns successfully. when the decorated function yields no errors, or fails when the schedule is exhausted: ```python from datetime import timedelta -from stateless import retry, throw, Try, throw, success, Abilities, run +from stateless import retry, throw, Try, throw, success, supply, run from stateless.schedule import Recurs, Spaced from stateless.time import Time @@ -550,7 +597,7 @@ def f() -> Try[RuntimeError, str]: return success('Hooray!') -effect = Abilities().add(Time()).handel(f)() +effect = supply(Time())(f)() result = run(effect) print(result) # outputs: 'Hooray!' ``` @@ -561,24 +608,24 @@ Effects can be memoized using the `stateless.memoize` decorator: ```python -from stateless import memoize, Depend, Abilities, run +from stateless import memoize, Depend, supply, run, Need, supply from stateless.console import Console, print_line @memoize -def f() -> Depend[Console, str]: +def f() -> Depend[Need[Console], str]: yield from print_line('f was called') return 'done' -def g() -> Depend[Console, tuple[str, str]]: +def g() -> Depend[Need[Console], tuple[str, str]]: first = yield from f() second = yield from f() return first, second -effect = Abilities().add(Console()).handle(f)() -result = run(effect) # outputs: 'f was called' once, even though the effect was yielded twice +effect = supply(Console())(f)() +result = run(effect) # outputs: 'f was called' once, even though the effect `f()` was yielded from twice print(result) # outputs: ('done', 'done') ``` diff --git a/src/stateless/__init__.py b/src/stateless/__init__.py index b796238..7b7d08a 100644 --- a/src/stateless/__init__.py +++ b/src/stateless/__init__.py @@ -3,7 +3,7 @@ # ruff: noqa: F401 from stateless.ability import Ability -from stateless.async_ import Async, Executor, fork, wait +from stateless.async_ import Async, Executor, Task, fork, wait from stateless.effect import ( Depend, Effect, From e00d916271ee393319a9b5aac9ab9888fc919b00 Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Mon, 3 Nov 2025 21:27:17 +0100 Subject: [PATCH 12/31] fix supply --- src/stateless/need.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stateless/need.py b/src/stateless/need.py index 844508e..e41da55 100644 --- a/src/stateless/need.py +++ b/src/stateless/need.py @@ -164,7 +164,7 @@ def supply( # type: ignore instances = (first, *rest) def on(ability: Need[T1]) -> T1: - if not isinstance(ability, Need) or not isinstance(first, ability.t): + if not isinstance(ability, Need): raise UnhandledAbilityError() for instance in instances: if isinstance(instance, ability.t): From 0b877a8241fc37383fd5a8adb31cfa46d1b18f8a Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Mon, 3 Nov 2025 21:33:23 +0100 Subject: [PATCH 13/31] lint for python < 3.13 --- src/stateless/effect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stateless/effect.py b/src/stateless/effect.py index 2c984c0..255b7de 100644 --- a/src/stateless/effect.py +++ b/src/stateless/effect.py @@ -363,7 +363,7 @@ def throw( error: BaseException | object | None = None, exc_tb: TracebackType | None = None, /, - ) -> Type[A] | E: + ) -> A | E: """Throw an exception into the effect.""" try: From 7d53f9af32eca67750fb5e1e5938fa5468b42e6b Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Mon, 3 Nov 2025 21:37:55 +0100 Subject: [PATCH 14/31] fix imports for python < 3.11 --- src/stateless/ability.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/stateless/ability.py b/src/stateless/ability.py index 2c0f706..dace923 100644 --- a/src/stateless/ability.py +++ b/src/stateless/ability.py @@ -1,6 +1,8 @@ """Module containing the base ability type.""" -from typing import Generator, Generic, Self, TypeVar +from typing import Generator, Generic, TypeVar + +from typing_extensions import Self from stateless.errors import MissingAbilityError From e7d0d89b58cc03153318424189f11744a0e1cbe6 Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Mon, 3 Nov 2025 21:46:49 +0100 Subject: [PATCH 15/31] upload report in tests --- .github/actions/test/action.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/actions/test/action.yml b/.github/actions/test/action.yml index c9ceb0a..98bcf20 100644 --- a/.github/actions/test/action.yml +++ b/.github/actions/test/action.yml @@ -16,3 +16,11 @@ runs: - name: run tests shell: bash run: poetry run just test + - name: generate report + shell: bash + run: poetry run coverage html + - name: upload report + uses: actions/upload-artifact@v4 + with: + name: py-${{ inputs.python-version }}-${{ github.sha }}-coverage-report + path: htmlcov/ From 530a1738eb49ce7f70377bcda1663eebb26035ee Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Mon, 3 Nov 2025 21:49:06 +0100 Subject: [PATCH 16/31] upload report in tests --- .github/actions/test/action.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/actions/test/action.yml b/.github/actions/test/action.yml index 98bcf20..954cd2d 100644 --- a/.github/actions/test/action.yml +++ b/.github/actions/test/action.yml @@ -17,9 +17,11 @@ runs: shell: bash run: poetry run just test - name: generate report + if: failure() shell: bash run: poetry run coverage html - name: upload report + if: failure() uses: actions/upload-artifact@v4 with: name: py-${{ inputs.python-version }}-${{ github.sha }}-coverage-report From 192cf66e6d473fdbaf7abcf56a904bb7bba044f4 Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Mon, 3 Nov 2025 21:52:45 +0100 Subject: [PATCH 17/31] fix coverage --- src/stateless/effect.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/stateless/effect.py b/src/stateless/effect.py index 255b7de..3fb3c9e 100644 --- a/src/stateless/effect.py +++ b/src/stateless/effect.py @@ -103,7 +103,7 @@ def throw( ) -> Never: """Throw an exception in this effect.""" raise exc_type - else: + else: # pragma: no cover def throw(self, value: Exception, /) -> Never: # type: ignore """Throw an exception in this effect.""" @@ -371,7 +371,7 @@ def throw( except StopIteration as e: object.__setattr__(self, "_memoized_result", e.value) raise e - else: + else: # pragma: no cover def throw(self, value: Exception, /) -> A | E: # type: ignore """Throw an exception into the effect.""" From 0d42f3783bcababf73ee6ee0e5b8105c9271ffbb Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Tue, 4 Nov 2025 08:45:33 +0100 Subject: [PATCH 18/31] fix tests --- tests/test_handler.py | 17 +++-------------- tests/test_need.py | 5 ++++- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/tests/test_handler.py b/tests/test_handler.py index 0af850f..b7bedd8 100644 --- a/tests/test_handler.py +++ b/tests/test_handler.py @@ -1,3 +1,4 @@ +import sys from dataclasses import dataclass from pytest import raises @@ -57,20 +58,8 @@ def effect() -> Depend[Need[Super], Super]: # expression in `effect` function above # (first is stateless.run(..) # second is effect.throw in Runtime.run) - frame = info.traceback[6] - assert str(frame.path) == __file__ - assert frame.lineno == effect.__code__.co_firstlineno - - -def test_missing_dependency_with_abilities() -> None: - def effect() -> Depend[Need[Super], Super]: - ability: Super = yield from need(Super) - return ability - - with raises(MissingAbilityError, match="Super") as info: - run(effect()) # type: ignore - - frame = info.traceback[6] + index = 6 if sys.version_info > (3, 11) else 5 + frame = info.traceback[index] assert str(frame.path) == __file__ assert frame.lineno == effect.__code__.co_firstlineno diff --git a/tests/test_need.py b/tests/test_need.py index 5aa56f8..d1bd09d 100644 --- a/tests/test_need.py +++ b/tests/test_need.py @@ -1,3 +1,5 @@ +import sys + from pytest import raises from stateless import Depend, Need, need, run, supply from stateless.errors import MissingAbilityError @@ -21,7 +23,8 @@ def effect() -> Depend[Need[int], int]: assert str(frame.path) == __file__ assert frame.lineno == test_need_missing_ability.__code__.co_firstlineno + 4 - frame = info.traceback[6] + index = 6 if sys.version_info > (3, 11) else 5 + frame = info.traceback[index] assert str(frame.path) == __file__ assert frame.lineno == test_need_missing_ability.__code__.co_firstlineno + 1 From c44f2c98038beb8c413f66feae3ce4879e3da08f Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Tue, 4 Nov 2025 08:49:46 +0100 Subject: [PATCH 19/31] improve readme --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 0489032..85e68c1 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ As a result, "business logic" code never performs side-effects, which makes it e ```python from typing import Any, Never + from stateless import Effect, Need, need, throws, catch, run @@ -84,6 +85,7 @@ run(effect) ```python from typing import Any, Generator + from stateless import Ability From 0393a8eec5296629c4f7c47f88f1b9c29b2ad938 Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Tue, 4 Nov 2025 08:50:31 +0100 Subject: [PATCH 20/31] readme typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 85e68c1..2897146 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ type Effect[A: Ability, E: Exception, R] = Generator[A | E, Any, R] ``` In other words, an `Effect` is a generator that can yield values of type `A` or exceptions of type `E`, can be sent anything, and returns results of type `R`. Let's break that down a bit further: -- The type parameter `A` stands for _"Ability"_. This is the type of value, or types of values, that must be handled in order the effect to produce its result. +- The type parameter `A` stands for _"Ability"_. This is the type of value, or types of values, that must be handled in order for the effect to produce its result. - The type parameter `E` stands for _"Error"_. This the type of errors that an effect might fail with. From e79c17807bba18cc0d4efaed1a618f5375c7ac94 Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Tue, 4 Nov 2025 08:51:19 +0100 Subject: [PATCH 21/31] improve readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2897146..3023782 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ In other words, an `Effect` is a generator that can yield values of type `A` or - The type parameter `A` stands for _"Ability"_. This is the type of value, or types of values, that must be handled in order for the effect to produce its result. - - The type parameter `E` stands for _"Error"_. This the type of errors that an effect might fail with. + - The type parameter `E` stands for _"Error"_. This the type of error, or types of errors, that an effect might fail with. - The type parameter `R` stands for _"Result"_. This is the type of value that an `Effect` will produce if no errors occur. From bcc1dafd44e8b48be09f4efeddf2b3de55120516 Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Tue, 4 Nov 2025 10:03:17 +0100 Subject: [PATCH 22/31] improve readme --- README.md | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3023782..c24ea00 100644 --- a/README.md +++ b/README.md @@ -118,9 +118,13 @@ type Success[R] = Effect[Never, Never, R] # for effects that don't fail Lets define a simple ability. `stateless.Ability` is defined as: ```python +from typing import Self + + class Ability[R]: - ... + def __iter__(self: Self) -> Generator[Self, R, R]: ... ``` + The `R` type parameter represents the expected result of handling the effect. For example: ```python @@ -135,6 +139,11 @@ class Greet(Ability[str]): When `Greet` inherits from `Ability[str]`, it means that when a function yields an instance of `Greet`, the function should expect that the result of handling `Greet` has type `str`. +You may recall that the "send" type of `stateless.Effect` is `Any`. This is because functions using effects may depend on multiple abilities that return different types of values when handled, +so in general we can't say what the "send" type should be. + +The `Abilities.__iter__` method is a way to get around this. The send and return types are `R`, which allows your type-checker to correctly infer the type of handling an ability by using `yield from`. + Let's use `Greet`: ```python @@ -144,7 +153,7 @@ from stateless import Effect def hello_world() -> Effect[Greet, Never, None]: - greeting = yield Greet(name="world") + greeting = yield from Greet(name="world") print(greeting) ``` @@ -243,7 +252,7 @@ def depend_on_both_abilities() -> Depend[SomeAbility | AnotherAbility, None]: yield from g() ``` -One way to think about abilities is as a generalization of exceptions: when a function needs to have an ability handled it passes the ability up the call stack until an appropriate handler is found, similar to how a raised exception travels up the call stack. In contrast with exception handling however, once the ability is handled, the result of handling the ability is returned to function that yielded it in the first place, and execution resumes. +One way to think about abilities is as a generalization of exceptions: when a function needs to have an ability handled it passes the ability up the call stack until an appropriate handler is found, similar to how a raised exception travels up the call stack. In contrast with exception handling however, once the ability is handled, the result of handling the ability is returned to the function that yielded it in the first place, and execution resumes. Like exceptions, abilities can be partially handled (with type-safety): @@ -453,12 +462,16 @@ async def run_async(effect: Effect[Async, Exception, R]) -> R: ... ``` `run_async` simply awaits `asyncio` coroutines yielded by effects. The reason `stateless.run` does not need the `Async` effect handled is because `stateless.run` just calls `asyncio.run(run_async(effect))`. +This also means that it is always safe to call e.g `asyncio.get_running_loop` from functions that return effects. To run effects in other process/threads, use `stateless.fork`, defined as: ```python +from stateless import Task, Depend, Need, Executor, Try + + def fork[**P, R](f: Callable[P, Try[Exception, R]]) -> Callable[P, Depend[Need[Executor], Task[R]]]: ... ``` @@ -504,7 +517,7 @@ with ProcessPoolExecutor as pool: `fork` will simply call `stateless.run` in the remote process/thread, so all abilities of `f` must be handled before forking. -Moreover, all unhandled errors yielded by `f` will be raised in the remote thread, so if you want to handle errors from forked effect in the main process/thread, you need to use `stateless.catch` before forking: +Moreover, all unhandled errors yielded by `f` will be raised in the remote thread/process, so if you want to handle errors from forked effects in the main process/thread, you need to use `stateless.catch` before forking: ```python From 6ccf9feed1e2160179abb976db9e011e064214be Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Tue, 4 Nov 2025 10:04:50 +0100 Subject: [PATCH 23/31] improve readme --- README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/README.md b/README.md index c24ea00..cd5beaa 100644 --- a/README.md +++ b/README.md @@ -192,12 +192,6 @@ effect = handle(greet)(hello_world)() reveal_type(effect) # revealed type is: Effect[Never, Never, None] ``` -> [!NOTE] -> `stateless.handle` depends on type annotations of the handler function to match abilities with handler functions. To use `stateless.handle` you must annotate the argument of the handler function with an appropriate ability. -> -> `stateless.handle` just uses `isinstance` to match abilities -> with handlers, so handling abilities with type parameters may not work as expected. - We can see in the revealed type how `handle(greet)` has eliminated the `Greet` ability from the effect returned by `hello_world`, and the type is now `Never`, meaning the new effect does not require any abilities. To run effects you'll use `stateless.run`. Its type signature is: From 1cef97b1a4b22a2b2959f212ad9364b9d2194d26 Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Tue, 4 Nov 2025 11:25:37 +0100 Subject: [PATCH 24/31] improve readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index cd5beaa..8222453 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ from dataclasses import dataclass from stateless import Ability + @dataclass class Greet(Ability[str]): name: str From 02375a24213e7a938f1ba92b28535112bd87a040 Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Tue, 4 Nov 2025 11:26:06 +0100 Subject: [PATCH 25/31] improve readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8222453..262cfb1 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ class Greet(Ability[str]): When `Greet` inherits from `Ability[str]`, it means that when a function yields an instance of `Greet`, the function should expect that the result of handling `Greet` has type `str`. -You may recall that the "send" type of `stateless.Effect` is `Any`. This is because functions using effects may depend on multiple abilities that return different types of values when handled, +You may recall that the "send" type of `stateless.Effect` is `Any`. This is because functions that return effects may depend on multiple abilities that return different types of values when handled, so in general we can't say what the "send" type should be. The `Abilities.__iter__` method is a way to get around this. The send and return types are `R`, which allows your type-checker to correctly infer the type of handling an ability by using `yield from`. From 78c0d0db7bb299b75b216b52202ed02695c793d2 Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Tue, 4 Nov 2025 15:37:08 +0100 Subject: [PATCH 26/31] improve readme --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 262cfb1..fc8d762 100644 --- a/README.md +++ b/README.md @@ -190,10 +190,10 @@ def greet(ability: Greet) -> str: effect = handle(greet)(hello_world)() -reveal_type(effect) # revealed type is: Effect[Never, Never, None] +reveal_type(effect) # revealed type is: Success[None] ``` -We can see in the revealed type how `handle(greet)` has eliminated the `Greet` ability from the effect returned by `hello_world`, and the type is now `Never`, meaning the new effect does not require any abilities. +We can see in the revealed type how `handle(greet)` has eliminated the `Greet` ability from the effect returned by `hello_world`, so that it is now a `Success[None]` (or `Effect[Never, Never, None]`), meaning the new effect does not require any abilities. To run effects you'll use `stateless.run`. Its type signature is: @@ -408,7 +408,7 @@ class MockConsole(Console): pass -def test_handler() -> Handler[Console]: +def test_handler() -> Handler[Need[Console]]: console = MockConsole() return supply(console) @@ -417,7 +417,7 @@ effect = test_handler()(say_hello)('foo.txt') run(effect) ``` -Our type-checker will likely infer the type of`console` to be `MockConsole`, so we have moved the initialization to a function with the annotated return type `Handler[Console`]. Otherwise, our type checker will not be able to infer that the handler in fact handles the `Console` ability of `say_hello`. +Our type-checker will likely infer the type of`console` to be `MockConsole`, so we have moved the initialization to a function with the annotated return type `Handler[Need[Console]]`. Otherwise, our type checker will not be able to infer that the handler in fact handles the `Console` ability of `say_hello`. ### Async The `Async` ability is used to run code asynchronously, either with `asyncio` or `concurrent.futures`. From 9e1603f20af39eb5753c8e8bad0e068b79b4f501 Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Tue, 4 Nov 2025 22:54:12 +0100 Subject: [PATCH 27/31] improve readme and simplify using subtypes with supply --- README.md | 86 +++++++++++++++++++------------------- poetry.lock | 78 +++++++++++++++++----------------- pyproject.toml | 1 - src/stateless/__init__.py | 4 +- src/stateless/async_.py | 47 ++------------------- src/stateless/functions.py | 20 ++++++++- tests/test_async.py | 13 +++--- 7 files changed, 113 insertions(+), 136 deletions(-) diff --git a/README.md b/README.md index fc8d762..1a18f76 100644 --- a/README.md +++ b/README.md @@ -158,9 +158,9 @@ def hello_world() -> Effect[Greet, Never, None]: print(greeting) ``` -When `hello_world` returns an `Effect[Greet, Never, None]`, it means that it depends on the `Greet` ability (`A` is parameterized with `Greet`). It can't fail (`E` is parameterized with `Never`), and it doesn't produce a value (`R` is parameterized with `None`). +When `hello_world` returns an `Effect[Greet, Never, None]`, it means that it depends on the `Greet` ability (`A` is parameterized with `Greet`). It doesn't produce errors (`E` is parameterized with `Never`), and it doesn't return a value (`R` is parameterized with `None`). -To run an `Effect` that depends on abilities, you need to handle all of the abilities of that effect. Abilities are handled using `stateless.Handler`, defined as: +To run an `Effect` that depends on abilities, you need to handle all of the abilities yielded by that effect. Abilities are handled using `stateless.Handler`, defined as: ```python from stateless import Ability, Effect @@ -274,14 +274,14 @@ from stateless import Effect, Try def throws[E2: Exception, E: Exception, A: Ability, R]( - *errors: Type[E2], + *errors: Type[E], ) -> Callable[ - [Callable[P, Effect[A, E, R] | R]], + [Callable[P, Effect[A, E2, R] | R]], Callable[P, Effect[A, E | E2, R] | Try[E2, R]] ]: ... ``` -In words, `throws` catches exceptions of type `E2`, and yields them. +In words, `throws` returns a decorator that catches exceptions of type `E` raised by the decorated function, and yields them. Let's use `throws` to model the potential errors when reading a file. @@ -315,7 +315,7 @@ def catch[**P, A, E: Exception, E2: Exception, R]( In words, the `catch` decorator catches errors of type `E` and moves the error from the error type `E` of the `Effect` produced by the decorated function, to the result type `R` of the effect of the return function. -This means you can access the potential errors directly in your code: +For example: ```python @@ -357,21 +357,20 @@ This means that: ### Need -`Need` is a an ability for type-safe dependency injection. By "type-safe" we mean: +`Need` is an ability for type-safe dependency injection. By "type-safe" we mean: - Functions with dependencies can't fail to report a dependency in its type signature without a type-checker error. - You can't run effects with dependencies without handling them without a type-checker error. -`Need` is defined as: +`Need` is used by calling the `need` function. Its signature is: ```python from typing import Type -from stateless import Ability +from stateless import Need, Depend -class Need[T](Ability[T]): - t: Type[T] +def need[T](t: Type[T]) -> Depend[Need[T], T]: ... ``` `T` could be anything, but will often be types that can perform side-effects. @@ -385,12 +384,6 @@ from stateless import Depend, Need, need class Console: def print(self, line: str) -> None: print(line) -``` - -`Need` is used by calling the `need` function: - -```python -from stateless import Need, need def say_hello() -> Depend[Need[Console], None]: @@ -399,32 +392,46 @@ def say_hello() -> Depend[Need[Console], None]: ``` A major purpose of dependency injection is to vary the injected ability to change the behavior of the effect. For example, we -might want to change the behavior of `say_hello` in tests: +might want to change the behavior of `say_hello` in tests. Lets define a subtype of `Console` to use in a test: ```python class MockConsole(Console): def print(self, line: str) -> None: pass +``` +When trying to handle `Need[Console]` with `supply(MockConsole())`, you may need to explicitly tell your type checker that `supply(MockConsole())` has type `Handler[Console]`. For some type checkers this can be done with an explicit annotation. If you use a type checker that uses local type narrowing however, such as pyright, this is harder than you might expect. +To assist with type inference for type checkers with local type narrowing, stateless supplies a utility function `as_type`, that tells your type checker to treat a subtype as a supertype in a certain context. -def test_handler() -> Handler[Need[Console]]: - console = MockConsole() - return supply(console) +Lets use `as_type` with `supply`: +```python +from stateless import as_type, supply -effect = test_handler()(say_hello)('foo.txt') +console = as_type(Console)(MockConsole()) +effect = supply(console)(say_hello)('foo.txt') run(effect) ``` - -Our type-checker will likely infer the type of`console` to be `MockConsole`, so we have moved the initialization to a function with the annotated return type `Handler[Need[Console]]`. Otherwise, our type checker will not be able to infer that the handler in fact handles the `Console` ability of `say_hello`. +Using `as_type`, our type checker has correctly inferred that the `Need[Console]` ability yielded by `say_hello` was eliminated by `supply(console)`. ### Async The `Async` ability is used to run code asynchronously, either with `asyncio` or `concurrent.futures`. -to use the result of an `asyncio` coroutine, use the `stateless.wait` function: +to use the result of an `asyncio` coroutine in an effect, use the `stateless.wait` function. Its defined as: + + +```python +from typing import Awaitable + +from stateless import Depend +def wait[R](target: Awaitable[R]) -> Depend[Async, R]: ... +``` +In words, `wait` translates an `Awaitable` into an `Effect` that depends on the `Async` ability. + +For example: ```python from stateless import wait, Async, Depend @@ -453,6 +460,7 @@ def run[R](effect: Effect[Async, Exception, R]) -> R: ... ```python from stateless import Async + async def run_async(effect: Effect[Async, Exception, R]) -> R: ... ``` @@ -464,17 +472,19 @@ To run effects in other process/threads, use `stateless.fork`, defined as: ```python +from concurrent.futures import Executor from stateless import Task, Depend, Need, Executor, Try def fork[**P, R](f: Callable[P, Try[Exception, R]]) -> Callable[P, Depend[Need[Executor], Task[R]]]: ... ``` -`stateless.Task` is a type that represents an effect executing in a another process or thread. You can access the result of a task by using `stateless.wait`: +`stateless.Task` is a type that represents an effect executing in a another process or thread. `stateless.wait` is in fact overloaded to allow you to access the result of a task: ```python -from stateless import fork, wait, Success, Depend, Need, Executor +from concurrent.futures import Executor +from stateless import fork, wait, Success, Depend, Need, Async def do_something() -> Success[float]: ... @@ -486,30 +496,18 @@ def do_something_async() -> Depend[Need[Executor] | Async, float]: return result ``` -`stateless.Executor` is simply a wrapper for `concurrent.futures.Executor`. It exists solely to allow you to handle the `Need[Executor]` ability with type safety: +To handle the `Need[Executor]` ability yielded by `fork`, use `concurrent.futures.ThreadPoolExecutor` or `concurrent.futures.ProcessPoolExecutor`. Since these are subtypes of `concurrent.futures.Executor`, you may need to use `stateless.as_type` depending on the type inference algorithm used by your type checker: ```python -from stateless import Executor, supply, run +from concurrent.futures import ThreadPoolExecutor, Executor +from stateless import as_type, supply, run - -with Executor() as executor: +executor = as_type(Executor)(ThreadPoolExecutor()) +with executor: effect = supply(executor)(do_something_async)() run(effect) ``` -If you want fine-grained control over the executor being used, pass it to `stateless.Executor`: - -```python -from concurrent.futures import ProcessPoolExecutor - -from stateless import Executor, supply, run - - -with ProcessPoolExecutor as pool: - effect = supply(Executor(pool))(do_something_async)() - run(effect) -``` - `fork` will simply call `stateless.run` in the remote process/thread, so all abilities of `f` must be handled before forking. Moreover, all unhandled errors yielded by `f` will be raised in the remote thread/process, so if you want to handle errors from forked effects in the main process/thread, you need to use `stateless.catch` before forking: diff --git a/poetry.lock b/poetry.lock index 309e9ea..be0ef17 100644 --- a/poetry.lock +++ b/poetry.lock @@ -325,50 +325,50 @@ traitlets = "*" [[package]] name = "mypy" -version = "1.18.1" +version = "1.18.2" description = "Optional static typing for Python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "mypy-1.18.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2761b6ae22a2b7d8e8607fb9b81ae90bc2e95ec033fd18fa35e807af6c657763"}, - {file = "mypy-1.18.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5b10e3ea7f2eec23b4929a3fabf84505da21034a4f4b9613cda81217e92b74f3"}, - {file = "mypy-1.18.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:261fbfced030228bc0f724d5d92f9ae69f46373bdfd0e04a533852677a11dbea"}, - {file = "mypy-1.18.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4dc6b34a1c6875e6286e27d836a35c0d04e8316beac4482d42cfea7ed2527df8"}, - {file = "mypy-1.18.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1cabb353194d2942522546501c0ff75c4043bf3b63069cb43274491b44b773c9"}, - {file = "mypy-1.18.1-cp310-cp310-win_amd64.whl", hash = "sha256:738b171690c8e47c93569635ee8ec633d2cdb06062f510b853b5f233020569a9"}, - {file = "mypy-1.18.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6c903857b3e28fc5489e54042684a9509039ea0aedb2a619469438b544ae1961"}, - {file = "mypy-1.18.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2a0c8392c19934c2b6c65566d3a6abdc6b51d5da7f5d04e43f0eb627d6eeee65"}, - {file = "mypy-1.18.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f85eb7efa2ec73ef63fc23b8af89c2fe5bf2a4ad985ed2d3ff28c1bb3c317c92"}, - {file = "mypy-1.18.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82ace21edf7ba8af31c3308a61dc72df30500f4dbb26f99ac36b4b80809d7e94"}, - {file = "mypy-1.18.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a2dfd53dfe632f1ef5d161150a4b1f2d0786746ae02950eb3ac108964ee2975a"}, - {file = "mypy-1.18.1-cp311-cp311-win_amd64.whl", hash = "sha256:320f0ad4205eefcb0e1a72428dde0ad10be73da9f92e793c36228e8ebf7298c0"}, - {file = "mypy-1.18.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:502cde8896be8e638588b90fdcb4c5d5b8c1b004dfc63fd5604a973547367bb9"}, - {file = "mypy-1.18.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7509549b5e41be279afc1228242d0e397f1af2919a8f2877ad542b199dc4083e"}, - {file = "mypy-1.18.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5956ecaabb3a245e3f34100172abca1507be687377fe20e24d6a7557e07080e2"}, - {file = "mypy-1.18.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8750ceb014a96c9890421c83f0db53b0f3b8633e2864c6f9bc0a8e93951ed18d"}, - {file = "mypy-1.18.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fb89ea08ff41adf59476b235293679a6eb53a7b9400f6256272fb6029bec3ce5"}, - {file = "mypy-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:2657654d82fcd2a87e02a33e0d23001789a554059bbf34702d623dafe353eabf"}, - {file = "mypy-1.18.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d70d2b5baf9b9a20bc9c730015615ae3243ef47fb4a58ad7b31c3e0a59b5ef1f"}, - {file = "mypy-1.18.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b8367e33506300f07a43012fc546402f283c3f8bcff1dc338636affb710154ce"}, - {file = "mypy-1.18.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:913f668ec50c3337b89df22f973c1c8f0b29ee9e290a8b7fe01cc1ef7446d42e"}, - {file = "mypy-1.18.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a0e70b87eb27b33209fa4792b051c6947976f6ab829daa83819df5f58330c71"}, - {file = "mypy-1.18.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c378d946e8a60be6b6ede48c878d145546fb42aad61df998c056ec151bf6c746"}, - {file = "mypy-1.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:2cd2c1e0f3a7465f22731987fff6fc427e3dcbb4ca5f7db5bbeaff2ff9a31f6d"}, - {file = "mypy-1.18.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ba24603c58e34dd5b096dfad792d87b304fc6470cbb1c22fd64e7ebd17edcc61"}, - {file = "mypy-1.18.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ed36662fb92ae4cb3cacc682ec6656208f323bbc23d4b08d091eecfc0863d4b5"}, - {file = "mypy-1.18.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:040ecc95e026f71a9ad7956fea2724466602b561e6a25c2e5584160d3833aaa8"}, - {file = "mypy-1.18.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:937e3ed86cb731276706e46e03512547e43c391a13f363e08d0fee49a7c38a0d"}, - {file = "mypy-1.18.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1f95cc4f01c0f1701ca3b0355792bccec13ecb2ec1c469e5b85a6ef398398b1d"}, - {file = "mypy-1.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:e4f16c0019d48941220ac60b893615be2f63afedaba6a0801bdcd041b96991ce"}, - {file = "mypy-1.18.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e37763af63a8018308859bc83d9063c501a5820ec5bd4a19f0a2ac0d1c25c061"}, - {file = "mypy-1.18.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:51531b6e94f34b8bd8b01dee52bbcee80daeac45e69ec5c36e25bce51cbc46e6"}, - {file = "mypy-1.18.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbfdea20e90e9c5476cea80cfd264d8e197c6ef2c58483931db2eefb2f7adc14"}, - {file = "mypy-1.18.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99f272c9b59f5826fffa439575716276d19cbf9654abc84a2ba2d77090a0ba14"}, - {file = "mypy-1.18.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8c05a7f8c00300a52f3a4fcc95a185e99bf944d7e851ff141bae8dcf6dcfeac4"}, - {file = "mypy-1.18.1-cp39-cp39-win_amd64.whl", hash = "sha256:2fbcecbe5cf213ba294aa8c0b8c104400bf7bb64db82fb34fe32a205da4b3531"}, - {file = "mypy-1.18.1-py3-none-any.whl", hash = "sha256:b76a4de66a0ac01da1be14ecc8ae88ddea33b8380284a9e3eae39d57ebcbe26e"}, - {file = "mypy-1.18.1.tar.gz", hash = "sha256:9e988c64ad3ac5987f43f5154f884747faf62141b7f842e87465b45299eea5a9"}, + {file = "mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c"}, + {file = "mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e"}, + {file = "mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b"}, + {file = "mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66"}, + {file = "mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428"}, + {file = "mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed"}, + {file = "mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f"}, + {file = "mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341"}, + {file = "mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d"}, + {file = "mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86"}, + {file = "mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37"}, + {file = "mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8"}, + {file = "mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34"}, + {file = "mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764"}, + {file = "mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893"}, + {file = "mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914"}, + {file = "mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8"}, + {file = "mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074"}, + {file = "mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc"}, + {file = "mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e"}, + {file = "mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986"}, + {file = "mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d"}, + {file = "mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba"}, + {file = "mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544"}, + {file = "mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce"}, + {file = "mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d"}, + {file = "mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c"}, + {file = "mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb"}, + {file = "mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075"}, + {file = "mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf"}, + {file = "mypy-1.18.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:25a9c8fb67b00599f839cf472713f54249a62efd53a54b565eb61956a7e3296b"}, + {file = "mypy-1.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c2b9c7e284ee20e7598d6f42e13ca40b4928e6957ed6813d1ab6348aa3f47133"}, + {file = "mypy-1.18.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6985ed057513e344e43a26cc1cd815c7a94602fb6a3130a34798625bc2f07b6"}, + {file = "mypy-1.18.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f27105f1525ec024b5c630c0b9f36d5c1cc4d447d61fe51ff4bd60633f47ac"}, + {file = "mypy-1.18.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:030c52d0ea8144e721e49b1f68391e39553d7451f0c3f8a7565b59e19fcb608b"}, + {file = "mypy-1.18.2-cp39-cp39-win_amd64.whl", hash = "sha256:aa5e07ac1a60a253445797e42b8b2963c9675563a94f11291ab40718b016a7a0"}, + {file = "mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e"}, + {file = "mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b"}, ] [package.dependencies] diff --git a/pyproject.toml b/pyproject.toml index 7db5c3c..c8bcb1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,6 @@ ignore = ["D107", "D213", "D203", "D202", "D212"] [tool.ruff.lint.per-file-ignores] "tests/**/*" = ["D100", "D101", "D102", "D103", "D104", "D105", "D107"] - [tool.coverage.report] exclude_also = [ "@overload" diff --git a/src/stateless/__init__.py b/src/stateless/__init__.py index 7b7d08a..f222534 100644 --- a/src/stateless/__init__.py +++ b/src/stateless/__init__.py @@ -3,7 +3,7 @@ # ruff: noqa: F401 from stateless.ability import Ability -from stateless.async_ import Async, Executor, Task, fork, wait +from stateless.async_ import Async, Task, fork, wait from stateless.effect import ( Depend, Effect, @@ -17,7 +17,7 @@ throw, throws, ) -from stateless.functions import repeat, retry +from stateless.functions import as_type, repeat, retry from stateless.handler import Handler, handle from stateless.need import Need, need, supply from stateless.schedule import Schedule diff --git a/src/stateless/async_.py b/src/stateless/async_.py index 4e08dfc..977d8ef 100644 --- a/src/stateless/async_.py +++ b/src/stateless/async_.py @@ -1,10 +1,9 @@ """Module for asyncio integration and running effects in parallel.""" import asyncio -from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor +from concurrent.futures import Executor, ProcessPoolExecutor from dataclasses import dataclass from functools import wraps -from types import TracebackType from typing import ( Any, Awaitable, @@ -59,44 +58,6 @@ class Async(Ability[Any]): awaitable: Awaitable[Any] -# this exists only for type inference purposes, -# specifally that `Need[Executor]` can be -# eliminated by handling the need ability -# with either a Process- or ThreadPoolExecutor -@dataclass(frozen=True, init=False) -class Executor: - """ - Wrapper for `concurrent.futures.Executor`. - - Exists mainly for improved type inference when handling - `Need[Executor]`. - """ - - executor: ThreadPoolExecutor | ProcessPoolExecutor - - def __init__( - self, executor: ThreadPoolExecutor | ProcessPoolExecutor | None = None - ): - if not executor: - executor = ThreadPoolExecutor() - - object.__setattr__(self, "executor", executor) - - def __enter__(self) -> "Executor": - """Call `__enter__` on the wrapped executor.""" - self.executor.__enter__() - return self - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: TracebackType | None, - ) -> None: - """Call `__exit__` on the wrapped executor.""" - self.executor.__exit__(exc_type, exc_val, exc_tb) - - @overload def fork( f: Callable[P, Success[R]], @@ -146,11 +107,11 @@ def thread_target() -> R: executor = yield from need(Executor) loop = asyncio.get_running_loop() - if isinstance(executor.executor, ProcessPoolExecutor): + if isinstance(executor, ProcessPoolExecutor): payload = cloudpickle.dumps((f, args, kwargs)) - future = loop.run_in_executor(executor.executor, _process_target, payload) + future = loop.run_in_executor(executor, _process_target, payload) else: - future = loop.run_in_executor(executor.executor, thread_target) # type: ignore + future = loop.run_in_executor(executor, thread_target) # type: ignore return Task(future) return decorator diff --git a/src/stateless/functions.py b/src/stateless/functions.py index 2c24134..7a9911c 100644 --- a/src/stateless/functions.py +++ b/src/stateless/functions.py @@ -1,7 +1,7 @@ """Functions for working with effects.""" from functools import wraps -from typing import Any, Callable, Generic, ParamSpec, Tuple, TypeVar +from typing import Any, Callable, Generic, ParamSpec, Tuple, Type, TypeVar from stateless.ability import Ability from stateless.async_ import Async @@ -114,3 +114,21 @@ def wrapper( return wrapper return decorator + + +def as_type(t: Type[R]) -> Callable[[R], R]: + """ + Create an identity function with additional type information. + + Args: + ---- + t: The (super)type to consider the result of the identity function + Returns: + The identity function. + + """ + + def _(v: R) -> R: + return v + + return _ diff --git a/tests/test_async.py b/tests/test_async.py index 0de72b9..0bc51ed 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -1,12 +1,13 @@ -from concurrent.futures import ProcessPoolExecutor +from concurrent.futures import Executor, ProcessPoolExecutor +from concurrent.futures.thread import ThreadPoolExecutor from threading import Event from stateless import ( Async, Depend, - Executor, Need, Success, + as_type, fork, run, success, @@ -26,7 +27,7 @@ def fork_say_hi() -> Depend[Need[Executor] | Async, str]: def test_fork_and_wait() -> None: - with Executor() as executor: + with as_type(Executor)(ThreadPoolExecutor()) as executor: assert run(supply(executor)(fork_say_hi)()) == "hi" @@ -40,7 +41,7 @@ def f() -> Success[None]: def g() -> Depend[Need[Executor], None]: yield from fork(f)() - with Executor() as executor: + with as_type(Executor)(ThreadPoolExecutor()) as executor: effect = supply(executor)(g)() run(effect) @@ -59,6 +60,6 @@ def f() -> Depend[Async, str]: def test_fork_with_process_executor() -> None: - with ProcessPoolExecutor() as executor: - effect = supply(Executor(executor))(fork_say_hi)() + with as_type(Executor)(ProcessPoolExecutor()) as executor: + effect = supply(executor)(fork_say_hi)() assert run(effect) == "hi" From 5953898273b4573bd00dc7995de36de3b3691248 Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Tue, 4 Nov 2025 23:02:21 +0100 Subject: [PATCH 28/31] improve readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1a18f76..f25b638 100644 --- a/README.md +++ b/README.md @@ -351,7 +351,7 @@ def handle_subset_of_errors() -> Try[PermissionError, str]: This means that: - You can't neglect to report an error in the signature for `handle_subset_of_errors` without a type-checker error, since your type checker can tell that `yield from catch(...)(fails_in_multiple_ways)` will still yield `PermissionError` -- You can't neglect to handle errors in your code without a type-checker error because your type checker can tell that `result` may be 2 different errors or a string. +- You can't neglect to handle errors in your code without a type-checker error because your type checker can tell that `result` may be `FileNotFoundError` or `str`. ## Built-in Abilities From 706ffe354a9bc41c6b61b5cc85483ca870c5b0be Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Tue, 4 Nov 2025 23:04:39 +0100 Subject: [PATCH 29/31] improve readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f25b638..89c6550 100644 --- a/README.md +++ b/README.md @@ -424,7 +424,7 @@ to use the result of an `asyncio` coroutine in an effect, use the `stateless.wai ```python from typing import Awaitable -from stateless import Depend +from stateless import Depend, Async def wait[R](target: Awaitable[R]) -> Depend[Async, R]: ... From d362e9b5d872de0765b4dee99ace6e8349a2bf65 Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Tue, 4 Nov 2025 23:06:18 +0100 Subject: [PATCH 30/31] improve readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 89c6550..6843f81 100644 --- a/README.md +++ b/README.md @@ -468,7 +468,7 @@ async def run_async(effect: Effect[Async, Exception, R]) -> R: ... This also means that it is always safe to call e.g `asyncio.get_running_loop` from functions that return effects. -To run effects in other process/threads, use `stateless.fork`, defined as: +To run effects in other process/threads, use the `stateless.fork` decorator, defined as: ```python From b0118526f099929d9cfd86a5455193b0a8b09532 Mon Sep 17 00:00:00 2001 From: Sune Debel <1228354+suned@users.noreply.github.com> Date: Tue, 4 Nov 2025 23:11:51 +0100 Subject: [PATCH 31/31] improve readme --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6843f81..444b004 100644 --- a/README.md +++ b/README.md @@ -561,7 +561,8 @@ from stateless.time import Time def f() -> Success[str]: return success("hi!") -effect = supply(Time())(f)() +time = Time() +effect = supply(time)(f)() result = run(effect) print(run) # outputs: ("hi!", "hi!") ``` @@ -604,8 +605,8 @@ def f() -> Try[RuntimeError, str]: else: return success('Hooray!') - -effect = supply(Time())(f)() +time = Time() +effect = supply(time)(f)() result = run(effect) print(result) # outputs: 'Hooray!' ``` @@ -631,8 +632,8 @@ def g() -> Depend[Need[Console], tuple[str, str]]: second = yield from f() return first, second - -effect = supply(Console())(f)() +console = Console() +effect = supply(console)(f)() result = run(effect) # outputs: 'f was called' once, even though the effect `f()` was yielded from twice print(result) # outputs: ('done', 'done')