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.
- python_wrap/__init__.py +272 -0
- python_wrap/_core.py +212 -0
- python_wrap/aio.py +620 -0
- python_wrap/batching.py +556 -0
- python_wrap/bulkhead.py +431 -0
- python_wrap/caching.py +1613 -0
- python_wrap/circuit.py +553 -0
- python_wrap/concurrency.py +1886 -0
- python_wrap/config.py +217 -0
- python_wrap/deadline.py +168 -0
- python_wrap/decorators.py +7 -0
- python_wrap/devtools.py +385 -0
- python_wrap/errors.py +86 -0
- python_wrap/events.py +161 -0
- python_wrap/hedging.py +192 -0
- python_wrap/logs.py +767 -0
- python_wrap/metrics.py +242 -0
- python_wrap/periodic.py +491 -0
- python_wrap/policy.py +263 -0
- python_wrap/py.typed +0 -0
- python_wrap/retry.py +1324 -0
- python_wrap/state.py +500 -0
- python_wrap/testing.py +662 -0
- python_wrap/timing.py +535 -0
- python_wrap/validation.py +662 -0
- python_wrap-0.2.1.dist-info/METADATA +485 -0
- python_wrap-0.2.1.dist-info/RECORD +29 -0
- python_wrap-0.2.1.dist-info/WHEEL +4 -0
- python_wrap-0.2.1.dist-info/licenses/LICENSE +21 -0
python_wrap/bulkhead.py
ADDED
|
@@ -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
|