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.
- swiftpy/.gitignore +0 -0
- swiftpy/__init__.py +572 -0
- swiftpy/admin/__init__.py +50 -0
- swiftpy/admin/auth.py +522 -0
- swiftpy/admin/registry.py +567 -0
- swiftpy/admin/views.py +1583 -0
- swiftpy/ai/__init__.py +199 -0
- swiftpy/ai/_deterministic.py +40 -0
- swiftpy/ai/_fake.py +604 -0
- swiftpy/ai/agents/__init__.py +30 -0
- swiftpy/ai/agents/base_agent.py +506 -0
- swiftpy/ai/agents/memory.py +864 -0
- swiftpy/ai/client.py +1011 -0
- swiftpy/ai/conversation.py +171 -0
- swiftpy/ai/embeddings.py +321 -0
- swiftpy/ai/exceptions.py +75 -0
- swiftpy/ai/history_strategies.py +157 -0
- swiftpy/ai/middleware.py +918 -0
- swiftpy/ai/observability/__init__.py +28 -0
- swiftpy/ai/observability/drivers.py +763 -0
- swiftpy/ai/observability/observer.py +364 -0
- swiftpy/ai/pool.py +102 -0
- swiftpy/ai/pricing.py +101 -0
- swiftpy/ai/prompts/__init__.py +6 -0
- swiftpy/ai/prompts/loader.py +343 -0
- swiftpy/ai/providers/__init__.py +22 -0
- swiftpy/ai/providers/anthropic_provider.py +353 -0
- swiftpy/ai/providers/base.py +273 -0
- swiftpy/ai/providers/openai_provider.py +275 -0
- swiftpy/ai/rag/__init__.py +53 -0
- swiftpy/ai/rag/context_formatter.py +263 -0
- swiftpy/ai/rag/pipeline_builder.py +544 -0
- swiftpy/ai/rag/reranker.py +484 -0
- swiftpy/ai/rag/result.py +78 -0
- swiftpy/ai/rag/retriever.py +315 -0
- swiftpy/ai/redis_store.py +227 -0
- swiftpy/ai/streaming.py +115 -0
- swiftpy/ai/tools/__init__.py +22 -0
- swiftpy/ai/tools/base_tool.py +489 -0
- swiftpy/ai/tools/first_party.py +384 -0
- swiftpy/ai/tools/tool_loop.py +417 -0
- swiftpy/ai/vectorstore/__init__.py +46 -0
- swiftpy/ai/vectorstore/base.py +542 -0
- swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
- swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
- swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
- swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
- swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
- swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
- swiftpy/auth/__init__.py +50 -0
- swiftpy/auth/api_key_guard.py +544 -0
- swiftpy/auth/auth_manager.py +259 -0
- swiftpy/auth/exceptions.py +71 -0
- swiftpy/auth/jwt_guard.py +230 -0
- swiftpy/auth/jwt_issuer.py +658 -0
- swiftpy/auth/token_blacklist.py +300 -0
- swiftpy/cache/__init__.py +14 -0
- swiftpy/cache/cache.py +251 -0
- swiftpy/cache/driver.py +216 -0
- swiftpy/cache/drivers/memory.py +199 -0
- swiftpy/cache/drivers/redis.py +421 -0
- swiftpy/console/__init__.py +0 -0
- swiftpy/console/cli.py +68 -0
- swiftpy/console/commands/__init__.py +0 -0
- swiftpy/console/commands/cache.py +164 -0
- swiftpy/console/commands/make.py +935 -0
- swiftpy/console/commands/migrate.py +280 -0
- swiftpy/console/commands/new.py +1806 -0
- swiftpy/console/commands/queue.py +306 -0
- swiftpy/console/commands/seed.py +252 -0
- swiftpy/console/commands/start.py +171 -0
- swiftpy/console/commands/storage.py +104 -0
- swiftpy/console/loader.py +21 -0
- swiftpy/console/stubs/__init__.py +26 -0
- swiftpy/console/stubs/controller.stub +14 -0
- swiftpy/console/stubs/event.stub +14 -0
- swiftpy/console/stubs/job.stub +22 -0
- swiftpy/console/stubs/listener.stub +14 -0
- swiftpy/console/stubs/mail.stub +20 -0
- swiftpy/console/stubs/middleware.stub +16 -0
- swiftpy/console/stubs/migration.alter.stub +17 -0
- swiftpy/console/stubs/migration.api_keys.stub +22 -0
- swiftpy/console/stubs/migration.blank.stub +11 -0
- swiftpy/console/stubs/migration.create.stub +15 -0
- swiftpy/console/stubs/migration.stub +11 -0
- swiftpy/console/stubs/migration.update.stub +17 -0
- swiftpy/console/stubs/model.stub +17 -0
- swiftpy/console/stubs/provider.stub +18 -0
- swiftpy/console/stubs/repository.stub +10 -0
- swiftpy/console/stubs/request.stub +16 -0
- swiftpy/console/stubs/resource.stub +21 -0
- swiftpy/console/stubs/schema.stub +12 -0
- swiftpy/console/stubs/seeder.stub +14 -0
- swiftpy/console/stubs/service.stub +10 -0
- swiftpy/core/__init__.py +0 -0
- swiftpy/core/application.py +1306 -0
- swiftpy/core/bootstrap.py +30 -0
- swiftpy/core/config.py +432 -0
- swiftpy/core/container.py +596 -0
- swiftpy/core/container_exceptions.py +18 -0
- swiftpy/core/context.py +148 -0
- swiftpy/core/exceptions.py +352 -0
- swiftpy/core/providers/base.py +35 -0
- swiftpy/database/__init__.py +41 -0
- swiftpy/database/exceptions.py +42 -0
- swiftpy/database/identifiers.py +105 -0
- swiftpy/database/migrations/__init__.py +36 -0
- swiftpy/database/migrations/blueprint.py +431 -0
- swiftpy/database/migrations/lock.py +104 -0
- swiftpy/database/migrations/migration.py +22 -0
- swiftpy/database/migrations/migrator.py +507 -0
- swiftpy/database/migrations/schema.py +435 -0
- swiftpy/database/orm/__init__.py +33 -0
- swiftpy/database/orm/fields.py +92 -0
- swiftpy/database/orm/model.py +2034 -0
- swiftpy/database/orm/registry.py +33 -0
- swiftpy/database/orm/relationships.py +836 -0
- swiftpy/database/pagination/__init__.py +16 -0
- swiftpy/database/pagination/contracts.py +60 -0
- swiftpy/database/pagination/paginator.py +112 -0
- swiftpy/database/pagination/simple_paginator.py +97 -0
- swiftpy/database/pool.py +218 -0
- swiftpy/database/query_builder.py +1803 -0
- swiftpy/database/seeder.py +39 -0
- swiftpy/database/transaction.py +276 -0
- swiftpy/events/__init__.py +10 -0
- swiftpy/events/dispatcher.py +308 -0
- swiftpy/events/redis_bus.py +552 -0
- swiftpy/filesystem/__init__.py +227 -0
- swiftpy/filesystem/disk.py +378 -0
- swiftpy/filesystem/drivers/__init__.py +11 -0
- swiftpy/filesystem/drivers/base.py +156 -0
- swiftpy/filesystem/drivers/local.py +559 -0
- swiftpy/filesystem/drivers/s3.py +655 -0
- swiftpy/filesystem/events.py +37 -0
- swiftpy/filesystem/exceptions.py +127 -0
- swiftpy/filesystem/manager.py +138 -0
- swiftpy/filesystem/metadata.py +22 -0
- swiftpy/filesystem/paths.py +99 -0
- swiftpy/filesystem/service_provider.py +17 -0
- swiftpy/filesystem/uploads/__init__.py +13 -0
- swiftpy/filesystem/uploads/handlers.py +362 -0
- swiftpy/filesystem/uploads/uploaded_file.py +222 -0
- swiftpy/filesystem/uploads/validators.py +186 -0
- swiftpy/filesystem/visibility.py +22 -0
- swiftpy/helpers/__init__.py +42 -0
- swiftpy/helpers/helper.py +180 -0
- swiftpy/http/exceptions.py +41 -0
- swiftpy/http/middleware.py +307 -0
- swiftpy/http/request.py +375 -0
- swiftpy/http/response.py +442 -0
- swiftpy/http/router.py +1050 -0
- swiftpy/mail/__init__.py +58 -0
- swiftpy/mail/attachments.py +255 -0
- swiftpy/mail/config.py +71 -0
- swiftpy/mail/envelope.py +183 -0
- swiftpy/mail/exceptions.py +90 -0
- swiftpy/mail/facade.py +312 -0
- swiftpy/mail/mailable.py +53 -0
- swiftpy/mail/manager.py +240 -0
- swiftpy/mail/message.py +186 -0
- swiftpy/mail/models.py +19 -0
- swiftpy/mail/queue/__init__.py +5 -0
- swiftpy/mail/queue/job.py +279 -0
- swiftpy/mail/service_provider.py +19 -0
- swiftpy/mail/templates/__init__.py +5 -0
- swiftpy/mail/templates/renderer.py +173 -0
- swiftpy/mail/transports/__init__.py +21 -0
- swiftpy/mail/transports/api/__init__.py +13 -0
- swiftpy/mail/transports/api/base.py +194 -0
- swiftpy/mail/transports/api/mailgun.py +158 -0
- swiftpy/mail/transports/api/resend.py +156 -0
- swiftpy/mail/transports/api/sendgrid.py +172 -0
- swiftpy/mail/transports/base.py +24 -0
- swiftpy/mail/transports/console.py +91 -0
- swiftpy/mail/transports/sendmail.py +128 -0
- swiftpy/mail/transports/smtp.py +466 -0
- swiftpy/queue/__init__.py +13 -0
- swiftpy/queue/dispatcher.py +813 -0
- swiftpy/queue/job.py +541 -0
- swiftpy/queue/worker.py +556 -0
- swiftpy/support/__init__.py +12 -0
- swiftpy/support/keys.py +108 -0
- swiftpy/support/redis_config.py +223 -0
- swiftpy/support/strings.py +114 -0
- swiftpy/testing/__init__.py +20 -0
- swiftpy/testing/fakes/__init__.py +11 -0
- swiftpy/testing/fakes/ai_fake.py +21 -0
- swiftpy/testing/fakes/vectorstore_fake.py +194 -0
- swiftpy/testing/fixtures.py +124 -0
- swiftpy/testing/http_client.py +730 -0
- swiftpy/testing/test_case.py +126 -0
- swiftpy/validation/__init__.py +97 -0
- swiftpy/validation/exceptions.py +26 -0
- swiftpy/validation/form_request.py +302 -0
- swiftpy/validation/protocols.py +33 -0
- swiftpy/validation/rules/__init__.py +89 -0
- swiftpy/validation/rules/builtin.py +391 -0
- swiftpy/validation/rules/exists.py +86 -0
- swiftpy/validation/rules/registry.py +290 -0
- swiftpy/validation/rules/required.py +55 -0
- swiftpy/validation/rules/unique.py +138 -0
- swiftpy/validation/validator.py +612 -0
- swiftpy/websockets/__init__.py +19 -0
- swiftpy/websockets/connection.py +317 -0
- swiftpy/websockets/manager.py +271 -0
- swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
- swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
- swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
- swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
- 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"]
|