Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/filelock/_async.py: 31%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""Separate caller cancellation from backend task and executor-future results."""
3from __future__ import annotations
5import asyncio
6import contextlib
7import time
8from concurrent.futures import Future as ConcurrentFuture
9from dataclasses import dataclass
10from threading import Lock
11from typing import TYPE_CHECKING, Final, Generic, NoReturn, TypeVar, cast
13from ._api import _append_exception_context, _raise_chained_errors
15if TYPE_CHECKING:
16 from collections.abc import AsyncIterator, Awaitable, Callable
18_T = TypeVar("_T")
21class _AsyncTransitionUnavailableError(Exception):
22 pass
25@dataclass(frozen=True)
26class _BackendOutcome(Generic[_T]):
27 value: _T | None = None
28 error: BaseException | None = None
31class _AsyncTransitionGate:
32 def __init__(self) -> None:
33 self._tail_lock: Final[Lock] = Lock()
34 self._tail: ConcurrentFuture[None] | None = None
36 @contextlib.asynccontextmanager
37 async def hold(self) -> AsyncIterator[None]:
38 ticket: ConcurrentFuture[None] = ConcurrentFuture()
39 with self._tail_lock:
40 predecessor = self._tail
41 self._tail = ticket
42 if predecessor is not None:
43 try:
44 await _wait_until_done(asyncio.wrap_future(predecessor))
45 except asyncio.CancelledError:
46 predecessor.add_done_callback(lambda _predecessor: self._leave(ticket))
47 raise
48 try:
49 yield
50 finally:
51 self._leave(ticket)
53 @contextlib.asynccontextmanager
54 async def hold_for_acquire(
55 self,
56 *,
57 blocking: bool,
58 cancel_check: Callable[[], bool] | None,
59 deadline: float | None,
60 poll_interval: float,
61 ) -> AsyncIterator[None]:
62 ticket: ConcurrentFuture[None] = ConcurrentFuture()
63 with self._tail_lock:
64 predecessor = self._tail
65 self._tail = ticket
66 if predecessor is not None and not predecessor.done():
67 try:
68 await self._wait_for_predecessor(
69 predecessor,
70 blocking=blocking,
71 cancel_check=cancel_check,
72 deadline=deadline,
73 poll_interval=poll_interval,
74 )
75 except BaseException:
76 predecessor.add_done_callback(lambda _predecessor: self._leave(ticket))
77 raise
78 try:
79 yield
80 finally:
81 self._leave(ticket)
83 @staticmethod
84 async def _wait_for_predecessor(
85 predecessor: ConcurrentFuture[None],
86 *,
87 blocking: bool,
88 cancel_check: Callable[[], bool] | None,
89 deadline: float | None,
90 poll_interval: float,
91 ) -> None:
92 if not blocking:
93 raise _AsyncTransitionUnavailableError
94 waiter = asyncio.wrap_future(predecessor)
95 while not predecessor.done():
96 if cancel_check is not None and cancel_check():
97 raise _AsyncTransitionUnavailableError
98 if deadline is not None:
99 if (remaining := deadline - time.perf_counter()) <= 0:
100 raise _AsyncTransitionUnavailableError
101 wait_interval = min(poll_interval, remaining) if cancel_check is not None else remaining
102 else:
103 wait_interval = poll_interval if cancel_check is not None else None
104 await asyncio.wait((waiter,), timeout=wait_interval)
106 def _leave(self, ticket: ConcurrentFuture[None]) -> None:
107 with self._tail_lock:
108 if self._tail is ticket:
109 self._tail = None
110 ticket.set_result(None)
113async def _drain_future(future: asyncio.Future[_BackendOutcome[_T]]) -> _T:
114 while not future.done():
115 with contextlib.suppress(asyncio.CancelledError):
116 await _wait_until_done(future)
117 return _future_result(future)
120async def _wait_until_done(future: asyncio.Future[_T]) -> None:
121 if not future.done():
122 await asyncio.wait((future,))
125def _future_result(future: asyncio.Future[_BackendOutcome[_T]]) -> _T:
126 outcome = future.result()
127 if (error := outcome.error) is None:
128 return cast("_T", outcome.value)
129 context = error.__context__
130 try:
131 raise error # ruff:ignore[raise-within-try] # the handler restores context changed across the async boundary
132 except BaseException:
133 error.__context__ = context
134 raise
137def _capture_call(func: Callable[[], _T]) -> _BackendOutcome[_T]:
138 try:
139 return _BackendOutcome(value=func())
140 except BaseException as error: # ruff:ignore[blind-except] # backend control-flow exceptions are operation results
141 return _BackendOutcome(error=error)
144def _raise_cancelled_error(cancellation: asyncio.CancelledError, error: BaseException) -> NoReturn:
145 # A reconciliation step failed while unwinding a cancellation, so keep both exception chains. Splice the error's
146 # existing context onto the cancellation, then make the cancellation the error's context, so both the failure and
147 # the cancellation that triggered it survive. Shared by the async wrappers so cancellations report the same way.
148 if (context := error.__context__) is not None and context is not cancellation:
149 if (cancellation_context := cancellation.__context__) is not None:
150 _append_exception_context(context, cancellation_context)
151 cancellation.__context__ = context
152 error.__context__ = cancellation
153 _raise_chained_errors(error)
156async def _capture_awaitable(awaitable: Awaitable[_T]) -> _BackendOutcome[_T]:
157 try:
158 return _BackendOutcome(value=await awaitable)
159 except BaseException as error: # ruff:ignore[blind-except] # backend cancellation must remain distinct from caller cancellation
160 return _BackendOutcome(error=error)
163__all__ = [
164 "_AsyncTransitionGate",
165 "_AsyncTransitionUnavailableError",
166 "_BackendOutcome",
167 "_capture_awaitable",
168 "_capture_call",
169 "_drain_future",
170 "_future_result",
171 "_raise_cancelled_error",
172 "_wait_until_done",
173]