diff --git a/.github/actions/test/action.yml b/.github/actions/test/action.yml index c9ceb0a..954cd2d 100644 --- a/.github/actions/test/action.yml +++ b/.github/actions/test/action.yml @@ -16,3 +16,13 @@ runs: - name: run tests 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 + path: htmlcov/ diff --git a/README.md b/README.md index 1495ac5..444b004 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. @@ -17,14 +17,16 @@ 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 +40,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 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) # 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 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: @@ -69,438 +69,239 @@ def print_file(path: str) -> Effect[Files | Console, Never, None]: yield from print_(content) +# Before an effect can be executed, all of its abilities must be handled. +# The `Need` ability is handled using `stateless.supply`. +effect = supply(Files(), Console())(print_file)('foo.txt') # Effects are run using `stateless.run`. -# Abilities are provided to effects via `Abilities.handle` -# 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') 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: ```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: +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 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. - We'll see shortly why the _"send"_ type of effects must be `Any`, and how `stateless` can still provide good type inference. +`A` and `E` of `stateless.Effect` are often parameterized with `Never`, so +stateless provides the following type aliases to save you some typing: - -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) -``` - -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`). - -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: - - -```python -from stateless import Effect - +from stateless import Ability, Effect -class Ability[A]: - def add[A2](self, ability: A2) -> Ability[A | A2]: - ... - def handle[**P, A2, E: Exception, R](self, Callable[P, Effect[A | A2, E, R]]) -> Callable[P, Effect[A2, E, R]]: - ... +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 ``` -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. -`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`. -For example, we can use `Abilities` to handle the `str` ability required by `hello_world`: +Lets define a simple ability. `stateless.Ability` is defined as: ```python -abilities = Abilities().add('Hello world!') -effect = abilities.handle(hello_world)() -reveal_type(effect) # revealed type is: Effect[Never, Never, None] -``` - -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. +from typing import Self -To run effects you'll use `stateless.run`. Its type signature is: - -```python -def run[R](effect: Effect[Never, Exception, R]) -> R: - ... +class Ability[R]: + def __iter__(self: Self) -> Generator[Self, R, 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`. - -Let's run `hello_world`: - -```python -from stateless import run, Abilities - -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: +The `R` type parameter represents the expected result of handling the effect. For example: ```python -from stateless import run, Abilities +from dataclasses import dataclass -abilities = Abilities().add('Hello world!') -effect = abilities.handle(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 `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. +from stateless import Ability -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. -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. - -We'll start with an ability we'll call `Console` for writing to the console: - -```python -class Console: - def print(self, line: str) -> None: - print(line) +@dataclass +class Greet(Ability[str]): + name: str ``` -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: - -```python -from typing import Type -from stateless import Depend +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 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. -def depend[A](ability: Type[A]) -> Depend[A, A]: - ... -``` +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`. -`stateless.Depend` is a type alias: +Let's use `Greet`: ```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 +from stateless import Effect -def say_hello() -> Depend[Console, None]: - console = yield from depend(Console) - console.print(f"Hello, world!") +def hello_world() -> Effect[Greet, Never, None]: + greeting = yield from Greet(name="world") + print(greeting) ``` -Let's add another ability `Files` to read rom the file system: +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 yielded by that effect. Abilities are handled using `stateless.Handler`, defined as: ```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 +from stateless import Ability, Effect -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) +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]]: + ... ``` -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 - - -def get_int() -> Depend[str | int, tuple[str, int]]: - s = yield from get_str() - i = yield from depend(int) - return (s, i) -``` +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. -you can of course run `print_file` with `Abilities` and `run`: +`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 Abilities, run - +from stateless import handle -abilities = Abilities().add(Files()).add(Console()) -effect = abilities.handle(print_file)('foo.txt') -run(effect) -``` -`Abilities` also allows us to partially provide abilities for an effect: +def greet(ability: Greet) -> str: + return f"Hello, {ability.name}!" -```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] +effect = handle(greet)(hello_world)() +reveal_type(effect) # revealed type is: Success[None] ``` -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. +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. -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: +To run effects you'll use `stateless.run`. Its type signature is: ```python -class MockConsole(Console): - def print(self, line: str) -> None: - 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]: - console = MockConsole() - files = MockFiles("mock content") - return Abilities().add(console).add(files)) - -abilities = mock_abilities() -effect = abilities.handle(print_file)('foo.txt') -run(effect) +def run[R](effect: Effect[Async, Exception, R]) -> R: + ... ``` -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: +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`. +If we try to do: ```python -from typing import Never +from stateless import run -type Success[R] = Effect[Never, Never, R] +run(hello_world()) # type-checker error! ``` -for effects that don't fail and don't require abilities (can be easily instantiated using the `stateless.success` function). +We'll get a type-checker error since we can't run an effect with unhandled abilities. -Secondly, the `Depend` type alias, defined as: +Lets try this instead: ```python -from typing import Never - -type Depend[A, R] = Effect[A, Never, R] +effect = handle(greet)(hello_world)() +run(effect) # outputs: Hello, world! ``` - -for effects that depend on `A` but produces no errors. +Since we've handled the `Greet` ability for `hello_world`, we can now run the resulting effect with no type checker errors. -Finally the `Try` type alias, defined as: +To access the result type of an effect from another effect, use `yield from`: ```python -from typing import Never - +def f() -> Success[float]: ... -type Try[E, R] = Effect[Never, E, R] +def g() -> Success[float]: + number = yield from f() + return number * 2 ``` -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: +Simple effects can be combined into complex effects by depending on multiple abilities: ```python -from stateless import Depend +def depend_on_some_ability() -> Depend[SomeAbility, None]: ... +def depend_on_another_ability() -> Depend[AnotherAbility, None]: ... -class Config: - ... - - -def main() -> Depend[Config, None]: - ... +def depend_on_both_abilities() -> Depend[SomeAbility | AnotherAbility, None]: + yield from f() + yield from g() ``` -Now imagine that you want to provide the `Config` ability by reading from environment variables: +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): ```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'] - ) +handle_some_ability: Handler[SomeAbility] = ... +effect = handle_some_ability(depend_on_both_abilities)() +reveal_type(effect) # Revealed type is: Depend[AnotherAbility, None] ``` -To supply the `Config` instance returned from `get_config`, we can use `Abilities.add_effect`: - - -```python -from stateless import Abilities - - -Abilities().add(OS()).add_effect(get_config()) -``` - -`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 - - -class A: - pass - - -class B: - pass - - -def get_B() -> Depend[A, B]: - ... - -Abilities().add(A()).add_effect(get_B()) # OK -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.) +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.` -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`: +The main way to turn exceptions into errors of effects is using `stateless.throws`. Its signature is: ```python -from stateless import Effect, throw +from typing import Type, Callable +from stateless import Effect, Try -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]: +def throws[E2: Exception, E: Exception, A: Ability, R]( + *errors: Type[E], +) -> Callable[ + [Callable[P, Effect[A, E2, 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))`. +In words, `throws` returns a decorator that catches exceptions of type `E` raised by the decorated function, and yields them. -More conveniently you can use `stateless.throws` that just catches exceptions and yields them as an effect +Let's use `throws` to model the potential errors when reading a file. ```python -from stateless import Depend, throws - +from stateless import Effect, throws -@throws(OSError) -def read_file(path: str) -> Depend[Files, str]: - files = yield Files - return files.read_file(path) +@throws(FileNotFoundError, PermissionError) +def read_file(path: str) -> str: + with open(path) as f: + return f.read() -reveal_type(read_file) # revealed type is: def (str) -> Effect[Files, OSError, str] +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 from typing import Type -from stateless import Effect, Depend +from stateless import Effect def catch[**P, A, E: Exception, E2: Exception, R]( @@ -512,220 +313,221 @@ 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. + +For example: ```python -from stateless import Depend +from typing import reveal_type + +from stateless import Success -def handle_errors() -> Depend[Files, str]: - result: OSError | str = yield from catch(OSError)(read_file)('foo.txt') +def handle_errors() -> Success[str]: + 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. -`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 -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 ``` 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 `FileNotFoundError` or `str`. + +## Built-in Abilities +### Need -`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. +`Need` is an ability for type-safe dependency injection. By "type-safe" we mean: -For example, we could have defined catch like this: +- 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 used by calling the `need` function. Its signature is: ```python -def bad_catch(effect: Effect[A, E, R]) -> Depend[A, E | R]: - ... -``` +from typing import Type -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. +from stateless import Need, Depend -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: +def need[T](t: Type[T]) -> Depend[Need[T], T]: ... +``` + +`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 -def f() -> Depend[str, int]: - ... +from stateless import Depend, Need, need -def dont_do_this(e: Depend[str, int]) -> Depend[str, int]: - i = yield from e - return i +class Console: + def print(self, line: str) -> None: + print(line) -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) +def say_hello() -> Depend[Need[Console], None]: + console = yield from need(Console) + console.print(f"Hello, world!") ``` -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: + +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. Lets define a subtype of `Console` to use in a test: + ```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 +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. + +Lets use `as_type` with `supply`: +```python +from stateless import as_type, supply -def this_is_easy(): - e = f() - r = yield from do_this_instead(f)() - r_again = yield from e - return (r, r_again) +console = as_type(Console)(MockConsole()) +effect = supply(console)(say_hello)('foo.txt') +run(effect) ``` +Using `as_type`, our type checker has correctly inferred that the `Need[Console]` ability yielded by `say_hello` was eliminated by `supply(console)`. -## Parallel Effects -Two challenges present themselves when running generator based effects in parallel: +### Async +The `Async` ability is used to run code asynchronously, either with `asyncio` or `concurrent.futures`. -- Generators aren't thread-safe. -- Generators can't be pickled. +to use the result of an `asyncio` coroutine in an effect, use the `stateless.wait` function. Its defined as: -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. +```python +from typing import Awaitable -`stateless` provides two decorators for instantiating `Task` instances: `stateless.parallel.thread` and `stateless.parallel.process`. Their signatures are: +from stateless import Depend, Async +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 typing import Callable +from stateless import wait, Async, Depend -from stateless import Effect -from stateless.parallel import Task +async def do_io() -> str: ... -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]]: - ... +def use_io() -> Depend[Async, str]: + result = yield from wait(do_io()) + return result ``` -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: +Recall that the signature of `stateless.run` is: ```python -from stateless import Effect -from stateless.parallel import Parallel +from stateless import Async -def parallel[A, E: Exception, R](*tasks: Task[A, E, R]) -> Effect[A | Parallel, E, tuple[R, ...]]: - ... +def run[R](effect: Effect[Async, Exception, R]) -> 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. +`stateless` has another run function, `run_async`. that gives us a hint how this works: -Here is a full example: ```python -from stateless import parallel, Success, success, Depend -from stateless.parallel import thread, process, Parallel +from stateless import Async -def sing() -> Success[str]: - return success("🎵") +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 the `stateless.fork` decorator, defined as: -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 +from concurrent.futures import Executor +from stateless import Task, Depend, Need, Executor, Try -with Parallel() as ability: - effect = Abilities().add(ability).handle(duet)() - result = run(effect) - print(result) # outputs: ("🎵", "🎵") +def fork[**P, R](f: Callable[P, Try[Exception, R]]) -> Callable[P, Depend[Need[Executor], Task[R]]]: ... ``` -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. +`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: -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 concurrent.futures import Executor +from stateless import fork, wait, Success, Depend, Need, Async -from stateless.parallel import Parallel +def do_something() -> Success[float]: ... -with ( - Manager() as manager, - manager.Pool() as pool, - ThreadPool() as thread_pool, - Parallel(thread_pool, pool) as parallel -): - ... + +def do_something_async() -> Depend[Need[Executor] | Async, float]: + task = yield from fork(do_something)() + result = yield from wait(task) + return result ``` -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. +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 concurrent.futures import ThreadPoolExecutor, Executor +from stateless import as_type, supply, run -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`: +executor = as_type(Executor)(ThreadPoolExecutor()) +with executor: + effect = supply(executor)(do_something_async)() + run(effect) +``` -```python -from stateless import Abilities, Effect, run -from stateless.parallel import Parallel, Task +`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: -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) +```python +def may_fail() -> Try[OSError, str]: ... + - 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) +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 ``` + ## 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: @@ -734,10 +536,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]]: ... ``` @@ -750,7 +552,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 @@ -759,11 +561,12 @@ from stateless.time import Time def f() -> Success[str]: return success("hi!") -effect = Abilities().add(Time()).handle(f)() +time = Time() +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. @@ -779,13 +582,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 @@ -802,8 +605,8 @@ def f() -> Try[RuntimeError, str]: else: return success('Hooray!') - -effect = Abilities().add(Time()).handel(f)() +time = Time() +effect = supply(time)(f)() result = run(effect) print(result) # outputs: 'Hooray!' ``` @@ -814,24 +617,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 +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') ``` 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/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/poetry.lock b/poetry.lock index 01db8b4..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] @@ -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] @@ -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]] @@ -800,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]] @@ -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..c8bcb1d 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" @@ -34,13 +34,28 @@ warn_return_any = true strict_equality = true disallow_any_generics = true -[tool.ruff] +[[tool.mypy.overrides]] +module = [ + "cloudpickle", +] +ignore_missing_imports = true + +[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"] +[tool.coverage.report] +exclude_also = [ + "@overload" +] + +[tool.coverage.run] +concurrency = ["multiprocessing", "thread"] +source = ["src/"] + [build-system] requires = ["poetry-core"] diff --git a/src/stateless/__init__.py b/src/stateless/__init__.py index e940100..f222534 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, Task, 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.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/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..dace923 --- /dev/null +++ b/src/stateless/ability.py @@ -0,0 +1,21 @@ +"""Module containing the base ability type.""" + +from typing import Generator, Generic, TypeVar + +from typing_extensions import Self + +from stateless.errors import MissingAbilityError + +T = TypeVar("T", covariant=True) + + +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: + 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..977d8ef --- /dev/null +++ b/src/stateless/async_.py @@ -0,0 +1,158 @@ +"""Module for asyncio integration and running effects in parallel.""" + +import asyncio +from concurrent.futures import Executor, ProcessPoolExecutor +from dataclasses import dataclass +from functools import wraps +from typing import ( + Any, + Awaitable, + Callable, + Coroutine, + Generic, + ParamSpec, + TypeVar, + cast, + overload, +) + +import cloudpickle + +from stateless.ability import Ability +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[Any]) +E = TypeVar("E", bound=Exception) +B = TypeVar("B") + + +@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 + return result + + +@dataclass(frozen=True) +class Async(Ability[Any]): + """ + The Async ability. + + Used for integration with asyncio. + """ + + awaitable: Awaitable[Any] + + +@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 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: + result = run(f(*args, **kwargs)) + return result + + executor = yield from need(Executor) + loop = asyncio.get_running_loop() + if isinstance(executor, ProcessPoolExecutor): + payload = cloudpickle.dumps((f, args, kwargs)) + future = loop.run_in_executor(executor, _process_target, payload) + else: + future = loop.run_in_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]: + ... + + +@overload +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. + # 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 cast(R, 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..3fb3c9e 100644 --- a/src/stateless/effect.py +++ b/src/stateless/effect.py @@ -1,38 +1,72 @@ """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[Any]) 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] +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] -class NoResultError(Exception): - """Raised when an effect has no result. +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`. - If this error is raised to user code - it should be considered a bug in stateless. """ + from stateless.async_ import Async + + try: + ability_or_error = next(effect) + while True: + match ability_or_error: + 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 + ability_or_error = effect.throw(error) + case ability: + # At this point all abilities should be handled, + # so any ability request indicates a missing ability + ability_or_error = effect.throw(MissingAbilityError(ability)) + except StopIteration as e: + return cast(R, e.value) -def run(effect: Try[Exception, R]) -> R: +def run(effect: Effect[Async, Exception, R]) -> R: """ Run an effect. @@ -45,22 +79,7 @@ def run(effect: Try[Exception, R]) -> R: The result of running `effect`. """ - while True: - try: - ability_or_error = next(effect) - match ability_or_error: - case sentinel if sentinel == PARALLEL_SENTINEL: - effect.send(()) - case Exception() as error: - # at this point this is an exception - # not handled with stateless.catch anywhere - effect.throw(error) - case ability_type: - # 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) + return asyncio.run(run_async(effect)) @dataclass(frozen=True) @@ -84,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.""" @@ -158,7 +177,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,29 +257,71 @@ 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. +@dataclass(frozen=True) +class Throws(Generic[E2]): + """Provides improved type inference for `throws`.""" - Args: - ---- - ability: The ability to yield. + errors: tuple[Type[E2], ...] - Returns: - ------- - An effect that yields the ability and returns the ability sent from the runtime. + @overload + def __call__(self, f: Callable[P, Success[R]]) -> Callable[P, Try[E2, R]]: + ... - """ - try: - a = yield ability - except MissingAbilityError as e: - raise MissingAbilityError(*e.args) from None - return cast(A, a) + @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__( # type: ignore + 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]: + """ + 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 + ) -> Effect[Ability[Any], Exception, R]: + try: + result = f(*args, **kwargs) + if isinstance(result, Generator): + result = yield from result + + return result # pyright: 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. @@ -273,18 +334,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) @@ -294,7 +344,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: @@ -313,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: @@ -321,9 +371,9 @@ 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, /) -> Type[A] | E: # type: ignore + def throw(self, value: Exception, /) -> A | E: # type: ignore """Throw an exception into the effect.""" try: @@ -349,7 +399,7 @@ def memoize( ... # pragma: no cover -def memoize( # type: ignore +def memoize( f: Callable[P, Effect[A, E, R]] | None = None, *, maxsize: int | None = None, @@ -372,7 +422,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/errors.py b/src/stateless/errors.py index b1af6e8..d1f1dfe 100644 --- a/src/stateless/errors.py +++ b/src/stateless/errors.py @@ -7,3 +7,9 @@ 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): + """Raised when a handler is unable to handle an ability.""" + + 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..7a9911c 100644 --- a/src/stateless/functions.py +++ b/src/stateless/functions.py @@ -1,14 +1,17 @@ """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, Type, 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[Any]) +A2 = TypeVar("A2", bound=Ability[Any]) 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: @@ -111,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/src/stateless/handler.py b/src/stateless/handler.py new file mode 100644 index 0000000..c74673a --- /dev/null +++ b/src/stateless/handler.py @@ -0,0 +1,134 @@ +"""Types and functions for handling abilities.""" + +from dataclasses import dataclass +from functools import wraps +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 +from stateless.errors import UnhandledAbilityError + +E = TypeVar("E", bound=Exception) +A = TypeVar("A", covariant=True, bound=Ability[Any]) +A2 = TypeVar("A2", bound=Ability[Any]) +R = TypeVar("R") +P = ParamSpec("P") + + +@dataclass(frozen=True) +class Handler(Generic[A]): + """Handles abilities.""" + + # Sadly, complete type safety here requires higher-kinded types. + handle: Callable[[A], Any] + + @overload + def __call__(self, f: Callable[P, Depend[A, R]]) -> Callable[P, Success[R]]: + ... # pragma: no cover + + @overload + 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, Depend[A | A2, R]]) -> Callable[P, Depend[A2, 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]]: + """ + 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 + ) -> 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: + yield error # type: ignore + case ability: + try: + value = self.handle(ability) # type: ignore + 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 + + +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.") + 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, *_ = d.values() + + def on(ability: A2) -> Any: + if not isinstance(ability, t): + raise UnhandledAbilityError() + return f(ability) + + return Handler(on) diff --git a/src/stateless/need.py b/src/stateless/need.py new file mode 100644 index 0000000..e41da55 --- /dev/null +++ b/src/stateless/need.py @@ -0,0 +1,174 @@ +"""Ability for dependency injection.""" + +from dataclasses import dataclass +from typing import Any, Type, TypeVar, overload + +from typing_extensions import ParamSpec + +from stateless.ability import Ability +from stateless.effect import Depend +from stateless.errors import UnhandledAbilityError +from stateless.handler import Handler + +T = TypeVar("T", covariant=True) + +R = TypeVar("R") +P = ParamSpec("P") +E = TypeVar("E", bound=Exception) +A = TypeVar("A", bound=Ability[Any]) + + +@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 + + +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 + + +@overload +def supply(v1: T1, v2: T2, /) -> Handler[Need[T1] | Need[T2]]: + ... # pragma: no cover + + +@overload +def supply(v1: T1, v2: T2, v3: T3, /) -> Handler[Need[T1] | Need[T2] | Need[T3]]: + ... # pragma: no cover + + +@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: + if not isinstance(ability, Need): + raise UnhandledAbilityError() + for instance in instances: + if isinstance(instance, ability.t): + return instance + raise UnhandledAbilityError() + + return Handler(handle=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/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/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 deleted file mode 100644 index 34515ee..0000000 --- a/tests/test_abilities.py +++ /dev/null @@ -1,147 +0,0 @@ -from dataclasses import dataclass - -from pytest import raises -from stateless import Abilities, Depend, Effect, depend, parallel, process, run -from stateless.errors import MissingAbilityError -from stateless.parallel import Parallel -from typing_extensions import Never - -from tests.utils import run_with_abilities - - -@dataclass(frozen=True) -class Super: - pass - - -@dataclass(frozen=True) -class Sub(Super): - pass - - -@dataclass(frozen=True) -class SubSub(Sub): - pass - - -def test_run_with_unhandled_exception() -> None: - def fails() -> Depend[str, None]: - yield str - raise RuntimeError("oops") - - e = fails() - with raises(RuntimeError, match="oops"): - run_with_abilities(e, Abilities("")) - - -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() - - -def test_missing_dependency() -> None: - def effect() -> Depend[Super, Super]: - ability: Super = yield Super - return ability - - with raises(MissingAbilityError, match="Super") as info: - run(effect()) # type: ignore - - # test that the fourth frame is the yield - # expression in `effect` function above - # (first is Runtime().run(..) - # second is effect.throw in Runtime.run) - frame = info.traceback[2] - 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 - return ability - - with raises(MissingAbilityError, match="Super") as info: - run_with_abilities(effect(), Abilities()) - - frame = info.traceback[5] - 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 - return ability - - assert run_with_abilities(effect(), Abilities("hi!")) == "hi!" - - -def test_simple_failure() -> None: - def effect() -> Effect[Never, ValueError, None]: - yield ValueError("oops") - return - - with raises(ValueError, match="oops"): - 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) - return result - - outer = Abilities().add("outer") - inner = Abilities().add("inner") - - effect = outer.handle(inner.handle(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_async.py b/tests/test_async.py new file mode 100644 index 0000000..0bc51ed --- /dev/null +++ b/tests/test_async.py @@ -0,0 +1,65 @@ +from concurrent.futures import Executor, ProcessPoolExecutor +from concurrent.futures.thread import ThreadPoolExecutor +from threading import Event + +from stateless import ( + Async, + Depend, + Need, + Success, + as_type, + 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 as_type(Executor)(ThreadPoolExecutor()) 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 as_type(Executor)(ThreadPoolExecutor()) 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() -> None: + with as_type(Executor)(ProcessPoolExecutor()) as executor: + effect = supply(executor)(fork_say_hi)() + assert run(effect) == "hi" 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..695ed2c 100644 --- a/tests/test_effect.py +++ b/tests/test_effect.py @@ -3,23 +3,22 @@ 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.need import need from stateless.schedule import Recurs, Spaced from stateless.time import Time @@ -27,7 +26,7 @@ class MockTime(Time): - def sleep(self, seconds: float) -> None: + async def sleep(self, seconds: float) -> None: pass @@ -98,33 +97,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 +111,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 +120,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 +134,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 +143,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: @@ -221,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_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_handler.py b/tests/test_handler.py new file mode 100644 index 0000000..b7bedd8 --- /dev/null +++ b/tests/test_handler.py @@ -0,0 +1,148 @@ +import sys +from dataclasses import dataclass + +from pytest import raises +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 + + +@dataclass(frozen=True) +class Super: + pass + + +@dataclass(frozen=True) +class Sub(Super): + pass + + +@dataclass(frozen=True) +class SubSub(Sub): + pass + + +def test_run_with_unhandled_exception() -> None: + def fails() -> Depend[Need[str], None]: + yield from need(str) + raise RuntimeError("oops") + + e = fails() + with raises(RuntimeError, match="oops"): + run_with_abilities(e, supply("")) + + +def test_provide_multiple_sub_types() -> None: + sub: Super = Sub() + subsub: Super = 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[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 sixth frame is the yield + # expression in `effect` function above + # (first is stateless.run(..) + # second is effect.throw in Runtime.run) + 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 + + +def test_simple_dependency() -> None: + def effect() -> Depend[Need[str], str]: + ability: str = yield from need(str) + return ability + + assert run_with_abilities(effect(), supply("hi!")) == "hi!" + + +def test_simple_failure() -> None: + def effect() -> Effect[Never, ValueError, None]: + yield ValueError("oops") + return + + with raises(ValueError, match="oops"): + run(effect()) + + +def test_ability_order_with_multiple_abilities() -> None: + def f() -> Depend[Need[str], str]: + result = yield from need(str) + return result + + outer = supply("outer") + inner = supply("inner") + + 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(_): # type: ignore + pass + + def only_return_annotation(_) -> None: # type: ignore + 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_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 new file mode 100644 index 0000000..d1bd09d --- /dev/null +++ b/tests/test_need.py @@ -0,0 +1,35 @@ +import sys + +from pytest import raises +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 + + +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 + + 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 + + +def test_missing_ability_with_supply() -> None: + with raises(MissingAbilityError): + effect = supply("")(lambda: need(int))() + run(effect) # type: ignore 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..75474c9 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,13 +1,14 @@ -from typing import TypeVar +from typing import Any, TypeVar -from stateless import Abilities, Effect, run +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: 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