├── .github ├── actions │ └── install-dependencies │ │ └── action.yml └── workflows │ └── pr.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── justfile ├── poetry.lock ├── pyproject.toml ├── src └── stateless │ ├── __init__.py │ ├── console.py │ ├── effect.py │ ├── errors.py │ ├── files.py │ ├── functions.py │ ├── parallel.py │ ├── py.typed │ ├── runtime.py │ ├── schedule.py │ └── time.py └── tests ├── test_console.py ├── test_effect.py ├── test_file.py ├── test_parallel.py ├── test_runtime.py ├── test_schedule.py └── test_time.py /.github/actions/install-dependencies/action.yml: -------------------------------------------------------------------------------- 1 | name: "Install stateless dependencies" 2 | inputs: 3 | python-version: 4 | description: "Python version to use" 5 | default: "3.10" 6 | required: false 7 | type: string 8 | poetry-version: 9 | description: "Poetry version to use" 10 | required: false 11 | default: "1.7.1" 12 | type: string 13 | just-version: 14 | description: "Just version to use" 15 | default: "1.16.0" 16 | required: false 17 | type: string 18 | 19 | 20 | runs: 21 | using: "composite" 22 | steps: 23 | - name: Set up Python ${{ inputs.python-version }} 24 | uses: actions/setup-python@v2 25 | with: 26 | python-version: ${{ inputs.python-version }} 27 | - name: Load cached Poetry installation 28 | id: cached-poetry 29 | uses: actions/cache@v3 30 | with: 31 | path: ~/.local 32 | key: poetry-0 # increment to reset cache 33 | - name: Install Poetry 34 | uses: snok/install-poetry@v1 35 | with: 36 | version: ${{ inputs.poetry-version }} 37 | virtualenvs-create: true 38 | virtualenvs-in-project: true 39 | installer-parallel: true 40 | - name: Load cached venv 41 | id: cached-poetry-dependencies 42 | uses: actions/cache@v3 43 | with: 44 | path: .venv 45 | key: venv-${{ runner.os }}-${{ inputs.python-version }}-${{ hashFiles('**/poetry.lock') }} 46 | - name: Install dependencies 47 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 48 | shell: bash 49 | run: poetry install --no-interaction 50 | - name: Install just 51 | uses: extractions/setup-just@v1 52 | with: 53 | just-version: ${{ inputs.just-version }} 54 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: PR 2 | on: 3 | pull_request: 4 | 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ["3.9", "3.10", "3.11", "3.12"] 12 | steps: 13 | - name: Check out repository 14 | uses: actions/checkout@v3 15 | - name: Install dependencies 16 | uses: ./.github/actions/install-dependencies 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Run tests 20 | run: poetry run just test 21 | lint: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Check out repository 25 | uses: actions/checkout@v3 26 | - name: Install dependencies 27 | uses: ./.github/actions/install-dependencies 28 | - name: Cache pre-commit 29 | uses: actions/cache@v3 30 | with: 31 | path: ~/.cache/pre-commit/ 32 | key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} 33 | - name: lint 34 | run: poetry run just lint 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | .vscode/ 162 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.5.0 4 | hooks: 5 | - id: end-of-file-fixer 6 | - id: check-merge-conflict 7 | - id: trailing-whitespace 8 | - id: check-yaml 9 | - id: check-toml 10 | - id: debug-statements 11 | - repo: https://github.com/astral-sh/ruff-pre-commit 12 | # Ruff version. 13 | rev: v0.1.6 14 | hooks: 15 | # Run the linter. 16 | - id: ruff 17 | args: [ --fix ] 18 | # Run the formatter. 19 | - id: ruff-format 20 | - repo: https://github.com/jsh9/pydoclint 21 | rev: 0.3.4 22 | hooks: 23 | - id: pydoclint 24 | - repo: local 25 | hooks: 26 | - id: mypy 27 | name: mypy 28 | entry: mypy 29 | language: system 30 | types: [python] 31 | - id: pyright 32 | name: pyright 33 | language: system 34 | types: [python] 35 | entry: pyright 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Sune Debel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # stateless 2 | 3 | Statically typed, purely functional effects for Python. 4 | 5 | # Motivation 6 | Programming with side-effects is hard: To reason about a unit in your code, like a function, you need to know what the other units in the program are doing to the program state, and understand how that affects what you're trying to achieve. 7 | 8 | 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. 9 | 10 | 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. 11 | 12 | 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. 13 | 14 | As a result, "business logic" code never performs side-effects, which makes it easier to reason about, test and re-use. 15 | 16 | # Quickstart 17 | 18 | ```python 19 | from typing import Any, Never 20 | from stateless import Effect, depend, throws, catch, Runtime 21 | 22 | 23 | # stateless.Effect is just an alias for: 24 | # 25 | # from typing import Generator, Any 26 | # 27 | # type Effect[A, E: Exception, R] = Generator[Type[A] | E, Any, R] 28 | 29 | 30 | class Files: 31 | def read_file(self, path: str) -> str: 32 | with open(path) as f: 33 | return f.read() 34 | 35 | 36 | class Console: 37 | def print(self, value: Any) -> None: 38 | print(value) 39 | 40 | 41 | # Effects are generators that yield "Abilities" that can be sent to the 42 | # generator when an effect is executed. Abilities could be anything, but will often be things that 43 | # handle side-effects. Here it's a class that can print to the console. 44 | # In other effects systems, abilities are called "effect handlers". 45 | def print_(value: Any) -> Effect[Console, Never, None]: 46 | console = yield from depend(Console) # depend returns abilities 47 | console.print(value) 48 | 49 | 50 | # Effects can yield exceptions. 'stateless.throws' will catch exceptions 51 | # for you and yield them to other functions so you can handle them with 52 | # type safety. The return type of the decorated function in this 53 | # example is: ´Effect[Files, OSError, str]' 54 | @throws(OSError) 55 | def read_file(path: str) -> Effect[Files, Never, str]: 56 | files = yield from depend(Files) 57 | return files.read_file(path) 58 | 59 | 60 | # Simple effects can be combined into complex ones by 61 | # depending on multiple abilities. 62 | def print_file(path: str) -> Effect[Files | Console, Never, None]: 63 | # catch will return exceptions yielded by other functions 64 | result = yield from catch(read_file)(path) 65 | match result: 66 | case OSError() as error: 67 | yield from print_(f"error: {error}") 68 | case _ as content: 69 | yield from print_(content) 70 | 71 | 72 | # Effects are run using `stateless.Runtime.run`. 73 | # Abilities are provided to effects via 'stateless.Runtime.use' 74 | runtime = Runtime().use(Console()).use(Files()) 75 | runtime.run(print_file('foo.txt')) 76 | 77 | 78 | ``` 79 | 80 | # Guide 81 | 82 | 83 | ## Effects, Abilities and Runtime 84 | `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. This is in fact just a type alias for a generator: 85 | 86 | 87 | ```python 88 | from typing import Any, Generator, Type 89 | 90 | 91 | type Effect[A, E: Exception, R] = Generator[Type[A] | E, Any, R] 92 | ``` 93 | 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: 94 | 95 | - The type variable `A` in `Effect` stands for _"Ability"_. This is the type of value that an effect depends on in order to produce its result. 96 | 97 | - The type variable `E` parameter of `Effect` stands for _"Error"_. This the type of errors that an effect might fail with. 98 | 99 | - The type variable `R` stands for _"Result"_. This is the type of value that an `Effect` will produce if no errors occur. 100 | 101 | We'll see shortly why the _"send"_ type of effects must be `Any`, and how `stateless` can still provide good type inference. 102 | 103 | 104 | 105 | Lets start with a very simple example of an `Effect`: 106 | ```python 107 | from typing import Never 108 | 109 | from stateless import Effect 110 | 111 | 112 | def hello_world() -> Effect[str, Never, None]: 113 | message = yield str 114 | print(message) 115 | ``` 116 | 117 | 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`). 118 | 119 | `Never` is quite frequently used as the parameter for `E`, so `stateless` also supplies a type alias `Depend` with just that: 120 | 121 | ```python 122 | from typing import Never 123 | 124 | from stateless import Effect 125 | 126 | 127 | type Depend[A, R] = Effect[A, Never, R] 128 | ``` 129 | 130 | So `hello_world` could also have been written: 131 | 132 | 133 | ```python 134 | from stateless import Depend 135 | 136 | 137 | def hello_world() -> Depend[str, None]: 138 | message = yield str 139 | print(message) 140 | ``` 141 | 142 | To run an `Effect`, you need an instance of `stateless.Runtime`. `Runtime` has just two methods: `use` and `run`. Let's look at their definitions: 143 | 144 | 145 | ```python 146 | from stateless import Effect 147 | 148 | 149 | class Runtime[A]: 150 | def use[A2](self, ability: A2) -> Runtime[A | A2]: 151 | ... 152 | 153 | def run[E: Exception, R](self, effect: Effect[A, E, R]) -> R: 154 | ... 155 | ``` 156 | The type parameter `A` of runtime again stands for "Ability". This is the type of abilities that this `Runtime` instance can provide. 157 | 158 | `Runtime.use` takes an instance of `A`, the ability type, to be sent to the effect passed to `run` upon request (i.e when its type is yielded by the effect). 159 | 160 | `Runtime.run` returns the result of running the `Effect` (or raises an exception if the effect fails). 161 | 162 | Let's run `hello_world`: 163 | 164 | ```python 165 | from stateless import Runtime 166 | 167 | 168 | runtime = Runtime().use(b"Hello, world!") 169 | runtime.run(hello_world()) # type-checker error! 170 | ``` 171 | Whoops! We accidentally provided an instance of `bytes` instead of `str`, which was required by `hello_world`. Let's try again: 172 | 173 | ```python 174 | from stateless import Runtime 175 | 176 | 177 | runtime = Runtime().use("Hello, world!") 178 | runtime.run(hello_world()) # outputs: Hello, world! 179 | ``` 180 | 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. 181 | 182 | One thing to note is that the `A` type parameter of `Effect` and `Runtime` 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. 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. 183 | 184 | 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. 185 | 186 | We'll start with an ability we'll call `Console` for writing to the console: 187 | 188 | ```python 189 | class Console: 190 | def print(self, line: str) -> None: 191 | print(line) 192 | ``` 193 | 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: 194 | 195 | ```python 196 | from typing import Type 197 | 198 | from stateless import Depend 199 | 200 | 201 | def depend[A](ability: Type[A]) -> Depend[A, A]: 202 | ... 203 | ``` 204 | 205 | So `depend` just yields the ability type for us, and then returns the instance that will eventually be sent from `Runtime`. 206 | 207 | Let's see that in action with the `Console` ability: 208 | 209 | ```python 210 | from stateless import Depend, depend 211 | 212 | 213 | def say_hello() -> Depend[Console, None]: 214 | console = yield from depend(Console) 215 | console.print(f"Hello, world!") 216 | ``` 217 | 218 | You can of course also just annotate `console` if you prefer: 219 | 220 | ```python 221 | from stateless import Depend 222 | 223 | 224 | def say_hello() -> Depend[Console, None]: 225 | console: Console = yield Console 226 | console.print(f"Hello, world!") 227 | ``` 228 | 229 | Let's add another ability `Files` to read rom the file system: 230 | 231 | 232 | ```python 233 | class Files: 234 | def read(self, path: str) -> str: 235 | with open(path, 'r') as f: 236 | return f.read() 237 | ``` 238 | Putting it all together: 239 | 240 | ```python 241 | from stateless import Depend 242 | 243 | 244 | def print_file(path: str) -> Depend[Console | Files, None]: 245 | ... 246 | ``` 247 | Note that `A` is parameterized with `Console | Files` since `print_file` depends on both `Console` and `Files` (i.e it will yield both classes). 248 | 249 | Let's add a body for `print_file`: 250 | 251 | ```python 252 | from stateless import Depend, depend 253 | 254 | 255 | def print_file(path: str) -> Depend[Console | Files, None]: 256 | files = yield from depend(Files) 257 | console = yield from depend(Console) 258 | 259 | content = files.read(path) 260 | console.print(content) 261 | ``` 262 | 263 | `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`. 264 | 265 | `depend` is a good example of how you can build complex effects using functions that return simpler effects using `yield from`: 266 | 267 | 268 | ```python 269 | from stateless import Depend, depend 270 | 271 | 272 | def get_str() -> Depend[str, str]: 273 | s = yield from depend(str) 274 | return s 275 | 276 | 277 | def get_int() -> Depend[str | int, tuple[str, int]]: 278 | s = yield from get_str() 279 | i = yield from depend(int) 280 | 281 | return (s, i) 282 | ``` 283 | 284 | It will often make sense to use an `abc.ABC` as your ability type 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. 285 | 286 | you can of course run `print_file` with `Runtime`: 287 | 288 | 289 | ```python 290 | from stateless import Runtime 291 | 292 | 293 | runtime = Runtime().use(Files()).use(Console()) 294 | runtime.run(print_file('foo.txt')) 295 | ``` 296 | Again, if we forget to supply an ability for `runtime` required by `print_file`, we'll get a type error. 297 | 298 | Of course the main purpose of dependency injection is to vary the injected ability to change the behavior of the effect. For example, we 299 | might want to change the behavior of `print_files` in tests: 300 | 301 | 302 | ```python 303 | class MockConsole(Console): 304 | def print(self, line: str) -> None: 305 | pass 306 | 307 | 308 | class MockFiles(Files): 309 | def __init__(self, content: str) -> None: 310 | self.content = content 311 | 312 | def read(self, path: str) -> str: 313 | return self.content 314 | 315 | 316 | console: Console = MockConsole() 317 | files: Files = MockFiles('mock content'.) 318 | 319 | runtime = Runtime().use(console).use(files) 320 | runtime.run(print_file('foo.txt')) 321 | ``` 322 | 323 | Our type-checker will likely infer the types `console` and `files` to be `MockConsole` and `MockFiles` respectively, so we need to annotate them with the super-types `Console` and `Files`. Otherwise it will cause the inferred type of `runtime` to be `Runtime[MockConsole, MockFiles]` which would not be type-safe when calling `run` with an argument of type `Effect[Console | Files, Never, None]` due to the variance of `collections.abc.Generator`. 324 | 325 | Besides `Effect` and `Depend`, `stateless` provides you with a few other type aliases that can save you a bit of typing. Firstly success which is just defined as: 326 | 327 | 328 | ```python 329 | from typing import Never 330 | 331 | 332 | type Success[R] = Effect[Never, Never, R] 333 | ``` 334 | 335 | for effects that don't fail and don't require abilities (can be easily instantiated using the `stateless.success` function). 336 | 337 | Secondly the `Try` type alias, defined as: 338 | 339 | 340 | ```python 341 | from typing import Never 342 | 343 | 344 | type Try[E, R] = Effect[Never, E, R] 345 | ``` 346 | For effects that do not require abilities, but might fail. 347 | 348 | Sometimes, instantiating abilities may itself require side-effects. For example, consider a program that requires a `Config` ability: 349 | 350 | 351 | ```python 352 | from stateless import Depend 353 | 354 | 355 | class Config: 356 | ... 357 | 358 | 359 | def main() -> Depend[Config, None]: 360 | ... 361 | ``` 362 | 363 | Now imagine that you want to provide the `Config` ability by reading from environment variables: 364 | 365 | 366 | ```python 367 | import os 368 | 369 | from stateless import Depend, depend 370 | 371 | 372 | class OS: 373 | environ: dict[str, str] = os.environ 374 | 375 | 376 | def get_config() -> Depend[OS, Config]: 377 | os = yield from depend(OS) 378 | return Config( 379 | url=os.environ['AUTH_TOKEN'], 380 | auth_token=os.environ['URL'] 381 | ) 382 | ``` 383 | 384 | To supply the `Config` instance returned from `get_config`, we can use `Runtime.use_effect`: 385 | 386 | 387 | ```python 388 | from stateless import Runtime 389 | 390 | 391 | Runtime().use(OS()).use_effect(get_config()).run(main()) 392 | ``` 393 | 394 | `Runtime.use_effect` assumes that all abilities required by the effect given as its argument can be provided by the runtime. If this is not the case, you'll get a type-checker error: 395 | 396 | ```python 397 | from stateless import Depend, Runtime 398 | 399 | 400 | class A: 401 | pass 402 | 403 | 404 | class B: 405 | pass 406 | 407 | 408 | def get_B() -> Depend[A, B]: 409 | ... 410 | 411 | Runtime().use(A()).use_effect(get_B()) # OK 412 | Runtime().use_effect(get_B()) # Type-checker error! 413 | ``` 414 | 415 | ## Error Handling 416 | 417 | 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.` 418 | 419 | 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: 420 | 421 | ```python 422 | from stateless import Depend 423 | 424 | 425 | def f() -> Depend[Files, None]: 426 | yield from print_file('doesnt_exist.txt') # raises FileNotFoundError 427 | ``` 428 | So what's the point of `E`? 429 | 430 | 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. 431 | 432 | Unrecoverable errors are errors that there is no point in telling the users of your API about. Depending on the context, `ZeroDivisionError` or `KeyError` might be examples of unrecoverable errors. 433 | 434 | The intended use of `E` is to model recoverable errors so that users of your API can handle them with type safety. 435 | 436 | Let's use `E` to model the errors of `Files.read_file`: 437 | 438 | 439 | ```python 440 | from stateless import Effect, throw 441 | 442 | 443 | def read_file(path: str) -> Effect[Files, OSError, str]: 444 | files = yield Files 445 | try: 446 | return files.read_file(path) 447 | except OSError as e: 448 | return (yield from throw(e)) 449 | ``` 450 | 451 | The signature of `stateless.throw` is 452 | 453 | ```python 454 | from typing import Never 455 | 456 | from stateless import Effect 457 | 458 | 459 | def throw[E: Exception](e: E) -> Effect[Never, E, Never]: 460 | ... 461 | ``` 462 | 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 463 | to annotate it with. So you're better off using the somewhat strange looking syntax `return (yield from throw(e))`. 464 | 465 | At a slightly higher level you can use `stateless.throws` that just catches exceptions and yields them as an effect 466 | 467 | ```python 468 | from stateless import Depend, throws 469 | 470 | 471 | @throws(OSError) 472 | def read_file(path: str) -> Depend[Files, str]: 473 | files = yield Files 474 | return files.read_file(path) 475 | 476 | 477 | reveal_type(read_file) # revealed type is: def (str) -> Effect[Files, OSError, str] 478 | ``` 479 | 480 | Error handling in `stateless` is done using the `stateless.catch` decorator. Its signature is: 481 | 482 | ```python 483 | from stateless import Effect, Depend 484 | 485 | 486 | def catch[**P, A, E: Exception, R]( 487 | f: Callable[P, [Effect[A, E, R]]] 488 | ) -> Callable[P, Depend[A, E | R]]: 489 | ... 490 | ``` 491 | 492 | In words, the `catch` decorator moves the error from the yield type of the `Effect` produced by its argument to the return type of the effect of the function returned from `catch`. This means you can access the potential errors directly in your code: 493 | 494 | 495 | ```python 496 | from stateless import Depend 497 | 498 | 499 | def handle_errors() -> Depend[Files, str]: 500 | result: OSError | str = yield from catch(read_file)('foo.txt') 501 | match result: 502 | case OSError: 503 | return 'default value' 504 | case str(): 505 | return result 506 | 507 | ``` 508 | (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.) 509 | 510 | Consequently you can use your type checker to avoid unintentionally unhandled errors, or ignore them with type-safety as you please. 511 | 512 | `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. 513 | 514 | For example, we could have defined catch like this: 515 | 516 | 517 | ```python 518 | def bad_catch(effect: Effect[A, E, R]) -> Depend[A, E | R]: 519 | ... 520 | ``` 521 | 522 | 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. 523 | 524 | 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: 525 | 526 | 527 | ```python 528 | def f() -> Depend[str, int]: 529 | ... 530 | 531 | 532 | def dont_do_this(e: Depend[str, int]) -> Depend[str, int]: 533 | i = yield from e 534 | return i 535 | 536 | 537 | def this_is_confusing() -> Depend[str, tuple[int, int]]: 538 | e = f() 539 | r = yield from dont_do_this(e) 540 | r_again = yield from e # e was already exhausted, so 'r_again' is None! 541 | return (r, r_again) 542 | ``` 543 | 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: 544 | 545 | ```python 546 | def do_this_instead[**P](f: Callable[P, Depend[str, int]]) -> Callable[P, Depend[str, int]]: 547 | @wraps(f) 548 | def decorator(*args: P.args, **kwargs: P.kwargs) -> Depend[str, int]: 549 | i = yield from f(*args, **kwargs) 550 | return i 551 | return decorator 552 | 553 | 554 | def this_is_easy(): 555 | e = f() 556 | r = yield from do_this_instead(f)() 557 | r_again = yield from e 558 | return (r, r_again) 559 | 560 | ``` 561 | 562 | ## Parallel Effects 563 | Two challenges present themselves when running generator based effects in parallel: 564 | 565 | - Generators aren't thread-safe. 566 | - Generators can't be pickled. 567 | 568 | 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. 569 | 570 | `stateless` calls a function that returns an effect plus arguments to pass to that function a _task_, represented by the `stateless.parallel.Task` class. 571 | 572 | `stateless` provides two decorators for instantiating `Task` instances: `stateless.parallel.thread` and `stateless.parallel.process`. Their signatures are: 573 | 574 | 575 | ```python 576 | from typing import Callable 577 | 578 | from stateless import Effect 579 | from stateless.parallel import Task 580 | 581 | 582 | def process[**P, A, E: Exception, R](f: Callable[P, Effect[A, E, R]]) -> Callable[P, Task[A, E, R]]: 583 | ... 584 | 585 | def thread[**P, A, E: Exception, R](f: Callable[P, Effect[A, E, R]]) -> Callable[P, Task[A, E, R]]: 586 | ... 587 | ``` 588 | 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. 589 | 590 | 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`. 591 | 592 | To run effects in parallel, you use the `stateless.parallel` function. It's signature is roughly: 593 | 594 | 595 | ```python 596 | from stateless import Effect 597 | from stateless.parallel import Parallel 598 | 599 | 600 | def parallel[A, E: Exception, R](*tasks: Task[A, E, R]) -> Effect[A | Parallel, E, tuple[R, ...]]: 601 | ... 602 | ``` 603 | (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.) 604 | 605 | 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. 606 | 607 | 608 | Here is a full example: 609 | ```python 610 | from stateless import parallel, Success, success, Depend 611 | from stateless.parallel import thread, process, Parallel 612 | 613 | 614 | def sing() -> Success[str]: 615 | return success("🎵") 616 | 617 | 618 | def duet() -> Depend[Parallel, tuple[str, str]]: 619 | result = yield from parallel( 620 | thread(sing)(), 621 | process(sing)() 622 | ) 623 | return result 624 | ``` 625 | When using the `Parallel` ability, you must use it as a context manager, because it manages multiple resources to enable concurrent execution of effects: 626 | ```python 627 | from stateless import Runtime 628 | from stateless.parallel import Parallel 629 | 630 | 631 | with Parallel() as ability: 632 | print(Runtime().use(ability).run(duet())) # outputs: ("🎵", "🎵") 633 | ``` 634 | 635 | 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 636 | reduces the re-usability of the decorated function. Use them at the call site as shown in the example instead. 637 | 638 | `stateless.parallel.Task` _does_ however implement `__iter__` to return the result of the decorated function, so you _can_ yield from them if necessary: 639 | 640 | ```python 641 | from stateless import Success, thread 642 | 643 | 644 | def sing_more() -> Success[str]: 645 | # This is rather pointless, 646 | # but helps you out if you for some 647 | # reason have used @thread instead of thread(...) 648 | note = yield from thread(sing)() 649 | return note * 2 650 | ``` 651 | If you need more control over the resources managed by `stateless.parallel.Parallel`, you can pass them as arguments: 652 | ```python 653 | from multiprocessing.pool import ThreadPool 654 | from multiprocessing import Manager 655 | 656 | from stateless.parallel import Parallel 657 | 658 | 659 | with ( 660 | Manager() as manager, 661 | manager.Pool() as pool, 662 | ThreadPool() as thread_pool, 663 | Parallel(thread_pool, pool) as parallel 664 | ): 665 | ... 666 | ``` 667 | 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 668 | effects in other processes. 669 | 670 | 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. 671 | 672 | 673 | 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`: 674 | 675 | ```python 676 | from stateless import Runtime, Effect 677 | from stateless.parallel import Parallel, Task 678 | 679 | 680 | class MockParallel(Parallel): 681 | def __init__(self): 682 | pass 683 | 684 | def run_cpu_tasks(self, 685 | runtime: Runtime[object], 686 | tasks: Sequence[Task[object, Exception, object]]) -> Tuple[object, ...]: 687 | return tuple(runtime.run(iter(task)) for task in tasks) 688 | 689 | def run_thread_tasks(self 690 | runtime: Runtime[object], 691 | effects: Sequence[Effect[object, Exception, object]]) -> Tuple[object, ...]: 692 | return tuple(runtime.run(iter(task)) for task in tasks) 693 | ``` 694 | ## Repeating and Retrying Effects 695 | 696 | A `stateless.Schedule` is a type with an `__iter__` method that returns an effect producing an iterator of `timedelta` instances. It's defined like: 697 | 698 | ```python 699 | from typing import Protocol, Iterator 700 | from datetime import timedelta 701 | 702 | from stateless import Depend 703 | 704 | 705 | class Schedule[A](Protocol): 706 | def __iter__(self) -> Depend[A, Iterator[timedelta]]: 707 | ... 708 | ``` 709 | The type parameter `A` is present because some schedules may require abilities to complete. 710 | 711 | The `stateless.schedule` module contains a number of of helpful implemenations of `Schedule`, for example `Spaced` or `Recurs`. 712 | 713 | Schedules can be used with the `repeat` decorator, which takes schedule as its first argument and repeats the decorated function returning an effect until the schedule is exhausted or an error occurs: 714 | 715 | ```python 716 | from datetime import timedelta 717 | 718 | from stateless import repeat, success, Success, Runtime 719 | from stateless.schedule import Recurs, Spaced 720 | from stateless.time import Time 721 | 722 | 723 | @repeat(Recurs(2, Spaced(timedelta(seconds=2)))) 724 | def f() -> Success[str]: 725 | return success("hi!") 726 | 727 | 728 | print(Runtime().use(Time()).run(f())) # outputs: ("hi!", "hi!") 729 | ``` 730 | Effects created through repeat depends on the `Time` ability from `stateless.time` because it needs to sleep between each execution of the effect. 731 | 732 | Schedules are a good example of a pattern used a lot in `stateless`: Classes with an `__iter__` method that returns effects. 733 | 734 | This is a useful pattern because such objects can be yielded from in functions returning effects multiple times where a new generator will be instantiated every time: 735 | 736 | ```python 737 | def this_works() -> Success[timedelta]: 738 | schedule = Spaced(timedelta(seconds=2)) 739 | deltas = yield from schedule 740 | deltas_again = yield from schedule # safe! 741 | return deltas 742 | ``` 743 | 744 | 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. 745 | 746 | `stateless.retry` is like `repeat`, except that it returns succesfully 747 | when the decorated function yields no errors, or fails when the schedule is exhausted: 748 | 749 | ```python 750 | from datetime import timedelta 751 | 752 | from stateless import retry, throw, Try, throw, success, Runtime 753 | from stateless.schedule import Recurs, Spaced 754 | from stateless.time import Time 755 | 756 | 757 | fail = True 758 | 759 | 760 | @retry(Recurs(2, Spaced(timedelta(seconds=2)))) 761 | def f() -> Try[RuntimeError, str]: 762 | global fail 763 | if fail: 764 | fail = False 765 | return throw(RuntimeError('Whoops...')) 766 | else: 767 | return success('Hooray!') 768 | 769 | 770 | print(Runtime().use(Time()).run(f())) # outputs: 'Hooray!' 771 | ``` 772 | 773 | ## Memoization 774 | 775 | Effects can be memoized using the `stateless.memoize` decorator: 776 | 777 | 778 | ```python 779 | from stateless import memoize, Depend 780 | from stateless.console import Console, print_line 781 | 782 | 783 | @memoize 784 | def f() -> Depend[Console, str]: 785 | yield from print_line('f was called') 786 | return 'done' 787 | 788 | 789 | def g() -> Depend[Console, tuple[str, str]]: 790 | first = yield from f() 791 | second = yield from f() 792 | return first, second 793 | 794 | 795 | result = Runtime().use(Console()).run(f()) # outputs: 'f was called' once, even though the effect was yielded twice 796 | 797 | print(result) # outputs: ('done', 'done') 798 | ``` 799 | `memoize` works like [`functools.lru_cache`](https://docs.python.org/3/library/functools.html#functools.lru_cache), in that the memoized effect 800 | is cached based on the arguments of the decorated function. In fact, `memoize` takes the same parameters as `functools.lru_cache` (`maxsize` and `typed`) with the same meaning. 801 | # Known Issues 802 | 803 | See the [issues](https://github.com/suned/stateless/issues) page. 804 | 805 | # Algebraic Effects Vs. Monads 806 | 807 | All functional effect system work essentially the same way: 808 | 809 | 1. Programs send a description of the side-effect needed to be performed to the effect system and pause their executing while the effect system handles the side-effect. 810 | 2. Once the result of performing the side-effect is ready, program execution is resumed at the point it was paused 811 | 812 | Step 2. is the tricky part: how can program execution be resumed at the point it was paused? 813 | 814 | [Monads](https://en.wikipedia.org/wiki/Monad_(functional_programming)) are the most common solution. When programming with monads, in addition to supplying the effect system with a description of a side-effect, the programmer also supplies a function to 815 | be called with the result of handling the described effect. In functional programming such a function is called a _continuation_. In other paradigms it might be called a _callback function_. 816 | 817 | For example it might look like this: 818 | 819 | ```python 820 | def say_hello() -> IO[None]: 821 | return Input("whats your name?").bind(lambda name: Print(f"Hello, {name}!")) 822 | ``` 823 | One of the main benefits of basing effect systems on monads is that they don't rely on any special language features: its all literally just functions. 824 | 825 | However, many programmers find monads awkward. Programming with callback functions often lead to code thats hard for humans to parse, which has ultimately inspired specialized language features for hiding the callback functions with syntax sugar like [Haskell's do notation](https://en.wikibooks.org/wiki/Haskell/do_notation), or [for comprehensions in Scala](https://docs.scala-lang.org/tour/for-comprehensions.html). 826 | 827 | Moreover, monads famously do not compose, meaning that when writing code that needs to juggle multiple types of side-effects (like errors and IO), it's up to the programmer to pack and unpack results of various types of effects (or use advanced features like [monad transformers](https://en.wikibooks.org/wiki/Haskell/Monad_transformers) which come with their own set of problems). 828 | 829 | Additionally, in languages with dynamic binding such as Python, calling functions is relatively expensive, which means that using callbacks as the principal method for resuming computation comes with a fair amount of performance overhead. 830 | 831 | Finally, interpreting monads is often a recursive procedure, meaning that it's necessary to worry about stack safety in languages without tail call optimisation such as Python. This is usually solved using [trampolines](https://en.wikipedia.org/wiki/Trampoline_(computing)) which further adds to the performance overhead. 832 | 833 | 834 | Because of all these practical challenges of programming with monads, people have been looking for alternatives. Algebraic effects is one the things suggested that address many of the challenges of monadic effect systems. 835 | 836 | In algebraic effect systems, such as `stateless`, the programmer still supplies the effect system with a description of the side-effect to be carried out, but instead of supplying a callback function to resume the 837 | computation with, the result of handling the effect is returned to the point in program execution that the effect description was produced. The main drawback of this approach is that it requires special language features to do this. In Python however, such a language feature _does_ exist: Generators and coroutines. 838 | 839 | Using coroutines for algebraic effects solves many of the challenges with monadic effect systems: 840 | 841 | - No callback functions are required, so readability and understandability of the effectful code is much more straightforward. 842 | - Code that needs to describe side-effects can simply list all the effects it requires, so there is no composition problem. 843 | - There are no callback functions, so no need to worry about performance overhead of calling a large number of functions or using trampolines to ensure stack safety. 844 | 845 | 846 | # Background 847 | 848 | - [Do be do be do (Lindley, McBride and McLaughlin)](https://arxiv.org/pdf/1611.09259.pdf) 849 | 850 | - [Handlers of Algebraic Effects (Plotkin and Pretnar)](https://homepages.inf.ed.ac.uk/gdp/publications/Effect_Handlers.pdf) 851 | 852 | - [One-Shot Algebraic Effects as Coroutines (Kawahara and Kameyama)](https://link.springer.com/chapter/10.1007/978-3-030-57761-2_8) (with an implementation in [ruby](https://github.com/nymphium/ruff) and [lua](https://github.com/Nymphium/eff.lua)) 853 | 854 | # Similar Projects 855 | 856 | - [Abilities in the Unison language](https://www.unison-lang.org/) 857 | 858 | - [Effects in OCaml 5.0](https://v2.ocaml.org/manual/effects.html) 859 | 860 | - [Frank language](https://github.com/frank-lang/frank) 861 | 862 | - [Koka language](https://koka-lang.github.io/koka/doc/index.html) 863 | 864 | - [Eff language](https://www.eff-lang.org/) 865 | 866 | - [Effekt language](https://effekt-lang.org/) 867 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | test: 2 | coverage run --source=src -m pytest tests 3 | coverage report --fail-under=100 4 | 5 | lint: 6 | pre-commit run --all-files 7 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "appnope" 5 | version = "0.1.3" 6 | description = "Disable App Nap on macOS >= 10.9" 7 | optional = false 8 | python-versions = "*" 9 | files = [ 10 | {file = "appnope-0.1.3-py2.py3-none-any.whl", hash = "sha256:265a455292d0bd8a72453494fa24df5a11eb18373a60c7c0430889f22548605e"}, 11 | {file = "appnope-0.1.3.tar.gz", hash = "sha256:02bd91c4de869fbb1e1c50aafc4098827a7a54ab2f39d9dcba6c9547ed920e24"}, 12 | ] 13 | 14 | [[package]] 15 | name = "asttokens" 16 | version = "2.4.1" 17 | description = "Annotate AST trees with source code positions" 18 | optional = false 19 | python-versions = "*" 20 | files = [ 21 | {file = "asttokens-2.4.1-py2.py3-none-any.whl", hash = "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24"}, 22 | {file = "asttokens-2.4.1.tar.gz", hash = "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0"}, 23 | ] 24 | 25 | [package.dependencies] 26 | six = ">=1.12.0" 27 | 28 | [package.extras] 29 | astroid = ["astroid (>=1,<2)", "astroid (>=2,<4)"] 30 | test = ["astroid (>=1,<2)", "astroid (>=2,<4)", "pytest"] 31 | 32 | [[package]] 33 | name = "cfgv" 34 | version = "3.4.0" 35 | description = "Validate configuration and produce human readable error messages." 36 | optional = false 37 | python-versions = ">=3.8" 38 | files = [ 39 | {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, 40 | {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, 41 | ] 42 | 43 | [[package]] 44 | name = "cloudpickle" 45 | version = "3.0.0" 46 | description = "Pickler class to extend the standard pickle.Pickler functionality" 47 | optional = false 48 | python-versions = ">=3.8" 49 | files = [ 50 | {file = "cloudpickle-3.0.0-py3-none-any.whl", hash = "sha256:246ee7d0c295602a036e86369c77fecda4ab17b506496730f2f576d9016fd9c7"}, 51 | {file = "cloudpickle-3.0.0.tar.gz", hash = "sha256:996d9a482c6fb4f33c1a35335cf8afd065d2a56e973270364840712d9131a882"}, 52 | ] 53 | 54 | [[package]] 55 | name = "colorama" 56 | version = "0.4.6" 57 | description = "Cross-platform colored terminal text." 58 | optional = false 59 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 60 | files = [ 61 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 62 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 63 | ] 64 | 65 | [[package]] 66 | name = "coverage" 67 | version = "7.3.2" 68 | description = "Code coverage measurement for Python" 69 | optional = false 70 | python-versions = ">=3.8" 71 | files = [ 72 | {file = "coverage-7.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d872145f3a3231a5f20fd48500274d7df222e291d90baa2026cc5152b7ce86bf"}, 73 | {file = "coverage-7.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:310b3bb9c91ea66d59c53fa4989f57d2436e08f18fb2f421a1b0b6b8cc7fffda"}, 74 | {file = "coverage-7.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f47d39359e2c3779c5331fc740cf4bce6d9d680a7b4b4ead97056a0ae07cb49a"}, 75 | {file = "coverage-7.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa72dbaf2c2068404b9870d93436e6d23addd8bbe9295f49cbca83f6e278179c"}, 76 | {file = "coverage-7.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:beaa5c1b4777f03fc63dfd2a6bd820f73f036bfb10e925fce067b00a340d0f3f"}, 77 | {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:dbc1b46b92186cc8074fee9d9fbb97a9dd06c6cbbef391c2f59d80eabdf0faa6"}, 78 | {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:315a989e861031334d7bee1f9113c8770472db2ac484e5b8c3173428360a9148"}, 79 | {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d1bc430677773397f64a5c88cb522ea43175ff16f8bfcc89d467d974cb2274f9"}, 80 | {file = "coverage-7.3.2-cp310-cp310-win32.whl", hash = "sha256:a889ae02f43aa45032afe364c8ae84ad3c54828c2faa44f3bfcafecb5c96b02f"}, 81 | {file = "coverage-7.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c0ba320de3fb8c6ec16e0be17ee1d3d69adcda99406c43c0409cb5c41788a611"}, 82 | {file = "coverage-7.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ac8c802fa29843a72d32ec56d0ca792ad15a302b28ca6203389afe21f8fa062c"}, 83 | {file = "coverage-7.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:89a937174104339e3a3ffcf9f446c00e3a806c28b1841c63edb2b369310fd074"}, 84 | {file = "coverage-7.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e267e9e2b574a176ddb983399dec325a80dbe161f1a32715c780b5d14b5f583a"}, 85 | {file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2443cbda35df0d35dcfb9bf8f3c02c57c1d6111169e3c85fc1fcc05e0c9f39a3"}, 86 | {file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4175e10cc8dda0265653e8714b3174430b07c1dca8957f4966cbd6c2b1b8065a"}, 87 | {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf38419fb1a347aaf63481c00f0bdc86889d9fbf3f25109cf96c26b403fda1"}, 88 | {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:5c913b556a116b8d5f6ef834038ba983834d887d82187c8f73dec21049abd65c"}, 89 | {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1981f785239e4e39e6444c63a98da3a1db8e971cb9ceb50a945ba6296b43f312"}, 90 | {file = "coverage-7.3.2-cp311-cp311-win32.whl", hash = "sha256:43668cabd5ca8258f5954f27a3aaf78757e6acf13c17604d89648ecc0cc66640"}, 91 | {file = "coverage-7.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10c39c0452bf6e694511c901426d6b5ac005acc0f78ff265dbe36bf81f808a2"}, 92 | {file = "coverage-7.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4cbae1051ab791debecc4a5dcc4a1ff45fc27b91b9aee165c8a27514dd160836"}, 93 | {file = "coverage-7.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12d15ab5833a997716d76f2ac1e4b4d536814fc213c85ca72756c19e5a6b3d63"}, 94 | {file = "coverage-7.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c7bba973ebee5e56fe9251300c00f1579652587a9f4a5ed8404b15a0471f216"}, 95 | {file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe494faa90ce6381770746077243231e0b83ff3f17069d748f645617cefe19d4"}, 96 | {file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6e9589bd04d0461a417562649522575d8752904d35c12907d8c9dfeba588faf"}, 97 | {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d51ac2a26f71da1b57f2dc81d0e108b6ab177e7d30e774db90675467c847bbdf"}, 98 | {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:99b89d9f76070237975b315b3d5f4d6956ae354a4c92ac2388a5695516e47c84"}, 99 | {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fa28e909776dc69efb6ed975a63691bc8172b64ff357e663a1bb06ff3c9b589a"}, 100 | {file = "coverage-7.3.2-cp312-cp312-win32.whl", hash = "sha256:289fe43bf45a575e3ab10b26d7b6f2ddb9ee2dba447499f5401cfb5ecb8196bb"}, 101 | {file = "coverage-7.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dbc3ed60e8659bc59b6b304b43ff9c3ed858da2839c78b804973f613d3e92ed"}, 102 | {file = "coverage-7.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f94b734214ea6a36fe16e96a70d941af80ff3bfd716c141300d95ebc85339738"}, 103 | {file = "coverage-7.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:af3d828d2c1cbae52d34bdbb22fcd94d1ce715d95f1a012354a75e5913f1bda2"}, 104 | {file = "coverage-7.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:630b13e3036e13c7adc480ca42fa7afc2a5d938081d28e20903cf7fd687872e2"}, 105 | {file = "coverage-7.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9eacf273e885b02a0273bb3a2170f30e2d53a6d53b72dbe02d6701b5296101c"}, 106 | {file = "coverage-7.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8f17966e861ff97305e0801134e69db33b143bbfb36436efb9cfff6ec7b2fd9"}, 107 | {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b4275802d16882cf9c8b3d057a0839acb07ee9379fa2749eca54efbce1535b82"}, 108 | {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:72c0cfa5250f483181e677ebc97133ea1ab3eb68645e494775deb6a7f6f83901"}, 109 | {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:cb536f0dcd14149425996821a168f6e269d7dcd2c273a8bff8201e79f5104e76"}, 110 | {file = "coverage-7.3.2-cp38-cp38-win32.whl", hash = "sha256:307adb8bd3abe389a471e649038a71b4eb13bfd6b7dd9a129fa856f5c695cf92"}, 111 | {file = "coverage-7.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:88ed2c30a49ea81ea3b7f172e0269c182a44c236eb394718f976239892c0a27a"}, 112 | {file = "coverage-7.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b631c92dfe601adf8f5ebc7fc13ced6bb6e9609b19d9a8cd59fa47c4186ad1ce"}, 113 | {file = "coverage-7.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d3d9df4051c4a7d13036524b66ecf7a7537d14c18a384043f30a303b146164e9"}, 114 | {file = "coverage-7.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f7363d3b6a1119ef05015959ca24a9afc0ea8a02c687fe7e2d557705375c01f"}, 115 | {file = "coverage-7.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f11cc3c967a09d3695d2a6f03fb3e6236622b93be7a4b5dc09166a861be6d25"}, 116 | {file = "coverage-7.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:149de1d2401ae4655c436a3dced6dd153f4c3309f599c3d4bd97ab172eaf02d9"}, 117 | {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3a4006916aa6fee7cd38db3bfc95aa9c54ebb4ffbfc47c677c8bba949ceba0a6"}, 118 | {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9028a3871280110d6e1aa2df1afd5ef003bab5fb1ef421d6dc748ae1c8ef2ebc"}, 119 | {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9f805d62aec8eb92bab5b61c0f07329275b6f41c97d80e847b03eb894f38d083"}, 120 | {file = "coverage-7.3.2-cp39-cp39-win32.whl", hash = "sha256:d1c88ec1a7ff4ebca0219f5b1ef863451d828cccf889c173e1253aa84b1e07ce"}, 121 | {file = "coverage-7.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b4767da59464bb593c07afceaddea61b154136300881844768037fd5e859353f"}, 122 | {file = "coverage-7.3.2-pp38.pp39.pp310-none-any.whl", hash = "sha256:ae97af89f0fbf373400970c0a21eef5aa941ffeed90aee43650b81f7d7f47637"}, 123 | {file = "coverage-7.3.2.tar.gz", hash = "sha256:be32ad29341b0170e795ca590e1c07e81fc061cb5b10c74ce7203491484404ef"}, 124 | ] 125 | 126 | [package.extras] 127 | toml = ["tomli"] 128 | 129 | [[package]] 130 | name = "decorator" 131 | version = "5.1.1" 132 | description = "Decorators for Humans" 133 | optional = false 134 | python-versions = ">=3.5" 135 | files = [ 136 | {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, 137 | {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, 138 | ] 139 | 140 | [[package]] 141 | name = "distlib" 142 | version = "0.3.7" 143 | description = "Distribution utilities" 144 | optional = false 145 | python-versions = "*" 146 | files = [ 147 | {file = "distlib-0.3.7-py2.py3-none-any.whl", hash = "sha256:2e24928bc811348f0feb63014e97aaae3037f2cf48712d51ae61df7fd6075057"}, 148 | {file = "distlib-0.3.7.tar.gz", hash = "sha256:9dafe54b34a028eafd95039d5e5d4851a13734540f1331060d31c9916e7147a8"}, 149 | ] 150 | 151 | [[package]] 152 | name = "exceptiongroup" 153 | version = "1.1.3" 154 | description = "Backport of PEP 654 (exception groups)" 155 | optional = false 156 | python-versions = ">=3.7" 157 | files = [ 158 | {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, 159 | {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, 160 | ] 161 | 162 | [package.extras] 163 | test = ["pytest (>=6)"] 164 | 165 | [[package]] 166 | name = "executing" 167 | version = "2.0.1" 168 | description = "Get the currently executing AST node of a frame, and other information" 169 | optional = false 170 | python-versions = ">=3.5" 171 | files = [ 172 | {file = "executing-2.0.1-py2.py3-none-any.whl", hash = "sha256:eac49ca94516ccc753f9fb5ce82603156e590b27525a8bc32cce8ae302eb61bc"}, 173 | {file = "executing-2.0.1.tar.gz", hash = "sha256:35afe2ce3affba8ee97f2d69927fa823b08b472b7b994e36a52a964b93d16147"}, 174 | ] 175 | 176 | [package.extras] 177 | tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"] 178 | 179 | [[package]] 180 | name = "filelock" 181 | version = "3.13.1" 182 | description = "A platform independent file lock." 183 | optional = false 184 | python-versions = ">=3.8" 185 | files = [ 186 | {file = "filelock-3.13.1-py3-none-any.whl", hash = "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c"}, 187 | {file = "filelock-3.13.1.tar.gz", hash = "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e"}, 188 | ] 189 | 190 | [package.extras] 191 | docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.24)"] 192 | testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] 193 | typing = ["typing-extensions (>=4.8)"] 194 | 195 | [[package]] 196 | name = "identify" 197 | version = "2.5.32" 198 | description = "File identification library for Python" 199 | optional = false 200 | python-versions = ">=3.8" 201 | files = [ 202 | {file = "identify-2.5.32-py2.py3-none-any.whl", hash = "sha256:0b7656ef6cba81664b783352c73f8c24b39cf82f926f78f4550eda928e5e0545"}, 203 | {file = "identify-2.5.32.tar.gz", hash = "sha256:5d9979348ec1a21c768ae07e0a652924538e8bce67313a73cb0f681cf08ba407"}, 204 | ] 205 | 206 | [package.extras] 207 | license = ["ukkonen"] 208 | 209 | [[package]] 210 | name = "iniconfig" 211 | version = "2.0.0" 212 | description = "brain-dead simple config-ini parsing" 213 | optional = false 214 | python-versions = ">=3.7" 215 | files = [ 216 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 217 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 218 | ] 219 | 220 | [[package]] 221 | name = "ipdb" 222 | version = "0.13.13" 223 | description = "IPython-enabled pdb" 224 | optional = false 225 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 226 | files = [ 227 | {file = "ipdb-0.13.13-py3-none-any.whl", hash = "sha256:45529994741c4ab6d2388bfa5d7b725c2cf7fe9deffabdb8a6113aa5ed449ed4"}, 228 | {file = "ipdb-0.13.13.tar.gz", hash = "sha256:e3ac6018ef05126d442af680aad863006ec19d02290561ac88b8b1c0b0cfc726"}, 229 | ] 230 | 231 | [package.dependencies] 232 | decorator = {version = "*", markers = "python_version > \"3.6\""} 233 | ipython = {version = ">=7.31.1", markers = "python_version > \"3.6\""} 234 | tomli = {version = "*", markers = "python_version > \"3.6\" and python_version < \"3.11\""} 235 | 236 | [[package]] 237 | name = "ipython" 238 | version = "8.17.2" 239 | description = "IPython: Productive Interactive Computing" 240 | optional = false 241 | python-versions = ">=3.9" 242 | files = [ 243 | {file = "ipython-8.17.2-py3-none-any.whl", hash = "sha256:1e4d1d666a023e3c93585ba0d8e962867f7a111af322efff6b9c58062b3e5444"}, 244 | {file = "ipython-8.17.2.tar.gz", hash = "sha256:126bb57e1895594bb0d91ea3090bbd39384f6fe87c3d57fd558d0670f50339bb"}, 245 | ] 246 | 247 | [package.dependencies] 248 | appnope = {version = "*", markers = "sys_platform == \"darwin\""} 249 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 250 | decorator = "*" 251 | exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} 252 | jedi = ">=0.16" 253 | matplotlib-inline = "*" 254 | pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""} 255 | prompt-toolkit = ">=3.0.30,<3.0.37 || >3.0.37,<3.1.0" 256 | pygments = ">=2.4.0" 257 | stack-data = "*" 258 | traitlets = ">=5" 259 | 260 | [package.extras] 261 | all = ["black", "curio", "docrepr", "exceptiongroup", "ipykernel", "ipyparallel", "ipywidgets", "matplotlib", "matplotlib (!=3.2.0)", "nbconvert", "nbformat", "notebook", "numpy (>=1.22)", "pandas", "pickleshare", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio (<0.22)", "qtconsole", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "trio", "typing-extensions"] 262 | black = ["black"] 263 | doc = ["docrepr", "exceptiongroup", "ipykernel", "matplotlib", "pickleshare", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio (<0.22)", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "typing-extensions"] 264 | kernel = ["ipykernel"] 265 | nbconvert = ["nbconvert"] 266 | nbformat = ["nbformat"] 267 | notebook = ["ipywidgets", "notebook"] 268 | parallel = ["ipyparallel"] 269 | qtconsole = ["qtconsole"] 270 | test = ["pickleshare", "pytest (<7.1)", "pytest-asyncio (<0.22)", "testpath"] 271 | test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.22)", "pandas", "pickleshare", "pytest (<7.1)", "pytest-asyncio (<0.22)", "testpath", "trio"] 272 | 273 | [[package]] 274 | name = "jedi" 275 | version = "0.19.1" 276 | description = "An autocompletion tool for Python that can be used for text editors." 277 | optional = false 278 | python-versions = ">=3.6" 279 | files = [ 280 | {file = "jedi-0.19.1-py2.py3-none-any.whl", hash = "sha256:e983c654fe5c02867aef4cdfce5a2fbb4a50adc0af145f70504238f18ef5e7e0"}, 281 | {file = "jedi-0.19.1.tar.gz", hash = "sha256:cf0496f3651bc65d7174ac1b7d043eff454892c708a87d1b683e57b569927ffd"}, 282 | ] 283 | 284 | [package.dependencies] 285 | parso = ">=0.8.3,<0.9.0" 286 | 287 | [package.extras] 288 | docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] 289 | qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] 290 | testing = ["Django", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] 291 | 292 | [[package]] 293 | name = "matplotlib-inline" 294 | version = "0.1.6" 295 | description = "Inline Matplotlib backend for Jupyter" 296 | optional = false 297 | python-versions = ">=3.5" 298 | files = [ 299 | {file = "matplotlib-inline-0.1.6.tar.gz", hash = "sha256:f887e5f10ba98e8d2b150ddcf4702c1e5f8b3a20005eb0f74bfdbd360ee6f304"}, 300 | {file = "matplotlib_inline-0.1.6-py3-none-any.whl", hash = "sha256:f1f41aab5328aa5aaea9b16d083b128102f8712542f819fe7e6a420ff581b311"}, 301 | ] 302 | 303 | [package.dependencies] 304 | traitlets = "*" 305 | 306 | [[package]] 307 | name = "mypy" 308 | version = "1.7.0" 309 | description = "Optional static typing for Python" 310 | optional = false 311 | python-versions = ">=3.8" 312 | files = [ 313 | {file = "mypy-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5da84d7bf257fd8f66b4f759a904fd2c5a765f70d8b52dde62b521972a0a2357"}, 314 | {file = "mypy-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a3637c03f4025f6405737570d6cbfa4f1400eb3c649317634d273687a09ffc2f"}, 315 | {file = "mypy-1.7.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b633f188fc5ae1b6edca39dae566974d7ef4e9aaaae00bc36efe1f855e5173ac"}, 316 | {file = "mypy-1.7.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d6ed9a3997b90c6f891138e3f83fb8f475c74db4ccaa942a1c7bf99e83a989a1"}, 317 | {file = "mypy-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:1fe46e96ae319df21359c8db77e1aecac8e5949da4773c0274c0ef3d8d1268a9"}, 318 | {file = "mypy-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:df67fbeb666ee8828f675fee724cc2cbd2e4828cc3df56703e02fe6a421b7401"}, 319 | {file = "mypy-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a79cdc12a02eb526d808a32a934c6fe6df07b05f3573d210e41808020aed8b5d"}, 320 | {file = "mypy-1.7.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f65f385a6f43211effe8c682e8ec3f55d79391f70a201575def73d08db68ead1"}, 321 | {file = "mypy-1.7.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e81ffd120ee24959b449b647c4b2fbfcf8acf3465e082b8d58fd6c4c2b27e46"}, 322 | {file = "mypy-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:f29386804c3577c83d76520abf18cfcd7d68264c7e431c5907d250ab502658ee"}, 323 | {file = "mypy-1.7.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:87c076c174e2c7ef8ab416c4e252d94c08cd4980a10967754f91571070bf5fbe"}, 324 | {file = "mypy-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cb8d5f6d0fcd9e708bb190b224089e45902cacef6f6915481806b0c77f7786d"}, 325 | {file = "mypy-1.7.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d93e76c2256aa50d9c82a88e2f569232e9862c9982095f6d54e13509f01222fc"}, 326 | {file = "mypy-1.7.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cddee95dea7990e2215576fae95f6b78a8c12f4c089d7e4367564704e99118d3"}, 327 | {file = "mypy-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:d01921dbd691c4061a3e2ecdbfbfad029410c5c2b1ee88946bf45c62c6c91210"}, 328 | {file = "mypy-1.7.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:185cff9b9a7fec1f9f7d8352dff8a4c713b2e3eea9c6c4b5ff7f0edf46b91e41"}, 329 | {file = "mypy-1.7.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7a7b1e399c47b18feb6f8ad4a3eef3813e28c1e871ea7d4ea5d444b2ac03c418"}, 330 | {file = "mypy-1.7.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc9fe455ad58a20ec68599139ed1113b21f977b536a91b42bef3ffed5cce7391"}, 331 | {file = "mypy-1.7.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d0fa29919d2e720c8dbaf07d5578f93d7b313c3e9954c8ec05b6d83da592e5d9"}, 332 | {file = "mypy-1.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:2b53655a295c1ed1af9e96b462a736bf083adba7b314ae775563e3fb4e6795f5"}, 333 | {file = "mypy-1.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c1b06b4b109e342f7dccc9efda965fc3970a604db70f8560ddfdee7ef19afb05"}, 334 | {file = "mypy-1.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bf7a2f0a6907f231d5e41adba1a82d7d88cf1f61a70335889412dec99feeb0f8"}, 335 | {file = "mypy-1.7.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:551d4a0cdcbd1d2cccdcc7cb516bb4ae888794929f5b040bb51aae1846062901"}, 336 | {file = "mypy-1.7.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:55d28d7963bef00c330cb6461db80b0b72afe2f3c4e2963c99517cf06454e665"}, 337 | {file = "mypy-1.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:870bd1ffc8a5862e593185a4c169804f2744112b4a7c55b93eb50f48e7a77010"}, 338 | {file = "mypy-1.7.0-py3-none-any.whl", hash = "sha256:96650d9a4c651bc2a4991cf46f100973f656d69edc7faf91844e87fe627f7e96"}, 339 | {file = "mypy-1.7.0.tar.gz", hash = "sha256:1e280b5697202efa698372d2f39e9a6713a0395a756b1c6bd48995f8d72690dc"}, 340 | ] 341 | 342 | [package.dependencies] 343 | mypy-extensions = ">=1.0.0" 344 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 345 | typing-extensions = ">=4.1.0" 346 | 347 | [package.extras] 348 | dmypy = ["psutil (>=4.0)"] 349 | install-types = ["pip"] 350 | mypyc = ["setuptools (>=50)"] 351 | reports = ["lxml"] 352 | 353 | [[package]] 354 | name = "mypy-extensions" 355 | version = "1.0.0" 356 | description = "Type system extensions for programs checked with the mypy type checker." 357 | optional = false 358 | python-versions = ">=3.5" 359 | files = [ 360 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 361 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 362 | ] 363 | 364 | [[package]] 365 | name = "nodeenv" 366 | version = "1.8.0" 367 | description = "Node.js virtual environment builder" 368 | optional = false 369 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" 370 | files = [ 371 | {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, 372 | {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, 373 | ] 374 | 375 | [package.dependencies] 376 | setuptools = "*" 377 | 378 | [[package]] 379 | name = "packaging" 380 | version = "23.2" 381 | description = "Core utilities for Python packages" 382 | optional = false 383 | python-versions = ">=3.7" 384 | files = [ 385 | {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, 386 | {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, 387 | ] 388 | 389 | [[package]] 390 | name = "parso" 391 | version = "0.8.3" 392 | description = "A Python Parser" 393 | optional = false 394 | python-versions = ">=3.6" 395 | files = [ 396 | {file = "parso-0.8.3-py2.py3-none-any.whl", hash = "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75"}, 397 | {file = "parso-0.8.3.tar.gz", hash = "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0"}, 398 | ] 399 | 400 | [package.extras] 401 | qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] 402 | testing = ["docopt", "pytest (<6.0.0)"] 403 | 404 | [[package]] 405 | name = "pexpect" 406 | version = "4.8.0" 407 | description = "Pexpect allows easy control of interactive console applications." 408 | optional = false 409 | python-versions = "*" 410 | files = [ 411 | {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"}, 412 | {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"}, 413 | ] 414 | 415 | [package.dependencies] 416 | ptyprocess = ">=0.5" 417 | 418 | [[package]] 419 | name = "platformdirs" 420 | version = "3.11.0" 421 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 422 | optional = false 423 | python-versions = ">=3.7" 424 | files = [ 425 | {file = "platformdirs-3.11.0-py3-none-any.whl", hash = "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"}, 426 | {file = "platformdirs-3.11.0.tar.gz", hash = "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3"}, 427 | ] 428 | 429 | [package.extras] 430 | docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] 431 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] 432 | 433 | [[package]] 434 | name = "pluggy" 435 | version = "1.3.0" 436 | description = "plugin and hook calling mechanisms for python" 437 | optional = false 438 | python-versions = ">=3.8" 439 | files = [ 440 | {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, 441 | {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, 442 | ] 443 | 444 | [package.extras] 445 | dev = ["pre-commit", "tox"] 446 | testing = ["pytest", "pytest-benchmark"] 447 | 448 | [[package]] 449 | name = "pre-commit" 450 | version = "3.5.0" 451 | description = "A framework for managing and maintaining multi-language pre-commit hooks." 452 | optional = false 453 | python-versions = ">=3.8" 454 | files = [ 455 | {file = "pre_commit-3.5.0-py2.py3-none-any.whl", hash = "sha256:841dc9aef25daba9a0238cd27984041fa0467b4199fc4852e27950664919f660"}, 456 | {file = "pre_commit-3.5.0.tar.gz", hash = "sha256:5804465c675b659b0862f07907f96295d490822a450c4c40e747d0b1c6ebcb32"}, 457 | ] 458 | 459 | [package.dependencies] 460 | cfgv = ">=2.0.0" 461 | identify = ">=1.0.0" 462 | nodeenv = ">=0.11.1" 463 | pyyaml = ">=5.1" 464 | virtualenv = ">=20.10.0" 465 | 466 | [[package]] 467 | name = "prompt-toolkit" 468 | version = "3.0.39" 469 | description = "Library for building powerful interactive command lines in Python" 470 | optional = false 471 | python-versions = ">=3.7.0" 472 | files = [ 473 | {file = "prompt_toolkit-3.0.39-py3-none-any.whl", hash = "sha256:9dffbe1d8acf91e3de75f3b544e4842382fc06c6babe903ac9acb74dc6e08d88"}, 474 | {file = "prompt_toolkit-3.0.39.tar.gz", hash = "sha256:04505ade687dc26dc4284b1ad19a83be2f2afe83e7a828ace0c72f3a1df72aac"}, 475 | ] 476 | 477 | [package.dependencies] 478 | wcwidth = "*" 479 | 480 | [[package]] 481 | name = "ptyprocess" 482 | version = "0.7.0" 483 | description = "Run a subprocess in a pseudo terminal" 484 | optional = false 485 | python-versions = "*" 486 | files = [ 487 | {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, 488 | {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, 489 | ] 490 | 491 | [[package]] 492 | name = "pure-eval" 493 | version = "0.2.2" 494 | description = "Safely evaluate AST nodes without side effects" 495 | optional = false 496 | python-versions = "*" 497 | files = [ 498 | {file = "pure_eval-0.2.2-py3-none-any.whl", hash = "sha256:01eaab343580944bc56080ebe0a674b39ec44a945e6d09ba7db3cb8cec289350"}, 499 | {file = "pure_eval-0.2.2.tar.gz", hash = "sha256:2b45320af6dfaa1750f543d714b6d1c520a1688dec6fd24d339063ce0aaa9ac3"}, 500 | ] 501 | 502 | [package.extras] 503 | tests = ["pytest"] 504 | 505 | [[package]] 506 | name = "pygments" 507 | version = "2.16.1" 508 | description = "Pygments is a syntax highlighting package written in Python." 509 | optional = false 510 | python-versions = ">=3.7" 511 | files = [ 512 | {file = "Pygments-2.16.1-py3-none-any.whl", hash = "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692"}, 513 | {file = "Pygments-2.16.1.tar.gz", hash = "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29"}, 514 | ] 515 | 516 | [package.extras] 517 | plugins = ["importlib-metadata"] 518 | 519 | [[package]] 520 | name = "pyright" 521 | version = "1.1.337" 522 | description = "Command line wrapper for pyright" 523 | optional = false 524 | python-versions = ">=3.7" 525 | files = [ 526 | {file = "pyright-1.1.337-py3-none-any.whl", hash = "sha256:8cbd4ef71797258f816a8393a758c9c91213479f472082d0e3a735ef7ab5f65a"}, 527 | {file = "pyright-1.1.337.tar.gz", hash = "sha256:81d81f839d1750385390c4c4a7b84b062ece2f9a078f87055d4d2a5914ef2a08"}, 528 | ] 529 | 530 | [package.dependencies] 531 | nodeenv = ">=1.6.0" 532 | 533 | [package.extras] 534 | all = ["twine (>=3.4.1)"] 535 | dev = ["twine (>=3.4.1)"] 536 | 537 | [[package]] 538 | name = "pytest" 539 | version = "7.4.3" 540 | description = "pytest: simple powerful testing with Python" 541 | optional = false 542 | python-versions = ">=3.7" 543 | files = [ 544 | {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"}, 545 | {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"}, 546 | ] 547 | 548 | [package.dependencies] 549 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 550 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 551 | iniconfig = "*" 552 | packaging = "*" 553 | pluggy = ">=0.12,<2.0" 554 | tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} 555 | 556 | [package.extras] 557 | testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 558 | 559 | [[package]] 560 | name = "pyyaml" 561 | version = "6.0.1" 562 | description = "YAML parser and emitter for Python" 563 | optional = false 564 | python-versions = ">=3.6" 565 | files = [ 566 | {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, 567 | {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, 568 | {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, 569 | {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, 570 | {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, 571 | {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, 572 | {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, 573 | {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, 574 | {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, 575 | {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, 576 | {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, 577 | {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, 578 | {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, 579 | {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, 580 | {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, 581 | {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, 582 | {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, 583 | {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, 584 | {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, 585 | {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, 586 | {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, 587 | {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, 588 | {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, 589 | {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, 590 | {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, 591 | {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, 592 | {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, 593 | {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, 594 | {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, 595 | {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, 596 | {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, 597 | {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, 598 | {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, 599 | {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, 600 | {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, 601 | {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, 602 | {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, 603 | {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, 604 | {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, 605 | {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, 606 | ] 607 | 608 | [[package]] 609 | name = "ruff" 610 | version = "0.1.6" 611 | description = "An extremely fast Python linter and code formatter, written in Rust." 612 | optional = false 613 | python-versions = ">=3.7" 614 | files = [ 615 | {file = "ruff-0.1.6-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:88b8cdf6abf98130991cbc9f6438f35f6e8d41a02622cc5ee130a02a0ed28703"}, 616 | {file = "ruff-0.1.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5c549ed437680b6105a1299d2cd30e4964211606eeb48a0ff7a93ef70b902248"}, 617 | {file = "ruff-0.1.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cf5f701062e294f2167e66d11b092bba7af6a057668ed618a9253e1e90cfd76"}, 618 | {file = "ruff-0.1.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:05991ee20d4ac4bb78385360c684e4b417edd971030ab12a4fbd075ff535050e"}, 619 | {file = "ruff-0.1.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87455a0c1f739b3c069e2f4c43b66479a54dea0276dd5d4d67b091265f6fd1dc"}, 620 | {file = "ruff-0.1.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:683aa5bdda5a48cb8266fcde8eea2a6af4e5700a392c56ea5fb5f0d4bfdc0240"}, 621 | {file = "ruff-0.1.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:137852105586dcbf80c1717facb6781555c4e99f520c9c827bd414fac67ddfb6"}, 622 | {file = "ruff-0.1.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd98138a98d48a1c36c394fd6b84cd943ac92a08278aa8ac8c0fdefcf7138f35"}, 623 | {file = "ruff-0.1.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a0cd909d25f227ac5c36d4e7e681577275fb74ba3b11d288aff7ec47e3ae745"}, 624 | {file = "ruff-0.1.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8fd1c62a47aa88a02707b5dd20c5ff20d035d634aa74826b42a1da77861b5ff"}, 625 | {file = "ruff-0.1.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fd89b45d374935829134a082617954120d7a1470a9f0ec0e7f3ead983edc48cc"}, 626 | {file = "ruff-0.1.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:491262006e92f825b145cd1e52948073c56560243b55fb3b4ecb142f6f0e9543"}, 627 | {file = "ruff-0.1.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ea284789861b8b5ca9d5443591a92a397ac183d4351882ab52f6296b4fdd5462"}, 628 | {file = "ruff-0.1.6-py3-none-win32.whl", hash = "sha256:1610e14750826dfc207ccbcdd7331b6bd285607d4181df9c1c6ae26646d6848a"}, 629 | {file = "ruff-0.1.6-py3-none-win_amd64.whl", hash = "sha256:4558b3e178145491e9bc3b2ee3c4b42f19d19384eaa5c59d10acf6e8f8b57e33"}, 630 | {file = "ruff-0.1.6-py3-none-win_arm64.whl", hash = "sha256:03910e81df0d8db0e30050725a5802441c2022ea3ae4fe0609b76081731accbc"}, 631 | {file = "ruff-0.1.6.tar.gz", hash = "sha256:1b09f29b16c6ead5ea6b097ef2764b42372aebe363722f1605ecbcd2b9207184"}, 632 | ] 633 | 634 | [[package]] 635 | name = "setuptools" 636 | version = "68.2.2" 637 | description = "Easily download, build, install, upgrade, and uninstall Python packages" 638 | optional = false 639 | python-versions = ">=3.8" 640 | files = [ 641 | {file = "setuptools-68.2.2-py3-none-any.whl", hash = "sha256:b454a35605876da60632df1a60f736524eb73cc47bbc9f3f1ef1b644de74fd2a"}, 642 | {file = "setuptools-68.2.2.tar.gz", hash = "sha256:4ac1475276d2f1c48684874089fefcd83bd7162ddaafb81fac866ba0db282a87"}, 643 | ] 644 | 645 | [package.extras] 646 | docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] 647 | testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] 648 | testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] 649 | 650 | [[package]] 651 | name = "six" 652 | version = "1.16.0" 653 | description = "Python 2 and 3 compatibility utilities" 654 | optional = false 655 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 656 | files = [ 657 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 658 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 659 | ] 660 | 661 | [[package]] 662 | name = "stack-data" 663 | version = "0.6.3" 664 | description = "Extract data from python stack frames and tracebacks for informative displays" 665 | optional = false 666 | python-versions = "*" 667 | files = [ 668 | {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"}, 669 | {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, 670 | ] 671 | 672 | [package.dependencies] 673 | asttokens = ">=2.1.0" 674 | executing = ">=1.2.0" 675 | pure-eval = "*" 676 | 677 | [package.extras] 678 | tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] 679 | 680 | [[package]] 681 | name = "toml" 682 | version = "0.10.2" 683 | description = "Python Library for Tom's Obvious, Minimal Language" 684 | optional = false 685 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 686 | files = [ 687 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 688 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 689 | ] 690 | 691 | [[package]] 692 | name = "tomli" 693 | version = "2.0.1" 694 | description = "A lil' TOML parser" 695 | optional = false 696 | python-versions = ">=3.7" 697 | files = [ 698 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 699 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 700 | ] 701 | 702 | [[package]] 703 | name = "traitlets" 704 | version = "5.13.0" 705 | description = "Traitlets Python configuration system" 706 | optional = false 707 | python-versions = ">=3.8" 708 | files = [ 709 | {file = "traitlets-5.13.0-py3-none-any.whl", hash = "sha256:baf991e61542da48fe8aef8b779a9ea0aa38d8a54166ee250d5af5ecf4486619"}, 710 | {file = "traitlets-5.13.0.tar.gz", hash = "sha256:9b232b9430c8f57288c1024b34a8f0251ddcc47268927367a0dd3eeaca40deb5"}, 711 | ] 712 | 713 | [package.extras] 714 | docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] 715 | test = ["argcomplete (>=3.0.3)", "mypy (>=1.6.0)", "pre-commit", "pytest (>=7.0,<7.5)", "pytest-mock", "pytest-mypy-testing"] 716 | 717 | [[package]] 718 | name = "typing-extensions" 719 | version = "4.8.0" 720 | description = "Backported and Experimental Type Hints for Python 3.8+" 721 | optional = false 722 | python-versions = ">=3.8" 723 | files = [ 724 | {file = "typing_extensions-4.8.0-py3-none-any.whl", hash = "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0"}, 725 | {file = "typing_extensions-4.8.0.tar.gz", hash = "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef"}, 726 | ] 727 | 728 | [[package]] 729 | name = "virtualenv" 730 | version = "20.24.6" 731 | description = "Virtual Python Environment builder" 732 | optional = false 733 | python-versions = ">=3.7" 734 | files = [ 735 | {file = "virtualenv-20.24.6-py3-none-any.whl", hash = "sha256:520d056652454c5098a00c0f073611ccbea4c79089331f60bf9d7ba247bb7381"}, 736 | {file = "virtualenv-20.24.6.tar.gz", hash = "sha256:02ece4f56fbf939dbbc33c0715159951d6bf14aaf5457b092e4548e1382455af"}, 737 | ] 738 | 739 | [package.dependencies] 740 | distlib = ">=0.3.7,<1" 741 | filelock = ">=3.12.2,<4" 742 | platformdirs = ">=3.9.1,<4" 743 | 744 | [package.extras] 745 | docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] 746 | test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] 747 | 748 | [[package]] 749 | name = "wcwidth" 750 | version = "0.2.9" 751 | description = "Measures the displayed width of unicode strings in a terminal" 752 | optional = false 753 | python-versions = "*" 754 | files = [ 755 | {file = "wcwidth-0.2.9-py2.py3-none-any.whl", hash = "sha256:9a929bd8380f6cd9571a968a9c8f4353ca58d7cd812a4822bba831f8d685b223"}, 756 | {file = "wcwidth-0.2.9.tar.gz", hash = "sha256:a675d1a4a2d24ef67096a04b85b02deeecd8e226f57b5e3a72dbb9ed99d27da8"}, 757 | ] 758 | 759 | [metadata] 760 | lock-version = "2.0" 761 | python-versions = "^3.10" 762 | content-hash = "4957630d074aafc1f910f38d348dd730050db3bf68d1dad09a468e4ca6c8ddd4" 763 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "stateless" 3 | version = "0.5.2" 4 | description = "Statically typed, purely functional effects for Python" 5 | authors = ["suned "] 6 | readme = "README.md" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.10" 10 | typing-extensions = "^4.8.0" 11 | cloudpickle = "^3.0.0" 12 | 13 | 14 | [tool.poetry.group.dev.dependencies] 15 | mypy = "^1.6.1" 16 | ipdb = "^0.13.13" 17 | ipython = "^8.17.2" 18 | pytest = "^7.4.3" 19 | pyright = "^1.1.336" 20 | pre-commit = "^3.5.0" 21 | ruff = "^0.1.6" 22 | coverage = "^7.3.2" 23 | toml = "^0.10.2" 24 | 25 | 26 | [tool.mypy] 27 | disallow_untyped_calls = true 28 | disallow_untyped_defs = true 29 | disallow_incomplete_defs = true 30 | disallow_untyped_decorators = true 31 | warn_redundant_casts = true 32 | warn_unused_ignores = true 33 | warn_return_any = true 34 | strict_equality = true 35 | disallow_any_generics = true 36 | 37 | [tool.ruff] 38 | select = ["I", "F", "N", "RUF", "D"] 39 | ignore = ["D107", "D213", "D203", "D202", "D212"] 40 | 41 | [tool.ruff.per-file-ignores] 42 | "tests/**/*" = ["D100", "D101", "D102", "D103", "D104", "D105", "D107"] 43 | 44 | 45 | [build-system] 46 | requires = ["poetry-core"] 47 | build-backend = "poetry.core.masonry.api" 48 | -------------------------------------------------------------------------------- /src/stateless/__init__.py: -------------------------------------------------------------------------------- 1 | """Statically typed, purely functional effects.""" 2 | 3 | # ruff: noqa: F401 4 | 5 | from stateless.effect import ( 6 | Depend, 7 | Effect, 8 | Success, 9 | Try, 10 | catch, 11 | depend, 12 | memoize, 13 | success, 14 | throw, 15 | throws, 16 | ) 17 | from stateless.functions import repeat, retry 18 | from stateless.parallel import parallel, process 19 | from stateless.runtime import Runtime 20 | from stateless.schedule import Schedule 21 | -------------------------------------------------------------------------------- /src/stateless/console.py: -------------------------------------------------------------------------------- 1 | """Contains the Console ability and ability helpers.""" 2 | 3 | from typing import Any 4 | 5 | from stateless.effect import Depend 6 | 7 | 8 | class Console: 9 | """The Console ability.""" 10 | 11 | def print(self, content: Any) -> None: 12 | """Print the given content to stdout. 13 | 14 | Args: 15 | ---- 16 | content: The content to print. 17 | """ 18 | print(content) 19 | 20 | def input(self, prompt: str = "") -> str: 21 | """Read a line from stdin. 22 | 23 | Args: 24 | ---- 25 | prompt: The prompt to display. 26 | 27 | Returns: 28 | ------- 29 | The line read from stdin. 30 | """ 31 | return input(prompt) 32 | 33 | 34 | def print_line(content: Any) -> Depend[Console, None]: 35 | """Print the given content to stdout. 36 | 37 | Args: 38 | ---- 39 | content: The content to print. 40 | 41 | Returns: 42 | ------- 43 | A Depend that prints the given content. 44 | """ 45 | console = yield Console 46 | console.print(content) 47 | 48 | 49 | def read_line(prompt: str = "") -> Depend[Console, str]: 50 | """Read a line from stdin. 51 | 52 | Args: 53 | ---- 54 | prompt: The prompt to display. 55 | 56 | Returns: 57 | ------- 58 | A Depend that reads a line from stdin. 59 | """ 60 | console: Console = yield Console 61 | return console.input(prompt) 62 | -------------------------------------------------------------------------------- /src/stateless/effect.py: -------------------------------------------------------------------------------- 1 | """Contains the Effect type and core functions for working with effects.""" 2 | 3 | from collections.abc import Generator, Hashable 4 | from dataclasses import dataclass, field 5 | from functools import lru_cache, partial, wraps 6 | from types import TracebackType 7 | from typing import Any, Callable, Type, TypeVar, cast, overload 8 | 9 | from typing_extensions import Never, ParamSpec, TypeAlias 10 | 11 | R = TypeVar("R") 12 | A = TypeVar("A", bound=Hashable) 13 | E = TypeVar("E", bound=Exception) 14 | P = ParamSpec("P") 15 | E2 = TypeVar("E2", bound=Exception) 16 | 17 | Effect: TypeAlias = Generator[Type[A] | E, Any, R] 18 | Depend: TypeAlias = Generator[Type[A], Any, R] 19 | Success: TypeAlias = Depend[Never, R] 20 | Try: TypeAlias = Generator[E, Never, R] 21 | 22 | 23 | class NoResultError(Exception): 24 | """Raised when an effect has no result. 25 | 26 | If this error is raised to user code 27 | it should be considered a bug in stateless. 28 | """ 29 | 30 | 31 | def success(result: R) -> Success[R]: 32 | """ 33 | Create an effect that returns a value. 34 | 35 | Args: 36 | ---- 37 | result: The value to return. 38 | 39 | Returns: 40 | ------- 41 | An effect that returns the value. 42 | """ 43 | yield None # type: ignore 44 | return result 45 | 46 | 47 | def throw(reason: E) -> Try[E, Never]: # type: ignore 48 | """ 49 | Create an effect that yields an exception. 50 | 51 | Args: 52 | ---- 53 | reason: The exception to yield. 54 | 55 | Returns: 56 | ------- 57 | An effect that yields the exception. 58 | """ 59 | yield reason 60 | 61 | 62 | def catch(f: Callable[P, Effect[A, E, R]]) -> Callable[P, Depend[A, E | R]]: 63 | """ 64 | Catch exceptions yielded by the effect return by `f`. 65 | 66 | Args: 67 | ---- 68 | f: The function to catch exceptions from. 69 | 70 | Returns: 71 | ------- 72 | `f` decorated such that exceptions yielded by the resulting effect are returned. 73 | """ 74 | 75 | @wraps(f) 76 | def wrapper(*args: P.args, **kwargs: P.kwargs) -> Depend[A, E | R]: 77 | try: 78 | effect = f(*args, **kwargs) 79 | ability_or_error = next(effect) 80 | while True: 81 | if isinstance(ability_or_error, Exception): 82 | return ability_or_error # type: ignore 83 | else: 84 | ability = yield ability_or_error 85 | ability_or_error = effect.send(ability) 86 | except StopIteration as e: 87 | return e.value # type: ignore 88 | 89 | return wrapper 90 | 91 | 92 | def depend(ability: Type[A]) -> Depend[A, A]: 93 | """ 94 | Create an effect that yields an ability and returns the ability sent from the runtime. 95 | 96 | Args: 97 | ---- 98 | ability: The ability to yield. 99 | 100 | Returns: 101 | ------- 102 | An effect that yields the ability and returns the ability sent from the runtime. 103 | """ 104 | a = yield ability 105 | return cast(A, a) 106 | 107 | 108 | @overload 109 | def throws( 110 | *errors: Type[E2], 111 | ) -> Callable[[Callable[P, Depend[A, R]]], Callable[P, Effect[A, E2, R]]]: 112 | ... # pragma: no cover 113 | 114 | 115 | @overload 116 | def throws( # type: ignore 117 | *errors: Type[E2], 118 | ) -> Callable[[Callable[P, Effect[A, E, R]]], Callable[P, Effect[A, E | E2, R]]]: 119 | ... # pragma: no cover 120 | 121 | 122 | def throws( # type: ignore 123 | *errors: Type[E2], 124 | ) -> Callable[ 125 | [Callable[P, Effect[A, E, R] | Depend[A, R]]], 126 | Callable[P, Effect[A, E | E2, R] | Effect[A, E2, R]], 127 | ]: 128 | """ 129 | Decorate functions returning effects by catching exceptions of a certain type and yields them as an effect. 130 | 131 | Args: 132 | ---- 133 | *errors: The types of exceptions to catch. 134 | 135 | Returns: 136 | ------- 137 | A decorator that catches exceptions of a certain type from functions returning effects and yields them as an effect. 138 | """ 139 | 140 | def decorator(f: Callable[P, Effect[A, E, R]]) -> Callable[P, Effect[A, E | E2, R]]: 141 | @wraps(f) 142 | def wrapper(*args: P.args, **kwargs: P.kwargs) -> Effect[A, E | E2, R]: 143 | try: 144 | return (yield from f(*args, **kwargs)) 145 | except errors as e: # pyright: ignore 146 | return (yield from throw(e)) 147 | 148 | return wrapper 149 | 150 | return decorator 151 | 152 | 153 | @dataclass(frozen=True) 154 | class Memoize(Effect[A, E, R]): 155 | """Effect that memoizes the result of an effect.""" 156 | 157 | effect: Effect[A, E, R] 158 | _memoized_result: R | None = field(init=False, default=None) 159 | 160 | def send(self, value: A) -> Type[A] | E: 161 | """Send a value to the effect.""" 162 | 163 | if self._memoized_result is not None: 164 | raise StopIteration(self._memoized_result) 165 | try: 166 | return self.effect.send(value) 167 | except StopIteration as e: 168 | object.__setattr__(self, "_memoized_result", e.value) 169 | raise e 170 | 171 | def throw( 172 | self, 173 | exc_type: Type[BaseException] | BaseException, 174 | error: BaseException | object | None = None, 175 | exc_tb: TracebackType | None = None, 176 | /, 177 | ) -> Type[A] | E: 178 | """Throw an exception into the effect.""" 179 | 180 | try: 181 | return self.effect.throw(exc_type, error, exc_tb) # type: ignore 182 | except StopIteration as e: 183 | object.__setattr__(self, "_memoized_result", e.value) 184 | raise e 185 | 186 | 187 | @overload 188 | def memoize( 189 | f: Callable[P, Effect[A, E, R]], 190 | ) -> Callable[P, Effect[A, E, R]]: 191 | ... # pragma: no cover 192 | 193 | 194 | @overload 195 | def memoize( 196 | *, 197 | maxsize: int | None = None, 198 | typed: bool = False, 199 | ) -> Callable[[Callable[P, Effect[A, E, R]]], Callable[P, Effect[A, E, R]]]: 200 | ... # pragma: no cover 201 | 202 | 203 | def memoize( # type: ignore 204 | f: Callable[P, Effect[A, E, R]] | None = None, 205 | *, 206 | maxsize: int | None = None, 207 | typed: bool = False, 208 | ) -> ( 209 | Callable[P, Effect[A, E, R]] 210 | | Callable[[Callable[P, Effect[A, E, R]]], Callable[P, Effect[A, E, R]]] 211 | ): 212 | """Memoize a function that returns an effect. 213 | 214 | Args: 215 | ---- 216 | f: The function to memoize. 217 | maxsize: The maximum size of the cache. 218 | typed: Whether to use typed caching. 219 | 220 | Returns: 221 | ------- 222 | The memoized function. 223 | """ 224 | if f is None: 225 | return partial(memoize, maxsize=maxsize, typed=typed) # type: ignore 226 | 227 | @lru_cache(maxsize=maxsize, typed=typed) 228 | @wraps(f) 229 | def wrapper(*args: P.args, **kwargs: P.kwargs) -> Effect[A, E, R]: 230 | return Memoize(f(*args, **kwargs)) 231 | 232 | return wrapper # type: ignore 233 | -------------------------------------------------------------------------------- /src/stateless/errors.py: -------------------------------------------------------------------------------- 1 | """Custom errors for the stateless package.""" 2 | 3 | from typing import Type 4 | 5 | 6 | class MissingAbilityError(Exception): 7 | """Raised when an effect requires an ability that is not available in the runtime thats executing it.""" 8 | 9 | ability: Type[object] 10 | -------------------------------------------------------------------------------- /src/stateless/files.py: -------------------------------------------------------------------------------- 1 | """Files ability and ability helpers.""" 2 | 3 | from stateless.effect import Depend, throws 4 | 5 | 6 | class Files: 7 | """The Files ability.""" 8 | 9 | def read_file(self, path: str) -> str: 10 | """ 11 | Read a file. 12 | 13 | Args: 14 | ---- 15 | path: The path to the file. 16 | 17 | Returns: 18 | ------- 19 | The contents of the file. 20 | """ 21 | with open(path) as f: 22 | return f.read() 23 | 24 | 25 | @throws(FileNotFoundError, PermissionError) 26 | def read_file(path: str) -> Depend[Files, str]: 27 | """ 28 | Read a file. 29 | 30 | Args: 31 | ---- 32 | path: The path to the file. 33 | 34 | Returns: 35 | ------- 36 | The contents of the file as an effect. 37 | """ 38 | files: Files = yield Files 39 | return files.read_file(path) 40 | -------------------------------------------------------------------------------- /src/stateless/functions.py: -------------------------------------------------------------------------------- 1 | """Functions for working with effects.""" 2 | 3 | from functools import wraps 4 | from typing import Callable, Generic, ParamSpec, Tuple, TypeVar 5 | 6 | from stateless.effect import Effect, catch, throw 7 | from stateless.schedule import Schedule 8 | from stateless.time import Time, sleep 9 | 10 | A = TypeVar("A") 11 | A2 = TypeVar("A2") 12 | E = TypeVar("E", bound=Exception) 13 | R = TypeVar("R") 14 | P = ParamSpec("P") 15 | 16 | 17 | def repeat( 18 | schedule: Schedule[A], 19 | ) -> Callable[ 20 | [Callable[P, Effect[A2, E, R]]], 21 | Callable[P, Effect[A | A2 | Time, E, Tuple[R, ...]]], 22 | ]: 23 | """ 24 | Repeat an effect according to a schedule. 25 | 26 | Decorates a function that returns an effect to repeat the effect according to a schedule. 27 | Repeats the effect until the schedule is exhausted, or an exception is yielded. 28 | 29 | Args: 30 | ---- 31 | schedule: The schedule to repeat the effect according to. 32 | 33 | Returns: 34 | ------- 35 | A decorator that repeats the effect according to the schedule. 36 | """ 37 | 38 | def decorator( 39 | f: Callable[P, Effect[A2, E, R]] 40 | ) -> Callable[P, Effect[A | A2 | Time, E, Tuple[R, ...]]]: 41 | @wraps(f) 42 | def wrapper( 43 | *args: P.args, **kwargs: P.kwargs 44 | ) -> Effect[A | A2 | Time, E, Tuple[R, ...]]: 45 | deltas = yield from schedule 46 | results = [] 47 | for interval in deltas: 48 | result = yield from catch(f)(*args, **kwargs) 49 | match result: 50 | case Exception() as error: 51 | return (yield from throw(error)) # type: ignore 52 | case _: 53 | results.append(result) 54 | yield from sleep(interval.total_seconds()) 55 | return tuple(results) 56 | 57 | return wrapper 58 | 59 | return decorator 60 | 61 | 62 | class RetryError(Exception, Generic[E]): 63 | """An error that contains all the errors from a retry.""" 64 | 65 | errors: tuple[E, ...] 66 | 67 | 68 | def retry( 69 | schedule: Schedule[A], 70 | ) -> Callable[ 71 | [Callable[P, Effect[A2, E, R]]], 72 | Callable[P, Effect[A | A2 | Time, RetryError[E], R]], 73 | ]: 74 | """ 75 | Retry an effect according to a schedule. 76 | 77 | Decorates a function that returns an effect to retry the effect according to a schedule. 78 | Retries the effect until the schedule is exhausted, or the effect returns a value. 79 | If the effect never returns a value before the schedule is exhausted, a `RetryError` is yielded containing all the errors. 80 | 81 | Args: 82 | ---- 83 | schedule: The schedule to retry the effect according to. 84 | 85 | Returns: 86 | ------- 87 | A decorator that retries the effect according to the schedule. 88 | """ 89 | 90 | def decorator( 91 | f: Callable[P, Effect[A2, E, R]] 92 | ) -> Callable[P, Effect[A | A2 | Time, RetryError[E], R]]: 93 | @wraps(f) 94 | def wrapper( 95 | *args: P.args, **kwargs: P.kwargs 96 | ) -> Effect[A | A2 | Time, RetryError[E], R]: 97 | deltas = yield from schedule 98 | errors = [] 99 | for interval in deltas: 100 | result = yield from catch(f)(*args, **kwargs) 101 | match result: 102 | case Exception() as error: 103 | errors.append(error) 104 | case _: 105 | return result 106 | yield from sleep(interval.total_seconds()) 107 | return (yield from throw(RetryError(tuple(errors)))) 108 | 109 | return wrapper 110 | 111 | return decorator 112 | -------------------------------------------------------------------------------- /src/stateless/parallel.py: -------------------------------------------------------------------------------- 1 | """Contains the Parallel ability and ability helpers.""" 2 | 3 | from dataclasses import dataclass 4 | from functools import wraps 5 | from multiprocessing import Manager 6 | from multiprocessing.managers import BaseManager, PoolProxy # type: ignore 7 | from multiprocessing.pool import ThreadPool 8 | from types import TracebackType 9 | from typing import ( 10 | TYPE_CHECKING, 11 | Callable, 12 | Generic, 13 | Literal, 14 | ParamSpec, 15 | Sequence, 16 | Type, 17 | TypeVar, 18 | cast, 19 | overload, 20 | ) 21 | 22 | import cloudpickle # type: ignore 23 | from typing_extensions import Never 24 | 25 | from stateless.effect import Depend, Effect, Success, throw 26 | 27 | if TYPE_CHECKING: 28 | from stateless.runtime import Runtime # pragma: no cover 29 | 30 | 31 | A = TypeVar("A") 32 | E = TypeVar("E", bound=Exception) 33 | R = TypeVar("R") 34 | 35 | 36 | @dataclass(frozen=True) 37 | class Task(Generic[A, E, R]): 38 | """A task that can be run in parallel. 39 | 40 | Captures arguments to functions that return effects 41 | in order that they can be run in parallel, without concerns 42 | about serialization and thread-safety of effects. 43 | """ 44 | 45 | f: Callable[..., Effect[A, E, R]] 46 | args: tuple[object, ...] 47 | kwargs: dict[str, object] 48 | use_threads: bool 49 | 50 | def __iter__(self) -> Effect[A, E, R]: 51 | """Iterate the effect wrapped by this task.""" 52 | return self.f(*self.args, **self.kwargs) 53 | 54 | 55 | def _run_task(payload: bytes) -> bytes: 56 | runtime, task = cast( 57 | tuple["Runtime[Parallel]", Task[object, Exception, object]], 58 | cloudpickle.loads(payload), 59 | ) 60 | ability: Parallel = runtime.get_ability(Parallel) 61 | with ability: 62 | result = runtime.run(iter(task), return_errors=True) # type: ignore 63 | return cloudpickle.dumps(result) # type: ignore 64 | 65 | 66 | class SuccessTask(Task[Never, Never, R]): 67 | """A task that can be run in parallel. 68 | 69 | Captures arguments to functions that return effects 70 | in order that they can be run in parallel, remove concerns 71 | about serialization and thread-safety of effects. 72 | """ 73 | 74 | 75 | class DependTask(Task[A, Never, R]): 76 | """A task that can be run in parallel. 77 | 78 | Captures arguments to functions that return effects 79 | in order that they can be run in parallel, without concerns 80 | about serialization and thread-safety of effects. 81 | """ 82 | 83 | 84 | @dataclass(frozen=True, init=False) 85 | class Parallel: 86 | """The Parallel ability. 87 | 88 | Enables running tasks in parallel using threads and processes. 89 | 90 | Args: 91 | ---- 92 | thread_pool: The thread pool to use to run tasks in parallel. 93 | pool: The multiprocessing pool to use to run tasks in parallel. Must be a proxy pool. 94 | """ 95 | 96 | _thread_pool: ThreadPool | None 97 | _manager: BaseManager | None 98 | _pool: PoolProxy | None 99 | state: Literal["init", "entered", "exited"] = "init" 100 | _owns_thread_pool: bool = True 101 | _owns_process_pool: bool = True 102 | 103 | @property 104 | def thread_pool(self) -> ThreadPool: 105 | """The thread pool used to run tasks in parallel.""" 106 | 107 | if self._thread_pool is None: 108 | object.__setattr__(self, "_thread_pool", ThreadPool()) 109 | self._thread_pool.__enter__() # type: ignore 110 | return self._thread_pool # type: ignore 111 | 112 | @property 113 | def manager(self) -> BaseManager: 114 | """The multiprocessing manager used to run tasks in parallel.""" 115 | 116 | if self._manager is None: 117 | object.__setattr__(self, "_manager", Manager()) 118 | self._manager.__enter__() # type: ignore 119 | return self._manager # type: ignore 120 | 121 | @property 122 | def pool(self) -> PoolProxy: 123 | """The multiprocessing pool used to run tasks in parallel.""" 124 | 125 | if self._pool is None: 126 | object.__setattr__(self, "_pool", self.manager.Pool()) # type: ignore 127 | self._pool.__enter__() # type: ignore 128 | return self._pool 129 | 130 | def __init__( 131 | self, thread_pool: ThreadPool | None = None, pool: PoolProxy | None = None 132 | ): 133 | object.__setattr__(self, "_thread_pool", thread_pool) 134 | object.__setattr__(self, "_manager", None) 135 | object.__setattr__(self, "_pool", pool) 136 | 137 | if thread_pool is not None: 138 | object.__setattr__(self, "_owns_thread_pool", False) 139 | if pool is not None: 140 | object.__setattr__(self, "_owns_process_pool", False) 141 | 142 | def __getstate__( 143 | self, 144 | ) -> tuple[ 145 | tuple[int, Callable[..., tuple[object, ...]], tuple[object, ...]] | None, 146 | PoolProxy, 147 | ]: 148 | """ 149 | Get the state of the Parallel ability for pickling. 150 | 151 | Returns 152 | ------- 153 | The state of the Parallel ability. 154 | """ 155 | if self._thread_pool is None: 156 | return None, self.pool 157 | else: 158 | return ( 159 | ( 160 | self.thread_pool._processes, # type: ignore 161 | self.thread_pool._initializer, # type: ignore 162 | self.thread_pool._initargs, # type: ignore 163 | ), 164 | self.pool, 165 | ) 166 | 167 | def __setstate__( 168 | self, 169 | state: tuple[ 170 | tuple[int, Callable[..., tuple[object, ...]], tuple[object, ...]], PoolProxy 171 | ], 172 | ) -> None: 173 | """ 174 | Set the state of the Parallel ability from pickling. 175 | 176 | Args: 177 | ---- 178 | state: The state of the Parallel ability obtained using __getstate__. 179 | """ 180 | thread_pool_args, pool = state 181 | if thread_pool_args is None: 182 | object.__setattr__(self, "_thread_pool", None) 183 | else: 184 | object.__setattr__(self, "_thread_pool", ThreadPool(*thread_pool_args)) 185 | 186 | object.__setattr__(self, "_pool", pool) 187 | object.__setattr__(self, "_manager", None) 188 | object.__setattr__(self, "state", "entered") 189 | 190 | def __enter__(self) -> "Parallel": 191 | """Enter the Parallel ability context.""" 192 | object.__setattr__(self, "state", "entered") 193 | return self 194 | 195 | def __exit__( 196 | self, 197 | exc_type: Type[BaseException] | None, 198 | exc_value: BaseException | None, 199 | exc_tb: TracebackType | None, 200 | ) -> None | bool: 201 | """Exit the Parallel ability context.""" 202 | 203 | if self._manager is not None: 204 | if self._owns_process_pool: 205 | self._pool.__exit__(exc_type, exc_value, exc_tb) # type: ignore 206 | self._manager.__exit__(exc_type, exc_value, exc_tb) 207 | if self._thread_pool is not None and self._owns_thread_pool: 208 | self._thread_pool.__exit__(exc_type, exc_value, exc_tb) 209 | object.__setattr__(self, "_thread_pool", None) 210 | object.__setattr__(self, "state", "exited") 211 | 212 | return None 213 | 214 | def run_thread_tasks( 215 | self, 216 | runtime: "Runtime[object]", 217 | tasks: Sequence[Task[object, Exception, object]], 218 | ) -> Sequence[object]: 219 | """ 220 | Run tasks in parallel using threads. 221 | 222 | Args: 223 | ---- 224 | runtime: The runtime to run the tasks in. 225 | tasks: The tasks to run. 226 | 227 | Returns: 228 | ------- 229 | The results of the tasks. 230 | """ 231 | self.thread_pool.__enter__() 232 | return self.thread_pool.map( 233 | lambda task: runtime.run(iter(task), return_errors=True), tasks 234 | ) 235 | 236 | def run_process_tasks( 237 | self, 238 | runtime: "Runtime[object]", 239 | tasks: Sequence[Task[object, Exception, object]], 240 | ) -> Sequence[object]: 241 | """ 242 | Run tasks in parallel using processes. 243 | 244 | Args: 245 | ---- 246 | runtime: The runtime to run the tasks in. 247 | tasks: The tasks to run. 248 | 249 | Returns: 250 | ------- 251 | The results of the tasks. 252 | """ 253 | payloads: list[bytes] = [cloudpickle.dumps((runtime, task)) for task in tasks] 254 | return [ 255 | cloudpickle.loads(result) for result in self.pool.map(_run_task, payloads) 256 | ] 257 | 258 | def run( 259 | self, 260 | runtime: "Runtime[object]", 261 | tasks: tuple[Task[object, Exception, object], ...], 262 | ) -> tuple[object, ...] | Exception: 263 | """ 264 | Run tasks in parallel. 265 | 266 | Args: 267 | ---- 268 | runtime: The runtime to run the tasks in. 269 | tasks: The tasks to run. 270 | 271 | Returns: 272 | ------- 273 | The results of the tasks. 274 | """ 275 | if self.state == "init": 276 | raise RuntimeError("Parallel must be used as a context manager") 277 | if self.state == "exited": 278 | raise RuntimeError("Parallel context manager has already exited") 279 | thread_tasks_and_indices = [ 280 | (i, task) for i, task in enumerate(tasks) if task.use_threads 281 | ] 282 | 283 | if thread_tasks_and_indices: 284 | thread_indices, thread_tasks = zip(*thread_tasks_and_indices) 285 | thread_results = self.run_thread_tasks(runtime, thread_tasks) 286 | for result in thread_results: 287 | if isinstance(result, Exception): 288 | return result 289 | else: 290 | thread_results = () 291 | thread_indices = () 292 | 293 | cpu_tasks_and_indices = [ 294 | (i, task) for i, task in enumerate(tasks) if not task.use_threads 295 | ] 296 | 297 | if cpu_tasks_and_indices: 298 | cpu_indices, cpu_tasks = zip(*cpu_tasks_and_indices) 299 | cpu_results = self.run_process_tasks(runtime, cpu_tasks) 300 | for result in cpu_results: 301 | if isinstance(result, Exception): 302 | return result 303 | else: 304 | cpu_results = () 305 | cpu_indices = () 306 | results: list[object] = [None] * len(tasks) 307 | for i, result in zip(thread_indices, thread_results): 308 | results[i] = result 309 | for i, result in zip(cpu_indices, cpu_results): 310 | results[i] = result 311 | return tuple(results) 312 | 313 | 314 | A1 = TypeVar("A1") 315 | A2 = TypeVar("A2") 316 | A3 = TypeVar("A3") 317 | A4 = TypeVar("A4") 318 | A5 = TypeVar("A5") 319 | A6 = TypeVar("A6") 320 | A7 = TypeVar("A7") 321 | E1 = TypeVar("E1", bound=Exception) 322 | E2 = TypeVar("E2", bound=Exception) 323 | E3 = TypeVar("E3", bound=Exception) 324 | E4 = TypeVar("E4", bound=Exception) 325 | E5 = TypeVar("E5", bound=Exception) 326 | E6 = TypeVar("E6", bound=Exception) 327 | E7 = TypeVar("E7", bound=Exception) 328 | R1 = TypeVar("R1") 329 | R2 = TypeVar("R2") 330 | R3 = TypeVar("R3") 331 | R4 = TypeVar("R4") 332 | R5 = TypeVar("R5") 333 | R6 = TypeVar("R6") 334 | R7 = TypeVar("R7") 335 | 336 | 337 | P = ParamSpec("P") 338 | 339 | 340 | # I'm not sure why this is overload is necessary, but mypy complains without it 341 | @overload 342 | def process( # type: ignore 343 | f: Callable[P, Success[R]] 344 | ) -> Callable[P, SuccessTask[R]]: 345 | ... # pragma: no cover 346 | 347 | 348 | @overload 349 | def process(f: Callable[P, Depend[A, R]]) -> Callable[P, DependTask[A, R]]: 350 | ... # pragma: no cover 351 | 352 | 353 | @overload 354 | def process(f: Callable[P, Effect[A, E, R]]) -> Callable[P, Task[A, E, R]]: 355 | ... # pragma: no cover 356 | 357 | 358 | def process( # type: ignore 359 | f: Callable[P, Effect[object, Exception, object]] 360 | ) -> Callable[P, Task[object, Exception, object]]: 361 | """ 362 | Create a task that can be run in parallel using processes. 363 | 364 | Args: 365 | ---- 366 | f: The function to capture as a task. 367 | 368 | Returns: 369 | ------- 370 | `f` decorated to return a task. 371 | """ 372 | 373 | @wraps(f) 374 | def wrapper(*args: P.args, **kwargs: P.kwargs) -> Task[object, Exception, object]: 375 | return Task( 376 | f, 377 | args, 378 | kwargs, 379 | use_threads=False, 380 | ) 381 | 382 | return wrapper 383 | 384 | 385 | @overload 386 | def thread( # type: ignore 387 | f: Callable[P, Success[R]] 388 | ) -> Callable[P, SuccessTask[R]]: 389 | ... # pragma: no cover 390 | 391 | 392 | @overload 393 | def thread(f: Callable[P, Depend[A, R]]) -> Callable[P, DependTask[A, R]]: 394 | ... # pragma: no cover 395 | 396 | 397 | @overload 398 | def thread(f: Callable[P, Effect[A, E, R]]) -> Callable[P, Task[A, E, R]]: 399 | ... # pragma: no cover 400 | 401 | 402 | def thread( # type: ignore 403 | f: Callable[P, Effect[object, Exception, object]] 404 | ) -> Callable[P, Task[object, Exception, object]]: 405 | """ 406 | Create a task that can be run in parallel using threads. 407 | 408 | Args: 409 | ---- 410 | f: The function to capture as a task. 411 | 412 | Returns: 413 | ------- 414 | `f` decorated to return a task. 415 | """ 416 | 417 | @wraps(f) 418 | def wrapper(*args: P.args, **kwargs: P.kwargs) -> Task[object, Exception, object]: 419 | return Task( 420 | f, 421 | args, 422 | kwargs, 423 | use_threads=True, 424 | ) 425 | 426 | return wrapper 427 | 428 | 429 | @overload 430 | def parallel() -> Effect[Parallel, Never, tuple[()]]: 431 | ... # pragma: no cover 432 | 433 | 434 | @overload 435 | def parallel(t1: Task[A1, E1, R1], /) -> Effect[A1 | Parallel, E1, tuple[R1]]: 436 | ... # pragma: no cover 437 | 438 | 439 | @overload 440 | def parallel( 441 | t1: Task[A1, E1, R1], t2: Task[A2, E2, R2], / 442 | ) -> Effect[A1 | A2 | Parallel, E1 | E2, tuple[R1, R2]]: 443 | ... # pragma: no cover 444 | 445 | 446 | @overload 447 | def parallel( 448 | t1: Task[A1, E1, R1], 449 | t2: Task[A2, E2, R2], 450 | t3: Task[A3, E3, R3], 451 | /, 452 | ) -> Effect[A1 | A2 | A3 | Parallel, E1 | E2 | E3, tuple[R1, R2, R3]]: 453 | ... # pragma: no cover 454 | 455 | 456 | @overload 457 | def parallel( 458 | *tasks: Task[A1, E1, R1], 459 | ) -> Effect[A1 | Parallel, E1, tuple[R1, ...]]: 460 | ... # pragma: no cover 461 | 462 | 463 | def parallel( # type: ignore 464 | *tasks: Task[object, Exception, object], 465 | ) -> Effect[Parallel, Exception, tuple[object, ...]]: 466 | """ 467 | Run tasks in parallel. 468 | 469 | If any of the tasks yield an exception, the exception is yielded. 470 | 471 | Args: 472 | ---- 473 | tasks: The tasks to run. 474 | 475 | Returns: 476 | ------- 477 | The results of the tasks. 478 | """ 479 | runtime: "Runtime[Parallel]" = cast("Runtime[Parallel]", (yield Parallel)) 480 | ability = runtime.get_ability(Parallel) 481 | result = ability.run(runtime, tasks) # type: ignore 482 | if isinstance(result, Exception): 483 | return (yield from throw(result)) 484 | else: 485 | return result 486 | -------------------------------------------------------------------------------- /src/stateless/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/suned/stateless/c632a294f2a89843def54d7218cc259a707adf3f/src/stateless/py.typed -------------------------------------------------------------------------------- /src/stateless/runtime.py: -------------------------------------------------------------------------------- 1 | """Runtime for executing effects.""" 2 | 3 | from collections.abc import Generator, Hashable 4 | from dataclasses import dataclass 5 | from functools import cache 6 | from typing import Generic, Literal, Tuple, Type, TypeVar, cast, overload 7 | 8 | from stateless.effect import Effect 9 | from stateless.errors import MissingAbilityError 10 | from stateless.parallel import Parallel 11 | 12 | A = TypeVar("A", bound=Hashable) 13 | A2 = TypeVar("A2", bound=Hashable) 14 | A3 = TypeVar("A3", bound=Hashable) 15 | R = TypeVar("R") 16 | E = TypeVar("E", bound=Exception) 17 | 18 | 19 | @cache 20 | def _get_ability(ability_type: Type[A], abilities: Tuple[A, ...]) -> A: 21 | for ability in abilities: 22 | if isinstance(ability, ability_type): 23 | return ability 24 | raise MissingAbilityError(ability_type) 25 | 26 | 27 | @dataclass(frozen=True, init=False) 28 | class Runtime(Generic[A]): 29 | """A runtime for executing effects.""" 30 | 31 | abilities: tuple[A, ...] 32 | 33 | def __init__(self, *abilities: A): 34 | object.__setattr__(self, "abilities", abilities) 35 | 36 | def use(self, ability: A2) -> "Runtime[A | A2]": 37 | """ 38 | Use an ability with this runtime. 39 | 40 | Enables running effects that require the ability. 41 | 42 | Args: 43 | ---- 44 | ability: The ability to use. 45 | 46 | Returns: 47 | ------- 48 | A new runtime with the ability. 49 | """ 50 | return Runtime(*(*self.abilities, ability)) # type: ignore 51 | 52 | def use_effect(self, effect: Effect[A, Exception, A2]) -> "Runtime[A | A2]": 53 | """ 54 | Use an ability produced by an effect with this runtime. 55 | 56 | Enables running effects that require the ability. 57 | 58 | All abilities required by `effect` must be provided by the runtime. 59 | 60 | Args: 61 | ---- 62 | effect: The effect producing an ability. 63 | 64 | Returns: 65 | ------- 66 | A new runtime with the ability. 67 | """ 68 | return self.use(effect) # type: ignore 69 | 70 | def get_ability(self, ability_type: Type[A]) -> A: 71 | """ 72 | Get an ability from the runtime. 73 | 74 | Args: 75 | ---- 76 | ability_type: The type of the ability to get. 77 | 78 | Returns: 79 | ------- 80 | The ability. 81 | """ 82 | 83 | return _get_ability(ability_type, self.abilities) # type: ignore 84 | 85 | @overload 86 | def run( 87 | self, 88 | effect: Effect[A, E, R], 89 | return_errors: Literal[False] = False, 90 | ) -> R: 91 | ... # pragma: no cover 92 | 93 | @overload 94 | def run( 95 | self, 96 | effect: Effect[A, E, R], 97 | return_errors: Literal[True] = True, 98 | ) -> R | E: 99 | ... # pragma: no cover 100 | 101 | def run(self, effect: Effect[A, E, R], return_errors: bool = False) -> R | E: 102 | """ 103 | Run an effect. 104 | 105 | Args: 106 | ---- 107 | effect: The effect to run. 108 | return_errors: Whether to return errors yielded by the effect. 109 | 110 | Returns: 111 | ------- 112 | The result of the effect. 113 | """ 114 | abilities: tuple[A, ...] = () 115 | for ability in self.abilities: 116 | if isinstance(ability, Generator): 117 | abilities = ( # pyright: ignore 118 | self._run(ability, abilities, return_errors=False), 119 | *abilities, 120 | ) 121 | else: 122 | abilities = (ability, *abilities) # pyright: ignore 123 | return self._run(effect, abilities, return_errors) 124 | 125 | def _run( 126 | self, effect: Effect[A, E, R], abilities: Tuple[A, ...], return_errors: bool 127 | ) -> R | E: 128 | try: 129 | ability_or_error = next(effect) 130 | 131 | while True: 132 | try: 133 | match ability_or_error: 134 | case None: 135 | ability_or_error = effect.send(None) 136 | case Exception() as error: 137 | try: 138 | ability_or_error = effect.throw(error) 139 | except type(error) as e: 140 | if return_errors and e is error: 141 | return cast(E, e) 142 | raise e 143 | case ability_type if ability_type is Parallel: 144 | ability_or_error = effect.send(self) 145 | case ability_type: 146 | ability = _get_ability(ability_type, abilities) 147 | ability_or_error = effect.send(ability) 148 | except MissingAbilityError as error: 149 | ability_or_error = effect.throw(error) 150 | except StopIteration as e: 151 | return cast(R, e.value) 152 | -------------------------------------------------------------------------------- /src/stateless/schedule.py: -------------------------------------------------------------------------------- 1 | """Contains the Schedule type and combinators.""" 2 | 3 | import itertools 4 | from dataclasses import dataclass 5 | from datetime import timedelta 6 | from typing import Iterator, Protocol, TypeVar 7 | from typing import NoReturn as Never 8 | 9 | from stateless.effect import Depend, Success, success 10 | 11 | A = TypeVar("A", covariant=True) 12 | 13 | 14 | class Schedule(Protocol[A]): 15 | """An iterator of timedeltas depending on stateless abilities.""" 16 | 17 | def __iter__(self) -> Depend[A, Iterator[timedelta]]: 18 | """Iterate over the schedule.""" 19 | ... # pragma: no cover 20 | 21 | 22 | @dataclass(frozen=True) 23 | class Spaced(Schedule[Never]): 24 | """A schedule that yields a timedelta at a fixed interval forever.""" 25 | 26 | interval: timedelta 27 | 28 | def __iter__(self) -> Success[Iterator[timedelta]]: 29 | """Iterate over the schedule.""" 30 | return success(itertools.repeat(self.interval)) 31 | 32 | 33 | @dataclass(frozen=True) 34 | class Recurs(Schedule[A]): 35 | """A schedule that yields timedeltas from the schedule given as arguments fixed number of times.""" 36 | 37 | n: int 38 | schedule: Schedule[A] 39 | 40 | def __iter__(self) -> Depend[A, Iterator[timedelta]]: 41 | """Iterate over the schedule.""" 42 | deltas = yield from self.schedule 43 | return itertools.islice(deltas, self.n) 44 | -------------------------------------------------------------------------------- /src/stateless/time.py: -------------------------------------------------------------------------------- 1 | """Contains the Time ability and ability helpers.""" 2 | 3 | import time 4 | from dataclasses import dataclass 5 | 6 | from stateless.effect import Depend 7 | 8 | 9 | @dataclass(frozen=True) 10 | class Time: 11 | """The Time ability.""" 12 | 13 | def sleep(self, seconds: float) -> None: 14 | """ 15 | Sleep for a number of seconds. 16 | 17 | Args: 18 | ---- 19 | seconds: The number of seconds to sleep for. 20 | """ 21 | time.sleep(seconds) 22 | 23 | 24 | def sleep(seconds: float) -> Depend[Time, None]: 25 | """ 26 | Sleep for a number of seconds. 27 | 28 | Args: 29 | ---- 30 | seconds: The number of seconds to sleep for. 31 | 32 | Returns: 33 | ------- 34 | An effect that sleeps for a number of seconds. 35 | """ 36 | time_ = yield Time 37 | time_.sleep(seconds) 38 | -------------------------------------------------------------------------------- /tests/test_console.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, patch 2 | 3 | from pytest import CaptureFixture 4 | from stateless import Runtime 5 | from stateless.console import Console, print_line, read_line 6 | 7 | 8 | def test_print_line(capsys: CaptureFixture[str]) -> None: 9 | console = Console() 10 | Runtime().use(console).run(print_line("hello")) 11 | captured = capsys.readouterr() 12 | assert captured.out == "hello\n" 13 | 14 | 15 | @patch("stateless.console.input", return_value="hello") 16 | def test_read_line(input_mock: MagicMock) -> None: 17 | console = Console() 18 | assert Runtime().use(console).run(read_line("hi!")) == "hello" 19 | input_mock.assert_called_once_with("hi!") 20 | -------------------------------------------------------------------------------- /tests/test_effect.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from typing import NoReturn as Never 3 | 4 | from pytest import raises 5 | from stateless import ( 6 | Effect, 7 | Runtime, 8 | Success, 9 | Try, 10 | catch, 11 | depend, 12 | memoize, 13 | repeat, 14 | retry, 15 | success, 16 | throw, 17 | throws, 18 | ) 19 | from stateless.functions import RetryError 20 | from stateless.schedule import Recurs, Spaced 21 | from stateless.time import Time 22 | 23 | 24 | class MockTime(Time): 25 | def sleep(self, seconds: float) -> None: 26 | pass 27 | 28 | 29 | def test_throw() -> None: 30 | effect = throw(RuntimeError("oops")) 31 | with raises(RuntimeError, match="oops"): 32 | Runtime().run(effect) 33 | 34 | 35 | def test_catch() -> None: 36 | effect: Success[RuntimeError] = catch(lambda: throw(RuntimeError("oops")))() 37 | 38 | error = Runtime().run(effect) 39 | 40 | assert isinstance(error, RuntimeError) 41 | assert str(error) == "oops" 42 | 43 | 44 | def test_catch_success() -> None: 45 | effect = catch(lambda: success(42))() 46 | value = Runtime().run(effect) 47 | 48 | assert value == 42 49 | 50 | 51 | def test_catch_unhandled() -> None: 52 | def effect() -> Success[None]: 53 | raise ValueError("oops") 54 | 55 | with raises(ValueError, match="oops"): 56 | Runtime().run(catch(effect)()) 57 | 58 | 59 | def test_throws() -> None: 60 | @throws(ValueError) 61 | def effect() -> Never: 62 | raise ValueError("oops") 63 | 64 | with raises(ValueError, match="oops"): 65 | Runtime().run(effect()) 66 | 67 | 68 | def test_depend() -> None: 69 | effect = depend(int) 70 | assert Runtime().use(0).run(effect) == 0 71 | 72 | 73 | def test_repeat() -> None: 74 | @repeat(Recurs(2, Spaced(timedelta(seconds=1)))) 75 | def effect() -> Success[int]: 76 | return success(42) 77 | 78 | time: Time = MockTime() 79 | assert Runtime().use(time).run(effect()) == (42, 42) 80 | 81 | 82 | def test_repeat_on_error() -> None: 83 | @repeat(Recurs(2, Spaced(timedelta(seconds=1)))) 84 | def effect() -> Try[RuntimeError, Never]: 85 | return throw(RuntimeError("oops")) 86 | 87 | time: Time = MockTime() 88 | with raises(RuntimeError, match="oops"): 89 | Runtime().use(time).run(effect()) 90 | 91 | 92 | def test_retry() -> None: 93 | @repeat(Recurs(2, Spaced(timedelta(seconds=1)))) 94 | def effect() -> Try[RuntimeError, Never]: 95 | return throw(RuntimeError("oops")) 96 | 97 | time: Time = MockTime() 98 | with raises(RuntimeError, match="oops"): 99 | Runtime().use(time).run(effect()) 100 | 101 | 102 | def test_retry_on_eventual_success() -> None: 103 | counter = 0 104 | 105 | @retry(Recurs(2, Spaced(timedelta(seconds=1)))) 106 | def effect() -> Effect[Never, RuntimeError, int]: 107 | nonlocal counter 108 | if counter == 1: 109 | return success(42) 110 | counter += 1 111 | return throw(RuntimeError("oops")) 112 | 113 | time: Time = MockTime() 114 | assert Runtime().use(time).run(effect()) == 42 115 | 116 | 117 | def test_retry_on_failure() -> None: 118 | @retry(Recurs(2, Spaced(timedelta(seconds=1)))) 119 | def effect() -> Effect[Never, RuntimeError, int]: 120 | return throw(RuntimeError("oops")) 121 | 122 | time: Time = MockTime() 123 | with raises(RetryError): 124 | Runtime().use(time).run(effect()) 125 | 126 | 127 | def test_memoize() -> None: 128 | counter = 0 129 | 130 | @memoize 131 | def f(_: int) -> Success[int]: 132 | nonlocal counter 133 | counter += 1 134 | return success(counter) 135 | 136 | def g() -> Success[tuple[int, int, int, int]]: 137 | i1 = yield from f(0) 138 | i2 = yield from f(1) 139 | e: Success[int] = f(0) 140 | i3 = yield from e 141 | i4 = yield from e 142 | return (i1, i2, i3, i4) 143 | 144 | assert Runtime().run(g()) == (1, 2, 1, 1) 145 | assert counter == 2 146 | 147 | 148 | def test_memoize_with_args() -> None: 149 | @memoize(maxsize=1, typed=False) 150 | def f() -> Success[int]: 151 | return success(42) 152 | 153 | assert f.cache_parameters() == {"maxsize": 1, "typed": False} # type: ignore 154 | 155 | 156 | def test_memoize_on_unhandled_error() -> None: 157 | @memoize 158 | def f() -> Try[RuntimeError, Never]: 159 | return throw(RuntimeError("oops")) 160 | 161 | with raises(RuntimeError, match="oops"): 162 | Runtime().run(f()) 163 | 164 | 165 | def test_memoize_on_handled_error() -> None: 166 | @memoize 167 | def f() -> Try[RuntimeError, str]: 168 | try: 169 | return (yield from throw(RuntimeError("oops"))) 170 | except RuntimeError: 171 | return "done" 172 | 173 | assert Runtime().run(f()) == "done" 174 | -------------------------------------------------------------------------------- /tests/test_file.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import mock_open, patch 2 | 3 | from stateless import Runtime 4 | from stateless.files import Files, read_file 5 | 6 | 7 | def test_read_file() -> None: 8 | with patch("builtins.open", mock_open(read_data="hello")) as open_mock: 9 | assert Runtime().use(Files()).run(read_file("hello.txt")) == "hello" 10 | open_mock.assert_called_once_with("hello.txt") 11 | -------------------------------------------------------------------------------- /tests/test_parallel.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | from multiprocessing import Manager 3 | from multiprocessing.pool import ThreadPool 4 | from typing import Iterator 5 | 6 | import cloudpickle # type: ignore 7 | from pytest import fixture, raises 8 | from stateless import Depend, Effect, Runtime, Success, catch, success, throws 9 | from stateless.parallel import Parallel, _run_task, parallel, process, thread 10 | 11 | 12 | @fixture(scope="module", name="runtime") 13 | def runtime_fixture() -> Iterator[Runtime[Parallel]]: 14 | with Parallel() as p: 15 | yield Runtime().use(p) 16 | 17 | 18 | def test_error_handling(runtime: Runtime[Parallel]) -> None: 19 | @throws(ValueError) 20 | def f() -> Success[str]: 21 | raise ValueError("error") 22 | 23 | def g() -> Effect[Parallel, ValueError, tuple[str]]: 24 | result = yield from parallel(thread(f)()) 25 | return result 26 | 27 | result = runtime.run(catch(g)()) 28 | assert isinstance(result, ValueError) 29 | assert result.args == ("error",) 30 | 31 | 32 | def test_process_error_handling(runtime: Runtime[Parallel]) -> None: 33 | @throws(ValueError) 34 | def f() -> Success[str]: 35 | raise ValueError("error") 36 | 37 | def g() -> Effect[Parallel, ValueError, tuple[str]]: 38 | result = yield from parallel(process(f)()) 39 | return result 40 | 41 | result = runtime.run(catch(g)()) 42 | assert isinstance(result, ValueError) 43 | assert result.args == ("error",) 44 | 45 | 46 | def test_unhandled_errors(runtime: Runtime[Parallel]) -> None: 47 | def f() -> Success[str]: 48 | raise ValueError("error") 49 | 50 | with raises(ValueError, match="error"): 51 | effect = parallel(thread(f)()) 52 | runtime.run(effect) 53 | 54 | 55 | def test_pickling() -> None: 56 | with Parallel() as p: 57 | assert p._thread_pool is None 58 | assert p._manager is None 59 | assert p._pool is None 60 | 61 | p2 = pickle.loads(pickle.dumps(p)) 62 | 63 | assert p._pool is not None 64 | assert p._manager is not None 65 | 66 | assert p2._thread_pool is None 67 | assert p2._manager is None 68 | assert p2._pool is not None 69 | 70 | assert p2._pool._id == p._pool._id 71 | 72 | p.thread_pool # initialize thread pool 73 | p3 = pickle.loads(pickle.dumps(p)) 74 | assert p3._thread_pool is not None 75 | 76 | 77 | def test_cpu_effect(runtime: Runtime[Parallel]) -> None: 78 | @process 79 | def f() -> Success[str]: 80 | return success("done") 81 | 82 | effect = parallel(f()) 83 | result = runtime.run(effect) 84 | assert result == ("done",) 85 | 86 | 87 | def test_io_effect(runtime: Runtime[Parallel]) -> None: 88 | @thread 89 | def f() -> Success[str]: 90 | return success("done") 91 | 92 | effect = parallel(f()) 93 | result = runtime.run(effect) 94 | assert result == ("done",) 95 | 96 | 97 | def ping() -> str: 98 | return "pong" 99 | 100 | 101 | def test_yield_from_parallel(runtime: Runtime[Parallel]) -> None: 102 | def f() -> Success[str]: 103 | return success("done") 104 | 105 | def g() -> Depend[Parallel, tuple[str, str]]: 106 | result = yield from parallel(thread(f)(), process(f)()) 107 | return result 108 | 109 | result = runtime.run(g()) 110 | assert result == ("done", "done") 111 | 112 | 113 | def test_passed_in_resources() -> None: 114 | with Manager() as manager, manager.Pool() as pool, ThreadPool() as thread_pool: # type: ignore 115 | with Parallel(thread_pool, pool) as p: 116 | assert p._manager is None 117 | 118 | # check that Parallel did not close the thread pool or pool 119 | assert thread_pool.apply(ping) == "pong" 120 | assert pool.apply(ping) == "pong" 121 | 122 | 123 | def test_use_before_with() -> None: 124 | task = thread(success)("done") 125 | with raises(RuntimeError, match="Parallel must be used as a context manager"): 126 | Runtime().use(Parallel()).run(parallel(task)) # type: ignore 127 | 128 | 129 | def test_use_after_with() -> None: 130 | with Parallel() as p: 131 | pass 132 | 133 | with raises(RuntimeError, match="Parallel context manager has already exited"): 134 | Runtime().use(p).run(parallel(thread(success)("done"))) # type: ignore 135 | 136 | 137 | def test_run_task(runtime: Runtime[Parallel]) -> None: 138 | def f() -> Success[str]: 139 | return success("done") 140 | 141 | payload = cloudpickle.dumps((runtime, thread(f)())) 142 | result = _run_task(payload) 143 | assert cloudpickle.loads(result) == "done" 144 | -------------------------------------------------------------------------------- /tests/test_runtime.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from pytest import raises 4 | from stateless import Depend, Effect, Runtime, Try, depend 5 | from stateless.errors import MissingAbilityError 6 | from typing_extensions import Never 7 | 8 | 9 | @dataclass(frozen=True) 10 | class Super: 11 | pass 12 | 13 | 14 | @dataclass(frozen=True) 15 | class Sub(Super): 16 | pass 17 | 18 | 19 | @dataclass(frozen=True) 20 | class SubSub(Sub): 21 | pass 22 | 23 | 24 | def test_run_with_unhandled_exception() -> None: 25 | def fails() -> Depend[str, None]: 26 | yield str 27 | raise RuntimeError("oops") 28 | 29 | e = fails() 30 | with raises(RuntimeError, match="oops"): 31 | Runtime().use("").run(e) 32 | 33 | 34 | def test_provide_multiple_sub_types() -> None: 35 | sub: Super = Sub() 36 | subsub: Super = SubSub() 37 | assert Runtime().use(subsub).use(sub).run(depend(Super)) == Sub() 38 | assert Runtime().use(sub).use(subsub).run(depend(Super)) == SubSub() 39 | 40 | 41 | def test_missing_dependency() -> None: 42 | def effect() -> Depend[Super, Super]: 43 | ability: Super = yield Super 44 | return ability 45 | 46 | with raises(MissingAbilityError, match="Super") as info: 47 | Runtime().run(effect()) # type: ignore 48 | 49 | # test that the fourth frame is the yield 50 | # expression in `effect` function above 51 | # (first is Runtime().run(..) 52 | # second is effect.throw in Runtime.run) 53 | frame = info.traceback[3] 54 | assert str(frame.path) == __file__ 55 | assert frame.lineno == effect.__code__.co_firstlineno 56 | 57 | 58 | def test_simple_dependency() -> None: 59 | def effect() -> Depend[str, str]: 60 | ability: str = yield str 61 | return ability 62 | 63 | assert Runtime().use("hi!").run(effect()) == "hi!" 64 | 65 | 66 | def test_simple_failure() -> None: 67 | def effect() -> Effect[Never, ValueError, None]: 68 | yield ValueError("oops") 69 | return 70 | 71 | with raises(ValueError, match="oops"): 72 | Runtime().run(effect()) 73 | 74 | 75 | def test_return_errors() -> None: 76 | def fails() -> Try[ValueError, None]: 77 | yield ValueError("oops") 78 | return 79 | 80 | result = Runtime().run(fails(), return_errors=True) 81 | assert isinstance(result, ValueError) 82 | assert result.args == ("oops",) 83 | 84 | 85 | def test_return_errors_on_duplicate_error_type() -> None: 86 | def fails() -> Try[ValueError, None]: 87 | yield ValueError("oops") 88 | return 89 | 90 | def catches() -> Try[ValueError, None]: 91 | try: 92 | yield from fails() 93 | except ValueError: 94 | pass 95 | raise ValueError("oops again") 96 | 97 | with raises(ValueError, match="oops again"): 98 | Runtime().run(catches(), return_errors=True) 99 | 100 | 101 | def test_use_effect() -> None: 102 | def effect() -> Depend[str, bytes]: 103 | ability: str = yield str 104 | return ability.encode() 105 | 106 | assert Runtime("ability").use_effect(effect()).run(depend(bytes)) == b"ability" 107 | -------------------------------------------------------------------------------- /tests/test_schedule.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | from datetime import timedelta 3 | from typing import Iterator 4 | 5 | from stateless import Runtime, Success 6 | from stateless.schedule import Recurs, Spaced 7 | 8 | 9 | def test_spaced() -> None: 10 | def effect() -> Success[Iterator[timedelta]]: 11 | schedule = yield from Spaced(timedelta(seconds=1)) 12 | return itertools.islice(schedule, 3) 13 | 14 | deltas = Runtime().run(effect()) 15 | assert list(deltas) == [timedelta(seconds=1)] * 3 16 | 17 | 18 | def test_recurs() -> None: 19 | schedule = Recurs(3, Spaced(timedelta(seconds=1))) 20 | deltas = Runtime().run(iter(schedule)) 21 | assert list(deltas) == [timedelta(seconds=1)] * 3 22 | -------------------------------------------------------------------------------- /tests/test_time.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, patch 2 | 3 | from stateless import Runtime 4 | from stateless.time import Time, sleep 5 | 6 | 7 | @patch("stateless.time.time.sleep") 8 | def test_sleep(sleep_mock: MagicMock) -> None: 9 | Runtime().use(Time()).run(sleep(1)) 10 | sleep_mock.assert_called_once_with(1) 11 | --------------------------------------------------------------------------------