puda-comms 0.0.4__py3-none-any.whl → 0.0.6__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,388 @@
1
+ """
2
+ Reusable NATS JetStream subscriber for services that need to consume messages.
3
+
4
+ Provides a base class for subscribing to NATS streams with durable consumers,
5
+ automatic reconnection, and message handling callbacks.
6
+
7
+ This implements a push consumer pattern where NATS JetStream automatically
8
+ delivers messages to registered callbacks as they arrive, rather than requiring
9
+ the client to explicitly fetch/pull messages.
10
+ """
11
+ import asyncio
12
+ import logging
13
+ from typing import Optional, Callable, Awaitable, List, Any
14
+ from abc import abstractmethod
15
+ import nats
16
+ from nats.js.client import JetStreamContext
17
+ from nats.aio.msg import Msg
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class StreamSubscriber:
23
+ """
24
+ Base class for subscribing to NATS JetStream streams using push consumer pattern.
25
+
26
+ This class implements a push consumer where NATS JetStream automatically delivers
27
+ messages to registered callbacks as they arrive. The server pushes messages to
28
+ the client rather than requiring the client to pull/fetch them.
29
+
30
+ Handles connection management, durable subscriptions, and message routing.
31
+ Services can extend this class and implement message handling logic.
32
+
33
+ Example:
34
+ ```python
35
+ class MyService(StreamSubscriber):
36
+ async def handle_message(self, msg: Msg, stream: str, subject: str):
37
+ # Process message
38
+ data = json.loads(msg.data.decode())
39
+ # ... your logic ...
40
+ await msg.ack()
41
+
42
+ service = MyService(servers=["nats://localhost:4222"])
43
+ await service.subscribe("STREAM_NAME", "puda.*.cmd.response.queue", "my_consumer")
44
+ await service.run()
45
+ ```
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ servers: List[str],
51
+ connect_timeout: int = 10,
52
+ reconnect_time_wait: int = 2,
53
+ max_reconnect_attempts: int = -1
54
+ ):
55
+ """
56
+ Initialize the stream subscriber.
57
+
58
+ Args:
59
+ servers: List of NATS server URLs (e.g., ["nats://localhost:4222"])
60
+ connect_timeout: Timeout for initial connection in seconds
61
+ reconnect_time_wait: Wait time between reconnection attempts in seconds
62
+ max_reconnect_attempts: Maximum reconnection attempts (-1 for unlimited)
63
+ """
64
+ if not servers:
65
+ raise ValueError("servers must be a non-empty list")
66
+
67
+ self.servers = servers
68
+ self.connect_timeout = connect_timeout
69
+ self.reconnect_time_wait = reconnect_time_wait
70
+ self.max_reconnect_attempts = max_reconnect_attempts
71
+
72
+ self.nc: Optional[nats.NATS] = None
73
+ self.js: Optional[JetStreamContext] = None
74
+ self._subscriptions: List[Any] = []
75
+ self._is_connected = False
76
+ self._should_run = True
77
+
78
+ async def connect(self) -> bool:
79
+ """
80
+ Connect to NATS servers.
81
+
82
+ Returns:
83
+ True if connected successfully, False otherwise
84
+ """
85
+ if self._is_connected:
86
+ return True
87
+
88
+ try:
89
+ self.nc = await nats.connect(
90
+ servers=self.servers,
91
+ connect_timeout=self.connect_timeout,
92
+ reconnect_time_wait=self.reconnect_time_wait,
93
+ max_reconnect_attempts=self.max_reconnect_attempts,
94
+ error_cb=self._error_callback,
95
+ disconnected_cb=self._disconnected_callback,
96
+ reconnected_cb=self._reconnected_callback,
97
+ closed_cb=self._closed_callback
98
+ )
99
+ self.js = self.nc.jetstream()
100
+ self._is_connected = True
101
+ logger.info("Connected to NATS servers: %s", self.servers)
102
+ return True
103
+ except Exception as e:
104
+ logger.error("Failed to connect to NATS: %s", e)
105
+ self._is_connected = False
106
+ return False
107
+
108
+ async def disconnect(self):
109
+ """Disconnect from NATS and cleanup subscriptions."""
110
+ self._should_run = False
111
+
112
+ # Unsubscribe from all streams
113
+ for sub in self._subscriptions:
114
+ try:
115
+ await sub.unsubscribe()
116
+ except Exception as e:
117
+ logger.debug("Error unsubscribing: %s", e)
118
+ self._subscriptions.clear()
119
+
120
+ # Close NATS connection
121
+ if self.nc:
122
+ await self.nc.close()
123
+ self.nc = None
124
+ self.js = None
125
+
126
+ self._is_connected = False
127
+ logger.info("Disconnected from NATS")
128
+
129
+ async def subscribe(
130
+ self,
131
+ stream: str,
132
+ subject: str,
133
+ durable: Optional[str] = None,
134
+ callback: Optional[Callable[[Msg, str, str], Awaitable[None]]] = None
135
+ ):
136
+ """
137
+ Subscribe to a NATS JetStream stream using push consumer pattern.
138
+
139
+ This creates a push subscription where NATS JetStream automatically delivers
140
+ messages to the callback as they arrive. Messages are pushed to the client
141
+ rather than requiring explicit fetch/pull operations.
142
+
143
+ Args:
144
+ stream: Name of the JetStream stream
145
+ subject: Subject pattern to subscribe to (supports wildcards)
146
+ durable: Optional durable consumer name (for persistent subscriptions)
147
+ callback: Optional async callback function(msg, stream, subject) -> None
148
+ If not provided, calls handle_message() method
149
+
150
+ Raises:
151
+ RuntimeError: If not connected to NATS
152
+ """
153
+ if not self._is_connected or not self.js:
154
+ raise RuntimeError("Not connected to NATS. Call connect() first.")
155
+
156
+ # Use provided callback or default to handle_message method
157
+ if callback is None:
158
+ callback = self.handle_message
159
+
160
+ # Create callback wrapper
161
+ async def message_wrapper(msg: Msg):
162
+ try:
163
+ await callback(msg, stream, subject)
164
+ except Exception as e:
165
+ logger.error(
166
+ "Error in message callback for stream=%s, subject=%s: %s",
167
+ stream, subject, e, exc_info=True
168
+ )
169
+ # Don't ack on error - let the caller decide
170
+ # This allows for retry logic in the handler
171
+
172
+ try:
173
+ # Subscribe with durable consumer if specified
174
+ if durable:
175
+ sub = await self.js.subscribe(
176
+ subject,
177
+ stream=stream,
178
+ durable=durable,
179
+ cb=lambda msg: asyncio.create_task(message_wrapper(msg))
180
+ )
181
+ else:
182
+ # Ephemeral subscription
183
+ sub = await self.js.subscribe(
184
+ subject,
185
+ stream=stream,
186
+ cb=lambda msg: asyncio.create_task(message_wrapper(msg))
187
+ )
188
+
189
+ self._subscriptions.append(sub)
190
+ logger.info(
191
+ "Subscribed to stream=%s, subject=%s, durable=%s",
192
+ stream, subject, durable or "ephemeral"
193
+ )
194
+ except Exception as e:
195
+ error_msg = str(e)
196
+ # Handle the specific case where consumer is already bound
197
+ if "consumer is already bound" in error_msg.lower():
198
+ logger.warning(
199
+ "Consumer '%s' for stream '%s' is already bound. "
200
+ "This usually happens when the service didn't shut down cleanly. "
201
+ "Attempting to delete the consumer and retry...",
202
+ durable, stream
203
+ )
204
+ if durable:
205
+ try:
206
+ # Try to delete the consumer (may fail if actively bound)
207
+ await self.js.delete_consumer(stream, durable)
208
+ logger.info("Deleted consumer '%s' for stream '%s'", durable, stream)
209
+ # Retry subscription after deletion
210
+ sub = await self.js.subscribe(
211
+ subject,
212
+ stream=stream,
213
+ durable=durable,
214
+ cb=lambda msg: asyncio.create_task(message_wrapper(msg))
215
+ )
216
+ self._subscriptions.append(sub)
217
+ logger.info(
218
+ "Successfully subscribed after consumer cleanup: stream=%s, subject=%s, durable=%s",
219
+ stream, subject, durable
220
+ )
221
+ except Exception as retry_error:
222
+ retry_error_msg = str(retry_error)
223
+ if "bound" in retry_error_msg.lower() or "in use" in retry_error_msg.lower():
224
+ logger.error(
225
+ "Consumer '%s' for stream '%s' cannot be deleted because it's still bound. "
226
+ "This typically means the previous service instance is still running or "
227
+ "the subscription hasn't timed out yet. Solutions:\n"
228
+ " 1. Wait a few seconds and restart the service\n"
229
+ " 2. Manually delete the consumer: nats consumer rm %s %s\n"
230
+ " 3. Restart the NATS server\n"
231
+ " 4. Use a different durable consumer name",
232
+ durable, stream, stream, durable
233
+ )
234
+ else:
235
+ logger.error(
236
+ "Failed to delete consumer '%s' for stream '%s': %s",
237
+ durable, stream, retry_error
238
+ )
239
+ raise
240
+ else:
241
+ raise
242
+ else:
243
+ logger.error(
244
+ "Failed to subscribe to stream=%s, subject=%s: %s",
245
+ stream, subject, e
246
+ )
247
+ raise
248
+
249
+ @abstractmethod
250
+ async def handle_message(self, msg: Msg, stream: str, subject: str):
251
+ """
252
+ Handle an incoming message pushed by NATS JetStream. Override this method in subclasses.
253
+
254
+ This method is called automatically when NATS JetStream pushes a message
255
+ to this subscriber. The push consumer pattern means messages arrive
256
+ asynchronously via callbacks rather than being explicitly fetched.
257
+
258
+ Default implementation logs and acks the message.
259
+ Subclasses should implement their own message processing logic.
260
+
261
+ Args:
262
+ msg: NATS message object
263
+ stream: Name of the stream the message came from
264
+ subject: Subject pattern that matched this message
265
+ """
266
+ logger.debug(
267
+ "Received message from stream=%s, subject=%s, data_size=%d",
268
+ stream, subject, len(msg.data)
269
+ )
270
+ # Default: ack the message
271
+ await msg.ack()
272
+
273
+ async def _error_callback(self, error: Exception):
274
+ """Callback for NATS errors."""
275
+ if error:
276
+ logger.error("NATS error: %s", error, exc_info=True)
277
+ else:
278
+ logger.error("NATS error: Unknown error (error object is None)")
279
+
280
+ async def _disconnected_callback(self):
281
+ """Callback when disconnected from NATS."""
282
+ logger.warning("Disconnected from NATS servers")
283
+ self._is_connected = False
284
+
285
+ async def _reconnected_callback(self):
286
+ """Callback when reconnected to NATS."""
287
+ logger.info("Reconnected to NATS servers")
288
+ self._is_connected = True
289
+ if self.nc:
290
+ self.js = self.nc.jetstream()
291
+ # Clear old subscriptions as they're no longer valid after reconnection
292
+ self._subscriptions.clear()
293
+ # Re-subscribe to all streams
294
+ await self._resubscribe_all()
295
+
296
+ async def _closed_callback(self):
297
+ """Callback when connection is closed."""
298
+ logger.info("NATS connection closed")
299
+ self._is_connected = False
300
+
301
+ async def _resubscribe_all(self):
302
+ """
303
+ Re-subscribe to all streams after reconnection.
304
+
305
+ Override this method in subclasses to restore subscriptions.
306
+ The default implementation does nothing - subclasses should track
307
+ their subscriptions and re-subscribe here.
308
+ """
309
+ logger.debug("Reconnection detected, but no subscriptions to restore")
310
+
311
+ async def run(self, health_check_interval: float = 1.0):
312
+ """
313
+ Run the subscriber service with connection health monitoring.
314
+
315
+ This method will:
316
+ 1. Connect to NATS (with retry logic)
317
+ 2. Call on_start() hook for subclasses to set up subscriptions
318
+ 3. Monitor connection health and reconnect if needed
319
+ 4. Call on_stop() hook on shutdown
320
+
321
+ Args:
322
+ health_check_interval: Interval in seconds to check connection health
323
+ """
324
+ # Connect to NATS with retry logic
325
+ while self._should_run:
326
+ if await self.connect():
327
+ break
328
+ logger.warning("Failed to connect to NATS, retrying in 5 seconds...")
329
+ await asyncio.sleep(5)
330
+
331
+ # Call on_start hook for subclasses to set up subscriptions
332
+ await self.on_start()
333
+
334
+ logger.info("Stream subscriber service started")
335
+
336
+ # Main loop with health monitoring
337
+ try:
338
+ while self._should_run:
339
+ await asyncio.sleep(health_check_interval)
340
+
341
+ # Check connection health
342
+ if not self._is_connected:
343
+ logger.warning("Connection lost, attempting to reconnect...")
344
+ if await self.connect():
345
+ # Clear old subscriptions as they're no longer valid after reconnection
346
+ self._subscriptions.clear()
347
+ await self._resubscribe_all()
348
+ except KeyboardInterrupt:
349
+ logger.info("Received KeyboardInterrupt, shutting down...")
350
+ except Exception as e:
351
+ logger.error("Unexpected error in main loop: %s", e, exc_info=True)
352
+ finally:
353
+ await self.on_stop()
354
+ await self.disconnect()
355
+
356
+ @abstractmethod
357
+ async def on_start(self):
358
+ """
359
+ Hook called when the service starts. Override in subclasses to set up subscriptions.
360
+
361
+ Example:
362
+ ```python
363
+ async def on_start(self):
364
+ await self.subscribe("STREAM_NAME", "puda.*.cmd.response.queue", "my_consumer")
365
+ await self.subscribe("STREAM_NAME", "puda.*.cmd.response.immediate", "my_consumer2")
366
+ ```
367
+ """
368
+ pass
369
+
370
+ @abstractmethod
371
+ async def on_stop(self):
372
+ """
373
+ Hook called when the service stops. Override in subclasses for cleanup.
374
+ """
375
+ pass
376
+
377
+ # ==================== Context Manager ====================
378
+
379
+ async def __aenter__(self):
380
+ """Async context manager entry."""
381
+ await self.connect()
382
+ return self
383
+
384
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
385
+ """Async context manager exit."""
386
+ await self.disconnect()
387
+ return False # Don't suppress exceptions
388
+
@@ -1,11 +1,10 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: puda-comms
3
- Version: 0.0.4
3
+ Version: 0.0.6
4
4
  Summary: Communication library for the PUDA platform.
5
5
  Author: zhao
6
6
  Author-email: zhao <20024592+agentzhao@users.noreply.github.com>
7
7
  Requires-Dist: nats-py>=2.12.0
8
- Requires-Dist: puda-drivers
9
8
  Requires-Dist: pydantic>=2.12.5
10
9
  Requires-Python: >=3.14
11
10
  Description-Content-Type: text/markdown
@@ -73,6 +72,7 @@ Represents a command to be sent to a machine.
73
72
 
74
73
  **Fields:**
75
74
  - `name` (str): The command name to execute
75
+ - `machine_id` (str): Machine ID to send the command to (required)
76
76
  - `params` (Dict[str, Any]): Command parameters (default: empty dict)
77
77
  - `step_number` (int): Execution step number for tracking progress
78
78
  - `version` (str): Command version (default: "1.0")
@@ -81,7 +81,8 @@ Represents a command to be sent to a machine.
81
81
  ```python
82
82
  command = CommandRequest(
83
83
  name="attach_tip",
84
- params={"slot": "A3", "well": "G8"},
84
+ machine_id="first",
85
+ params={"deck_slot": "A3", "well_name": "G8"},
85
86
  step_number=2,
86
87
  version="1.0"
87
88
  )
@@ -109,7 +110,7 @@ response = CommandResponse(
109
110
  error_response = CommandResponse(
110
111
  status=CommandResponseStatus.ERROR,
111
112
  code="EXECUTION_ERROR",
112
- message="Failed to attach tip: slot A3 not found",
113
+ message="Failed to attach tip: deck_slot A3 not found",
113
114
  completed_at="2026-01-20T02:00:46Z"
114
115
  )
115
116
  ```
@@ -166,8 +167,8 @@ Complete NATS message structure combining header with optional command or respon
166
167
  "command": {
167
168
  "name": "attach_tip",
168
169
  "params": {
169
- "slot": "A3",
170
- "well": "G8"
170
+ "deck_slot": "A3",
171
+ "well_name": "G8"
171
172
  },
172
173
  "step_number": 2,
173
174
  "version": "1.0"
@@ -230,10 +231,9 @@ Queue commands are regular commands that are executed in sequence. Use `send_que
230
231
  Both `send_queue_command()`, `send_queue_commands()`, and `send_immediate_command()` accept an optional `timeout` parameter (default: 120 seconds):
231
232
 
232
233
  ```python
233
- # Single command
234
+ # Single command (machine_id must be in CommandRequest)
234
235
  reply = await service.send_queue_command(
235
- request=request,
236
- machine_id="first",
236
+ request=request, # request.machine_id must be set
237
237
  run_id=run_id,
238
238
  user_id="user123",
239
239
  username="John Doe",
@@ -241,9 +241,9 @@ reply = await service.send_queue_command(
241
241
  )
242
242
 
243
243
  # Multiple commands (timeout applies to each command)
244
+ # Each command in the list must have machine_id set
244
245
  reply = await service.send_queue_commands(
245
- requests=commands,
246
- machine_id="first",
246
+ requests=commands, # Each CommandRequest must have machine_id
247
247
  run_id=run_id,
248
248
  user_id="user123",
249
249
  username="John Doe",
@@ -282,8 +282,7 @@ Always check the response status and handle errors appropriately:
282
282
 
283
283
  ```python
284
284
  reply: NATSMessage = await service.send_queue_command(
285
- request=request,
286
- machine_id="first",
285
+ request=request, # request.machine_id must be set
287
286
  run_id=run_id,
288
287
  user_id="user123",
289
288
  username="John Doe"
@@ -0,0 +1,10 @@
1
+ puda_comms/__init__.py,sha256=2HHpV6ypjlkvrZz6OxguelX3GhqHvztjZT0b9bVSFq8,387
2
+ puda_comms/command_service.py,sha256=BBzYPd4DFAbxtVRgsxHkVjiip0C4PDdT125G5QIUe68,33591
3
+ puda_comms/execution_state.py,sha256=aTaejCnJgg1y_FP-ymIC1GQzqC81FIWo0RZ18XzAQnA,2881
4
+ puda_comms/machine_client.py,sha256=13WYvg49b8XgawRBiSIiCAu-XYUgIP0xzN7Ec8PRHXA,43504
5
+ puda_comms/models.py,sha256=yRd8RuKl4mIAfEZ2-WqsSfzPkU2ZpLoxPlEV6jeeUmU,3911
6
+ puda_comms/run_manager.py,sha256=_s4VYVGwtRMcduz95_DPIObso4uWRS24n5NH7AiGgjI,3591
7
+ puda_comms/stream_subscriber.py,sha256=coT2M_e-sNf0HeIkmsiynjPq6Rn4B-sqt1eX306O_oA,15475
8
+ puda_comms-0.0.6.dist-info/WHEEL,sha256=ZyFSCYkV2BrxH6-HRVRg3R9Fo7MALzer9KiPYqNxSbo,79
9
+ puda_comms-0.0.6.dist-info/METADATA,sha256=f3J0A9qNPZ-cgF2NeDSUfTBYGbJx3U-m54m9PAPQLdE,11731
10
+ puda_comms-0.0.6.dist-info/RECORD,,
@@ -1,8 +0,0 @@
1
- puda_comms/__init__.py,sha256=lntvVFJJez_rv5lZy5mYj4_43B9Y3NRNzxWfBuSAQ1M,194
2
- puda_comms/command_service.py,sha256=KFremcEGfsTeUVQMIhyk1knYmUCvRYQ12vS_jy_14wA,25193
3
- puda_comms/execution_state.py,sha256=aTaejCnJgg1y_FP-ymIC1GQzqC81FIWo0RZ18XzAQnA,2881
4
- puda_comms/machine_client.py,sha256=wj6t_QHGs7l1Oc8JQ6hq2hqBd5C14TCPA_dTU9qOLzw,37430
5
- puda_comms/models.py,sha256=9ZGX0PR7SgMBOL5zVLrPuSUhZqutQU96PubyjyQLhf8,3617
6
- puda_comms-0.0.4.dist-info/WHEEL,sha256=ZyFSCYkV2BrxH6-HRVRg3R9Fo7MALzer9KiPYqNxSbo,79
7
- puda_comms-0.0.4.dist-info/METADATA,sha256=0cMHDub_3NZt7Cj5U1jzrQXI8atQqpMM-i3vSMrT5lo,11512
8
- puda_comms-0.0.4.dist-info/RECORD,,