Source code for action0.client.backends.futures

"""
The thread-pool backend — a :py:data:`~action0.client.backend.FuturesBackend`
(``Backend[Future[Response]]``), stdlib-only: it wraps any synchronous
backend and runs its sends on a :py:class:`~concurrent.futures.ThreadPoolExecutor`,
so plain sync code gets parallel requests as
:py:class:`concurrent.futures.Future` results — no async machinery.
"""

from concurrent.futures import Future
from concurrent.futures import ThreadPoolExecutor
from typing import Callable
from typing import TypeVar

from action0.req import Request
from action0.req import Response

from ..backend import Backend

T = TypeVar("T")
S = TypeVar("S")


[docs] class ThreadPoolBackend: """ A backend whose execution model is :py:class:`concurrent.futures.Future`: every send runs the wrapped synchronous backend on the thread pool. ``Client(ThreadPoolBackend(...)).send(request)`` is a ``Future[Response]``, and :py:meth:`APIClient.send <action0.client.api.APIClient.send>` returns ``Future[R]`` — response parsing (and any instrumentation hooks, which belong on the *wrapped* backend) runs on the pool threads. Example:: >>> from action0.client.testing import StubBackend >>> from action0.req import Request, Response >>> >>> with ThreadPoolBackend(StubBackend(Response(200, body="pong"))) as backend: ... future = backend.send(Request("https://api.example.com/ping")) ... future.result().body_str() 'pong' Real-world use — fan out over a shared session, sync code throughout:: from action0.client.backends.requests import RequestsBackend from action0.client.backends.futures import ThreadPoolBackend with RequestsBackend() as inner, ThreadPoolBackend(inner) as backend: client = APIClient(backend, "https://api.example.com/v1") futures = [client.send(GetItem(item_id=item_id)) for item_id in range(100)] items = [future.result() for future in futures] # Future[Item] each """ def __init__( self, inner: Backend[Response], pool: "ThreadPoolExecutor | None" = None, *, max_workers: "int | None" = None, ) -> None: """ :param inner: the synchronous backend that actually sends (put instrumentation hooks there — this wrapper stays out of the way) :param pool: the executor to run sends on; ``None`` creates (and owns) one, shut down again by :py:meth:`close` :param max_workers: the size of the created pool (``None`` is the executor's default); ignored when a ``pool`` is given """ self._inner = inner self._pool = ( pool if pool is not None else ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="action0-client") ) self._owns_pool = pool is None @property def inner(self) -> Backend[Response]: """The wrapped synchronous backend doing the actual sends.""" return self._inner
[docs] def send(self, request: Request) -> "Future[Response]": """ Run the wrapped backend's send on the pool. :param request: the request to send :return: a Future of the response; transport errors surface when its result is retrieved """ return self._pool.submit(self._inner.send, request)
[docs] def map(self, result: "Future[T]", fn: Callable[[T], S]) -> "Future[S]": """ Apply a function inside a Future result of :py:meth:`send`: the returned Future resolves to ``fn`` of the original result, and failures (of the send or of ``fn``) propagate. The function runs via a done-callback, so no pool thread is spent waiting. :param result: a Future as returned by :py:meth:`send` :param fn: the function to apply to the eventual value :return: a Future of the return value of ``fn`` """ chained: "Future[S]" = Future() def propagate(done: "Future[T]") -> None: if done.cancelled(): chained.cancel() return error = done.exception() if error is not None: chained.set_exception(error) return try: chained.set_result(fn(done.result())) except BaseException as fn_error: # noqa: BLE001 — must reach the Future chained.set_exception(fn_error) result.add_done_callback(propagate) return chained
[docs] def close(self, wait: bool = True) -> None: """ Shut down the pool — but only if this backend created it; a pool that was passed in is left to its owner. The wrapped backend is never closed here. :param wait: whether to block until running sends finished """ if self._owns_pool: self._pool.shutdown(wait=wait)
def __enter__(self) -> "ThreadPoolBackend": """ :return: the backend itself, closed again when the ``with`` block ends """ return self def __exit__(self, *exc_info: object) -> None: """ Close the backend on leaving the ``with`` block. :param exc_info: the exception leaving the block, if any (ignored) """ self.close() def __repr__(self) -> str: """ :return: the backend with its wrapped backend, e.g. ``ThreadPoolBackend(StubBackend(0 requests))`` """ return f"{self.__class__.__name__}({self._inner!r})"