quantum-framework 0.9.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.
- quantum/__init__.py +1 -0
- quantum/cli/__init__.py +2 -0
- quantum/cli/commands/__init__.py +15 -0
- quantum/cli/commands/build.py +417 -0
- quantum/cli/commands/dev.py +185 -0
- quantum/cli/commands/docs.py +329 -0
- quantum/cli/commands/lint.py +523 -0
- quantum/cli/commands/migrate.py +527 -0
- quantum/cli/commands/new.py +622 -0
- quantum/cli/commands/serve.py +190 -0
- quantum/cli/commands/test.py +193 -0
- quantum/cli/deploy.py +810 -0
- quantum/cli/hot_reload.py +951 -0
- quantum/cli/jobs.py +356 -0
- quantum/cli/mq.py +582 -0
- quantum/cli/pkg.py +390 -0
- quantum/cli/runner.py +547 -0
- quantum/cli/server_process.py +159 -0
- quantum/cli/utils.py +334 -0
- quantum/compiler/__init__.py +30 -0
- quantum/compiler/base_generator.py +367 -0
- quantum/compiler/cli.py +295 -0
- quantum/compiler/expression_transformer.py +444 -0
- quantum/compiler/javascript/__init__.py +10 -0
- quantum/compiler/javascript/generator.py +659 -0
- quantum/compiler/optimizer.py +270 -0
- quantum/compiler/python/__init__.py +10 -0
- quantum/compiler/python/generator.py +883 -0
- quantum/compiler/python/runtime.py +863 -0
- quantum/compiler/transpiler.py +330 -0
- quantum/core/__init__.py +3 -0
- quantum/core/ast_nodes.py +2611 -0
- quantum/core/expression_diagnostics.py +87 -0
- quantum/core/expression_stdlib.py +148 -0
- quantum/core/expressions.py +591 -0
- quantum/core/features/agents/src/__init__.py +30 -0
- quantum/core/features/agents/src/ast_node.py +540 -0
- quantum/core/features/conditionals/src/__init__.py +8 -0
- quantum/core/features/conditionals/src/ast_node.py +68 -0
- quantum/core/features/data_fetching/src/__init__.py +21 -0
- quantum/core/features/data_fetching/src/ast_node.py +312 -0
- quantum/core/features/data_fetching/src/desktop_adapter.py +351 -0
- quantum/core/features/data_fetching/src/html_adapter.py +474 -0
- quantum/core/features/data_fetching/src/parser.py +225 -0
- quantum/core/features/data_import/src/__init__.py +0 -0
- quantum/core/features/data_import/src/ast_node.py +291 -0
- quantum/core/features/data_import/src/runtime.py +538 -0
- quantum/core/features/dump/src/__init__.py +12 -0
- quantum/core/features/dump/src/ast_node.py +106 -0
- quantum/core/features/dump/src/parser.py +61 -0
- quantum/core/features/dump/src/runtime.py +246 -0
- quantum/core/features/functions/src/__init__.py +8 -0
- quantum/core/features/functions/src/ast_node.py +149 -0
- quantum/core/features/game_engine_2d/src/__init__.py +19 -0
- quantum/core/features/game_engine_2d/src/ast_nodes.py +1719 -0
- quantum/core/features/game_engine_2d/src/parser.py +983 -0
- quantum/core/features/invocation/src/__init__.py +0 -0
- quantum/core/features/invocation/src/ast_node.py +146 -0
- quantum/core/features/invocation/src/runtime.py +327 -0
- quantum/core/features/knowledge_base/src/__init__.py +6 -0
- quantum/core/features/knowledge_base/src/ast_node.py +113 -0
- quantum/core/features/knowledge_base/src/parser.py +82 -0
- quantum/core/features/logging/src/__init__.py +12 -0
- quantum/core/features/logging/src/ast_node.py +111 -0
- quantum/core/features/logging/src/parser.py +50 -0
- quantum/core/features/logging/src/runtime.py +190 -0
- quantum/core/features/loops/src/__init__.py +8 -0
- quantum/core/features/loops/src/ast_node.py +60 -0
- quantum/core/features/query/src/__init__.py +0 -0
- quantum/core/features/query/src/database_service.py +322 -0
- quantum/core/features/query/src/query_validators.py +20 -0
- quantum/core/features/state_management/src/__init__.py +11 -0
- quantum/core/features/state_management/src/ast_node.py +228 -0
- quantum/core/features/terminal_engine/src/__init__.py +21 -0
- quantum/core/features/terminal_engine/src/ast_nodes.py +560 -0
- quantum/core/features/terminal_engine/src/parser.py +361 -0
- quantum/core/features/testing_engine/src/__init__.py +41 -0
- quantum/core/features/testing_engine/src/ast_nodes.py +1212 -0
- quantum/core/features/testing_engine/src/parser.py +604 -0
- quantum/core/features/theming/src/__init__.py +48 -0
- quantum/core/features/theming/src/ast_node.py +137 -0
- quantum/core/features/theming/src/presets.py +405 -0
- quantum/core/features/ui_engine/src/__init__.py +20 -0
- quantum/core/features/ui_engine/src/ast_nodes.py +1854 -0
- quantum/core/features/ui_engine/src/parser.py +1106 -0
- quantum/core/features/websocket/src/__init__.py +24 -0
- quantum/core/features/websocket/src/ast_node.py +247 -0
- quantum/core/html_compat.py +299 -0
- quantum/core/parser.py +1235 -0
- quantum/core/parser_registry.py +213 -0
- quantum/core/parsers/__init__.py +76 -0
- quantum/core/parsers/ai/__init__.py +12 -0
- quantum/core/parsers/ai/agent_parser.py +106 -0
- quantum/core/parsers/ai/knowledge_parser.py +86 -0
- quantum/core/parsers/ai/llm_parser.py +78 -0
- quantum/core/parsers/ai/team_parser.py +88 -0
- quantum/core/parsers/base.py +322 -0
- quantum/core/parsers/composition/__init__.py +10 -0
- quantum/core/parsers/composition/import_parser.py +50 -0
- quantum/core/parsers/composition/slot_parser.py +48 -0
- quantum/core/parsers/control_flow/__init__.py +11 -0
- quantum/core/parsers/control_flow/if_parser.py +68 -0
- quantum/core/parsers/control_flow/loop_parser.py +105 -0
- quantum/core/parsers/control_flow/set_parser.py +89 -0
- quantum/core/parsers/data/__init__.py +12 -0
- quantum/core/parsers/data/data_parser.py +217 -0
- quantum/core/parsers/data/invoke_parser.py +116 -0
- quantum/core/parsers/data/query_parser.py +188 -0
- quantum/core/parsers/data/transaction_parser.py +77 -0
- quantum/core/parsers/events/__init__.py +9 -0
- quantum/core/parsers/events/dispatch_event_parser.py +44 -0
- quantum/core/parsers/forms/__init__.py +11 -0
- quantum/core/parsers/forms/action_parser.py +54 -0
- quantum/core/parsers/forms/flash_parser.py +34 -0
- quantum/core/parsers/forms/redirect_parser.py +33 -0
- quantum/core/parsers/functions/__init__.py +11 -0
- quantum/core/parsers/functions/function_parser.py +137 -0
- quantum/core/parsers/functions/param_parser.py +66 -0
- quantum/core/parsers/functions/return_parser.py +31 -0
- quantum/core/parsers/html/__init__.py +10 -0
- quantum/core/parsers/html/component_call_parser.py +130 -0
- quantum/core/parsers/html/html_parser.py +115 -0
- quantum/core/parsers/jobs/__init__.py +11 -0
- quantum/core/parsers/jobs/job_parser.py +71 -0
- quantum/core/parsers/jobs/schedule_parser.py +62 -0
- quantum/core/parsers/jobs/thread_parser.py +57 -0
- quantum/core/parsers/messaging/__init__.py +17 -0
- quantum/core/parsers/messaging/message_ack_parser.py +30 -0
- quantum/core/parsers/messaging/message_nack_parser.py +30 -0
- quantum/core/parsers/messaging/message_parser.py +114 -0
- quantum/core/parsers/messaging/queue_parser.py +61 -0
- quantum/core/parsers/messaging/websocket_parser.py +121 -0
- quantum/core/parsers/persistence/__init__.py +9 -0
- quantum/core/parsers/persistence/persist_parser.py +64 -0
- quantum/core/parsers/routing/__init__.py +9 -0
- quantum/core/parsers/routing/route_parser.py +41 -0
- quantum/core/parsers/scripting/__init__.py +12 -0
- quantum/core/parsers/scripting/pyclass_parser.py +62 -0
- quantum/core/parsers/scripting/pydecorator_parser.py +68 -0
- quantum/core/parsers/scripting/pyimport_parser.py +52 -0
- quantum/core/parsers/scripting/python_parser.py +49 -0
- quantum/core/parsers/services/__init__.py +12 -0
- quantum/core/parsers/services/dump_parser.py +52 -0
- quantum/core/parsers/services/file_parser.py +48 -0
- quantum/core/parsers/services/log_parser.py +46 -0
- quantum/core/parsers/services/mail_parser.py +65 -0
- quantum/core/tiers.py +82 -0
- quantum/packages/__init__.py +28 -0
- quantum/packages/manager.py +413 -0
- quantum/packages/manifest.py +351 -0
- quantum/packages/registry.py +399 -0
- quantum/packages/resolver.py +336 -0
- quantum/plugins/__init__.py +33 -0
- quantum/plugins/hooks.py +329 -0
- quantum/plugins/loader.py +479 -0
- quantum/plugins/manifest.py +336 -0
- quantum/plugins/registry.py +371 -0
- quantum/runtime/__init__.py +28 -0
- quantum/runtime/action_handler.py +443 -0
- quantum/runtime/adapters/__init__.py +88 -0
- quantum/runtime/adapters/memory_adapter.py +690 -0
- quantum/runtime/adapters/rabbitmq_adapter.py +715 -0
- quantum/runtime/adapters/redis_adapter.py +582 -0
- quantum/runtime/adapters/sqlite_adapter.py +414 -0
- quantum/runtime/agent_service.py +1133 -0
- quantum/runtime/api_server.py +86 -0
- quantum/runtime/ast_cache.py +506 -0
- quantum/runtime/auth_service.py +267 -0
- quantum/runtime/component.py +990 -0
- quantum/runtime/component_composer.py +319 -0
- quantum/runtime/component_resolver.py +174 -0
- quantum/runtime/database_service.py +598 -0
- quantum/runtime/email_service.py +162 -0
- quantum/runtime/error_handler.py +295 -0
- quantum/runtime/execution_context.py +286 -0
- quantum/runtime/executor_registry.py +171 -0
- quantum/runtime/executors/__init__.py +71 -0
- quantum/runtime/executors/ai/__init__.py +12 -0
- quantum/runtime/executors/ai/agent_executor.py +217 -0
- quantum/runtime/executors/ai/knowledge_executor.py +114 -0
- quantum/runtime/executors/ai/llm_executor.py +153 -0
- quantum/runtime/executors/ai/team_executor.py +171 -0
- quantum/runtime/executors/base.py +262 -0
- quantum/runtime/executors/control_flow/__init__.py +11 -0
- quantum/runtime/executors/control_flow/if_executor.py +93 -0
- quantum/runtime/executors/control_flow/loop_executor.py +307 -0
- quantum/runtime/executors/control_flow/set_executor.py +412 -0
- quantum/runtime/executors/data/__init__.py +12 -0
- quantum/runtime/executors/data/data_executor.py +145 -0
- quantum/runtime/executors/data/invoke_executor.py +176 -0
- quantum/runtime/executors/data/query_executor.py +256 -0
- quantum/runtime/executors/data/transaction_executor.py +91 -0
- quantum/runtime/executors/jobs/__init__.py +11 -0
- quantum/runtime/executors/jobs/job_executor.py +190 -0
- quantum/runtime/executors/jobs/schedule_executor.py +132 -0
- quantum/runtime/executors/jobs/thread_executor.py +127 -0
- quantum/runtime/executors/messaging/__init__.py +17 -0
- quantum/runtime/executors/messaging/message_ack_executor.py +51 -0
- quantum/runtime/executors/messaging/message_executor.py +174 -0
- quantum/runtime/executors/messaging/queue_executor.py +103 -0
- quantum/runtime/executors/messaging/websocket_executor.py +197 -0
- quantum/runtime/executors/scripting/__init__.py +11 -0
- quantum/runtime/executors/scripting/pyclass_executor.py +90 -0
- quantum/runtime/executors/scripting/pyimport_executor.py +81 -0
- quantum/runtime/executors/scripting/python_executor.py +249 -0
- quantum/runtime/executors/services/__init__.py +12 -0
- quantum/runtime/executors/services/dump_executor.py +72 -0
- quantum/runtime/executors/services/file_executor.py +89 -0
- quantum/runtime/executors/services/log_executor.py +77 -0
- quantum/runtime/executors/services/mail_executor.py +81 -0
- quantum/runtime/expression_cache.py +498 -0
- quantum/runtime/file_upload_service.py +326 -0
- quantum/runtime/function_registry.py +118 -0
- quantum/runtime/game_builder.py +166 -0
- quantum/runtime/game_code_generator.py +2371 -0
- quantum/runtime/game_templates.py +2006 -0
- quantum/runtime/godot_code_generator.py +4681 -0
- quantum/runtime/godot_templates.py +1449 -0
- quantum/runtime/job_executor.py +1599 -0
- quantum/runtime/knowledge_service.py +500 -0
- quantum/runtime/llm_cache.py +100 -0
- quantum/runtime/llm_providers.py +704 -0
- quantum/runtime/llm_service.py +287 -0
- quantum/runtime/logging_setup.py +140 -0
- quantum/runtime/message_broker.py +364 -0
- quantum/runtime/message_queue_service.py +571 -0
- quantum/runtime/param_validation.py +184 -0
- quantum/runtime/pypy_compat.py +315 -0
- quantum/runtime/python_bridge.py +698 -0
- quantum/runtime/query_validators.py +304 -0
- quantum/runtime/renderer.py +733 -0
- quantum/runtime/service_container.py +444 -0
- quantum/runtime/terminal_builder.py +76 -0
- quantum/runtime/terminal_code_generator.py +607 -0
- quantum/runtime/terminal_templates.py +243 -0
- quantum/runtime/testing_builder.py +77 -0
- quantum/runtime/testing_code_generator.py +833 -0
- quantum/runtime/testing_templates.py +85 -0
- quantum/runtime/ui_builder.py +188 -0
- quantum/runtime/ui_desktop_adapter.py +1730 -0
- quantum/runtime/ui_desktop_templates.py +307 -0
- quantum/runtime/ui_html_adapter.py +2691 -0
- quantum/runtime/ui_html_templates.py +2297 -0
- quantum/runtime/ui_mobile_adapter.py +1832 -0
- quantum/runtime/ui_mobile_templates.py +1003 -0
- quantum/runtime/ui_textual_adapter.py +1866 -0
- quantum/runtime/ui_textual_templates.py +45 -0
- quantum/runtime/ui_tokens.py +465 -0
- quantum/runtime/ui_validator.py +365 -0
- quantum/runtime/validators.py +256 -0
- quantum/runtime/web_server.py +1766 -0
- quantum/runtime/websocket_adapter.py +501 -0
- quantum/runtime/websocket_service.py +585 -0
- quantum/runtime/websocket_transport.py +289 -0
- quantum/runtime/wsgi.py +101 -0
- quantum/utils/__init__.py +1 -0
- quantum_framework-0.9.0.dist-info/METADATA +244 -0
- quantum_framework-0.9.0.dist-info/RECORD +262 -0
- quantum_framework-0.9.0.dist-info/WHEEL +5 -0
- quantum_framework-0.9.0.dist-info/entry_points.txt +2 -0
- quantum_framework-0.9.0.dist-info/licenses/LICENSE +21 -0
- quantum_framework-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WebSocket Service - Connection Management and Messaging
|
|
3
|
+
|
|
4
|
+
Manages WebSocket connections for Quantum components, providing:
|
|
5
|
+
- Connection lifecycle management
|
|
6
|
+
- Message sending/receiving
|
|
7
|
+
- Event dispatching
|
|
8
|
+
- Reconnection logic
|
|
9
|
+
- Connection state tracking
|
|
10
|
+
|
|
11
|
+
Server-side counterpart to client WebSocket connections.
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
from quantum.runtime.websocket_service import get_websocket_service
|
|
15
|
+
|
|
16
|
+
ws_service = get_websocket_service()
|
|
17
|
+
|
|
18
|
+
# Register connection handler
|
|
19
|
+
ws_service.register_handler("chat", on_message=handle_message)
|
|
20
|
+
|
|
21
|
+
# Send message to connected clients
|
|
22
|
+
ws_service.broadcast("chat", {"type": "notification", "text": "Hello!"})
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
from typing import Dict, List, Any, Optional, Callable, Set
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from datetime import datetime
|
|
32
|
+
from enum import IntEnum
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class WebSocketState(IntEnum):
|
|
38
|
+
"""WebSocket connection states (matches JavaScript WebSocket.readyState)."""
|
|
39
|
+
CONNECTING = 0
|
|
40
|
+
OPEN = 1
|
|
41
|
+
CLOSING = 2
|
|
42
|
+
CLOSED = 3
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class WebSocketConnection:
|
|
47
|
+
"""Represents a WebSocket connection."""
|
|
48
|
+
id: str
|
|
49
|
+
name: str
|
|
50
|
+
url: str
|
|
51
|
+
state: WebSocketState = WebSocketState.CLOSED
|
|
52
|
+
connected_at: Optional[datetime] = None
|
|
53
|
+
last_message_at: Optional[datetime] = None
|
|
54
|
+
message_count: int = 0
|
|
55
|
+
error: Optional[str] = None
|
|
56
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
57
|
+
# "websockets" once a real socket was opened for this record; "none"
|
|
58
|
+
# while it is only bookkeeping.
|
|
59
|
+
transport: str = "none"
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
62
|
+
"""Convert to dictionary for template access."""
|
|
63
|
+
result = {
|
|
64
|
+
"id": self.id,
|
|
65
|
+
"name": self.name,
|
|
66
|
+
"url": self.url,
|
|
67
|
+
"connected": self.state == WebSocketState.OPEN,
|
|
68
|
+
"readyState": int(self.state),
|
|
69
|
+
"connectedAt": self.connected_at.isoformat() if self.connected_at else None,
|
|
70
|
+
"lastMessageAt": self.last_message_at.isoformat() if self.last_message_at else None,
|
|
71
|
+
"messageCount": self.message_count,
|
|
72
|
+
"error": self.error,
|
|
73
|
+
"transport": self.transport,
|
|
74
|
+
}
|
|
75
|
+
if self.transport == "none":
|
|
76
|
+
# Registered but never opened. A template checking {ws.connected}
|
|
77
|
+
# already gets False; this says WHY, so a record cannot be
|
|
78
|
+
# mistaken for a live socket.
|
|
79
|
+
result["note"] = (
|
|
80
|
+
"no socket was opened for this connection — nothing sent "
|
|
81
|
+
"through it is delivered"
|
|
82
|
+
)
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class WebSocketMessage:
|
|
88
|
+
"""Represents a WebSocket message."""
|
|
89
|
+
connection_id: str
|
|
90
|
+
data: Any
|
|
91
|
+
type: str = "text" # text, json, binary
|
|
92
|
+
timestamp: datetime = field(default_factory=datetime.now)
|
|
93
|
+
direction: str = "incoming" # incoming, outgoing
|
|
94
|
+
|
|
95
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
96
|
+
"""Convert to dictionary."""
|
|
97
|
+
return {
|
|
98
|
+
"connectionId": self.connection_id,
|
|
99
|
+
"data": self.data,
|
|
100
|
+
"type": self.type,
|
|
101
|
+
"timestamp": self.timestamp.isoformat(),
|
|
102
|
+
"direction": self.direction
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class WebSocketError(Exception):
|
|
107
|
+
"""WebSocket-related error."""
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class WebSocketService:
|
|
112
|
+
"""
|
|
113
|
+
Manages WebSocket connections and messaging.
|
|
114
|
+
|
|
115
|
+
This service handles:
|
|
116
|
+
- Connection registration and lifecycle
|
|
117
|
+
- Message routing to handlers
|
|
118
|
+
- Broadcasting to multiple connections
|
|
119
|
+
- Connection state tracking
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(self):
|
|
123
|
+
self._connections: Dict[str, WebSocketConnection] = {}
|
|
124
|
+
self._handlers: Dict[str, Dict[str, List[Callable]]] = {}
|
|
125
|
+
self._message_queue: Dict[str, List[WebSocketMessage]] = {}
|
|
126
|
+
self._lock = threading.RLock()
|
|
127
|
+
self._connection_counter = 0
|
|
128
|
+
|
|
129
|
+
def _generate_connection_id(self) -> str:
|
|
130
|
+
"""Generate unique connection ID."""
|
|
131
|
+
self._connection_counter += 1
|
|
132
|
+
return f"ws_{self._connection_counter}_{int(time.time() * 1000)}"
|
|
133
|
+
|
|
134
|
+
def register_connection(
|
|
135
|
+
self,
|
|
136
|
+
name: str,
|
|
137
|
+
url: str,
|
|
138
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
139
|
+
connect: Optional[bool] = None,
|
|
140
|
+
) -> WebSocketConnection:
|
|
141
|
+
"""
|
|
142
|
+
Register a WebSocket connection, and open it when asked to.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
name: Connection name (used for referencing)
|
|
146
|
+
url: WebSocket URL
|
|
147
|
+
metadata: Additional connection metadata
|
|
148
|
+
connect: Open the socket now. Defaults to the metadata's
|
|
149
|
+
``auto_connect`` (which is what q:websocket autoConnect
|
|
150
|
+
sets), i.e. registration alone opens nothing.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
WebSocketConnection object — check ``.state``/``.transport``, a
|
|
154
|
+
record is not proof of a socket.
|
|
155
|
+
"""
|
|
156
|
+
with self._lock:
|
|
157
|
+
conn_id = self._generate_connection_id()
|
|
158
|
+
connection = WebSocketConnection(
|
|
159
|
+
id=conn_id,
|
|
160
|
+
name=name,
|
|
161
|
+
url=url,
|
|
162
|
+
state=WebSocketState.CONNECTING,
|
|
163
|
+
metadata=metadata or {}
|
|
164
|
+
)
|
|
165
|
+
self._connections[conn_id] = connection
|
|
166
|
+
self._message_queue[conn_id] = []
|
|
167
|
+
|
|
168
|
+
if connect is None:
|
|
169
|
+
connect = bool((metadata or {}).get('auto_connect'))
|
|
170
|
+
|
|
171
|
+
if connect:
|
|
172
|
+
# Deliberately outside the lock: open() blocks until the socket
|
|
173
|
+
# is up, and the transport calls back into set_connection_state
|
|
174
|
+
# from its own thread. Holding the lock here would deadlock.
|
|
175
|
+
self.open(conn_id)
|
|
176
|
+
else:
|
|
177
|
+
logger.info(
|
|
178
|
+
"q:websocket '%s' -> %s registered without connecting "
|
|
179
|
+
"(autoConnect off); nothing sent through it is delivered "
|
|
180
|
+
"until it is opened.", name, url
|
|
181
|
+
)
|
|
182
|
+
return connection
|
|
183
|
+
|
|
184
|
+
def open(self, name_or_id: str, timeout: float = 10.0) -> bool:
|
|
185
|
+
"""Open a real socket for a registered connection.
|
|
186
|
+
|
|
187
|
+
Returns True only when the socket reached OPEN. On failure the
|
|
188
|
+
connection goes to CLOSED carrying the reason, rather than sitting
|
|
189
|
+
in CONNECTING forever the way it used to.
|
|
190
|
+
"""
|
|
191
|
+
conn = self.get_connection(name_or_id)
|
|
192
|
+
if conn is None:
|
|
193
|
+
logger.warning("q:websocket: no connection named %r", name_or_id)
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
from quantum.runtime.websocket_transport import (
|
|
198
|
+
get_transport, HAS_WEBSOCKETS,
|
|
199
|
+
)
|
|
200
|
+
except ImportError: # pragma: no cover
|
|
201
|
+
HAS_WEBSOCKETS = False
|
|
202
|
+
|
|
203
|
+
if not HAS_WEBSOCKETS:
|
|
204
|
+
msg = ("no websocket transport available — install the "
|
|
205
|
+
"'websockets' package (pip install quantum[websocket])")
|
|
206
|
+
logger.warning("q:websocket '%s' -> %s: %s", conn.name, conn.url, msg)
|
|
207
|
+
self.set_connection_state(conn.id, WebSocketState.CLOSED, msg)
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
conn_id = conn.id
|
|
211
|
+
transport = get_transport()
|
|
212
|
+
opened, error = transport.connect(
|
|
213
|
+
conn_id,
|
|
214
|
+
conn.url,
|
|
215
|
+
on_open=lambda: self.set_connection_state(conn_id, WebSocketState.OPEN),
|
|
216
|
+
on_message=lambda payload: self.receive_message(
|
|
217
|
+
conn_id, payload,
|
|
218
|
+
'json' if isinstance(payload, (dict, list)) else 'text'),
|
|
219
|
+
on_close=lambda: self.set_connection_state(conn_id, WebSocketState.CLOSED),
|
|
220
|
+
on_error=lambda exc: self.set_connection_state(
|
|
221
|
+
conn_id, WebSocketState.CLOSED, str(exc)),
|
|
222
|
+
timeout=timeout,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# The callbacks run on the transport's handler thread, so they may not
|
|
226
|
+
# have landed yet. Settle the state here too — whichever arrives
|
|
227
|
+
# second is a no-op — so the caller never sees an open socket reported
|
|
228
|
+
# as CONNECTING, nor a refused one still "connecting" forever.
|
|
229
|
+
if opened:
|
|
230
|
+
with self._lock:
|
|
231
|
+
live = self._connections.get(conn_id)
|
|
232
|
+
if live is not None:
|
|
233
|
+
live.transport = "websockets"
|
|
234
|
+
self.set_connection_state(conn_id, WebSocketState.OPEN)
|
|
235
|
+
else:
|
|
236
|
+
self.set_connection_state(conn_id, WebSocketState.CLOSED, error)
|
|
237
|
+
return opened
|
|
238
|
+
|
|
239
|
+
def get_connection(self, name_or_id: str) -> Optional[WebSocketConnection]:
|
|
240
|
+
"""Get connection by name or ID."""
|
|
241
|
+
with self._lock:
|
|
242
|
+
# Try by ID first
|
|
243
|
+
if name_or_id in self._connections:
|
|
244
|
+
return self._connections[name_or_id]
|
|
245
|
+
|
|
246
|
+
# Try by name
|
|
247
|
+
for conn in self._connections.values():
|
|
248
|
+
if conn.name == name_or_id:
|
|
249
|
+
return conn
|
|
250
|
+
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
def get_connections_by_name(self, name: str) -> List[WebSocketConnection]:
|
|
254
|
+
"""Get all connections with the given name."""
|
|
255
|
+
with self._lock:
|
|
256
|
+
return [c for c in self._connections.values() if c.name == name]
|
|
257
|
+
|
|
258
|
+
def set_connection_state(
|
|
259
|
+
self,
|
|
260
|
+
connection_id: str,
|
|
261
|
+
state: WebSocketState,
|
|
262
|
+
error: Optional[str] = None
|
|
263
|
+
):
|
|
264
|
+
"""Update connection state."""
|
|
265
|
+
# O handler roda DEPOIS de soltar o lock (ver _dispatch_event).
|
|
266
|
+
evento = None
|
|
267
|
+
with self._lock:
|
|
268
|
+
if connection_id in self._connections:
|
|
269
|
+
conn = self._connections[connection_id]
|
|
270
|
+
old_state = conn.state
|
|
271
|
+
conn.state = state
|
|
272
|
+
conn.error = error
|
|
273
|
+
|
|
274
|
+
if state == WebSocketState.OPEN and old_state != WebSocketState.OPEN:
|
|
275
|
+
conn.connected_at = datetime.now()
|
|
276
|
+
evento = ("connect", {})
|
|
277
|
+
|
|
278
|
+
elif (state in (WebSocketState.CLOSING, WebSocketState.CLOSED)
|
|
279
|
+
and old_state not in (WebSocketState.CLOSING,
|
|
280
|
+
WebSocketState.CLOSED)):
|
|
281
|
+
# Only on the transition. A failed connect settles the
|
|
282
|
+
# state from the caller AND from on_error AND from
|
|
283
|
+
# on_close; without this the app's on-close body would
|
|
284
|
+
# run three times for one failure.
|
|
285
|
+
evento = ("close", {"error": error})
|
|
286
|
+
|
|
287
|
+
logger.debug(f"Connection {connection_id} state: {old_state.name} -> {state.name}")
|
|
288
|
+
|
|
289
|
+
if evento is not None:
|
|
290
|
+
self._dispatch_event(connection_id, evento[0], evento[1])
|
|
291
|
+
|
|
292
|
+
def remove_connection(self, connection_id: str):
|
|
293
|
+
"""Remove a connection."""
|
|
294
|
+
with self._lock:
|
|
295
|
+
if connection_id in self._connections:
|
|
296
|
+
del self._connections[connection_id]
|
|
297
|
+
if connection_id in self._message_queue:
|
|
298
|
+
del self._message_queue[connection_id]
|
|
299
|
+
if connection_id in self._handlers:
|
|
300
|
+
del self._handlers[connection_id]
|
|
301
|
+
|
|
302
|
+
logger.info(f"Removed WebSocket connection: {connection_id}")
|
|
303
|
+
|
|
304
|
+
def register_handler(
|
|
305
|
+
self,
|
|
306
|
+
connection_name: str,
|
|
307
|
+
event: str,
|
|
308
|
+
handler: Callable[[Dict[str, Any]], None]
|
|
309
|
+
):
|
|
310
|
+
"""
|
|
311
|
+
Register an event handler for a connection.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
connection_name: Name of the connection
|
|
315
|
+
event: Event type (connect, message, error, close)
|
|
316
|
+
handler: Callback function
|
|
317
|
+
"""
|
|
318
|
+
with self._lock:
|
|
319
|
+
if connection_name not in self._handlers:
|
|
320
|
+
self._handlers[connection_name] = {}
|
|
321
|
+
if event not in self._handlers[connection_name]:
|
|
322
|
+
self._handlers[connection_name][event] = []
|
|
323
|
+
|
|
324
|
+
self._handlers[connection_name][event].append(handler)
|
|
325
|
+
logger.debug(f"Registered handler for {connection_name}:{event}")
|
|
326
|
+
|
|
327
|
+
def _dispatch_event(
|
|
328
|
+
self,
|
|
329
|
+
connection_id: str,
|
|
330
|
+
event: str,
|
|
331
|
+
data: Dict[str, Any]
|
|
332
|
+
):
|
|
333
|
+
"""Dispatch event to registered handlers.
|
|
334
|
+
|
|
335
|
+
Os handlers rodam FORA do `self._lock`.
|
|
336
|
+
|
|
337
|
+
Antes eram chamados de dentro dele — `receive_message` embrulhava o
|
|
338
|
+
corpo inteiro num `with self._lock` e so entao despachava. O lock e do
|
|
339
|
+
servico, nao da conexao, entao um on-message lento de UMA conexao
|
|
340
|
+
parava tudo: nenhum outro send_message, nenhum outro receive_message,
|
|
341
|
+
nenhuma mudanca de estado de nenhuma outra conexao andava enquanto o
|
|
342
|
+
handler nao voltasse. Um <q:query> de dez segundos dentro de um
|
|
343
|
+
on-message congelava o servico de websocket inteiro por dez segundos.
|
|
344
|
+
(Isolar os workers de handler por conexao no transporte nao resolvia
|
|
345
|
+
isso sozinho: eles disputavam o mesmo lock.)
|
|
346
|
+
|
|
347
|
+
Aqui o lock so protege a LEITURA da lista de handlers.
|
|
348
|
+
"""
|
|
349
|
+
with self._lock:
|
|
350
|
+
conn = self._connections.get(connection_id)
|
|
351
|
+
if not conn:
|
|
352
|
+
return
|
|
353
|
+
nome = conn.name
|
|
354
|
+
# Copia: um handler pode registrar outro, e a lista viva mudaria
|
|
355
|
+
# debaixo da iteracao.
|
|
356
|
+
handlers = list(self._handlers.get(nome, {}).get(event, []))
|
|
357
|
+
|
|
358
|
+
for handler in handlers:
|
|
359
|
+
try:
|
|
360
|
+
handler(data)
|
|
361
|
+
except Exception as e:
|
|
362
|
+
logger.error(f"Error in WebSocket handler {nome}:{event}: {e}")
|
|
363
|
+
|
|
364
|
+
def receive_message(
|
|
365
|
+
self,
|
|
366
|
+
connection_id: str,
|
|
367
|
+
data: Any,
|
|
368
|
+
msg_type: str = "text"
|
|
369
|
+
):
|
|
370
|
+
"""
|
|
371
|
+
Process an incoming message.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
connection_id: Connection that received the message
|
|
375
|
+
data: Message data
|
|
376
|
+
msg_type: Message type (text, json, binary)
|
|
377
|
+
"""
|
|
378
|
+
with self._lock:
|
|
379
|
+
if connection_id not in self._connections:
|
|
380
|
+
logger.warning(f"Message for unknown connection: {connection_id}")
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
conn = self._connections[connection_id]
|
|
384
|
+
conn.message_count += 1
|
|
385
|
+
conn.last_message_at = datetime.now()
|
|
386
|
+
|
|
387
|
+
# Parse JSON if needed
|
|
388
|
+
parsed_data = data
|
|
389
|
+
if msg_type == "json" and isinstance(data, str):
|
|
390
|
+
try:
|
|
391
|
+
parsed_data = json.loads(data)
|
|
392
|
+
except json.JSONDecodeError:
|
|
393
|
+
logger.warning(f"Failed to parse JSON message: {data[:100]}")
|
|
394
|
+
|
|
395
|
+
message = WebSocketMessage(
|
|
396
|
+
connection_id=connection_id,
|
|
397
|
+
data=parsed_data,
|
|
398
|
+
type=msg_type,
|
|
399
|
+
direction="incoming"
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
# Store in queue
|
|
403
|
+
self._message_queue[connection_id].append(message)
|
|
404
|
+
|
|
405
|
+
# Keep only last 100 messages per connection
|
|
406
|
+
if len(self._message_queue[connection_id]) > 100:
|
|
407
|
+
self._message_queue[connection_id] = self._message_queue[connection_id][-100:]
|
|
408
|
+
|
|
409
|
+
payload = {
|
|
410
|
+
"data": parsed_data,
|
|
411
|
+
"type": msg_type,
|
|
412
|
+
"raw": data,
|
|
413
|
+
"timestamp": message.timestamp.isoformat()
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
# Fora do lock: o handler e codigo do app e pode demorar o que quiser
|
|
417
|
+
# sem parar as outras conexoes.
|
|
418
|
+
self._dispatch_event(connection_id, "message", payload)
|
|
419
|
+
|
|
420
|
+
def send_message(
|
|
421
|
+
self,
|
|
422
|
+
connection_name: str,
|
|
423
|
+
data: Any,
|
|
424
|
+
msg_type: str = "text"
|
|
425
|
+
) -> bool:
|
|
426
|
+
"""
|
|
427
|
+
Send a message to a connection.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
connection_name: Name of the connection
|
|
431
|
+
data: Message data
|
|
432
|
+
msg_type: Message type (text, json)
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
True if the message was written to a live socket, or recorded
|
|
436
|
+
for a connection that has no transport.
|
|
437
|
+
"""
|
|
438
|
+
connections = self.get_connections_by_name(connection_name)
|
|
439
|
+
if not connections:
|
|
440
|
+
logger.warning(f"No connections found for: {connection_name}")
|
|
441
|
+
return False
|
|
442
|
+
|
|
443
|
+
success = False
|
|
444
|
+
for conn in connections:
|
|
445
|
+
if self._send_to_connection(conn, data, msg_type):
|
|
446
|
+
success = True
|
|
447
|
+
|
|
448
|
+
return success
|
|
449
|
+
|
|
450
|
+
def _send_to_connection(
|
|
451
|
+
self,
|
|
452
|
+
conn: WebSocketConnection,
|
|
453
|
+
data: Any,
|
|
454
|
+
msg_type: str
|
|
455
|
+
) -> bool:
|
|
456
|
+
"""Write to exactly ONE connection. Returns True if it was written.
|
|
457
|
+
|
|
458
|
+
Existe porque `broadcast` chamava `send_message(conn.name, ...)` dentro
|
|
459
|
+
de um laco sobre as conexoes com aquele nome: send_message ja percorre
|
|
460
|
+
TODAS as conexoes do nome, entao N conexoes recebiam N frames cada —
|
|
461
|
+
N*N no total — e a contagem devolvida era N. Com 50 clientes num
|
|
462
|
+
broadcast eram 2500 frames e cada cliente via a mesma mensagem 50
|
|
463
|
+
vezes. O laco de fora precisa de um envio para UMA conexao.
|
|
464
|
+
"""
|
|
465
|
+
if conn.state != WebSocketState.OPEN:
|
|
466
|
+
return False
|
|
467
|
+
|
|
468
|
+
# Serialize if needed
|
|
469
|
+
send_data = data
|
|
470
|
+
if msg_type == "json" and not isinstance(data, str):
|
|
471
|
+
send_data = json.dumps(data, default=str)
|
|
472
|
+
|
|
473
|
+
if conn.transport == "websockets":
|
|
474
|
+
from quantum.runtime.websocket_transport import get_transport
|
|
475
|
+
if not get_transport().send(conn.id, send_data):
|
|
476
|
+
logger.warning(
|
|
477
|
+
"q:websocket send to '%s' failed — socket not "
|
|
478
|
+
"writable", conn.name)
|
|
479
|
+
return False
|
|
480
|
+
|
|
481
|
+
message = WebSocketMessage(
|
|
482
|
+
connection_id=conn.id,
|
|
483
|
+
data=send_data,
|
|
484
|
+
type=msg_type,
|
|
485
|
+
direction="outgoing"
|
|
486
|
+
)
|
|
487
|
+
with self._lock:
|
|
488
|
+
self._message_queue.setdefault(conn.id, []).append(message)
|
|
489
|
+
|
|
490
|
+
logger.debug(f"Message sent to {conn.name}: {str(data)[:100]}")
|
|
491
|
+
return True
|
|
492
|
+
|
|
493
|
+
def broadcast(
|
|
494
|
+
self,
|
|
495
|
+
connection_name: str,
|
|
496
|
+
data: Any,
|
|
497
|
+
msg_type: str = "json"
|
|
498
|
+
) -> int:
|
|
499
|
+
"""
|
|
500
|
+
Broadcast message to all connections with the given name.
|
|
501
|
+
|
|
502
|
+
Args:
|
|
503
|
+
connection_name: Name of connections to broadcast to
|
|
504
|
+
data: Message data
|
|
505
|
+
msg_type: Message type
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
Number of connections messaged
|
|
509
|
+
"""
|
|
510
|
+
connections = self.get_connections_by_name(connection_name)
|
|
511
|
+
sent = 0
|
|
512
|
+
|
|
513
|
+
for conn in connections:
|
|
514
|
+
# UMA conexao por volta. Chamar send_message(conn.name) aqui
|
|
515
|
+
# reenviava para o grupo inteiro a cada volta.
|
|
516
|
+
if self._send_to_connection(conn, data, msg_type):
|
|
517
|
+
sent += 1
|
|
518
|
+
|
|
519
|
+
return sent
|
|
520
|
+
|
|
521
|
+
def get_pending_messages(self, connection_id: str) -> List[WebSocketMessage]:
|
|
522
|
+
"""Get pending outgoing messages for a connection."""
|
|
523
|
+
with self._lock:
|
|
524
|
+
messages = [
|
|
525
|
+
m for m in self._message_queue.get(connection_id, [])
|
|
526
|
+
if m.direction == "outgoing"
|
|
527
|
+
]
|
|
528
|
+
# Clear sent messages
|
|
529
|
+
self._message_queue[connection_id] = [
|
|
530
|
+
m for m in self._message_queue.get(connection_id, [])
|
|
531
|
+
if m.direction == "incoming"
|
|
532
|
+
]
|
|
533
|
+
return messages
|
|
534
|
+
|
|
535
|
+
def get_connection_status(self, name: str) -> Dict[str, Any]:
|
|
536
|
+
"""Get status object for a connection."""
|
|
537
|
+
conn = self.get_connection(name)
|
|
538
|
+
if conn:
|
|
539
|
+
return conn.to_dict()
|
|
540
|
+
return {
|
|
541
|
+
"connected": False,
|
|
542
|
+
"readyState": WebSocketState.CLOSED,
|
|
543
|
+
"error": "Connection not found"
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
def get_all_connections(self) -> List[Dict[str, Any]]:
|
|
547
|
+
"""Get all connections as dictionaries."""
|
|
548
|
+
with self._lock:
|
|
549
|
+
return [c.to_dict() for c in self._connections.values()]
|
|
550
|
+
|
|
551
|
+
def close_connection(
|
|
552
|
+
self,
|
|
553
|
+
connection_name: str,
|
|
554
|
+
code: int = 1000,
|
|
555
|
+
reason: str = ""
|
|
556
|
+
):
|
|
557
|
+
"""Close the connection — the socket too, when there is one."""
|
|
558
|
+
connections = self.get_connections_by_name(connection_name)
|
|
559
|
+
for conn in connections:
|
|
560
|
+
self.set_connection_state(
|
|
561
|
+
conn.id,
|
|
562
|
+
WebSocketState.CLOSING,
|
|
563
|
+
reason or f"Close requested (code: {code})"
|
|
564
|
+
)
|
|
565
|
+
if conn.transport == "websockets":
|
|
566
|
+
from quantum.runtime.websocket_transport import get_transport
|
|
567
|
+
get_transport().close(conn.id)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
# Global singleton
|
|
571
|
+
_websocket_service: Optional[WebSocketService] = None
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def get_websocket_service() -> WebSocketService:
|
|
575
|
+
"""Get the global WebSocket service instance."""
|
|
576
|
+
global _websocket_service
|
|
577
|
+
if _websocket_service is None:
|
|
578
|
+
_websocket_service = WebSocketService()
|
|
579
|
+
return _websocket_service
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def reset_websocket_service():
|
|
583
|
+
"""Reset the global WebSocket service (for testing)."""
|
|
584
|
+
global _websocket_service
|
|
585
|
+
_websocket_service = None
|