kstlib 0.0.1a0__py3-none-any.whl → 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 (166) hide show
  1. kstlib/__init__.py +266 -1
  2. kstlib/__main__.py +16 -0
  3. kstlib/alerts/__init__.py +110 -0
  4. kstlib/alerts/channels/__init__.py +36 -0
  5. kstlib/alerts/channels/base.py +197 -0
  6. kstlib/alerts/channels/email.py +227 -0
  7. kstlib/alerts/channels/slack.py +389 -0
  8. kstlib/alerts/exceptions.py +72 -0
  9. kstlib/alerts/manager.py +651 -0
  10. kstlib/alerts/models.py +142 -0
  11. kstlib/alerts/throttle.py +263 -0
  12. kstlib/auth/__init__.py +139 -0
  13. kstlib/auth/callback.py +399 -0
  14. kstlib/auth/config.py +502 -0
  15. kstlib/auth/errors.py +127 -0
  16. kstlib/auth/models.py +316 -0
  17. kstlib/auth/providers/__init__.py +14 -0
  18. kstlib/auth/providers/base.py +393 -0
  19. kstlib/auth/providers/oauth2.py +645 -0
  20. kstlib/auth/providers/oidc.py +821 -0
  21. kstlib/auth/session.py +338 -0
  22. kstlib/auth/token.py +482 -0
  23. kstlib/cache/__init__.py +50 -0
  24. kstlib/cache/decorator.py +261 -0
  25. kstlib/cache/strategies.py +516 -0
  26. kstlib/cli/__init__.py +8 -0
  27. kstlib/cli/app.py +195 -0
  28. kstlib/cli/commands/__init__.py +5 -0
  29. kstlib/cli/commands/auth/__init__.py +39 -0
  30. kstlib/cli/commands/auth/common.py +122 -0
  31. kstlib/cli/commands/auth/login.py +325 -0
  32. kstlib/cli/commands/auth/logout.py +74 -0
  33. kstlib/cli/commands/auth/providers.py +57 -0
  34. kstlib/cli/commands/auth/status.py +291 -0
  35. kstlib/cli/commands/auth/token.py +199 -0
  36. kstlib/cli/commands/auth/whoami.py +106 -0
  37. kstlib/cli/commands/config.py +89 -0
  38. kstlib/cli/commands/ops/__init__.py +39 -0
  39. kstlib/cli/commands/ops/attach.py +49 -0
  40. kstlib/cli/commands/ops/common.py +269 -0
  41. kstlib/cli/commands/ops/list_sessions.py +252 -0
  42. kstlib/cli/commands/ops/logs.py +49 -0
  43. kstlib/cli/commands/ops/start.py +98 -0
  44. kstlib/cli/commands/ops/status.py +138 -0
  45. kstlib/cli/commands/ops/stop.py +60 -0
  46. kstlib/cli/commands/rapi/__init__.py +60 -0
  47. kstlib/cli/commands/rapi/call.py +341 -0
  48. kstlib/cli/commands/rapi/list.py +99 -0
  49. kstlib/cli/commands/rapi/show.py +206 -0
  50. kstlib/cli/commands/secrets/__init__.py +35 -0
  51. kstlib/cli/commands/secrets/common.py +425 -0
  52. kstlib/cli/commands/secrets/decrypt.py +88 -0
  53. kstlib/cli/commands/secrets/doctor.py +743 -0
  54. kstlib/cli/commands/secrets/encrypt.py +242 -0
  55. kstlib/cli/commands/secrets/shred.py +96 -0
  56. kstlib/cli/common.py +86 -0
  57. kstlib/config/__init__.py +76 -0
  58. kstlib/config/exceptions.py +110 -0
  59. kstlib/config/export.py +225 -0
  60. kstlib/config/loader.py +963 -0
  61. kstlib/config/sops.py +287 -0
  62. kstlib/db/__init__.py +54 -0
  63. kstlib/db/aiosqlcipher.py +137 -0
  64. kstlib/db/cipher.py +112 -0
  65. kstlib/db/database.py +367 -0
  66. kstlib/db/exceptions.py +25 -0
  67. kstlib/db/pool.py +302 -0
  68. kstlib/helpers/__init__.py +35 -0
  69. kstlib/helpers/exceptions.py +11 -0
  70. kstlib/helpers/time_trigger.py +396 -0
  71. kstlib/kstlib.conf.yml +890 -0
  72. kstlib/limits.py +963 -0
  73. kstlib/logging/__init__.py +108 -0
  74. kstlib/logging/manager.py +633 -0
  75. kstlib/mail/__init__.py +42 -0
  76. kstlib/mail/builder.py +626 -0
  77. kstlib/mail/exceptions.py +27 -0
  78. kstlib/mail/filesystem.py +248 -0
  79. kstlib/mail/transport.py +224 -0
  80. kstlib/mail/transports/__init__.py +19 -0
  81. kstlib/mail/transports/gmail.py +268 -0
  82. kstlib/mail/transports/resend.py +324 -0
  83. kstlib/mail/transports/smtp.py +326 -0
  84. kstlib/meta.py +72 -0
  85. kstlib/metrics/__init__.py +88 -0
  86. kstlib/metrics/decorators.py +1090 -0
  87. kstlib/metrics/exceptions.py +14 -0
  88. kstlib/monitoring/__init__.py +116 -0
  89. kstlib/monitoring/_styles.py +163 -0
  90. kstlib/monitoring/cell.py +57 -0
  91. kstlib/monitoring/config.py +424 -0
  92. kstlib/monitoring/delivery.py +579 -0
  93. kstlib/monitoring/exceptions.py +63 -0
  94. kstlib/monitoring/image.py +220 -0
  95. kstlib/monitoring/kv.py +79 -0
  96. kstlib/monitoring/list.py +69 -0
  97. kstlib/monitoring/metric.py +88 -0
  98. kstlib/monitoring/monitoring.py +341 -0
  99. kstlib/monitoring/renderer.py +139 -0
  100. kstlib/monitoring/service.py +392 -0
  101. kstlib/monitoring/table.py +129 -0
  102. kstlib/monitoring/types.py +56 -0
  103. kstlib/ops/__init__.py +86 -0
  104. kstlib/ops/base.py +148 -0
  105. kstlib/ops/container.py +577 -0
  106. kstlib/ops/exceptions.py +209 -0
  107. kstlib/ops/manager.py +407 -0
  108. kstlib/ops/models.py +176 -0
  109. kstlib/ops/tmux.py +372 -0
  110. kstlib/ops/validators.py +287 -0
  111. kstlib/py.typed +0 -0
  112. kstlib/rapi/__init__.py +118 -0
  113. kstlib/rapi/client.py +875 -0
  114. kstlib/rapi/config.py +861 -0
  115. kstlib/rapi/credentials.py +887 -0
  116. kstlib/rapi/exceptions.py +213 -0
  117. kstlib/resilience/__init__.py +101 -0
  118. kstlib/resilience/circuit_breaker.py +440 -0
  119. kstlib/resilience/exceptions.py +95 -0
  120. kstlib/resilience/heartbeat.py +491 -0
  121. kstlib/resilience/rate_limiter.py +506 -0
  122. kstlib/resilience/shutdown.py +417 -0
  123. kstlib/resilience/watchdog.py +637 -0
  124. kstlib/secrets/__init__.py +29 -0
  125. kstlib/secrets/exceptions.py +19 -0
  126. kstlib/secrets/models.py +62 -0
  127. kstlib/secrets/providers/__init__.py +79 -0
  128. kstlib/secrets/providers/base.py +58 -0
  129. kstlib/secrets/providers/environment.py +66 -0
  130. kstlib/secrets/providers/keyring.py +107 -0
  131. kstlib/secrets/providers/kms.py +223 -0
  132. kstlib/secrets/providers/kwargs.py +101 -0
  133. kstlib/secrets/providers/sops.py +209 -0
  134. kstlib/secrets/resolver.py +221 -0
  135. kstlib/secrets/sensitive.py +130 -0
  136. kstlib/secure/__init__.py +23 -0
  137. kstlib/secure/fs.py +194 -0
  138. kstlib/secure/permissions.py +70 -0
  139. kstlib/ssl.py +347 -0
  140. kstlib/ui/__init__.py +23 -0
  141. kstlib/ui/exceptions.py +26 -0
  142. kstlib/ui/panels.py +484 -0
  143. kstlib/ui/spinner.py +864 -0
  144. kstlib/ui/tables.py +382 -0
  145. kstlib/utils/__init__.py +48 -0
  146. kstlib/utils/dict.py +36 -0
  147. kstlib/utils/formatting.py +338 -0
  148. kstlib/utils/http_trace.py +237 -0
  149. kstlib/utils/lazy.py +49 -0
  150. kstlib/utils/secure_delete.py +205 -0
  151. kstlib/utils/serialization.py +247 -0
  152. kstlib/utils/text.py +56 -0
  153. kstlib/utils/validators.py +124 -0
  154. kstlib/websocket/__init__.py +97 -0
  155. kstlib/websocket/exceptions.py +214 -0
  156. kstlib/websocket/manager.py +1102 -0
  157. kstlib/websocket/models.py +361 -0
  158. kstlib-1.0.0.dist-info/METADATA +201 -0
  159. kstlib-1.0.0.dist-info/RECORD +163 -0
  160. {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
  161. kstlib-1.0.0.dist-info/entry_points.txt +2 -0
  162. kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
  163. kstlib-0.0.1a0.dist-info/METADATA +0 -29
  164. kstlib-0.0.1a0.dist-info/RECORD +0 -6
  165. kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
  166. {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1102 @@
1
+ """WebSocket manager with proactive connection control.
2
+
3
+ This module provides WebSocketManager, an async WebSocket client that offers
4
+ proactive control over connections rather than just reactive reconnection.
5
+
6
+ The key differentiator is the ability to control WHEN to disconnect/reconnect
7
+ rather than just reacting to disconnections. This is essential for trading
8
+ applications where you want to avoid disconnections during critical operations.
9
+
10
+ Examples:
11
+ Basic usage:
12
+
13
+ >>> from kstlib.websocket import WebSocketManager
14
+ >>> async def main(): # doctest: +SKIP
15
+ ... async with WebSocketManager("wss://example.com/ws") as ws:
16
+ ... async for message in ws.stream():
17
+ ... print(message)
18
+
19
+ Proactive control for trading:
20
+
21
+ >>> def next_candle_in() -> float: # doctest: +SKIP
22
+ ... '''Seconds until next 4H candle.'''
23
+ ... ...
24
+ >>> async def trading(): # doctest: +SKIP
25
+ ... ws = WebSocketManager(
26
+ ... url="wss://stream.binance.com/ws/btcusdt@kline_4h",
27
+ ... should_disconnect=lambda: next_candle_in() > 30,
28
+ ... should_reconnect=lambda: next_candle_in() < 60,
29
+ ... )
30
+ ... async with ws:
31
+ ... async for candle in ws.stream():
32
+ ... process(candle)
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import asyncio
38
+ import json
39
+ import logging
40
+ import time
41
+ from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
42
+ from contextlib import suppress
43
+ from typing import TYPE_CHECKING, Any
44
+
45
+ from typing_extensions import Self
46
+
47
+ if TYPE_CHECKING:
48
+ import types
49
+
50
+ from kstlib.limits import get_websocket_limits
51
+ from kstlib.websocket.exceptions import (
52
+ WebSocketClosedError,
53
+ WebSocketConnectionError,
54
+ WebSocketReconnectError,
55
+ WebSocketTimeoutError,
56
+ )
57
+ from kstlib.websocket.models import (
58
+ ConnectionState,
59
+ DisconnectReason,
60
+ ReconnectStrategy,
61
+ WebSocketStats,
62
+ )
63
+
64
+ try:
65
+ import websockets
66
+ from websockets.asyncio.client import ClientConnection, connect
67
+ from websockets.exceptions import (
68
+ ConnectionClosed,
69
+ ConnectionClosedError,
70
+ ConnectionClosedOK,
71
+ InvalidURI,
72
+ WebSocketException,
73
+ )
74
+
75
+ HAS_WEBSOCKETS = True
76
+ except ImportError: # pragma: no cover
77
+ HAS_WEBSOCKETS = False
78
+ websockets = None # type: ignore[assignment]
79
+ ClientConnection = None # type: ignore[assignment,misc]
80
+ connect = None # type: ignore[assignment,misc]
81
+ ConnectionClosed = Exception # type: ignore[assignment,misc]
82
+ ConnectionClosedError = Exception # type: ignore[assignment,misc]
83
+ ConnectionClosedOK = Exception # type: ignore[assignment,misc]
84
+ InvalidURI = Exception # type: ignore[assignment,misc]
85
+ WebSocketException = Exception # type: ignore[assignment,misc]
86
+
87
+ __all__ = ["WebSocketManager"]
88
+
89
+ log = logging.getLogger(__name__)
90
+
91
+ # Type aliases for callbacks
92
+ ShouldDisconnectCallback = Callable[[], bool]
93
+ ShouldReconnectCallback = Callable[[], bool | float]
94
+ OnConnectCallback = Callable[[], Awaitable[None] | None]
95
+ OnDisconnectCallback = Callable[[DisconnectReason], Awaitable[None] | None]
96
+ OnMessageCallback = Callable[[Any], Awaitable[None] | None]
97
+ OnAlertCallback = Callable[[str, str, Mapping[str, Any]], Awaitable[None] | None]
98
+
99
+
100
+ def _check_websockets_installed() -> None:
101
+ """Raise ImportError if websockets is not installed."""
102
+ if not HAS_WEBSOCKETS:
103
+ msg = "websockets is required for WebSocketManager. Install it with: pip install kstlib[websocket]"
104
+ raise ImportError(msg)
105
+
106
+
107
+ class WebSocketManager:
108
+ """Async WebSocket manager with proactive connection control.
109
+
110
+ This manager provides both reactive (auto-reconnect on failure) and
111
+ proactive (user-controlled disconnect/reconnect) connection management.
112
+
113
+ The proactive control feature is the key differentiator: instead of just
114
+ reacting to disconnections, you can control WHEN to disconnect and reconnect.
115
+ This is essential for trading where you want to avoid mid-operation cuts.
116
+
117
+ Attributes:
118
+ url: WebSocket server URL.
119
+ state: Current connection state.
120
+ stats: Connection and message statistics.
121
+
122
+ Examples:
123
+ Basic streaming:
124
+
125
+ >>> async def main(): # doctest: +SKIP
126
+ ... async with WebSocketManager("wss://example.com/ws") as ws:
127
+ ... async for msg in ws.stream():
128
+ ... print(msg)
129
+
130
+ Proactive control:
131
+
132
+ >>> async def trading(): # doctest: +SKIP
133
+ ... ws = WebSocketManager(
134
+ ... url="wss://stream.binance.com/ws",
135
+ ... should_disconnect=lambda: not is_critical_time(),
136
+ ... should_reconnect=lambda: is_approaching_candle(),
137
+ ... )
138
+ ... async with ws:
139
+ ... await ws.subscribe("btcusdt@kline_4h")
140
+ ... async for msg in ws.stream():
141
+ ... process(msg)
142
+ """
143
+
144
+ def __init__(
145
+ self,
146
+ url: str,
147
+ *,
148
+ # Proactive callbacks
149
+ should_disconnect: ShouldDisconnectCallback | None = None,
150
+ should_reconnect: ShouldReconnectCallback | None = None,
151
+ on_connect: OnConnectCallback | None = None,
152
+ on_disconnect: OnDisconnectCallback | None = None,
153
+ on_message: OnMessageCallback | None = None,
154
+ on_alert: OnAlertCallback | None = None,
155
+ # Connection settings
156
+ ping_interval: float | None = None,
157
+ ping_timeout: float | None = None,
158
+ connection_timeout: float | None = None,
159
+ # Reconnection settings
160
+ reconnect_strategy: ReconnectStrategy = ReconnectStrategy.EXPONENTIAL_BACKOFF,
161
+ reconnect_delay: float | None = None,
162
+ max_reconnect_delay: float | None = None,
163
+ max_reconnect_attempts: int | None = None,
164
+ auto_reconnect: bool = True,
165
+ # Proactive control settings
166
+ disconnect_check_interval: float | None = None,
167
+ reconnect_check_interval: float | None = None,
168
+ disconnect_margin: float | None = None,
169
+ # Queue settings
170
+ queue_size: int | None = None,
171
+ # Config
172
+ config: Mapping[str, Any] | None = None,
173
+ ) -> None:
174
+ """Initialize WebSocket manager.
175
+
176
+ Args:
177
+ url: WebSocket server URL (wss:// or ws://).
178
+ should_disconnect: Callback returning True when disconnect is desired.
179
+ should_reconnect: Callback returning True or delay (seconds) for reconnect.
180
+ on_connect: Callback invoked after successful connection.
181
+ on_disconnect: Callback invoked after disconnection with reason.
182
+ on_message: Callback invoked for each received message.
183
+ on_alert: Callback for alerting (channel, message, context).
184
+ ping_interval: Seconds between ping frames.
185
+ ping_timeout: Seconds to wait for pong response.
186
+ connection_timeout: Timeout for initial connection.
187
+ reconnect_strategy: Strategy for reconnection delays.
188
+ reconnect_delay: Initial delay between reconnect attempts.
189
+ max_reconnect_delay: Maximum delay for exponential backoff.
190
+ max_reconnect_attempts: Maximum consecutive reconnection attempts.
191
+ auto_reconnect: Whether to auto-reconnect on disconnection.
192
+ disconnect_check_interval: Seconds between should_disconnect checks.
193
+ reconnect_check_interval: Seconds between should_reconnect checks.
194
+ disconnect_margin: Seconds before platform limit to disconnect.
195
+ queue_size: Maximum messages in queue (0 = unlimited).
196
+ config: Optional config mapping for limits resolution.
197
+
198
+ Raises:
199
+ ImportError: If websockets package is not installed.
200
+ """
201
+ _check_websockets_installed()
202
+
203
+ self._url = url
204
+ self._state = ConnectionState.DISCONNECTED
205
+ self._stats = WebSocketStats()
206
+
207
+ # Resolve limits from config with hard limit enforcement
208
+ limits = get_websocket_limits(config)
209
+
210
+ # Apply kwargs > config > defaults pattern
211
+ self._ping_interval = ping_interval if ping_interval is not None else limits.ping_interval
212
+ self._ping_timeout = ping_timeout if ping_timeout is not None else limits.ping_timeout
213
+ self._connection_timeout = connection_timeout if connection_timeout is not None else limits.connection_timeout
214
+ self._reconnect_delay = reconnect_delay if reconnect_delay is not None else limits.reconnect_delay
215
+ self._max_reconnect_delay = (
216
+ max_reconnect_delay if max_reconnect_delay is not None else limits.max_reconnect_delay
217
+ )
218
+ self._max_reconnect_attempts = (
219
+ max_reconnect_attempts if max_reconnect_attempts is not None else limits.max_reconnect_attempts
220
+ )
221
+ self._disconnect_check_interval = (
222
+ disconnect_check_interval if disconnect_check_interval is not None else limits.disconnect_check_interval
223
+ )
224
+ self._reconnect_check_interval = (
225
+ reconnect_check_interval if reconnect_check_interval is not None else limits.reconnect_check_interval
226
+ )
227
+ self._disconnect_margin = disconnect_margin if disconnect_margin is not None else limits.disconnect_margin
228
+ self._queue_size = queue_size if queue_size is not None else limits.queue_size
229
+
230
+ # Settings
231
+ self._reconnect_strategy = reconnect_strategy
232
+ self._auto_reconnect = auto_reconnect
233
+
234
+ # Callbacks
235
+ self._should_disconnect = should_disconnect
236
+ self._should_reconnect = should_reconnect
237
+ self._on_connect = on_connect
238
+ self._on_disconnect = on_disconnect
239
+ self._on_message = on_message
240
+ self._on_alert = on_alert
241
+
242
+ # Internal state
243
+ self._ws: ClientConnection | None = None
244
+ self._message_queue: asyncio.Queue[Any] = asyncio.Queue(maxsize=max(0, self._queue_size))
245
+ self._subscriptions: set[str] = set()
246
+ self._reconnect_count = 0
247
+ self._connect_time: float = 0.0
248
+ self._scheduled_reconnect_delay: float | None = None
249
+
250
+ # Background tasks
251
+ self._disconnect_check_task: asyncio.Task[None] | None = None
252
+ self._reconnect_check_task: asyncio.Task[None] | None = None
253
+ self._receive_task: asyncio.Task[None] | None = None
254
+ self._ping_task: asyncio.Task[None] | None = None
255
+
256
+ # Events
257
+ self._connected_event = asyncio.Event()
258
+ self._disconnected_event = asyncio.Event()
259
+ self._closed_event = asyncio.Event()
260
+ self._shutdown_event = asyncio.Event()
261
+ self._disconnected_event.set() # Start disconnected
262
+
263
+ @property
264
+ def url(self) -> str:
265
+ """WebSocket server URL."""
266
+ return self._url
267
+
268
+ @property
269
+ def state(self) -> ConnectionState:
270
+ """Current connection state."""
271
+ return self._state
272
+
273
+ @property
274
+ def stats(self) -> WebSocketStats:
275
+ """Connection and message statistics."""
276
+ return self._stats
277
+
278
+ @property
279
+ def is_connected(self) -> bool:
280
+ """Check if currently connected."""
281
+ return self._state == ConnectionState.CONNECTED
282
+
283
+ @property
284
+ def subscriptions(self) -> frozenset[str]:
285
+ """Current active subscriptions."""
286
+ return frozenset(self._subscriptions)
287
+
288
+ @property
289
+ def connection_duration(self) -> float:
290
+ """Seconds since last successful connection, or 0 if not connected."""
291
+ if self._connect_time == 0.0:
292
+ return 0.0
293
+ return time.monotonic() - self._connect_time
294
+
295
+ async def __aenter__(self) -> Self:
296
+ """Enter async context and connect."""
297
+ await self.connect()
298
+ return self
299
+
300
+ async def __aexit__(
301
+ self,
302
+ exc_type: type[BaseException] | None,
303
+ exc_val: BaseException | None,
304
+ exc_tb: types.TracebackType | None,
305
+ ) -> None:
306
+ """Exit async context and close connection."""
307
+ await self.close()
308
+
309
+ async def connect(self) -> None:
310
+ """Establish WebSocket connection.
311
+
312
+ Raises:
313
+ WebSocketConnectionError: If connection fails after retries.
314
+ WebSocketTimeoutError: If connection times out.
315
+
316
+ Examples:
317
+ >>> async def main(): # doctest: +SKIP
318
+ ... ws = WebSocketManager("wss://example.com/ws")
319
+ ... await ws.connect()
320
+ ... try:
321
+ ... async for msg in ws.stream():
322
+ ... print(msg)
323
+ ... finally:
324
+ ... await ws.close()
325
+ """
326
+ if not self._state.can_connect():
327
+ log.warning("Cannot connect from state %s", self._state)
328
+ return
329
+
330
+ self._state = ConnectionState.CONNECTING
331
+ self._disconnected_event.clear()
332
+
333
+ try:
334
+ await self._establish_connection()
335
+ except Exception:
336
+ self._state = ConnectionState.DISCONNECTED
337
+ self._disconnected_event.set()
338
+ raise
339
+
340
+ async def _try_connect(self) -> ClientConnection:
341
+ """Attempt a single connection with timeout."""
342
+ return await asyncio.wait_for(
343
+ connect(
344
+ self._url,
345
+ ping_interval=self._ping_interval,
346
+ ping_timeout=self._ping_timeout,
347
+ ),
348
+ timeout=self._connection_timeout,
349
+ )
350
+
351
+ async def _establish_connection(self) -> None:
352
+ """Internal connection establishment with timeout."""
353
+ last_error: BaseException | None = None
354
+
355
+ for attempt in range(1, self._max_reconnect_attempts + 2):
356
+ try:
357
+ self._ws = await self._try_connect()
358
+ break
359
+ except asyncio.TimeoutError as e:
360
+ last_error = e
361
+ log.warning("Connection timeout (attempt %d/%d)", attempt, self._max_reconnect_attempts + 1)
362
+ except InvalidURI as e:
363
+ raise WebSocketConnectionError(
364
+ f"Invalid WebSocket URL: {self._url}",
365
+ url=self._url,
366
+ attempts=attempt,
367
+ last_error=e,
368
+ ) from e
369
+ except (OSError, WebSocketException) as e:
370
+ last_error = e
371
+ log.warning("Connection failed (attempt %d/%d): %s", attempt, self._max_reconnect_attempts + 1, e)
372
+
373
+ if attempt <= self._max_reconnect_attempts:
374
+ await self._wait_reconnect_delay(attempt)
375
+ else:
376
+ self._raise_connection_failed(last_error, self._max_reconnect_attempts + 1)
377
+
378
+ # Connection successful
379
+ await self._finalize_connection()
380
+
381
+ def _raise_connection_failed(self, last_error: BaseException | None, attempts: int) -> None:
382
+ """Raise appropriate connection error after all attempts exhausted."""
383
+ if isinstance(last_error, asyncio.TimeoutError):
384
+ raise WebSocketTimeoutError(
385
+ f"Connection timed out after {attempts} attempts",
386
+ operation="connect",
387
+ timeout=self._connection_timeout,
388
+ ) from last_error
389
+ raise WebSocketConnectionError(
390
+ f"Failed to connect after {attempts} attempts",
391
+ url=self._url,
392
+ attempts=attempts,
393
+ last_error=last_error,
394
+ ) from last_error
395
+
396
+ async def _finalize_connection(self) -> None:
397
+ """Finalize successful connection setup."""
398
+ self._state = ConnectionState.CONNECTED
399
+ self._connected_event.set()
400
+ self._connect_time = time.monotonic()
401
+ self._reconnect_count = 0
402
+ self._stats.record_connect()
403
+
404
+ log.info("WebSocket connected to %s", self._url)
405
+
406
+ # Start background tasks
407
+ self._start_background_tasks()
408
+
409
+ # Invoke on_connect callback
410
+ if self._on_connect is not None:
411
+ result = self._on_connect()
412
+ if asyncio.iscoroutine(result):
413
+ await result
414
+
415
+ # Re-subscribe to channels
416
+ if self._subscriptions:
417
+ await self._resubscribe()
418
+
419
+ def _start_background_tasks(self) -> None:
420
+ """Start background monitoring tasks."""
421
+ # Start receive task
422
+ self._receive_task = asyncio.create_task(self._receive_loop(), name="ws_receive_loop")
423
+
424
+ # Start proactive disconnect check if callback provided
425
+ if self._should_disconnect is not None:
426
+ self._disconnect_check_task = asyncio.create_task(self._disconnect_check_loop(), name="ws_disconnect_check")
427
+
428
+ def _parse_message(self, message: str | bytes) -> Any:
429
+ """Parse incoming message, attempting JSON decode for strings."""
430
+ if isinstance(message, str):
431
+ try:
432
+ return json.loads(message)
433
+ except json.JSONDecodeError:
434
+ return message
435
+ return message
436
+
437
+ async def _process_message(self, message: str | bytes) -> None:
438
+ """Process a single received message."""
439
+ data = self._parse_message(message)
440
+ size = len(message) if isinstance(message, str | bytes) else 0
441
+ self._stats.record_message_received(size)
442
+
443
+ # Invoke on_message callback
444
+ if self._on_message is not None:
445
+ result = self._on_message(data)
446
+ if asyncio.iscoroutine(result):
447
+ await result
448
+
449
+ # Queue message
450
+ try:
451
+ self._message_queue.put_nowait(data)
452
+ except asyncio.QueueFull:
453
+ await self._handle_queue_full()
454
+
455
+ async def _handle_queue_full(self) -> None:
456
+ """Handle queue overflow situation."""
457
+ log.warning("Message queue full, dropping message")
458
+ if self._on_alert:
459
+ alert_result = self._on_alert(
460
+ "websocket",
461
+ "Message queue full",
462
+ {"queue_size": self._queue_size},
463
+ )
464
+ if asyncio.iscoroutine(alert_result):
465
+ await alert_result
466
+
467
+ async def _receive_loop(self) -> None:
468
+ """Background task to receive messages and queue them."""
469
+ if self._ws is None:
470
+ return
471
+
472
+ try:
473
+ async for message in self._ws:
474
+ await self._process_message(message)
475
+ except ConnectionClosedOK:
476
+ log.debug("WebSocket closed normally")
477
+ except ConnectionClosedError as e:
478
+ log.warning("WebSocket closed with error: code=%d reason=%s", e.code, e.reason)
479
+ await self._handle_disconnect(DisconnectReason.SERVER_CLOSED, code=e.code)
480
+ except ConnectionClosed as e:
481
+ log.warning("WebSocket connection closed: %s", e)
482
+ await self._handle_disconnect(DisconnectReason.SERVER_CLOSED)
483
+ except Exception:
484
+ log.exception("Unexpected error in receive loop")
485
+ await self._handle_disconnect(DisconnectReason.PROTOCOL_ERROR)
486
+
487
+ async def _disconnect_check_loop(self) -> None:
488
+ """Background task to check should_disconnect callback."""
489
+ while self._state == ConnectionState.CONNECTED and not self._shutdown_event.is_set():
490
+ await asyncio.sleep(self._disconnect_check_interval)
491
+
492
+ if self._shutdown_event.is_set():
493
+ break
494
+
495
+ if self._should_disconnect is not None and self._state == ConnectionState.CONNECTED:
496
+ try:
497
+ should_disconnect = self._should_disconnect()
498
+ if should_disconnect:
499
+ log.info("should_disconnect callback returned True, disconnecting")
500
+ await self.request_disconnect(reason=DisconnectReason.CALLBACK_TRIGGERED)
501
+ break
502
+ except Exception as e:
503
+ log.warning("Error in should_disconnect callback: %s", e)
504
+
505
+ async def _handle_disconnect(
506
+ self,
507
+ reason: DisconnectReason,
508
+ *,
509
+ code: int = 1006,
510
+ ) -> None:
511
+ """Handle disconnection and potentially reconnect."""
512
+ was_connected = self._state == ConnectionState.CONNECTED
513
+ self._state = ConnectionState.RECONNECTING if self._auto_reconnect else ConnectionState.DISCONNECTED
514
+ self._connected_event.clear()
515
+
516
+ if was_connected:
517
+ self._stats.record_disconnect(proactive=reason.is_proactive)
518
+
519
+ # Cancel background tasks
520
+ await self._cancel_background_tasks()
521
+
522
+ # Invoke on_disconnect callback
523
+ if self._on_disconnect is not None:
524
+ result = self._on_disconnect(reason)
525
+ if asyncio.iscoroutine(result):
526
+ await result
527
+
528
+ log.info("WebSocket disconnected: %s (code=%d)", reason.name, code)
529
+
530
+ # Handle reconnection
531
+ if self._auto_reconnect and not reason.is_proactive:
532
+ # Reactive reconnect for forced disconnections
533
+ await self._attempt_reconnect()
534
+ elif reason.is_proactive and self._scheduled_reconnect_delay is not None:
535
+ # Scheduled reconnect after proactive disconnect
536
+ delay = self._scheduled_reconnect_delay
537
+ self._scheduled_reconnect_delay = None
538
+ await asyncio.sleep(delay)
539
+ await self._attempt_reconnect()
540
+ elif reason.is_proactive and self._should_reconnect is not None:
541
+ # Callback-controlled reconnect
542
+ self._reconnect_check_task = asyncio.create_task(self._reconnect_check_loop(), name="ws_reconnect_check")
543
+ else:
544
+ self._state = ConnectionState.DISCONNECTED
545
+ self._disconnected_event.set()
546
+
547
+ async def _reconnect_check_loop(self) -> None:
548
+ """Background task to check should_reconnect callback."""
549
+ while self._state == ConnectionState.RECONNECTING and not self._shutdown_event.is_set():
550
+ await asyncio.sleep(self._reconnect_check_interval)
551
+
552
+ if self._shutdown_event.is_set():
553
+ break
554
+
555
+ if self._state not in (ConnectionState.RECONNECTING, ConnectionState.DISCONNECTED):
556
+ break
557
+
558
+ if self._should_reconnect is not None:
559
+ try:
560
+ result = self._should_reconnect()
561
+ if result is True:
562
+ log.info("should_reconnect callback returned True, reconnecting")
563
+ await self._attempt_reconnect()
564
+ break
565
+ if isinstance(result, int | float) and result > 0:
566
+ log.info(
567
+ "should_reconnect callback returned delay=%.1fs, scheduling",
568
+ result,
569
+ )
570
+ await asyncio.sleep(result)
571
+ await self._attempt_reconnect()
572
+ break
573
+ except Exception as e:
574
+ log.warning("Error in should_reconnect callback: %s", e)
575
+
576
+ async def _attempt_reconnect(self) -> None:
577
+ """Attempt to reconnect with retry logic."""
578
+ if self._shutdown_event.is_set():
579
+ log.debug("Shutdown requested, skipping reconnect attempt")
580
+ return
581
+
582
+ self._reconnect_count += 1
583
+
584
+ if self._reconnect_count > self._max_reconnect_attempts:
585
+ log.error(
586
+ "Max reconnection attempts (%d) exceeded",
587
+ self._max_reconnect_attempts,
588
+ )
589
+ self._state = ConnectionState.DISCONNECTED
590
+ self._disconnected_event.set()
591
+ raise WebSocketReconnectError(
592
+ f"Failed to reconnect after {self._reconnect_count} attempts",
593
+ attempts=self._reconnect_count,
594
+ )
595
+
596
+ log.info(
597
+ "Attempting reconnection (%d/%d)",
598
+ self._reconnect_count,
599
+ self._max_reconnect_attempts,
600
+ )
601
+
602
+ self._state = ConnectionState.CONNECTING
603
+ try:
604
+ await self._establish_connection()
605
+ except WebSocketConnectionError:
606
+ if self._shutdown_event.is_set():
607
+ return
608
+ self._state = ConnectionState.RECONNECTING
609
+ await self._wait_reconnect_delay(self._reconnect_count)
610
+ await self._attempt_reconnect()
611
+
612
+ async def _wait_reconnect_delay(self, attempt: int) -> None:
613
+ """Wait before next reconnection attempt based on strategy."""
614
+ if self._reconnect_strategy == ReconnectStrategy.IMMEDIATE:
615
+ return
616
+ if self._reconnect_strategy == ReconnectStrategy.FIXED_DELAY:
617
+ delay = self._reconnect_delay
618
+ elif self._reconnect_strategy == ReconnectStrategy.EXPONENTIAL_BACKOFF:
619
+ delay = min(
620
+ self._reconnect_delay * (2 ** (attempt - 1)),
621
+ self._max_reconnect_delay,
622
+ )
623
+ else: # CALLBACK_CONTROLLED
624
+ delay = self._reconnect_delay
625
+
626
+ log.debug("Waiting %.1fs before reconnection attempt", delay)
627
+ await asyncio.sleep(delay)
628
+
629
+ async def _cancel_background_tasks(self) -> None:
630
+ """Cancel all background tasks."""
631
+ tasks = [
632
+ self._receive_task,
633
+ self._disconnect_check_task,
634
+ self._reconnect_check_task,
635
+ self._ping_task,
636
+ ]
637
+ for task in tasks:
638
+ if task is not None and not task.done():
639
+ task.cancel()
640
+ with suppress(asyncio.CancelledError):
641
+ await task
642
+
643
+ self._receive_task = None
644
+ self._disconnect_check_task = None
645
+ self._reconnect_check_task = None
646
+ self._ping_task = None
647
+
648
+ async def _resubscribe(self) -> None:
649
+ """Re-subscribe to all channels after reconnection."""
650
+ if not self._subscriptions:
651
+ return
652
+
653
+ log.debug("Re-subscribing to %d channels", len(self._subscriptions))
654
+ for channel in self._subscriptions:
655
+ try:
656
+ await self._send_subscribe(channel)
657
+ except Exception as e:
658
+ log.warning("Failed to re-subscribe to %s: %s", channel, e)
659
+
660
+ async def _send_subscribe(self, channel: str) -> None:
661
+ """Send subscription message for a channel."""
662
+ if self._ws is None:
663
+ return
664
+
665
+ # Generic subscription format - can be overridden for specific protocols
666
+ message = json.dumps({"type": "subscribe", "channel": channel})
667
+ await self._ws.send(message)
668
+
669
+ # =========================================================================
670
+ # PUBLIC PROACTIVE CONTROL METHODS
671
+ # =========================================================================
672
+
673
+ async def request_disconnect(
674
+ self,
675
+ *,
676
+ reconnect_after: float | None = None,
677
+ reason: DisconnectReason = DisconnectReason.USER_REQUESTED,
678
+ ) -> None:
679
+ """Request a controlled disconnection.
680
+
681
+ This is a proactive method that allows the user to disconnect
682
+ at a time of their choosing rather than waiting for a forced cut.
683
+
684
+ Args:
685
+ reconnect_after: Optional delay before auto-reconnect (seconds).
686
+ reason: Reason for the disconnection.
687
+
688
+ Examples:
689
+ >>> async def main(): # doctest: +SKIP
690
+ ... async with WebSocketManager("wss://example.com/ws") as ws:
691
+ ... # Disconnect now, reconnect in 5 minutes
692
+ ... await ws.request_disconnect(reconnect_after=300)
693
+ """
694
+ if self._state != ConnectionState.CONNECTED:
695
+ log.warning("Cannot disconnect from state %s", self._state)
696
+ return
697
+
698
+ log.info("User requested disconnect (reconnect_after=%s)", reconnect_after)
699
+ self._scheduled_reconnect_delay = reconnect_after
700
+
701
+ # Close the connection gracefully
702
+ if self._ws is not None:
703
+ await self._ws.close(1000, "User requested disconnect")
704
+
705
+ await self._handle_disconnect(reason)
706
+
707
+ async def schedule_reconnect(self, delay: float) -> None:
708
+ """Schedule a reconnection after a delay.
709
+
710
+ Use this to programmatically reconnect after a proactive disconnect.
711
+
712
+ Args:
713
+ delay: Seconds to wait before reconnecting.
714
+
715
+ Examples:
716
+ >>> async def main(): # doctest: +SKIP
717
+ ... ws = WebSocketManager("wss://example.com/ws")
718
+ ... await ws.connect()
719
+ ... await ws.request_disconnect()
720
+ ... # Reconnect in 5 minutes
721
+ ... await ws.schedule_reconnect(300)
722
+ """
723
+ if self._state not in (ConnectionState.DISCONNECTED, ConnectionState.RECONNECTING):
724
+ log.warning("Cannot schedule reconnect from state %s", self._state)
725
+ return
726
+
727
+ log.info("Scheduling reconnection in %.1f seconds", delay)
728
+ self._state = ConnectionState.RECONNECTING
729
+ await asyncio.sleep(delay)
730
+ await self._attempt_reconnect()
731
+
732
+ async def wait_for_reconnect_window(
733
+ self,
734
+ should_reconnect: ShouldReconnectCallback | None = None,
735
+ timeout: float | None = None,
736
+ ) -> bool:
737
+ """Wait until the reconnection condition is met.
738
+
739
+ Args:
740
+ should_reconnect: Custom callback (overrides instance callback).
741
+ timeout: Maximum time to wait (seconds).
742
+
743
+ Returns:
744
+ True if reconnect condition was met, False if timed out.
745
+
746
+ Examples:
747
+ >>> async def main(): # doctest: +SKIP
748
+ ... ws = WebSocketManager("wss://example.com/ws")
749
+ ... await ws.connect()
750
+ ... await ws.request_disconnect()
751
+ ... # Wait for trading window
752
+ ... if await ws.wait_for_reconnect_window(
753
+ ... should_reconnect=lambda: is_trading_time(),
754
+ ... timeout=3600,
755
+ ... ):
756
+ ... await ws.connect()
757
+ """
758
+ callback = should_reconnect or self._should_reconnect
759
+ if callback is None:
760
+ log.warning("No should_reconnect callback provided")
761
+ return False
762
+
763
+ start_time = time.monotonic()
764
+ while True:
765
+ elapsed = time.monotonic() - start_time
766
+ if timeout is not None and elapsed >= timeout:
767
+ return False
768
+
769
+ try:
770
+ result = callback()
771
+ if result is True:
772
+ return True
773
+ if isinstance(result, int | float) and result > 0:
774
+ wait_time = min(
775
+ result,
776
+ (timeout - elapsed) if timeout else result,
777
+ )
778
+ await asyncio.sleep(wait_time)
779
+ continue
780
+ except Exception as e:
781
+ log.warning("Error in should_reconnect callback: %s", e)
782
+
783
+ await asyncio.sleep(self._reconnect_check_interval)
784
+
785
+ async def force_close(self) -> None:
786
+ """Emergency close without reconnection (terminal state).
787
+
788
+ This is for emergency situations where you need to stop immediately.
789
+ The WebSocket instance becomes unusable after this call.
790
+
791
+ **Key difference from kill() and shutdown():**
792
+ - `kill()`: Reactive. Server kicked us. State=DISCONNECTED. CAN reconnect.
793
+ - `shutdown()`: Proactive graceful. User wants to stop. State=CLOSED. CANNOT reconnect.
794
+ - `force_close()`: Emergency stop. State=CLOSED. CANNOT reconnect.
795
+
796
+ Examples:
797
+ >>> async def main(): # doctest: +SKIP
798
+ ... ws = WebSocketManager("wss://example.com/ws")
799
+ ... await ws.connect()
800
+ ... await ws.force_close()
801
+ ... # Cannot reconnect after force_close
802
+ ... assert ws.state == ConnectionState.CLOSED
803
+ """
804
+ self._auto_reconnect = False
805
+ self._state = ConnectionState.CLOSING
806
+
807
+ await self._cancel_background_tasks()
808
+
809
+ if self._ws is not None:
810
+ await self._ws.close(1000, "Force close")
811
+ self._ws = None
812
+
813
+ self._state = ConnectionState.CLOSED
814
+ self._closed_event.set()
815
+ self._disconnected_event.set()
816
+
817
+ log.info("WebSocket force closed")
818
+
819
+ async def kill(self) -> None:
820
+ """Simulate external disconnection (reactive, we are the victim).
821
+
822
+ This simulates a scenario where the server (e.g., Binance) forcefully
823
+ disconnects us. The WebSocket "suffers" this disconnection.
824
+
825
+ **Key difference from force_close() and shutdown():**
826
+ - `kill()`: Reactive. Server kicked us. State=DISCONNECTED. CAN reconnect.
827
+ - `shutdown()`: Proactive graceful. User wants to stop. State=CLOSED. CANNOT reconnect.
828
+ - `force_close()`: Emergency stop. State=CLOSED. CANNOT reconnect.
829
+
830
+ Use this to test heartbeat/watchdog recovery mechanisms.
831
+
832
+ Examples:
833
+ >>> async def main(): # doctest: +SKIP
834
+ ... ws = WebSocketManager("wss://example.com/ws", auto_reconnect=False)
835
+ ... await ws.connect()
836
+ ... await ws.kill() # Simulates Binance kicking us
837
+ ... assert ws.state == ConnectionState.DISCONNECTED
838
+ ... # Heartbeat detects is_dead=True and can restart
839
+ """
840
+ if self._state == ConnectionState.CLOSED:
841
+ log.warning("Cannot kill: already closed")
842
+ return
843
+
844
+ log.info("Killing WebSocket (simulating external disconnect)")
845
+
846
+ # Cancel background tasks first
847
+ await self._cancel_background_tasks()
848
+
849
+ # Close the raw connection if open
850
+ if self._ws is not None:
851
+ with suppress(Exception):
852
+ await self._ws.close(1006, "Killed")
853
+ self._ws = None
854
+
855
+ # Move to DISCONNECTED (not CLOSED) so reconnection is possible
856
+ self._state = ConnectionState.DISCONNECTED
857
+ self._connected_event.clear()
858
+ self._disconnected_event.set()
859
+
860
+ # Record as reactive disconnect (forced)
861
+ self._stats.record_disconnect(proactive=False)
862
+
863
+ # Invoke on_disconnect callback with KILLED reason
864
+ if self._on_disconnect is not None:
865
+ result = self._on_disconnect(DisconnectReason.KILLED)
866
+ if asyncio.iscoroutine(result):
867
+ await result
868
+
869
+ async def shutdown(self) -> None:
870
+ """Graceful intentional shutdown (proactive, user-initiated).
871
+
872
+ This is for clean shutdown scenarios like CTRL+C or service stop.
873
+ The WebSocket proactively decides to close.
874
+
875
+ **Key difference from kill() and force_close():**
876
+ - `kill()`: Reactive. Server kicked us. State=DISCONNECTED. CAN reconnect.
877
+ - `shutdown()`: Proactive graceful. User wants to stop. State=CLOSED. CANNOT reconnect.
878
+ - `force_close()`: Emergency stop. State=CLOSED. CANNOT reconnect.
879
+
880
+ Sets the shutdown event so external code (heartbeat, watchdog) knows
881
+ we're stopping intentionally and should not try to restart us.
882
+
883
+ Examples:
884
+ >>> async def main(): # doctest: +SKIP
885
+ ... ws = WebSocketManager("wss://example.com/ws")
886
+ ... await ws.connect()
887
+ ... # In SIGINT handler:
888
+ ... await ws.shutdown()
889
+ ... assert ws.is_shutdown # Heartbeat knows not to restart
890
+ """
891
+ log.info("WebSocket shutdown requested")
892
+ self._shutdown_event.set()
893
+ await self.force_close()
894
+
895
+ @property
896
+ def is_shutdown(self) -> bool:
897
+ """Check if shutdown has been requested."""
898
+ return self._shutdown_event.is_set()
899
+
900
+ @property
901
+ def is_dead(self) -> bool:
902
+ """Check if connection is dead (not connected and not reconnecting).
903
+
904
+ Useful for heartbeat monitoring to detect if the WebSocket needs restart.
905
+
906
+ Returns:
907
+ True if connection is in a dead state requiring manual intervention.
908
+
909
+ Examples:
910
+ >>> async def main(): # doctest: +SKIP
911
+ ... ws = WebSocketManager("wss://example.com/ws")
912
+ ... if ws.is_dead:
913
+ ... await ws.connect() # Restart
914
+ """
915
+ return self._state in (
916
+ ConnectionState.DISCONNECTED,
917
+ ConnectionState.CLOSED,
918
+ )
919
+
920
+ async def close(self) -> None:
921
+ """Gracefully close the connection.
922
+
923
+ Alias for force_close() when used in context manager.
924
+ """
925
+ await self.force_close()
926
+
927
+ # =========================================================================
928
+ # MESSAGING METHODS
929
+ # =========================================================================
930
+
931
+ async def send(self, data: Any) -> None:
932
+ """Send a message to the WebSocket server.
933
+
934
+ Args:
935
+ data: Data to send (dict/list will be JSON-encoded).
936
+
937
+ Raises:
938
+ WebSocketClosedError: If connection is not active.
939
+
940
+ Examples:
941
+ >>> async def main(): # doctest: +SKIP
942
+ ... async with WebSocketManager("wss://example.com/ws") as ws:
943
+ ... await ws.send({"type": "ping"})
944
+ """
945
+ if self._ws is None or not self._state.can_send():
946
+ raise WebSocketClosedError(
947
+ "Cannot send: connection not active",
948
+ code=1006,
949
+ reason="Not connected",
950
+ )
951
+
952
+ message = json.dumps(data) if isinstance(data, dict | list) else str(data)
953
+ await self._ws.send(message)
954
+ self._stats.record_message_sent(len(message))
955
+
956
+ async def receive(self, timeout: float | None = None) -> Any:
957
+ """Receive a single message from the queue.
958
+
959
+ Args:
960
+ timeout: Maximum time to wait (seconds).
961
+
962
+ Returns:
963
+ The received message.
964
+
965
+ Raises:
966
+ WebSocketTimeoutError: If timeout expires.
967
+ WebSocketClosedError: If connection is closed.
968
+
969
+ Examples:
970
+ >>> async def main(): # doctest: +SKIP
971
+ ... async with WebSocketManager("wss://example.com/ws") as ws:
972
+ ... msg = await ws.receive(timeout=10)
973
+ """
974
+ try:
975
+ if timeout is not None:
976
+ return await asyncio.wait_for(self._message_queue.get(), timeout=timeout)
977
+ return await self._message_queue.get()
978
+ except asyncio.TimeoutError as e:
979
+ raise WebSocketTimeoutError(
980
+ "Receive timed out",
981
+ operation="receive",
982
+ timeout=timeout or 0,
983
+ ) from e
984
+
985
+ async def stream(self) -> AsyncIterator[Any]:
986
+ """Iterate over received messages.
987
+
988
+ This is the main method for consuming WebSocket messages.
989
+ It handles reconnection transparently.
990
+
991
+ Yields:
992
+ Received messages (parsed JSON or raw string).
993
+
994
+ Examples:
995
+ >>> async def main(): # doctest: +SKIP
996
+ ... async with WebSocketManager("wss://example.com/ws") as ws:
997
+ ... async for msg in ws.stream():
998
+ ... print(msg)
999
+ """
1000
+ while self._state not in (ConnectionState.CLOSED, ConnectionState.CLOSING):
1001
+ if self._shutdown_event.is_set():
1002
+ break
1003
+
1004
+ try:
1005
+ # Wait for connection if disconnected
1006
+ if not self.is_connected:
1007
+ if self._state == ConnectionState.CLOSED:
1008
+ break
1009
+ await self._connected_event.wait()
1010
+
1011
+ # Get message with timeout to allow state checking
1012
+ try:
1013
+ message = await asyncio.wait_for(
1014
+ self._message_queue.get(),
1015
+ timeout=1.0,
1016
+ )
1017
+ yield message
1018
+ except asyncio.TimeoutError:
1019
+ continue
1020
+
1021
+ except asyncio.CancelledError:
1022
+ break
1023
+
1024
+ # =========================================================================
1025
+ # SUBSCRIPTION METHODS
1026
+ # =========================================================================
1027
+
1028
+ async def subscribe(self, *channels: str) -> None:
1029
+ """Subscribe to one or more channels.
1030
+
1031
+ Subscriptions are automatically restored after reconnection.
1032
+
1033
+ Args:
1034
+ channels: Channel names to subscribe to.
1035
+
1036
+ Examples:
1037
+ >>> async def main(): # doctest: +SKIP
1038
+ ... async with WebSocketManager("wss://stream.binance.com/ws") as ws:
1039
+ ... await ws.subscribe("btcusdt@trade", "ethusdt@trade")
1040
+ """
1041
+ for channel in channels:
1042
+ if channel not in self._subscriptions:
1043
+ self._subscriptions.add(channel)
1044
+ if self.is_connected:
1045
+ await self._send_subscribe(channel)
1046
+
1047
+ async def unsubscribe(self, *channels: str) -> None:
1048
+ """Unsubscribe from one or more channels.
1049
+
1050
+ Args:
1051
+ channels: Channel names to unsubscribe from.
1052
+
1053
+ Examples:
1054
+ >>> async def main(): # doctest: +SKIP
1055
+ ... async with WebSocketManager("wss://stream.binance.com/ws") as ws:
1056
+ ... await ws.unsubscribe("btcusdt@trade")
1057
+ """
1058
+ for channel in channels:
1059
+ self._subscriptions.discard(channel)
1060
+ if self.is_connected and self._ws is not None:
1061
+ message = json.dumps({"type": "unsubscribe", "channel": channel})
1062
+ await self._ws.send(message)
1063
+
1064
+ # =========================================================================
1065
+ # WAIT METHODS
1066
+ # =========================================================================
1067
+
1068
+ async def wait_connected(self, timeout: float | None = None) -> bool:
1069
+ """Wait until connected.
1070
+
1071
+ Args:
1072
+ timeout: Maximum time to wait (seconds).
1073
+
1074
+ Returns:
1075
+ True if connected, False if timed out.
1076
+ """
1077
+ try:
1078
+ if timeout is not None:
1079
+ await asyncio.wait_for(self._connected_event.wait(), timeout=timeout)
1080
+ else:
1081
+ await self._connected_event.wait()
1082
+ return True
1083
+ except asyncio.TimeoutError:
1084
+ return False
1085
+
1086
+ async def wait_disconnected(self, timeout: float | None = None) -> bool:
1087
+ """Wait until disconnected.
1088
+
1089
+ Args:
1090
+ timeout: Maximum time to wait (seconds).
1091
+
1092
+ Returns:
1093
+ True if disconnected, False if timed out.
1094
+ """
1095
+ try:
1096
+ if timeout is not None:
1097
+ await asyncio.wait_for(self._disconnected_event.wait(), timeout=timeout)
1098
+ else:
1099
+ await self._disconnected_event.wait()
1100
+ return True
1101
+ except asyncio.TimeoutError:
1102
+ return False