swiftpy-framework 0.1.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 (211) hide show
  1. swiftpy/.gitignore +0 -0
  2. swiftpy/__init__.py +572 -0
  3. swiftpy/admin/__init__.py +50 -0
  4. swiftpy/admin/auth.py +522 -0
  5. swiftpy/admin/registry.py +567 -0
  6. swiftpy/admin/views.py +1583 -0
  7. swiftpy/ai/__init__.py +199 -0
  8. swiftpy/ai/_deterministic.py +40 -0
  9. swiftpy/ai/_fake.py +604 -0
  10. swiftpy/ai/agents/__init__.py +30 -0
  11. swiftpy/ai/agents/base_agent.py +506 -0
  12. swiftpy/ai/agents/memory.py +864 -0
  13. swiftpy/ai/client.py +1011 -0
  14. swiftpy/ai/conversation.py +171 -0
  15. swiftpy/ai/embeddings.py +321 -0
  16. swiftpy/ai/exceptions.py +75 -0
  17. swiftpy/ai/history_strategies.py +157 -0
  18. swiftpy/ai/middleware.py +918 -0
  19. swiftpy/ai/observability/__init__.py +28 -0
  20. swiftpy/ai/observability/drivers.py +763 -0
  21. swiftpy/ai/observability/observer.py +364 -0
  22. swiftpy/ai/pool.py +102 -0
  23. swiftpy/ai/pricing.py +101 -0
  24. swiftpy/ai/prompts/__init__.py +6 -0
  25. swiftpy/ai/prompts/loader.py +343 -0
  26. swiftpy/ai/providers/__init__.py +22 -0
  27. swiftpy/ai/providers/anthropic_provider.py +353 -0
  28. swiftpy/ai/providers/base.py +273 -0
  29. swiftpy/ai/providers/openai_provider.py +275 -0
  30. swiftpy/ai/rag/__init__.py +53 -0
  31. swiftpy/ai/rag/context_formatter.py +263 -0
  32. swiftpy/ai/rag/pipeline_builder.py +544 -0
  33. swiftpy/ai/rag/reranker.py +484 -0
  34. swiftpy/ai/rag/result.py +78 -0
  35. swiftpy/ai/rag/retriever.py +315 -0
  36. swiftpy/ai/redis_store.py +227 -0
  37. swiftpy/ai/streaming.py +115 -0
  38. swiftpy/ai/tools/__init__.py +22 -0
  39. swiftpy/ai/tools/base_tool.py +489 -0
  40. swiftpy/ai/tools/first_party.py +384 -0
  41. swiftpy/ai/tools/tool_loop.py +417 -0
  42. swiftpy/ai/vectorstore/__init__.py +46 -0
  43. swiftpy/ai/vectorstore/base.py +542 -0
  44. swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
  45. swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
  46. swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
  47. swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
  48. swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
  49. swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
  50. swiftpy/auth/__init__.py +50 -0
  51. swiftpy/auth/api_key_guard.py +544 -0
  52. swiftpy/auth/auth_manager.py +259 -0
  53. swiftpy/auth/exceptions.py +71 -0
  54. swiftpy/auth/jwt_guard.py +230 -0
  55. swiftpy/auth/jwt_issuer.py +658 -0
  56. swiftpy/auth/token_blacklist.py +300 -0
  57. swiftpy/cache/__init__.py +14 -0
  58. swiftpy/cache/cache.py +251 -0
  59. swiftpy/cache/driver.py +216 -0
  60. swiftpy/cache/drivers/memory.py +199 -0
  61. swiftpy/cache/drivers/redis.py +421 -0
  62. swiftpy/console/__init__.py +0 -0
  63. swiftpy/console/cli.py +68 -0
  64. swiftpy/console/commands/__init__.py +0 -0
  65. swiftpy/console/commands/cache.py +164 -0
  66. swiftpy/console/commands/make.py +935 -0
  67. swiftpy/console/commands/migrate.py +280 -0
  68. swiftpy/console/commands/new.py +1806 -0
  69. swiftpy/console/commands/queue.py +306 -0
  70. swiftpy/console/commands/seed.py +252 -0
  71. swiftpy/console/commands/start.py +171 -0
  72. swiftpy/console/commands/storage.py +104 -0
  73. swiftpy/console/loader.py +21 -0
  74. swiftpy/console/stubs/__init__.py +26 -0
  75. swiftpy/console/stubs/controller.stub +14 -0
  76. swiftpy/console/stubs/event.stub +14 -0
  77. swiftpy/console/stubs/job.stub +22 -0
  78. swiftpy/console/stubs/listener.stub +14 -0
  79. swiftpy/console/stubs/mail.stub +20 -0
  80. swiftpy/console/stubs/middleware.stub +16 -0
  81. swiftpy/console/stubs/migration.alter.stub +17 -0
  82. swiftpy/console/stubs/migration.api_keys.stub +22 -0
  83. swiftpy/console/stubs/migration.blank.stub +11 -0
  84. swiftpy/console/stubs/migration.create.stub +15 -0
  85. swiftpy/console/stubs/migration.stub +11 -0
  86. swiftpy/console/stubs/migration.update.stub +17 -0
  87. swiftpy/console/stubs/model.stub +17 -0
  88. swiftpy/console/stubs/provider.stub +18 -0
  89. swiftpy/console/stubs/repository.stub +10 -0
  90. swiftpy/console/stubs/request.stub +16 -0
  91. swiftpy/console/stubs/resource.stub +21 -0
  92. swiftpy/console/stubs/schema.stub +12 -0
  93. swiftpy/console/stubs/seeder.stub +14 -0
  94. swiftpy/console/stubs/service.stub +10 -0
  95. swiftpy/core/__init__.py +0 -0
  96. swiftpy/core/application.py +1306 -0
  97. swiftpy/core/bootstrap.py +30 -0
  98. swiftpy/core/config.py +432 -0
  99. swiftpy/core/container.py +596 -0
  100. swiftpy/core/container_exceptions.py +18 -0
  101. swiftpy/core/context.py +148 -0
  102. swiftpy/core/exceptions.py +352 -0
  103. swiftpy/core/providers/base.py +35 -0
  104. swiftpy/database/__init__.py +41 -0
  105. swiftpy/database/exceptions.py +42 -0
  106. swiftpy/database/identifiers.py +105 -0
  107. swiftpy/database/migrations/__init__.py +36 -0
  108. swiftpy/database/migrations/blueprint.py +431 -0
  109. swiftpy/database/migrations/lock.py +104 -0
  110. swiftpy/database/migrations/migration.py +22 -0
  111. swiftpy/database/migrations/migrator.py +507 -0
  112. swiftpy/database/migrations/schema.py +435 -0
  113. swiftpy/database/orm/__init__.py +33 -0
  114. swiftpy/database/orm/fields.py +92 -0
  115. swiftpy/database/orm/model.py +2034 -0
  116. swiftpy/database/orm/registry.py +33 -0
  117. swiftpy/database/orm/relationships.py +836 -0
  118. swiftpy/database/pagination/__init__.py +16 -0
  119. swiftpy/database/pagination/contracts.py +60 -0
  120. swiftpy/database/pagination/paginator.py +112 -0
  121. swiftpy/database/pagination/simple_paginator.py +97 -0
  122. swiftpy/database/pool.py +218 -0
  123. swiftpy/database/query_builder.py +1803 -0
  124. swiftpy/database/seeder.py +39 -0
  125. swiftpy/database/transaction.py +276 -0
  126. swiftpy/events/__init__.py +10 -0
  127. swiftpy/events/dispatcher.py +308 -0
  128. swiftpy/events/redis_bus.py +552 -0
  129. swiftpy/filesystem/__init__.py +227 -0
  130. swiftpy/filesystem/disk.py +378 -0
  131. swiftpy/filesystem/drivers/__init__.py +11 -0
  132. swiftpy/filesystem/drivers/base.py +156 -0
  133. swiftpy/filesystem/drivers/local.py +559 -0
  134. swiftpy/filesystem/drivers/s3.py +655 -0
  135. swiftpy/filesystem/events.py +37 -0
  136. swiftpy/filesystem/exceptions.py +127 -0
  137. swiftpy/filesystem/manager.py +138 -0
  138. swiftpy/filesystem/metadata.py +22 -0
  139. swiftpy/filesystem/paths.py +99 -0
  140. swiftpy/filesystem/service_provider.py +17 -0
  141. swiftpy/filesystem/uploads/__init__.py +13 -0
  142. swiftpy/filesystem/uploads/handlers.py +362 -0
  143. swiftpy/filesystem/uploads/uploaded_file.py +222 -0
  144. swiftpy/filesystem/uploads/validators.py +186 -0
  145. swiftpy/filesystem/visibility.py +22 -0
  146. swiftpy/helpers/__init__.py +42 -0
  147. swiftpy/helpers/helper.py +180 -0
  148. swiftpy/http/exceptions.py +41 -0
  149. swiftpy/http/middleware.py +307 -0
  150. swiftpy/http/request.py +375 -0
  151. swiftpy/http/response.py +442 -0
  152. swiftpy/http/router.py +1050 -0
  153. swiftpy/mail/__init__.py +58 -0
  154. swiftpy/mail/attachments.py +255 -0
  155. swiftpy/mail/config.py +71 -0
  156. swiftpy/mail/envelope.py +183 -0
  157. swiftpy/mail/exceptions.py +90 -0
  158. swiftpy/mail/facade.py +312 -0
  159. swiftpy/mail/mailable.py +53 -0
  160. swiftpy/mail/manager.py +240 -0
  161. swiftpy/mail/message.py +186 -0
  162. swiftpy/mail/models.py +19 -0
  163. swiftpy/mail/queue/__init__.py +5 -0
  164. swiftpy/mail/queue/job.py +279 -0
  165. swiftpy/mail/service_provider.py +19 -0
  166. swiftpy/mail/templates/__init__.py +5 -0
  167. swiftpy/mail/templates/renderer.py +173 -0
  168. swiftpy/mail/transports/__init__.py +21 -0
  169. swiftpy/mail/transports/api/__init__.py +13 -0
  170. swiftpy/mail/transports/api/base.py +194 -0
  171. swiftpy/mail/transports/api/mailgun.py +158 -0
  172. swiftpy/mail/transports/api/resend.py +156 -0
  173. swiftpy/mail/transports/api/sendgrid.py +172 -0
  174. swiftpy/mail/transports/base.py +24 -0
  175. swiftpy/mail/transports/console.py +91 -0
  176. swiftpy/mail/transports/sendmail.py +128 -0
  177. swiftpy/mail/transports/smtp.py +466 -0
  178. swiftpy/queue/__init__.py +13 -0
  179. swiftpy/queue/dispatcher.py +813 -0
  180. swiftpy/queue/job.py +541 -0
  181. swiftpy/queue/worker.py +556 -0
  182. swiftpy/support/__init__.py +12 -0
  183. swiftpy/support/keys.py +108 -0
  184. swiftpy/support/redis_config.py +223 -0
  185. swiftpy/support/strings.py +114 -0
  186. swiftpy/testing/__init__.py +20 -0
  187. swiftpy/testing/fakes/__init__.py +11 -0
  188. swiftpy/testing/fakes/ai_fake.py +21 -0
  189. swiftpy/testing/fakes/vectorstore_fake.py +194 -0
  190. swiftpy/testing/fixtures.py +124 -0
  191. swiftpy/testing/http_client.py +730 -0
  192. swiftpy/testing/test_case.py +126 -0
  193. swiftpy/validation/__init__.py +97 -0
  194. swiftpy/validation/exceptions.py +26 -0
  195. swiftpy/validation/form_request.py +302 -0
  196. swiftpy/validation/protocols.py +33 -0
  197. swiftpy/validation/rules/__init__.py +89 -0
  198. swiftpy/validation/rules/builtin.py +391 -0
  199. swiftpy/validation/rules/exists.py +86 -0
  200. swiftpy/validation/rules/registry.py +290 -0
  201. swiftpy/validation/rules/required.py +55 -0
  202. swiftpy/validation/rules/unique.py +138 -0
  203. swiftpy/validation/validator.py +612 -0
  204. swiftpy/websockets/__init__.py +19 -0
  205. swiftpy/websockets/connection.py +317 -0
  206. swiftpy/websockets/manager.py +271 -0
  207. swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
  208. swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
  209. swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
  210. swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
  211. swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,317 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import enum
