audex 1.0.7a3__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 (192) hide show
  1. audex/__init__.py +9 -0
  2. audex/__main__.py +7 -0
  3. audex/cli/__init__.py +189 -0
  4. audex/cli/apis/__init__.py +12 -0
  5. audex/cli/apis/init/__init__.py +34 -0
  6. audex/cli/apis/init/gencfg.py +130 -0
  7. audex/cli/apis/init/setup.py +330 -0
  8. audex/cli/apis/init/vprgroup.py +125 -0
  9. audex/cli/apis/serve.py +141 -0
  10. audex/cli/args.py +356 -0
  11. audex/cli/exceptions.py +44 -0
  12. audex/cli/helper/__init__.py +0 -0
  13. audex/cli/helper/ansi.py +193 -0
  14. audex/cli/helper/display.py +288 -0
  15. audex/config/__init__.py +64 -0
  16. audex/config/core/__init__.py +30 -0
  17. audex/config/core/app.py +29 -0
  18. audex/config/core/audio.py +45 -0
  19. audex/config/core/logging.py +163 -0
  20. audex/config/core/session.py +11 -0
  21. audex/config/helper/__init__.py +1 -0
  22. audex/config/helper/client/__init__.py +1 -0
  23. audex/config/helper/client/http.py +28 -0
  24. audex/config/helper/client/websocket.py +21 -0
  25. audex/config/helper/provider/__init__.py +1 -0
  26. audex/config/helper/provider/dashscope.py +13 -0
  27. audex/config/helper/provider/unisound.py +18 -0
  28. audex/config/helper/provider/xfyun.py +23 -0
  29. audex/config/infrastructure/__init__.py +31 -0
  30. audex/config/infrastructure/cache.py +51 -0
  31. audex/config/infrastructure/database.py +48 -0
  32. audex/config/infrastructure/recorder.py +32 -0
  33. audex/config/infrastructure/store.py +19 -0
  34. audex/config/provider/__init__.py +18 -0
  35. audex/config/provider/transcription.py +109 -0
  36. audex/config/provider/vpr.py +99 -0
  37. audex/container.py +40 -0
  38. audex/entity/__init__.py +468 -0
  39. audex/entity/doctor.py +109 -0
  40. audex/entity/doctor.pyi +51 -0
  41. audex/entity/fields.py +401 -0
  42. audex/entity/segment.py +115 -0
  43. audex/entity/segment.pyi +38 -0
  44. audex/entity/session.py +133 -0
  45. audex/entity/session.pyi +47 -0
  46. audex/entity/utterance.py +142 -0
  47. audex/entity/utterance.pyi +48 -0
  48. audex/entity/vp.py +68 -0
  49. audex/entity/vp.pyi +35 -0
  50. audex/exceptions.py +157 -0
  51. audex/filters/__init__.py +692 -0
  52. audex/filters/generated/__init__.py +21 -0
  53. audex/filters/generated/doctor.py +987 -0
  54. audex/filters/generated/segment.py +723 -0
  55. audex/filters/generated/session.py +978 -0
  56. audex/filters/generated/utterance.py +939 -0
  57. audex/filters/generated/vp.py +815 -0
  58. audex/helper/__init__.py +1 -0
  59. audex/helper/hash.py +33 -0
  60. audex/helper/mixin.py +65 -0
  61. audex/helper/net.py +19 -0
  62. audex/helper/settings/__init__.py +830 -0
  63. audex/helper/settings/fields.py +317 -0
  64. audex/helper/stream.py +153 -0
  65. audex/injectors/__init__.py +1 -0
  66. audex/injectors/config.py +12 -0
  67. audex/injectors/lifespan.py +7 -0
  68. audex/lib/__init__.py +1 -0
  69. audex/lib/cache/__init__.py +383 -0
  70. audex/lib/cache/inmemory.py +513 -0
  71. audex/lib/database/__init__.py +83 -0
  72. audex/lib/database/sqlite.py +406 -0
  73. audex/lib/exporter.py +189 -0
  74. audex/lib/injectors/__init__.py +1 -0
  75. audex/lib/injectors/cache.py +25 -0
  76. audex/lib/injectors/container.py +47 -0
  77. audex/lib/injectors/exporter.py +26 -0
  78. audex/lib/injectors/recorder.py +33 -0
  79. audex/lib/injectors/server.py +17 -0
  80. audex/lib/injectors/session.py +18 -0
  81. audex/lib/injectors/sqlite.py +24 -0
  82. audex/lib/injectors/store.py +13 -0
  83. audex/lib/injectors/transcription.py +42 -0
  84. audex/lib/injectors/usb.py +12 -0
  85. audex/lib/injectors/vpr.py +65 -0
  86. audex/lib/injectors/wifi.py +7 -0
  87. audex/lib/recorder.py +844 -0
  88. audex/lib/repos/__init__.py +149 -0
  89. audex/lib/repos/container.py +23 -0
  90. audex/lib/repos/database/__init__.py +1 -0
  91. audex/lib/repos/database/sqlite.py +672 -0
  92. audex/lib/repos/decorators.py +74 -0
  93. audex/lib/repos/doctor.py +286 -0
  94. audex/lib/repos/segment.py +302 -0
  95. audex/lib/repos/session.py +285 -0
  96. audex/lib/repos/tables/__init__.py +70 -0
  97. audex/lib/repos/tables/doctor.py +137 -0
  98. audex/lib/repos/tables/segment.py +113 -0
  99. audex/lib/repos/tables/session.py +140 -0
  100. audex/lib/repos/tables/utterance.py +131 -0
  101. audex/lib/repos/tables/vp.py +102 -0
  102. audex/lib/repos/utterance.py +288 -0
  103. audex/lib/repos/vp.py +286 -0
  104. audex/lib/restful.py +251 -0
  105. audex/lib/server/__init__.py +97 -0
  106. audex/lib/server/auth.py +98 -0
  107. audex/lib/server/handlers.py +248 -0
  108. audex/lib/server/templates/index.html.j2 +226 -0
  109. audex/lib/server/templates/login.html.j2 +111 -0
  110. audex/lib/server/templates/static/script.js +68 -0
  111. audex/lib/server/templates/static/style.css +579 -0
  112. audex/lib/server/types.py +123 -0
  113. audex/lib/session.py +503 -0
  114. audex/lib/store/__init__.py +238 -0
  115. audex/lib/store/localfile.py +411 -0
  116. audex/lib/transcription/__init__.py +33 -0
  117. audex/lib/transcription/dashscope.py +525 -0
  118. audex/lib/transcription/events.py +62 -0
  119. audex/lib/usb.py +554 -0
  120. audex/lib/vpr/__init__.py +38 -0
  121. audex/lib/vpr/unisound/__init__.py +185 -0
  122. audex/lib/vpr/unisound/types.py +469 -0
  123. audex/lib/vpr/xfyun/__init__.py +483 -0
  124. audex/lib/vpr/xfyun/types.py +679 -0
  125. audex/lib/websocket/__init__.py +8 -0
  126. audex/lib/websocket/connection.py +485 -0
  127. audex/lib/websocket/pool.py +991 -0
  128. audex/lib/wifi.py +1146 -0
  129. audex/lifespan.py +75 -0
  130. audex/service/__init__.py +27 -0
  131. audex/service/decorators.py +73 -0
  132. audex/service/doctor/__init__.py +652 -0
  133. audex/service/doctor/const.py +36 -0
  134. audex/service/doctor/exceptions.py +96 -0
  135. audex/service/doctor/types.py +54 -0
  136. audex/service/export/__init__.py +236 -0
  137. audex/service/export/const.py +17 -0
  138. audex/service/export/exceptions.py +34 -0
  139. audex/service/export/types.py +21 -0
  140. audex/service/injectors/__init__.py +1 -0
  141. audex/service/injectors/container.py +53 -0
  142. audex/service/injectors/doctor.py +34 -0
  143. audex/service/injectors/export.py +27 -0
  144. audex/service/injectors/session.py +49 -0
  145. audex/service/session/__init__.py +754 -0
  146. audex/service/session/const.py +34 -0
  147. audex/service/session/exceptions.py +67 -0
  148. audex/service/session/types.py +91 -0
  149. audex/types.py +39 -0
  150. audex/utils.py +287 -0
  151. audex/valueobj/__init__.py +81 -0
  152. audex/valueobj/common/__init__.py +1 -0
  153. audex/valueobj/common/auth.py +84 -0
  154. audex/valueobj/common/email.py +16 -0
  155. audex/valueobj/common/ops.py +22 -0
  156. audex/valueobj/common/phone.py +84 -0
  157. audex/valueobj/common/version.py +72 -0
  158. audex/valueobj/session.py +19 -0
  159. audex/valueobj/utterance.py +15 -0
  160. audex/view/__init__.py +51 -0
  161. audex/view/container.py +17 -0
  162. audex/view/decorators.py +303 -0
  163. audex/view/pages/__init__.py +1 -0
  164. audex/view/pages/dashboard/__init__.py +286 -0
  165. audex/view/pages/dashboard/wifi.py +407 -0
  166. audex/view/pages/login.py +110 -0
  167. audex/view/pages/recording.py +348 -0
  168. audex/view/pages/register.py +202 -0
  169. audex/view/pages/sessions/__init__.py +196 -0
  170. audex/view/pages/sessions/details.py +224 -0
  171. audex/view/pages/sessions/export.py +443 -0
  172. audex/view/pages/settings.py +374 -0
  173. audex/view/pages/voiceprint/__init__.py +1 -0
  174. audex/view/pages/voiceprint/enroll.py +195 -0
  175. audex/view/pages/voiceprint/update.py +195 -0
  176. audex/view/static/css/dashboard.css +452 -0
  177. audex/view/static/css/glass.css +22 -0
  178. audex/view/static/css/global.css +541 -0
  179. audex/view/static/css/login.css +386 -0
  180. audex/view/static/css/recording.css +439 -0
  181. audex/view/static/css/register.css +293 -0
  182. audex/view/static/css/sessions/styles.css +501 -0
  183. audex/view/static/css/settings.css +186 -0
  184. audex/view/static/css/voiceprint/enroll.css +43 -0
  185. audex/view/static/css/voiceprint/styles.css +209 -0
  186. audex/view/static/css/voiceprint/update.css +44 -0
  187. audex/view/static/images/logo.svg +95 -0
  188. audex/view/static/js/recording.js +42 -0
  189. audex-1.0.7a3.dist-info/METADATA +361 -0
  190. audex-1.0.7a3.dist-info/RECORD +192 -0
  191. audex-1.0.7a3.dist-info/WHEEL +4 -0
  192. audex-1.0.7a3.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,991 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio as aio
