rocket-welder-sdk 1.1.32__py3-none-any.whl → 1.1.34__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.
@@ -0,0 +1,238 @@
1
+ """SessionId parsing utilities for NNG URL generation.
2
+
3
+ SessionId format: ps-{guid} (e.g., ps-a1b2c3d4-e5f6-7890-abcd-ef1234567890)
4
+ Prefix "ps" = PipelineSession.
5
+
6
+ This module provides utilities to:
7
+ 1. Parse SessionId from environment variable
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
+
12
+ ## URL Configuration Priority
13
+
14
+ The SDK supports two ways to configure NNG URLs:
15
+
16
+ 1. **Explicit URLs (PREFERRED)** - Set by rocket-welder2:
17
+ - SEGMENTATION_SINK_URL
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).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import logging
31
+ import os
32
+ import uuid
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ SESSION_ID_PREFIX = "ps-"
37
+ SESSION_ID_ENV_VAR = "SessionId"
38
+
39
+ # Explicit URL environment variables (set by rocket-welder2)
40
+ SEGMENTATION_SINK_URL_ENV = "SEGMENTATION_SINK_URL"
41
+ KEYPOINTS_SINK_URL_ENV = "KEYPOINTS_SINK_URL"
42
+ ACTIONS_SINK_URL_ENV = "ACTIONS_SINK_URL"
43
+
44
+
45
+ def parse_session_id(session_id: str) -> uuid.UUID:
46
+ """Parse SessionId (ps-{guid}) to extract Guid.
47
+
48
+ Args:
49
+ session_id: SessionId string (e.g., "ps-a1b2c3d4-...")
50
+
51
+ Returns:
52
+ UUID extracted from SessionId
53
+
54
+ Raises:
55
+ ValueError: If session_id format is invalid
56
+
57
+ Examples:
58
+ >>> parse_session_id("ps-a1b2c3d4-e5f6-7890-abcd-ef1234567890")
59
+ UUID('a1b2c3d4-e5f6-7890-abcd-ef1234567890')
60
+ >>> parse_session_id("a1b2c3d4-e5f6-7890-abcd-ef1234567890") # backwards compat
61
+ UUID('a1b2c3d4-e5f6-7890-abcd-ef1234567890')
62
+ """
63
+ if session_id.startswith(SESSION_ID_PREFIX):
64
+ return uuid.UUID(session_id[len(SESSION_ID_PREFIX) :])
65
+ # Fallback: try parsing as raw guid for backwards compatibility
66
+ return uuid.UUID(session_id)
67
+
68
+
69
+ def get_session_id_from_env() -> str | None:
70
+ """Get SessionId from environment variable.
71
+
72
+ Returns:
73
+ SessionId string or None if not set
74
+ """
75
+ 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
+ )
@@ -0,0 +1,30 @@
1
+ """
2
+ Transport layer for RocketWelder SDK.
3
+
4
+ Provides transport-agnostic frame sink/source abstractions for protocols.
5
+ """
6
+
7
+ from .frame_sink import IFrameSink
8
+ from .frame_source import IFrameSource
9
+ from .nng_transport import NngFrameSink, NngFrameSource
10
+ from .stream_transport import StreamFrameSink, StreamFrameSource
11
+ from .tcp_transport import TcpFrameSink, TcpFrameSource
12
+ from .unix_socket_transport import (
13
+ UnixSocketFrameSink,
14
+ UnixSocketFrameSource,
15
+ UnixSocketServer,
16
+ )
17
+
18
+ __all__ = [
19
+ "IFrameSink",
20
+ "IFrameSource",
21
+ "NngFrameSink",
22
+ "NngFrameSource",
23
+ "StreamFrameSink",
24
+ "StreamFrameSource",
25
+ "TcpFrameSink",
26
+ "TcpFrameSource",
27
+ "UnixSocketFrameSink",
28
+ "UnixSocketFrameSource",
29
+ "UnixSocketServer",
30
+ ]
@@ -0,0 +1,77 @@
1
+ """Frame sink abstraction for writing frames to any transport."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+
6
+ class IFrameSink(ABC):
7
+ """
8
+ Low-level abstraction for writing discrete frames to any transport.
9
+
10
+ Transport-agnostic interface that handles the question: "where do frames go?"
11
+ This abstraction decouples protocol logic (KeyPoints, SegmentationResults) from
12
+ transport mechanisms (File, TCP, WebSocket, NNG). Each frame is written atomically.
13
+ """
14
+
15
+ @abstractmethod
16
+ def write_frame(self, frame_data: bytes) -> None:
17
+ """
18
+ Write a complete frame to the underlying transport synchronously.
19
+
20
+ Args:
21
+ frame_data: Complete frame data to write
22
+ """
23
+ pass
24
+
25
+ @abstractmethod
26
+ async def write_frame_async(self, frame_data: bytes) -> None:
27
+ """
28
+ Write a complete frame to the underlying transport asynchronously.
29
+
30
+ Args:
31
+ frame_data: Complete frame data to write
32
+ """
33
+ pass
34
+
35
+ @abstractmethod
36
+ def flush(self) -> None:
37
+ """
38
+ Flush any buffered data to the transport synchronously.
39
+
40
+ For message-based transports (NNG, WebSocket), this may be a no-op.
41
+ """
42
+ pass
43
+
44
+ @abstractmethod
45
+ async def flush_async(self) -> None:
46
+ """
47
+ Flush any buffered data to the transport asynchronously.
48
+
49
+ For message-based transports (NNG, WebSocket), this may be a no-op.
50
+ """
51
+ pass
52
+
53
+ def __enter__(self) -> "IFrameSink":
54
+ """Context manager entry."""
55
+ return self
56
+
57
+ def __exit__(self, *args: object) -> None:
58
+ """Context manager exit."""
59
+ self.close()
60
+
61
+ async def __aenter__(self) -> "IFrameSink":
62
+ """Async context manager entry."""
63
+ return self
64
+
65
+ async def __aexit__(self, *args: object) -> None:
66
+ """Async context manager exit."""
67
+ await self.close_async()
68
+
69
+ @abstractmethod
70
+ def close(self) -> None:
71
+ """Close the sink and release resources."""
72
+ pass
73
+
74
+ @abstractmethod
75
+ async def close_async(self) -> None:
76
+ """Close the sink and release resources asynchronously."""
77
+ pass
@@ -0,0 +1,74 @@
1
+ """Frame source abstraction for reading frames from any transport."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Optional
5
+
6
+
7
+ class IFrameSource(ABC):
8
+ """
9
+ Low-level abstraction for reading discrete frames from any transport.
10
+
11
+ Transport-agnostic interface that handles the question: "where do frames come from?"
12
+ This abstraction decouples protocol logic (KeyPoints, SegmentationResults) from
13
+ transport mechanisms (File, TCP, WebSocket, NNG). Each frame is read atomically.
14
+ """
15
+
16
+ @abstractmethod
17
+ def read_frame(self) -> Optional[bytes]:
18
+ """
19
+ Read a complete frame from the underlying transport synchronously.
20
+
21
+ Returns:
22
+ Complete frame data, or None if end of stream/no more messages
23
+ """
24
+ pass
25
+
26
+ @abstractmethod
27
+ async def read_frame_async(self) -> Optional[bytes]:
28
+ """
29
+ Read a complete frame from the underlying transport asynchronously.
30
+
31
+ Returns:
32
+ Complete frame data, or None if end of stream/no more messages
33
+ """
34
+ pass
35
+
36
+ @property
37
+ @abstractmethod
38
+ def has_more_frames(self) -> bool:
39
+ """
40
+ Check if more frames are available.
41
+
42
+ For streaming transports (file), this checks for EOF.
43
+ For message-based transports (NNG), this may always return True until disconnection.
44
+
45
+ Returns:
46
+ True if more frames are available, False otherwise
47
+ """
48
+ pass
49
+
50
+ def __enter__(self) -> "IFrameSource":
51
+ """Context manager entry."""
52
+ return self
53
+
54
+ def __exit__(self, *args: object) -> None:
55
+ """Context manager exit."""
56
+ self.close()
57
+
58
+ async def __aenter__(self) -> "IFrameSource":
59
+ """Async context manager entry."""
60
+ return self
61
+
62
+ async def __aexit__(self, *args: object) -> None:
63
+ """Async context manager exit."""
64
+ await self.close_async()
65
+
66
+ @abstractmethod
67
+ def close(self) -> None:
68
+ """Close the source and release resources."""
69
+ pass
70
+
71
+ @abstractmethod
72
+ async def close_async(self) -> None:
73
+ """Close the source and release resources asynchronously."""
74
+ pass
@@ -0,0 +1,197 @@
1
+ """NNG transport using pynng library.
2
+
3
+ NNG (nanomsg next generation) provides high-performance, scalable messaging patterns.
4
+ Supported patterns:
5
+ - Pub/Sub: One publisher to many subscribers
6
+ - Push/Pull: Load-balanced distribution to workers
7
+ """
8
+
9
+ from typing import Any, Optional, cast
10
+
11
+ import pynng
12
+
13
+ from .frame_sink import IFrameSink
14
+ from .frame_source import IFrameSource
15
+
16
+
17
+ class NngFrameSink(IFrameSink):
18
+ """
19
+ Frame sink that publishes to NNG Pub/Sub or Push/Pull pattern.
20
+
21
+ Each frame is sent as a single NNG message (no framing needed - NNG handles message boundaries).
22
+ """
23
+
24
+ def __init__(self, socket: Any, leave_open: bool = False):
25
+ """
26
+ Create an NNG frame sink from a socket.
27
+
28
+ Args:
29
+ socket: pynng socket (Publisher or Pusher)
30
+ leave_open: If True, doesn't close socket on close
31
+ """
32
+ self._socket: Any = socket
33
+ self._leave_open = leave_open
34
+ self._closed = False
35
+
36
+ @classmethod
37
+ def create_publisher(cls, url: str) -> "NngFrameSink":
38
+ """
39
+ Create an NNG Publisher frame sink bound to the specified URL.
40
+
41
+ Args:
42
+ url: NNG URL (e.g., "tcp://127.0.0.1:5555", "ipc:///tmp/mysocket")
43
+
44
+ Returns:
45
+ Frame sink ready to publish messages
46
+ """
47
+ socket = pynng.Pub0()
48
+ socket.listen(url)
49
+ return cls(socket, leave_open=False)
50
+
51
+ @classmethod
52
+ def create_pusher(cls, url: str, bind_mode: bool = True) -> "NngFrameSink":
53
+ """
54
+ Create an NNG Pusher frame sink.
55
+
56
+ Args:
57
+ url: NNG URL (e.g., "tcp://127.0.0.1:5555", "ipc:///tmp/mysocket")
58
+ bind_mode: If True, listens (bind); if False, dials (connect)
59
+
60
+ Returns:
61
+ Frame sink ready to push messages
62
+ """
63
+ socket = pynng.Push0()
64
+ if bind_mode:
65
+ socket.listen(url)
66
+ else:
67
+ socket.dial(url)
68
+ return cls(socket, leave_open=False)
69
+
70
+ def write_frame(self, frame_data: bytes) -> None:
71
+ """Write frame to NNG socket (no length prefix - NNG handles message boundaries)."""
72
+ if self._closed:
73
+ raise ValueError("Cannot write to closed sink")
74
+
75
+ self._socket.send(frame_data)
76
+
77
+ async def write_frame_async(self, frame_data: bytes) -> None:
78
+ """Write frame asynchronously."""
79
+ if self._closed:
80
+ raise ValueError("Cannot write to closed sink")
81
+
82
+ await self._socket.asend(frame_data)
83
+
84
+ def flush(self) -> None:
85
+ """Flush is a no-op for NNG (data sent immediately)."""
86
+ pass
87
+
88
+ async def flush_async(self) -> None:
89
+ """Flush asynchronously is a no-op for NNG."""
90
+ pass
91
+
92
+ def close(self) -> None:
93
+ """Close the NNG sink."""
94
+ if self._closed:
95
+ return
96
+ self._closed = True
97
+ if not self._leave_open:
98
+ self._socket.close()
99
+
100
+ async def close_async(self) -> None:
101
+ """Close the NNG sink asynchronously."""
102
+ self.close()
103
+
104
+
105
+ class NngFrameSource(IFrameSource):
106
+ """
107
+ Frame source that subscribes to NNG Pub/Sub or Pull pattern.
108
+
109
+ Each NNG message is treated as a complete frame (no framing needed - NNG handles message boundaries).
110
+ """
111
+
112
+ def __init__(self, socket: Any, leave_open: bool = False):
113
+ """
114
+ Create an NNG frame source from a socket.
115
+
116
+ Args:
117
+ socket: pynng socket (Subscriber or Puller)
118
+ leave_open: If True, doesn't close socket on close
119
+ """
120
+ self._socket: Any = socket
121
+ self._leave_open = leave_open
122
+ self._closed = False
123
+
124
+ @classmethod
125
+ def create_subscriber(cls, url: str, topic: bytes = b"") -> "NngFrameSource":
126
+ """
127
+ Create an NNG Subscriber frame source connected to the specified URL.
128
+
129
+ Args:
130
+ url: NNG URL (e.g., "tcp://127.0.0.1:5555", "ipc:///tmp/mysocket")
131
+ topic: Optional topic filter (empty for all messages)
132
+
133
+ Returns:
134
+ Frame source ready to receive messages
135
+ """
136
+ socket = pynng.Sub0()
137
+ socket.subscribe(topic)
138
+ socket.dial(url)
139
+ return cls(socket, leave_open=False)
140
+
141
+ @classmethod
142
+ def create_puller(cls, url: str, bind_mode: bool = True) -> "NngFrameSource":
143
+ """
144
+ Create an NNG Puller frame source.
145
+
146
+ Args:
147
+ url: NNG URL (e.g., "tcp://127.0.0.1:5555", "ipc:///tmp/mysocket")
148
+ bind_mode: If True, listens (bind); if False, dials (connect)
149
+
150
+ Returns:
151
+ Frame source ready to pull messages
152
+ """
153
+ socket = pynng.Pull0()
154
+ if bind_mode:
155
+ socket.listen(url)
156
+ else:
157
+ socket.dial(url)
158
+ return cls(socket, leave_open=False)
159
+
160
+ @property
161
+ def has_more_frames(self) -> bool:
162
+ """Check if more frames available (NNG blocks waiting for messages)."""
163
+ return not self._closed
164
+
165
+ def read_frame(self) -> Optional[bytes]:
166
+ """Read frame from NNG socket (blocking)."""
167
+ if self._closed:
168
+ return None
169
+
170
+ try:
171
+ return cast("bytes", self._socket.recv())
172
+ except pynng.Closed:
173
+ self._closed = True
174
+ return None
175
+
176
+ async def read_frame_async(self) -> Optional[bytes]:
177
+ """Read frame asynchronously."""
178
+ if self._closed:
179
+ return None
180
+
181
+ try:
182
+ return cast("bytes", await self._socket.arecv())
183
+ except pynng.Closed:
184
+ self._closed = True
185
+ return None
186
+
187
+ def close(self) -> None:
188
+ """Close the NNG source."""
189
+ if self._closed:
190
+ return
191
+ self._closed = True
192
+ if not self._leave_open:
193
+ self._socket.close()
194
+
195
+ async def close_async(self) -> None:
196
+ """Close the NNG source asynchronously."""
197
+ self.close()