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.
Files changed (43) hide show
  1. datacurrent/__init__.py +157 -0
  2. datacurrent/_version.py +6 -0
  3. datacurrent/buffering/__init__.py +8 -0
  4. datacurrent/buffering/backpressure.py +47 -0
  5. datacurrent/buffering/queue.py +304 -0
  6. datacurrent/concurrency/__init__.py +8 -0
  7. datacurrent/concurrency/limits.py +106 -0
  8. datacurrent/concurrency/tasks.py +86 -0
  9. datacurrent/config.py +86 -0
  10. datacurrent/core/__init__.py +17 -0
  11. datacurrent/core/_util.py +42 -0
  12. datacurrent/core/lifecycle.py +81 -0
  13. datacurrent/core/operators.py +844 -0
  14. datacurrent/core/pipeline.py +64 -0
  15. datacurrent/core/sink.py +70 -0
  16. datacurrent/core/stream.py +566 -0
  17. datacurrent/events/__init__.py +9 -0
  18. datacurrent/events/bus.py +380 -0
  19. datacurrent/events/routing.py +59 -0
  20. datacurrent/events/subscriptions.py +75 -0
  21. datacurrent/exceptions.py +191 -0
  22. datacurrent/integrations/__init__.py +18 -0
  23. datacurrent/integrations/fastapi.py +76 -0
  24. datacurrent/integrations/starlette.py +75 -0
  25. datacurrent/observability/__init__.py +17 -0
  26. datacurrent/observability/logging.py +128 -0
  27. datacurrent/observability/metrics.py +127 -0
  28. datacurrent/py.typed +1 -0
  29. datacurrent/reliability/__init__.py +14 -0
  30. datacurrent/reliability/retry.py +134 -0
  31. datacurrent/reliability/timeout.py +39 -0
  32. datacurrent/transports/__init__.py +31 -0
  33. datacurrent/transports/_deps.py +23 -0
  34. datacurrent/transports/base.py +80 -0
  35. datacurrent/transports/http.py +211 -0
  36. datacurrent/transports/memory.py +171 -0
  37. datacurrent/transports/sse.py +374 -0
  38. datacurrent/transports/websocket.py +277 -0
  39. datacurrent-1.0.0.dist-info/METADATA +250 -0
  40. datacurrent-1.0.0.dist-info/RECORD +43 -0
  41. datacurrent-1.0.0.dist-info/WHEEL +5 -0
  42. datacurrent-1.0.0.dist-info/licenses/LICENSE +20 -0
  43. 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
+ )