datacurrent 1.0.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.
- datacurrent/__init__.py +157 -0
- datacurrent/_version.py +6 -0
- datacurrent/buffering/__init__.py +8 -0
- datacurrent/buffering/backpressure.py +47 -0
- datacurrent/buffering/queue.py +304 -0
- datacurrent/concurrency/__init__.py +8 -0
- datacurrent/concurrency/limits.py +106 -0
- datacurrent/concurrency/tasks.py +86 -0
- datacurrent/config.py +86 -0
- datacurrent/core/__init__.py +17 -0
- datacurrent/core/_util.py +42 -0
- datacurrent/core/lifecycle.py +81 -0
- datacurrent/core/operators.py +844 -0
- datacurrent/core/pipeline.py +64 -0
- datacurrent/core/sink.py +70 -0
- datacurrent/core/stream.py +566 -0
- datacurrent/events/__init__.py +9 -0
- datacurrent/events/bus.py +380 -0
- datacurrent/events/routing.py +59 -0
- datacurrent/events/subscriptions.py +75 -0
- datacurrent/exceptions.py +191 -0
- datacurrent/integrations/__init__.py +18 -0
- datacurrent/integrations/fastapi.py +76 -0
- datacurrent/integrations/starlette.py +75 -0
- datacurrent/observability/__init__.py +17 -0
- datacurrent/observability/logging.py +128 -0
- datacurrent/observability/metrics.py +127 -0
- datacurrent/py.typed +1 -0
- datacurrent/reliability/__init__.py +14 -0
- datacurrent/reliability/retry.py +134 -0
- datacurrent/reliability/timeout.py +39 -0
- datacurrent/transports/__init__.py +31 -0
- datacurrent/transports/_deps.py +23 -0
- datacurrent/transports/base.py +80 -0
- datacurrent/transports/http.py +211 -0
- datacurrent/transports/memory.py +171 -0
- datacurrent/transports/sse.py +374 -0
- datacurrent/transports/websocket.py +277 -0
- datacurrent-1.0.0.dist-info/METADATA +250 -0
- datacurrent-1.0.0.dist-info/RECORD +43 -0
- datacurrent-1.0.0.dist-info/WHEEL +5 -0
- datacurrent-1.0.0.dist-info/licenses/LICENSE +20 -0
- datacurrent-1.0.0.dist-info/top_level.txt +1 -0
datacurrent/__init__.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""DataCurrent — Python streaming, made simple.
|
|
2
|
+
|
|
3
|
+
Async streams, composable operators, real backpressure and modular real-time
|
|
4
|
+
transports for Python applications.
|
|
5
|
+
|
|
6
|
+
from datacurrent import Stream
|
|
7
|
+
|
|
8
|
+
stream = Stream.from_iterable([1, 2, 3]).map(lambda n: n * 2)
|
|
9
|
+
async for value in stream:
|
|
10
|
+
print(value)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from ._version import VERSION, __version__
|
|
16
|
+
from .buffering import BoundedQueue, BufferPolicy, BufferStats
|
|
17
|
+
from .concurrency import ConcurrencyLimiter, TaskTracker, gather_limited
|
|
18
|
+
from .config import (
|
|
19
|
+
DEFAULT_BUFFER_SIZE,
|
|
20
|
+
DEFAULT_CONCURRENCY,
|
|
21
|
+
DEFAULT_MAX_MESSAGE_SIZE,
|
|
22
|
+
DEFAULT_TIMEOUT,
|
|
23
|
+
OverflowPolicy,
|
|
24
|
+
StreamConfig,
|
|
25
|
+
)
|
|
26
|
+
from .core import (
|
|
27
|
+
Stream,
|
|
28
|
+
StreamIterator,
|
|
29
|
+
concat_sources,
|
|
30
|
+
consume,
|
|
31
|
+
drain_into,
|
|
32
|
+
merge_sources,
|
|
33
|
+
pipeline,
|
|
34
|
+
)
|
|
35
|
+
from .events import Event, EventBus, Subscription, matches
|
|
36
|
+
from .exceptions import (
|
|
37
|
+
BufferOverflowError,
|
|
38
|
+
ConfigurationError,
|
|
39
|
+
ConnectionFailedError,
|
|
40
|
+
DataCurrentError,
|
|
41
|
+
EmptyStreamError,
|
|
42
|
+
EventBusError,
|
|
43
|
+
MessageTooLargeError,
|
|
44
|
+
NonReplayableStreamError,
|
|
45
|
+
OptionalDependencyError,
|
|
46
|
+
QueueClosedError,
|
|
47
|
+
QueueEmptyError,
|
|
48
|
+
RetryError,
|
|
49
|
+
SSEProtocolError,
|
|
50
|
+
StreamClosedError,
|
|
51
|
+
StreamConsumedError,
|
|
52
|
+
StreamError,
|
|
53
|
+
StreamProtocolError,
|
|
54
|
+
StreamTimeoutError,
|
|
55
|
+
SubscriptionClosedError,
|
|
56
|
+
TransportError,
|
|
57
|
+
WebSocketClosedError,
|
|
58
|
+
)
|
|
59
|
+
from .observability import (
|
|
60
|
+
Counter,
|
|
61
|
+
Metrics,
|
|
62
|
+
StreamStats,
|
|
63
|
+
get_logger,
|
|
64
|
+
redact_headers,
|
|
65
|
+
redact_secret,
|
|
66
|
+
redact_url,
|
|
67
|
+
)
|
|
68
|
+
from .reliability import RetryPolicy, retry_async, with_timeout
|
|
69
|
+
from .transports import (
|
|
70
|
+
Channel,
|
|
71
|
+
HTTPStream,
|
|
72
|
+
SSEEvent,
|
|
73
|
+
SSEStream,
|
|
74
|
+
StreamTransport,
|
|
75
|
+
WebSocketStream,
|
|
76
|
+
format_heartbeat,
|
|
77
|
+
format_sse,
|
|
78
|
+
parse_sse,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
__all__ = [
|
|
82
|
+
# Constants
|
|
83
|
+
"DEFAULT_BUFFER_SIZE",
|
|
84
|
+
"DEFAULT_CONCURRENCY",
|
|
85
|
+
"DEFAULT_MAX_MESSAGE_SIZE",
|
|
86
|
+
"DEFAULT_TIMEOUT",
|
|
87
|
+
"VERSION",
|
|
88
|
+
# Buffering
|
|
89
|
+
"BoundedQueue",
|
|
90
|
+
"BufferOverflowError",
|
|
91
|
+
"BufferPolicy",
|
|
92
|
+
"BufferStats",
|
|
93
|
+
# Transports
|
|
94
|
+
"Channel",
|
|
95
|
+
# Concurrency
|
|
96
|
+
"ConcurrencyLimiter",
|
|
97
|
+
"ConfigurationError",
|
|
98
|
+
"ConnectionFailedError",
|
|
99
|
+
"Counter",
|
|
100
|
+
# Exceptions
|
|
101
|
+
"DataCurrentError",
|
|
102
|
+
"EmptyStreamError",
|
|
103
|
+
# Events
|
|
104
|
+
"Event",
|
|
105
|
+
"EventBus",
|
|
106
|
+
"EventBusError",
|
|
107
|
+
"HTTPStream",
|
|
108
|
+
"MessageTooLargeError",
|
|
109
|
+
# Observability
|
|
110
|
+
"Metrics",
|
|
111
|
+
"NonReplayableStreamError",
|
|
112
|
+
"OptionalDependencyError",
|
|
113
|
+
"OverflowPolicy",
|
|
114
|
+
"QueueClosedError",
|
|
115
|
+
"QueueEmptyError",
|
|
116
|
+
"RetryError",
|
|
117
|
+
# Reliability
|
|
118
|
+
"RetryPolicy",
|
|
119
|
+
"SSEEvent",
|
|
120
|
+
"SSEProtocolError",
|
|
121
|
+
"SSEStream",
|
|
122
|
+
# Core engine
|
|
123
|
+
"Stream",
|
|
124
|
+
"StreamClosedError",
|
|
125
|
+
"StreamConfig",
|
|
126
|
+
"StreamConsumedError",
|
|
127
|
+
"StreamError",
|
|
128
|
+
"StreamIterator",
|
|
129
|
+
"StreamProtocolError",
|
|
130
|
+
"StreamStats",
|
|
131
|
+
"StreamTimeoutError",
|
|
132
|
+
"StreamTransport",
|
|
133
|
+
"Subscription",
|
|
134
|
+
"SubscriptionClosedError",
|
|
135
|
+
"TaskTracker",
|
|
136
|
+
"TransportError",
|
|
137
|
+
"WebSocketClosedError",
|
|
138
|
+
"WebSocketStream",
|
|
139
|
+
# Version
|
|
140
|
+
"__version__",
|
|
141
|
+
"concat_sources",
|
|
142
|
+
"consume",
|
|
143
|
+
"drain_into",
|
|
144
|
+
"format_heartbeat",
|
|
145
|
+
"format_sse",
|
|
146
|
+
"gather_limited",
|
|
147
|
+
"get_logger",
|
|
148
|
+
"matches",
|
|
149
|
+
"merge_sources",
|
|
150
|
+
"parse_sse",
|
|
151
|
+
"pipeline",
|
|
152
|
+
"redact_headers",
|
|
153
|
+
"redact_secret",
|
|
154
|
+
"redact_url",
|
|
155
|
+
"retry_async",
|
|
156
|
+
"with_timeout",
|
|
157
|
+
]
|
datacurrent/_version.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Normalized buffer settings shared by streams, channels and the event bus."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..config import DEFAULT_BUFFER_SIZE, OverflowPolicy, StreamConfig
|
|
9
|
+
from ..exceptions import ConfigurationError
|
|
10
|
+
from .queue import BoundedQueue
|
|
11
|
+
|
|
12
|
+
__all__ = ["BufferPolicy"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class BufferPolicy:
|
|
17
|
+
"""A size + overflow policy pair that can build :class:`BoundedQueue` objects."""
|
|
18
|
+
|
|
19
|
+
size: int = DEFAULT_BUFFER_SIZE
|
|
20
|
+
overflow: OverflowPolicy = OverflowPolicy.BLOCK
|
|
21
|
+
|
|
22
|
+
def __post_init__(self) -> None:
|
|
23
|
+
object.__setattr__(self, "overflow", OverflowPolicy.coerce(self.overflow))
|
|
24
|
+
if isinstance(self.size, bool) or not isinstance(self.size, int):
|
|
25
|
+
raise ConfigurationError(
|
|
26
|
+
f"buffer size must be an integer, got {type(self.size).__name__}"
|
|
27
|
+
)
|
|
28
|
+
if self.size < 1:
|
|
29
|
+
raise ConfigurationError(f"buffer size must be >= 1, got {self.size}")
|
|
30
|
+
|
|
31
|
+
def build(self, *, name: str | None = None, on_drop: Any = None) -> BoundedQueue[Any]:
|
|
32
|
+
"""Create a queue configured by this policy."""
|
|
33
|
+
return BoundedQueue(self.size, overflow=self.overflow, name=name, on_drop=on_drop)
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_config(
|
|
37
|
+
cls,
|
|
38
|
+
config: StreamConfig,
|
|
39
|
+
*,
|
|
40
|
+
size: int | None = None,
|
|
41
|
+
overflow: OverflowPolicy | str | None = None,
|
|
42
|
+
) -> BufferPolicy:
|
|
43
|
+
"""Derive a policy from a stream config, allowing per-call overrides."""
|
|
44
|
+
return cls(
|
|
45
|
+
size=config.buffer_size if size is None else size,
|
|
46
|
+
overflow=OverflowPolicy.coerce(config.overflow if overflow is None else overflow),
|
|
47
|
+
)
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Bounded asynchronous queues with explicit overflow policies.
|
|
2
|
+
|
|
3
|
+
The queue is the backpressure primitive used by ``Stream.buffer()``,
|
|
4
|
+
``Stream.merge()``, :class:`~datacurrent.Channel` and the event bus. It never
|
|
5
|
+
silently drops data: the default policy blocks the producer.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from collections import deque
|
|
12
|
+
from collections.abc import AsyncIterator, Callable
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Generic, TypeVar
|
|
15
|
+
|
|
16
|
+
from ..config import DEFAULT_BUFFER_SIZE, OverflowPolicy
|
|
17
|
+
from ..exceptions import (
|
|
18
|
+
BufferOverflowError,
|
|
19
|
+
ConfigurationError,
|
|
20
|
+
QueueClosedError,
|
|
21
|
+
QueueEmptyError,
|
|
22
|
+
)
|
|
23
|
+
from ..observability.logging import get_logger
|
|
24
|
+
|
|
25
|
+
T = TypeVar("T")
|
|
26
|
+
|
|
27
|
+
_EMPTY = object()
|
|
28
|
+
_log = get_logger("buffering")
|
|
29
|
+
|
|
30
|
+
__all__ = ["BoundedQueue", "BufferStats"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class BufferStats:
|
|
35
|
+
"""Point-in-time snapshot of a queue."""
|
|
36
|
+
|
|
37
|
+
size: int
|
|
38
|
+
maxsize: int
|
|
39
|
+
enqueued: int
|
|
40
|
+
dequeued: int
|
|
41
|
+
dropped: int
|
|
42
|
+
overflow: int
|
|
43
|
+
high_watermark: int
|
|
44
|
+
closed: bool
|
|
45
|
+
|
|
46
|
+
def as_dict(self) -> dict[str, Any]:
|
|
47
|
+
return {
|
|
48
|
+
"size": self.size,
|
|
49
|
+
"maxsize": self.maxsize,
|
|
50
|
+
"enqueued": self.enqueued,
|
|
51
|
+
"dequeued": self.dequeued,
|
|
52
|
+
"dropped": self.dropped,
|
|
53
|
+
"overflow": self.overflow,
|
|
54
|
+
"high_watermark": self.high_watermark,
|
|
55
|
+
"closed": self.closed,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class BoundedQueue(Generic[T]):
|
|
60
|
+
"""An asyncio queue with a bounded size and a configurable overflow policy.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
maxsize: Maximum number of buffered items (must be >= 1).
|
|
64
|
+
overflow: Policy applied when the buffer is full. ``BLOCK`` applies
|
|
65
|
+
backpressure, ``DROP_NEWEST`` / ``DROP_OLDEST`` discard one item per
|
|
66
|
+
overflow, and ``ERROR`` raises :class:`BufferOverflowError`.
|
|
67
|
+
name: Optional name used in logging and error messages.
|
|
68
|
+
on_drop: Called once per dropped item (used for stream statistics).
|
|
69
|
+
|
|
70
|
+
The queue is intended for use from a single event loop. It is not thread
|
|
71
|
+
safe and does not perform cross-loop wakeups.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
__slots__ = (
|
|
75
|
+
"_closed",
|
|
76
|
+
"_cond",
|
|
77
|
+
"_dequeued",
|
|
78
|
+
"_dropped",
|
|
79
|
+
"_enqueued",
|
|
80
|
+
"_error",
|
|
81
|
+
"_high_watermark",
|
|
82
|
+
"_items",
|
|
83
|
+
"_maxsize",
|
|
84
|
+
"_name",
|
|
85
|
+
"_on_drop",
|
|
86
|
+
"_overflow",
|
|
87
|
+
"_overflow_count",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
maxsize: int = DEFAULT_BUFFER_SIZE,
|
|
93
|
+
*,
|
|
94
|
+
overflow: OverflowPolicy | str = OverflowPolicy.BLOCK,
|
|
95
|
+
name: str | None = None,
|
|
96
|
+
on_drop: Callable[[], None] | None = None,
|
|
97
|
+
) -> None:
|
|
98
|
+
if isinstance(maxsize, bool) or not isinstance(maxsize, int):
|
|
99
|
+
raise ConfigurationError(f"maxsize must be an integer, got {type(maxsize).__name__}")
|
|
100
|
+
if maxsize < 1:
|
|
101
|
+
raise ConfigurationError(f"maxsize must be >= 1, got {maxsize}")
|
|
102
|
+
self._items: deque[T] = deque()
|
|
103
|
+
self._maxsize = maxsize
|
|
104
|
+
self._overflow = OverflowPolicy.coerce(overflow)
|
|
105
|
+
self._cond = asyncio.Condition()
|
|
106
|
+
self._closed = False
|
|
107
|
+
self._error: BaseException | None = None
|
|
108
|
+
self._name = name
|
|
109
|
+
self._on_drop = on_drop
|
|
110
|
+
self._enqueued = 0
|
|
111
|
+
self._dequeued = 0
|
|
112
|
+
self._dropped = 0
|
|
113
|
+
self._overflow_count = 0
|
|
114
|
+
self._high_watermark = 0
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------------ #
|
|
117
|
+
# Introspection
|
|
118
|
+
# ------------------------------------------------------------------ #
|
|
119
|
+
@property
|
|
120
|
+
def name(self) -> str | None:
|
|
121
|
+
return self._name
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def maxsize(self) -> int:
|
|
125
|
+
return self._maxsize
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def overflow(self) -> OverflowPolicy:
|
|
129
|
+
return self._overflow
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def closed(self) -> bool:
|
|
133
|
+
return self._closed
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def error(self) -> BaseException | None:
|
|
137
|
+
"""The failure that terminated the queue, if any."""
|
|
138
|
+
return self._error
|
|
139
|
+
|
|
140
|
+
def qsize(self) -> int:
|
|
141
|
+
return len(self._items)
|
|
142
|
+
|
|
143
|
+
def empty(self) -> bool:
|
|
144
|
+
return not self._items
|
|
145
|
+
|
|
146
|
+
def full(self) -> bool:
|
|
147
|
+
return len(self._items) >= self._maxsize
|
|
148
|
+
|
|
149
|
+
def stats(self) -> BufferStats:
|
|
150
|
+
return BufferStats(
|
|
151
|
+
size=len(self._items),
|
|
152
|
+
maxsize=self._maxsize,
|
|
153
|
+
enqueued=self._enqueued,
|
|
154
|
+
dequeued=self._dequeued,
|
|
155
|
+
dropped=self._dropped,
|
|
156
|
+
overflow=self._overflow_count,
|
|
157
|
+
high_watermark=self._high_watermark,
|
|
158
|
+
closed=self._closed,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def _label(self) -> str:
|
|
162
|
+
return f" {self._name!r}" if self._name else ""
|
|
163
|
+
|
|
164
|
+
def _record_drop(self) -> None:
|
|
165
|
+
self._dropped += 1
|
|
166
|
+
self._overflow_count += 1
|
|
167
|
+
if self._on_drop is not None:
|
|
168
|
+
self._on_drop()
|
|
169
|
+
|
|
170
|
+
# ------------------------------------------------------------------ #
|
|
171
|
+
# Writing
|
|
172
|
+
# ------------------------------------------------------------------ #
|
|
173
|
+
async def put(self, item: T) -> bool:
|
|
174
|
+
"""Insert ``item``.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
``True`` if the item was stored, ``False`` if it was dropped by a
|
|
178
|
+
drop policy.
|
|
179
|
+
|
|
180
|
+
Raises:
|
|
181
|
+
QueueClosedError: The queue has been closed.
|
|
182
|
+
BufferOverflowError: The queue is full and the policy is ``ERROR``.
|
|
183
|
+
"""
|
|
184
|
+
async with self._cond:
|
|
185
|
+
while True:
|
|
186
|
+
if self._closed:
|
|
187
|
+
raise QueueClosedError(f"queue{self._label()} is closed")
|
|
188
|
+
if len(self._items) < self._maxsize:
|
|
189
|
+
break
|
|
190
|
+
policy = self._overflow
|
|
191
|
+
if policy is OverflowPolicy.DROP_NEWEST:
|
|
192
|
+
self._record_drop()
|
|
193
|
+
self._cond.notify_all()
|
|
194
|
+
return False
|
|
195
|
+
if policy is OverflowPolicy.DROP_OLDEST:
|
|
196
|
+
self._items.popleft()
|
|
197
|
+
self._record_drop()
|
|
198
|
+
break
|
|
199
|
+
if policy is OverflowPolicy.ERROR:
|
|
200
|
+
self._overflow_count += 1
|
|
201
|
+
raise BufferOverflowError(self._maxsize, policy=policy, dropped=self._dropped)
|
|
202
|
+
await self._cond.wait()
|
|
203
|
+
|
|
204
|
+
self._items.append(item)
|
|
205
|
+
self._enqueued += 1
|
|
206
|
+
if len(self._items) > self._high_watermark:
|
|
207
|
+
self._high_watermark = len(self._items)
|
|
208
|
+
self._cond.notify_all()
|
|
209
|
+
return True
|
|
210
|
+
|
|
211
|
+
async def put_many(self, items: Any) -> int:
|
|
212
|
+
"""Insert every item from an iterable, returning the number stored."""
|
|
213
|
+
stored = 0
|
|
214
|
+
for item in items:
|
|
215
|
+
if await self.put(item):
|
|
216
|
+
stored += 1
|
|
217
|
+
return stored
|
|
218
|
+
|
|
219
|
+
# ------------------------------------------------------------------ #
|
|
220
|
+
# Reading
|
|
221
|
+
# ------------------------------------------------------------------ #
|
|
222
|
+
async def _take(self, default: Any) -> Any:
|
|
223
|
+
async with self._cond:
|
|
224
|
+
while not self._items:
|
|
225
|
+
if self._error is not None:
|
|
226
|
+
raise self._error
|
|
227
|
+
if self._closed:
|
|
228
|
+
return default
|
|
229
|
+
await self._cond.wait()
|
|
230
|
+
item = self._items.popleft()
|
|
231
|
+
self._dequeued += 1
|
|
232
|
+
self._cond.notify_all()
|
|
233
|
+
return item
|
|
234
|
+
|
|
235
|
+
async def get(self) -> T:
|
|
236
|
+
"""Return the oldest item, waiting if necessary.
|
|
237
|
+
|
|
238
|
+
Raises:
|
|
239
|
+
QueueClosedError: The queue is closed and empty.
|
|
240
|
+
"""
|
|
241
|
+
item = await self._take(_EMPTY)
|
|
242
|
+
if item is _EMPTY:
|
|
243
|
+
raise QueueClosedError(f"queue{self._label()} is closed and empty")
|
|
244
|
+
return item
|
|
245
|
+
|
|
246
|
+
async def get_nowait(self) -> T:
|
|
247
|
+
"""Return the oldest item without waiting.
|
|
248
|
+
|
|
249
|
+
Raises:
|
|
250
|
+
QueueEmptyError: No item is buffered.
|
|
251
|
+
"""
|
|
252
|
+
async with self._cond:
|
|
253
|
+
if not self._items:
|
|
254
|
+
if self._error is not None:
|
|
255
|
+
raise self._error
|
|
256
|
+
raise QueueEmptyError(f"queue{self._label()} is empty")
|
|
257
|
+
item = self._items.popleft()
|
|
258
|
+
self._dequeued += 1
|
|
259
|
+
self._cond.notify_all()
|
|
260
|
+
return item
|
|
261
|
+
|
|
262
|
+
def __aiter__(self) -> AsyncIterator[T]:
|
|
263
|
+
return self._iterate()
|
|
264
|
+
|
|
265
|
+
async def _iterate(self) -> AsyncIterator[T]:
|
|
266
|
+
while True:
|
|
267
|
+
item = await self._take(_EMPTY)
|
|
268
|
+
if item is _EMPTY:
|
|
269
|
+
return
|
|
270
|
+
yield item
|
|
271
|
+
|
|
272
|
+
# ------------------------------------------------------------------ #
|
|
273
|
+
# Lifecycle
|
|
274
|
+
# ------------------------------------------------------------------ #
|
|
275
|
+
async def aclose(self) -> None:
|
|
276
|
+
"""Close the queue. Buffered items can still be drained."""
|
|
277
|
+
async with self._cond:
|
|
278
|
+
if self._closed:
|
|
279
|
+
return
|
|
280
|
+
self._closed = True
|
|
281
|
+
self._cond.notify_all()
|
|
282
|
+
|
|
283
|
+
async def fail(self, error: BaseException) -> None:
|
|
284
|
+
"""Store ``error`` and close the queue; consumers re-raise it when drained."""
|
|
285
|
+
if not isinstance(error, BaseException):
|
|
286
|
+
raise TypeError("error must be an exception instance")
|
|
287
|
+
async with self._cond:
|
|
288
|
+
if self._error is None:
|
|
289
|
+
self._error = error
|
|
290
|
+
self._closed = True
|
|
291
|
+
self._cond.notify_all()
|
|
292
|
+
_log.debug("queue%s failed: %r", self._label(), error)
|
|
293
|
+
|
|
294
|
+
async def __aenter__(self) -> BoundedQueue[T]:
|
|
295
|
+
return self
|
|
296
|
+
|
|
297
|
+
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
298
|
+
await self.aclose()
|
|
299
|
+
|
|
300
|
+
def __repr__(self) -> str:
|
|
301
|
+
return (
|
|
302
|
+
f"BoundedQueue(size={len(self._items)}, maxsize={self._maxsize}, "
|
|
303
|
+
f"overflow={self._overflow.value!r}, closed={self._closed})"
|
|
304
|
+
)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Concurrency: bounded limits and structured background tasks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .limits import ConcurrencyLimiter, gather_limited
|
|
6
|
+
from .tasks import TaskTracker
|
|
7
|
+
|
|
8
|
+
__all__ = ["ConcurrencyLimiter", "TaskTracker", "gather_limited"]
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Limits and helpers for bounded concurrency."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Awaitable, Callable, Iterable
|
|
7
|
+
from typing import Any, TypeVar, cast
|
|
8
|
+
|
|
9
|
+
from ..core._util import maybe_await
|
|
10
|
+
from ..exceptions import ConfigurationError
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
__all__ = ["ConcurrencyLimiter", "gather_limited"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConcurrencyLimiter:
|
|
18
|
+
"""A semaphore with introspection.
|
|
19
|
+
|
|
20
|
+
``limit`` workers may run at once; additional callers wait instead of
|
|
21
|
+
spawning unbounded tasks.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
__slots__ = ("_active", "_limit", "_peak", "_semaphore", "_waiting")
|
|
25
|
+
|
|
26
|
+
def __init__(self, limit: int) -> None:
|
|
27
|
+
if isinstance(limit, bool) or not isinstance(limit, int):
|
|
28
|
+
raise ConfigurationError(
|
|
29
|
+
f"concurrency limit must be an integer, got {type(limit).__name__}"
|
|
30
|
+
)
|
|
31
|
+
if limit < 1:
|
|
32
|
+
raise ConfigurationError(f"concurrency limit must be >= 1, got {limit}")
|
|
33
|
+
self._limit = limit
|
|
34
|
+
self._semaphore = asyncio.Semaphore(limit)
|
|
35
|
+
self._active = 0
|
|
36
|
+
self._peak = 0
|
|
37
|
+
self._waiting = 0
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def limit(self) -> int:
|
|
41
|
+
return self._limit
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def active(self) -> int:
|
|
45
|
+
return self._active
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def waiting(self) -> int:
|
|
49
|
+
return self._waiting
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def peak(self) -> int:
|
|
53
|
+
return self._peak
|
|
54
|
+
|
|
55
|
+
async def acquire(self) -> None:
|
|
56
|
+
self._waiting += 1
|
|
57
|
+
try:
|
|
58
|
+
await self._semaphore.acquire()
|
|
59
|
+
finally:
|
|
60
|
+
self._waiting -= 1
|
|
61
|
+
self._active += 1
|
|
62
|
+
self._peak = max(self._peak, self._active)
|
|
63
|
+
|
|
64
|
+
def release(self) -> None:
|
|
65
|
+
if self._active > 0:
|
|
66
|
+
self._active -= 1
|
|
67
|
+
self._semaphore.release()
|
|
68
|
+
|
|
69
|
+
async def __aenter__(self) -> ConcurrencyLimiter:
|
|
70
|
+
await self.acquire()
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
74
|
+
self.release()
|
|
75
|
+
|
|
76
|
+
async def run(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
|
77
|
+
async with self:
|
|
78
|
+
return await maybe_await(func(*args, **kwargs))
|
|
79
|
+
|
|
80
|
+
def __repr__(self) -> str: # pragma: no cover - trivial
|
|
81
|
+
return (
|
|
82
|
+
f"ConcurrencyLimiter(limit={self._limit}, active={self._active}, "
|
|
83
|
+
f"waiting={self._waiting}, peak={self._peak})"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def gather_limited(
|
|
88
|
+
limit: int,
|
|
89
|
+
awaitables: Iterable[Awaitable[T]],
|
|
90
|
+
*,
|
|
91
|
+
return_exceptions: bool = False,
|
|
92
|
+
) -> list[T]:
|
|
93
|
+
"""Await ``awaitables`` with at most ``limit`` running at the same time."""
|
|
94
|
+
limiter = ConcurrencyLimiter(limit)
|
|
95
|
+
|
|
96
|
+
async def run(awaitable: Awaitable[T]) -> T:
|
|
97
|
+
async with limiter:
|
|
98
|
+
return await awaitable
|
|
99
|
+
|
|
100
|
+
return cast(
|
|
101
|
+
"list[T]",
|
|
102
|
+
await asyncio.gather(
|
|
103
|
+
*(run(awaitable) for awaitable in awaitables),
|
|
104
|
+
return_exceptions=return_exceptions,
|
|
105
|
+
),
|
|
106
|
+
)
|