python-wrap 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,431 @@
1
+ """Bulkheads: cap how many calls run at once, and how many may queue for a slot.
2
+
3
+ * `Bulkhead` — a reusable limit, used as a decorator, `with bulkhead:` or
4
+ `async with bulkhead:`.
5
+ * `limit_concurrency` — decorator giving each function its own `Bulkhead`.
6
+
7
+ A bulkhead protects something with limited capacity (a database pool, a slow API, a
8
+ CPU-heavy section) and fails fast with `BulkheadFull` instead of letting an unbounded
9
+ queue build up.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import functools
16
+ import threading
17
+ import weakref
18
+ from collections.abc import Callable
19
+ from types import TracebackType
20
+ from typing import Any, Concatenate, NoReturn, ParamSpec, Protocol, TypeVar, cast, overload
21
+
22
+ from python_wrap import _core
23
+ from python_wrap._core import F, P, R
24
+ from python_wrap.aio import _cancel_and_settle
25
+ from python_wrap.errors import BulkheadFull
26
+
27
+ __all__ = ["Bulkhead", "Limited", "limit_concurrency"]
28
+
29
+ R_co = TypeVar("R_co", covariant=True)
30
+ T = TypeVar("T")
31
+ P2 = ParamSpec("P2")
32
+
33
+
34
+ class _Pool:
35
+ """Counters for one semaphore (the threads' one, or one event loop's)."""
36
+
37
+ __slots__ = ("active", "waiting")
38
+
39
+ def __init__(self) -> None:
40
+ self.active = 0
41
+ self.waiting = 0
42
+
43
+
44
+ class _AsyncPool(_Pool):
45
+ __slots__ = ("semaphore",)
46
+
47
+ def __init__(self, size: int) -> None:
48
+ super().__init__()
49
+ self.semaphore = asyncio.Semaphore(size)
50
+
51
+
52
+ async def _acquire_within(semaphore: asyncio.Semaphore, seconds: float) -> bool:
53
+ """Acquire ``semaphore`` unless ``seconds`` pass first (real time).
54
+
55
+ The limit is a deadline, not a backoff wait, so it uses `asyncio.sleep` directly:
56
+ `fast_mode()` and `fake_clock()` do not shorten it, matching the sync side.
57
+
58
+ Never leaks a permit: if the acquisition wins at the last moment while we time out or
59
+ are cancelled, the permit is either kept (timeout) or released (cancellation).
60
+ """
61
+ acquire = asyncio.ensure_future(semaphore.acquire())
62
+ timer = asyncio.ensure_future(asyncio.sleep(seconds))
63
+ try:
64
+ await asyncio.wait((acquire, timer), return_when=asyncio.FIRST_COMPLETED)
65
+ except BaseException:
66
+ await _cancel_and_settle((acquire, timer))
67
+ if _won(acquire):
68
+ semaphore.release()
69
+ raise
70
+ if not acquire.done():
71
+ acquire.cancel()
72
+ await _cancel_and_settle((timer, acquire))
73
+ return _won(acquire)
74
+
75
+
76
+ def _won(acquire: asyncio.Future[Any]) -> bool:
77
+ return acquire.done() and not acquire.cancelled() and acquire.exception() is None
78
+
79
+
80
+ class Bulkhead:
81
+ """Allow at most `max_concurrent` calls at once; reject when too many are waiting.
82
+
83
+ Use one instance to guard several functions or code blocks together:
84
+
85
+ * as a decorator, on sync or `async def` functions (and methods);
86
+ * as `with bulkhead:` in threaded code;
87
+ * as `async with bulkhead:` in async code.
88
+
89
+ Sync callers share a `threading.BoundedSemaphore`. Async callers use an
90
+ `asyncio.Semaphore` per event loop (asyncio primitives cannot be shared across
91
+ loops). So the limit applies separately to threads and to each running loop; use
92
+ one bulkhead per side if you mix them. Never use `with bulkhead:` inside a coroutine:
93
+ waiting for a slot would block the event loop.
94
+
95
+ A caller that finds no free slot queues for one, unless `max_waiting` callers are
96
+ already queued (then it gets `BulkheadFull` immediately). A queued caller gives up
97
+ with `BulkheadFull` after `timeout` seconds. Each rejection emits the
98
+ `bulkhead.rejected` event with the `active` and `waiting` counts at that moment.
99
+
100
+ Args:
101
+ max_concurrent: Maximum number of calls running at once (>= 1).
102
+ max_waiting: Maximum number of callers queued for a slot; `None` means no
103
+ limit, `0` means never wait (fail fast when full).
104
+ timeout: Maximum seconds to wait for a slot; `None` waits forever.
105
+ name: Label used in error messages and `repr`.
106
+
107
+ Raises:
108
+ ValueError: An argument is out of range.
109
+
110
+ Example:
111
+ ```python
112
+ import asyncio
113
+ from python_wrap.bulkhead import Bulkhead
114
+ from python_wrap.errors import BulkheadFull
115
+
116
+ db = Bulkhead(2, max_waiting=0, name="db")
117
+
118
+ @db
119
+ async def query(n: int) -> int:
120
+ await asyncio.sleep(0.01)
121
+ return n
122
+
123
+ async def main() -> None:
124
+ results = await asyncio.gather(*(query(n) for n in range(3)),
125
+ return_exceptions=True)
126
+ assert results[:2] == [0, 1]
127
+ assert isinstance(results[2], BulkheadFull) # two running, none may wait
128
+
129
+ asyncio.run(main())
130
+ ```
131
+ """
132
+
133
+ def __reduce__(self) -> NoReturn:
134
+ _core.refuse_pickle(self)
135
+
136
+ def __init__(
137
+ self,
138
+ max_concurrent: int,
139
+ *,
140
+ max_waiting: int | None = None,
141
+ timeout: float | None = None,
142
+ name: str | None = None,
143
+ ) -> None:
144
+ if isinstance(max_concurrent, bool) or not isinstance(max_concurrent, int):
145
+ raise ValueError(f"max_concurrent must be an integer, got {max_concurrent!r}")
146
+ if max_concurrent < 1:
147
+ raise ValueError(f"max_concurrent must be >= 1, got {max_concurrent}")
148
+ if max_waiting is not None and max_waiting < 0:
149
+ raise ValueError(f"max_waiting must be >= 0 or None, got {max_waiting}")
150
+ if timeout is not None and timeout < 0:
151
+ raise ValueError(f"timeout must be >= 0 or None, got {timeout}")
152
+ self.max_concurrent = max_concurrent
153
+ self.max_waiting = max_waiting
154
+ self.timeout = timeout
155
+ self.name = name
156
+ self._lock = threading.Lock()
157
+ self._thread_semaphore = threading.BoundedSemaphore(max_concurrent)
158
+ self._thread_pool = _Pool()
159
+ self._loop_pools: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, _AsyncPool] = (
160
+ weakref.WeakKeyDictionary()
161
+ )
162
+
163
+ # -- introspection ------------------------------------------------------------------
164
+
165
+ def _pools(self) -> list[_Pool]:
166
+ return [self._thread_pool, *self._loop_pools.values()]
167
+
168
+ @property
169
+ def active(self) -> int:
170
+ """Number of calls currently holding a slot (threads and every loop together)."""
171
+ with self._lock:
172
+ return sum(pool.active for pool in self._pools())
173
+
174
+ @property
175
+ def waiting(self) -> int:
176
+ """Number of callers currently queued for a slot (threads and every loop)."""
177
+ with self._lock:
178
+ return sum(pool.waiting for pool in self._pools())
179
+
180
+ def __repr__(self) -> str:
181
+ return (
182
+ f"Bulkhead(name={self.name!r}, max_concurrent={self.max_concurrent}, "
183
+ f"active={self.active}, waiting={self.waiting})"
184
+ )
185
+
186
+ # -- bookkeeping --------------------------------------------------------------------
187
+
188
+ def _full(self, why: str, active: int, waiting: int) -> BulkheadFull:
189
+ """Build the rejection and emit ``bulkhead.rejected`` (call without the lock)."""
190
+ _core.emit("bulkhead.rejected", self.name or "bulkhead", active=active, waiting=waiting)
191
+ label = f"bulkhead {self.name!r}" if self.name else "bulkhead"
192
+ return BulkheadFull(
193
+ f"{label} is full: {why}", name=self.name, max_concurrent=self.max_concurrent
194
+ )
195
+
196
+ def _start_waiting(self, pool: _Pool) -> None:
197
+ with self._lock:
198
+ active, waiting = pool.active, pool.waiting
199
+ full = self.max_waiting is not None and waiting >= self.max_waiting
200
+ if not full:
201
+ pool.waiting += 1
202
+ if full:
203
+ raise self._full(
204
+ f"{self.max_concurrent} calls running and "
205
+ f"{waiting} waiting (max_waiting={self.max_waiting})",
206
+ active,
207
+ waiting,
208
+ )
209
+
210
+ def _stop_waiting(self, pool: _Pool) -> None:
211
+ with self._lock:
212
+ pool.waiting -= 1
213
+
214
+ def _enter(self, pool: _Pool) -> None:
215
+ with self._lock:
216
+ pool.active += 1
217
+
218
+ def _exit(self, pool: _Pool) -> None:
219
+ with self._lock:
220
+ pool.active -= 1
221
+
222
+ def _timed_out(self, pool: _Pool) -> BulkheadFull:
223
+ with self._lock:
224
+ active, waiting = pool.active, pool.waiting
225
+ return self._full(f"no slot freed up within {self.timeout}s", active, waiting)
226
+
227
+ # -- sync ---------------------------------------------------------------------------
228
+
229
+ def acquire(self) -> None:
230
+ """Take a slot, blocking the thread while waiting. Prefer `with bulkhead:`.
231
+
232
+ Raises:
233
+ BulkheadFull: The queue is full, or `timeout` expired.
234
+ """
235
+ pool = self._thread_pool
236
+ semaphore = self._thread_semaphore
237
+ if not semaphore.acquire(blocking=False):
238
+ self._start_waiting(pool)
239
+ try:
240
+ if self.timeout is None:
241
+ got = semaphore.acquire()
242
+ else:
243
+ got = semaphore.acquire(timeout=self.timeout)
244
+ finally:
245
+ self._stop_waiting(pool)
246
+ if not got:
247
+ raise self._timed_out(pool)
248
+ self._enter(pool)
249
+
250
+ def release(self) -> None:
251
+ """Give back a slot taken with `acquire`."""
252
+ # Decrement `active` before freeing the permit, so a caller that takes the slot
253
+ # right away never sees more than `max_concurrent` active.
254
+ pool = self._thread_pool
255
+ self._exit(pool)
256
+ try:
257
+ self._thread_semaphore.release() # ValueError if nothing was acquired
258
+ except ValueError:
259
+ self._enter(pool)
260
+ raise
261
+
262
+ def __enter__(self) -> Bulkhead:
263
+ self.acquire()
264
+ return self
265
+
266
+ def __exit__(
267
+ self,
268
+ exc_type: type[BaseException] | None,
269
+ exc: BaseException | None,
270
+ tb: TracebackType | None,
271
+ ) -> None:
272
+ self.release()
273
+
274
+ # -- async --------------------------------------------------------------------------
275
+
276
+ def _loop_pool(self) -> _AsyncPool:
277
+ loop = asyncio.get_running_loop()
278
+ with self._lock:
279
+ pool = self._loop_pools.get(loop)
280
+ if pool is None:
281
+ pool = self._loop_pools[loop] = _AsyncPool(self.max_concurrent)
282
+ return pool
283
+
284
+ async def acquire_async(self) -> None:
285
+ """Take a slot in the running event loop. Prefer `async with bulkhead:`.
286
+
287
+ Raises:
288
+ BulkheadFull: The queue is full, or `timeout` expired.
289
+ """
290
+ pool = self._loop_pool()
291
+ semaphore = pool.semaphore
292
+ if semaphore.locked():
293
+ self._start_waiting(pool)
294
+ try:
295
+ if self.timeout is None:
296
+ await semaphore.acquire()
297
+ elif not await _acquire_within(semaphore, self.timeout):
298
+ raise self._timed_out(pool)
299
+ finally:
300
+ self._stop_waiting(pool)
301
+ else:
302
+ await semaphore.acquire() # a slot is free: returns without suspending
303
+ self._enter(pool)
304
+
305
+ def release_async(self) -> None:
306
+ """Give back a slot taken with `acquire_async` (from the same event loop)."""
307
+ pool = self._loop_pool()
308
+ self._exit(pool)
309
+ pool.semaphore.release()
310
+
311
+ async def __aenter__(self) -> Bulkhead:
312
+ await self.acquire_async()
313
+ return self
314
+
315
+ async def __aexit__(
316
+ self,
317
+ exc_type: type[BaseException] | None,
318
+ exc: BaseException | None,
319
+ tb: TracebackType | None,
320
+ ) -> None:
321
+ self.release_async()
322
+
323
+ # -- decorator ----------------------------------------------------------------------
324
+
325
+ def __call__(self, func: F, /) -> F:
326
+ """Guard every call of `func` with this bulkhead (sync or `async def`)."""
327
+ if _core.is_async(func):
328
+
329
+ @functools.wraps(func)
330
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
331
+ async with self:
332
+ return await func(*args, **kwargs)
333
+
334
+ return cast(F, async_wrapper)
335
+
336
+ @functools.wraps(func)
337
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
338
+ with self:
339
+ return func(*args, **kwargs)
340
+
341
+ return cast(F, wrapper)
342
+
343
+
344
+ class Limited(Protocol[P, R_co]):
345
+ """A function wrapped by `limit_concurrency`; `.bulkhead` is its own `Bulkhead`.
346
+
347
+ Binds `self` and `cls` (including under a `@classmethod` stacked above) like
348
+ `python_wrap.caching.Memoized`, with the same typing limitations.
349
+ """
350
+
351
+ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: ...
352
+
353
+ @overload
354
+ def __get__(
355
+ self: Limited[Concatenate[type[T], P2], R_co], instance: None, owner: type[T]
356
+ ) -> _LimitedOnClass[P, P2, R_co]: ...
357
+ @overload
358
+ def __get__(self, instance: None, owner: type | None = None) -> Limited[P, R_co]: ...
359
+ @overload
360
+ def __get__(
361
+ self: Limited[Concatenate[Any, P2], R_co], instance: object, owner: type | None = None
362
+ ) -> Limited[P2, R_co]: ...
363
+
364
+ @property
365
+ def bulkhead(self) -> Bulkhead:
366
+ """The function's own `Bulkhead` (inspect `.active` and `.waiting`)."""
367
+ ...
368
+
369
+
370
+ class _LimitedOnClass(Limited[P, R_co], Protocol[P, P2, R_co]):
371
+ """`Limited` read from a class: accepts the call with and without the first argument.
372
+
373
+ Returned for class access (`Cls.attr`) when the first parameter could be `cls`.
374
+ pyright only picks it under `@classmethod`; mypy cannot tell a classmethod from an
375
+ instance or static method there, so both call forms are accepted.
376
+ """
377
+
378
+ @overload
379
+ def __call__(self, *args: P2.args, **kwargs: P2.kwargs) -> R_co: ...
380
+ @overload
381
+ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: ...
382
+
383
+
384
+ def limit_concurrency(n: int, /) -> Callable[[Callable[P, R]], Limited[P, R]]:
385
+ """Let at most `n` calls of the decorated function run at once; the rest wait.
386
+
387
+ Each decorated function gets its own `Bulkhead(n)` (no queue limit, no timeout),
388
+ exposed as `wrapped.bulkhead` for introspection (`.active`, `.waiting`). For a limit
389
+ shared by several functions, or for `max_waiting`/`timeout`, use a `Bulkhead`
390
+ directly. Works on sync and `async def` functions and on methods (the limit is then
391
+ shared by every instance).
392
+
393
+ Args:
394
+ n: Maximum number of concurrent calls (>= 1).
395
+
396
+ Returns:
397
+ A decorator.
398
+
399
+ Raises:
400
+ ValueError: `n` is not a positive integer.
401
+
402
+ Example:
403
+ ```python
404
+ import asyncio
405
+ from python_wrap.bulkhead import limit_concurrency
406
+
407
+ peak = 0
408
+
409
+ @limit_concurrency(2)
410
+ async def crawl(page: int) -> int:
411
+ global peak
412
+ peak = max(peak, crawl.bulkhead.active)
413
+ await asyncio.sleep(0)
414
+ return page
415
+
416
+ async def main() -> None:
417
+ assert await asyncio.gather(*(crawl(p) for p in range(6))) == list(range(6))
418
+ assert peak == 2
419
+
420
+ asyncio.run(main())
421
+ ```
422
+ """
423
+ Bulkhead(n) # validate eagerly, at decoration time
424
+
425
+ def decorate(func: Callable[P, R]) -> Limited[P, R]:
426
+ bulkhead = Bulkhead(n, name=_core.qualname(func))
427
+ wrapped = cast(Any, bulkhead(func))
428
+ wrapped.bulkhead = bulkhead
429
+ return cast(Limited[P, R], wrapped)
430
+
431
+ return decorate