├── .gitignore ├── .readthedocs.yaml ├── LICENSE ├── README.rst ├── aioresult ├── __init__.py ├── _aio.py ├── _src.py ├── _version.py ├── _wait.py └── py.typed ├── docs ├── Makefile ├── make.bat └── source │ ├── _static │ └── custom.css │ ├── conf.py │ ├── future.rst │ ├── history.rst │ ├── index.rst │ ├── overview.rst │ ├── result_capture.rst │ └── wait.rst ├── pyproject.toml └── tests ├── test_all.py └── type_tests.py /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # PyCharm files 3 | .idea/ 4 | 5 | # Byte-compiled files 6 | __pycache__/ 7 | 8 | # Sphinx docs 9 | docs/build/ 10 | 11 | # Distribution / packaging 12 | /dist/ 13 | *.egg-info/ 14 | 15 | # Tests / coverage 16 | .pytest_cache 17 | .coverage 18 | /htmlcov/ 19 | 20 | # Notes to self 21 | _TODO.txt -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: "ubuntu-22.04" 5 | tools: 6 | python: "3.10" 7 | 8 | python: 9 | # Tell RTD to install the package with pip - needed to make it install sniffio dependency 10 | install: 11 | - method: pip 12 | path: . 13 | extra_requirements: 14 | - docs 15 | 16 | sphinx: 17 | configuration: docs/source/conf.py -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Boost Software License - Version 1.0 - August 17th, 2003 2 | 3 | Permission is hereby granted, free of charge, to any person or organization 4 | obtaining a copy of the software and accompanying documentation covered by 5 | this license (the "Software") to use, reproduce, display, distribute, 6 | execute, and transmit the Software, and to prepare derivative works of the 7 | Software, and to permit third-parties to whom the Software is furnished to 8 | do so, all subject to the following: 9 | 10 | The copyright notices in the Software and this entire statement, including 11 | the above license grant, this restriction and the following disclaimer, 12 | must be included in all copies of the Software, in whole or in part, and 13 | all derivative works of the Software, unless such copies or derivative 14 | works are solely in the form of machine-executable object code generated by 15 | a source language processor. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT 20 | SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE 21 | FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, 22 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Welcome to **aioresult**! 2 | 3 | This is a very small library to capture the result of an asynchronous operation, either an async 4 | function (with the ``ResultCapture`` class) or more generally (with the ``Future`` class). It works 5 | with `Trio nurseries 6 | `__ and `anyio 7 | task groups `__. It does not work with vanilla 8 | asyncio `task groups `__, but I 9 | wouldn't recommend using those (see `Trio vs asyncio `__). 10 | 11 | * Code is hosted on github: https://github.com/arthur-tacca/aioresult 12 | 13 | * Documentation is on ReadTheDocs: 14 | 15 | * Overview (this page): https://aioresult.readthedocs.io/en/v1.2/overview.html 16 | * Capturing a result: https://aioresult.readthedocs.io/en/v1.2/result_capture.html 17 | * Future objects: https://aioresult.readthedocs.io/en/v1.2/future.html 18 | * Utility functions for waiting: https://aioresult.readthedocs.io/en/v1.2/wait.html 19 | 20 | * The package is on PyPI: https://pypi.org/project/aioresult/ 21 | 22 | 23 | Quick Overview 24 | -------------- 25 | 26 | The ``ResultCapture`` class runs an async function in a nursery and stores its return value (or 27 | raised exception) for later:: 28 | 29 | async with trio.open_nursery() as n: 30 | result1 = ResultCapture.start_soon(n, foo, 1) 31 | result2 = ResultCapture.start_soon(n, foo, 2) 32 | # At this point the tasks have completed, and results are stashed in ResultCapture objects 33 | print("results", result1.result(), result2.result()) 34 | 35 | When stored in a list, the effect is very similar to the `asyncio gather() function 36 | `__:: 37 | 38 | async with trio.open_nursery() as n: 39 | results = [ResultCapture.start_soon(n, foo, i) for i in range(10)] 40 | print("results:", *[r.result() for r in results]) 41 | 42 | 43 | .. note:: A key design decision about the ``ResultCapture`` class is that **exceptions are allowed 44 | to propagate out of the task into their enclosing nursery**. This is unlike some similar 45 | libraries, which consume the exception in its original context and rethrow it later. In practice, 46 | aioresult's behaviour is simpler and less error prone. 47 | 48 | There is also a simple ``Future`` class that shares a lot of its code with ``ResultCapture``. The 49 | result is retrieved the same way, but it is set explicitly rather than captured from a task. It is 50 | most often used when an API wants to return a value that will be demultiplexed from a shared 51 | connection:: 52 | 53 | # When making a request, create a future, store it for later and return to caller 54 | f = aioresult.Future() 55 | 56 | # The result is set, usually inside a networking API 57 | f.set_result(result) 58 | 59 | # The calling code can wait for the result then retrieve it 60 | await f.wait_done() 61 | print("result:", f.result()) 62 | 63 | The interface in ``Future`` and ``ResultCapture`` to wait for a result and retrieve it is shared in 64 | a base class ``ResultBase``. 65 | 66 | There are also a few simple utility functions to help waiting for results: ``wait_any()`` and 67 | ``wait_all()`` to wait for one or all of a collection of tasks to complete, and 68 | ``results_to_channel()`` to allow using the results as they become available. 69 | 70 | 71 | Installation and Usage 72 | ---------------------- 73 | 74 | Install into a suitable virtual environment with ``pip``:: 75 | 76 | pip install aioresult 77 | 78 | aioresult can be used with Trio nurseries:: 79 | 80 | import trio 81 | from aioresult import ResultCapture 82 | 83 | async def wait_and_return(i): 84 | await trio.sleep(i) 85 | return i 86 | 87 | async def use_aioresult(): 88 | async with trio.open_nursery() as n: 89 | results = [ResultCapture.start_soon(n, wait_and_return, i) for i in range(5)] 90 | print("results:", *[r.result() for r in results]) 91 | 92 | if __name__ == "__main__": 93 | trio.run(use_aioresult) 94 | 95 | It can also be used with anyio task groups:: 96 | 97 | import asyncio 98 | import anyio 99 | from aioresult import ResultCapture 100 | 101 | async def wait_and_return(i): 102 | await anyio.sleep(i) 103 | return i 104 | 105 | async def use_aioresult(): 106 | async with anyio.create_task_group() as tg: 107 | results = [ResultCapture.start_soon(tg, wait_and_return, i) for i in range(5)] 108 | print("results:", *[r.result() for r in results]) 109 | 110 | if __name__ == "__main__": 111 | asyncio.run(use_aioresult()) 112 | 113 | 114 | Contributing 115 | ------------ 116 | 117 | This library is deliberately small and limited in scope, so it is essentially "done" (but you never 118 | know). 119 | 120 | To test any changes, install the test requirements (see the ``pyproject.toml`` file) and run 121 | ``pytest`` in the root of the repository:: 122 | 123 | python -m pytest 124 | 125 | To also get coverage information, run it with the ``coverage`` command:: 126 | 127 | coverage run -m pytest 128 | 129 | You can then use ``coverage html`` to get a nice HTML output of exactly what code has been tested 130 | and what has been missed. 131 | 132 | To run the type tests, run ``pyright`` or ``mypy`` in the project root directory. You may need to 133 | install the ``excetiongroup`` compatibility package, even on newer versions of Python. 134 | 135 | 136 | License 137 | ------- 138 | 139 | Copyright Arthur Tacca 2022 - 2025 140 | 141 | Distributed under the Boost Software License, Version 1.0. 142 | See accompanying file LICENSE or the copy at https://www.boost.org/LICENSE_1_0.txt 143 | 144 | This is similar to other liberal licenses like MIT and BSD: you can use this library without the 145 | need to share your program's source code, so long as you provide attribution of aioresult. 146 | 147 | The Boost license has the additional provision that you do not even need to provide attribution if 148 | you are distributing your software in binary form only, e.g. if you have compiled to an executable 149 | with `Nuitka `__. (Bundlers like `pyinstaller `__, 150 | `py2exe `__ and `pex `__ don't count for this 151 | because they still include the source code internally.) 152 | -------------------------------------------------------------------------------- /aioresult/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Arthur Tacca 2022 - 2025 2 | # Distributed under the Boost Software License, Version 1.0. 3 | # See accompanying file LICENSE or the copy at https://www.boost.org/LICENSE_1_0.txt 4 | 5 | from aioresult._version import __version__ 6 | 7 | from aioresult._src import ( 8 | ResultBase, ResultCapture, Future, 9 | TaskFailedException, TaskNotDoneException, FutureSetAgainException 10 | ) 11 | from aioresult._wait import wait_all, wait_any, results_to_channel 12 | 13 | __all__ = [ 14 | "ResultBase", "ResultCapture", "Future", "TaskFailedException", "TaskNotDoneException", 15 | "FutureSetAgainException", 16 | "wait_all", "wait_any", "results_to_channel", 17 | ] 18 | -------------------------------------------------------------------------------- /aioresult/_aio.py: -------------------------------------------------------------------------------- 1 | # Copyright Arthur Tacca 2022 - 2025 2 | # Distributed under the Boost Software License, Version 1.0. 3 | # See accompanying file LICENSE or the copy at https://www.boost.org/LICENSE_1_0.txt 4 | 5 | """Contains conditional imports for Trio and anyio and routines for events and nurseries. 6 | 7 | The reason for defining these, rather than just using anyio (which already supports both Trio and 8 | wrappers for asyncio) is to allow use of aioresult with Trio even when anyio is not installed. 9 | """ 10 | from collections.abc import Awaitable, Callable 11 | from contextlib import AbstractAsyncContextManager 12 | from typing import Any, Protocol, TypeVar, cast 13 | from typing_extensions import TypeVarTuple, Unpack 14 | 15 | import asyncio 16 | import sniffio 17 | 18 | 19 | RetT = TypeVar("RetT") 20 | T_contra = TypeVar("T_contra", contravariant=True) 21 | ArgsT = TypeVarTuple("ArgsT") 22 | 23 | 24 | class CancelScopeLike(Protocol): 25 | """A Trio or anyio CancelGroup. Required only for Nursery's attribute.""" 26 | def cancel(self) -> None: 27 | ... 28 | 29 | 30 | class NurseryLike(Protocol): 31 | """A Trio Nursery or anyio TaskGroup.""" 32 | @property 33 | def cancel_scope(self) -> CancelScopeLike: 34 | # We only need read-only access. 35 | ... 36 | 37 | def start_soon( 38 | self, 39 | func: Callable[[Unpack[ArgsT]], Awaitable[object]], /, 40 | *args: Unpack[ArgsT], 41 | ) -> None: 42 | ... 43 | 44 | # This can't be typed yet. 45 | async def start(self, func: Callable[..., Awaitable[RetT]], /, *args: object) -> RetT: 46 | ... 47 | 48 | 49 | class EventLike(Protocol): 50 | """A Trio or asyncio Event.""" 51 | def is_set(self) -> bool: 52 | ... 53 | 54 | async def wait(self) -> object: 55 | ... 56 | 57 | def set(self) -> object: 58 | ... 59 | 60 | 61 | class SendChannelLike(Protocol[T_contra]): 62 | """A trio MemorySendChannel or anyio MemoryObjectSendStream.""" 63 | async def send(self, value: T_contra, /) -> None: 64 | ... 65 | 66 | def close(self) -> None: 67 | ... 68 | 69 | 70 | try: 71 | import trio 72 | except ImportError: 73 | # It's fine if trio/anyio is not installed, it should never be accessed. 74 | # Suppress errors about this being undefined, or None checks 75 | trio = cast(Any, None) 76 | try: 77 | import anyio 78 | except ImportError: 79 | anyio = cast(Any, None) 80 | 81 | 82 | def create_event() -> EventLike: 83 | """Creates a Trio Event or asyncio Event; they are similar enough for aioresult.""" 84 | sniffed = sniffio.current_async_library() 85 | if sniffed == "trio": 86 | return trio.Event() 87 | elif sniffed == "asyncio": 88 | return asyncio.Event() 89 | else: 90 | raise RuntimeError(f"Unknown async library {sniffed}") 91 | 92 | 93 | def open_nursery() -> AbstractAsyncContextManager[NurseryLike]: 94 | """Opens a Trio Nursery or anyio TaskGroup.""" 95 | sniffed = sniffio.current_async_library() 96 | if sniffed == "trio": 97 | return trio.open_nursery() 98 | elif sniffed == "asyncio": 99 | return anyio.create_task_group() 100 | else: 101 | raise RuntimeError(f"Unknown async library {sniffed}") 102 | 103 | 104 | __all__ = ["EventLike", "NurseryLike", "SendChannelLike", "create_event", "open_nursery"] 105 | -------------------------------------------------------------------------------- /aioresult/_src.py: -------------------------------------------------------------------------------- 1 | # Copyright Arthur Tacca 2022 - 2025 2 | # Distributed under the Boost Software License, Version 1.0. 3 | # See accompanying file LICENSE or the copy at https://www.boost.org/LICENSE_1_0.txt 4 | 5 | 6 | from collections.abc import Awaitable, Callable 7 | from typing import Any, Generic, Optional, TypeVar, cast 8 | 9 | from typing_extensions import TypeVarTuple, Unpack 10 | 11 | from aioresult._aio import * 12 | 13 | 14 | ResultT = TypeVar("ResultT") 15 | ResultT_co = TypeVar("ResultT_co", covariant=True) 16 | ArgsT = TypeVarTuple("ArgsT") 17 | _UNSET: Any = cast(Any, object()) # Sentinel, we manually check it's not present. 18 | 19 | 20 | class FutureSetAgainException(Exception): 21 | """Raised if :meth:`Future.set_result()` or :meth:`Future.set_exception()` called more than 22 | once. 23 | 24 | It can also be raised from a :class:`ResultCapture` if :meth:`ResultCapture.run()` is called 25 | more than once. 26 | 27 | It is raised with the future passed as the argument to the exception:: 28 | 29 | raise FutureSetAgainException(self) 30 | 31 | This allows access to the future from the exception using the ``args`` attribute: 32 | 33 | .. attribute:: args 34 | :type: tuple[ResultBase] 35 | 36 | 1-tuple of the :class:`ResultCapture` or :class:`Future` that raised this exception. 37 | """ 38 | pass 39 | 40 | 41 | class TaskNotDoneException(Exception): 42 | """Exception raised when attempting to access the result (using :meth:`ResultBase.result()` 43 | or :meth:`ResultBase.exception()`) that is not complete yet. 44 | 45 | It is raised with the capture instance passed as the argument to the exception:: 46 | 47 | raise TaskNotDoneException(self) 48 | 49 | This allows access to the :class:`ResultCapture` or :class:`Future` from the exception using the 50 | ``args`` attribute: 51 | 52 | .. attribute:: args 53 | :type: tuple[ResultBase] 54 | 55 | 1-tuple of the :class:`ResultCapture` or :class:`Future` that raised this exception. 56 | 57 | """ 58 | args: tuple['ResultBase[object]'] 59 | 60 | 61 | class TaskFailedException(Exception): 62 | """Exception raised when accessing :meth:`ResultBase.result()` for a task that raised an 63 | exception. This exception is raised as a `chained 64 | exception `__:: 65 | 66 | raise TaskFailedException(self) from original_exception 67 | 68 | This allows access to the original exception and the relevant :class:`ResultCapture` or 69 | :class:`Future` as attributes: 70 | 71 | .. attribute:: __cause__ 72 | :type: BaseException 73 | 74 | The original exception that was raised by the task. 75 | 76 | 77 | .. attribute:: args 78 | :type: tuple[ResultBase] 79 | 80 | 1-tuple of the :class:`ResultCapture` or :class:`Future` that raised this exception. 81 | 82 | """ 83 | pass 84 | 85 | 86 | # This is covariant even though result is technically mutable. 87 | class ResultBase(Generic[ResultT_co]): 88 | """Base class for :class:`ResultCapture` and :class:`Future`. 89 | 90 | The two main classes in aioresult, :class:`ResultCapture` and :class:`Future`, have almost 91 | identical interfaces: they both allow waiting for and retrieving a value. That interface is 92 | contained in this base class. 93 | 94 | :typeparam ResultT_co: Type of the value returned from :meth:`result()`. See :ref:`type_hints`. 95 | 96 | .. note:: 97 | If you are returning a :class:`ResultCapture` or :class:`Future` from a function then you 98 | may wish to document the return type as just :class:`ResultBase` because that has all the 99 | relevant interface for retrieving a result. 100 | """ 101 | def __init__(self) -> None: 102 | self._done_event: Optional[EventLike] = None 103 | self._result: ResultT_co = _UNSET 104 | self._exception: Optional[BaseException] = None 105 | 106 | def result(self) -> ResultT_co: 107 | """Returns the captured result of the task. 108 | 109 | :return ResultT_co: The value returned by the task. 110 | :raise TaskNotDoneException: If the task is not done yet; use :meth:`is_done` or 111 | :meth:`wait_done` to check or wait for completion to avoid this exception. 112 | :raise TaskFailedException: If the task failed with an exception; see `Exception handling`_. 113 | """ 114 | if self._exception is not None: 115 | raise TaskFailedException(self) from self._exception 116 | if self._result is _UNSET: 117 | raise TaskNotDoneException(self) 118 | return self._result 119 | 120 | def exception(self) -> Optional[BaseException]: 121 | """Returns the exception raised by the task. 122 | 123 | It is usually better design to use the :meth:`result` method and catch the exception it 124 | raises. However, :meth:`exception` can be useful in some situations e.g. filtering a list 125 | of :class:`ResultCapture` objects. 126 | 127 | 128 | :return BaseException: The exception raised by the task, if it completed by raising an 129 | exception. Note that it is the original exception returned, not a 130 | :class:`TaskFailedException` as raised by :meth:`result`. 131 | :return None: If the task completed by returning a value. 132 | :raise TaskNotDoneException: If the task is not done yet. 133 | """ 134 | if self._result is _UNSET and self._exception is None: 135 | raise TaskNotDoneException(self) 136 | return self._exception 137 | 138 | def is_done(self) -> bool: 139 | """Returns whether the task is done, i.e., the result (or an exception) is captured. 140 | 141 | :return bool: ``True`` if the task is done; ``False`` otherwise. 142 | """ 143 | return not (self._result is _UNSET and self._exception is None) 144 | 145 | async def wait_done(self) -> None: 146 | """Waits until the task is done. 147 | 148 | This is mainly useful for :class:`Future` instances. 149 | 150 | For :class:`ResultCapture` instances, there are specialised situations where it may be 151 | useful to use this method to wait until the task is done, typically where you are writing 152 | library code and you want to start a routine in a user supplied nursery but wait for it in 153 | some other context. Typically, though, it is better design to wait for the task's nursery to 154 | complete. Consider a nursery-based approach before using this method. 155 | 156 | :return None: To access the result use :meth:`result()`. 157 | 158 | .. note:: 159 | If the underlying routine raises an exception (which includes the case that it is 160 | cancelled) then this routine will *not* raise an exception; it will simply return 161 | to indicate that the task is done. Similarly, cancelling a call to this routine will 162 | *not* cancel the task running in this object. This is in contrast to awaiting an 163 | :class:`asyncio.Future` instance, where cancellations propagate directly. See 164 | `Exception handling`_ for more information. 165 | """ 166 | if self._done_event is None: 167 | self._done_event = create_event() 168 | if not (self._result is _UNSET and self._exception is None): 169 | # Task is done. We create an Event rather than just return so this is a checkpoint. 170 | self._done_event.set() 171 | await self._done_event.wait() 172 | 173 | def _set_result(self, result: object) -> None: 174 | """Protected implementation of Future.set_result(), also used in ResultCapture.run(). 175 | 176 | This is type-unsafe, since we're modifying it but ResultT is covariant. The caller needs to 177 | ensure these match. 178 | """ 179 | if not (self._result is _UNSET and self._exception is None): 180 | raise FutureSetAgainException(self) 181 | self._result = cast(ResultT_co, result) 182 | if self._done_event is not None: 183 | self._done_event.set() 184 | 185 | def _set_exception(self, exception: BaseException) -> None: 186 | """Protected implementation of Future.set_exception(), also used in ResultCapture.run().""" 187 | if not (self._result is _UNSET and self._exception is None): 188 | raise FutureSetAgainException(self) 189 | self._exception = exception 190 | if self._done_event is not None: 191 | self._done_event.set() 192 | 193 | def _routine_str(self) -> str: 194 | return "" # Overridden in ResultCapture 195 | 196 | def __format__(self, format_spec: str) -> str: 197 | if format_spec not in ("", "#"): 198 | raise ValueError("ResultBase format must be '' or '#'") 199 | routine_str = self._routine_str() if format_spec == "#" else "" 200 | if not self.is_done(): 201 | result_str = "is_done=False" 202 | elif self._exception is not None: 203 | result_str = "exception=" + repr(self._exception) 204 | else: 205 | result_str = "result=" + str(self._result) 206 | return f"{type(self).__name__}({routine_str}{result_str})" 207 | 208 | def __repr__(self) -> str: 209 | return self.__format__("") 210 | 211 | 212 | # Invariant, because set_result is public. 213 | class Future(ResultBase[ResultT]): 214 | """Stores a result or exception that is explicitly set by the caller. 215 | 216 | :typeparam ResultT: Type of the value stored; see :ref:`type_hints`. 217 | 218 | .. note:: :class:`Future` inherits most of its methods from its base class :class:`ResultBase`; 219 | see the documentation for that class for the inherited methods. 220 | """ 221 | 222 | def set_result(self, result: ResultT) -> None: 223 | """Sets the result of the future to the given value. 224 | 225 | After calling this method, later calls to :meth:`ResultBase.result()` will return the value 226 | passed in. 227 | 228 | :param result: The result value to be stored. 229 | :type result: ResultT 230 | :raise FutureSetAgainException: If the result has already been set with :meth:`set_result()` 231 | or :meth:`set_exception()`. 232 | """ 233 | self._set_result(result) 234 | 235 | def set_exception(self, exception: BaseException) -> None: 236 | """Sets the exception of the future to the given value. 237 | 238 | After calling this method, later calls to :meth:`ResultBase.result()` will throw an 239 | exception and calls to :meth:`ResultBase.exception()` will return this exception. The 240 | exception raised by :meth:`ResultBase.result()` will be a :class:`TaskFailedException` 241 | rather than the exception passed to this method, which matches the behaviour of 242 | :class:`ResultCapture`; see :ref:`Exception handling `. 243 | 244 | :param exception: The exception to be stored. 245 | :type exception: BaseException 246 | :raise FutureSetAgainException: If the result has already been set with :meth:`set_result()` 247 | or :meth:`set_exception()`. 248 | """ 249 | self._set_exception(exception) 250 | 251 | 252 | class ResultCapture(ResultBase[ResultT_co]): 253 | """Captures the result of a task for later access. 254 | 255 | Most usually, an instance is created with the :meth:`start_soon()` class method. However, it is 256 | possible to instantiate directly, in which case you will need to arrange for the :meth:`run()` 257 | method to be called. 258 | 259 | :typeparam ResultT_co: Type of the value returned from tbe task. See :ref:`type_hints`. 260 | :param routine: An async callable. 261 | :type routine: typing.Callable[..., typing.Awaitable[ResultT_co]] 262 | :param args: Positional arguments for ``routine``. 263 | :param suppress_exception: If ``True``, exceptions derived from :class:`Exception` (not those 264 | directly derived from :class:`BaseException` will be caught internally rather than allowed 265 | to escape into the enclosing nursery. If ``False`` (the default), all exceptions will be 266 | allowed to escape into the enclosing context. 267 | :type suppress_exception: bool 268 | 269 | .. note:: :class:`ResultCapture` inherits most of its methods from its base class 270 | :class:`ResultBase`; see the documentation for that class for the inherited methods. 271 | """ 272 | 273 | @classmethod 274 | def start_soon( 275 | cls, 276 | nursery: NurseryLike, 277 | routine: Callable[[Unpack[ArgsT]], Awaitable[ResultT]], 278 | *args: Unpack[ArgsT], 279 | suppress_exception: bool = False, 280 | ) -> 'ResultCapture[ResultT]': 281 | """Runs the task in the given nursery and captures its result. 282 | 283 | Under the hood, this simply constructs an instance with ``ResultCapture(routine, *args)``, 284 | starts the routine by calling ``nursery.start_soon(rc.run)``, then returns the new object. 285 | It's literally three lines long! But it's the preferred way to create an instance. 286 | 287 | :typeparam ResultT: The return type of ``routine``, which will be captured in the returned 288 | ``ResultCapture``. 289 | :typeparam \\*ArgsT: The type of the arguments to ``routine``. 290 | :param nursery: A nursery to run the routine. 291 | :type nursery: trio.Nursery | anyio.abc.TaskGroup 292 | :param routine: An async callable. 293 | :type routine: typing.Callable[[\\*ArgsT], typing.Awaitable[ResultT]] 294 | :param args: Positional arguments for ``routine``. If you want to pass keyword arguments, 295 | use :func:`functools.partial`. 296 | :type args: \\*ArgsT 297 | :param suppress_exception: If ``True``, exceptions derived from :class:`Exception` (not 298 | those directly derived from :class:`BaseException` will be caught internally rather than 299 | allowed to escape into the enclosing nursery. If ``False`` (the default), all exceptions 300 | will be allowed to escape into the enclosing context. 301 | :type suppress_exception: bool 302 | :return ResultCapture[ResultT]: A new instance representing the result of the given routine. 303 | """ 304 | rc = cls(routine, *args, suppress_exception=suppress_exception) # type: ignore 305 | nursery.start_soon(rc.run) 306 | return rc # type: ignore 307 | 308 | def __init__( 309 | self, 310 | routine: Callable[..., Awaitable[ResultT_co]], 311 | *args: object, 312 | suppress_exception: bool = False, 313 | ) -> None: 314 | super().__init__() 315 | self._routine = routine 316 | self._args = args 317 | self._suppress_exception = suppress_exception 318 | 319 | async def run(self, **kwargs: Any) -> None: 320 | """Runs the routine and captures its result. 321 | 322 | This is where the magic of :class:`ResultCapture` happens ... except it's not very magical 323 | (see `How it works`_ for details). 324 | 325 | Typically, you would use the :meth:`start_soon()` class method, which constructs the 326 | :class:`ResultCapture` and arranges for this :meth:`run()` method to be run in the given 327 | nursery. But it is reasonable to manually construct the object and call the :meth:`run()` 328 | method in situations where extra control is needed. 329 | 330 | :param kwargs: Keyword arguments to pass to the routine. This exists mainly to support usage 331 | with the task start protocol (see `Waiting for a task to finish starting`_). If you want 332 | to pass arguments to the routine then pass them as positional arguments to 333 | :meth:`start_soon()` or use :func:`functools.partial`. 334 | :return None: To access the return value of the routine, use :meth:`ResultBase.result()`. 335 | :raise BaseException: Whatever exception is raised by the routine. 336 | 337 | .. warning:: 338 | Ensure this routine is not called more than once. In particular, do not call it at all 339 | if the instance was created with :meth:`start_soon()`, which already calls this once. 340 | """ 341 | try: 342 | result = await self._routine(*self._args, **kwargs) 343 | self._set_result(result) 344 | except Exception as e: 345 | self._set_exception(e) 346 | if not self._suppress_exception: 347 | raise # Allowed the exception to propagate into user nursery 348 | except BaseException as e: 349 | self._set_exception(e) 350 | raise # Allowed the exception to propagate into user nursery 351 | 352 | @property 353 | def routine(self) -> Callable[..., Awaitable[ResultT_co]]: 354 | """The routine whose result will be captured. This is the ``routine`` argument that was 355 | passed to the constructor or :meth:`start_soon()`.""" 356 | return self._routine 357 | 358 | @property 359 | def args(self) -> tuple[Any, ...]: 360 | """The arguments passed to the routine whose result will be captured. This is the ``args`` 361 | argument that was passed to the constructor or :meth:`start_soon()`.""" 362 | return self._args 363 | 364 | @classmethod 365 | def capture_start_and_done_results( 366 | cls, 367 | run_nursery: NurseryLike, 368 | routine: Callable[..., Awaitable[ResultT]], 369 | *args: Any, 370 | start_nursery: Optional[NurseryLike] = None, 371 | ) -> tuple['ResultCapture[Any]', 'ResultCapture[ResultT]']: 372 | """Captures both the startup and completion result of a task. 373 | 374 | The first return value represents whether the task has finished starting yet (i.e., whether it 375 | has called ``task_status.started()``), and its :meth:`ResultBase.result()` value is the value 376 | passed as the argument to ``task_status.started()`` (or ``None`` if no argument is passed). The 377 | second return value represents whether the routine has completed entirely (and its return value 378 | or exception). 379 | 380 | :param run_nursery: The nursery to run the routine once it has finished starting. 381 | :type nursery: trio.Nursery | anyio.abc.TaskGroup 382 | :param routine: An async callable. 383 | :type routine: typing.Callable[..., typing.Awaitable[ResultT]] 384 | :param args: Positional arguments for ``routine``. If you want to pass keyword arguments, 385 | use :func:`functools.partial`. 386 | :param start_nursery: The nursery to run the routine until it has finished starting. If this 387 | is omitted then ``run_nursery`` is used. 388 | :type start_nursery: trio.Nursery | anyio.abc.TaskGroup | None 389 | :return tuple[ResultCapture[Any], ResultCapture[ResultT]]: ``(start_result, done_result)`` 390 | representing the value returned from the routine's startup and its completion. 391 | 392 | .. note:: The semantics are a little fiddly if the routine raises an exception before it 393 | completes startup (i.e., before it calls ``task_status.started()``): 394 | 395 | * The task is running (only) in the ``start_nursery`` until this point (see 396 | :meth:`trio.Nursery.start()`), so the exception is propagated out in only 397 | ``start_nursery``, rather than ``run_nursery``. 398 | * The exception is recorded in ``start_result`` (because exceptions before startup is 399 | complete are recorded in the context of the startup nursery). 400 | * The potentially surprising part is that the same exception is also recorded in 401 | ``done_result`` (because ``done_result.run()`` directly wraps the call to the 402 | routine). 403 | 404 | Another fiddly case is if the routine returns before it calls ``task_status.started()``: 405 | 406 | * This is considered an error so a ``RuntimeError`` exception is raised (see 407 | :meth:`trio.Nursery.start()`), and again this is propagated out in only 408 | ``start_nursery``, rather than ``run_nursery``. 409 | * This ``RuntimeError`` exception is recorded ``start_result``. 410 | * However, ``done_result`` records whatever result was returned by the routine, and 411 | ``done_result.exception()`` is ``None`` (again, this is because ``done_result.run()`` 412 | directly wraps the call to the routine). 413 | 414 | Once the routine completes startup (i.e., after it has called ``task_status.started()``), 415 | the semantics are simple: any return value or exception is associated with ``done_result``, 416 | and the routine is now running in the context of ``run_nursery`` so any exception is 417 | propagated out into the ``run_nursery``. 418 | 419 | A consequence of all the above cases is that ``start_result.is_done()`` and 420 | ``done_result.is_done()`` **are eventually both true regardless of when and how the routine 421 | finished**. 422 | """ 423 | if start_nursery is None: 424 | start_nursery = run_nursery 425 | done_result = cls(routine, *args) # type: ignore 426 | start_result = cls(run_nursery.start, done_result.run) # pyright: ignore 427 | start_nursery.start_soon(start_result.run) 428 | return start_result, done_result # type: ignore 429 | 430 | def _routine_str(self) -> str: 431 | """Allows the alternative format to include routine and arguments; used in __format__.""" 432 | return f"routine={self.routine.__qualname__}, args={self.args}, " 433 | -------------------------------------------------------------------------------- /aioresult/_version.py: -------------------------------------------------------------------------------- 1 | 2 | __version__ = "1.2" 3 | -------------------------------------------------------------------------------- /aioresult/_wait.py: -------------------------------------------------------------------------------- 1 | # Copyright Arthur Tacca 2022 - 2025 2 | # Distributed under the Boost Software License, Version 1.0. 3 | # See accompanying file LICENSE or the copy at https://www.boost.org/LICENSE_1_0.txt 4 | 5 | from typing import Any, Iterable, Optional, TypeVar 6 | from aioresult._aio import * 7 | from aioresult._src import ResultBase 8 | 9 | 10 | ResultBaseT = TypeVar("ResultBaseT", bound=ResultBase[Any]) 11 | 12 | 13 | async def wait_all(results: Iterable[ResultBase[object]]) -> None: 14 | """Waits until all tasks are done. 15 | 16 | The implementation is extremely simple: it just iterates over the parameter and calls 17 | :meth:`ResultBase.wait_done()` on each in turn. (It does not matter if they finish in a 18 | different order than the iteration order because :meth:`ResultBase.wait_done()` returns 19 | immediately if the task is already done.) 20 | 21 | :param results: The results to wait for. 22 | :type results: typing.Iterable[ResultBase[object]] 23 | """ 24 | for r in results: 25 | await r.wait_done() 26 | 27 | 28 | async def wait_any(results: Iterable[ResultBaseT]) -> ResultBaseT: 29 | """Waits until one of the tasks is complete, and returns that object. 30 | 31 | Note that it is possible that, when this function returns, more than one of the tasks has 32 | actually completed (i.e., :meth:`ResultBase.is_done()` could return ``True`` for more than one 33 | of them). 34 | 35 | :typeparam ResultBaseT: A subtype of :class:`ResultBase`. The effect of this type parameter is 36 | just that the result type of this function is whatever is common to the types in the 37 | parameter. 38 | :param results: The result objects to wait for. 39 | :type results: typing.Iterable[ResultBaseT] 40 | :return ResultBaseT: One of the objects in ``results``. 41 | :raise RuntimeError: If ``results`` is empty. 42 | """ 43 | first_result: Optional[ResultBaseT] = None 44 | 45 | async def wait_one(result: ResultBaseT) -> None: 46 | nonlocal first_result 47 | await result.wait_done() 48 | if first_result is None: 49 | first_result = result 50 | nursery.cancel_scope.cancel() 51 | 52 | async with open_nursery() as nursery: 53 | for r in results: 54 | nursery.start_soon(wait_one, r) 55 | 56 | if first_result is None: 57 | raise RuntimeError("No elements were passed to wait_any") 58 | 59 | return first_result 60 | 61 | 62 | async def results_to_channel( 63 | results: Iterable[ResultBaseT], 64 | channel: SendChannelLike[ResultBaseT], 65 | close_on_complete: bool = True, 66 | ) -> None: 67 | """Waits for :class:`ResultBase` tasks to complete, and sends them to an async channel. 68 | 69 | The results are waited for in parallel, so they are sent to the channel in the order they 70 | complete rather than the order they are in the ``results`` iterable. As usual when waiting for 71 | async tasks, the ordering is not guaranteed for tasks that finish at very similar times. 72 | 73 | This function does not return until all tasks in ``results`` have completed, so it would 74 | normally be used by being passed to :meth:`trio.Nursery.start_soon()` rather than being directly 75 | awaited in the caller. 76 | 77 | :typeparam ResultBaseT: A subtype of :class:`ResultBase`. The effect of this type parameter is 78 | just that, if you have used a type parameter for the send channel, then it should be a base 79 | of (or the same as) the types in the ``results`` parameter. 80 | :param results: The :class:`ResultBase` objects to send to the channel. 81 | :type results: typing.Iterable[ResultBaseT] 82 | :param channel: The send end of the channel to send to. 83 | :type channel: trio.MemorySendChannel[ResultBaseT] | 84 | anyio.streams.memory.MemoryObjectSendStream[ResultBaseT] 85 | :param close_on_complete: If ``True`` (the default), the channel will be closed when the 86 | function completes. This means that iterating over the receive end of the channel with 87 | ``async for`` will complete once all results have been returned. 88 | :type close_on_complete: bool 89 | 90 | .. warning:: 91 | If ``close_on_complete`` is True and this routine is cancelled then the channel is still 92 | closed. (The close is done in a ``finally:`` block.) This means that, in this situation, 93 | an ``async for`` loop over the receive end will complete without all results being returned. 94 | This is done to avoid a recipient waiting forever. 95 | 96 | In practice, it will very often be the case that the same nursery is used for both this 97 | routine and the routine that iterates over the results. In that case, the ``async for`` 98 | would be interrupted anyway. 99 | """ 100 | 101 | async def wait_one(result: ResultBaseT) -> None: 102 | await result.wait_done() 103 | await channel.send(result) 104 | 105 | try: 106 | async with open_nursery() as nursery: 107 | for r in results: 108 | nursery.start_soon(wait_one, r) 109 | finally: 110 | if close_on_complete: 111 | channel.close() 112 | -------------------------------------------------------------------------------- /aioresult/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arthur-tacca/aioresult/2fb969bc217caef907c69856bdec6385ac794b7a/aioresult/py.typed -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = aioresult 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | set SPHINXPROJ=aioresult 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/source/_static/custom.css: -------------------------------------------------------------------------------- 1 | @media screen and (max-width:1024px) { 2 | html.writer-html5 .rst-content dl.field-list { display: block } 3 | } 4 | html.writer-html5 .rst-content dl.field-list > dt { padding-left: 0px } 5 | html.writer-html5 .rst-content { overflow-wrap: break-word } 6 | html.writer-html5 .rst-content dt.sig { display: block !important } -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Documentation build configuration file, created by 5 | # sphinx-quickstart on Sat Jan 21 19:11:14 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | import os 21 | import sys 22 | # So autodoc can import our package 23 | sys.path.insert(0, os.path.abspath('../..')) 24 | 25 | # Warn about all references to unknown targets 26 | nitpicky = True 27 | # Except for these ones, which we expect to point to unknown targets: 28 | nitpick_ignore = [ 29 | ("py:obj", "aioresult._src.ResultT"), 30 | ("py:obj", "aioresult._src.ResultT_co"), 31 | ("py:class", "ResultT"), 32 | ("py:class", "ResultT_co"), 33 | ("py:class", "ResultBaseT"), 34 | ("py:class", "*ArgsT"), 35 | ] 36 | autodoc_inherit_docstrings = False 37 | autodoc_typehints = "none" 38 | default_role = "obj" 39 | 40 | # -- General configuration ------------------------------------------------ 41 | 42 | # If your documentation needs a minimal Sphinx version, state it here. 43 | # 44 | # needs_sphinx = '1.0' 45 | 46 | # Add any Sphinx extension module names here, as strings. They can be 47 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 48 | # ones. 49 | extensions = [ 50 | 'sphinx.ext.autodoc', 51 | 'sphinx.ext.intersphinx', 52 | 'sphinxcontrib_trio', 53 | ] 54 | 55 | intersphinx_mapping = { 56 | "python": ('https://docs.python.org/3', None), 57 | "trio": ('https://trio.readthedocs.io/en/stable', None), 58 | "anyio": ('https://anyio.readthedocs.io/en/stable', None) 59 | } 60 | 61 | autodoc_member_order = "bysource" 62 | 63 | # Add any paths that contain templates here, relative to this directory. 64 | templates_path = [] 65 | 66 | # The master toctree document. 67 | master_doc = 'index' 68 | 69 | # General information about the project. 70 | project = 'aioresult' 71 | copyright = 'Arthur Tacca' 72 | author = 'Arthur Tacca' 73 | 74 | # The version info for the project you're documenting, acts as replacement for 75 | # |version| and |release|, also used in various other places throughout the 76 | # built documents. 77 | # 78 | # The short X.Y version. 79 | import aioresult 80 | version = aioresult.__version__ 81 | # The full version, including alpha/beta/rc tags. 82 | release = version 83 | 84 | # The language for content autogenerated by Sphinx. Refer to documentation 85 | # for a list of supported languages. 86 | # 87 | # This is also used if you do content translation via gettext catalogs. 88 | # Usually you set "language" from the command line for these cases. 89 | language = "en" 90 | 91 | # List of patterns, relative to source directory, that match files and 92 | # directories to ignore when looking for source files. 93 | # This patterns also effect to html_static_path and html_extra_path 94 | exclude_patterns = [] 95 | 96 | # The name of the Pygments (syntax highlighting) style to use. 97 | pygments_style = 'sphinx' 98 | 99 | # The default language for :: blocks 100 | highlight_language = 'python3' 101 | 102 | 103 | # -- Options for HTML output ---------------------------------------------- 104 | 105 | # The theme to use for HTML and HTML Help pages. 106 | html_theme = 'sphinx_rtd_theme' 107 | 108 | # Theme options are theme-specific and customize the look and feel of a theme 109 | # further. For a list of options available for each theme, see the 110 | # documentation. 111 | # 112 | html_theme_options = { 113 | "navigation_depth": 3 114 | } 115 | 116 | # Add any paths that contain custom static files (such as style sheets) here, 117 | # relative to this directory. They are copied after the builtin static files, 118 | # so a file named "default.css" will overwrite the builtin "default.css". 119 | html_static_path = ['_static'] 120 | 121 | # Custom style sheet to lay out function parameters better on mobile. See: 122 | # https://stackoverflow.com/questions/79114648/ 123 | html_css_files = [ 124 | 'custom.css', 125 | ] 126 | 127 | 128 | # Print the type parameters for generic functions and classes. This is a workaround for lack of 129 | # support in autodoc that requires listing every specific class and function. 130 | # https://github.com/sphinx-doc/sphinx/issues/10568#issuecomment-2413039360 131 | type_parameters = { 132 | "aioresult.Future": "ResultT", 133 | "aioresult.ResultBase": "ResultT_co", 134 | "aioresult.ResultCapture": "ResultT_co", 135 | # Probably too confusing to include type parameters in function signatures 136 | # "aioresult.ResultCapture.start_soon": "ResultT, *ArgsT", 137 | # "aioresult.ResultCapture.capture_start_and_done_results": "ResultT", 138 | # "aioresult.wait_any": "ResultBaseT: ResultBase", 139 | # "aioresult.results_to_channel": "ResultBaseT: ResultBase", 140 | } 141 | 142 | def process_signature(app, what, name, obj, options, signature, return_annotation): 143 | if name in type_parameters: 144 | signature = "[" + type_parameters[name] + "]" + (signature or "") 145 | return signature, return_annotation 146 | 147 | def setup(app): 148 | app.connect("autodoc-process-signature", process_signature) 149 | 150 | 151 | # Allow using `:return TypeName: Description` with type name on same line of output 152 | # https://github.com/orgs/sphinx-doc/discussions/13125#discussioncomment-11219198 153 | from sphinx.domains.python import PyObject, PyGroupedField 154 | for i, f in enumerate(PyObject.doc_field_types): 155 | if f.name == "returnvalue": 156 | PyObject.doc_field_types[i] = PyGroupedField( 157 | f.name, label=f.label, names=f.names, rolename="class", can_collapse=True 158 | ) 159 | 160 | 161 | # Also allow documenting type parameters. 162 | PyObject.doc_field_types.append(PyGroupedField( 163 | "typeparam", label="Type Parameters", names=("typeparam",), rolename="class", can_collapse=True 164 | )) 165 | -------------------------------------------------------------------------------- /docs/source/future.rst: -------------------------------------------------------------------------------- 1 | Using futures 2 | ============= 3 | 4 | .. currentmodule:: aioresult 5 | 6 | Motivation and usage 7 | -------------------- 8 | 9 | The :class:`Future` class allows storing the result of an operation, either a return value or a 10 | raised exception. It differs from :class:`ResultCapture` in that you manually specify the result by 11 | calling either :meth:`Future.set_result()` or :meth:`Future.set_exception()` rather than the result 12 | automatically being captured from some async function. 13 | 14 | This is often useful when you are implementing an API in a library where requests can be sent to 15 | some remote server, but multiple requests can be outstanding at a time so the result is set in some 16 | separate async routine:: 17 | 18 | # Public function in the API: Send the request over some connection 19 | def start_request(request_payload) -> aioresult.ResultBase: 20 | request_id = connection.send_request(request_payload) 21 | result = aioresult.Future() 22 | outstanding_requests[request_id] = result 23 | return result 24 | 25 | # Hidden function in the API: In a separate task, wait for responses to any request 26 | async def get_responses(): 27 | while True: 28 | request_id, response = await connection.get_next_response() 29 | outstanding_requests[request_id].set_result(response) 30 | del outstanding_requests[request_id] 31 | 32 | # Caller code: Use the API and returned Future object 33 | async def make_request(): 34 | f = start_request(my_request) 35 | await f.wait_done() 36 | print("result:", f.result()) 37 | 38 | 39 | If you need to wait for several futures to finish, in a similar way to :func:`asyncio.gather()`, 40 | then you can use :func:`wait_all()`:: 41 | 42 | results = [start_request(i) for i in range(10)] 43 | await aioresult.wait_all(results) 44 | print("results:", *[f.result() for f in results]) 45 | 46 | Reference 47 | --------- 48 | 49 | .. autoclass:: Future 50 | :show-inheritance: 51 | :members: 52 | 53 | .. autoexception:: FutureSetAgainException 54 | :show-inheritance: 55 | :members: 56 | 57 | -------------------------------------------------------------------------------- /docs/source/history.rst: -------------------------------------------------------------------------------- 1 | Release history 2 | =============== 3 | 4 | .. currentmodule:: aioresult 5 | 6 | aioresult 1.2 (2025-02-28) 7 | -------------------------- 8 | 9 | - Add ``py.typed`` marker so that type checkers know that type annotations should be used for type 10 | checking. 11 | 12 | aioresult 1.1 (2025-02-26) 13 | -------------------------- 14 | 15 | - Add type hints (to the maximum extent reasonably possible), courtesy of TeamSpen210. 16 | 17 | - Lazily construct the internal ``Event``, so that ``ResultCapture`` objects can be constructed 18 | before the event loop runs (e.g., at global module scope). 19 | 20 | - Add nice string representation. 21 | 22 | aioresult 1.0 (2024-02-08) 23 | -------------------------- 24 | 25 | - **Breaking API change**: :attr:`ResultCapture.routine` and :attr:`ResultCapture.args` are now 26 | properties rather than methods (since all they do is directly return an underlying attribute). 27 | 28 | - **Breaking API change**: Remove ``StartableResultCapture`` class; replace with two new elements of 29 | functionality: 30 | 31 | - Add ``**kwargs`` to :meth:`ResultCapture.run()`, passed through to the underlying async 32 | routine. This allows it to be used directly with :meth:`trio.Nursery.start()` and 33 | :meth:`anyio.abc.TaskGroup.start()`. 34 | 35 | - Add :meth:`ResultCapture.capture_start_and_done_results()`, which allows capturing both the 36 | start result and the overall task result as separate :class:`ResultCapture` objects. 37 | 38 | - Add some utility functions for :doc:`waiting for a result `: :func:`wait_any()`, 39 | :func:`wait_all()` and :func:`results_to_channel()`. 40 | 41 | - Allow exceptions to be optionally suppressed from propagating out of :class:`ResultCapture` (but 42 | only those of type :class:`Exception`, not those directly derived from :class:`BaseException`). 43 | 44 | - Reorganise docs slightly (the tutorial for the main :class:`ResultCapture` functionality is 45 | separated from the reference documentation, and the :class:`Future` documentation is moved to its 46 | own separate page, and there is an extra page for the new wait functions). 47 | 48 | aioresult 0.9 (2023-01-02) 49 | -------------------------- 50 | 51 | - Initial release, with main class :class:`ResultCapture`, derived class 52 | ``StartableResultCapture``, along with :class:`Future`, and their base class 53 | :class:`ResultBase`. 54 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | 2 | .. module:: aioresult 3 | 4 | aioresult: Capture the result of a Trio or anyio task 5 | ===================================================== 6 | 7 | Welcome to **aioresult**, a very small library to capture the result of an asynchronous 8 | operation. See the :doc:`overview` for a brief introduction. 9 | 10 | Documentation Contents 11 | ---------------------- 12 | 13 | .. toctree:: 14 | :maxdepth: 2 15 | 16 | overview.rst 17 | result_capture.rst 18 | future.rst 19 | wait.rst 20 | history.rst 21 | 22 | Indices 23 | ------- 24 | 25 | * :ref:`genindex` 26 | * :ref:`search` 27 | -------------------------------------------------------------------------------- /docs/source/overview.rst: -------------------------------------------------------------------------------- 1 | 2 | .. currentmodule:: aioresult 3 | 4 | Overview 5 | ======== 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | 10 | .. include:: ../../README.rst 11 | -------------------------------------------------------------------------------- /docs/source/result_capture.rst: -------------------------------------------------------------------------------- 1 | Capturing a result 2 | ================== 3 | 4 | .. currentmodule:: aioresult 5 | 6 | 7 | Motivation and usage 8 | -------------------- 9 | 10 | The main class of aioresult is the :class:`ResultCapture` class. If you are directly awaiting a task 11 | then there is no need to use this class – you can just use the return value:: 12 | 13 | result1 = await foo(1) 14 | result2 = await foo(2) 15 | print("results:", result1, result2) 16 | 17 | If you want to run your tasks in parallel then you would typically use a nursery, but then it's 18 | harder to get hold of the results:: 19 | 20 | async with trio.open_nursery() as n: 21 | n.start_soon(foo, 1) 22 | n.start_soon(foo, 2) 23 | # At this point the tasks have completed, but the results are lost 24 | print("results: ??") 25 | 26 | To get access to the results, the usual advice is to either modify the routines so that they 27 | store their result somewhere rather than returning it or to create a little wrapper function 28 | that stores the return value of the function you actually care about. :class:`ResultCapture` is a 29 | simple helper to do this:: 30 | 31 | async with trio.open_nursery() as n: 32 | result1 = ResultCapture.start_soon(n, foo, 1) 33 | result2 = ResultCapture.start_soon(n, foo, 2) 34 | # At this point the tasks have completed, and results are stashed in ResultCapture objects 35 | print("results", result1.result(), result2.result()) 36 | 37 | You can get very similar effect to :func:`asyncio.gather()` by using a nursery and an array 38 | of :class:`ResultCapture` objects:: 39 | 40 | async with trio.open_nursery() as n: 41 | results = [ResultCapture.start_soon(n, foo, i) for i in range(10)] 42 | print("results:", *[r.result() for r in results]) 43 | 44 | Unlike asyncio's gather, you benefit from the safer behaviour of Trio nurseries if one of the tasks 45 | throws an exception. :class:`ResultCapture` is also more flexible because you don't have to use a 46 | list, for example you could use a dictionary:: 47 | 48 | async with trio.open_nursery() as n: 49 | results = {i: ResultCapture.start_soon(n, foo, i) for i in range(10)} 50 | print("results:", *[f"{i} -> {r.result()}," for i, r in results.items()]) 51 | 52 | It is also possible to check whether the task is done, wait for it to be done, and check whether it 53 | finished with an exception:: 54 | 55 | async with trio.open_nursery() as n: 56 | result1 = ResultCapture.start_soon(n, foo, 1) 57 | result2 = ResultCapture.start_soon(n, foo, 2) 58 | await result1.wait_done() 59 | # At this point the first task is done but the second could still be running 60 | assert result1.is_done() 61 | print("results:", *[f"{i} -> {r.result()}," for i, r in results.items()]) 62 | 63 | 64 | How it works 65 | ------------ 66 | 67 | The implementation of :class:`ResultCapture` is very simple. Rather than running your routine 68 | directly in a nursery, you instead run :meth:`ResultCapture.run()` in the nursery, which runs the 69 | routine and saves the result (or exception) in a member variable:: 70 | 71 | # ResultCapture.run() method 72 | def run(self, **kwargs): 73 | try: 74 | self._result = await self._fn(*self._args, **kwargs) 75 | except BaseException as e: 76 | self._exception = e 77 | raise 78 | finally: 79 | self._done_event.set() 80 | 81 | The actual implementation looks slightly different, but that's just so it can share some code with 82 | the :class:`Future` class, and so that it can avoid calling ``raise`` if 83 | ``suppress_exception=True``. It still fundamentally works like this. 84 | 85 | The typical way to run this is to start it in a nursery with the :meth:`ResultCapture.start_soon()` 86 | class method. This is even more simple! It just constructs an instance and then runs it in the 87 | nursery:: 88 | 89 | # ResultCapture.start_soon() class method 90 | @classmethod 91 | def start_soon(cls: type, nursery: Nursery, routine, *args): 92 | task = cls(routine, *args) # cls is ResultCapture, so this constructs an instance 93 | nursery.start_soon(task.run) 94 | return task 95 | 96 | 97 | .. _exception: 98 | 99 | Exception handling 100 | ------------------ 101 | 102 | Behaviour 103 | ^^^^^^^^^ 104 | 105 | A key design decision about the :class:`ResultCapture` class is that **exceptions are allowed to 106 | escape out of the task** so they propagate into the task's nursery. 107 | 108 | To illustrate this behaviour, here is an async function that raises an exception:: 109 | 110 | async def raises_after(n): 111 | print(f"throws_after({n}) starting") 112 | await trio.sleep(n) 113 | print(f"throws_after({n}) raising") 114 | raise RuntimeError(n) 115 | 116 | Consider its use in the following snippet:: 117 | 118 | try: 119 | async with trio.open_nursery() as n: 120 | result1 = ResultCapture.start_soon(n, raises_after, 1) 121 | result2 = ResultCapture.start_soon(n, raises_after, 2) 122 | print("Completed without exception") 123 | except Exception as e: 124 | print("Exception caught") 125 | print(f"result1 exception: {repr(result1.exception())}") 126 | print(f"result2 exception: {repr(result2.exception())}") 127 | 128 | This results in the following output: 129 | 130 | .. code-block:: none 131 | 132 | throws_after(1) starting 133 | throws_after(2) starting 134 | throws_after(1) raising 135 | Exception caught 136 | result1 exception: RuntimeError(1) 137 | result2 exception: Cancelled() 138 | 139 | This happens because the first task raises an exception after 1 second, which :class:`ResultCapture` 140 | allows to propagate out into the nursery ``n`` and causes the other task to be cancelled. Once the 141 | cancellation is complete, the nursery re-raises the exception, so ``"Exception caught"`` is printed. 142 | The next two print statements show that ``result1`` finished with the exception it raised, while the 143 | second was cancelled before it had the chance to raise its own exception. 144 | 145 | If a task raises an exception and you attempt to retrieve its return value by calling 146 | :meth:`ResultBase.result()`, then it will raise an instance of :class:`TaskFailedException` rather 147 | than the original exception. For example, if the following is run after the above snippet then it 148 | will print ``"TaskFailedException from RuntimeError(1)"`` rather than ``"RuntimeError"``:: 149 | 150 | try: 151 | print(result1.result()) 152 | except RuntimeError as e: 153 | print("RuntimeError") 154 | except TaskFailedException as e: 155 | print(f"TaskFailedException from {repr(e.__cause__)}") 156 | 157 | 158 | Overriding this behaviour 159 | ^^^^^^^^^^^^^^^^^^^^^^^^^ 160 | 161 | It is possible to override the behaviour of :class:`ResultCapture` so that it suppresses exceptions 162 | from propagating out into the nursery. For example, consider the following snippet, which is like 163 | the one above but with ``suppress_exception`` set to ``True``:: 164 | 165 | try: 166 | async with trio.open_nursery() as n: 167 | result1 = ResultCapture.start_soon(n, raises_after, 1, suppress_exception=True) 168 | result2 = ResultCapture.start_soon(n, raises_after, 2, suppress_exception=True) 169 | print("Completed without exception") 170 | except Exception as e: 171 | print("Exception caught") 172 | print(f"result1 exception: {repr(result1.exception())}") 173 | print(f"result2 exception: {repr(result2.exception())}") 174 | 175 | The output will instead look like this: 176 | 177 | .. code-block:: none 178 | 179 | throws_after(1) starting 180 | throws_after(2) starting 181 | throws_after(1) raising 182 | throws_after(2) raising 183 | Completed without exception 184 | result1 exception: RuntimeError(1) 185 | result2 exception: RuntimeError(2) 186 | 187 | This option is most useful when using the :doc:`utility functions to wait for a result `. Note 188 | that it only suppresses exceptions of type :class:`Exception`, not those that directly derive from 189 | :class:`BaseException` (e.g., :class:`KeyboardInterrupt` and cancellation exceptions), since it 190 | usually would not make sense to suppress those. It also does not change the fact that any exception 191 | raised by :meth:`ResultBase.result()` is wrapped in a :class:`TaskFailedException`. 192 | 193 | 194 | Motivation 195 | ^^^^^^^^^^ 196 | 197 | Some related libraries, such as 198 | `Outcome `__ and 199 | `trio-future `__, consume any exception thrown by the 200 | task and reraise it when the result is retrieved. This gives the calling code more control: it can 201 | choose at what point to retrieve the result, and therefore at what point the exception is thrown. 202 | However, it has some disadvantages: 203 | 204 | * The calling code must ensure the exception is always retrieved, otherwise the exception is 205 | silently lost. That would be particularly problematic if it is an injected exception such as 206 | :class:`trio.Cancelled` or :class:`KeyboardInterrupt`. This can be difficult to arrange reliably, 207 | especially if multiple tasks like this raise exceptions. The whole point of `structured 208 | concurrency 209 | `__ 210 | was meant to be that you don't have to worry about this problem! 211 | * For many exceptions, it does not make sense to be raised more than once, so the calling code must 212 | be careful to retrieve the result only once. For example, Outcome `raises an error 213 | `__ if it is 214 | unwrapped more than once. 215 | * The calling code must be careful about whether the exception still makes sense in the context in 216 | which the result is retrieved. For example, if the exception is a :class:`trio.Cancelled` then 217 | its corresponding nursery must still be live. 218 | 219 | The simpler semantics of aioresult avoids the above complexities: the exception always makes sense 220 | in its context (because it's in the original place it was raised) and you are free to retrieve the 221 | result once, many times, or not at all. 222 | 223 | 224 | .. _type_hints: 225 | 226 | Type hints 227 | ---------- 228 | 229 | The classes and functions in aioresult include type hints. These allow IDEs and static type 230 | checkers to detect potential errors before your program is run; see the `introduction in the mypy 231 | docs `_ for more information on Python 232 | type hints in general. For example, a type checker would find the two errors in the following 233 | code:: 234 | 235 | async def double_int(x: int) -> int: 236 | await trio.sleep(1) 237 | return x * 2 238 | 239 | async with trio.open_nursery() as n: 240 | # Error! Parameter should be int not str 241 | rc = ResultCapture.start_soon(n, double_int, "seven") 242 | # Error! Assigning int into str variable 243 | x: str = rc.result() 244 | 245 | The :class:`ResultCapture`, :class:`Future` and :class:`ResultBase` classes have a type parameter, 246 | which represents the type of value being captured. This allows you to use these classes in type 247 | hints for variables and function parameters in your own code. For example:: 248 | 249 | async def do_something(rc: ResultCapture[str]): ... 250 | # Error! Assigning ResultCapture[int] into ResultCapture[str] 251 | await do_something(ResultCapture.start_soon(n, double_int, 7)) 252 | 253 | You do not need to specify the type parameter when using :func:`ResultCapture.start_soon()`. For 254 | that function, the type paremeter of :class:`ResultCapture` is inferred from the return type of the 255 | function passed to it. 256 | 257 | The type parameter of :class:`ResultCapture` and :class:`ResultBase` is `covariant 258 | `_; this means that you can 259 | assign to a variable where the parameter is a looser type. For example, if ``Animal`` is a base 260 | class with derived classes ``Cat`` and ``Dog``, then you would see this behaviour:: 261 | 262 | rc_dog = ResultCapture.start_soon(n, get_dog) # Inferred to be ResultCapture[Dog] 263 | rc_animal: ResultCapture[Animal] = rc_dog # This is OK 264 | rc_cat: ResultCapture[Cat] = rc_animal # This causes an error 265 | 266 | The :class:`Future` class does not behave this way because, if it did, you would be able to use the 267 | :func:`Future.set_result()` method on the looser type to put the wrong type of value in (e.g., if 268 | you have a ``Future[Dog]`` and were able to use it as a ``Future[Animal]`` then you could use that 269 | to put a ``Cat`` in it). You can always put it into a :class:`ResultBase` variable if you need this 270 | sort of behaviour (e.g., you can assign a ``Future[Dog]`` into a ``ResultBase[Animal]`` variable). 271 | 272 | Not all types can be perfectly type checked in Python. In aioresult, there are two main limitations 273 | of the type checking: 274 | 275 | * The function given to :class:`ResultCapture` is only checked for compatibility with the arguments 276 | that you give if you use :func:`ResultCapture.start_soon()`. If you manually construct 277 | :class:`ResultCapture` and call :func:`ResultCapture.run()`, or if you use 278 | :func:`ResultCapture.capture_start_and_done_results()`, the parameter types are not checked:: 279 | 280 | rc = ResultCapture[int](double_int, "seven") # Oops, no error 281 | 282 | * The :attr:`ResultCapture.args` property does not remember the type of the arguments passed in. 283 | Its type hint is simply a tuple of :class:`typing.Any`:: 284 | 285 | rc = ResultCapture.start_soon(n, double_int, 7) 286 | a: tuple[str, float] = rc.args # Oops, no error 287 | 288 | 289 | .. _starting: 290 | 291 | Waiting for a task to finish starting 292 | ------------------------------------- 293 | 294 | Trio and anyio support waiting until a task has finished starting with :meth:`trio.Nursery.start()` 295 | and :meth:`anyio.abc.TaskGroup.start()`. For example, a routine that supports this could look like 296 | this:: 297 | 298 | async def my_fn(i, task_status=trio.TASK_STATUS_IGNORED): 299 | await trio.sleep(i) 300 | task_status.started(i * 2) 301 | await trio.sleep(i) 302 | return i * 3 303 | 304 | It could be used as follows:: 305 | 306 | async with trio.open_nursery() as n: 307 | start_result = await n.start(my_fn, 1) 308 | # At this point task is running in background 309 | # Another 1 second passes before we get here 310 | 311 | For example, :func:`trio.serve_tcp()` signals that it has finished starting when the port is open 312 | for listening, and it returns which port number it is listening on (which is useful because the 313 | port can be assigned automatically). 314 | 315 | The peculiar-looking default value of ``trio.TASK_STATUS_IGNORED`` is there so that the function can 316 | be called without using it as part of this special dance. In particular, this means you can use 317 | these functions with :class:`ResultCapture` as usual:: 318 | 319 | async with trio.open_nursery() as n: 320 | rc = ResultCapture.start_soon(n, my_fn, 1) 321 | print("Done result:", rc.result()) 322 | 323 | If you need to wait until the task has just finished starting or retrieve its start value then the 324 | trick is to construct the :class:`ResultCapture` instance and use its :meth:`ResultCapture.run()` 325 | method explicitly, rather than using the :meth:`ResultCapture.start_soon()` wrapper function as 326 | usual. This allows you to pass :meth:`ResultCapture.run()` to :meth:`trio.Nursery.start()` rather 327 | than :meth:`trio.Nursery.start_soon()`:: 328 | 329 | async with trio.open_nursery() as n: 330 | rc = ResultCapture(my_fn, 1) 331 | start_value = await n.start(rc.run) 332 | print("Start value:", start_value) 333 | print("Done result:", rc.result()) 334 | 335 | .. note:: 336 | For most usages, you just need code like the snippet above: construct :class:`ResultCapture` 337 | explicitly and its :meth:`ResultCapture.run()` method to :meth:`trio.Nursery.start()`. To some 338 | extent, the :meth:`ResultCapture.capture_start_and_done_results()` function described below was 339 | written just to show it could be done. 340 | 341 | In some rare cases, it may be useful to run the startup code for several routines concurrently. In 342 | that case, as always, the solution is to use a nursery, which this time executes the startup code. 343 | The :meth:`ResultCapture.capture_start_and_done_results()` function allows this. It returns two 344 | :class:`ResultCapture` instances, with the first representing the start result and the second 345 | representing the done result. It can be used like so:: 346 | 347 | async with trio.open_nursery() as rn: 348 | async with trio.open_nursery() as sn: 349 | sr1, dr1 = ResultCapture.capture_start_and_done_results(rn, my_fn, 1, start_nursery=sn) 350 | sr2, dr2 = ResultCapture.capture_start_and_done_results(rn, my_fn, 2, start_nursery=sn) 351 | # The nursery sn is done, so both tasks are now started 352 | print("Start results:", sr1.result(), sr2.result()) 353 | # The nursery rn is done, so both tasks are now done 354 | print("Done results:", dr1.result(), dr2.result()) 355 | 356 | The implementation of :meth:`ResultCapture.capture_start_and_done_results()` is fairly simple, 357 | although it is awkward enough that it is useful not to have to write it out every time:: 358 | 359 | @classmethod 360 | def capture_start_and_done_results( 361 | cls, run_nursery: Nursery, routine, *args, start_nursery: Optional[Nursery] = None 362 | ): 363 | if start_nursery is None: 364 | start_nursery = run_nursery 365 | done_result = cls(routine, *args) # cls is ResultCapture, so this creates an instance 366 | start_result = cls(run_nursery.start, done_result.run) # As does this 367 | start_nursery.start_soon(start_result.run) 368 | return start_result, done_result 369 | 370 | 371 | String representation 372 | --------------------- 373 | 374 | Converting a :class:`ResultBase` object to a string shows whether it completed, and if so its 375 | result:: 376 | 377 | rc = ResultCapture.start_soon(nursery, foo, "arg1", 2) 378 | print(rc) # prints: ResultCapture(is_done=False) 379 | # Later, might print: ResultCapture(result=3) 380 | # Or it might print: ResultCapture(exception=KeyError(3)) 381 | 382 | For :class:`ResultCapture` objects, passing ``#`` as the format string will also include the 383 | routine name and its arguments in the string:: 384 | 385 | print(f"{rc:#}") # prints: ResultCapture(routine=foo, args=('arg1', 2), is_done=False) 386 | 387 | 388 | Reference 389 | --------- 390 | 391 | .. autoclass:: ResultBase 392 | :members: 393 | 394 | .. autoclass:: ResultCapture 395 | :members: 396 | :show-inheritance: 397 | 398 | .. autoexception:: TaskFailedException 399 | :show-inheritance: 400 | :members: 401 | 402 | .. autoexception:: TaskNotDoneException 403 | :show-inheritance: 404 | :members: 405 | -------------------------------------------------------------------------------- /docs/source/wait.rst: -------------------------------------------------------------------------------- 1 | 2 | .. currentmodule:: aioresult 3 | 4 | Waiting for a result 5 | ==================== 6 | 7 | Motivation and usage 8 | -------------------- 9 | 10 | As noted elsewhere, the best way to wait for :class:`ResultCapture` tasks is usually to run them in 11 | a nursery, and access the results after the nursery block. For example:: 12 | 13 | async with trio.open_nursery() as n: 14 | results = [ResultCapture.start_soon(n, foo, i) for i in range(10)] 15 | print("results:", *[r.result() for r in results]) 16 | 17 | It is also possible to wait on an individual task by using the :meth:`ResultBase.wait_done()` 18 | method:: 19 | 20 | async with trio.open_nursery() as n: 21 | results = [ResultCapture.start_soon(n, foo, i) for i in range(10)] 22 | 23 | await results[2].wait_done() 24 | # Task 2 is now done (while other tasks may still be running). 25 | print("result 2:", results[2].result() 26 | 27 | # All tasks are now done. 28 | print("results:", *[r.result() for r in results]) 29 | 30 | The :meth:`ResultBase.wait_done()` method is a method of the base class, so it is also available 31 | for :class:`Future` objects, as are the utility functions below. 32 | 33 | Sometimes, you may need some more control than this. This page documents a few utility functions in 34 | aioresult for slightly more complex waiting patterns. Under the hood, they all have very simple 35 | implementations in terms of :meth:`ResultBase.wait_done()`. 36 | 37 | If you wish to wait for a collection of tasks to complete without using a nursery, or have a 38 | collection of :class:`Future` objects (which cannot be used in the above pattern), then you can use 39 | the :func:`wait_all()` function:: 40 | 41 | async with trio.open_nursery() as n: 42 | results = [ResultCapture.start_soon(n, foo, i) for i in range(10)] 43 | 44 | even_results = results[::2] 45 | await wait_all(even_results) 46 | # All tasks in even positions are now done. 47 | print("even results:", *[r.result() for r in even_results] 48 | 49 | # All tasks are now done. 50 | print("all results:", *[r.result() for r in results]) 51 | 52 | Similarly, :func:`wait_any()` may be used to wait until (at least) one of the tasks is complete. 53 | The return value of the function is the :class:`ResultBase` object that is completed:: 54 | 55 | async with trio.open_nursery() as n: 56 | results = [ResultCapture.start_soon(n, foo, i) for i in range(10)] 57 | 58 | completed = await wait_any(results) 59 | print("task completed:", completed.args[0], "result:", completed.result()) 60 | 61 | print("all results:", *[r.result() for r in results]) 62 | 63 | If you wish to access results one at a time, as they complete, then it is tempting to call 64 | :func:`wait_any()` in a loop, erasing the most recent result each time. But a simpler solution is to 65 | use the :func:`results_to_channel()` function:: 66 | 67 | async with trio.open_nursery() as n: 68 | results = [ResultCapture.start_soon(n, foo, i) for i in range(10)] 69 | 70 | # Create a channel and run aioresult.results_to_channel() in the nursery 71 | send_channel, receive_channel = trio.open_memory_channel(1) 72 | n.start_soon(results_to_channel, results, send_channel) 73 | 74 | async for r in receive_channel: 75 | print("task completed:", r.args[0], "result:", r.result()) 76 | 77 | 78 | Running a callback when a task is done 79 | -------------------------------------- 80 | 81 | Another utility routine that was considered for aioresult is one that runs a callback when the task 82 | is done, similar to :meth:`asyncio.Future.add_done_callback()`. This would be straightforward to 83 | implement but is not in keeping with the spirit of `structured concurrency 84 | `_. If 85 | needed, it is simple to write a little boilerplate to wait for :meth:`ResultBase.wait_done()` and 86 | then call the desired callback:: 87 | 88 | def my_callback(result): 89 | print("Task completed:", result.args[0], "result:", result.result()) 90 | 91 | async def wait_and_run(result): 92 | await result.wait_done() 93 | my_callback(result) 94 | 95 | async def run_all(): 96 | async with trio.open_nursery() as n: 97 | results = [ResultCapture.start_soon(n, foo, i) for i in range(10)] 98 | for r in results: 99 | n.start_soon(wait_and_run, r) 100 | # By the time this line is reached, my_callback() has been completed for each result. 101 | 102 | An alternative, if ``my_callback()`` is a function you can modify, is to wait for 103 | :meth:`ResultBase.wait_done()` at the start. That way, it can be run in the nursery directly:: 104 | 105 | async def my_callback(result): 106 | await result.wait_done() 107 | print("Task completed:", result.args[0], "result:", result.result()) 108 | 109 | async def run_all(): 110 | async with trio.open_nursery() as n: 111 | results = [ResultCapture.start_soon(n, foo, i) for i in range(10)] 112 | for r in results: 113 | n.start_soon(my_callback, r) 114 | # By the time this line is reached, my_callback() has been completed for each result. 115 | 116 | Of course, if you can get this far, then you can probably modify your function to call the 117 | underlying routine directly and eliminate your usage of aioresult altogether:: 118 | 119 | async def my_fn(i): 120 | result = await foo(i) 121 | print("Task completed:", i, "result:", result) 122 | 123 | async def run_all(): 124 | async with trio.open_nursery() as n: 125 | for i in range(10): 126 | n.start_soon(my_fn, i) 127 | 128 | Reference 129 | --------- 130 | 131 | .. autofunction:: wait_all 132 | 133 | .. autofunction:: wait_any 134 | 135 | .. autofunction:: results_to_channel 136 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.setuptools.dynamic] 6 | version = {attr = "aioresult._version.__version__"} 7 | 8 | [tool.setuptools] 9 | include-package-data = true 10 | 11 | [project] 12 | name = "aioresult" 13 | dynamic = ["version"] 14 | description = "Capture the result of a Trio or anyio task" 15 | license = {file = "LICENSE"} 16 | authors = [{name = "Arthur Tacca"}] 17 | readme = "README.rst" 18 | requires-python = ">=3.9" 19 | dependencies = [ 20 | "sniffio>=1.0.0", 21 | "typing_extensions>=4.1.0", # First version with TypeVarTuple/Unpack 22 | ] 23 | 24 | keywords = ["async", "anyio", "trio", "result", "future", "nursery", "taskgroup"] 25 | classifiers = [ 26 | "Development Status :: 5 - Production/Stable", 27 | "Framework :: AnyIO", 28 | "Framework :: Trio", 29 | "Intended Audience :: Developers", 30 | "License :: OSI Approved :: Boost Software License 1.0 (BSL-1.0)", 31 | "Operating System :: OS Independent", 32 | "Programming Language :: Python :: 3 :: Only", 33 | "Programming Language :: Python :: 3.9", 34 | "Programming Language :: Python :: 3.10", 35 | "Programming Language :: Python :: 3.11", 36 | "Programming Language :: Python :: 3.12", 37 | "Programming Language :: Python :: 3.13", 38 | "Programming Language :: Python :: Implementation :: CPython", 39 | "Programming Language :: Python :: Implementation :: PyPy", 40 | "Topic :: System :: Networking", 41 | ] 42 | 43 | [project.urls] 44 | Repository = "https://github.com/arthur-tacca/aioresult" 45 | Documentation = "https://aioresult.readthedocs.io/en/v1.2/overview.html" 46 | 47 | [project.optional-dependencies] 48 | docs = ["sphinx>=6.1", "sphinxcontrib-trio", "sphinx_rtd_theme", "trio", "anyio"] 49 | tests = ["pytest", "coverage", "trio", "anyio", "exceptiongroup; python_version < '3.11'"] 50 | 51 | [tool.mypy] 52 | python_version = "3.9" 53 | files = ["aioresult/", "tests/"] 54 | disable_error_code = ["func-returns-value"] 55 | 56 | strict = true 57 | local_partial_types = true 58 | warn_unused_ignores = true 59 | warn_unused_configs = true 60 | warn_redundant_casts = true 61 | warn_return_any = true 62 | 63 | disallow_any_expr = false 64 | disallow_any_generics = true 65 | disallow_any_unimported = true 66 | disallow_incomplete_defs = true 67 | disallow_untyped_calls = true 68 | disallow_untyped_decorators = true 69 | disallow_untyped_defs = true 70 | 71 | [tool.pyright] 72 | include = ["aioresult/", "tests/"] 73 | pythonVersion = "3.9" 74 | typeCheckingMode = "strict" 75 | reportUnnecessaryTypeIgnoreComment = true 76 | -------------------------------------------------------------------------------- /tests/test_all.py: -------------------------------------------------------------------------------- 1 | from collections.abc import AsyncIterable, Callable 2 | from contextlib import AbstractAsyncContextManager 3 | from typing import Any, Generator, Optional 4 | from typing_extensions import TypeAlias 5 | import contextlib 6 | import sys 7 | 8 | 9 | if sys.version_info < (3, 11): 10 | from exceptiongroup import ExceptionGroup 11 | 12 | import pytest 13 | 14 | import anyio 15 | import sniffio 16 | import trio 17 | 18 | from aioresult import * 19 | from aioresult._aio import NurseryLike, SendChannelLike 20 | 21 | 22 | OpenNursery: TypeAlias = Callable[[], AbstractAsyncContextManager[NurseryLike]] 23 | 24 | 25 | # We run each test three times: 26 | # * Using asyncio with anyio.create_task_group 27 | # * Using trio with anyio.create_task_group (thin wrapper around trio.Nursery) 28 | # * Using trio with trio.open_nursery directly - to test direct usage of Trio 29 | @pytest.fixture(params=["anyio-asyncio", "anyio-trio", "trio"]) 30 | def resultcapture_test_mode(request: pytest.FixtureRequest) -> str: 31 | assert isinstance(request.param, str) 32 | return request.param 33 | 34 | 35 | # This is how we tell anyio's pytest plugin which backend to use 36 | @pytest.fixture 37 | def anyio_backend(resultcapture_test_mode: str) -> str: 38 | if "trio" in resultcapture_test_mode: 39 | return "trio" 40 | else: 41 | return "asyncio" 42 | 43 | 44 | # This is where we pick which function is used to create the nursery. We also pass anyio_backend so 45 | # that anyio's pytest plugin knows that any test depending on this fixture should be run through it. 46 | @pytest.fixture 47 | def open_nursery(anyio_backend: str, resultcapture_test_mode: str) -> OpenNursery: 48 | if resultcapture_test_mode == "trio": 49 | # Test using Trio's nursery type without anyio's (admittedly very thin) TaskGroup wrapper 50 | return trio.open_nursery 51 | else: 52 | return anyio.create_task_group 53 | 54 | 55 | class AioresultTestException(Exception): 56 | pass 57 | 58 | 59 | @contextlib.contextmanager 60 | def raises_aioresult_exception() -> Generator[None, None, None]: 61 | # Check that AioresultTestException is raised, either directly or in ExceptionGroup (but only 62 | # on its own at the top level). This allows the test to run regardless of whether nurseries 63 | # are in strict mode. 64 | eg: ExceptionGroup[Exception] 65 | try: 66 | yield 67 | except AioresultTestException: 68 | pass 69 | except ExceptionGroup as eg: 70 | assert len(eg.exceptions) == 1 and isinstance(eg.exceptions[0], AioresultTestException) 71 | 72 | 73 | # Some of the tests in here are quite fragile - they assume that processing delays are small enough 74 | # that e.g. a sleep of 1.5 seconds is still running when a sleep of 1 second has just completed. 75 | # Making this time multiplier smaller will make the tests run more quickly but increase their 76 | # fragility, potentially causing spurious test failures. 77 | # 78 | # (The tests could be made robust by communicating between the tasks, e.g. with memory channels, 79 | # so that they only move on when the current test or other tasks have reached certain points. But 80 | # this would significantly complicate the tests and does not seem worth it compared to the benefit.) 81 | _TIME_MULTIPLIER = 0.1 82 | 83 | 84 | # --- Tests for ResultCapture --- 85 | 86 | 87 | async def sleep_and_return(time_to_run: float) -> float: 88 | await anyio.sleep(time_to_run * _TIME_MULTIPLIER) 89 | return time_to_run 90 | 91 | async def sleep_and_raise(time_to_run: float) -> None: 92 | await anyio.sleep(time_to_run * _TIME_MULTIPLIER) 93 | raise AioresultTestException(time_to_run) 94 | 95 | 96 | async def test_resultcapture(open_nursery: OpenNursery) -> None: 97 | # Run four tasks in the nursery: 98 | # 1. completes (run() called manually) 99 | # 2. completes (started normally) 100 | # 3. raises exception 101 | # 4. still running - so cancelled 102 | 103 | print("Testing resultcapture with backend:", sniffio.current_async_library()) 104 | with raises_aioresult_exception(): 105 | async with open_nursery() as n: 106 | try: 107 | # Run all three tasks 108 | r1 = ResultCapture(sleep_and_return, 1) 109 | r2 = ResultCapture.start_soon(n, sleep_and_return, 2) 110 | r3 = ResultCapture.start_soon(n, sleep_and_raise, 3) 111 | r4 = ResultCapture.start_soon(n, sleep_and_return, 4) 112 | 113 | # All are not done yet 114 | assert not r1.is_done() and not r2.is_done() and not r3.is_done() and not r4.is_done() 115 | with pytest.raises(TaskNotDoneException): 116 | r1.result() 117 | with pytest.raises(TaskNotDoneException): 118 | r1.exception() 119 | 120 | # Run task 1 run() manually 121 | wait_result = await r1.run() 122 | assert wait_result is None 123 | assert r1.is_done() and not r2.is_done() and not r3.is_done() and not r4.is_done() 124 | 125 | # Wait for task 2 to finish 126 | await r2.wait_done() 127 | assert r2.result() == 2 128 | except AioresultTestException: 129 | # Should not be thrown until nursery.__aexit__() 130 | pytest.fail("Exception raised too early") 131 | 132 | # Task 1 finished successfully 133 | assert r1.is_done() 134 | assert r1.result() == 1 135 | assert r1.exception() is None 136 | 137 | # Task 1 finished successfully 138 | assert r2.is_done() 139 | assert r2.result() == 2 140 | assert r2.exception() is None 141 | 142 | # Task 3 raised exception 143 | assert r3.is_done() 144 | with pytest.raises(TaskFailedException) as exc_info: 145 | r3.result() 146 | assert exc_info.value.args == (r3,) 147 | cause = exc_info.value.__cause__ 148 | assert isinstance(cause, AioresultTestException) 149 | assert cause.args == (3,) 150 | assert cause is r3.exception() 151 | 152 | # Task 4 was cancelled 153 | assert r4.is_done() 154 | with pytest.raises(TaskFailedException) as exc_info: 155 | r4.result() 156 | assert exc_info.value.args == (r4,) 157 | cause = exc_info.value.__cause__ 158 | assert isinstance(cause, anyio.get_cancelled_exc_class()) 159 | assert cause is r4.exception() 160 | 161 | # Finally, check exception thrown when run twice (result or exception set) 162 | with pytest.raises(FutureSetAgainException) as exc_info2: 163 | await r1.run() 164 | assert exc_info2.value.args == (r1,) 165 | with pytest.raises(FutureSetAgainException) as exc_info2: 166 | await r3.run() 167 | assert exc_info2.value.args == (r3,) 168 | 169 | 170 | async def test_resultcapture_suppressexception(open_nursery: OpenNursery) -> None: 171 | async with open_nursery() as n: 172 | r1 = ResultCapture.start_soon(n, sleep_and_return, 1, suppress_exception=True) 173 | r2 = ResultCapture.start_soon(n, sleep_and_raise, 2, suppress_exception=True) 174 | r3 = ResultCapture.start_soon(n, sleep_and_return, 3, suppress_exception=True) 175 | 176 | assert r1.is_done() and r1.exception() is None 177 | assert r2.is_done() and isinstance(r2.exception(), AioresultTestException) 178 | assert r3.is_done() and r3.exception() is None 179 | 180 | 181 | 182 | # --- Tests for ResultCapture.capture_start_and_done_results() --- 183 | 184 | 185 | async def startable_sleep_and_return( 186 | time_to_run: float, 187 | task_status: trio.TaskStatus[float] = trio.TASK_STATUS_IGNORED, 188 | ) -> float: 189 | await anyio.sleep(time_to_run * _TIME_MULTIPLIER) 190 | task_status.started(time_to_run / 2) 191 | await anyio.sleep(time_to_run * _TIME_MULTIPLIER) 192 | return time_to_run 193 | 194 | async def startable_sleep_and_raise( 195 | time_to_run: float, 196 | task_status: trio.TaskStatus[float] = trio.TASK_STATUS_IGNORED, 197 | ) -> float: 198 | await anyio.sleep(time_to_run * _TIME_MULTIPLIER / 2) 199 | task_status.started(time_to_run / 2) 200 | await anyio.sleep(time_to_run * _TIME_MULTIPLIER / 2) 201 | raise AioresultTestException(time_to_run) 202 | 203 | async def startable_return_early( 204 | time_to_run: float, 205 | task_status: trio.TaskStatus[Any] = trio.TASK_STATUS_IGNORED, 206 | ) -> float: 207 | await anyio.sleep(time_to_run * _TIME_MULTIPLIER) 208 | return time_to_run 209 | 210 | async def startable_raise_early( 211 | time_to_run: float, 212 | task_status: trio.TaskStatus[Any] = trio.TASK_STATUS_IGNORED, 213 | ) -> None: 214 | await anyio.sleep(time_to_run * _TIME_MULTIPLIER) 215 | raise AioresultTestException(time_to_run) 216 | 217 | 218 | async def test_startable_success(open_nursery: OpenNursery) -> None: 219 | async with open_nursery() as run_nursery: 220 | async with open_nursery() as start_nursery: 221 | rc1 = ResultCapture(startable_sleep_and_return, 1) 222 | src2, rc2 = ResultCapture.capture_start_and_done_results( 223 | run_nursery, startable_sleep_and_return, 2, start_nursery=start_nursery 224 | ) 225 | src3, rc3 = ResultCapture.capture_start_and_done_results( 226 | run_nursery, startable_sleep_and_return, 3, start_nursery=start_nursery 227 | ) 228 | 229 | assert not rc1.is_done() 230 | assert not rc2.is_done() and not src2.is_done() 231 | assert not rc3.is_done() and not src3.is_done() 232 | 233 | start_result1 = await run_nursery.start(rc1.run) 234 | assert start_result1 == 0.5 and not rc1.is_done() 235 | assert not rc2.is_done() and not src2.is_done() 236 | assert not rc3.is_done() and not src3.is_done() 237 | 238 | await src2.wait_done() 239 | assert not src3.is_done() and src2.is_done() 240 | assert src2.result() == 1 241 | 242 | assert src3.is_done() and src2.is_done() 243 | assert src3.result() == 1.5 244 | 245 | assert not rc3.is_done() and not rc2.is_done() and rc1.is_done() 246 | 247 | assert rc3.is_done() and rc2.is_done() and rc1.is_done() 248 | 249 | 250 | async def test_startable_failure(open_nursery: OpenNursery) -> None: 251 | # Three tasks: 252 | # 2 seconds - starts successfully 253 | # 3 seconds - raises in startup 254 | # 4 seconds - cancelled during startup 255 | 256 | async with open_nursery() as run_nursery: 257 | with raises_aioresult_exception(): 258 | async with open_nursery() as start_nursery: 259 | try: 260 | src2, rc2 = ResultCapture.capture_start_and_done_results( 261 | run_nursery, startable_sleep_and_return, 2, start_nursery=start_nursery 262 | ) 263 | src3, rc3 = ResultCapture.capture_start_and_done_results( 264 | run_nursery, startable_raise_early, 3, start_nursery=start_nursery 265 | ) 266 | rc4 = ResultCapture(startable_sleep_and_return, 4) 267 | await anyio.sleep(0) 268 | 269 | except BaseException: 270 | pytest.fail("Exception raised too early") 271 | await run_nursery.start(rc4.run) 272 | pytest.fail("Should have been cancelled before now") 273 | 274 | assert src2.is_done() and src2.exception() is None and rc2.is_done() and rc2.exception() is None 275 | assert src3.is_done() and isinstance(src3.exception(), AioresultTestException) 276 | assert rc3.is_done() and isinstance(rc3.exception(), AioresultTestException) 277 | assert rc4.is_done() and isinstance(rc4.exception(), anyio.get_cancelled_exc_class()) 278 | 279 | 280 | async def test_startable_early_return(open_nursery: OpenNursery) -> None: 281 | src: Optional[ResultCapture[Any]] = None 282 | rc: Optional[ResultCapture[float]] = None 283 | eg: ExceptionGroup[Exception] 284 | async with open_nursery() as run_nursery: 285 | try: 286 | async with open_nursery() as start_nursery: 287 | src, rc = ResultCapture.capture_start_and_done_results( 288 | run_nursery, startable_return_early, 1, start_nursery=start_nursery 289 | ) 290 | except RuntimeError: 291 | pass 292 | except ExceptionGroup as eg: 293 | assert len(eg.exceptions) == 1 and isinstance(eg.exceptions[0], RuntimeError) 294 | 295 | assert src is not None and src.is_done() and isinstance(src.exception(), RuntimeError) 296 | assert rc is not None and rc.is_done() and rc.exception() is None and rc.result() == 1.0 297 | 298 | 299 | # -- Tests for Future -- 300 | 301 | 302 | async def wait_and_set(f: Future[int]) -> None: 303 | await anyio.sleep(0.1) 304 | f.set_result(1) 305 | 306 | 307 | async def wait_and_raise(f: Future[Any]) -> None: 308 | await anyio.sleep(0.2) 309 | f.set_exception(AioresultTestException(2)) 310 | 311 | 312 | async def test_future(open_nursery: OpenNursery) -> None: 313 | # Not much testing needed for future because it uses same functionality as ResultCapture 314 | async with open_nursery() as n: 315 | # future returning result 316 | fr: Future[int] = Future() 317 | n.start_soon(wait_and_set, fr) 318 | assert not fr.is_done() 319 | with pytest.raises(TaskNotDoneException): 320 | fr.result() 321 | with pytest.raises(TaskNotDoneException): 322 | fr.exception() 323 | 324 | # future throwing exception 325 | fx: Future[object] = Future() 326 | n.start_soon(wait_and_raise, fx) 327 | assert not fx.is_done() 328 | with pytest.raises(TaskNotDoneException): 329 | fx.result() 330 | with pytest.raises(TaskNotDoneException): 331 | fx.exception() 332 | 333 | # Check result, even after attempting to change result / exception 334 | assert fr.is_done() and fr.result() == 1 and fr.exception() is None 335 | with pytest.raises(FutureSetAgainException): 336 | fr.set_result(10) 337 | with pytest.raises(FutureSetAgainException): 338 | fr.set_exception(EOFError("foo")) 339 | assert fr.is_done() and fr.result() == 1 and fr.exception() is None 340 | 341 | # Check exception, even after attempting to change result / exception 342 | assert fx.is_done() and isinstance(fx.exception(), AioresultTestException) 343 | with pytest.raises(TaskFailedException): 344 | fx.result() 345 | with pytest.raises(FutureSetAgainException): 346 | fx.set_result(10) 347 | with pytest.raises(FutureSetAgainException): 348 | fx.set_exception(EOFError("foo")) 349 | assert fx.is_done() and isinstance(fx.exception(), AioresultTestException) 350 | with pytest.raises(TaskFailedException): 351 | fx.result() 352 | 353 | 354 | async def test_str(open_nursery: OpenNursery) -> None: 355 | async with open_nursery() as n: 356 | rc = ResultCapture.start_soon(n, sleep_and_return, 0.1) 357 | assert str(rc) == "ResultCapture(is_done=False)" 358 | assert format(rc, "#") == "ResultCapture(routine=sleep_and_return, args=(0.1,), is_done=False)" 359 | assert str(rc) == "ResultCapture(result=0.1)" 360 | 361 | f = Future[int]() 362 | assert str(f) == "Future(is_done=False)" 363 | f.set_exception(KeyError(3)) 364 | assert str(f) == "Future(exception=KeyError(3))" 365 | assert repr(f) == str(f) 366 | 367 | 368 | # -- Tests for wait functions 369 | 370 | 371 | async def test_wait_any_all(open_nursery: OpenNursery) -> None: 372 | async with open_nursery() as n: 373 | results = [ResultCapture.start_soon(n, sleep_and_return, i) for i in range(10)] 374 | 375 | finished_first = await wait_any(reversed(results)) 376 | assert finished_first == results[0] 377 | assert results[0].is_done() 378 | assert all(not r.is_done() for r in results[1:]) 379 | 380 | results_for_wait_all = results[3], results[2], results[0] 381 | await wait_all(results_for_wait_all) 382 | assert all(r.is_done() for r in results[:4]) 383 | assert all(not r.is_done() for r in results[4:]) 384 | 385 | assert all(r.is_done() for r in results) 386 | 387 | 388 | async def test_to_channel(open_nursery: OpenNursery, resultcapture_test_mode: str) -> None: 389 | async with open_nursery() as n: 390 | results = [ResultCapture.start_soon(n, sleep_and_return, i) for i in range(10)] 391 | 392 | send_channel: SendChannelLike[ResultBase[float]] 393 | # We only iterate, don't need a full protocol for this. 394 | receive_channel: AsyncIterable[ResultBase[float]] 395 | if resultcapture_test_mode == "trio": 396 | send_channel, receive_channel = trio.open_memory_channel(1) 397 | else: 398 | send_channel, receive_channel = anyio.create_memory_object_stream(1) 399 | 400 | n.start_soon(results_to_channel, results, send_channel) 401 | 402 | previous_result = -1.0 403 | async for r in receive_channel: 404 | assert r.result() > previous_result 405 | previous_result = r.result() 406 | 407 | 408 | if __name__ == "__main__": 409 | # Manually run the test functions - useful for debugging test failures. 410 | test_conditions: list[tuple[Any, Any, str]] = [ 411 | (trio.run, trio.open_nursery, "trio"), 412 | (anyio.run, anyio.create_task_group, "anyio-asyncio"), 413 | ] 414 | for run, open_nursery, library_name in test_conditions: 415 | run(test_resultcapture, open_nursery) 416 | run(test_resultcapture_suppressexception, open_nursery) 417 | run(test_startable_success, open_nursery) 418 | run(test_startable_failure, open_nursery) 419 | run(test_startable_early_return, open_nursery) 420 | run(test_future, open_nursery) 421 | run(test_wait_any_all, open_nursery) 422 | run(test_to_channel, open_nursery, library_name) 423 | -------------------------------------------------------------------------------- /tests/type_tests.py: -------------------------------------------------------------------------------- 1 | """Run as part of type checking, not at runtime.""" 2 | # pyright: reportUnusedVariable=false 3 | from typing_extensions import assert_type 4 | 5 | from aioresult import ResultCapture, wait_any, Future, ResultBase 6 | from aioresult._aio import NurseryLike, CancelScopeLike, SendChannelLike 7 | 8 | from anyio.abc import TaskGroup 9 | from anyio.streams.memory import MemoryObjectSendStream 10 | import trio 11 | 12 | 13 | def check_trio_protocols(trio_nursery: trio.Nursery, send: trio.MemorySendChannel[bool]) -> None: 14 | """Check Trio's classes satisfy our protocols.""" 15 | nursery: NurseryLike = trio_nursery 16 | cancel_scope: CancelScopeLike = trio_nursery.cancel_scope 17 | send_channel: SendChannelLike[bool] = send 18 | 19 | 20 | def check_anyio_protocols( 21 | task_group: TaskGroup, 22 | send: MemoryObjectSendStream[bool], 23 | ) -> None: 24 | """Check Anyio's classes satisfy our protocols.""" 25 | nursery: NurseryLike = task_group 26 | cancel_scope: CancelScopeLike = task_group.cancel_scope 27 | send_channel: SendChannelLike[bool] = send 28 | 29 | 30 | async def sample_func(a: int, b: str) -> list[str]: 31 | return [] 32 | 33 | 34 | async def returns_int() -> int: 35 | return 0 36 | 37 | 38 | async def returns_bool() -> bool: 39 | return True 40 | 41 | 42 | async def check_resultcapture_start_soon(nursery: NurseryLike) -> None: 43 | ResultCapture.start_soon(nursery, sample_func, 1) # type: ignore 44 | ResultCapture.start_soon(nursery, sample_func, 1, 'two', False) # type: ignore 45 | result = ResultCapture.start_soon(nursery, sample_func, 1, 'two') 46 | assert_type(result.result(), list[str]) 47 | arg1: int = result.args[0] 48 | 49 | 50 | async def check_is_covariant(nursery: NurseryLike) -> None: 51 | res_int: ResultCapture[int] = ResultCapture.start_soon(nursery, returns_int) 52 | res_bool: ResultCapture[bool] = ResultCapture.start_soon(nursery, returns_bool) 53 | also_int: ResultCapture[int] = res_bool 54 | not_str: ResultCapture[str] = res_bool # type: ignore 55 | 56 | future_bool = Future[bool]() 57 | future_bool.set_result(True) 58 | future_bool.set_result(1) # type: ignore 59 | base_int: ResultBase[int] = future_bool 60 | future_int: Future[int] = future_bool # type: ignore 61 | 62 | res_one: ResultCapture[int] = await wait_any([res_int]) 63 | res_two: ResultCapture[int] = await wait_any([res_int, res_bool]) 64 | res_three: ResultCapture[list[str]] = await wait_any([ 65 | ResultCapture.start_soon(nursery, sample_func, 1, 'two'), 66 | ResultCapture.start_soon(nursery, sample_func, 1, 'two') 67 | ]) 68 | --------------------------------------------------------------------------------