5
+ import json
6
+ from collections.abc import Awaitable, Callable
7
+ from typing import Any, cast
8
+ from urllib.parse import parse_qs
9
+
10
+ from swiftpy.http.response import encode_json
11
+
12
+
13
+ class WebSocketState(enum.Enum):
14
+ CONNECTING = 0
15
+ CONNECTED = 1
16
+ DISCONNECTED = 2
17
+
18
+
19
+ class WebSocketException(Exception):
20
+ """Base exception for WebSocket errors."""
21
+
22
+
23
+ class WebSocketDisconnect(WebSocketException):
24
+ """Raised when a WebSocket connection is disconnected."""
25
+
26
+ def __init__(self, code: int = 1000, reason: str = "") -> None:
27
+ self.code = code
28
+ self.reason = reason
29
+ super().__init__(f"WebSocket disconnected with code {code} ({reason or 'no reason given'})")
30
+
31
+
32
+ class WebSocketConnection:
33
+ """
34
+ Wraps an ASGI websocket connection.
35
+ Provides standard accept(), receive(), send(), and close() methods.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ scope: dict[str, Any],
41
+ receive: Callable[[], Awaitable[dict[str, Any]]],
42
+ send: Callable[[dict[str, Any]], Awaitable[None]],
43
+ max_message_size: int = 1_048_576,
44
+ idle_timeout: float | None = None,
45
+ ) -> None:
46
+ self.scope = scope
47
+ self._receive = receive
48
+ self._send = send
49
+ self.max_message_size = max_message_size
50
+ self.idle_timeout = idle_timeout
51
+
52
+ self.client_state = WebSocketState.CONNECTING
53
+ self.close_code: int | None = None
54
+ self.close_reason: str = ""
55
+ self.state: dict[str, Any] = {}
56
+ self._headers: dict[str, str] | None = None
57
+ self._query_param_list: dict[str, list[str]] | None = None
58
+ self._query_params: dict[str, str] | None = None
59
+
60
+ @property
61
+ def origin(self) -> str | None:
62
+ """The Origin header sent by the client, or None if absent."""
63
+ return self.headers.get("origin")
64
+
65
+ @property
66
+ def path(self) -> str:
67
+ """The URL path of the WebSocket request."""
68
+ return cast(str, self.scope.get("path", ""))
69
+
70
+ @property
71
+ def headers(self) -> dict[str, str]:
72
+ """Parsed request headers mapping lowercase header names to values (cached per connection)."""
73
+ cached = getattr(self, "_headers", None)
74
+ if cached is None:
75
+ result: dict[str, str] = {}
76
+ for k, v in self.scope.get("headers", []):
77
+ key = k.decode("latin-1").lower() if isinstance(k, bytes) else str(k).lower()
78
+ val = v.decode("latin-1") if isinstance(v, bytes) else str(v)
79
+ result[key] = val
80
+ cached = result
81
+ self._headers = cached
82
+ return cached
83
+
84
+ @property
85
+ def query_param_list(self) -> dict[str, list[str]]:
86
+ """Parsed dictionary of UTF-8 URL query parameters with all values as lists (cached)."""
87
+ cached = getattr(self, "_query_param_list", None)
88
+ if cached is None:
89
+ raw_qs = self.scope.get("query_string", b"")
90
+ if not raw_qs:
91
+ cached = {}
92
+ else:
93
+ qs_str = (
94
+ raw_qs.decode("utf-8", errors="replace")
95
+ if isinstance(raw_qs, bytes)
96
+ else str(raw_qs)
97
+ )
98
+ cached = parse_qs(qs_str, keep_blank_values=True)
99
+ self._query_param_list = cached
100
+ return cached
101
+
102
+ @property
103
+ def query_params(self) -> dict[str, str]:
104
+ """Parsed dictionary of UTF-8 URL query string parameters (first value per key, cached)."""
105
+ cached = getattr(self, "_query_params", None)
106
+ if cached is None:
107
+ cached = {k: v[0] for k, v in self.query_param_list.items() if v}
108
+ self._query_params = cached
109
+ return cached
110
+
111
+ @property
112
+ def client(self) -> tuple[str, int] | None:
113
+ """The client's (host, port) tuple if available."""
114
+ client_tuple = self.scope.get("client")
115
+ if client_tuple and len(client_tuple) == 2:
116
+ return (str(client_tuple[0]), int(client_tuple[1]))
117
+ return None
118
+
119
+ @property
120
+ def subprotocols(self) -> list[str]:
121
+ """Subprotocols requested by the client."""
122
+ return list(self.scope.get("subprotocols", []))
123
+
124
+ @property
125
+ def is_connected(self) -> bool:
126
+ """Whether the WebSocket connection is actively open."""
127
+ return self.client_state == WebSocketState.CONNECTED
128
+
129
+ async def accept(
130
+ self,
131
+ subprotocol: str | None = None,
132
+ headers: list[tuple[bytes, bytes]] | None = None,
133
+ ) -> None:
134
+ """
135
+ Accept the incoming WebSocket connection handshake.
136
+ Consumes the initial 'websocket.connect' ASGI message and transmits 'websocket.accept'.
137
+ """
138
+ if self.client_state == WebSocketState.CONNECTED:
139
+ raise WebSocketException("Cannot accept an already accepted WebSocket connection")
140
+ if self.client_state == WebSocketState.DISCONNECTED:
141
+ raise WebSocketException("Cannot accept a closed WebSocket connection")
142
+
143
+ message = await self._receive()
144
+ msg_type = message.get("type")
145
+
146
+ if msg_type == "websocket.disconnect":
147
+ self.client_state = WebSocketState.DISCONNECTED
148
+ self.close_code = message.get("code", 1006)
149
+ self.close_reason = message.get("reason", "")
150
+ raise WebSocketDisconnect(code=self.close_code, reason=self.close_reason)
151
+
152
+ if msg_type != "websocket.connect":
153
+ raise WebSocketException(
154
+ f"Expected 'websocket.connect' message, but received '{msg_type}'"
155
+ )
156
+
157
+ accept_msg: dict[str, Any] = {"type": "websocket.accept"}
158
+ if subprotocol is not None:
159
+ accept_msg["subprotocol"] = subprotocol
160
+ if headers is not None:
161
+ accept_msg["headers"] = headers
162
+
163
+ await self._send(accept_msg)
164
+ self.client_state = WebSocketState.CONNECTED
165
+
166
+ async def receive(self) -> str | bytes:
167
+ """
168
+ Receive raw text or bytes from the WebSocket client.
169
+ Raises WebSocketDisconnect when the client disconnects, times out, or exceeds size limits.
170
+ """
171
+ if self.client_state == WebSocketState.CONNECTING:
172
+ raise WebSocketException("Cannot receive data: WebSocket has not been accepted yet")
173
+ if self.client_state == WebSocketState.DISCONNECTED:
174
+ raise WebSocketDisconnect(
175
+ code=self.close_code or 1000,
176
+ reason=self.close_reason,
177
+ )
178
+
179
+ try:
180
+ if self.idle_timeout is not None:
181
+ message = await asyncio.wait_for(self._receive(), timeout=self.idle_timeout)
182
+ else:
183
+ message = await self._receive()
184
+ except TimeoutError:
185
+ self.client_state = WebSocketState.DISCONNECTED
186
+ self.close_code = 1000
187
+ self.close_reason = "Idle timeout"
188
+ try:
189
+ await self._send({"type": "websocket.close", "code": 1000, "reason": "Idle timeout"})
190
+ except Exception: # noqa: BLE001, S110
191
+ pass
192
+ raise WebSocketDisconnect(code=1000, reason="Idle timeout") from None
193
+
194
+ msg_type = message.get("type")
195
+
196
+ if msg_type == "websocket.receive":
197
+ raw_text = message.get("text")
198
+ raw_bytes = message.get("bytes")
199
+
200
+ if raw_text is not None:
201
+ text_str = cast(str, raw_text)
202
+ if len(text_str.encode("utf-8")) > self.max_message_size:
203
+ self.client_state = WebSocketState.DISCONNECTED
204
+ self.close_code = 1009
205
+ self.close_reason = "Message too large"
206
+ try:
207
+ await self._send({"type": "websocket.close", "code": 1009, "reason": "Message too large"})
208
+ except Exception: # noqa: BLE001, S110
209
+ pass
210
+ raise WebSocketDisconnect(code=1009, reason="Message too large")
211
+ return text_str
212
+
213
+ if raw_bytes is not None:
214
+ bytes_val = cast(bytes, raw_bytes)
215
+ if len(bytes_val) > self.max_message_size:
216
+ self.client_state = WebSocketState.DISCONNECTED
217
+ self.close_code = 1009
218
+ self.close_reason = "Message too large"
219
+ try:
220
+ await self._send({"type": "websocket.close", "code": 1009, "reason": "Message too large"})
221
+ except Exception: # noqa: BLE001, S110
222
+ pass
223
+ raise WebSocketDisconnect(code=1009, reason="Message too large")
224
+ return bytes_val
225
+
226
+ return ""
227
+
228
+ if msg_type == "websocket.disconnect":
229
+ self.client_state = WebSocketState.DISCONNECTED
230
+ self.close_code = message.get("code", 1000)
231
+ self.close_reason = message.get("reason", "")
232
+ raise WebSocketDisconnect(code=self.close_code, reason=self.close_reason)
233
+
234
+ raise WebSocketException(f"Unexpected WebSocket message type: '{msg_type}'")
235
+
236
+ async def receive_text(self) -> str:
237
+ """Receive a text message from the WebSocket client."""
238
+ data = await self.receive()
239
+ if not isinstance(data, str):
240
+ raise WebSocketException("Expected text frame, but received binary frame")
241
+ return data
242
+
243
+ async def receive_bytes(self) -> bytes:
244
+ """Receive a binary message from the WebSocket client."""
245
+ data = await self.receive()
246
+ if not isinstance(data, bytes):
247
+ raise WebSocketException("Expected binary frame, but received text frame")
248
+ return data
249
+
250
+ async def receive_json(self) -> Any:
251
+ """Receive and parse a JSON message from the WebSocket client."""
252
+ text = await self.receive_text()
253
+ try:
254
+ return json.loads(text)
255
+ except json.JSONDecodeError as exc:
256
+ raise WebSocketException(f"Malformed JSON received: {exc.msg}") from exc
257
+
258
+ async def send(self, data: str | bytes | dict[str, Any] | list[Any]) -> None:
259
+ """
260
+ Send data to the WebSocket client.
261
+ Automatically routes str, bytes, and JSON serializable dict/list.
262
+ """
263
+ if self.client_state != WebSocketState.CONNECTED:
264
+ raise WebSocketException("Cannot send message: WebSocket is not connected")
265
+
266
+ if isinstance(data, str):
267
+ await self.send_text(data)
268
+ elif isinstance(data, bytes):
269
+ await self.send_bytes(data)
270
+ elif isinstance(data, (dict, list)):
271
+ await self.send_json(data)
272
+ else:
273
+ await self.send_text(str(data))
274
+
275
+ async def send_text(self, text: str) -> None:
276
+ """Send a text frame to the WebSocket client."""
277
+ if self.client_state != WebSocketState.CONNECTED:
278
+ raise WebSocketException("Cannot send message: WebSocket is not connected")
279
+ await self._send({"type": "websocket.send", "text": text})
280
+
281
+ async def send_bytes(self, data: bytes) -> None:
282
+ """Send a binary frame to the WebSocket client."""
283
+ if self.client_state != WebSocketState.CONNECTED:
284
+ raise WebSocketException("Cannot send message: WebSocket is not connected")
285
+ await self._send({"type": "websocket.send", "bytes": data})
286
+
287
+ async def send_json(self, data: Any) -> None:
288
+ """Serialize data to JSON using SwiftPY's strict encoder and send as a text frame."""
289
+ if self.client_state != WebSocketState.CONNECTED:
290
+ raise WebSocketException("Cannot send message: WebSocket is not connected")
291
+ await self._send(
292
+ {
293
+ "type": "websocket.send",
294
+ "text": encode_json(data).decode("utf-8"),
295
+ }
296
+ )
297
+
298
+ async def close(self, code: int = 1000, reason: str = "") -> None:
299
+ """Close the WebSocket connection."""
300
+ if self.client_state == WebSocketState.DISCONNECTED:
301
+ return
302
+
303
+ self.client_state = WebSocketState.DISCONNECTED
304
+ self.close_code = code
305
+ self.close_reason = reason
306
+ await self._send({"type": "websocket.close", "code": code, "reason": reason})
307
+
308
+
309
+ WebSocket = WebSocketConnection
310
+
311
+ __all__ = [
312
+ "WebSocket",
313
+ "WebSocketConnection",
314
+ "WebSocketDisconnect",
315
+ "WebSocketException",
316
+ "WebSocketState",
317
+ ]
@@ -0,0 +1,271 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import uuid
7
+ from collections import defaultdict
8
+ from collections.abc import Callable
9
+ from typing import Any
10
+ from weakref import WeakSet
11
+
12
+ from swiftpy.core.container import get_container
13
+ from swiftpy.events.redis_bus import RedisEventBus
14
+ from swiftpy.http.response import encode_json
15
+ from swiftpy.websockets.connection import (
16
+ WebSocketConnection,
17
+ WebSocketDisconnect,
18
+ WebSocketException,
19
+ )
20
+
21
+ logger = logging.getLogger("swiftpy.websockets")
22
+
23
+
24
+ class WebSocketManager:
25
+ """
26
+ Manages active WebSocket connections.
27
+ Supports rooms, filtering, and concurrent broadcast distribution.
28
+ """
29
+
30
+ _instance: WebSocketManager | None = None
31
+
32
+ def __init__(self) -> None:
33
+ self._connections: set[WebSocketConnection] = set()
34
+ self._rooms: dict[str, set[WebSocketConnection]] = defaultdict(set)
35
+ self._connection_rooms: dict[WebSocketConnection, set[str]] = defaultdict(set)
36
+ self._lock = asyncio.Lock()
37
+ self._instance_id: str = uuid.uuid4().hex
38
+ self._subscribed_buses: WeakSet[RedisEventBus] = WeakSet()
39
+
40
+ def subscribe_to_bus(self, bus: RedisEventBus) -> None:
41
+ """Subscribe this manager to distributed WebSocket broadcasts on the given RedisEventBus."""
42
+ if bus in self._subscribed_buses:
43
+ return
44
+ bus.subscribe("websocket.broadcast", self._handle_distributed_broadcast)
45
+ self._subscribed_buses.add(bus)
46
+
47
+ def unsubscribe_from_bus(self, bus: RedisEventBus) -> None:
48
+ """Unsubscribe this manager from distributed WebSocket broadcasts on the given RedisEventBus."""
49
+ if bus in self._subscribed_buses:
50
+ bus.unsubscribe("websocket.broadcast", self._handle_distributed_broadcast)
51
+ self._subscribed_buses.discard(bus)
52
+
53
+ async def _handle_distributed_broadcast(self, payload: Any) -> None:
54
+ """Deliver a cross-worker broadcast event locally while ignoring messages sent by this worker."""
55
+ if not isinstance(payload, dict):
56
+ return
57
+ if payload.get("sender_id") == self._instance_id:
58
+ return
59
+ if "message" not in payload:
60
+ return
61
+ room = payload.get("room")
62
+ await self.broadcast(
63
+ payload["message"],
64
+ room=room if isinstance(room, str) or room is None else str(room),
65
+ distribute=False,
66
+ )
67
+
68
+ @classmethod
69
+ def get_instance(cls) -> WebSocketManager:
70
+ """Retrieve the active application's WebSocketManager from the container, or fallback singleton."""
71
+ try:
72
+ container = get_container()
73
+ if container.has(cls):
74
+ return container.resolve(cls)
75
+ except RuntimeError:
76
+ pass
77
+
78
+ if cls._instance is None:
79
+ cls._instance = cls()
80
+ return cls._instance
81
+
82
+ @classmethod
83
+ def reset_instance(cls) -> None:
84
+ """Reset the fallback singleton instance and clear the active container's manager if present."""
85
+ cls._instance = None
86
+ try:
87
+ container = get_container()
88
+ if container.has(cls):
89
+ fresh = cls()
90
+ container.instance(cls, fresh)
91
+ except RuntimeError:
92
+ pass
93
+
94
+ @property
95
+ def active_count(self) -> int:
96
+ """The total number of currently active connections."""
97
+ return len(self._connections)
98
+
99
+ def is_connected(self, connection: WebSocketConnection) -> bool:
100
+ """Check if a specific connection is actively registered."""
101
+ return connection in self._connections
102
+
103
+ def get_connections(self, room: str | None = None) -> list[WebSocketConnection]:
104
+ """
105
+ Get a list of active connections.
106
+ If a room is specified, returns active connections in that room.
107
+ """
108
+ if room is not None:
109
+ return list(self._rooms.get(room, set()))
110
+ return list(self._connections)
111
+
112
+ async def connect(
113
+ self,
114
+ connection: WebSocketConnection,
115
+ room: str | None = None,
116
+ ) -> None:
117
+ """
118
+ Register an active WebSocket connection.
119
+ Optionally assign the connection to a room.
120
+ """
121
+ async with self._lock:
122
+ self._connections.add(connection)
123
+ if room is not None:
124
+ self._rooms[room].add(connection)
125
+ self._connection_rooms[connection].add(room)
126
+
127
+ async def disconnect(
128
+ self,
129
+ connection: WebSocketConnection,
130
+ room: str | None = None,
131
+ ) -> None:
132
+ """
133
+ Unregister a WebSocket connection.
134
+ Removes the connection from active connections and all rooms.
135
+ """
136
+ async with self._lock:
137
+ self._connections.discard(connection)
138
+ if connection in self._connection_rooms:
139
+ for r in list(self._connection_rooms[connection]):
140
+ if r in self._rooms:
141
+ self._rooms[r].discard(connection)
142
+ if not self._rooms[r]:
143
+ del self._rooms[r]
144
+ del self._connection_rooms[connection]
145
+ if room is not None and room in self._rooms:
146
+ self._rooms[room].discard(connection)
147
+ if not self._rooms[room]:
148
+ del self._rooms[room]
149
+
150
+ async def join_room(self, room: str, connection: WebSocketConnection) -> None:
151
+ """Add an already connected WebSocket to a specific room."""
152
+ async with self._lock:
153
+ self._connections.add(connection)
154
+ self._rooms[room].add(connection)
155
+ self._connection_rooms[connection].add(room)
156
+
157
+ async def leave_room(self, room: str, connection: WebSocketConnection) -> None:
158
+ """Remove a WebSocket from a specific room without disconnecting it."""
159
+ async with self._lock:
160
+ if room in self._rooms:
161
+ self._rooms[room].discard(connection)
162
+ if not self._rooms[room]:
163
+ del self._rooms[room]
164
+ if connection in self._connection_rooms:
165
+ self._connection_rooms[connection].discard(room)
166
+ if not self._connection_rooms[connection]:
167
+ del self._connection_rooms[connection]
168
+
169
+ async def broadcast(
170
+ self,
171
+ message: str | bytes | dict[str, Any] | list[Any],
172
+ *,
173
+ room: str | None = None,
174
+ filter_fn: Callable[[WebSocketConnection], bool] | None = None,
175
+ timeout: float = 5.0,
176
+ max_concurrency: int = 100,
177
+ distribute: bool = False,
178
+ ) -> None:
179
+ """
180
+ Broadcast a message to active connections.
181
+ - If `room` is provided, delivers only to connections in that room.
182
+ - If `filter_fn` is provided, delivers only to connections matching the predicate.
183
+ - If neither is provided, delivers to all active connections.
184
+ - `timeout`: Maximum seconds to wait per connection send (prevents stalled clients from delaying others).
185
+ - `max_concurrency`: Bounded concurrency using asyncio.Semaphore.
186
+ - `distribute`: If True, also publishes the broadcast across workers via RedisEventBus.
187
+ Failed, timed out, or disconnected connections are automatically cleaned up.
188
+ """
189
+ async with self._lock:
190
+ if room is not None:
191
+ candidates = list(self._rooms.get(room, set()))
192
+ else:
193
+ candidates = list(self._connections)
194
+
195
+ if filter_fn is not None:
196
+ recipients = []
197
+ for conn in candidates:
198
+ try:
199
+ if filter_fn(conn):
200
+ recipients.append(conn)
201
+ except Exception as exc: # noqa: BLE001
202
+ logger.warning("Error evaluating filter_fn on WebSocket connection: %s", exc)
203
+ else:
204
+ recipients = candidates
205
+
206
+ # 1. Single serialization for JSON and text payloads (P9-W3, WS-2)
207
+ prepared_text: str | None = None
208
+ prepared_bytes: bytes | None = None
209
+ distributed_message: Any = message
210
+
211
+ if isinstance(message, (dict, list)):
212
+ prepared_text = encode_json(message).decode("utf-8")
213
+ distributed_message = json.loads(prepared_text)
214
+ elif isinstance(message, str):
215
+ prepared_text = message
216
+ elif isinstance(message, bytes):
217
+ prepared_bytes = message
218
+ else:
219
+ prepared_text = str(message)
220
+
221
+ if recipients:
222
+ sem = asyncio.Semaphore(max(1, max_concurrency))
223
+
224
+ async def _safe_send(conn: WebSocketConnection) -> tuple[WebSocketConnection, Exception | None]:
225
+ try:
226
+ async with sem:
227
+ if prepared_text is not None:
228
+ await asyncio.wait_for(conn.send_text(prepared_text), timeout=timeout)
229
+ elif prepared_bytes is not None:
230
+ await asyncio.wait_for(conn.send_bytes(prepared_bytes), timeout=timeout)
231
+ return conn, None
232
+ except (WebSocketDisconnect, WebSocketException) as exc:
233
+ return conn, exc
234
+ except TimeoutError as exc:
235
+ logger.warning(
236
+ "Timeout (%ss) broadcasting to WebSocket connection; evicting stalled connection",
237
+ timeout,
238
+ )
239
+ return conn, exc
240
+ except Exception as exc: # noqa: BLE001
241
+ # Failures on one client must not abort broadcast to remaining clients
242
+ logger.warning("Error broadcasting to WebSocket connection: %s", exc)
243
+ return conn, exc
244
+
245
+ results = await asyncio.gather(*[_safe_send(conn) for conn in recipients])
246
+
247
+ failed_conns = [conn for conn, exc in results if exc is not None]
248
+ if failed_conns:
249
+ for conn in failed_conns:
250
+ await self.disconnect(conn)
251
+
252
+ # 2. Cross-worker distributed fan-out via RedisEventBus (P9-W3, P6-Q6, WS-2)
253
+ if distribute:
254
+ try:
255
+ container = get_container()
256
+ if container.has(RedisEventBus):
257
+ bus = container.resolve(RedisEventBus)
258
+ await bus.publish(
259
+ "websocket.broadcast",
260
+ {
261
+ "message": distributed_message,
262
+ "room": room,
263
+ "sender_id": self._instance_id,
264
+ },
265
+ local=False,
266
+ )
267
+ except Exception as exc: # noqa: BLE001
268
+ logger.warning("Error publishing distributed WebSocket broadcast: %s", exc)
269
+
270
+
271
+ __all__ = ["WebSocketManager"]