timedbatch 0.2.0__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.
timedbatch/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """Batch items by a max count and a max age, flushing when either limit is hit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._abatcher import AsyncTimedBatcher
6
+ from ._abatching import abatched_by_time
7
+ from ._batcher import TimedBatcher
8
+ from ._batching import batched_by_time
9
+ from ._errors import BatcherClosed, BatcherFull, FlushError
10
+
11
+ __version__ = "0.2.0"
12
+
13
+ __all__ = [
14
+ "AsyncTimedBatcher",
15
+ "BatcherClosed",
16
+ "BatcherFull",
17
+ "FlushError",
18
+ "TimedBatcher",
19
+ "abatched_by_time",
20
+ "batched_by_time",
21
+ ]
@@ -0,0 +1,195 @@
1
+ """The asyncio counterpart of TimedBatcher, for a single event loop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import time
8
+ from collections.abc import Awaitable, Callable
9
+ from types import TracebackType
10
+ from typing import Generic
11
+
12
+ from ._core import Buffer, Ledger, T, wait_event
13
+ from ._errors import BatcherClosed, BatcherFull
14
+ from ._limits import check_limits, check_timeout
15
+
16
+
17
+ class AsyncTimedBatcher(Generic[T]):
18
+ """Collect items from coroutines and await ``flush(batch)`` in batches.
19
+
20
+ The same promises as :class:`TimedBatcher`: delivery on size, age (also while
21
+ idle), :meth:`flush` and :meth:`aclose`; one batch at a time, in order; every
22
+ accepted item delivered once or handed back once through ``on_error`` or a
23
+ :class:`FlushError`; ``max_pending`` makes :meth:`add` wait. Use it from one event
24
+ loop only, and close it (``async with``): there is no exit hook for coroutines. If
25
+ the flush task is cancelled (for example because the loop ends), the batcher closes
26
+ and the items it still held are handed back by the next call.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ flush: Callable[[list[T]], Awaitable[object]],
32
+ *,
33
+ max_items: int,
34
+ max_seconds: float,
35
+ max_pending: int | None = None,
36
+ on_error: Callable[[Exception, list[T]], object] | None = None,
37
+ clock: Callable[[], float] = time.monotonic,
38
+ ) -> None:
39
+ if not callable(flush):
40
+ raise TypeError("flush must be callable")
41
+ items, seconds, self._max_pending = check_limits(max_items, max_seconds, max_pending)
42
+ self._sink = flush
43
+ self._on_error = on_error
44
+ self._buffer: Buffer[T] = Buffer(items, seconds, clock)
45
+ self._ledger: Ledger[T] = Ledger()
46
+ self._accepted = self._done = self._wanted = 0
47
+ self._closed = False
48
+ self._stopped: BaseException | None = None
49
+ self._task: asyncio.Task[None] | None = None
50
+ self._wake = self._progress = asyncio.Event()
51
+
52
+ @property
53
+ def pending(self) -> int:
54
+ """Items accepted and not yet delivered or handed back."""
55
+ return self._accepted - self._done
56
+
57
+ @property
58
+ def closed(self) -> bool:
59
+ """Whether :meth:`aclose` has been called or the flush task has stopped."""
60
+ return self._closed
61
+
62
+ async def add(self, item: T, timeout: float | None = None) -> None:
63
+ """Accept ``item``, waiting up to ``timeout`` seconds for room under ``max_pending``."""
64
+ timeout = check_timeout(timeout)
65
+ self._check_open()
66
+ self._start()
67
+ limit = self._max_pending
68
+ if limit is not None and self.pending >= limit:
69
+ if self._inside():
70
+ raise BatcherFull("add() inside the flush callback cannot wait for room")
71
+ await self._wait(lambda: self.pending < limit, timeout, BatcherFull)
72
+ self._check_open()
73
+ self._buffer.append(item)
74
+ self._accepted += 1
75
+ self._wake.set()
76
+
77
+ async def flush(self, timeout: float | None = None) -> None:
78
+ """Deliver every item accepted so far; raise ``TimeoutError`` if it takes longer."""
79
+ timeout = check_timeout(timeout)
80
+ self._refuse_inside("flush")
81
+ if self._task is not None:
82
+ target = self._wanted = max(self._wanted, self._accepted)
83
+ self._wake.set()
84
+ await self._wait(lambda: self._done >= target, timeout, TimeoutError)
85
+ self._ledger.raise_any()
86
+
87
+ async def aclose(self) -> None:
88
+ """Stop accepting items, deliver the rest and stop the task. Idempotent."""
89
+ self._refuse_inside("aclose")
90
+ self._closed = True
91
+ if self._task is not None and not self._task.done():
92
+ self._wake.set()
93
+ # wait() neither cancels the task if this caller is cancelled nor re-raises
94
+ # the task's own cancellation into a caller that was not cancelled.
95
+ await asyncio.wait({self._task})
96
+ self._reap()
97
+ self._ledger.raise_any()
98
+
99
+ async def __aenter__(self) -> AsyncTimedBatcher[T]:
100
+ return self
101
+
102
+ async def __aexit__(
103
+ self,
104
+ exc_type: type[BaseException] | None,
105
+ exc: BaseException | None,
106
+ tb: TracebackType | None,
107
+ ) -> None:
108
+ await self.aclose()
109
+
110
+ def _reap(self) -> None:
111
+ # A task that stopped abnormally (cancelled, even before it ever ran, or killed
112
+ # by a BaseException from the sink) closes the batcher and hands back its items.
113
+ task = self._task
114
+ if task is None or not task.done() or self._stopped is not None:
115
+ return
116
+ error = asyncio.CancelledError() if task.cancelled() else task.exception()
117
+ if error is None:
118
+ return
119
+ self._stopped, self._closed = error, True
120
+ while (batch := self._buffer.take(True)) is not None:
121
+ self._ledger.record(error, batch)
122
+ self._finish(batch)
123
+
124
+ def _check_open(self) -> None:
125
+ self._reap()
126
+ self._ledger.raise_any()
127
+ if self._stopped is not None:
128
+ raise BatcherClosed("the flush task stopped") from self._stopped
129
+ if self._closed:
130
+ raise BatcherClosed("the batcher is closed")
131
+
132
+ def _start(self) -> None:
133
+ if self._task is None:
134
+ self._wake, self._progress = asyncio.Event(), asyncio.Event()
135
+ self._task = asyncio.get_running_loop().create_task(self._loop())
136
+
137
+ def _inside(self) -> bool:
138
+ return self._task is not None and asyncio.current_task() is self._task
139
+
140
+ def _refuse_inside(self, name: str) -> None:
141
+ if self._inside():
142
+ raise RuntimeError(f"{name}() cannot be called from inside the flush callback")
143
+
144
+ async def _wait(
145
+ self, ready: Callable[[], bool], timeout: float | None, error: type[Exception]
146
+ ) -> None:
147
+ loop = asyncio.get_running_loop()
148
+ deadline = None if timeout is None else loop.time() + timeout
149
+ while not ready():
150
+ assert self._task is not None
151
+ if self._task.done():
152
+ self._check_open()
153
+ left = None if deadline is None else deadline - loop.time()
154
+ if left is not None and left <= 0:
155
+ raise error("timed out waiting for the flush task")
156
+ await wait_event(self._progress, left)
157
+
158
+ async def _loop(self) -> None:
159
+ while True:
160
+ batch = self._buffer.take(self._closed or self._wanted > self._done)
161
+ if batch is None:
162
+ if self._closed:
163
+ return
164
+ self._wake.clear()
165
+ await wait_event(self._wake, self._buffer.remaining())
166
+ continue
167
+ try:
168
+ await self._sink(list(batch))
169
+ except Exception as error:
170
+ await self._report(error, batch)
171
+ except BaseException as error:
172
+ self._ledger.record(error, batch)
173
+ self._finish(batch)
174
+ raise
175
+ self._finish(batch)
176
+
177
+ async def _report(self, error: Exception, batch: list[T]) -> None:
178
+ if self._on_error is None:
179
+ self._ledger.record(error, batch)
180
+ return
181
+ try:
182
+ outcome = self._on_error(error, list(batch))
183
+ if inspect.isawaitable(outcome):
184
+ await outcome
185
+ except BaseException as handler_error:
186
+ self._ledger.record(handler_error, batch)
187
+ if not isinstance(handler_error, Exception):
188
+ self._finish(batch)
189
+ raise
190
+
191
+ def _finish(self, batch: list[T]) -> None:
192
+ self._done += len(batch)
193
+ # Waiters hold the old event; swapping in a fresh one wakes them all exactly once.
194
+ self._progress.set()
195
+ self._progress = asyncio.Event()
@@ -0,0 +1,91 @@
1
+ """Batch an async iterable by count and age, flushing on time even while it is idle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import math
7
+ import time
8
+ from collections.abc import AsyncGenerator, AsyncIterable, AsyncIterator, Callable
9
+ from typing import Any, TypeVar
10
+
11
+ from ._limits import check_limits
12
+
13
+ _T = TypeVar("_T")
14
+
15
+
16
+ def abatched_by_time(
17
+ aiterable: AsyncIterable[_T],
18
+ max_items: int,
19
+ max_seconds: float,
20
+ *,
21
+ clock: Callable[[], float] = time.monotonic,
22
+ ) -> AsyncGenerator[list[_T], None]:
23
+ """Yield batches from an async iterable, flushing on ``max_items`` or ``max_seconds``.
24
+
25
+ Unlike :func:`batched_by_time`, the age limit fires while the source is idle: a
26
+ partial batch is yielded ``max_seconds`` after its first item even if no further
27
+ item arrives. The pending read of the source is kept across those flushes, never
28
+ cancelled, so no item is lost; it is cancelled only when the iterator is closed. If
29
+ the source raises, the items already read are yielded first. ``clock`` must tick in
30
+ seconds; real waiting uses the event loop. Close it early with ``aclose()`` (or
31
+ :func:`contextlib.aclosing`): the read in flight is cancelled, and an item it had
32
+ already taken from the source is dropped, together with the partial batch. Each read
33
+ of the source runs in its own task, so context variables set by the source do not
34
+ carry over from one item to the next.
35
+ """
36
+ items, seconds, _ = check_limits(max_items, max_seconds)
37
+ if not isinstance(aiterable, AsyncIterable):
38
+ raise TypeError(f"expected an async iterable, not {type(aiterable).__name__}")
39
+ return _abatches(aiterable.__aiter__(), items, seconds, clock)
40
+
41
+
42
+ async def _abatches(
43
+ source: AsyncIterator[_T], max_items: int, max_seconds: float, clock: Callable[[], float]
44
+ ) -> AsyncGenerator[list[_T], None]:
45
+ batch: list[_T] = []
46
+ started = 0.0
47
+ pending: asyncio.Future[Any] | None = None
48
+ try:
49
+ while True:
50
+ if pending is None:
51
+ try:
52
+ step = source.__anext__()
53
+ except StopAsyncIteration:
54
+ break
55
+ except Exception:
56
+ if batch:
57
+ yield batch
58
+ raise
59
+ pending = asyncio.ensure_future(step)
60
+ timeout = None
61
+ if batch and not math.isinf(max_seconds):
62
+ timeout = max(0.0, started + max_seconds - clock())
63
+ # asyncio.wait, unlike wait_for, leaves the read running when it times out.
64
+ done, _ = await asyncio.wait({pending}, timeout=timeout)
65
+ if not done:
66
+ yield batch
67
+ batch = []
68
+ continue
69
+ read, pending = pending, None
70
+ try:
71
+ item = read.result()
72
+ except StopAsyncIteration:
73
+ break
74
+ except Exception:
75
+ if batch:
76
+ yield batch
77
+ raise
78
+ if not batch:
79
+ started = clock()
80
+ batch.append(item)
81
+ if len(batch) >= max_items or clock() - started >= max_seconds:
82
+ yield batch
83
+ batch = []
84
+ if batch:
85
+ yield batch
86
+ finally:
87
+ if pending is not None:
88
+ pending.cancel()
89
+ await asyncio.wait({pending})
90
+ if not pending.cancelled():
91
+ pending.exception()
timedbatch/_batcher.py ADDED
@@ -0,0 +1,156 @@
1
+ """A thread-safe push batcher that flushes on size, age, demand, close and exit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import atexit
6
+ import threading
7
+ import time
8
+ from collections.abc import Callable
9
+ from types import TracebackType
10
+
11
+ from ._core import Buffer, T, is_async
12
+ from ._engine import Engine
13
+ from ._errors import BatcherClosed
14
+ from ._limits import check_limits, check_timeout
15
+
16
+
17
+ class TimedBatcher(Engine[T]):
18
+ """Collect items from any thread and hand them to ``flush`` in batches.
19
+
20
+ A batch is delivered when it holds ``max_items`` items, when its oldest item is
21
+ ``max_seconds`` old, on :meth:`flush` and on :meth:`close`. Batches go to ``flush``
22
+ one at a time, in the order the items were accepted, and never exceed ``max_items``.
23
+ Every accepted item is delivered once or handed back once: to ``on_error(exc, batch)``
24
+ or inside a :class:`FlushError` raised by the next ``add``, ``poll``, ``flush`` or
25
+ ``close``. ``max_pending`` bounds items accepted but not yet delivered; ``add`` blocks
26
+ for room. With ``background=False`` there is no thread: due batches are delivered by
27
+ the caller on ``add`` and ``poll``, which makes an injected ``clock`` fully
28
+ deterministic. ``flush_at_exit`` closes a forgotten batcher when the interpreter exits.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ flush: Callable[[list[T]], object],
34
+ *,
35
+ max_items: int,
36
+ max_seconds: float,
37
+ max_pending: int | None = None,
38
+ on_error: Callable[[Exception, list[T]], object] | None = None,
39
+ clock: Callable[[], float] = time.monotonic,
40
+ background: bool = True,
41
+ flush_at_exit: bool = True,
42
+ ) -> None:
43
+ if not callable(flush):
44
+ raise TypeError("flush must be callable")
45
+ if is_async(flush) or is_async(on_error):
46
+ raise TypeError("flush and on_error must be plain functions; see AsyncTimedBatcher")
47
+ items, seconds, pending = check_limits(max_items, max_seconds, max_pending)
48
+ super().__init__(flush, on_error, Buffer(items, seconds, clock), pending)
49
+ self._at_exit = flush_at_exit
50
+ self._thread: threading.Thread | None = None
51
+ if background:
52
+ self._thread = threading.Thread(target=self._run, name="timedbatch", daemon=True)
53
+ self._thread.start()
54
+ if flush_at_exit:
55
+ atexit.register(self._close_at_exit)
56
+
57
+ @property
58
+ def pending(self) -> int:
59
+ """Items accepted and not yet delivered or handed back."""
60
+ with self._lock:
61
+ return self._accepted - self._done
62
+
63
+ @property
64
+ def closed(self) -> bool:
65
+ """Whether :meth:`close` has been called."""
66
+ return self._closed
67
+
68
+ def add(self, item: T, timeout: float | None = None) -> None:
69
+ """Accept ``item``, waiting up to ``timeout`` seconds for room under ``max_pending``.
70
+
71
+ Every exception it raises means the item was not accepted: :class:`BatcherClosed`,
72
+ :class:`BatcherFull`, or :class:`FlushError` for earlier failures not reported yet.
73
+ """
74
+ timeout = check_timeout(timeout)
75
+ with self._lock:
76
+ self._ledger.raise_any()
77
+ if self._closed:
78
+ raise BatcherClosed("the batcher is closed")
79
+ inside = self._delivering == threading.get_ident()
80
+ self._wait_for_room(timeout, inside)
81
+ self._buffer.append(item)
82
+ self._accepted += 1
83
+ if self._thread is not None:
84
+ if len(self._buffer) in (1, self._buffer.max_items):
85
+ self._wake.notify()
86
+ return
87
+ if not inside:
88
+ # A batch failing here is reported by the next call, so that a FlushError from
89
+ # add() always means the item was not accepted.
90
+ self._drain()
91
+
92
+ def poll(self) -> None:
93
+ """Deliver every batch that is due now and report failures not reported yet."""
94
+ self._refuse_inside("poll")
95
+ if self._thread is None:
96
+ self._drain()
97
+ else:
98
+ with self._lock:
99
+ self._wake.notify()
100
+ self._raise_failures()
101
+
102
+ def flush(self, timeout: float | None = None) -> None:
103
+ """Deliver every item accepted so far; raise ``TimeoutError`` if it takes longer.
104
+
105
+ Without a background thread the caller delivers, so ``timeout`` has no effect.
106
+ """
107
+ timeout = check_timeout(timeout)
108
+ self._refuse_inside("flush")
109
+ with self._lock:
110
+ target = self._wanted = max(self._wanted, self._accepted)
111
+ self._wake.notify()
112
+ if self._thread is None:
113
+ self._drain()
114
+ else:
115
+ self._wait_until_done(target, timeout)
116
+ self._raise_failures()
117
+
118
+ def close(self, timeout: float | None = None) -> None:
119
+ """Stop accepting items, deliver the rest and stop the thread. Idempotent.
120
+
121
+ If this times out or is interrupted, the exit hook stays registered, so what is
122
+ left is still delivered when the interpreter exits.
123
+ """
124
+ self._shutdown(check_timeout(timeout), unregister=True)
125
+
126
+ def __enter__(self) -> TimedBatcher[T]:
127
+ return self
128
+
129
+ def __exit__(
130
+ self,
131
+ exc_type: type[BaseException] | None,
132
+ exc: BaseException | None,
133
+ tb: TracebackType | None,
134
+ ) -> None:
135
+ self.close()
136
+
137
+ def _close_at_exit(self) -> None:
138
+ # The atexit docs leave unregistering from inside a handler undefined.
139
+ self._shutdown(None, unregister=False)
140
+
141
+ def _shutdown(self, timeout: float | None, unregister: bool) -> None:
142
+ self._refuse_inside("close")
143
+ with self._lock:
144
+ self._closed = True
145
+ self._wake.notify()
146
+ self._progress.notify_all()
147
+ if self._thread is None:
148
+ self._drain()
149
+ else:
150
+ self._thread.join(timeout)
151
+ if self._thread.is_alive():
152
+ raise TimeoutError("the flush thread is still delivering")
153
+ # Only now: an interrupted or timed out close must leave the exit flush in place.
154
+ if unregister and self._at_exit:
155
+ atexit.unregister(self._close_at_exit)
156
+ self._raise_failures()
@@ -0,0 +1,60 @@
1
+ """Batch an iterable by both a maximum count and a maximum time window."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Callable, Iterable, Iterator
7
+ from typing import TypeVar
8
+
9
+ from ._limits import check_limits
10
+
11
+ _T = TypeVar("_T")
12
+
13
+
14
+ def batched_by_time(
15
+ iterable: Iterable[_T],
16
+ max_items: int,
17
+ max_seconds: float,
18
+ *,
19
+ clock: Callable[[], float] = time.monotonic,
20
+ ) -> Iterator[list[_T]]:
21
+ """Yield batches of items, flushing when either limit is reached.
22
+
23
+ A batch is emitted as soon as it holds ``max_items`` items or ``max_seconds``
24
+ have elapsed since its first item arrived, whichever comes first. The window is
25
+ checked only when an item arrives, so an idle source holds its partial batch
26
+ until the next item (use :func:`abatched_by_time` or :class:`TimedBatcher` to
27
+ flush on time while idle). Remaining items are flushed when the source is
28
+ exhausted. If the source raises, the items already read are yielded first and the
29
+ exception is raised on the next step. ``max_seconds`` may be ``math.inf`` to batch
30
+ by count only.
31
+
32
+ The ``clock`` returns a monotonic timestamp in seconds. Inject it to make the
33
+ behaviour deterministic in tests; it defaults to :func:`time.monotonic`.
34
+ """
35
+ items, seconds, _ = check_limits(max_items, max_seconds)
36
+ return _batches(iter(iterable), items, seconds, clock)
37
+
38
+
39
+ def _batches(
40
+ source: Iterator[_T], max_items: int, max_seconds: float, clock: Callable[[], float]
41
+ ) -> Iterator[list[_T]]:
42
+ batch: list[_T] = []
43
+ started = 0.0
44
+ while True:
45
+ try:
46
+ item = next(source)
47
+ except StopIteration:
48
+ break
49
+ except Exception:
50
+ if batch:
51
+ yield batch
52
+ raise
53
+ if not batch:
54
+ started = clock()
55
+ batch.append(item)
56
+ if len(batch) >= max_items or clock() - started >= max_seconds:
57
+ yield batch
58
+ batch = []
59
+ if batch:
60
+ yield batch
timedbatch/_core.py ADDED
@@ -0,0 +1,119 @@
1
+ """State shared by the push-style batchers: the buffer and the failure ledger."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import math
8
+ from collections.abc import Callable
9
+ from typing import Generic, TypeVar
10
+
11
+ from ._errors import FlushError
12
+
13
+ T = TypeVar("T")
14
+
15
+
16
+ class Buffer(Generic[T]):
17
+ """Items waiting for delivery, with the time each one arrived. Not thread-safe."""
18
+
19
+ def __init__(self, max_items: int, max_seconds: float, clock: Callable[[], float]) -> None:
20
+ self.max_items = max_items
21
+ self.max_seconds = max_seconds
22
+ self.clock = clock
23
+ self._items: list[T] = []
24
+ # One stamp per item: after a slow delivery the buffer can hold more than one
25
+ # batch, and the next batch's age must come from its own oldest item.
26
+ self._stamps: list[float] = []
27
+
28
+ def __len__(self) -> int:
29
+ return len(self._items)
30
+
31
+ def append(self, item: T) -> None:
32
+ self._stamps.append(self.clock())
33
+ self._items.append(item)
34
+
35
+ def remaining(self) -> float | None:
36
+ """Seconds until the oldest item is due, or ``None`` when nothing will come due."""
37
+ if not self._items or math.isinf(self.max_seconds):
38
+ return None
39
+ return self._stamps[0] + self.max_seconds - self.clock()
40
+
41
+ def take(self, force: bool) -> list[T] | None:
42
+ """Remove and return the next batch if it is due (or ``force``), else ``None``."""
43
+ if not self._items:
44
+ return None
45
+ if (
46
+ not force
47
+ and len(self._items) < self.max_items
48
+ and self.clock() - self._stamps[0] < self.max_seconds
49
+ ):
50
+ return None
51
+ batch = self._items[: self.max_items]
52
+ del self._items[: self.max_items]
53
+ del self._stamps[: self.max_items]
54
+ return batch
55
+
56
+
57
+ class Ledger(Generic[T]):
58
+ """Failed batches not yet reported to the producer."""
59
+
60
+ def __init__(self) -> None:
61
+ self._batches: list[list[T]] = []
62
+ self._errors: list[BaseException] = []
63
+
64
+ def record(self, error: BaseException, batch: list[T]) -> None:
65
+ self._batches.append(batch)
66
+ self._errors.append(error)
67
+
68
+ def raise_any(self) -> None:
69
+ if self._batches:
70
+ batches, errors = self._batches, self._errors
71
+ self._batches, self._errors = [], []
72
+ raise FlushError(list(batches), errors)
73
+
74
+
75
+ def call_sink(
76
+ sink: Callable[[list[T]], object],
77
+ on_error: Callable[[Exception, list[T]], object] | None,
78
+ batch: list[T],
79
+ ) -> BaseException | None:
80
+ """Deliver ``batch``; return the exception that still needs reporting, if any."""
81
+ try:
82
+ # The sink gets its own list, so a sink that clears it cannot empty the copy we
83
+ # hand back on failure.
84
+ _plain(sink(list(batch)))
85
+ except Exception as error:
86
+ if on_error is None:
87
+ return error
88
+ try:
89
+ _plain(on_error(error, list(batch)))
90
+ except BaseException as handler_error:
91
+ return handler_error
92
+ except BaseException as error:
93
+ return error
94
+ return None
95
+
96
+
97
+ def is_async(function: object) -> bool:
98
+ """Whether calling ``function`` returns a coroutine (functions and callable objects)."""
99
+ call = type(function).__call__
100
+ return inspect.iscoroutinefunction(function) or inspect.iscoroutinefunction(call)
101
+
102
+
103
+ def _plain(outcome: object) -> None:
104
+ # A coroutine that is never awaited does nothing: treat it as a failed delivery.
105
+ if inspect.isawaitable(outcome):
106
+ if inspect.iscoroutine(outcome):
107
+ outcome.close()
108
+ raise TypeError("the flush function returned an awaitable; use AsyncTimedBatcher")
109
+
110
+
111
+ async def wait_event(event: asyncio.Event, timeout: float | None) -> None:
112
+ """Wait for ``event`` at most ``timeout`` seconds, without raising on timeout."""
113
+ # asyncio.wait instead of wait_for: on 3.10 and 3.11 wait_for can swallow a
114
+ # cancellation that arrives as the event fires.
115
+ waiter = asyncio.ensure_future(event.wait())
116
+ try:
117
+ await asyncio.wait({waiter}, timeout=timeout)
118
+ finally:
119
+ waiter.cancel()
timedbatch/_engine.py ADDED
@@ -0,0 +1,116 @@
1
+ """The locking and delivery machinery behind TimedBatcher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from collections.abc import Callable
8
+ from typing import Generic
9
+
10
+ from ._core import Buffer, Ledger, T, call_sink
11
+ from ._errors import BatcherClosed, BatcherFull
12
+
13
+
14
+ class Engine(Generic[T]):
15
+ """Buffer, counters and the one-at-a-time delivery loop, guarded by one lock."""
16
+
17
+ def __init__(
18
+ self,
19
+ sink: Callable[[list[T]], object],
20
+ on_error: Callable[[Exception, list[T]], object] | None,
21
+ buffer: Buffer[T],
22
+ max_pending: int | None,
23
+ ) -> None:
24
+ self._sink = sink
25
+ self._on_error = on_error
26
+ self._buffer = buffer
27
+ self._max_pending = max_pending
28
+ self._ledger: Ledger[T] = Ledger()
29
+ self._lock = threading.Lock()
30
+ self._wake = threading.Condition(self._lock)
31
+ self._progress = threading.Condition(self._lock)
32
+ self._order = threading.Lock()
33
+ self._accepted = self._done = self._wanted = 0
34
+ self._closed = False
35
+ self._delivering: int | None = None
36
+
37
+ def _refuse_inside(self, name: str) -> None:
38
+ with self._lock:
39
+ if self._delivering == threading.get_ident():
40
+ raise RuntimeError(f"{name}() cannot be called from inside the flush callback")
41
+
42
+ def _raise_failures(self) -> None:
43
+ with self._lock:
44
+ self._ledger.raise_any()
45
+
46
+ def _wait_for_room(self, timeout: float | None, inside: bool) -> None:
47
+ limit = self._max_pending
48
+ if limit is None or self._accepted - self._done < limit:
49
+ return
50
+ if inside:
51
+ raise BatcherFull("add() inside the flush callback cannot wait for room")
52
+ # Waiting uses the real clock: the injected one only measures batch age.
53
+ deadline = None if timeout is None else time.monotonic() + timeout
54
+ while self._accepted - self._done >= limit:
55
+ left = None if deadline is None else deadline - time.monotonic()
56
+ if left is not None and left <= 0:
57
+ raise BatcherFull(f"no room under max_pending={limit} within {timeout} seconds")
58
+ self._progress.wait(_bounded(left))
59
+ if self._closed:
60
+ raise BatcherClosed("the batcher closed while add() was waiting")
61
+
62
+ def _wait_until_done(self, target: int, timeout: float | None) -> None:
63
+ deadline = None if timeout is None else time.monotonic() + timeout
64
+ with self._lock:
65
+ while self._done < target:
66
+ left = None if deadline is None else deadline - time.monotonic()
67
+ if left is not None and left <= 0:
68
+ raise TimeoutError("flush() timed out before every item was delivered")
69
+ self._progress.wait(_bounded(left))
70
+
71
+ def _next_batch(self) -> list[T] | None:
72
+ batch = self._buffer.take(self._closed or self._wanted > self._done)
73
+ if batch is not None:
74
+ self._delivering = threading.get_ident()
75
+ return batch
76
+
77
+ def _deliver(self, batch: list[T]) -> BaseException | None:
78
+ error = call_sink(self._sink, self._on_error, batch)
79
+ with self._lock:
80
+ self._delivering = None
81
+ if error is not None:
82
+ self._ledger.record(error, batch)
83
+ self._done += len(batch)
84
+ self._progress.notify_all()
85
+ return error
86
+
87
+ def _drain(self) -> None:
88
+ with self._order:
89
+ while True:
90
+ with self._lock:
91
+ batch = self._next_batch()
92
+ if batch is None:
93
+ return
94
+ error = self._deliver(batch)
95
+ if error is not None and not isinstance(error, Exception):
96
+ raise error
97
+
98
+ def _run(self) -> None:
99
+ with self._lock:
100
+ while True:
101
+ batch = self._next_batch()
102
+ if batch is None:
103
+ if self._closed:
104
+ return
105
+ self._wake.wait(_bounded(self._buffer.remaining()))
106
+ continue
107
+ self._lock.release()
108
+ try:
109
+ self._deliver(batch)
110
+ finally:
111
+ self._lock.acquire()
112
+
113
+
114
+ def _bounded(seconds: float | None) -> float | None:
115
+ # Condition.wait raises OverflowError past TIMEOUT_MAX, which would kill the thread.
116
+ return None if seconds is None else min(seconds, threading.TIMEOUT_MAX)
timedbatch/_errors.py ADDED
@@ -0,0 +1,35 @@
1
+ """Exceptions raised by the batchers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class FlushError(Exception):
9
+ """One or more batches could not be delivered.
10
+
11
+ ``batches`` holds every failed batch, in delivery order, and ``errors`` the exception
12
+ each one raised, so the caller can retry or store them: no item is dropped silently.
13
+ """
14
+
15
+ def __init__(self, batches: list[list[Any]], errors: list[BaseException]) -> None:
16
+ if not errors or len(batches) != len(errors):
17
+ raise ValueError("FlushError needs one error per failed batch, and at least one")
18
+ count = sum(len(batch) for batch in batches)
19
+ noun = "batch" if len(batches) == 1 else "batches"
20
+ items = "item" if count == 1 else "items"
21
+ super().__init__(f"{len(batches)} {noun} ({count} {items}) failed to flush: {errors[0]!r}")
22
+ self.batches = batches
23
+ self.errors = errors
24
+ self.__cause__ = errors[0]
25
+
26
+ def __reduce__(self) -> tuple[Any, ...]:
27
+ return (type(self), (self.batches, self.errors))
28
+
29
+
30
+ class BatcherClosed(RuntimeError):
31
+ """The batcher was closed, so it accepts no more items."""
32
+
33
+
34
+ class BatcherFull(TimeoutError):
35
+ """``add`` gave up waiting for room under ``max_pending``; the item was not accepted."""
timedbatch/_limits.py ADDED
@@ -0,0 +1,52 @@
1
+ """Validation of the batching limits shared by every entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import operator
7
+ from decimal import Decimal
8
+ from numbers import Real
9
+
10
+
11
+ def _count(name: str, value: object) -> int:
12
+ if isinstance(value, bool):
13
+ raise TypeError(f"{name} must be an int, not bool")
14
+ try:
15
+ # operator.index accepts int and integer types such as NumPy's, never floats.
16
+ count = operator.index(value) # type: ignore[arg-type]
17
+ except TypeError:
18
+ raise TypeError(f"{name} must be an int, not {type(value).__name__}") from None
19
+ if count < 1:
20
+ raise ValueError(f"{name} must be at least 1")
21
+ return count
22
+
23
+
24
+ def _seconds(name: str, value: object) -> float:
25
+ if isinstance(value, bool) or not isinstance(value, (Real, Decimal)):
26
+ raise TypeError(f"{name} must be a real number, not {type(value).__name__}")
27
+ seconds = float(value)
28
+ # NaN compares false with everything, so it would silently disable a limit.
29
+ if math.isnan(seconds):
30
+ raise ValueError(f"{name} must not be NaN")
31
+ return seconds
32
+
33
+
34
+ def check_limits(
35
+ max_items: object, max_seconds: object, max_pending: object = None
36
+ ) -> tuple[int, float, int | None]:
37
+ """Return the limits as exact types, or raise ``TypeError`` / ``ValueError``."""
38
+ items = _count("max_items", max_items)
39
+ seconds = _seconds("max_seconds", max_seconds)
40
+ if seconds <= 0:
41
+ raise ValueError("max_seconds must be greater than 0")
42
+ if max_pending is None:
43
+ return items, seconds, None
44
+ pending = _count("max_pending", max_pending)
45
+ if pending < items:
46
+ raise ValueError("max_pending must be at least max_items")
47
+ return items, seconds, pending
48
+
49
+
50
+ def check_timeout(timeout: object) -> float | None:
51
+ """Return a wait timeout in seconds (``None`` waits forever); negative means now."""
52
+ return None if timeout is None else _seconds("timeout", timeout)
timedbatch/py.typed ADDED
File without changes
@@ -0,0 +1,247 @@
1
+ Metadata-Version: 2.5
2
+ Name: timedbatch
3
+ Version: 0.2.0
4
+ Summary: Batch items by a max count and a max age, for iterables, threads and asyncio, without losing items
5
+ Project-URL: Homepage, https://lianes8studio.com
6
+ Project-URL: Repository, https://github.com/JaviLianes8/timedbatch
7
+ Project-URL: Issues, https://github.com/JaviLianes8/timedbatch/issues
8
+ Project-URL: Changelog, https://github.com/JaviLianes8/timedbatch/blob/main/CHANGELOG.md
9
+ Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
10
+ Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
11
+ Author-email: Javier Lianes <javier@lianes8studio.com>
12
+ License-Expression: MIT
13
+ License-File: LICENSE
14
+ Keywords: asyncio,back-pressure,batch,batching,buffer,chunk,flush,iterator,streaming,time-window
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Framework :: AsyncIO
17
+ Classifier: Intended Audience :: Developers
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.14
26
+ Classifier: Typing :: Typed
27
+ Requires-Python: >=3.10
28
+ Provides-Extra: dev
29
+ Requires-Dist: build; extra == 'dev'
30
+ Requires-Dist: mypy; extra == 'dev'
31
+ Requires-Dist: pytest; extra == 'dev'
32
+ Requires-Dist: ruff; extra == 'dev'
33
+ Requires-Dist: twine; extra == 'dev'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # timedbatch
37
+
38
+ Batch items by a max count and a max age, flushing when either limit is hit. Typed, no
39
+ dependencies, for plain iterables, for threads and for asyncio.
40
+
41
+ ## Install
42
+
43
+ ```
44
+ pip install timedbatch
45
+ ```
46
+
47
+ ## Which one to use
48
+
49
+ | You have | Use | Flushes while idle |
50
+ |---|---|---|
51
+ | An iterable you loop over | `batched_by_time` | No: the age is checked when an item arrives |
52
+ | An async iterable | `abatched_by_time` | Yes |
53
+ | Many places calling `add(item)` from threads | `TimedBatcher` | Yes, from a background thread |
54
+ | Many coroutines calling `await add(item)` | `AsyncTimedBatcher` | Yes, from a background task |
55
+
56
+ A batch is delivered as soon as it holds `max_items` items or its oldest item is
57
+ `max_seconds` old, whichever comes first. `max_seconds` may be `math.inf` to batch by
58
+ count only.
59
+
60
+ ## Iterables
61
+
62
+ ```python
63
+ from timedbatch import batched_by_time
64
+
65
+ now = 0.0
66
+
67
+
68
+ def clock() -> float: # inject a clock and tests never sleep
69
+ return now
70
+
71
+
72
+ def events():
73
+ global now
74
+ for moment, name in [(0.0, "a"), (0.5, "b"), (0.6, "c"), (9.0, "d"), (9.1, "e")]:
75
+ now = moment
76
+ yield name
77
+
78
+
79
+ for batch in batched_by_time(events(), max_items=2, max_seconds=5.0, clock=clock):
80
+ print(batch) # ['a', 'b']
81
+ # ['c', 'd']
82
+ # ['e']
83
+ ```
84
+
85
+ `['c', 'd']` closes on age: `d` arrives 8.4 seconds after `c`. If the source raises, the
86
+ items already read are yielded first and the exception follows.
87
+
88
+ ## Threads: `TimedBatcher`
89
+
90
+ ```python
91
+ from timedbatch import TimedBatcher
92
+
93
+
94
+ def write_rows(batch: list[int]) -> None:
95
+ print("insert", batch)
96
+
97
+
98
+ with TimedBatcher(write_rows, max_items=2, max_seconds=1.0) as batcher:
99
+ for row in range(5):
100
+ batcher.add(row) # safe from any number of threads
101
+ # insert [0, 1]
102
+ # insert [2, 3]
103
+ # insert [4]
104
+ ```
105
+
106
+ What it promises:
107
+
108
+ - **Size and age.** A full batch goes out at once; a partial one goes out `max_seconds`
109
+ after its oldest item, even if nothing else is added.
110
+ - **Order and no overlap.** One background thread calls your function, one batch at a
111
+ time, in the order the items were accepted. A batch never exceeds `max_items`.
112
+ - **Nothing lost, nothing twice.** Every accepted item is delivered once or handed back
113
+ once. When `add` raises, the item was not accepted (one exception: with
114
+ `background=False`, an interrupt raised inside your function passes through `add`,
115
+ and its batch comes back in the next `FlushError`).
116
+ - **Failures.** Give `on_error(exc, batch)` to decide what to do with a failed batch.
117
+ Without it, failed batches are kept and raised as a `FlushError` (with `.batches` and
118
+ `.errors`) by the next `add`, `poll`, `flush` or `close`.
119
+ - **Back-pressure.** `max_pending` caps items accepted but not yet delivered. `add` waits
120
+ for room, or raises `BatcherFull` (a `TimeoutError`) after `timeout` seconds.
121
+ - **Shutdown.** `close()` (or leaving the `with` block) delivers the rest and stops the
122
+ thread; later calls to `add` raise `BatcherClosed`. A batcher you forget to close, or
123
+ whose `close(timeout=...)` timed out, is closed when the interpreter exits normally,
124
+ through `sys.exit` or an uncaught exception (`flush_at_exit=True`). That exit flush
125
+ waits for your function, so give it its own timeouts. Nothing can flush after
126
+ `os._exit`, a kill signal, or in a child created by `os.fork` (it has no thread).
127
+ - **No deadlocks from inside.** Calling `flush`, `poll` or `close` from the flush
128
+ function raises `RuntimeError`, and an `add` there that would wait raises `BatcherFull`.
129
+
130
+ Failures come back with their items:
131
+
132
+ ```python
133
+ from timedbatch import FlushError, TimedBatcher
134
+
135
+
136
+ def flaky(batch: list[str]) -> None:
137
+ raise ConnectionError("database is down")
138
+
139
+
140
+ batcher = TimedBatcher(flaky, max_items=10, max_seconds=60.0)
141
+ batcher.add("order-1")
142
+ batcher.add("order-2")
143
+ try:
144
+ batcher.close()
145
+ except FlushError as error:
146
+ print(error.batches) # [['order-1', 'order-2']]
147
+ print(error) # 1 batch (2 items) failed to flush: ConnectionError('database is down')
148
+ ```
149
+
150
+ ### Deterministic tests
151
+
152
+ `background=False` removes the thread: due batches are delivered by the caller inside
153
+ `add` and `poll()`, so an injected clock drives every age decision (and the `timeout`
154
+ of `flush` and `close` has nothing to wait for).
155
+
156
+ ```python
157
+ from timedbatch import TimedBatcher
158
+
159
+ now = 0.0
160
+ sent: list[list[str]] = []
161
+ batcher = TimedBatcher(
162
+ sent.append, max_items=100, max_seconds=5.0, clock=lambda: now, background=False
163
+ )
164
+ batcher.add("x")
165
+ now = 4.9
166
+ batcher.poll()
167
+ print(sent) # []
168
+ now = 5.0
169
+ batcher.poll()
170
+ print(sent) # [['x']]
171
+ ```
172
+
173
+ ## asyncio
174
+
175
+ `abatched_by_time` yields a partial batch on time even while the source waits. It keeps
176
+ the pending read across those flushes (it uses `asyncio.wait`, which does not cancel on
177
+ timeout), so no item is lost. Each read runs in its own task, so context variables the
178
+ source sets do not carry from one item to the next. Closing it early drops the partial
179
+ batch and the read in flight.
180
+
181
+ ```python
182
+ import asyncio
183
+ from timedbatch import abatched_by_time
184
+
185
+
186
+ async def ticks():
187
+ yield 1
188
+ yield 2
189
+ await asyncio.sleep(0.3) # the source goes quiet
190
+ yield 3
191
+
192
+
193
+ async def main() -> None:
194
+ async for batch in abatched_by_time(ticks(), max_items=10, max_seconds=0.1):
195
+ print(batch) # [1, 2]
196
+ # [3]
197
+
198
+
199
+ asyncio.run(main())
200
+ ```
201
+
202
+ `AsyncTimedBatcher` is `TimedBatcher` for one event loop, with an async flush function
203
+ (`on_error` may be sync or async). Close it with `async with` or `await aclose()`:
204
+ coroutines cannot run at interpreter exit. If its flush task is cancelled, for example
205
+ because the loop ended first, the batcher closes and the next call hands back what it
206
+ held. `TimedBatcher` refuses an `async def` flush function instead of dropping batches.
207
+
208
+ ```python
209
+ import asyncio
210
+ from timedbatch import AsyncTimedBatcher
211
+
212
+
213
+ async def send(batch: list[str]) -> None:
214
+ print("send", batch)
215
+
216
+
217
+ async def main() -> None:
218
+ async with AsyncTimedBatcher(send, max_items=3, max_seconds=1.0, max_pending=100) as out:
219
+ for word in ["a", "b", "c", "d"]:
220
+ await out.add(word) # waits when 100 items are already pending
221
+
222
+
223
+ asyncio.run(main())
224
+ # send ['a', 'b', 'c']
225
+ # send ['d']
226
+ ```
227
+
228
+ ## Why
229
+
230
+ Count-based batching is everywhere (`itertools.batched`, `more-itertools` `chunked`),
231
+ but none of it closes a batch on elapsed time. Code that ships logs, events or metrics
232
+ almost always wants both bounds: cap the size so a batch never grows without limit, and
233
+ cap the latency so a slow stream still drains on schedule. The hard part is the rest:
234
+ flushing an idle buffer, not losing items when the sink fails or the program exits,
235
+ slowing producers down when the sink falls behind. `timedbatch` does that once, with tests
236
+ that run hundreds of random timelines against a reference model and hammer the batchers
237
+ from many threads and tasks.
238
+
239
+ ## License
240
+
241
+ MIT. See [LICENSE](LICENSE).
242
+
243
+ ## Links
244
+
245
+ - Website: https://lianes8studio.com
246
+ - GitHub: https://github.com/JaviLianes8
247
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,14 @@
1
+ timedbatch/__init__.py,sha256=wA-qVQD-gl5METI1vGdVs2ndAuO2h2QZDAIq0g9vogU,524
2
+ timedbatch/_abatcher.py,sha256=A7qNh28XYRbNG60VStPp8IOpxIdb_xWLsjphA1LG0wk,7811
3
+ timedbatch/_abatching.py,sha256=oVzcvgxTH0C5rhPjxDg2zubIjBVWVvNef7e89lqp8XE,3465
4
+ timedbatch/_batcher.py,sha256=YxwYzQ1leF-KWdWnZEPytXE54Oc7abT7_aF7sG3hZ98,6208
5
+ timedbatch/_batching.py,sha256=-2n_oGVSvYLpCEr3ltZpJ0XXuwYsv_Uxb4yrjkPlC5g,2005
6
+ timedbatch/_core.py,sha256=-KSq-jz2nEpESAvuUzj4T8elt5-6DAbjcqKa5YhGHTg,4130
7
+ timedbatch/_engine.py,sha256=JLUR9o-Kqlj60z2O2DD6P3q_GG4yDE2kyrmERc5kZj4,4495
8
+ timedbatch/_errors.py,sha256=dbI8v13zpp6EjpTXiZekGE8BuoyBQy8TJwvpd-ophiI,1295
9
+ timedbatch/_limits.py,sha256=oOh1RUEOiVNdzkmAvgW5lmOho61HZ-JRneQW0zA3uUE,1942
10
+ timedbatch/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ timedbatch-0.2.0.dist-info/METADATA,sha256=VxW2jewvU2EdmYiDWOOwCm9btxxt3QqxqY6gV7klSN8,8569
12
+ timedbatch-0.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
13
+ timedbatch-0.2.0.dist-info/licenses/LICENSE,sha256=_WmABlQG__oqlx4Rzp-qDJ10xND5KCLScftbhXbLC_c,1070
14
+ timedbatch-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Javier Lianes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.