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,556 @@
1
+ """Coalesce many single-item calls into one batched call: `batch_calls`.
2
+
3
+ This is the DataLoader pattern. You write a function that takes a *list* of items and
4
+ returns their results; `batch_calls` turns it into a function that takes *one* item. Calls
5
+ that arrive close together are collected and sent to your function as a single batch.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import concurrent.futures
12
+ import contextlib
13
+ import functools
14
+ import threading
15
+ import warnings
16
+ import weakref
17
+ from collections.abc import Awaitable, Callable, Coroutine, Hashable, Mapping, Sequence
18
+ from typing import Any, Concatenate, ParamSpec, Protocol, TypeGuard, TypeVar, cast, overload
19
+
20
+ from python_wrap import _core
21
+ from python_wrap.errors import BatchError
22
+
23
+ __all__ = ["AsyncBatched", "Batched", "batch_calls"]
24
+
25
+ P = ParamSpec("P")
26
+ P2 = ParamSpec("P2")
27
+ T = TypeVar("T")
28
+ R = TypeVar("R")
29
+ S = TypeVar("S")
30
+ _O = TypeVar("_O")
31
+ R_co = TypeVar("R_co", covariant=True)
32
+
33
+ _GroupKey = tuple[int, ...]
34
+
35
+
36
+ class Batched(Protocol[P, R_co]):
37
+ """A sync function wrapped by `batch_calls`: call it with one item, get its result."""
38
+
39
+ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: ...
40
+
41
+ @overload
42
+ def __get__(
43
+ self: Batched[Concatenate[type[_O], P2], R_co], instance: None, owner: type[_O]
44
+ ) -> Batched[P2, R_co]: ... # under @classmethod: bind the class
45
+ @overload
46
+ def __get__(self, instance: None, owner: type | None = None) -> Batched[P, R_co]: ...
47
+ @overload
48
+ def __get__(
49
+ self: Batched[Concatenate[type[_O], P2], R_co], instance: _O, owner: type | None = None
50
+ ) -> Batched[P2, R_co]: ...
51
+ @overload
52
+ def __get__(
53
+ self: Batched[Concatenate[_O, P2], R_co], instance: _O, owner: type | None = None
54
+ ) -> Batched[P2, R_co]: ...
55
+ @overload
56
+ def __get__(self, instance: object, owner: type | None = None) -> Batched[P, R_co]: ...
57
+
58
+ @property
59
+ def pending(self) -> int:
60
+ """Number of distinct items queued and not yet sent to the batch function."""
61
+ ...
62
+
63
+ def flush(self) -> None:
64
+ """Send every queued batch now, in the calling thread, and wait for it."""
65
+ ...
66
+
67
+
68
+ class AsyncBatched(Protocol[P, R_co]):
69
+ """An `async def` function wrapped by `batch_calls`: `await loader(item)`."""
70
+
71
+ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Coroutine[Any, Any, R_co]: ...
72
+
73
+ @overload
74
+ def __get__(
75
+ self: AsyncBatched[Concatenate[type[_O], P2], R_co], instance: None, owner: type[_O]
76
+ ) -> AsyncBatched[P2, R_co]: ... # under @classmethod: bind the class
77
+ @overload
78
+ def __get__(self, instance: None, owner: type | None = None) -> AsyncBatched[P, R_co]: ...
79
+ @overload
80
+ def __get__(
81
+ self: AsyncBatched[Concatenate[type[_O], P2], R_co], instance: _O, owner: type | None = None
82
+ ) -> AsyncBatched[P2, R_co]: ...
83
+ @overload
84
+ def __get__(
85
+ self: AsyncBatched[Concatenate[_O, P2], R_co], instance: _O, owner: type | None = None
86
+ ) -> AsyncBatched[P2, R_co]: ...
87
+ @overload
88
+ def __get__(self, instance: object, owner: type | None = None) -> AsyncBatched[P, R_co]: ...
89
+
90
+ @property
91
+ def pending(self) -> int:
92
+ """Number of distinct items queued and not yet sent to the batch function."""
93
+ ...
94
+
95
+ def aflush(self) -> Coroutine[Any, Any, None]:
96
+ """Send every batch queued on the running loop now and wait for them to finish."""
97
+ ...
98
+
99
+
100
+ class _BatchDecorator(Protocol):
101
+ @overload
102
+ def __call__(
103
+ self, func: Callable[[list[T]], Awaitable[Sequence[R] | Mapping[Any, R]]], /
104
+ ) -> AsyncBatched[[T], R]: ...
105
+ @overload
106
+ def __call__(
107
+ self, func: Callable[[S, list[T]], Awaitable[Sequence[R] | Mapping[Any, R]]], /
108
+ ) -> AsyncBatched[[S, T], R]: ...
109
+ @overload
110
+ def __call__(
111
+ self, func: Callable[[list[T]], Sequence[R] | Mapping[Any, R]], /
112
+ ) -> Batched[[T], R]: ...
113
+ @overload
114
+ def __call__(
115
+ self, func: Callable[[S, list[T]], Sequence[R] | Mapping[Any, R]], /
116
+ ) -> Batched[[S, T], R]: ...
117
+
118
+
119
+ # --------------------------------------------------------------------------- shared
120
+
121
+
122
+ def _is_mapping(value: object) -> TypeGuard[Mapping[Any, Any]]:
123
+ return isinstance(value, Mapping)
124
+
125
+
126
+ def _is_sequence(value: object) -> TypeGuard[Sequence[Any]]:
127
+ return isinstance(value, Sequence)
128
+
129
+
130
+ def _split(name: str, result: object, keys: list[Hashable]) -> list[tuple[bool, Any]]:
131
+ """Match a batch result to its keys: ``(True, value)`` or ``(False, BatchError)``."""
132
+ if _is_mapping(result):
133
+ mapping = result
134
+ return [
135
+ (True, mapping[k])
136
+ if k in mapping
137
+ else (False, BatchError(f"{name}: the batch result has no entry for key {k!r}"))
138
+ for k in keys
139
+ ]
140
+ if isinstance(result, (str, bytes, bytearray)) or not _is_sequence(result):
141
+ message = (
142
+ f"{name}: the batch function must return a sequence or a mapping, "
143
+ f"got {type(result).__name__}"
144
+ )
145
+ return [(False, BatchError(message)) for _ in keys]
146
+ values = result
147
+ if len(values) != len(keys):
148
+ message = f"{name}: the batch function returned {len(values)} results for {len(keys)} items"
149
+ return [(False, BatchError(message)) for _ in keys]
150
+ return [(True, v) for v in values]
151
+
152
+
153
+ def _split_args(name: str, args: tuple[Any, ...]) -> tuple[tuple[Any, ...], Any]:
154
+ if not args:
155
+ raise TypeError(f"{name}() takes one item to load")
156
+ return args[:-1], args[-1]
157
+
158
+
159
+ class _Counter:
160
+ """Thread-safe count of queued items, mirrored onto ``wrapper.pending``."""
161
+
162
+ def __init__(self) -> None:
163
+ self.lock = threading.Lock()
164
+ self.value = 0
165
+ self.wrapper: Any = None
166
+
167
+ def add(self, n: int) -> None:
168
+ with self.lock:
169
+ self.value += n
170
+ self.wrapper.pending = self.value
171
+
172
+
173
+ # --------------------------------------------------------------------------- sync
174
+
175
+
176
+ class _SyncEntry:
177
+ __slots__ = ("future", "item")
178
+
179
+ def __init__(self, item: Any) -> None:
180
+ self.item = item
181
+ self.future: concurrent.futures.Future[Any] = concurrent.futures.Future()
182
+
183
+
184
+ class _SyncBatch:
185
+ __slots__ = ("entries", "group", "prefix")
186
+
187
+ def __init__(self, group: _GroupKey, prefix: tuple[Any, ...]) -> None:
188
+ self.group = group
189
+ self.prefix = prefix
190
+ self.entries: dict[Hashable, _SyncEntry] = {}
191
+
192
+
193
+ def _batch_sync(
194
+ func: Callable[..., Any],
195
+ max_size: int,
196
+ max_wait: float,
197
+ key_of: Callable[[Any], Hashable],
198
+ ) -> Callable[..., Any]:
199
+ name = _core.qualname(func)
200
+ lock = threading.Lock()
201
+ open_batches: dict[_GroupKey, _SyncBatch] = {}
202
+ counter = _Counter()
203
+ warned = False
204
+
205
+ def warn_if_in_event_loop() -> None:
206
+ nonlocal warned
207
+ if asyncio._get_running_loop() is None:
208
+ return
209
+ warned = True
210
+ warnings.warn(
211
+ f"{name} is a sync batch_calls loader called from a running event loop: it "
212
+ "blocks the loop while it waits for its batch (up to max_wait) and returns a "
213
+ "plain value, not a coroutine, so it cannot be awaited or passed to "
214
+ "asyncio.create_task(); decorate an `async def` batch function instead",
215
+ RuntimeWarning,
216
+ stacklevel=3,
217
+ )
218
+
219
+ def detach(batch: _SyncBatch) -> bool:
220
+ """Close ``batch`` to new items. Call with ``lock`` held. False if already closed."""
221
+ if open_batches.get(batch.group) is not batch:
222
+ return False
223
+ del open_batches[batch.group]
224
+ counter.add(-len(batch.entries))
225
+ return True
226
+
227
+ def run(batch: _SyncBatch) -> None:
228
+ keys = list(batch.entries)
229
+ entries = list(batch.entries.values())
230
+ try:
231
+ result = func(*batch.prefix, [e.item for e in entries])
232
+ outcomes = _split(name, result, keys)
233
+ except BaseException as exc:
234
+ for e in entries:
235
+ e.future.set_exception(exc)
236
+ if not isinstance(exc, Exception):
237
+ raise
238
+ return
239
+ for e, (ok, value) in zip(entries, outcomes, strict=True):
240
+ if ok:
241
+ e.future.set_result(value)
242
+ else:
243
+ e.future.set_exception(value)
244
+
245
+ @functools.wraps(func)
246
+ def wrapper(*args: Any) -> Any:
247
+ if not warned:
248
+ warn_if_in_event_loop()
249
+ prefix, item = _split_args(name, args)
250
+ k = key_of(item)
251
+ group = tuple(map(id, prefix))
252
+ with lock:
253
+ batch = open_batches.get(group)
254
+ leader = batch is None
255
+ if batch is None:
256
+ batch = open_batches[group] = _SyncBatch(group, prefix)
257
+ entry = batch.entries.get(k)
258
+ if entry is None:
259
+ entry = batch.entries[k] = _SyncEntry(item)
260
+ counter.add(1)
261
+ future = entry.future
262
+ full = len(batch.entries) >= max_size and detach(batch)
263
+ if full:
264
+ run(batch)
265
+ elif leader:
266
+ # The first caller of a window keeps time for it: it waits up to max_wait for
267
+ # its result (another thread may fill the batch and run it meanwhile), then
268
+ # runs the batch itself. No timer thread is involved.
269
+ done, _ = concurrent.futures.wait([future], timeout=max_wait)
270
+ if not done:
271
+ with lock:
272
+ mine = detach(batch)
273
+ if mine:
274
+ run(batch)
275
+ return future.result()
276
+
277
+ def flush() -> None:
278
+ with lock:
279
+ batches = list(open_batches.values())
280
+ for batch in batches:
281
+ detach(batch)
282
+ for batch in batches:
283
+ run(batch)
284
+
285
+ counter.wrapper = wrapper
286
+ wrapper.pending = 0 # type: ignore[attr-defined]
287
+ wrapper.flush = flush # type: ignore[attr-defined]
288
+ return wrapper
289
+
290
+
291
+ # --------------------------------------------------------------------------- async
292
+
293
+
294
+ class _AsyncEntry:
295
+ __slots__ = ("future", "item", "waiters")
296
+
297
+ def __init__(self, item: Any, loop: asyncio.AbstractEventLoop) -> None:
298
+ self.item = item
299
+ self.future: asyncio.Future[Any] = loop.create_future()
300
+ self.waiters = 0
301
+
302
+
303
+ class _AsyncBatch:
304
+ __slots__ = ("entries", "group", "prefix", "timer")
305
+
306
+ def __init__(self, group: _GroupKey, prefix: tuple[Any, ...]) -> None:
307
+ self.group = group
308
+ self.prefix = prefix
309
+ self.entries: dict[Hashable, _AsyncEntry] = {}
310
+ self.timer: asyncio.Task[None] | None = None
311
+
312
+
313
+ class _LoopState:
314
+ """Per-event-loop bookkeeping: open batches and batches being run."""
315
+
316
+ def __init__(self) -> None:
317
+ self.open: dict[_GroupKey, _AsyncBatch] = {}
318
+ self.running: set[asyncio.Future[None]] = set()
319
+
320
+
321
+ def _consume(task: asyncio.Task[Any]) -> None:
322
+ if not task.cancelled():
323
+ task.exception()
324
+
325
+
326
+ def _batch_async(
327
+ func: Callable[..., Awaitable[Any]],
328
+ max_size: int,
329
+ max_wait: float,
330
+ key_of: Callable[[Any], Hashable],
331
+ ) -> Callable[..., Any]:
332
+ name = _core.qualname(func)
333
+ loops: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, _LoopState] = (
334
+ weakref.WeakKeyDictionary()
335
+ )
336
+ counter = _Counter()
337
+
338
+ async def run(batch: _AsyncBatch) -> None:
339
+ keys = list(batch.entries)
340
+ entries = list(batch.entries.values())
341
+ try:
342
+ result = await func(*batch.prefix, [e.item for e in entries])
343
+ outcomes = _split(name, result, keys)
344
+ except asyncio.CancelledError:
345
+ for e in entries:
346
+ e.future.cancel()
347
+ raise
348
+ except BaseException as exc:
349
+ for e in entries:
350
+ e.future.set_exception(exc)
351
+ e.future.exception() # mark retrieved: every waiter may be gone
352
+ if not isinstance(exc, Exception):
353
+ raise
354
+ return
355
+ # The entries' futures are still pending: callers that cancel only detach.
356
+ for e, (ok, value) in zip(entries, outcomes, strict=True):
357
+ if ok:
358
+ e.future.set_result(value)
359
+ else:
360
+ e.future.set_exception(value)
361
+ e.future.exception()
362
+
363
+ def start(state: _LoopState, batch: _AsyncBatch) -> None:
364
+ """Close ``batch`` and run it in its own task (shared by all its callers)."""
365
+ if state.open.get(batch.group) is not batch: # pragma: no cover - defensive
366
+ return
367
+ del state.open[batch.group]
368
+ counter.add(-len(batch.entries))
369
+ timer, batch.timer = batch.timer, None
370
+ if timer is not None and timer is not asyncio.current_task():
371
+ timer.cancel()
372
+ task = asyncio.get_running_loop().create_task(run(batch))
373
+ state.running.add(task)
374
+ task.add_done_callback(state.running.discard)
375
+ task.add_done_callback(_consume)
376
+
377
+ async def run_inline(state: _LoopState, batch: _AsyncBatch, entry: _AsyncEntry) -> Any:
378
+ """Close ``batch`` and run it in the calling task; return ``entry``'s result."""
379
+ del state.open[batch.group]
380
+ counter.add(-len(batch.entries))
381
+ # Stands in for a batch task, so `aflush()` waits for this run too.
382
+ running = asyncio.get_running_loop().create_future()
383
+ state.running.add(running)
384
+ try:
385
+ with contextlib.suppress(Exception): # delivered through entry.future
386
+ await run(batch)
387
+ finally:
388
+ state.running.discard(running)
389
+ running.set_result(None)
390
+ return entry.future.result()
391
+
392
+ async def wait_then_start(state: _LoopState, batch: _AsyncBatch) -> None:
393
+ await asyncio.sleep(max_wait) # a window, not a backoff: real time
394
+ start(state, batch)
395
+
396
+ @functools.wraps(func)
397
+ async def wrapper(*args: Any) -> Any:
398
+ prefix, item = _split_args(name, args)
399
+ k = key_of(item)
400
+ group = tuple(map(id, prefix))
401
+ loop = asyncio.get_running_loop()
402
+ state = loops.get(loop)
403
+ if state is None:
404
+ state = loops[loop] = _LoopState()
405
+ batch = state.open.get(group)
406
+ fresh = batch is None
407
+ if batch is None:
408
+ batch = state.open[group] = _AsyncBatch(group, prefix)
409
+ entry = batch.entries.get(k)
410
+ if entry is None:
411
+ entry = batch.entries[k] = _AsyncEntry(item, loop)
412
+ counter.add(1)
413
+ entry.waiters += 1
414
+ if len(batch.entries) >= max_size:
415
+ if len(batch.entries) == 1 and entry.waiters == 1:
416
+ # This caller is the batch's only waiter (e.g. max_size=1): run it right
417
+ # here, in the calling task, with no extra task and no loop hops.
418
+ # Cancelling this caller cancels only its own batch.
419
+ return await run_inline(state, batch, entry)
420
+ start(state, batch)
421
+ elif fresh:
422
+ batch.timer = loop.create_task(wait_then_start(state, batch))
423
+ try:
424
+ return await asyncio.shield(entry.future)
425
+ except asyncio.CancelledError:
426
+ entry.waiters -= 1
427
+ if entry.waiters == 0 and state.open.get(group) is batch:
428
+ # Nobody wants this item any more and it has not been sent: drop it.
429
+ del batch.entries[k]
430
+ counter.add(-1)
431
+ entry.future.cancel()
432
+ if not batch.entries:
433
+ del state.open[group]
434
+ if batch.timer is not None:
435
+ batch.timer.cancel()
436
+ raise
437
+
438
+ async def aflush() -> None:
439
+ state = loops.get(asyncio.get_running_loop())
440
+ if state is None:
441
+ return
442
+ for batch in list(state.open.values()):
443
+ start(state, batch)
444
+ if state.running:
445
+ await asyncio.wait(list(state.running))
446
+
447
+ counter.wrapper = wrapper
448
+ wrapper.pending = 0 # type: ignore[attr-defined]
449
+ wrapper.aflush = aflush # type: ignore[attr-defined]
450
+ return wrapper
451
+
452
+
453
+ # --------------------------------------------------------------------------- public
454
+
455
+
456
+ def batch_calls(
457
+ *,
458
+ max_size: int = 100,
459
+ max_wait: float = 0.01,
460
+ key: Callable[[Any], Hashable] | None = None,
461
+ ) -> _BatchDecorator:
462
+ """Coalesce concurrent single-item calls into one call of a batch function.
463
+
464
+ Decorate a function that takes a **list of items** and returns their results. The
465
+ decorated object is called with **one item** and returns that item's result. Calls
466
+ made within `max_wait` seconds of the first call of a window — or until `max_size`
467
+ distinct items are queued — are sent to the function together, as one list.
468
+
469
+ **Output contract.** The batch function receives the distinct items in arrival order
470
+ and must return either:
471
+
472
+ * a **sequence** (list, tuple, ...; not `str`/`bytes`) with exactly one result per
473
+ item, in the same order; or
474
+ * a **mapping** from each item's key to its result. The key is `key(item)` when
475
+ `key=` is given, otherwise the item itself. Extra entries are ignored.
476
+
477
+ If the sequence has the wrong length, or the return value is neither a sequence nor a
478
+ mapping, every caller in the batch gets a `BatchError`. If a mapping lacks a key, only
479
+ the callers of that item get a `BatchError`; the others get their results. If the
480
+ batch function raises, every caller in the batch gets that exception.
481
+
482
+ **Deduplication.** Items with the same key in one window are sent once and their
483
+ callers share the result. The key must be hashable.
484
+
485
+ * `async def` batch functions: callers on one event loop `await loader(item)`. A
486
+ window starts with the first call and closes after `max_wait`; the batch runs in its
487
+ own task, so cancelling one caller does not cancel it for the others. A cancelled
488
+ caller whose item has not been sent yet removes it from the batch (unless another
489
+ caller is waiting for the same key). Batches never span event loops.
490
+ * Sync batch functions: callers are threads, each blocking until its result is ready.
491
+ The first caller of a window waits up to `max_wait` (or until the batch fills) and
492
+ then runs the batch in its own thread; no timer thread is started. If a caller
493
+ fills the batch to `max_size`, it runs the batch at once. Calling a sync loader
494
+ sequentially from one thread therefore costs up to `max_wait` per call.
495
+ A sync loader is **not** awaitable: `await loader(1)` or
496
+ `asyncio.create_task(loader(1))` fails (the call returns a plain value, after
497
+ blocking the event loop while it waits). Called from a running event loop, it
498
+ emits a one-time `RuntimeWarning`; in async code decorate an `async def` batch
499
+ function instead.
500
+ * When a call fills an async batch to `max_size` and its caller is the batch's only
501
+ waiter (always the case with `max_size=1`), the batch runs right in the calling
502
+ task, without an extra task or event-loop round trip.
503
+
504
+ On a method, the batch function is called as `func(self, items)`, and calls on
505
+ different instances (or classes, for a class method) go to separate batches.
506
+
507
+ The wrapper also has:
508
+
509
+ * `.pending` — the number of distinct items queued and not yet sent.
510
+ * `.flush()` (sync) — send every queued batch now, in the calling thread.
511
+ * `await .aflush()` (async) — send the batches queued on the running loop now and
512
+ wait until every batch running on that loop has finished.
513
+
514
+ Args:
515
+ max_size: Send a batch as soon as it holds this many distinct items (>= 1).
516
+ max_wait: Seconds to wait for more items after the first call of a window (>= 0).
517
+ key: Function of an item returning the hashable key used for deduplication and
518
+ for looking results up in a mapping. Defaults to the item itself.
519
+
520
+ Returns:
521
+ A decorator producing a `Batched` or `AsyncBatched` wrapper.
522
+
523
+ Raises:
524
+ ValueError: If `max_size` < 1 or `max_wait` < 0.
525
+ TypeError: When called, if the item's key is not hashable.
526
+ BatchError: When called, if the batch function broke the output contract.
527
+
528
+ Example:
529
+ >>> @batch_calls(max_size=50, max_wait=0.005)
530
+ ... async def load_users(ids: list[int]) -> dict[int, str]:
531
+ ... return {i: f"user{i}" for i in ids}
532
+ >>> # await load_users(1), from many tasks at once -> one call with [1, ...]
533
+ """
534
+ if type(max_size) is not int or max_size < 1:
535
+ raise ValueError(f"batch_calls max_size must be an integer >= 1, got {max_size!r}")
536
+ if max_wait < 0:
537
+ raise ValueError(f"batch_calls max_wait must be non-negative, got {max_wait!r}")
538
+ user_key = key
539
+
540
+ def key_of(item: Any) -> Hashable:
541
+ k = item if user_key is None else user_key(item)
542
+ try:
543
+ hash(k)
544
+ except TypeError:
545
+ raise TypeError(
546
+ f"batch_calls: key of type {type(k).__name__!r} is not hashable; "
547
+ "pass key=<callable> returning a hashable key"
548
+ ) from None
549
+ return cast(Hashable, k)
550
+
551
+ def decorate(func: Callable[..., Any]) -> Callable[..., Any]:
552
+ if _core.is_async(func):
553
+ return _batch_async(func, max_size, max_wait, key_of)
554
+ return _batch_sync(func, max_size, max_wait, key_of)
555
+
556
+ return cast(_BatchDecorator, decorate)