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
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Server-Sent Events (SSE).
|
|
2
|
+
|
|
3
|
+
The parser follows the WHATWG event-stream rules: ``data`` lines are joined with
|
|
4
|
+
newlines, blank lines dispatch, comment lines are ignored, ``event`` type,
|
|
5
|
+
``id`` and ``retry`` fields are honoured. The client supports ``Last-Event-ID``
|
|
6
|
+
resumption, heartbeat comments and configurable reconnection limits.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
from collections.abc import AsyncIterable, AsyncIterator, Mapping
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from ..config import DEFAULT_TIMEOUT
|
|
18
|
+
from ..exceptions import (
|
|
19
|
+
ConfigurationError,
|
|
20
|
+
ConnectionFailedError,
|
|
21
|
+
SSEProtocolError,
|
|
22
|
+
StreamClosedError,
|
|
23
|
+
)
|
|
24
|
+
from ..observability.logging import redact_url
|
|
25
|
+
from ._deps import require_module
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"SSEEvent",
|
|
29
|
+
"SSELineDecoder",
|
|
30
|
+
"SSEParser",
|
|
31
|
+
"SSEStream",
|
|
32
|
+
"format_heartbeat",
|
|
33
|
+
"format_sse",
|
|
34
|
+
"parse_sse",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class SSEEvent:
|
|
40
|
+
"""A dispatched Server-Sent Event."""
|
|
41
|
+
|
|
42
|
+
data: str = ""
|
|
43
|
+
event: str = "message"
|
|
44
|
+
id: str | None = None
|
|
45
|
+
retry: int | None = None
|
|
46
|
+
|
|
47
|
+
def json(self) -> Any:
|
|
48
|
+
"""Parse ``data`` as JSON."""
|
|
49
|
+
return json.loads(self.data)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SSELineDecoder:
|
|
53
|
+
"""Split arbitrary text chunks into lines following the SSE line rules."""
|
|
54
|
+
|
|
55
|
+
__slots__ = ("_buffer",)
|
|
56
|
+
|
|
57
|
+
def __init__(self) -> None:
|
|
58
|
+
self._buffer = ""
|
|
59
|
+
|
|
60
|
+
def feed(self, chunk: str) -> list[str]:
|
|
61
|
+
if chunk:
|
|
62
|
+
self._buffer += chunk
|
|
63
|
+
lines: list[str] = []
|
|
64
|
+
while True:
|
|
65
|
+
index = -1
|
|
66
|
+
for candidate in ("\n", "\r"):
|
|
67
|
+
position = self._buffer.find(candidate)
|
|
68
|
+
if position != -1 and (index == -1 or position < index):
|
|
69
|
+
index = position
|
|
70
|
+
if index == -1:
|
|
71
|
+
break
|
|
72
|
+
character = self._buffer[index]
|
|
73
|
+
if character == "\r" and index == len(self._buffer) - 1:
|
|
74
|
+
# A CRLF pair may be split across chunks; wait for the next chunk.
|
|
75
|
+
break
|
|
76
|
+
line = self._buffer[:index]
|
|
77
|
+
skip = 1
|
|
78
|
+
if (
|
|
79
|
+
character == "\r"
|
|
80
|
+
and index + 1 < len(self._buffer)
|
|
81
|
+
and self._buffer[index + 1] == "\n"
|
|
82
|
+
):
|
|
83
|
+
skip = 2
|
|
84
|
+
self._buffer = self._buffer[index + skip :]
|
|
85
|
+
lines.append(line)
|
|
86
|
+
return lines
|
|
87
|
+
|
|
88
|
+
def flush(self) -> list[str]:
|
|
89
|
+
"""Return a trailing line that had no newline terminator."""
|
|
90
|
+
if not self._buffer:
|
|
91
|
+
return []
|
|
92
|
+
line = self._buffer
|
|
93
|
+
self._buffer = ""
|
|
94
|
+
return [line]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class SSEParser:
|
|
98
|
+
"""Incremental SSE event parser.
|
|
99
|
+
|
|
100
|
+
Feed complete lines with :meth:`feed_line`; a non-``None`` return value is a
|
|
101
|
+
dispatched event.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
__slots__ = ("_data", "_event", "_has_data", "_last_id", "_retry")
|
|
105
|
+
|
|
106
|
+
def __init__(self) -> None:
|
|
107
|
+
self._data: list[str] = []
|
|
108
|
+
self._event = "message"
|
|
109
|
+
self._has_data = False
|
|
110
|
+
self._last_id: str | None = None
|
|
111
|
+
self._retry: int | None = None
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def last_event_id(self) -> str | None:
|
|
115
|
+
return self._last_id
|
|
116
|
+
|
|
117
|
+
def feed_line(self, line: str) -> SSEEvent | None:
|
|
118
|
+
line = line.rstrip("\r\n")
|
|
119
|
+
if line == "":
|
|
120
|
+
return self._dispatch()
|
|
121
|
+
if line.startswith(":"):
|
|
122
|
+
return None
|
|
123
|
+
field, separator, value = line.partition(":")
|
|
124
|
+
if not separator:
|
|
125
|
+
value = ""
|
|
126
|
+
elif value.startswith(" "):
|
|
127
|
+
value = value[1:]
|
|
128
|
+
|
|
129
|
+
if field == "data":
|
|
130
|
+
self._data.append(value)
|
|
131
|
+
self._has_data = True
|
|
132
|
+
elif field == "event":
|
|
133
|
+
self._event = value or "message"
|
|
134
|
+
elif field == "id":
|
|
135
|
+
if "\x00" not in value:
|
|
136
|
+
self._last_id = value
|
|
137
|
+
elif field == "retry":
|
|
138
|
+
try:
|
|
139
|
+
parsed = int(value)
|
|
140
|
+
except ValueError:
|
|
141
|
+
return None
|
|
142
|
+
if parsed >= 0:
|
|
143
|
+
self._retry = parsed
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
def _dispatch(self) -> SSEEvent | None:
|
|
147
|
+
if not self._has_data:
|
|
148
|
+
self._event = "message"
|
|
149
|
+
return None
|
|
150
|
+
event = SSEEvent(
|
|
151
|
+
data="\n".join(self._data),
|
|
152
|
+
event=self._event or "message",
|
|
153
|
+
id=self._last_id,
|
|
154
|
+
retry=self._retry,
|
|
155
|
+
)
|
|
156
|
+
self._data = []
|
|
157
|
+
self._has_data = False
|
|
158
|
+
self._event = "message"
|
|
159
|
+
return event
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def parse_sse(lines: AsyncIterable[str | bytes]) -> AsyncIterator[SSEEvent]:
|
|
163
|
+
"""Parse an async iterable of text or bytes chunks into events."""
|
|
164
|
+
decoder = SSELineDecoder()
|
|
165
|
+
parser = SSEParser()
|
|
166
|
+
async for chunk in lines:
|
|
167
|
+
text = (
|
|
168
|
+
chunk.decode("utf-8", errors="replace")
|
|
169
|
+
if isinstance(chunk, (bytes, bytearray))
|
|
170
|
+
else str(chunk)
|
|
171
|
+
)
|
|
172
|
+
for line in decoder.feed(text):
|
|
173
|
+
event = parser.feed_line(line)
|
|
174
|
+
if event is not None:
|
|
175
|
+
yield event
|
|
176
|
+
for line in decoder.flush():
|
|
177
|
+
event = parser.feed_line(line)
|
|
178
|
+
if event is not None:
|
|
179
|
+
yield event
|
|
180
|
+
trailing = parser.feed_line("")
|
|
181
|
+
if trailing is not None:
|
|
182
|
+
yield trailing
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def format_sse(event: SSEEvent) -> str:
|
|
186
|
+
"""Encode an :class:`SSEEvent` for a server response."""
|
|
187
|
+
if not isinstance(event, SSEEvent):
|
|
188
|
+
raise TypeError(f"format_sse() expects an SSEEvent, got {type(event).__name__}")
|
|
189
|
+
lines: list[str] = []
|
|
190
|
+
if event.id is not None:
|
|
191
|
+
lines.append(f"id: {event.id}")
|
|
192
|
+
if event.event and event.event != "message":
|
|
193
|
+
lines.append(f"event: {event.event}")
|
|
194
|
+
if event.retry is not None:
|
|
195
|
+
lines.append(f"retry: {int(event.retry)}")
|
|
196
|
+
for line in event.data.split("\n"):
|
|
197
|
+
lines.append(f"data: {line}")
|
|
198
|
+
return "\n".join(lines) + "\n\n"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def format_heartbeat(comment: str = "keep-alive") -> str:
|
|
202
|
+
"""Encode an SSE comment line, used as a heartbeat."""
|
|
203
|
+
return f": {comment}\n\n"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class SSEStream:
|
|
207
|
+
"""Async client for an SSE endpoint.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
url: ``http`` or ``https`` endpoint.
|
|
211
|
+
headers: Extra request headers (credentials are never logged).
|
|
212
|
+
params: Query parameters.
|
|
213
|
+
method: HTTP method, ``GET`` by default.
|
|
214
|
+
timeout: Per-request timeout in seconds.
|
|
215
|
+
client: Optional ``httpx.AsyncClient``; one is created when omitted.
|
|
216
|
+
max_reconnects: Additional connections after the first one ends or
|
|
217
|
+
fails. ``0`` (default) never reconnects.
|
|
218
|
+
reconnect_delay / max_reconnect_delay: Exponential backoff bounds.
|
|
219
|
+
last_event_id: Resume from this event id on the first request.
|
|
220
|
+
check_content_type: Require ``text/event-stream`` responses.
|
|
221
|
+
|
|
222
|
+
The client yields :class:`SSEEvent` objects and tracks the last event id so a
|
|
223
|
+
reconnect sends ``Last-Event-ID`` automatically.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
def __init__(
|
|
227
|
+
self,
|
|
228
|
+
url: str,
|
|
229
|
+
*,
|
|
230
|
+
headers: Mapping[str, str] | None = None,
|
|
231
|
+
params: Mapping[str, Any] | None = None,
|
|
232
|
+
method: str = "GET",
|
|
233
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
234
|
+
client: Any = None,
|
|
235
|
+
max_reconnects: int = 0,
|
|
236
|
+
reconnect_delay: float = 1.0,
|
|
237
|
+
max_reconnect_delay: float = 30.0,
|
|
238
|
+
last_event_id: str | None = None,
|
|
239
|
+
check_content_type: bool = True,
|
|
240
|
+
) -> None:
|
|
241
|
+
if not isinstance(url, str) or not url.lower().startswith(("http://", "https://")):
|
|
242
|
+
raise ConfigurationError("SSEStream requires an http:// or https:// URL")
|
|
243
|
+
if isinstance(max_reconnects, bool) or not isinstance(max_reconnects, int):
|
|
244
|
+
raise ConfigurationError("max_reconnects must be an integer")
|
|
245
|
+
if max_reconnects < 0:
|
|
246
|
+
raise ConfigurationError("max_reconnects must be >= 0")
|
|
247
|
+
if reconnect_delay < 0 or max_reconnect_delay < 0:
|
|
248
|
+
raise ConfigurationError("reconnect delays must be >= 0")
|
|
249
|
+
self._url = url
|
|
250
|
+
self._headers = {"Accept": "text/event-stream", **dict(headers or {})}
|
|
251
|
+
self._params = dict(params) if params else None
|
|
252
|
+
self._method = method.upper()
|
|
253
|
+
self._timeout = float(timeout)
|
|
254
|
+
self._client = client
|
|
255
|
+
self._owned_client: Any = None
|
|
256
|
+
self._max_reconnects = max_reconnects
|
|
257
|
+
self._reconnect_delay = float(reconnect_delay)
|
|
258
|
+
self._max_reconnect_delay = float(max_reconnect_delay)
|
|
259
|
+
self._last_event_id = last_event_id
|
|
260
|
+
self._retry_hint: float | None = None
|
|
261
|
+
self._check_content_type = check_content_type
|
|
262
|
+
self._closed = False
|
|
263
|
+
|
|
264
|
+
# ------------------------------------------------------------------ #
|
|
265
|
+
# Construction / lifecycle
|
|
266
|
+
# ------------------------------------------------------------------ #
|
|
267
|
+
@classmethod
|
|
268
|
+
def connect(cls, url: str, **kwargs: Any) -> SSEStream:
|
|
269
|
+
"""Create a client; use it with ``async for`` or ``async with``."""
|
|
270
|
+
return cls(url, **kwargs)
|
|
271
|
+
|
|
272
|
+
@property
|
|
273
|
+
def url(self) -> str:
|
|
274
|
+
return self._url
|
|
275
|
+
|
|
276
|
+
@property
|
|
277
|
+
def last_event_id(self) -> str | None:
|
|
278
|
+
return self._last_event_id
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def closed(self) -> bool:
|
|
282
|
+
return self._closed
|
|
283
|
+
|
|
284
|
+
async def __aenter__(self) -> SSEStream:
|
|
285
|
+
return self
|
|
286
|
+
|
|
287
|
+
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
288
|
+
await self.aclose()
|
|
289
|
+
|
|
290
|
+
async def aclose(self) -> None:
|
|
291
|
+
"""Close the client and drop the owned HTTP client, if any."""
|
|
292
|
+
self._closed = True
|
|
293
|
+
client, self._owned_client = self._owned_client, None
|
|
294
|
+
if client is not None:
|
|
295
|
+
await client.aclose()
|
|
296
|
+
|
|
297
|
+
def __aiter__(self) -> AsyncIterator[SSEEvent]:
|
|
298
|
+
return self._events()
|
|
299
|
+
|
|
300
|
+
# ------------------------------------------------------------------ #
|
|
301
|
+
# Streaming
|
|
302
|
+
# ------------------------------------------------------------------ #
|
|
303
|
+
def _next_delay(self, reconnects: int) -> float:
|
|
304
|
+
if self._retry_hint is not None:
|
|
305
|
+
return self._retry_hint
|
|
306
|
+
base = self._reconnect_delay * (2 ** min(reconnects, 10))
|
|
307
|
+
return min(self._max_reconnect_delay, base)
|
|
308
|
+
|
|
309
|
+
async def _events(self) -> AsyncIterator[SSEEvent]:
|
|
310
|
+
httpx = require_module("httpx", purpose="SSEStream")
|
|
311
|
+
if self._closed:
|
|
312
|
+
raise StreamClosedError("SSEStream is closed")
|
|
313
|
+
if self._client is not None:
|
|
314
|
+
client = self._client
|
|
315
|
+
else:
|
|
316
|
+
client = httpx.AsyncClient(follow_redirects=True)
|
|
317
|
+
self._owned_client = client
|
|
318
|
+
|
|
319
|
+
reconnects = 0
|
|
320
|
+
try:
|
|
321
|
+
while True:
|
|
322
|
+
headers = dict(self._headers)
|
|
323
|
+
if self._last_event_id is not None:
|
|
324
|
+
headers["Last-Event-ID"] = self._last_event_id
|
|
325
|
+
try:
|
|
326
|
+
async with client.stream(
|
|
327
|
+
self._method,
|
|
328
|
+
self._url,
|
|
329
|
+
headers=headers,
|
|
330
|
+
params=self._params,
|
|
331
|
+
timeout=self._timeout,
|
|
332
|
+
) as response:
|
|
333
|
+
if response.status_code >= 400:
|
|
334
|
+
response.raise_for_status()
|
|
335
|
+
if self._check_content_type:
|
|
336
|
+
content_type = response.headers.get("content-type", "")
|
|
337
|
+
if "text/event-stream" not in content_type.lower():
|
|
338
|
+
raise SSEProtocolError(
|
|
339
|
+
f"expected a 'text/event-stream' response, got {content_type!r}"
|
|
340
|
+
)
|
|
341
|
+
# aiter_bytes() gives decoded body chunks; parse_sse splits
|
|
342
|
+
# lines itself so events spanning chunks are handled.
|
|
343
|
+
async for event in parse_sse(response.aiter_bytes()):
|
|
344
|
+
if event.id is not None:
|
|
345
|
+
self._last_event_id = event.id
|
|
346
|
+
if event.retry is not None:
|
|
347
|
+
self._retry_hint = event.retry / 1000.0
|
|
348
|
+
yield event
|
|
349
|
+
except asyncio.CancelledError:
|
|
350
|
+
raise
|
|
351
|
+
except SSEProtocolError:
|
|
352
|
+
raise
|
|
353
|
+
except httpx.HTTPStatusError:
|
|
354
|
+
raise
|
|
355
|
+
except (httpx.HTTPError, OSError) as error:
|
|
356
|
+
if reconnects >= self._max_reconnects:
|
|
357
|
+
raise ConnectionFailedError(
|
|
358
|
+
f"SSE connection to {redact_url(self._url)} failed: {error}"
|
|
359
|
+
) from error
|
|
360
|
+
delay = self._next_delay(reconnects)
|
|
361
|
+
reconnects += 1
|
|
362
|
+
await asyncio.sleep(delay)
|
|
363
|
+
continue
|
|
364
|
+
|
|
365
|
+
if reconnects >= self._max_reconnects:
|
|
366
|
+
return
|
|
367
|
+
delay = self._next_delay(reconnects)
|
|
368
|
+
reconnects += 1
|
|
369
|
+
if delay:
|
|
370
|
+
await asyncio.sleep(delay)
|
|
371
|
+
finally:
|
|
372
|
+
if self._owned_client is client:
|
|
373
|
+
self._owned_client = None
|
|
374
|
+
await client.aclose()
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""WebSocket streaming over the maintained ``websockets`` library."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
from collections.abc import AsyncIterator, Mapping
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..config import DEFAULT_MAX_MESSAGE_SIZE
|
|
12
|
+
from ..exceptions import (
|
|
13
|
+
ConfigurationError,
|
|
14
|
+
ConnectionFailedError,
|
|
15
|
+
MessageTooLargeError,
|
|
16
|
+
StreamClosedError,
|
|
17
|
+
WebSocketClosedError,
|
|
18
|
+
)
|
|
19
|
+
from ..observability.logging import get_logger, redact_headers, redact_url
|
|
20
|
+
from ._deps import require_module
|
|
21
|
+
|
|
22
|
+
__all__ = ["WebSocketStream"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _close_details(error: Any) -> tuple[int | None, str | None]:
|
|
26
|
+
"""Read close code/reason without the deprecated attributes."""
|
|
27
|
+
frame = getattr(error, "rcvd", None) or getattr(error, "sent", None)
|
|
28
|
+
code = getattr(frame, "code", None)
|
|
29
|
+
reason = getattr(frame, "reason", None)
|
|
30
|
+
if code is None:
|
|
31
|
+
code = getattr(error, "code", None)
|
|
32
|
+
if reason is None:
|
|
33
|
+
reason = getattr(error, "reason", None)
|
|
34
|
+
return code, reason
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_log = get_logger("websocket")
|
|
38
|
+
|
|
39
|
+
_VALID_MODES = {"auto", "text", "json"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class WebSocketStream:
|
|
43
|
+
"""A WebSocket client that behaves like an async sequence.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
url: ``ws://`` or ``wss://`` endpoint.
|
|
47
|
+
headers: Handshake headers; values are redacted in logs and errors.
|
|
48
|
+
mode: ``auto`` yields ``str``/``bytes`` as received, ``text`` decodes
|
|
49
|
+
binary frames to text, ``json`` parses text frames as JSON.
|
|
50
|
+
max_size: Maximum accepted message size in bytes.
|
|
51
|
+
ping_interval / ping_timeout: Protocol heartbeats in seconds.
|
|
52
|
+
open_timeout / close_timeout: Handshake and close deadlines.
|
|
53
|
+
reconnect_attempts: Automatic reconnects after an abnormal close.
|
|
54
|
+
reconnect_delay / max_reconnect_delay: Exponential backoff bounds.
|
|
55
|
+
subprotocols: Optional WebSocket subprotocols.
|
|
56
|
+
ssl: Optional TLS context passed to ``websockets``.
|
|
57
|
+
origin: Optional ``Origin`` header.
|
|
58
|
+
|
|
59
|
+
TLS certificates are verified by default: ``wss://`` uses the system trust
|
|
60
|
+
store through ``websockets``. Only an explicit ``ssl=`` context can change
|
|
61
|
+
that, and doing so is the caller's decision.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
url: str,
|
|
67
|
+
*,
|
|
68
|
+
headers: Mapping[str, str] | None = None,
|
|
69
|
+
mode: str = "auto",
|
|
70
|
+
max_size: int = DEFAULT_MAX_MESSAGE_SIZE,
|
|
71
|
+
ping_interval: float | None = 20.0,
|
|
72
|
+
ping_timeout: float | None = 20.0,
|
|
73
|
+
open_timeout: float = 10.0,
|
|
74
|
+
close_timeout: float = 5.0,
|
|
75
|
+
reconnect_attempts: int = 0,
|
|
76
|
+
reconnect_delay: float = 0.5,
|
|
77
|
+
max_reconnect_delay: float = 30.0,
|
|
78
|
+
subprotocols: list[str] | None = None,
|
|
79
|
+
ssl: Any = None,
|
|
80
|
+
origin: str | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
if not isinstance(url, str) or not url.lower().startswith(("ws://", "wss://")):
|
|
83
|
+
raise ConfigurationError("WebSocketStream requires a ws:// or wss:// URL")
|
|
84
|
+
if mode not in _VALID_MODES:
|
|
85
|
+
raise ConfigurationError(f"mode must be one of {sorted(_VALID_MODES)}, got {mode!r}")
|
|
86
|
+
if isinstance(reconnect_attempts, bool) or not isinstance(reconnect_attempts, int):
|
|
87
|
+
raise ConfigurationError("reconnect_attempts must be an integer")
|
|
88
|
+
if reconnect_attempts < 0:
|
|
89
|
+
raise ConfigurationError("reconnect_attempts must be >= 0")
|
|
90
|
+
if max_size < 1:
|
|
91
|
+
raise ConfigurationError("max_size must be >= 1")
|
|
92
|
+
if ping_interval is not None and ping_interval < 0:
|
|
93
|
+
raise ConfigurationError("ping_interval must be >= 0 or None")
|
|
94
|
+
self._url = url
|
|
95
|
+
self._headers = dict(headers or {})
|
|
96
|
+
self._mode = mode
|
|
97
|
+
self._max_size = max_size
|
|
98
|
+
self._ping_interval = ping_interval
|
|
99
|
+
self._ping_timeout = ping_timeout
|
|
100
|
+
self._open_timeout = open_timeout
|
|
101
|
+
self._close_timeout = close_timeout
|
|
102
|
+
self._reconnect_attempts = reconnect_attempts
|
|
103
|
+
self._reconnect_delay = reconnect_delay
|
|
104
|
+
self._max_reconnect_delay = max_reconnect_delay
|
|
105
|
+
self._subprotocols = subprotocols
|
|
106
|
+
self._ssl = ssl
|
|
107
|
+
self._origin = origin
|
|
108
|
+
self._ws: Any = None
|
|
109
|
+
self._closed = False
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------ #
|
|
112
|
+
# Lifecycle
|
|
113
|
+
# ------------------------------------------------------------------ #
|
|
114
|
+
@classmethod
|
|
115
|
+
def connect(cls, url: str, **kwargs: Any) -> WebSocketStream:
|
|
116
|
+
"""Create a client; use ``async with WebSocketStream.connect(...)``."""
|
|
117
|
+
return cls(url, **kwargs)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def url(self) -> str:
|
|
121
|
+
return self._url
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def connected(self) -> bool:
|
|
125
|
+
return self._ws is not None
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def protocol(self) -> str | None:
|
|
129
|
+
return None if self._ws is None else getattr(self._ws, "subprotocol", None)
|
|
130
|
+
|
|
131
|
+
async def __aenter__(self) -> WebSocketStream:
|
|
132
|
+
await self._connect()
|
|
133
|
+
return self
|
|
134
|
+
|
|
135
|
+
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
136
|
+
await self.aclose()
|
|
137
|
+
|
|
138
|
+
async def _connect(self) -> None:
|
|
139
|
+
websockets = require_module("websockets", purpose="WebSocketStream")
|
|
140
|
+
parameters = inspect.signature(websockets.connect).parameters
|
|
141
|
+
header_key = "additional_headers" if "additional_headers" in parameters else "extra_headers"
|
|
142
|
+
kwargs: dict[str, Any] = {
|
|
143
|
+
header_key: self._headers,
|
|
144
|
+
"max_size": self._max_size,
|
|
145
|
+
"ping_interval": self._ping_interval,
|
|
146
|
+
"ping_timeout": self._ping_timeout,
|
|
147
|
+
"open_timeout": self._open_timeout,
|
|
148
|
+
"close_timeout": self._close_timeout,
|
|
149
|
+
}
|
|
150
|
+
if self._subprotocols:
|
|
151
|
+
kwargs["subprotocols"] = self._subprotocols
|
|
152
|
+
if self._ssl is not None:
|
|
153
|
+
kwargs["ssl"] = self._ssl
|
|
154
|
+
if self._origin is not None:
|
|
155
|
+
kwargs["origin"] = self._origin
|
|
156
|
+
try:
|
|
157
|
+
self._ws = await websockets.connect(self._url, **kwargs)
|
|
158
|
+
except asyncio.CancelledError:
|
|
159
|
+
raise
|
|
160
|
+
except Exception as error:
|
|
161
|
+
raise ConnectionFailedError(
|
|
162
|
+
f"could not connect to {redact_url(self._url)}: {error}"
|
|
163
|
+
) from error
|
|
164
|
+
_log.debug(
|
|
165
|
+
"websocket connected url=%s headers=%s",
|
|
166
|
+
redact_url(self._url),
|
|
167
|
+
redact_headers(self._headers),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
async def aclose(self) -> None:
|
|
171
|
+
"""Close the connection without raising on an already closed socket."""
|
|
172
|
+
self._closed = True
|
|
173
|
+
ws, self._ws = self._ws, None
|
|
174
|
+
if ws is None:
|
|
175
|
+
return
|
|
176
|
+
try:
|
|
177
|
+
await ws.close()
|
|
178
|
+
except asyncio.CancelledError:
|
|
179
|
+
raise
|
|
180
|
+
except Exception: # pragma: no cover - close races are harmless
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
# ------------------------------------------------------------------ #
|
|
184
|
+
# Sending
|
|
185
|
+
# ------------------------------------------------------------------ #
|
|
186
|
+
def _require_ws(self) -> Any:
|
|
187
|
+
if self._ws is None:
|
|
188
|
+
raise StreamClosedError(
|
|
189
|
+
"websocket is not connected; use 'async with WebSocketStream.connect(...)'"
|
|
190
|
+
)
|
|
191
|
+
return self._ws
|
|
192
|
+
|
|
193
|
+
async def send(self, data: str | bytes) -> None:
|
|
194
|
+
"""Send a text or binary frame."""
|
|
195
|
+
await self._require_ws().send(data)
|
|
196
|
+
|
|
197
|
+
async def send_json(self, value: Any) -> None:
|
|
198
|
+
"""Serialize ``value`` and send it as a text frame."""
|
|
199
|
+
await self._require_ws().send(json.dumps(value))
|
|
200
|
+
|
|
201
|
+
async def ping(self) -> None:
|
|
202
|
+
"""Send a ping and wait for the pong."""
|
|
203
|
+
waiter = await self._require_ws().ping()
|
|
204
|
+
await waiter
|
|
205
|
+
|
|
206
|
+
# ------------------------------------------------------------------ #
|
|
207
|
+
# Receiving
|
|
208
|
+
# ------------------------------------------------------------------ #
|
|
209
|
+
def _decode(self, message: str | bytes) -> Any:
|
|
210
|
+
size = len(message.encode("utf-8") if isinstance(message, str) else message)
|
|
211
|
+
if size > self._max_size:
|
|
212
|
+
raise MessageTooLargeError(size, self._max_size)
|
|
213
|
+
if self._mode == "json":
|
|
214
|
+
if isinstance(message, bytes):
|
|
215
|
+
message = message.decode("utf-8")
|
|
216
|
+
return json.loads(message)
|
|
217
|
+
if self._mode == "text" and isinstance(message, bytes):
|
|
218
|
+
return message.decode("utf-8")
|
|
219
|
+
return message
|
|
220
|
+
|
|
221
|
+
async def recv(self) -> Any:
|
|
222
|
+
"""Receive one message, reconnecting when configured."""
|
|
223
|
+
websockets = require_module("websockets", purpose="WebSocketStream")
|
|
224
|
+
if self._ws is None:
|
|
225
|
+
await self._connect()
|
|
226
|
+
assert self._ws is not None
|
|
227
|
+
try:
|
|
228
|
+
message = await self._ws.recv()
|
|
229
|
+
except websockets.ConnectionClosed as error:
|
|
230
|
+
code, reason = _close_details(error)
|
|
231
|
+
raise WebSocketClosedError(code, reason) from error
|
|
232
|
+
return self._decode(message)
|
|
233
|
+
|
|
234
|
+
def __aiter__(self) -> AsyncIterator[Any]:
|
|
235
|
+
return self._messages()
|
|
236
|
+
|
|
237
|
+
async def _messages(self) -> AsyncIterator[Any]:
|
|
238
|
+
websockets = require_module("websockets", purpose="WebSocketStream")
|
|
239
|
+
if self._closed:
|
|
240
|
+
raise StreamClosedError("WebSocketStream is closed")
|
|
241
|
+
if self._ws is None:
|
|
242
|
+
await self._connect()
|
|
243
|
+
attempts = 0
|
|
244
|
+
while True:
|
|
245
|
+
ws = self._ws
|
|
246
|
+
if ws is None:
|
|
247
|
+
raise StreamClosedError("websocket is not connected")
|
|
248
|
+
try:
|
|
249
|
+
async for message in ws:
|
|
250
|
+
yield self._decode(message)
|
|
251
|
+
return
|
|
252
|
+
except asyncio.CancelledError:
|
|
253
|
+
raise
|
|
254
|
+
except websockets.ConnectionClosed as error:
|
|
255
|
+
code, reason = _close_details(error)
|
|
256
|
+
if self._closed:
|
|
257
|
+
raise WebSocketClosedError(code, reason) from error
|
|
258
|
+
if code == 1000:
|
|
259
|
+
return
|
|
260
|
+
if attempts >= self._reconnect_attempts:
|
|
261
|
+
raise WebSocketClosedError(code, reason) from error
|
|
262
|
+
attempts += 1
|
|
263
|
+
await self.aclose()
|
|
264
|
+
self._closed = False
|
|
265
|
+
delay = min(
|
|
266
|
+
self._max_reconnect_delay,
|
|
267
|
+
self._reconnect_delay * (2 ** (attempts - 1)),
|
|
268
|
+
)
|
|
269
|
+
if delay:
|
|
270
|
+
await asyncio.sleep(delay)
|
|
271
|
+
await self._connect()
|
|
272
|
+
|
|
273
|
+
def __repr__(self) -> str: # pragma: no cover - trivial
|
|
274
|
+
return (
|
|
275
|
+
f"WebSocketStream(url={self._url!r}, mode={self._mode!r}, "
|
|
276
|
+
f"connected={self._ws is not None})"
|
|
277
|
+
)
|