kryten-robot 0.6.9__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.
kryten/nats_client.py ADDED
@@ -0,0 +1,468 @@
1
+ """NATS Client Wrapper for Event Publishing and Subscriptions.
2
+
3
+ This module provides an asynchronous NATS client wrapper with connection
4
+ lifecycle management, automatic reconnection, and error handling.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ from collections.abc import Awaitable, Callable
10
+ from typing import Any
11
+
12
+ from nats.aio.client import Client as NATS
13
+ from nats.aio.subscription import Subscription
14
+
15
+ from .config import NatsConfig
16
+ from .errors import ConnectionError, NotConnectedError
17
+
18
+
19
+ class NatsClient:
20
+ """Asynchronous NATS client wrapper with lifecycle management.
21
+
22
+ Provides a clean interface for connecting to NATS servers and publishing
23
+ messages with automatic reconnection and error handling.
24
+
25
+ Attributes:
26
+ config: NATS connection configuration.
27
+ logger: Logger instance for structured logging.
28
+ is_connected: Whether currently connected to NATS.
29
+ stats: Publishing statistics (messages, bytes, errors).
30
+
31
+ Examples:
32
+ >>> config = NatsConfig(servers=["nats://localhost:4222"])
33
+ >>> async with NatsClient(config, logger) as client:
34
+ ... await client.publish("test.subject", b"hello")
35
+ """
36
+
37
+ def __init__(self, config: NatsConfig, logger: logging.Logger):
38
+ """Initialize NATS client.
39
+
40
+ Args:
41
+ config: NATS connection configuration.
42
+ logger: Logger for structured output.
43
+ """
44
+ self.config = config
45
+ self.logger = logger
46
+ self._nc: NATS | None = None
47
+ self._connected = False
48
+
49
+ # Connection tracking
50
+ self._connected_since: float | None = None
51
+ self._reconnect_count: int = 0
52
+
53
+ # Statistics tracking
54
+ self._messages_published = 0
55
+ self._bytes_sent = 0
56
+ self._errors = 0
57
+
58
+ @property
59
+ def is_connected(self) -> bool:
60
+ """Check if currently connected to NATS.
61
+
62
+ Returns:
63
+ True if connected and client is active, False otherwise.
64
+ """
65
+ return self._connected and self._nc is not None and self._nc.is_connected
66
+
67
+ @property
68
+ def connected_since(self) -> float | None:
69
+ """Get timestamp when connection was established.
70
+
71
+ Returns:
72
+ Unix timestamp of connection time, or None if not connected.
73
+ """
74
+ return self._connected_since
75
+
76
+ @property
77
+ def reconnect_count(self) -> int:
78
+ """Get number of reconnection attempts.
79
+
80
+ Returns:
81
+ Count of reconnections since instance creation.
82
+ """
83
+ return self._reconnect_count
84
+
85
+ @property
86
+ def connected_url(self) -> str | None:
87
+ """Get the currently connected NATS server URL.
88
+
89
+ Returns:
90
+ Server URL if connected, None otherwise.
91
+ """
92
+ if self._nc and self._nc.is_connected:
93
+ return self._nc.connected_url.netloc
94
+ return None
95
+
96
+ @property
97
+ def stats(self) -> dict[str, int]:
98
+ """Get publishing statistics.
99
+
100
+ Returns:
101
+ Dictionary with 'messages_published', 'bytes_sent', 'errors' counts.
102
+
103
+ Examples:
104
+ >>> stats = client.stats
105
+ >>> print(f"Published: {stats['messages_published']}")
106
+ """
107
+ return {
108
+ 'messages_published': self._messages_published,
109
+ 'bytes_sent': self._bytes_sent,
110
+ 'errors': self._errors,
111
+ }
112
+
113
+ async def connect(self) -> None:
114
+ """Establish connection to NATS servers.
115
+
116
+ Attempts to connect to configured NATS servers with automatic
117
+ reconnection support if enabled.
118
+
119
+ Raises:
120
+ ConnectionError: If connection cannot be established.
121
+ asyncio.CancelledError: If connection is cancelled.
122
+
123
+ Examples:
124
+ >>> client = NatsClient(config, logger)
125
+ >>> await client.connect()
126
+ >>> assert client.is_connected
127
+ """
128
+ if self._connected:
129
+ self.logger.warning("Already connected to NATS, ignoring connect() call")
130
+ return
131
+
132
+ self.logger.info(
133
+ "Connecting to NATS",
134
+ extra={"servers": self.config.servers}
135
+ )
136
+
137
+ try:
138
+ # Create NATS client
139
+ self._nc = NATS()
140
+
141
+ # Configure connection options
142
+ options = {
143
+ "servers": self.config.servers,
144
+ "connect_timeout": self.config.connect_timeout,
145
+ "max_reconnect_attempts": self.config.max_reconnect_attempts if self.config.allow_reconnect else 0,
146
+ "reconnect_time_wait": self.config.reconnect_time_wait,
147
+ "allow_reconnect": self.config.allow_reconnect,
148
+ "error_cb": self._error_callback,
149
+ "disconnected_cb": self._disconnected_callback,
150
+ "reconnected_cb": self._reconnected_callback,
151
+ "closed_cb": self._closed_callback,
152
+ }
153
+
154
+ # Add credentials if provided
155
+ if self.config.user and self.config.password:
156
+ options["user"] = self.config.user
157
+ options["password"] = self.config.password
158
+ self.logger.debug("Using NATS credentials for authentication")
159
+
160
+ # Connect
161
+ await self._nc.connect(**options)
162
+
163
+ self._connected = True
164
+
165
+ # Track connection timing
166
+ import time
167
+ self._connected_since = time.time()
168
+
169
+ self.logger.info(
170
+ "Connected to NATS",
171
+ extra={
172
+ "servers": self.config.servers,
173
+ "server_info": self._nc.connected_server_version
174
+ }
175
+ )
176
+
177
+ except asyncio.CancelledError:
178
+ self.logger.warning("NATS connection cancelled")
179
+ await self._cleanup()
180
+ raise
181
+
182
+ except Exception as e:
183
+ self.logger.error(
184
+ "Failed to connect to NATS",
185
+ extra={"servers": self.config.servers, "error": str(e)}
186
+ )
187
+ await self._cleanup()
188
+ raise ConnectionError(f"Failed to connect to NATS: {e}") from e
189
+
190
+ async def disconnect(self) -> None:
191
+ """Drain and close NATS connection gracefully.
192
+
193
+ Flushes pending messages before disconnecting. Safe to call
194
+ multiple times or when not connected.
195
+
196
+ Examples:
197
+ >>> await client.connect()
198
+ >>> await client.disconnect()
199
+ >>> assert not client.is_connected
200
+ """
201
+ if not self._connected:
202
+ self.logger.debug("Not connected to NATS, disconnect() is a no-op")
203
+ return
204
+
205
+ self.logger.info("Disconnecting from NATS")
206
+
207
+ await self._cleanup()
208
+
209
+ self.logger.info("Disconnected from NATS")
210
+
211
+ async def _cleanup(self) -> None:
212
+ """Clean up resources and reset state.
213
+
214
+ Internal method for draining, closing connection, and resetting state.
215
+ """
216
+ self._connected = False
217
+ self._connected_since = None
218
+
219
+ if self._nc:
220
+ try:
221
+ # Drain to flush pending messages
222
+ await self._nc.drain()
223
+ except Exception as e:
224
+ self.logger.warning(f"Error draining NATS connection: {e}")
225
+
226
+ try:
227
+ # Close connection
228
+ if not self._nc.is_closed:
229
+ await self._nc.close()
230
+ except Exception as e:
231
+ self.logger.warning(f"Error closing NATS connection: {e}")
232
+
233
+ finally:
234
+ self._nc = None
235
+
236
+ async def publish(self, subject: str, data: bytes) -> None:
237
+ """Publish message to NATS subject.
238
+
239
+ Args:
240
+ subject: NATS subject name (e.g., "cytube.events.chatMsg").
241
+ data: Message payload as bytes.
242
+
243
+ Raises:
244
+ NotConnectedError: If not connected to NATS.
245
+ ValueError: If subject is empty or data is not bytes.
246
+
247
+ Examples:
248
+ >>> await client.publish("test.subject", b"hello world")
249
+ """
250
+ if not self.is_connected:
251
+ raise NotConnectedError("Not connected to NATS server")
252
+
253
+ if not subject:
254
+ raise ValueError("Subject cannot be empty")
255
+
256
+ if not isinstance(data, bytes):
257
+ raise ValueError("Data must be bytes")
258
+
259
+ try:
260
+ await self._nc.publish(subject, data)
261
+ self._messages_published += 1
262
+ self._bytes_sent += len(data)
263
+
264
+ self.logger.debug(
265
+ "Published message to NATS",
266
+ extra={"subject": subject, "size": len(data)}
267
+ )
268
+
269
+ except Exception as e:
270
+ self._errors += 1
271
+ self.logger.error(
272
+ "Failed to publish message",
273
+ extra={"subject": subject, "error": str(e)}
274
+ )
275
+ raise
276
+
277
+ async def subscribe(
278
+ self,
279
+ subject: str,
280
+ callback: Callable[[str, bytes], Awaitable[None]]
281
+ ) -> Subscription:
282
+ """Subscribe to NATS subject with callback.
283
+
284
+ Args:
285
+ subject: NATS subject pattern (e.g., "cytube.commands.>").
286
+ callback: Async callback function(subject: str, data: bytes).
287
+
288
+ Returns:
289
+ Subscription object that can be used to unsubscribe.
290
+
291
+ Raises:
292
+ NotConnectedError: If not connected to NATS.
293
+ ValueError: If subject is empty or callback is not callable.
294
+
295
+ Examples:
296
+ >>> async def handler(subject: str, data: bytes):
297
+ ... print(f"Received on {subject}: {data}")
298
+ >>> sub = await client.subscribe("test.>", handler)
299
+ """
300
+ if not self.is_connected:
301
+ raise NotConnectedError("Not connected to NATS server")
302
+
303
+ if not subject:
304
+ raise ValueError("Subject cannot be empty")
305
+
306
+ if not callable(callback):
307
+ raise ValueError("Callback must be callable")
308
+
309
+ try:
310
+ # NATS callback receives a Msg object, we need to adapt it
311
+ async def nats_callback(msg):
312
+ await callback(msg.subject, msg.data)
313
+
314
+ subscription = await self._nc.subscribe(subject, cb=nats_callback)
315
+
316
+ self.logger.debug(
317
+ "Subscribed to NATS subject",
318
+ extra={"subject": subject}
319
+ )
320
+
321
+ return subscription
322
+
323
+ except Exception as e:
324
+ self._errors += 1
325
+ self.logger.error(
326
+ "Failed to subscribe to subject",
327
+ extra={"subject": subject, "error": str(e)}
328
+ )
329
+ raise
330
+
331
+ async def subscribe_request_reply(
332
+ self,
333
+ subject: str,
334
+ callback: Callable[[Any], Awaitable[None]]
335
+ ) -> Subscription:
336
+ """Subscribe to NATS subject for request-reply pattern.
337
+
338
+ Unlike regular subscribe, this passes the full NATS Msg object to the callback,
339
+ allowing access to msg.reply for sending responses.
340
+
341
+ Args:
342
+ subject: NATS subject pattern (e.g., "kryten.robot.command").
343
+ callback: Async callback function(msg: Msg) that receives full message.
344
+
345
+ Returns:
346
+ Subscription object that can be used to unsubscribe.
347
+
348
+ Raises:
349
+ NotConnectedError: If not connected to NATS.
350
+ ValueError: If subject is empty or callback is not callable.
351
+
352
+ Examples:
353
+ >>> async def handler(msg):
354
+ ... request = json.loads(msg.data.decode())
355
+ ... response = {"result": "ok"}
356
+ ... await nats.publish(msg.reply, json.dumps(response).encode())
357
+ >>> sub = await client.subscribe_request_reply("kryten.robot.command", handler)
358
+ """
359
+ if not self.is_connected:
360
+ raise NotConnectedError("Not connected to NATS server")
361
+
362
+ if not subject:
363
+ raise ValueError("Subject cannot be empty")
364
+
365
+ if not callable(callback):
366
+ raise ValueError("Callback must be callable")
367
+
368
+ try:
369
+ # Pass the full msg object directly to callback
370
+ subscription = await self._nc.subscribe(subject, cb=callback)
371
+
372
+ self.logger.debug(
373
+ "Subscribed to NATS subject (request-reply)",
374
+ extra={"subject": subject}
375
+ )
376
+
377
+ return subscription
378
+
379
+ except Exception as e:
380
+ self._errors += 1
381
+ self.logger.error(
382
+ "Failed to subscribe to subject",
383
+ extra={"subject": subject, "error": str(e)}
384
+ )
385
+ raise
386
+
387
+ async def unsubscribe(self, subscription: Subscription) -> None:
388
+ """Unsubscribe from NATS subject.
389
+
390
+ Args:
391
+ subscription: Subscription object from subscribe().
392
+
393
+ Examples:
394
+ >>> sub = await client.subscribe("test.>", handler)
395
+ >>> await client.unsubscribe(sub)
396
+ """
397
+ if subscription:
398
+ try:
399
+ await subscription.unsubscribe()
400
+ self.logger.debug("Unsubscribed from NATS subject")
401
+ except Exception as e:
402
+ self.logger.warning(f"Error unsubscribing: {e}")
403
+
404
+ async def _error_callback(self, error: Exception) -> None:
405
+ """Callback for NATS connection errors.
406
+
407
+ Args:
408
+ error: Exception that occurred.
409
+ """
410
+ self.logger.error(f"NATS error: {error}")
411
+ self._errors += 1
412
+
413
+ async def _disconnected_callback(self) -> None:
414
+ """Callback when disconnected from NATS."""
415
+ self.logger.warning("Disconnected from NATS server")
416
+ self._connected = False
417
+
418
+ async def _reconnected_callback(self) -> None:
419
+ """Callback when reconnected to NATS."""
420
+ self._reconnect_count += 1
421
+
422
+ # Update connected_since for the new connection
423
+ import time
424
+ self._connected_since = time.time()
425
+
426
+ self.logger.info(
427
+ f"Reconnected to NATS server (reconnect #{self._reconnect_count})"
428
+ )
429
+ self._connected = True
430
+
431
+ async def _closed_callback(self) -> None:
432
+ """Callback when NATS connection is closed."""
433
+ self.logger.info("NATS connection closed")
434
+ self._connected = False
435
+
436
+ async def __aenter__(self):
437
+ """Async context manager entry.
438
+
439
+ Automatically connects when entering the context.
440
+
441
+ Returns:
442
+ Self for use in context.
443
+
444
+ Examples:
445
+ >>> async with NatsClient(config, logger) as client:
446
+ ... await client.publish("test", b"data")
447
+ """
448
+ await self.connect()
449
+ return self
450
+
451
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
452
+ """Async context manager exit.
453
+
454
+ Ensures disconnect is called even if an exception occurs.
455
+
456
+ Args:
457
+ exc_type: Exception type if raised.
458
+ exc_val: Exception value if raised.
459
+ exc_tb: Exception traceback if raised.
460
+
461
+ Returns:
462
+ False to propagate exceptions.
463
+ """
464
+ await self.disconnect()
465
+ return False
466
+
467
+
468
+ __all__ = ["NatsClient"]
kryten/raw_event.py ADDED
@@ -0,0 +1,165 @@
1
+ """Raw Event Dataclass for CyTube Socket.IO Events.
2
+
3
+ This module defines the RawEvent dataclass for wrapping unparsed CyTube events
4
+ with metadata before publishing to NATS.
5
+ """
6
+
7
+ import json
8
+ import uuid
9
+ from dataclasses import dataclass, field
10
+ from datetime import UTC, datetime
11
+ from typing import Any
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class RawEvent:
16
+ """Immutable container for raw Socket.IO events with metadata.
17
+
18
+ Wraps CyTube Socket.IO events with timestamps, correlation IDs, and channel
19
+ information for NATS publishing and distributed tracing.
20
+
21
+ Attributes:
22
+ event_name: Socket.IO event name (e.g., "chatMsg", "addUser").
23
+ payload: Raw Socket.IO event data as dictionary.
24
+ channel: CyTube channel name.
25
+ domain: CyTube server domain (e.g., "cytu.be").
26
+ timestamp: UTC ISO 8601 timestamp with microseconds.
27
+ correlation_id: UUID4 for distributed tracing.
28
+
29
+ Examples:
30
+ >>> event = RawEvent(
31
+ ... event_name="chatMsg",
32
+ ... payload={"user": "bob", "msg": "hello"},
33
+ ... channel="lounge",
34
+ ... domain="cytu.be"
35
+ ... )
36
+ >>> json_bytes = event.to_bytes()
37
+ >>> await nats_client.publish("cytube.events.chatMsg", json_bytes)
38
+ """
39
+
40
+ event_name: str
41
+ payload: dict[str, Any]
42
+ channel: str
43
+ domain: str
44
+ timestamp: str = field(
45
+ default_factory=lambda: datetime.now(UTC).isoformat()
46
+ )
47
+ correlation_id: str = field(default_factory=lambda: str(uuid.uuid4()))
48
+
49
+ def to_dict(self) -> dict[str, Any]:
50
+ """Convert to dictionary representation.
51
+
52
+ Returns:
53
+ Dictionary with all fields as key-value pairs.
54
+
55
+ Examples:
56
+ >>> event = RawEvent("test", {}, "ch", "dom")
57
+ >>> d = event.to_dict()
58
+ >>> assert "event_name" in d
59
+ >>> assert "timestamp" in d
60
+ """
61
+ return {
62
+ "event_name": self.event_name,
63
+ "payload": self.payload,
64
+ "channel": self.channel,
65
+ "domain": self.domain,
66
+ "timestamp": self.timestamp,
67
+ "correlation_id": self.correlation_id,
68
+ }
69
+
70
+ def to_json(self) -> str:
71
+ """Serialize to JSON string.
72
+
73
+ Returns:
74
+ JSON string representation of the event.
75
+
76
+ Examples:
77
+ >>> event = RawEvent("chatMsg", {"msg": "hi"}, "ch", "dom")
78
+ >>> json_str = event.to_json()
79
+ >>> assert "chatMsg" in json_str
80
+ """
81
+ return json.dumps(self.to_dict())
82
+
83
+ def to_bytes(self) -> bytes:
84
+ """Serialize to UTF-8 encoded JSON bytes.
85
+
86
+ Returns:
87
+ UTF-8 encoded JSON bytes suitable for NATS publishing.
88
+
89
+ Examples:
90
+ >>> event = RawEvent("test", {}, "ch", "dom")
91
+ >>> data = event.to_bytes()
92
+ >>> assert isinstance(data, bytes)
93
+ """
94
+ return self.to_json().encode("utf-8")
95
+
96
+ @classmethod
97
+ def from_dict(cls, data: dict[str, Any]) -> "RawEvent":
98
+ """Deserialize from dictionary.
99
+
100
+ Args:
101
+ data: Dictionary with event fields.
102
+
103
+ Returns:
104
+ RawEvent instance with fields from dictionary.
105
+
106
+ Examples:
107
+ >>> data = {
108
+ ... "event_name": "test",
109
+ ... "payload": {},
110
+ ... "channel": "ch",
111
+ ... "domain": "dom",
112
+ ... "timestamp": "2024-01-15T10:30:00.123456Z",
113
+ ... "correlation_id": "550e8400-e29b-41d4-a716-446655440000"
114
+ ... }
115
+ >>> event = RawEvent.from_dict(data)
116
+ >>> assert event.event_name == "test"
117
+ """
118
+ return cls(
119
+ event_name=data["event_name"],
120
+ payload=data["payload"],
121
+ channel=data["channel"],
122
+ domain=data["domain"],
123
+ timestamp=data.get(
124
+ "timestamp", datetime.now(UTC).isoformat()
125
+ ),
126
+ correlation_id=data.get("correlation_id", str(uuid.uuid4())),
127
+ )
128
+
129
+ def __str__(self) -> str:
130
+ """String representation for logging.
131
+
132
+ Returns:
133
+ Human-readable string with key event information.
134
+
135
+ Examples:
136
+ >>> event = RawEvent("chatMsg", {}, "lounge", "cytu.be")
137
+ >>> s = str(event)
138
+ >>> assert "chatMsg" in s
139
+ >>> assert "lounge" in s
140
+ """
141
+ return (
142
+ f"RawEvent(event={self.event_name}, channel={self.channel}, "
143
+ f"domain={self.domain}, id={self.correlation_id[:8]}...)"
144
+ )
145
+
146
+ def __repr__(self) -> str:
147
+ """Detailed representation for debugging.
148
+
149
+ Returns:
150
+ Full representation with all fields.
151
+
152
+ Examples:
153
+ >>> event = RawEvent("test", {}, "ch", "dom")
154
+ >>> repr(event) # doctest: +ELLIPSIS
155
+ "RawEvent(event_name='test', ...)"
156
+ """
157
+ return (
158
+ f"RawEvent(event_name={self.event_name!r}, "
159
+ f"payload={self.payload!r}, channel={self.channel!r}, "
160
+ f"domain={self.domain!r}, timestamp={self.timestamp!r}, "
161
+ f"correlation_id={self.correlation_id!r})"
162
+ )
163
+
164
+
165
+ __all__ = ["RawEvent"]