Format: text/x-python
⬇ Download File (17.1 KB)
   1  from __future__ import annotations
   2  
   3  import sys
   4  from collections.abc import Awaitable, Callable, Generator
   5  from concurrent.futures import Future
   6  from contextlib import (
   7      AbstractAsyncContextManager,
   8      AbstractContextManager,
   9      contextmanager,
  10  )
  11  from dataclasses import dataclass, field
  12  from inspect import isawaitable
  13  from threading import Lock, Thread, get_ident
  14  from types import TracebackType
  15  from typing import (
  16      Any,
  17      Generic,
  18      TypeVar,
  19      cast,
  20      overload,
  21  )
  22  
  23  from ._core import _eventloop
  24  from ._core._eventloop import get_async_backend, get_cancelled_exc_class, threadlocals
  25  from ._core._synchronization import Event
  26  from ._core._tasks import CancelScope, create_task_group
  27  from .abc import AsyncBackend
  28  from .abc._tasks import TaskStatus
  29  
  30  if sys.version_info >= (3, 11):
  31      from typing import TypeVarTuple, Unpack
  32  else:
  33      from typing_extensions import TypeVarTuple, Unpack
  34  
  35  T_Retval = TypeVar("T_Retval")
  36  T_co = TypeVar("T_co", covariant=True)
  37  PosArgsT = TypeVarTuple("PosArgsT")
  38  
  39  
  40  def run(
  41      func: Callable[[Unpack[PosArgsT]], Awaitable[T_Retval]], *args: Unpack[PosArgsT]
  42  ) -> T_Retval:
  43      """
  44      Call a coroutine function from a worker thread.
  45  
  46      :param func: a coroutine function
  47      :param args: positional arguments for the callable
  48      :return: the return value of the coroutine function
  49  
  50      """
  51      try:
  52          async_backend = threadlocals.current_async_backend
  53          token = threadlocals.current_token
  54      except AttributeError:
  55          raise RuntimeError(
  56              "This function can only be run from an AnyIO worker thread"
  57          ) from None
  58  
  59      return async_backend.run_async_from_thread(func, args, token=token)
  60  
  61  
  62  def run_sync(
  63      func: Callable[[Unpack[PosArgsT]], T_Retval], *args: Unpack[PosArgsT]
  64  ) -> T_Retval:
  65      """
  66      Call a function in the event loop thread from a worker thread.
  67  
  68      :param func: a callable
  69      :param args: positional arguments for the callable
  70      :return: the return value of the callable
  71  
  72      """
  73      try:
  74          async_backend = threadlocals.current_async_backend
  75          token = threadlocals.current_token
  76      except AttributeError:
  77          raise RuntimeError(
  78              "This function can only be run from an AnyIO worker thread"
  79          ) from None
  80  
  81      return async_backend.run_sync_from_thread(func, args, token=token)
  82  
  83  
  84  class _BlockingAsyncContextManager(Generic[T_co], AbstractContextManager):
  85      _enter_future: Future[T_co]
  86      _exit_future: Future[bool | None]
  87      _exit_event: Event
  88      _exit_exc_info: tuple[
  89          type[BaseException] | None, BaseException | None, TracebackType | None
  90      ] = (None, None, None)
  91  
  92      def __init__(
  93          self, async_cm: AbstractAsyncContextManager[T_co], portal: BlockingPortal
  94      ):
  95          self._async_cm = async_cm
  96          self._portal = portal
  97  
  98      async def run_async_cm(self) -> bool | None:
  99          try:
 100              self._exit_event = Event()
 101              value = await self._async_cm.__aenter__()
 102          except BaseException as exc:
 103              self._enter_future.set_exception(exc)
 104              raise
 105          else:
 106              self._enter_future.set_result(value)
 107  
 108          try:
 109              # Wait for the sync context manager to exit.
 110              # This next statement can raise `get_cancelled_exc_class()` if
 111              # something went wrong in a task group in this async context
 112              # manager.
 113              await self._exit_event.wait()
 114          finally:
 115              # In case of cancellation, it could be that we end up here before
 116              # `_BlockingAsyncContextManager.__exit__` is called, and an
 117              # `_exit_exc_info` has been set.
 118              result = await self._async_cm.__aexit__(*self._exit_exc_info)
 119              return result
 120  
 121      def __enter__(self) -> T_co:
 122          self._enter_future = Future()
 123          self._exit_future = self._portal.start_task_soon(self.run_async_cm)
 124          return self._enter_future.result()
 125  
 126      def __exit__(
 127          self,
 128          __exc_type: type[BaseException] | None,
 129          __exc_value: BaseException | None,
 130          __traceback: TracebackType | None,
 131      ) -> bool | None:
 132          self._exit_exc_info = __exc_type, __exc_value, __traceback
 133          self._portal.call(self._exit_event.set)
 134          return self._exit_future.result()
 135  
 136  
 137  class _BlockingPortalTaskStatus(TaskStatus):
 138      def __init__(self, future: Future):
 139          self._future = future
 140  
 141      def started(self, value: object = None) -> None:
 142          self._future.set_result(value)
 143  
 144  
 145  class BlockingPortal:
 146      """An object that lets external threads run code in an asynchronous event loop."""
 147  
 148      def __new__(cls) -> BlockingPortal:
 149          return get_async_backend().create_blocking_portal()
 150  
 151      def __init__(self) -> None:
 152          self._event_loop_thread_id: int | None = get_ident()
 153          self._stop_event = Event()
 154          self._task_group = create_task_group()
 155          self._cancelled_exc_class = get_cancelled_exc_class()
 156  
 157      async def __aenter__(self) -> BlockingPortal:
 158          await self._task_group.__aenter__()
 159          return self
 160  
 161      async def __aexit__(
 162          self,
 163          exc_type: type[BaseException] | None,
 164          exc_val: BaseException | None,
 165          exc_tb: TracebackType | None,
 166      ) -> bool | None:
 167          await self.stop()
 168          return await self._task_group.__aexit__(exc_type, exc_val, exc_tb)
 169  
 170      def _check_running(self) -> None:
 171          if self._event_loop_thread_id is None:
 172              raise RuntimeError("This portal is not running")
 173          if self._event_loop_thread_id == get_ident():
 174              raise RuntimeError(
 175                  "This method cannot be called from the event loop thread"
 176              )
 177  
 178      async def sleep_until_stopped(self) -> None:
 179          """Sleep until :meth:`stop` is called."""
 180          await self._stop_event.wait()
 181  
 182      async def stop(self, cancel_remaining: bool = False) -> None:
 183          """
 184          Signal the portal to shut down.
 185  
 186          This marks the portal as no longer accepting new calls and exits from
 187          :meth:`sleep_until_stopped`.
 188  
 189          :param cancel_remaining: ``True`` to cancel all the remaining tasks, ``False``
 190              to let them finish before returning
 191  
 192          """
 193          self._event_loop_thread_id = None
 194          self._stop_event.set()
 195          if cancel_remaining:
 196              self._task_group.cancel_scope.cancel()
 197  
 198      async def _call_func(
 199          self,
 200          func: Callable[[Unpack[PosArgsT]], Awaitable[T_Retval] | T_Retval],
 201          args: tuple[Unpack[PosArgsT]],
 202          kwargs: dict[str, Any],
 203          future: Future[T_Retval],
 204      ) -> None:
 205          def callback(f: Future[T_Retval]) -> None:
 206              if f.cancelled() and self._event_loop_thread_id not in (
 207                  None,
 208                  get_ident(),
 209              ):
 210                  self.call(scope.cancel)
 211  
 212          try:
 213              retval_or_awaitable = func(*args, **kwargs)
 214              if isawaitable(retval_or_awaitable):
 215                  with CancelScope() as scope:
 216                      if future.cancelled():
 217                          scope.cancel()
 218                      else:
 219                          future.add_done_callback(callback)
 220  
 221                      retval = await retval_or_awaitable
 222              else:
 223                  retval = retval_or_awaitable
 224          except self._cancelled_exc_class:
 225              future.cancel()
 226              future.set_running_or_notify_cancel()
 227          except BaseException as exc:
 228              if not future.cancelled():
 229                  future.set_exception(exc)
 230  
 231              # Let base exceptions fall through
 232              if not isinstance(exc, Exception):
 233                  raise
 234          else:
 235              if not future.cancelled():
 236                  future.set_result(retval)
 237          finally:
 238              scope = None  # type: ignore[assignment]
 239  
 240      def _spawn_task_from_thread(
 241          self,
 242          func: Callable[[Unpack[PosArgsT]], Awaitable[T_Retval] | T_Retval],
 243          args: tuple[Unpack[PosArgsT]],
 244          kwargs: dict[str, Any],
 245          name: object,
 246          future: Future[T_Retval],
 247      ) -> None:
 248          """
 249          Spawn a new task using the given callable.
 250  
 251          Implementers must ensure that the future is resolved when the task finishes.
 252  
 253          :param func: a callable
 254          :param args: positional arguments to be passed to the callable
 255          :param kwargs: keyword arguments to be passed to the callable
 256          :param name: name of the task (will be coerced to a string if not ``None``)
 257          :param future: a future that will resolve to the return value of the callable,
 258              or the exception raised during its execution
 259  
 260          """
 261          raise NotImplementedError
 262  
 263      @overload
 264      def call(
 265          self,
 266          func: Callable[[Unpack[PosArgsT]], Awaitable[T_Retval]],
 267          *args: Unpack[PosArgsT],
 268      ) -> T_Retval: ...
 269  
 270      @overload
 271      def call(
 272          self, func: Callable[[Unpack[PosArgsT]], T_Retval], *args: Unpack[PosArgsT]
 273      ) -> T_Retval: ...
 274  
 275      def call(
 276          self,
 277          func: Callable[[Unpack[PosArgsT]], Awaitable[T_Retval] | T_Retval],
 278          *args: Unpack[PosArgsT],
 279      ) -> T_Retval:
 280          """
 281          Call the given function in the event loop thread.
 282  
 283          If the callable returns a coroutine object, it is awaited on.
 284  
 285          :param func: any callable
 286          :raises RuntimeError: if the portal is not running or if this method is called
 287              from within the event loop thread
 288  
 289          """
 290          return cast(T_Retval, self.start_task_soon(func, *args).result())
 291  
 292      @overload
 293      def start_task_soon(
 294          self,
 295          func: Callable[[Unpack[PosArgsT]], Awaitable[T_Retval]],
 296          *args: Unpack[PosArgsT],
 297          name: object = None,
 298      ) -> Future[T_Retval]: ...
 299  
 300      @overload
 301      def start_task_soon(
 302          self,
 303          func: Callable[[Unpack[PosArgsT]], T_Retval],
 304          *args: Unpack[PosArgsT],
 305          name: object = None,
 306      ) -> Future[T_Retval]: ...
 307  
 308      def start_task_soon(
 309          self,
 310          func: Callable[[Unpack[PosArgsT]], Awaitable[T_Retval] | T_Retval],
 311          *args: Unpack[PosArgsT],
 312          name: object = None,
 313      ) -> Future[T_Retval]:
 314          """
 315          Start a task in the portal's task group.
 316  
 317          The task will be run inside a cancel scope which can be cancelled by cancelling
 318          the returned future.
 319  
 320          :param func: the target function
 321          :param args: positional arguments passed to ``func``
 322          :param name: name of the task (will be coerced to a string if not ``None``)
 323          :return: a future that resolves with the return value of the callable if the
 324              task completes successfully, or with the exception raised in the task
 325          :raises RuntimeError: if the portal is not running or if this method is called
 326              from within the event loop thread
 327          :rtype: concurrent.futures.Future[T_Retval]
 328  
 329          .. versionadded:: 3.0
 330  
 331          """
 332          self._check_running()
 333          f: Future[T_Retval] = Future()
 334          self._spawn_task_from_thread(func, args, {}, name, f)
 335          return f
 336  
 337      def start_task(
 338          self,
 339          func: Callable[..., Awaitable[T_Retval]],
 340          *args: object,
 341          name: object = None,
 342      ) -> tuple[Future[T_Retval], Any]:
 343          """
 344          Start a task in the portal's task group and wait until it signals for readiness.
 345  
 346          This method works the same way as :meth:`.abc.TaskGroup.start`.
 347  
 348          :param func: the target function
 349          :param args: positional arguments passed to ``func``
 350          :param name: name of the task (will be coerced to a string if not ``None``)
 351          :return: a tuple of (future, task_status_value) where the ``task_status_value``
 352              is the value passed to ``task_status.started()`` from within the target
 353              function
 354          :rtype: tuple[concurrent.futures.Future[T_Retval], Any]
 355  
 356          .. versionadded:: 3.0
 357  
 358          """
 359  
 360          def task_done(future: Future[T_Retval]) -> None:
 361              if not task_status_future.done():
 362                  if future.cancelled():
 363                      task_status_future.cancel()
 364                  elif future.exception():
 365                      task_status_future.set_exception(future.exception())
 366                  else:
 367                      exc = RuntimeError(
 368                          "Task exited without calling task_status.started()"
 369                      )
 370                      task_status_future.set_exception(exc)
 371  
 372          self._check_running()
 373          task_status_future: Future = Future()
 374          task_status = _BlockingPortalTaskStatus(task_status_future)
 375          f: Future = Future()
 376          f.add_done_callback(task_done)
 377          self._spawn_task_from_thread(func, args, {"task_status": task_status}, name, f)
 378          return f, task_status_future.result()
 379  
 380      def wrap_async_context_manager(
 381          self, cm: AbstractAsyncContextManager[T_co]
 382      ) -> AbstractContextManager[T_co]:
 383          """
 384          Wrap an async context manager as a synchronous context manager via this portal.
 385  
 386          Spawns a task that will call both ``__aenter__()`` and ``__aexit__()``, stopping
 387          in the middle until the synchronous context manager exits.
 388  
 389          :param cm: an asynchronous context manager
 390          :return: a synchronous context manager
 391  
 392          .. versionadded:: 2.1
 393  
 394          """
 395          return _BlockingAsyncContextManager(cm, self)
 396  
 397  
 398  @dataclass
 399  class BlockingPortalProvider:
 400      """
 401      A manager for a blocking portal. Used as a context manager. The first thread to
 402      enter this context manager causes a blocking portal to be started with the specific
 403      parameters, and the last thread to exit causes the portal to be shut down. Thus,
 404      there will be exactly one blocking portal running in this context as long as at
 405      least one thread has entered this context manager.
 406  
 407      The parameters are the same as for :func:`~anyio.run`.
 408  
 409      :param backend: name of the backend
 410      :param backend_options: backend options
 411  
 412      .. versionadded:: 4.4
 413      """
 414  
 415      backend: str = "asyncio"
 416      backend_options: dict[str, Any] | None = None
 417      _lock: Lock = field(init=False, default_factory=Lock)
 418      _leases: int = field(init=False, default=0)
 419      _portal: BlockingPortal = field(init=False)
 420      _portal_cm: AbstractContextManager[BlockingPortal] | None = field(
 421          init=False, default=None
 422      )
 423  
 424      def __enter__(self) -> BlockingPortal:
 425          with self._lock:
 426              if self._portal_cm is None:
 427                  self._portal_cm = start_blocking_portal(
 428                      self.backend, self.backend_options
 429                  )
 430                  self._portal = self._portal_cm.__enter__()
 431  
 432              self._leases += 1
 433              return self._portal
 434  
 435      def __exit__(
 436          self,
 437          exc_type: type[BaseException] | None,
 438          exc_val: BaseException | None,
 439          exc_tb: TracebackType | None,
 440      ) -> None:
 441          portal_cm: AbstractContextManager[BlockingPortal] | None = None
 442          with self._lock:
 443              assert self._portal_cm
 444              assert self._leases > 0
 445              self._leases -= 1
 446              if not self._leases:
 447                  portal_cm = self._portal_cm
 448                  self._portal_cm = None
 449                  del self._portal
 450  
 451          if portal_cm:
 452              portal_cm.__exit__(None, None, None)
 453  
 454  
 455  @contextmanager
 456  def start_blocking_portal(
 457      backend: str = "asyncio", backend_options: dict[str, Any] | None = None
 458  ) -> Generator[BlockingPortal, Any, None]:
 459      """
 460      Start a new event loop in a new thread and run a blocking portal in its main task.
 461  
 462      The parameters are the same as for :func:`~anyio.run`.
 463  
 464      :param backend: name of the backend
 465      :param backend_options: backend options
 466      :return: a context manager that yields a blocking portal
 467  
 468      .. versionchanged:: 3.0
 469          Usage as a context manager is now required.
 470  
 471      """
 472  
 473      async def run_portal() -> None:
 474          async with BlockingPortal() as portal_:
 475              future.set_result(portal_)
 476              await portal_.sleep_until_stopped()
 477  
 478      def run_blocking_portal() -> None:
 479          if future.set_running_or_notify_cancel():
 480              try:
 481                  _eventloop.run(
 482                      run_portal, backend=backend, backend_options=backend_options
 483                  )
 484              except BaseException as exc:
 485                  if not future.done():
 486                      future.set_exception(exc)
 487  
 488      future: Future[BlockingPortal] = Future()
 489      thread = Thread(target=run_blocking_portal, daemon=True)
 490      thread.start()
 491      try:
 492          cancel_remaining_tasks = False
 493          portal = future.result()
 494          try:
 495              yield portal
 496          except BaseException:
 497              cancel_remaining_tasks = True
 498              raise
 499          finally:
 500              try:
 501                  portal.call(portal.stop, cancel_remaining_tasks)
 502              except RuntimeError:
 503                  pass
 504      finally:
 505          thread.join()
 506  
 507  
 508  def check_cancelled() -> None:
 509      """
 510      Check if the cancel scope of the host task's running the current worker thread has
 511      been cancelled.
 512  
 513      If the host task's current cancel scope has indeed been cancelled, the
 514      backend-specific cancellation exception will be raised.
 515  
 516      :raises RuntimeError: if the current thread was not spawned by
 517          :func:`.to_thread.run_sync`
 518  
 519      """
 520      try:
 521          async_backend: AsyncBackend = threadlocals.current_async_backend
 522      except AttributeError:
 523          raise RuntimeError(
 524              "This function can only be run from an AnyIO worker thread"
 525          ) from None
 526  
 527      async_backend.check_cancelled()
 528