rocket-welder-sdk 1.1.43__py3-none-any.whl → 1.1.44__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.
- rocket_welder_sdk/__init__.py +18 -22
- rocket_welder_sdk/binary_frame_reader.py +222 -0
- rocket_welder_sdk/binary_frame_writer.py +213 -0
- rocket_welder_sdk/confidence.py +206 -0
- rocket_welder_sdk/delta_frame.py +150 -0
- rocket_welder_sdk/high_level/__init__.py +8 -1
- rocket_welder_sdk/high_level/client.py +114 -3
- rocket_welder_sdk/high_level/connection_strings.py +3 -15
- rocket_welder_sdk/high_level/frame_sink_factory.py +2 -15
- rocket_welder_sdk/high_level/transport_protocol.py +4 -130
- rocket_welder_sdk/keypoints_protocol.py +520 -55
- rocket_welder_sdk/rocket_welder_client.py +0 -77
- rocket_welder_sdk/segmentation_result.py +387 -2
- rocket_welder_sdk/session_id.py +6 -182
- rocket_welder_sdk/transport/__init__.py +10 -3
- rocket_welder_sdk/transport/frame_sink.py +3 -3
- rocket_welder_sdk/transport/frame_source.py +2 -2
- rocket_welder_sdk/transport/websocket_transport.py +316 -0
- rocket_welder_sdk/varint.py +213 -0
- {rocket_welder_sdk-1.1.43.dist-info → rocket_welder_sdk-1.1.44.dist-info}/METADATA +1 -4
- {rocket_welder_sdk-1.1.43.dist-info → rocket_welder_sdk-1.1.44.dist-info}/RECORD +23 -18
- rocket_welder_sdk/transport/nng_transport.py +0 -197
- {rocket_welder_sdk-1.1.43.dist-info → rocket_welder_sdk-1.1.44.dist-info}/WHEEL +0 -0
- {rocket_welder_sdk-1.1.43.dist-info → rocket_welder_sdk-1.1.44.dist-info}/top_level.txt +0 -0
rocket_welder_sdk/session_id.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""SessionId parsing utilities
|
|
1
|
+
"""SessionId parsing utilities.
|
|
2
2
|
|
|
3
3
|
SessionId format: ps-{guid} (e.g., ps-a1b2c3d4-e5f6-7890-abcd-ef1234567890)
|
|
4
4
|
Prefix "ps" = PipelineSession.
|
|
@@ -6,33 +6,20 @@ Prefix "ps" = PipelineSession.
|
|
|
6
6
|
This module provides utilities to:
|
|
7
7
|
1. Parse SessionId from environment variable
|
|
8
8
|
2. Extract the Guid portion
|
|
9
|
-
3. Generate NNG IPC URLs for streaming results
|
|
10
|
-
4. Read explicit NNG URLs from environment variables (preferred)
|
|
11
9
|
|
|
12
|
-
## URL Configuration
|
|
10
|
+
## URL Configuration
|
|
13
11
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
- KEYPOINTS_SINK_URL
|
|
19
|
-
- ACTIONS_SINK_URL
|
|
20
|
-
|
|
21
|
-
2. **Derived from SessionId (FALLBACK)** - For backwards compatibility:
|
|
22
|
-
- SessionId env var → parse GUID → generate URLs
|
|
23
|
-
|
|
24
|
-
Use `get_nng_urls_from_env()` for explicit URLs (preferred).
|
|
25
|
-
Use `get_nng_urls(session_id)` for SessionId-derived URLs (fallback).
|
|
12
|
+
Output URLs are configured via environment variables set by rocket-welder2:
|
|
13
|
+
- SEGMENTATION_SINK_URL: URL for segmentation output (e.g., socket:///tmp/seg.sock)
|
|
14
|
+
- KEYPOINTS_SINK_URL: URL for keypoints output (e.g., socket:///tmp/kp.sock)
|
|
15
|
+
- ACTIONS_SINK_URL: URL for actions output
|
|
26
16
|
"""
|
|
27
17
|
|
|
28
18
|
from __future__ import annotations
|
|
29
19
|
|
|
30
|
-
import logging
|
|
31
20
|
import os
|
|
32
21
|
import uuid
|
|
33
22
|
|
|
34
|
-
logger = logging.getLogger(__name__)
|
|
35
|
-
|
|
36
23
|
SESSION_ID_PREFIX = "ps-"
|
|
37
24
|
SESSION_ID_ENV_VAR = "SessionId"
|
|
38
25
|
|
|
@@ -73,166 +60,3 @@ def get_session_id_from_env() -> str | None:
|
|
|
73
60
|
SessionId string or None if not set
|
|
74
61
|
"""
|
|
75
62
|
return os.environ.get(SESSION_ID_ENV_VAR)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def get_nng_urls(session_id: str) -> dict[str, str]:
|
|
79
|
-
"""Generate NNG IPC URLs from SessionId.
|
|
80
|
-
|
|
81
|
-
Args:
|
|
82
|
-
session_id: SessionId string (e.g., "ps-a1b2c3d4-...")
|
|
83
|
-
|
|
84
|
-
Returns:
|
|
85
|
-
Dictionary with 'segmentation', 'keypoints', 'actions' URLs
|
|
86
|
-
|
|
87
|
-
Examples:
|
|
88
|
-
>>> urls = get_nng_urls("ps-a1b2c3d4-e5f6-7890-abcd-ef1234567890")
|
|
89
|
-
>>> urls["segmentation"]
|
|
90
|
-
'ipc:///tmp/rw-a1b2c3d4-e5f6-7890-abcd-ef1234567890-seg.sock'
|
|
91
|
-
"""
|
|
92
|
-
guid = parse_session_id(session_id)
|
|
93
|
-
return {
|
|
94
|
-
"segmentation": f"ipc:///tmp/rw-{guid}-seg.sock",
|
|
95
|
-
"keypoints": f"ipc:///tmp/rw-{guid}-kp.sock",
|
|
96
|
-
"actions": f"ipc:///tmp/rw-{guid}-actions.sock",
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def get_segmentation_url(session_id: str) -> str:
|
|
101
|
-
"""Get NNG URL for segmentation stream.
|
|
102
|
-
|
|
103
|
-
Args:
|
|
104
|
-
session_id: SessionId string (e.g., "ps-a1b2c3d4-...")
|
|
105
|
-
|
|
106
|
-
Returns:
|
|
107
|
-
IPC URL for segmentation stream
|
|
108
|
-
"""
|
|
109
|
-
guid = parse_session_id(session_id)
|
|
110
|
-
return f"ipc:///tmp/rw-{guid}-seg.sock"
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def get_keypoints_url(session_id: str) -> str:
|
|
114
|
-
"""Get NNG URL for keypoints stream.
|
|
115
|
-
|
|
116
|
-
Args:
|
|
117
|
-
session_id: SessionId string (e.g., "ps-a1b2c3d4-...")
|
|
118
|
-
|
|
119
|
-
Returns:
|
|
120
|
-
IPC URL for keypoints stream
|
|
121
|
-
"""
|
|
122
|
-
guid = parse_session_id(session_id)
|
|
123
|
-
return f"ipc:///tmp/rw-{guid}-kp.sock"
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
def get_actions_url(session_id: str) -> str:
|
|
127
|
-
"""Get NNG URL for actions stream.
|
|
128
|
-
|
|
129
|
-
Args:
|
|
130
|
-
session_id: SessionId string (e.g., "ps-a1b2c3d4-...")
|
|
131
|
-
|
|
132
|
-
Returns:
|
|
133
|
-
IPC URL for actions stream
|
|
134
|
-
"""
|
|
135
|
-
guid = parse_session_id(session_id)
|
|
136
|
-
return f"ipc:///tmp/rw-{guid}-actions.sock"
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
# ============================================================================
|
|
140
|
-
# Explicit URL functions (PREFERRED - URLs set by rocket-welder2)
|
|
141
|
-
# ============================================================================
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
def get_nng_urls_from_env() -> dict[str, str | None]:
|
|
145
|
-
"""Get NNG URLs from explicit environment variables.
|
|
146
|
-
|
|
147
|
-
This is the PREFERRED method for getting NNG URLs. rocket-welder2
|
|
148
|
-
sets these environment variables when starting containers.
|
|
149
|
-
|
|
150
|
-
Returns:
|
|
151
|
-
Dictionary with 'segmentation', 'keypoints', 'actions' URLs.
|
|
152
|
-
Values are None if not configured.
|
|
153
|
-
|
|
154
|
-
Examples:
|
|
155
|
-
>>> os.environ["SEGMENTATION_SINK_URL"] = "ipc:///tmp/rw-abc-seg.sock"
|
|
156
|
-
>>> urls = get_nng_urls_from_env()
|
|
157
|
-
>>> urls["segmentation"]
|
|
158
|
-
'ipc:///tmp/rw-abc-seg.sock'
|
|
159
|
-
"""
|
|
160
|
-
return {
|
|
161
|
-
"segmentation": os.environ.get(SEGMENTATION_SINK_URL_ENV),
|
|
162
|
-
"keypoints": os.environ.get(KEYPOINTS_SINK_URL_ENV),
|
|
163
|
-
"actions": os.environ.get(ACTIONS_SINK_URL_ENV),
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
def get_segmentation_url_from_env() -> str | None:
|
|
168
|
-
"""Get segmentation NNG URL from environment variable.
|
|
169
|
-
|
|
170
|
-
Returns:
|
|
171
|
-
IPC URL for segmentation stream, or None if not configured.
|
|
172
|
-
"""
|
|
173
|
-
return os.environ.get(SEGMENTATION_SINK_URL_ENV)
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
def get_keypoints_url_from_env() -> str | None:
|
|
177
|
-
"""Get keypoints NNG URL from environment variable.
|
|
178
|
-
|
|
179
|
-
Returns:
|
|
180
|
-
IPC URL for keypoints stream, or None if not configured.
|
|
181
|
-
"""
|
|
182
|
-
return os.environ.get(KEYPOINTS_SINK_URL_ENV)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
def get_actions_url_from_env() -> str | None:
|
|
186
|
-
"""Get actions NNG URL from environment variable.
|
|
187
|
-
|
|
188
|
-
Returns:
|
|
189
|
-
IPC URL for actions stream, or None if not configured.
|
|
190
|
-
"""
|
|
191
|
-
return os.environ.get(ACTIONS_SINK_URL_ENV)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def has_explicit_nng_urls() -> bool:
|
|
195
|
-
"""Check if explicit NNG URLs are configured.
|
|
196
|
-
|
|
197
|
-
Returns:
|
|
198
|
-
True if at least segmentation OR keypoints URL is configured.
|
|
199
|
-
"""
|
|
200
|
-
urls = get_nng_urls_from_env()
|
|
201
|
-
return bool(urls["segmentation"] or urls["keypoints"])
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
def get_configured_nng_urls() -> dict[str, str]:
|
|
205
|
-
"""Get all configured NNG URLs (explicit or derived from SessionId).
|
|
206
|
-
|
|
207
|
-
Priority:
|
|
208
|
-
1. Explicit URLs from environment (SEGMENTATION_SINK_URL, etc.)
|
|
209
|
-
2. Derived from SessionId environment variable (fallback)
|
|
210
|
-
|
|
211
|
-
Returns:
|
|
212
|
-
Dictionary with 'segmentation', 'keypoints', 'actions' URLs.
|
|
213
|
-
Only includes URLs that are actually configured.
|
|
214
|
-
|
|
215
|
-
Raises:
|
|
216
|
-
ValueError: If no NNG URLs are configured (neither explicit nor SessionId).
|
|
217
|
-
"""
|
|
218
|
-
# Try explicit URLs first (preferred)
|
|
219
|
-
explicit_urls = get_nng_urls_from_env()
|
|
220
|
-
result: dict[str, str] = {}
|
|
221
|
-
|
|
222
|
-
for name, url in explicit_urls.items():
|
|
223
|
-
if url:
|
|
224
|
-
result[name] = url
|
|
225
|
-
|
|
226
|
-
# If we have at least one explicit URL, return what we have
|
|
227
|
-
if result:
|
|
228
|
-
return result
|
|
229
|
-
|
|
230
|
-
# Fallback: derive from SessionId
|
|
231
|
-
session_id = get_session_id_from_env()
|
|
232
|
-
if session_id:
|
|
233
|
-
return get_nng_urls(session_id)
|
|
234
|
-
|
|
235
|
-
raise ValueError(
|
|
236
|
-
"No NNG URLs configured. Set SEGMENTATION_SINK_URL/KEYPOINTS_SINK_URL "
|
|
237
|
-
"environment variables, or set SessionId for URL derivation."
|
|
238
|
-
)
|
|
@@ -6,7 +6,6 @@ Provides transport-agnostic frame sink/source abstractions for protocols.
|
|
|
6
6
|
|
|
7
7
|
from .frame_sink import IFrameSink, NullFrameSink
|
|
8
8
|
from .frame_source import IFrameSource
|
|
9
|
-
from .nng_transport import NngFrameSink, NngFrameSource
|
|
10
9
|
from .stream_transport import StreamFrameSink, StreamFrameSource
|
|
11
10
|
from .tcp_transport import TcpFrameSink, TcpFrameSource
|
|
12
11
|
from .unix_socket_transport import (
|
|
@@ -14,12 +13,16 @@ from .unix_socket_transport import (
|
|
|
14
13
|
UnixSocketFrameSource,
|
|
15
14
|
UnixSocketServer,
|
|
16
15
|
)
|
|
16
|
+
from .websocket_transport import (
|
|
17
|
+
WebSocketFrameSink,
|
|
18
|
+
WebSocketFrameSource,
|
|
19
|
+
connect_websocket_sink,
|
|
20
|
+
connect_websocket_source,
|
|
21
|
+
)
|
|
17
22
|
|
|
18
23
|
__all__ = [
|
|
19
24
|
"IFrameSink",
|
|
20
25
|
"IFrameSource",
|
|
21
|
-
"NngFrameSink",
|
|
22
|
-
"NngFrameSource",
|
|
23
26
|
"NullFrameSink",
|
|
24
27
|
"StreamFrameSink",
|
|
25
28
|
"StreamFrameSource",
|
|
@@ -28,4 +31,8 @@ __all__ = [
|
|
|
28
31
|
"UnixSocketFrameSink",
|
|
29
32
|
"UnixSocketFrameSource",
|
|
30
33
|
"UnixSocketServer",
|
|
34
|
+
"WebSocketFrameSink",
|
|
35
|
+
"WebSocketFrameSource",
|
|
36
|
+
"connect_websocket_sink",
|
|
37
|
+
"connect_websocket_source",
|
|
31
38
|
]
|
|
@@ -9,7 +9,7 @@ class IFrameSink(ABC):
|
|
|
9
9
|
|
|
10
10
|
Transport-agnostic interface that handles the question: "where do frames go?"
|
|
11
11
|
This abstraction decouples protocol logic (KeyPoints, SegmentationResults) from
|
|
12
|
-
transport mechanisms (File, TCP, WebSocket,
|
|
12
|
+
transport mechanisms (File, TCP, WebSocket, Unix Socket). Each frame is written atomically.
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
15
|
@abstractmethod
|
|
@@ -37,7 +37,7 @@ class IFrameSink(ABC):
|
|
|
37
37
|
"""
|
|
38
38
|
Flush any buffered data to the transport synchronously.
|
|
39
39
|
|
|
40
|
-
For message-based transports (
|
|
40
|
+
For message-based transports (WebSocket), this may be a no-op.
|
|
41
41
|
"""
|
|
42
42
|
pass
|
|
43
43
|
|
|
@@ -46,7 +46,7 @@ class IFrameSink(ABC):
|
|
|
46
46
|
"""
|
|
47
47
|
Flush any buffered data to the transport asynchronously.
|
|
48
48
|
|
|
49
|
-
For message-based transports (
|
|
49
|
+
For message-based transports (WebSocket), this may be a no-op.
|
|
50
50
|
"""
|
|
51
51
|
pass
|
|
52
52
|
|
|
@@ -10,7 +10,7 @@ class IFrameSource(ABC):
|
|
|
10
10
|
|
|
11
11
|
Transport-agnostic interface that handles the question: "where do frames come from?"
|
|
12
12
|
This abstraction decouples protocol logic (KeyPoints, SegmentationResults) from
|
|
13
|
-
transport mechanisms (File, TCP, WebSocket,
|
|
13
|
+
transport mechanisms (File, TCP, WebSocket, Unix Socket). Each frame is read atomically.
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
16
|
@abstractmethod
|
|
@@ -40,7 +40,7 @@ class IFrameSource(ABC):
|
|
|
40
40
|
Check if more frames are available.
|
|
41
41
|
|
|
42
42
|
For streaming transports (file), this checks for EOF.
|
|
43
|
-
For message-based transports
|
|
43
|
+
For message-based transports, this may always return True until disconnection.
|
|
44
44
|
|
|
45
45
|
Returns:
|
|
46
46
|
True if more frames are available, False otherwise
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WebSocket transport for reading/writing frames.
|
|
3
|
+
Matches C# WebSocketFrameSink and WebSocketFrameSource from RocketWelder.SDK.Transport.
|
|
4
|
+
|
|
5
|
+
Uses the websockets library for async WebSocket support.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from typing import Any, Optional, Protocol
|
|
12
|
+
|
|
13
|
+
from .frame_sink import IFrameSink
|
|
14
|
+
from .frame_source import IFrameSource
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WebSocketProtocol(Protocol):
|
|
18
|
+
"""Protocol for WebSocket-like objects (for type checking)."""
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def closed(self) -> bool: ...
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def state(self) -> Any: ...
|
|
25
|
+
|
|
26
|
+
async def send(self, message: bytes) -> None: ...
|
|
27
|
+
|
|
28
|
+
async def recv(self) -> bytes: ...
|
|
29
|
+
|
|
30
|
+
async def close(self) -> None: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class WebSocketFrameSink(IFrameSink):
|
|
34
|
+
"""
|
|
35
|
+
Frame sink that writes to a WebSocket connection.
|
|
36
|
+
|
|
37
|
+
Each frame is sent as a single binary WebSocket message.
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
leave_open: If True, doesn't close WebSocket on disposal
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
__slots__ = ("_closed", "_leave_open", "_websocket")
|
|
44
|
+
|
|
45
|
+
def __init__(self, websocket: WebSocketProtocol, leave_open: bool = False) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Create a WebSocket frame sink.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
websocket: WebSocket connection to write to
|
|
51
|
+
leave_open: If True, doesn't close WebSocket on disposal
|
|
52
|
+
"""
|
|
53
|
+
if websocket is None:
|
|
54
|
+
raise ValueError("websocket cannot be None")
|
|
55
|
+
self._websocket = websocket
|
|
56
|
+
self._leave_open = leave_open
|
|
57
|
+
self._closed = False
|
|
58
|
+
|
|
59
|
+
def write_frame(self, frame_data: bytes) -> None:
|
|
60
|
+
"""
|
|
61
|
+
Write a complete frame to the WebSocket synchronously.
|
|
62
|
+
|
|
63
|
+
Note: WebSocket is inherently async, so this runs the async
|
|
64
|
+
version in a new event loop. Prefer write_frame_async for better performance.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
frame_data: Complete frame data to write
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
RuntimeError: If sink is closed or WebSocket is not open
|
|
71
|
+
"""
|
|
72
|
+
if self._closed:
|
|
73
|
+
raise RuntimeError("WebSocketFrameSink is closed")
|
|
74
|
+
|
|
75
|
+
# Use asyncio.run for simplicity (creates new event loop)
|
|
76
|
+
try:
|
|
77
|
+
loop = asyncio.get_running_loop()
|
|
78
|
+
# If we're already in an async context, use run_coroutine_threadsafe
|
|
79
|
+
future = asyncio.run_coroutine_threadsafe(self.write_frame_async(frame_data), loop)
|
|
80
|
+
future.result()
|
|
81
|
+
except RuntimeError:
|
|
82
|
+
# No running event loop, create a new one
|
|
83
|
+
asyncio.run(self.write_frame_async(frame_data))
|
|
84
|
+
|
|
85
|
+
async def write_frame_async(self, frame_data: bytes) -> None:
|
|
86
|
+
"""
|
|
87
|
+
Write a complete frame to the WebSocket asynchronously.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
frame_data: Complete frame data to write
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
RuntimeError: If sink is closed or WebSocket is not open
|
|
94
|
+
"""
|
|
95
|
+
if self._closed:
|
|
96
|
+
raise RuntimeError("WebSocketFrameSink is closed")
|
|
97
|
+
|
|
98
|
+
if self._websocket.closed:
|
|
99
|
+
raise RuntimeError(f"WebSocket is not open: {self._websocket.state.name}")
|
|
100
|
+
|
|
101
|
+
# Send as single binary message
|
|
102
|
+
await self._websocket.send(frame_data)
|
|
103
|
+
|
|
104
|
+
def flush(self) -> None:
|
|
105
|
+
"""
|
|
106
|
+
Flush any buffered data.
|
|
107
|
+
|
|
108
|
+
WebSocket sends immediately, so this is a no-op.
|
|
109
|
+
"""
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
async def flush_async(self) -> None:
|
|
113
|
+
"""
|
|
114
|
+
Flush any buffered data asynchronously.
|
|
115
|
+
|
|
116
|
+
WebSocket sends immediately, so this is a no-op.
|
|
117
|
+
"""
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
def close(self) -> None:
|
|
121
|
+
"""Close the sink and release resources."""
|
|
122
|
+
if self._closed:
|
|
123
|
+
return
|
|
124
|
+
self._closed = True
|
|
125
|
+
|
|
126
|
+
if not self._leave_open and not self._websocket.closed:
|
|
127
|
+
try:
|
|
128
|
+
loop = asyncio.get_running_loop()
|
|
129
|
+
future = asyncio.run_coroutine_threadsafe(self.close_async(), loop)
|
|
130
|
+
future.result()
|
|
131
|
+
except RuntimeError:
|
|
132
|
+
asyncio.run(self._close_websocket())
|
|
133
|
+
|
|
134
|
+
async def close_async(self) -> None:
|
|
135
|
+
"""Close the sink and release resources asynchronously."""
|
|
136
|
+
if self._closed:
|
|
137
|
+
return
|
|
138
|
+
self._closed = True
|
|
139
|
+
|
|
140
|
+
await self._close_websocket()
|
|
141
|
+
|
|
142
|
+
async def _close_websocket(self) -> None:
|
|
143
|
+
"""Internal async close helper."""
|
|
144
|
+
if not self._leave_open and not self._websocket.closed:
|
|
145
|
+
try: # noqa: SIM105
|
|
146
|
+
await self._websocket.close()
|
|
147
|
+
except Exception:
|
|
148
|
+
# Best effort close
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class WebSocketFrameSource(IFrameSource):
|
|
153
|
+
"""
|
|
154
|
+
Frame source that reads from a WebSocket connection.
|
|
155
|
+
|
|
156
|
+
Each WebSocket binary message is treated as a complete frame.
|
|
157
|
+
|
|
158
|
+
Attributes:
|
|
159
|
+
leave_open: If True, doesn't close WebSocket on disposal
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
__slots__ = ("_closed", "_leave_open", "_websocket")
|
|
163
|
+
|
|
164
|
+
def __init__(self, websocket: WebSocketProtocol, leave_open: bool = False) -> None:
|
|
165
|
+
"""
|
|
166
|
+
Create a WebSocket frame source.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
websocket: WebSocket connection to read from
|
|
170
|
+
leave_open: If True, doesn't close WebSocket on disposal
|
|
171
|
+
"""
|
|
172
|
+
if websocket is None:
|
|
173
|
+
raise ValueError("websocket cannot be None")
|
|
174
|
+
self._websocket = websocket
|
|
175
|
+
self._leave_open = leave_open
|
|
176
|
+
self._closed = False
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def has_more_frames(self) -> bool:
|
|
180
|
+
"""
|
|
181
|
+
Check if more frames are available.
|
|
182
|
+
|
|
183
|
+
Returns True if the WebSocket is open or in CloseSent state.
|
|
184
|
+
"""
|
|
185
|
+
return not self._websocket.closed
|
|
186
|
+
|
|
187
|
+
def read_frame(self) -> Optional[bytes]:
|
|
188
|
+
"""
|
|
189
|
+
Read a complete frame from the WebSocket synchronously.
|
|
190
|
+
|
|
191
|
+
Note: WebSocket is inherently async, so this runs the async
|
|
192
|
+
version. Prefer read_frame_async for better performance.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Complete frame data, or None if connection closed
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
RuntimeError: If source is closed
|
|
199
|
+
"""
|
|
200
|
+
if self._closed:
|
|
201
|
+
raise RuntimeError("WebSocketFrameSource is closed")
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
loop = asyncio.get_running_loop()
|
|
205
|
+
future = asyncio.run_coroutine_threadsafe(self.read_frame_async(), loop)
|
|
206
|
+
return future.result()
|
|
207
|
+
except RuntimeError:
|
|
208
|
+
return asyncio.run(self.read_frame_async())
|
|
209
|
+
|
|
210
|
+
async def read_frame_async(self) -> Optional[bytes]:
|
|
211
|
+
"""
|
|
212
|
+
Read a complete frame from the WebSocket asynchronously.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Complete frame data, or None if connection closed
|
|
216
|
+
|
|
217
|
+
Raises:
|
|
218
|
+
RuntimeError: If source is closed
|
|
219
|
+
ValueError: If received non-binary message
|
|
220
|
+
"""
|
|
221
|
+
if self._closed:
|
|
222
|
+
raise RuntimeError("WebSocketFrameSource is closed")
|
|
223
|
+
|
|
224
|
+
if not self.has_more_frames:
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
# websockets library handles message framing automatically
|
|
229
|
+
message = await self._websocket.recv()
|
|
230
|
+
|
|
231
|
+
# Ensure we got binary data
|
|
232
|
+
if isinstance(message, str):
|
|
233
|
+
raise ValueError("Expected binary message, got text")
|
|
234
|
+
|
|
235
|
+
return message
|
|
236
|
+
|
|
237
|
+
except Exception:
|
|
238
|
+
# Connection closed or error
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
def close(self) -> None:
|
|
242
|
+
"""Close the source and release resources."""
|
|
243
|
+
if self._closed:
|
|
244
|
+
return
|
|
245
|
+
self._closed = True
|
|
246
|
+
|
|
247
|
+
if not self._leave_open and not self._websocket.closed:
|
|
248
|
+
try:
|
|
249
|
+
loop = asyncio.get_running_loop()
|
|
250
|
+
future = asyncio.run_coroutine_threadsafe(self.close_async(), loop)
|
|
251
|
+
future.result()
|
|
252
|
+
except RuntimeError:
|
|
253
|
+
asyncio.run(self._close_websocket())
|
|
254
|
+
|
|
255
|
+
async def close_async(self) -> None:
|
|
256
|
+
"""Close the source and release resources asynchronously."""
|
|
257
|
+
if self._closed:
|
|
258
|
+
return
|
|
259
|
+
self._closed = True
|
|
260
|
+
|
|
261
|
+
await self._close_websocket()
|
|
262
|
+
|
|
263
|
+
async def _close_websocket(self) -> None:
|
|
264
|
+
"""Internal async close helper."""
|
|
265
|
+
if not self._leave_open and not self._websocket.closed:
|
|
266
|
+
try: # noqa: SIM105
|
|
267
|
+
await self._websocket.close()
|
|
268
|
+
except Exception:
|
|
269
|
+
# Best effort close
|
|
270
|
+
pass
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
async def connect_websocket_sink(url: str, leave_open: bool = False) -> WebSocketFrameSink:
|
|
274
|
+
"""
|
|
275
|
+
Connect to a WebSocket server and return a frame sink.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
url: WebSocket URL (ws:// or wss://)
|
|
279
|
+
leave_open: If True, doesn't close WebSocket on disposal
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Connected WebSocketFrameSink
|
|
283
|
+
"""
|
|
284
|
+
try:
|
|
285
|
+
import websockets
|
|
286
|
+
except ImportError as e:
|
|
287
|
+
raise ImportError(
|
|
288
|
+
"websockets package is required for WebSocket transport. "
|
|
289
|
+
"Install with: pip install websockets"
|
|
290
|
+
) from e
|
|
291
|
+
|
|
292
|
+
websocket = await websockets.connect(url)
|
|
293
|
+
return WebSocketFrameSink(websocket, leave_open)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
async def connect_websocket_source(url: str, leave_open: bool = False) -> WebSocketFrameSource:
|
|
297
|
+
"""
|
|
298
|
+
Connect to a WebSocket server and return a frame source.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
url: WebSocket URL (ws:// or wss://)
|
|
302
|
+
leave_open: If True, doesn't close WebSocket on disposal
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
Connected WebSocketFrameSource
|
|
306
|
+
"""
|
|
307
|
+
try:
|
|
308
|
+
import websockets
|
|
309
|
+
except ImportError as e:
|
|
310
|
+
raise ImportError(
|
|
311
|
+
"websockets package is required for WebSocket transport. "
|
|
312
|
+
"Install with: pip install websockets"
|
|
313
|
+
) from e
|
|
314
|
+
|
|
315
|
+
websocket = await websockets.connect(url)
|
|
316
|
+
return WebSocketFrameSource(websocket, leave_open)
|