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/CONFIG.md +504 -0
- kryten/__init__.py +127 -0
- kryten/__main__.py +882 -0
- kryten/application_state.py +98 -0
- kryten/audit_logger.py +237 -0
- kryten/command_subscriber.py +341 -0
- kryten/config.example.json +35 -0
- kryten/config.py +510 -0
- kryten/connection_watchdog.py +209 -0
- kryten/correlation.py +241 -0
- kryten/cytube_connector.py +754 -0
- kryten/cytube_event_sender.py +1476 -0
- kryten/errors.py +161 -0
- kryten/event_publisher.py +416 -0
- kryten/health_monitor.py +482 -0
- kryten/lifecycle_events.py +274 -0
- kryten/logging_config.py +314 -0
- kryten/nats_client.py +468 -0
- kryten/raw_event.py +165 -0
- kryten/service_registry.py +371 -0
- kryten/shutdown_handler.py +383 -0
- kryten/socket_io.py +903 -0
- kryten/state_manager.py +711 -0
- kryten/state_query_handler.py +698 -0
- kryten/state_updater.py +314 -0
- kryten/stats_tracker.py +108 -0
- kryten/subject_builder.py +330 -0
- kryten_robot-0.6.9.dist-info/METADATA +469 -0
- kryten_robot-0.6.9.dist-info/RECORD +32 -0
- kryten_robot-0.6.9.dist-info/WHEEL +4 -0
- kryten_robot-0.6.9.dist-info/entry_points.txt +3 -0
- kryten_robot-0.6.9.dist-info/licenses/LICENSE +21 -0
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"]
|