4
+ import collections
5
+ import contextlib
6
+ import time
7
+ import types
8
+ import typing as t
9
+ import uuid
10
+
11
+ import tenacity
12
+
13
+ from audex.helper.mixin import LoggingMixin
14
+ from audex.lib.websocket import WebsocketError
15
+ from audex.lib.websocket.connection import ConnectionBusyError
16
+ from audex.lib.websocket.connection import ConnectionClosedError
17
+ from audex.lib.websocket.connection import ConnectionUnavailableError
18
+ from audex.lib.websocket.connection import DrainConditionCallback
19
+ from audex.lib.websocket.connection import WebsocketConnection
20
+
21
+
22
+ class ConnectionPoolExhaustedError(WebsocketError):
23
+ """Raised when the connection pool has reached its maximum
24
+ capacity."""
25
+
26
+ default_message = "Connection pool exhausted."
27
+
28
+
29
+ class ConnectionPoolUnavailableError(WebsocketError):
30
+ """Raised when attempting to use a closed or unavailable pool."""
31
+
32
+ default_message = "Connection pool is unavailable."
33
+
34
+
35
+ class PendingConnection:
36
+ """Represents a connection that is being drained before pool return.
37
+
38
+ A pending connection is in the process of draining server-sent data
39
+ before being returned to the available connection pool.
40
+
41
+ Attributes:
42
+ connection: The WebsocketConnection being drained.
43
+ drain_timeout: Maximum time to spend draining in seconds.
44
+ drain_condition: Callback to determine what data should be drained.
45
+ created_at: Unix timestamp when this pending connection was created.
46
+ drain_task: The asyncio Task performing the drain operation.
47
+ pending_id: Unique identifier for this pending connection.
48
+ """
49
+
50
+ __logtag__ = "websocket.pool"
51
+
52
+ def __init__(
53
+ self,
54
+ connection: WebsocketConnection,
55
+ drain_timeout: float,
56
+ drain_condition: DrainConditionCallback,
57
+ ):
58
+ """Initialize a pending connection.
59
+
60
+ Args:
61
+ connection: The WebsocketConnection to drain.
62
+ drain_timeout: Maximum time to spend draining in seconds.
63
+ drain_condition: Function to determine what constitutes server data.
64
+ """
65
+ self.connection = connection
66
+ self.drain_timeout = drain_timeout
67
+ self.drain_condition = drain_condition
68
+ self.created_at = time.time()
69
+ self.drain_task: aio.Task[None] | None = None
70
+ self.pending_id = uuid.uuid4().hex
71
+ self._completed = False
72
+ self._lock = aio.Lock()
73
+
74
+ async def mark_completed(self) -> bool:
75
+ """Atomically mark this pending connection as completed.
76
+
77
+ Returns:
78
+ True if this call marked it as completed, False if already completed.
79
+ """
80
+ async with self._lock:
81
+ if self._completed:
82
+ return False
83
+ self._completed = True
84
+ return True
85
+
86
+ def __repr__(self) -> str:
87
+ return f"PendingConnection({self.pending_id}, {self.connection})"
88
+
89
+
90
+ class WebsocketConnectionPool(LoggingMixin):
91
+ """An asynchronous WebSocket connection pool for managing reusable
92
+ connections.
93
+
94
+ This class provides efficient and fault-tolerant management of a pool of
95
+ WebSocket connections to a specified URI. It supports connection reuse,
96
+ automatic cleanup of idle or disconnected connections, retry mechanisms,
97
+ optional warm-up on startup, and server data detection during connection
98
+ release.
99
+
100
+ Key Features:
101
+ - Connection reuse for improved performance
102
+ - Connection acquisition with automatic exponential backoff retries
103
+ - Optional warm-up of initial connections on startup
104
+ - Background cleanup of idle or disconnected connections
105
+ - Asynchronous server data draining to prevent data loss
106
+ - Supports context management for automatic connection release
107
+ - Thread-safe operations with proper lock management
108
+
109
+ Attributes:
110
+ uri: The WebSocket URI to connect to.
111
+ headers: Optional headers sent with each connection.
112
+ """
113
+
114
+ __logtag__ = "audex.lib.websocket.pool"
115
+
116
+ def __init__(
117
+ self,
118
+ *,
119
+ uri: str,
120
+ headers: dict[str, str] | None = None,
121
+ idle_timeout: float = 60.0,
122
+ max_connections: int = 50,
123
+ max_retries: int = 3,
124
+ cleanup_interval: float = 5.0,
125
+ connection_timeout: float = 10.0,
126
+ warmup_connections: int = 0,
127
+ check_server_data_on_release: bool = False,
128
+ drain_timeout: float = 10.0,
129
+ drain_quiet_period: float = 2.0,
130
+ drain_condition: DrainConditionCallback | None = None,
131
+ **kwargs: t.Any,
132
+ ):
133
+ """Initialize a WebSocket connection pool.
134
+
135
+ Args:
136
+ uri: The WebSocket URI to connect to.
137
+ headers: Optional HTTP headers to include in connection requests.
138
+ idle_timeout: Maximum time in seconds a connection can remain idle
139
+ before being closed. Defaults to 60.0.
140
+ max_connections: Maximum number of concurrent connections allowed
141
+ in the pool. Defaults to 50.
142
+ max_retries: Maximum number of retry attempts when acquiring a
143
+ connection fails. Defaults to 3.
144
+ cleanup_interval: Interval in seconds between cleanup operations
145
+ for idle connections. Defaults to 5.0.
146
+ connection_timeout: Timeout in seconds for establishing new
147
+ connections. Defaults to 10.0.
148
+ warmup_connections: Number of connections to create during pool
149
+ initialization. Defaults to 0.
150
+ check_server_data_on_release: Whether to check for server data
151
+ during connection release. Defaults to False.
152
+ drain_timeout: Maximum time in seconds to drain server data
153
+ during release. Defaults to 10.0.
154
+ drain_quiet_period: Time in seconds to wait without receiving data
155
+ before considering connection clean. Defaults to 2.0.
156
+ drain_condition: Function to determine what constitutes server data
157
+ that should be drained. Defaults to None (uses default
158
+ condition).
159
+ **kwargs: Additional parameters to pass to WebsocketConnection.
160
+ """
161
+ super().__init__()
162
+ self.uri = uri
163
+ self.headers = headers or {}
164
+ self._idle_timeout = idle_timeout
165
+ self._max_connections = max_connections
166
+ self._max_retries = max_retries
167
+ self._cleanup_interval = cleanup_interval
168
+ self._connection_timeout = connection_timeout
169
+ self._warmup_connections = warmup_connections
170
+ self._check_server_data_on_release = check_server_data_on_release
171
+ self._drain_timeout = drain_timeout
172
+ self._drain_quiet_period = drain_quiet_period
173
+ self._drain_condition = drain_condition or self._default_drain_condition
174
+ self._params = kwargs
175
+
176
+ self._available: collections.deque[WebsocketConnection] = collections.deque()
177
+ self._busy: set[WebsocketConnection] = set()
178
+ # pending_id -> PendingConnection
179
+ self._pending: dict[str, PendingConnection] = {}
180
+ self._total = 0
181
+ self._lock = aio.Lock()
182
+ self._closed = False
183
+ self._cleanup_task: aio.Task[None] | None = None
184
+ self._started = False
185
+
186
+ @staticmethod
187
+ def _default_drain_condition(message: str | bytes) -> bool:
188
+ """Default condition to determine if incoming data should be
189
+ drained.
190
+
191
+ Args:
192
+ message: The incoming message from the server.
193
+
194
+ Returns:
195
+ True if the message should be drained (considered as server data).
196
+ """
197
+ # By default, consider any non-empty message as server data
198
+ return len(message) > 0
199
+
200
+ @property
201
+ def is_closed(self) -> bool:
202
+ """Check if the connection pool is closed.
203
+
204
+ Returns:
205
+ True if the pool is closed, False otherwise.
206
+ """
207
+ return self._closed
208
+
209
+ @property
210
+ def is_started(self) -> bool:
211
+ """Check if the connection pool has been started.
212
+
213
+ Returns:
214
+ True if the pool has been started, False otherwise.
215
+ """
216
+ return self._started
217
+
218
+ @property
219
+ def total_connections(self) -> int:
220
+ """Get the total number of connections in the pool.
221
+
222
+ Returns:
223
+ The total number of connections (available, busy, and pending).
224
+ """
225
+ return self._total
226
+
227
+ @property
228
+ def available_connections(self) -> int:
229
+ """Get the number of available connections.
230
+
231
+ Returns:
232
+ The number of connections available for use.
233
+ """
234
+ return len(self._available)
235
+
236
+ @property
237
+ def busy_connections(self) -> int:
238
+ """Get the number of busy connections.
239
+
240
+ Returns:
241
+ The number of connections currently in use.
242
+ """
243
+ return len(self._busy)
244
+
245
+ @property
246
+ def pending_connections(self) -> int:
247
+ """Get the number of pending connections.
248
+
249
+ Returns:
250
+ The number of connections being drained.
251
+ """
252
+ return len(self._pending)
253
+
254
+ def _create_connection(self) -> WebsocketConnection:
255
+ """Create a new WebsocketConnection with pool configuration.
256
+
257
+ Returns:
258
+ A new WebsocketConnection instance configured with pool parameters.
259
+ """
260
+ return WebsocketConnection(
261
+ uri=self.uri,
262
+ headers=self.headers,
263
+ idle_timeout=self._idle_timeout,
264
+ check_server_data_on_release=self._check_server_data_on_release,
265
+ drain_timeout=self._drain_timeout,
266
+ drain_condition=self._drain_condition,
267
+ **self._params,
268
+ )
269
+
270
+ async def start(self) -> None:
271
+ """Start the connection pool.
272
+
273
+ Initializes the pool, starts the cleanup task, and optionally
274
+ creates warmup connections if configured.
275
+ """
276
+ if self._started or self._closed:
277
+ return
278
+
279
+ self._started = True
280
+ if self._cleanup_task is None or self._cleanup_task.done():
281
+ self._cleanup_task = aio.create_task(self._cleanup_loop())
282
+
283
+ # Warmup connections if specified
284
+ if self._warmup_connections > 0:
285
+ await self._warmup_pool()
286
+
287
+ self.logger.info(f"Started WebSocket connection pool for {self.uri}")
288
+
289
+ async def _warmup_pool(self) -> None:
290
+ """Create and connect initial connections for the pool.
291
+
292
+ Creates up to warmup_connections or max_connections (whichever
293
+ is smaller) and adds them to the available connection pool.
294
+ """
295
+ warmup_count = min(self._warmup_connections, self._max_connections)
296
+
297
+ for _ in range(warmup_count):
298
+ try:
299
+ connection = self._create_connection()
300
+ await aio.wait_for(connection.connect(), timeout=self._connection_timeout)
301
+
302
+ async with self._lock:
303
+ self._available.append(connection)
304
+ self._total += 1
305
+
306
+ self.logger.debug(f"Warmed up connection: {connection}")
307
+
308
+ except Exception as e:
309
+ self.logger.warning(f"Failed to warm up connection: {e}")
310
+ # Continue warming up remaining connections even if one fails
311
+ continue
312
+
313
+ self.logger.info(f"Warmed up {len(self._available)} connections")
314
+
315
+ async def acquire(self) -> WebsocketConnection:
316
+ """Acquire a connection from the pool.
317
+
318
+ Attempts to reuse an existing available connection, or creates a new
319
+ one if none are available and the pool limit hasn't been reached.
320
+ Includes automatic retry logic for transient failures.
321
+
322
+ Returns:
323
+ An acquired WebsocketConnection ready for use.
324
+
325
+ Raises:
326
+ ConnectionPoolUnavailableError: If the pool is closed.
327
+ ConnectionPoolExhaustedError: If maximum connections limit is
328
+ reached.
329
+ ConnectionUnavailableError: If connection acquisition fails
330
+ after retries.
331
+ """
332
+ if not self._started:
333
+ await self.start()
334
+
335
+ retry = tenacity.retry(
336
+ stop=tenacity.stop_after_attempt(self._max_retries),
337
+ wait=tenacity.wait_exponential(multiplier=1, max=10),
338
+ retry=tenacity.retry_if_exception_type((
339
+ ConnectionUnavailableError,
340
+ ConnectionClosedError,
341
+ OSError,
342
+ aio.TimeoutError,
343
+ )),
344
+ )
345
+ return await retry(self._acquire)()
346
+
347
+ async def _acquire(self) -> WebsocketConnection: # type: ignore
348
+ """Internal method to acquire a connection from the pool.
349
+
350
+ This method attempts to reuse existing connections or create new ones.
351
+ Health checks are performed outside of locks to avoid blocking.
352
+
353
+ Returns:
354
+ An acquired WebsocketConnection.
355
+
356
+ Raises:
357
+ ConnectionPoolUnavailableError: If the pool is closed.
358
+ ConnectionPoolExhaustedError: If the pool is at capacity.
359
+ ConnectionUnavailableError: If connection cannot be established.
360
+ """
361
+ while True:
362
+ connection_to_test = None
363
+
364
+ async with self._lock:
365
+ if self._closed:
366
+ raise ConnectionPoolUnavailableError("Connection pool is closed")
367
+
368
+ # Try to reuse existing available connection
369
+ if self._available:
370
+ connection_to_test = self._available.popleft()
371
+
372
+ # Test connection outside lock if we got one
373
+ if connection_to_test is not None:
374
+ if connection_to_test.is_connected and not connection_to_test._closed:
375
+ try:
376
+ await connection_to_test.acquire()
377
+
378
+ # Health check the connection (outside lock)
379
+ try:
380
+ await connection_to_test.ping()
381
+
382
+ async with self._lock:
383
+ self._busy.add(connection_to_test)
384
+
385
+ self.logger.debug(f"Reused connection from pool: {connection_to_test}")
386
+ return connection_to_test
387
+
388
+ except (ConnectionClosedError, ConnectionUnavailableError):
389
+ await connection_to_test.release()
390
+ await self._remove_connection(connection_to_test)
391
+ continue
392
+
393
+ except (ConnectionBusyError, ConnectionClosedError, ConnectionUnavailableError):
394
+ # Connection is no longer usable
395
+ await self._remove_connection(connection_to_test)
396
+ continue
397
+ else:
398
+ # Connection is not healthy
399
+ await self._remove_connection(connection_to_test)
400
+ continue
401
+
402
+ # No available connection, need to create new one
403
+ async with self._lock:
404
+ if self._closed:
405
+ raise ConnectionPoolUnavailableError("Connection pool is closed")
406
+
407
+ # Double-check we're still under limit
408
+ if self._total >= self._max_connections:
409
+ raise ConnectionPoolExhaustedError(
410
+ f"Maximum connections {self._max_connections} reached. "
411
+ f"Available: {len(self._available)}, Busy: "
412
+ f"{len(self._busy)}, Pending: {len(self._pending)}"
413
+ )
414
+
415
+ # Reserve a slot for new connection
416
+ self._total += 1
417
+
418
+ # Create and connect outside lock
419
+ connection = self._create_connection()
420
+ creation_success = False
421
+
422
+ try:
423
+ await aio.wait_for(connection.connect(), timeout=self._connection_timeout)
424
+ await connection.acquire()
425
+
426
+ async with self._lock:
427
+ self._busy.add(connection)
428
+
429
+ creation_success = True
430
+ self.logger.debug(f"Created new connection: {connection}")
431
+ return connection
432
+
433
+ except Exception as e:
434
+ self.logger.error(f"Failed to create connection: {e}")
435
+
436
+ if not creation_success:
437
+ # Release the reserved slot if creation failed
438
+ async with self._lock:
439
+ self._total -= 1
440
+
441
+ await connection.close()
442
+ raise ConnectionUnavailableError(f"Failed to create connection: {e}") from e
443
+
444
+ async def acquire_new(self) -> WebsocketConnection:
445
+ """Force acquire a new connection, avoiding reuse of existing
446
+ ones.
447
+
448
+ This method always creates a fresh connection and returns it in
449
+ acquired state, similar to acquire() but without attempting to
450
+ reuse existing connections.
451
+
452
+ Returns:
453
+ A newly created and acquired WebsocketConnection.
454
+
455
+ Raises:
456
+ ConnectionPoolUnavailableError: If the pool is closed.
457
+ ConnectionPoolExhaustedError: If maximum connections limit is
458
+ reached.
459
+ ConnectionUnavailableError: If connection creation fails after
460
+ retries.
461
+ """
462
+ if not self._started:
463
+ await self.start()
464
+
465
+ retry = tenacity.retry(
466
+ stop=tenacity.stop_after_attempt(self._max_retries),
467
+ wait=tenacity.wait_exponential(multiplier=1, max=10),
468
+ retry=tenacity.retry_if_exception_type((
469
+ ConnectionUnavailableError,
470
+ ConnectionClosedError,
471
+ OSError,
472
+ aio.TimeoutError,
473
+ )),
474
+ )
475
+ return await retry(self._acquire_new)()
476
+
477
+ async def _acquire_new(self) -> WebsocketConnection:
478
+ """Internal method to force create a new connection.
479
+
480
+ Returns:
481
+ A newly created and acquired WebsocketConnection.
482
+
483
+ Raises:
484
+ ConnectionPoolUnavailableError: If the pool is closed.
485
+ ConnectionPoolExhaustedError: If the pool is at capacity.
486
+ ConnectionUnavailableError: If connection cannot be established.
487
+ """
488
+ async with self._lock:
489
+ if self._closed:
490
+ raise ConnectionPoolUnavailableError("Connection pool is closed")
491
+
492
+ # Check if we can create a new connection
493
+ if self._total >= self._max_connections:
494
+ raise ConnectionPoolExhaustedError(
495
+ f"Maximum connections {self._max_connections} reached. "
496
+ f"Available: {len(self._available)}, Busy: "
497
+ f"{len(self._busy)}, Pending: {len(self._pending)}"
498
+ )
499
+
500
+ # Reserve a slot
501
+ self._total += 1
502
+
503
+ # Create and connect outside lock
504
+ connection = self._create_connection()
505
+ creation_success = False
506
+
507
+ try:
508
+ await aio.wait_for(connection.connect(), timeout=self._connection_timeout)
509
+ await connection.acquire()
510
+
511
+ async with self._lock:
512
+ self._busy.add(connection)
513
+
514
+ creation_success = True
515
+ self.logger.debug(f"Force created new connection: {connection}")
516
+ return connection
517
+
518
+ except Exception as e:
519
+ self.logger.error(f"Failed to force create connection: {e}")
520
+
521
+ if not creation_success:
522
+ # Release the reserved slot
523
+ async with self._lock:
524
+ self._total -= 1
525
+
526
+ await connection.close()
527
+ raise ConnectionUnavailableError(f"Failed to create connection: {e}") from e
528
+
529
+ async def _remove_connection(self, connection: WebsocketConnection) -> None:
530
+ """Remove and close a connection, updating counters atomically.
531
+
532
+ This method ensures that a connection is removed from all tracking
533
+ structures exactly once and the total counter is decremented correctly.
534
+
535
+ Args:
536
+ connection: The WebsocketConnection to remove.
537
+ """
538
+ async with self._lock:
539
+ was_tracked = False
540
+
541
+ # Remove from busy set
542
+ if connection in self._busy:
543
+ self._busy.discard(connection)
544
+ was_tracked = True
545
+
546
+ # Remove from available deque
547
+ try:
548
+ self._available.remove(connection)
549
+ was_tracked = True
550
+ except ValueError:
551
+ pass # Not in available
552
+
553
+ # Remove from pending connections
554
+ pending_to_remove = []
555
+ for pending_id, pending_conn in self._pending.items():
556
+ if pending_conn.connection == connection:
557
+ pending_to_remove.append(pending_id)
558
+ was_tracked = True
559
+
560
+ for pending_id in pending_to_remove:
561
+ pending_conn = self._pending.pop(pending_id)
562
+ if pending_conn.drain_task and not pending_conn.drain_task.done():
563
+ pending_conn.drain_task.cancel()
564
+
565
+ # Only decrement counter if we actually tracked this connection
566
+ if was_tracked:
567
+ self._total -= 1
568
+
569
+ # Close connection outside lock
570
+ try:
571
+ await connection.close()
572
+ except Exception as e:
573
+ self.logger.warning(f"Error closing connection during removal: {e}")
574
+
575
+ async def _drain_connection(self, pending_conn: PendingConnection) -> None:
576
+ """Background task to drain server data from a connection.
577
+
578
+ This task continuously receives data from the websocket until either:
579
+ 1. No data is received for drain_quiet_period seconds
580
+ 2. The drain_timeout is reached
581
+ 3. An error occurs
582
+
583
+ If the connection is successfully drained, it's moved to available pool.
584
+ Otherwise, it's removed from the pool.
585
+
586
+ Args:
587
+ pending_conn: The PendingConnection to drain.
588
+ """
589
+ connection = pending_conn.connection
590
+ start_time = time.time()
591
+ last_message_time = start_time
592
+ drained_messages = 0
593
+
594
+ self.logger.debug(f"Starting drain task for connection {connection}")
595
+
596
+ try:
597
+ while True:
598
+ current_time = time.time()
599
+
600
+ # Check if we've exceeded the overall drain timeout
601
+ if current_time - start_time > pending_conn.drain_timeout:
602
+ self.logger.debug(f"Drain timeout reached for connection {connection}")
603
+ break
604
+
605
+ # Check if we've been quiet for the required period
606
+ if current_time - last_message_time > self._drain_quiet_period:
607
+ self.logger.debug(
608
+ f"Quiet period reached for connection {connection}, moving to available"
609
+ )
610
+
611
+ # Mark as completed atomically
612
+ if not await pending_conn.mark_completed():
613
+ # Already completed by cleanup
614
+ return
615
+
616
+ # Connection is clean, move it to available
617
+ async with self._lock:
618
+ if pending_conn.pending_id in self._pending:
619
+ self._pending.pop(pending_conn.pending_id)
620
+ if connection.is_connected and not connection._closed:
621
+ self._available.append(connection)
622
+ self.logger.debug(
623
+ f"Moved drained connection to available: {connection}"
624
+ )
625
+ else:
626
+ self.logger.debug(
627
+ f"Connection not connected during drain "
628
+ f"completion: {connection}"
629
+ )
630
+ # Don't call _remove_connection here as we already popped from pending
631
+ self._total -= 1
632
+ await connection.close()
633
+ return
634
+
635
+ # Try to receive data with a short timeout
636
+ try:
637
+ recv_timeout = min(0.5, self._drain_quiet_period / 2)
638
+ message = await aio.wait_for(connection.websocket.recv(), timeout=recv_timeout) # type: ignore
639
+
640
+ last_message_time = current_time
641
+ drained_messages += 1
642
+
643
+ # Check if this message matches our drain condition
644
+ if pending_conn.drain_condition(message):
645
+ self.logger.debug(
646
+ f"Drained server message from {connection}: {str(message)[:100]}..."
647
+ )
648
+ continue
649
+
650
+ # Message doesn't match drain condition, connection not clean
651
+ self.logger.debug(
652
+ f"Received unexpected message during drain from "
653
+ f"{connection}: {str(message)[:100]}..."
654
+ )
655
+ break
656
+
657
+ except TimeoutError:
658
+ # No data received within timeout, continue checking
659
+ continue
660
+
661
+ except Exception as e:
662
+ self.logger.debug(f"Error during drain process for {connection}: {e}")
663
+ break
664
+
665
+ except Exception as e:
666
+ self.logger.debug(f"Unexpected error in drain task for {connection}: {e}")
667
+
668
+ finally:
669
+ # Mark as completed atomically
670
+ if not await pending_conn.mark_completed():
671
+ # Already completed by cleanup
672
+ return # noqa: B012
673
+
674
+ # Connection is not clean or an error occurred, remove it
675
+ self.logger.debug(
676
+ f"Removing connection after drain task: {connection}, "
677
+ f"messages drained: {drained_messages}"
678
+ )
679
+
680
+ async with self._lock:
681
+ if pending_conn.pending_id in self._pending:
682
+ self._pending.pop(pending_conn.pending_id)
683
+ self._total -= 1
684
+
685
+ await connection.close()
686
+
687
+ async def release(self, connection: WebsocketConnection, *, force_remove: bool = False) -> None:
688
+ """Release a connection back to the pool.
689
+
690
+ The connection will be either:
691
+ 1. Returned to the available pool immediately if data draining is disabled
692
+ 2. Put into pending state for background draining if data draining is enabled
693
+ 3. Removed from the pool if force_remove is True or the connection is unhealthy
694
+
695
+ Args:
696
+ connection: The WebsocketConnection to release.
697
+ force_remove: If True, the connection will be removed from the pool
698
+ regardless of its state. Defaults to False.
699
+ """
700
+ async with self._lock:
701
+ if connection not in self._busy:
702
+ self.logger.warning(
703
+ f"Attempting to release connection not in busy set: {connection}"
704
+ )
705
+ return
706
+
707
+ self._busy.remove(connection)
708
+
709
+ # Release connection outside lock
710
+ try:
711
+ await connection.release()
712
+ except Exception as e:
713
+ self.logger.warning(f"Error releasing connection: {e}")
714
+ await self._remove_connection(connection)
715
+ return
716
+
717
+ async with self._lock:
718
+ if force_remove or not connection.is_connected or self._closed:
719
+ # Connection should be removed
720
+ if connection.is_connected:
721
+ self._total -= 1
722
+ await connection.close()
723
+ self.logger.debug(f"Removed connection from pool: {connection}")
724
+ return
725
+
726
+ # If we don't need to check for server data, directly return to available
727
+ if not self._check_server_data_on_release:
728
+ self._available.append(connection)
729
+ self.logger.debug(f"Released connection directly to available: {connection}")
730
+ return
731
+
732
+ # Put connection into pending state for background draining
733
+ pending_conn = PendingConnection(
734
+ connection=connection,
735
+ drain_timeout=self._drain_timeout,
736
+ drain_condition=self._drain_condition,
737
+ )
738
+
739
+ self._pending[pending_conn.pending_id] = pending_conn
740
+
741
+ # Start background drain task outside lock
742
+ pending_conn.drain_task = aio.create_task(self._drain_connection(pending_conn))
743
+
744
+ self.logger.debug(f"Put connection into pending state for draining: {connection}")
745
+
746
+ @contextlib.asynccontextmanager
747
+ async def get_connection(self) -> t.AsyncGenerator[WebsocketConnection, None]:
748
+ """Get a connection from the pool as an async context manager.
749
+
750
+ The connection will be automatically released back to the pool when
751
+ the context manager exits.
752
+
753
+ Yields:
754
+ A WebsocketConnection from the pool.
755
+
756
+ Raises:
757
+ ConnectionPoolUnavailableError: If the pool is closed.
758
+ ConnectionPoolExhaustedError: If maximum connections limit is reached.
759
+ ConnectionUnavailableError: If connection acquisition fails.
760
+
761
+ Example:
762
+ async with pool.get_connection() as conn:
763
+ await conn.send("Hello")
764
+ response = await conn.recv()
765
+ """
766
+ connection = await self.acquire()
767
+ try:
768
+ yield connection
769
+ finally:
770
+ await self.release(connection)
771
+
772
+ @contextlib.asynccontextmanager
773
+ async def get_new_connection(self) -> t.AsyncGenerator[WebsocketConnection, None]:
774
+ """Get a new connection from the pool as an async context
775
+ manager.
776
+
777
+ Forces creation of a new connection without reusing existing ones.
778
+ The connection will be automatically released back to the pool when
779
+ the context manager exits.
780
+
781
+ Yields:
782
+ A newly created WebsocketConnection.
783
+
784
+ Raises:
785
+ ConnectionPoolUnavailableError: If the pool is closed.
786
+ ConnectionPoolExhaustedError: If maximum connections limit is
787
+ reached.
788
+ ConnectionUnavailableError: If connection creation fails.
789
+
790
+ Example:
791
+ async with pool.get_new_connection() as conn:
792
+ await conn.send("Hello")
793
+ response = await conn.recv()
794
+ """
795
+ connection = await self.acquire_new()
796
+ try:
797
+ yield connection
798
+ finally:
799
+ await self.release(connection)
800
+
801
+ async def _cleanup_loop(self) -> None:
802
+ """Background task that periodically cleans up idle connections.
803
+
804
+ This task runs continuously until the pool is closed, checking
805
+ for and removing idle or disconnected connections at regular
806
+ intervals.
807
+ """
808
+ try:
809
+ while not self._closed:
810
+ await aio.sleep(self._cleanup_interval)
811
+ await self._cleanup_idle()
812
+ except aio.CancelledError:
813
+ pass
814
+ except Exception as e:
815
+ self.logger.error(f"Error in cleanup loop: {e}")
816
+
817
+ async def _cleanup_idle(self) -> None:
818
+ """Clean up idle and disconnected connections from the pool.
819
+
820
+ This method removes:
821
+ - Available connections that are idle or disconnected
822
+ - Busy connections that are no longer connected
823
+ - Pending connections that have timed out or are disconnected
824
+ """
825
+ current_time = time.time()
826
+
827
+ # Collect connections to remove
828
+ available_to_remove = []
829
+ busy_to_remove = []
830
+ pending_to_remove = []
831
+
832
+ async with self._lock:
833
+ # Find available connections to clean up
834
+ for conn in list(self._available):
835
+ if (
836
+ not conn.is_connected
837
+ or (current_time - conn.last_activity) > self._idle_timeout
838
+ ):
839
+ available_to_remove.append(conn)
840
+
841
+ # Find busy connections that are no longer connected
842
+ for conn in list(self._busy):
843
+ if not conn.is_connected:
844
+ busy_to_remove.append(conn)
845
+
846
+ # Find pending connections to clean up
847
+ for pending_id, pending_conn in list(self._pending.items()):
848
+ conn = pending_conn.connection
849
+
850
+ # Check if connection is no longer connected
851
+ if not conn.is_connected:
852
+ pending_to_remove.append((pending_id, pending_conn))
853
+ continue
854
+
855
+ # Check if pending connection has been around too long
856
+ if (current_time - pending_conn.created_at) > (
857
+ self._drain_timeout + self._cleanup_interval
858
+ ):
859
+ pending_to_remove.append((pending_id, pending_conn))
860
+ continue
861
+
862
+ # Remove from tracking structures
863
+ for conn in available_to_remove:
864
+ try:
865
+ self._available.remove(conn)
866
+ self._total -= 1
867
+ except ValueError:
868
+ pass # Already removed
869
+
870
+ for conn in busy_to_remove:
871
+ self._busy.discard(conn)
872
+ self._total -= 1
873
+
874
+ for pending_id, pending_conn in pending_to_remove:
875
+ # Check if drain task already completed this
876
+ if await pending_conn.mark_completed() and pending_id in self._pending:
877
+ # We're the first to mark it completed
878
+ self._pending.pop(pending_id)
879
+ self._total -= 1
880
+
881
+ # Close connections outside lock
882
+ for conn in available_to_remove:
883
+ try:
884
+ await conn.close()
885
+ self.logger.debug(f"Cleaned up idle connection: {conn}")
886
+ except Exception as e:
887
+ self.logger.warning(f"Error closing idle connection: {e}")
888
+
889
+ for conn in busy_to_remove:
890
+ try:
891
+ await conn.close()
892
+ self.logger.warning(f"Cleaned up disconnected busy connection: {conn}")
893
+ except Exception as e:
894
+ self.logger.warning(f"Error closing disconnected connection: {e}")
895
+
896
+ for _, pending_conn in pending_to_remove:
897
+ # Cancel the drain task if it's still running
898
+ if pending_conn.drain_task and not pending_conn.drain_task.done():
899
+ pending_conn.drain_task.cancel()
900
+
901
+ try:
902
+ await pending_conn.connection.close()
903
+ self.logger.debug(
904
+ f"Cleaned up timed out pending connection: {pending_conn.connection}"
905
+ )
906
+ except Exception as e:
907
+ self.logger.warning(f"Error closing timed out pending connection: {e}")
908
+
909
+ async def close_all(self) -> None:
910
+ """Close all connections and shut down the pool.
911
+
912
+ Stops the cleanup task and closes all connections in the pool.
913
+ After calling this method, the pool cannot be used again.
914
+ """
915
+ async with self._lock:
916
+ if self._closed:
917
+ return
918
+
919
+ self._closed = True
920
+
921
+ # Cancel cleanup task
922
+ if self._cleanup_task and not self._cleanup_task.done():
923
+ self._cleanup_task.cancel()
924
+
925
+ # Collect all connections to close
926
+ connections_to_close = []
927
+ connections_to_close.extend(list(self._available))
928
+ connections_to_close.extend(list(self._busy))
929
+
930
+ drain_tasks_to_cancel = []
931
+ for pending_conn in self._pending.values():
932
+ connections_to_close.append(pending_conn.connection)
933
+ if pending_conn.drain_task and not pending_conn.drain_task.done():
934
+ drain_tasks_to_cancel.append(pending_conn.drain_task)
935
+
936
+ # Clear all tracking structures
937
+ self._available.clear()
938
+ self._busy.clear()
939
+ self._pending.clear()
940
+ self._total = 0
941
+
942
+ # Wait for cleanup task to finish
943
+ if self._cleanup_task:
944
+ with contextlib.suppress(aio.CancelledError):
945
+ await self._cleanup_task
946
+ self._cleanup_task = None
947
+
948
+ # Cancel all drain tasks
949
+ for task in drain_tasks_to_cancel:
950
+ task.cancel()
951
+
952
+ # Wait for all drain tasks to finish
953
+ if drain_tasks_to_cancel:
954
+ await aio.gather(*drain_tasks_to_cancel, return_exceptions=True)
955
+
956
+ # Close all connections
957
+ for conn in connections_to_close:
958
+ try:
959
+ await conn.close()
960
+ except Exception as e:
961
+ self.logger.warning(f"Error closing connection during pool shutdown: {e}")
962
+
963
+ self.logger.info(f"Closed WebSocket connection pool for {self.uri}")
964
+
965
+ def __len__(self) -> int:
966
+ return self._total
967
+
968
+ def __repr__(self) -> str:
969
+ return (
970
+ f"{self.__class__.__name__}("
971
+ f"uri={self.uri}, "
972
+ f"total={self._total}, "
973
+ f"available={len(self._available)}, "
974
+ f"busy={len(self._busy)}, "
975
+ f"pending={len(self._pending)}, "
976
+ f"closed={self._closed})"
977
+ )
978
+
979
+ __str__ = __repr__
980
+
981
+ async def __aenter__(self) -> t.Self:
982
+ await self.start()
983
+ return self
984
+
985
+ async def __aexit__(
986
+ self,
987
+ exc_type: type[BaseException] | None,
988
+ exc_value: BaseException | None,
989
+ traceback: types.TracebackType | None,
990
+ ) -> None:
991
+ await self.close_all()