soorma-core 0.3.0__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.
soorma/events.py ADDED
@@ -0,0 +1,496 @@
1
+ """
2
+ Event Client for the Soorma SDK.
3
+
4
+ This module provides an EventClient class that connects to the Soorma Event Service
5
+ using Server-Sent Events (SSE) for real-time event streaming and HTTP for publishing.
6
+
7
+ The SDK abstracts away the underlying message bus (NATS, Kafka, etc.), providing
8
+ a clean interface for agents to publish and subscribe to events.
9
+
10
+ Usage:
11
+ from soorma.events import EventClient
12
+
13
+ # Create client
14
+ client = EventClient(
15
+ event_service_url="http://localhost:8082",
16
+ agent_id="my-agent",
17
+ )
18
+
19
+ # Define event handlers
20
+ @client.on_event("research.requested")
21
+ async def handle_research(event):
22
+ print(f"Got research request: {event['data']}")
23
+
24
+ # Connect and start receiving events
25
+ await client.connect(topics=["research.*", "action-requests"])
26
+
27
+ # Publish events
28
+ await client.publish(
29
+ event_type="research.completed",
30
+ topic="action-results",
31
+ data={"result": "Analysis complete"},
32
+ )
33
+ """
34
+ import asyncio
35
+ import json
36
+ import logging
37
+ from datetime import datetime, timezone
38
+ from typing import Any, Awaitable, Callable, Dict, List, Optional
39
+ from uuid import uuid4
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ # Type alias for event handlers
44
+ EventHandler = Callable[[Dict[str, Any]], Awaitable[None]]
45
+
46
+
47
+ class EventClient:
48
+ """
49
+ Client for publishing and subscribing to events via the Soorma Event Service.
50
+
51
+ This client:
52
+ - Uses HTTP POST for publishing events
53
+ - Uses SSE (Server-Sent Events) for real-time event subscription
54
+ - Handles auto-reconnection with exponential backoff
55
+ - Dispatches events to registered handlers
56
+
57
+ Attributes:
58
+ event_service_url: Base URL of the Event Service
59
+ agent_id: Unique identifier for this agent
60
+ source: Source identifier for published events (defaults to agent_id)
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ event_service_url: str = "http://localhost:8082",
66
+ agent_id: Optional[str] = None,
67
+ source: Optional[str] = None,
68
+ tenant_id: Optional[str] = None,
69
+ session_id: Optional[str] = None,
70
+ max_reconnect_attempts: int = -1, # -1 = infinite
71
+ reconnect_base_delay: float = 1.0,
72
+ reconnect_max_delay: float = 60.0,
73
+ ):
74
+ """
75
+ Initialize the EventClient.
76
+
77
+ Args:
78
+ event_service_url: Base URL of the Event Service (e.g., "http://localhost:8082")
79
+ agent_id: Unique identifier for this agent (auto-generated if not provided)
80
+ source: Source identifier for events (defaults to agent_id)
81
+ tenant_id: Default tenant ID for multi-tenancy
82
+ session_id: Default session ID for correlation
83
+ max_reconnect_attempts: Max reconnection attempts (-1 for infinite)
84
+ reconnect_base_delay: Initial delay between reconnection attempts (seconds)
85
+ reconnect_max_delay: Maximum delay between reconnection attempts (seconds)
86
+ """
87
+ self.event_service_url = event_service_url.rstrip("/")
88
+ self.agent_id = agent_id or f"agent-{str(uuid4())[:8]}"
89
+ self.source = source or self.agent_id
90
+ self.tenant_id = tenant_id
91
+ self.session_id = session_id
92
+
93
+ # Reconnection settings
94
+ self._max_reconnect_attempts = max_reconnect_attempts
95
+ self._reconnect_base_delay = reconnect_base_delay
96
+ self._reconnect_max_delay = reconnect_max_delay
97
+
98
+ # Connection state
99
+ self._connected = False
100
+ self._connection_id: Optional[str] = None
101
+ self._subscribed_topics: List[str] = []
102
+ self._stream_task: Optional[asyncio.Task] = None
103
+ self._stop_event = asyncio.Event()
104
+
105
+ # Event handlers: event_type -> list of handlers
106
+ self._handlers: Dict[str, List[EventHandler]] = {}
107
+ # Catch-all handlers (receive all events)
108
+ self._catch_all_handlers: List[EventHandler] = []
109
+
110
+ # HTTP client (lazy initialized)
111
+ self._http_client = None
112
+
113
+ # =========================================================================
114
+ # Decorator for registering handlers
115
+ # =========================================================================
116
+
117
+ def on_event(self, event_type: str) -> Callable[[EventHandler], EventHandler]:
118
+ """
119
+ Decorator to register an event handler for a specific event type.
120
+
121
+ Usage:
122
+ @client.on_event("research.requested")
123
+ async def handle_research(event):
124
+ print(f"Received: {event}")
125
+
126
+ Args:
127
+ event_type: The event type to handle (e.g., "research.requested")
128
+
129
+ Returns:
130
+ Decorator function
131
+ """
132
+ def decorator(func: EventHandler) -> EventHandler:
133
+ if event_type not in self._handlers:
134
+ self._handlers[event_type] = []
135
+ self._handlers[event_type].append(func)
136
+ logger.debug(f"Registered handler for event type: {event_type}")
137
+ return func
138
+ return decorator
139
+
140
+ def on_all_events(self, func: EventHandler) -> EventHandler:
141
+ """
142
+ Decorator to register a catch-all event handler.
143
+
144
+ Usage:
145
+ @client.on_all_events
146
+ async def handle_all(event):
147
+ print(f"Received: {event}")
148
+
149
+ Args:
150
+ func: The handler function
151
+
152
+ Returns:
153
+ The handler function
154
+ """
155
+ self._catch_all_handlers.append(func)
156
+ logger.debug("Registered catch-all handler")
157
+ return func
158
+
159
+ # =========================================================================
160
+ # Connection Management
161
+ # =========================================================================
162
+
163
+ async def connect(self, topics: List[str]) -> None:
164
+ """
165
+ Connect to the Event Service and start receiving events.
166
+
167
+ This method:
168
+ 1. Establishes an SSE connection to the Event Service
169
+ 2. Subscribes to the specified topics
170
+ 3. Starts a background task to process incoming events
171
+
172
+ Args:
173
+ topics: List of topic patterns to subscribe to (e.g., ["research.*"])
174
+
175
+ Raises:
176
+ ConnectionError: If unable to connect to the Event Service
177
+ """
178
+ if self._connected:
179
+ logger.warning("Already connected")
180
+ return
181
+
182
+ self._subscribed_topics = topics
183
+ self._stop_event.clear()
184
+
185
+ # Start the SSE stream task
186
+ self._stream_task = asyncio.create_task(self._run_stream())
187
+
188
+ # Wait briefly for connection to establish
189
+ await asyncio.sleep(0.5)
190
+
191
+ if not self._connected:
192
+ logger.warning("Connection not yet established, will retry in background")
193
+
194
+ async def disconnect(self) -> None:
195
+ """
196
+ Disconnect from the Event Service.
197
+
198
+ This gracefully closes the SSE connection and stops the background task.
199
+ """
200
+ logger.info("Disconnecting from Event Service")
201
+
202
+ self._stop_event.set()
203
+
204
+ if self._stream_task:
205
+ self._stream_task.cancel()
206
+ try:
207
+ await self._stream_task
208
+ except asyncio.CancelledError:
209
+ pass
210
+ self._stream_task = None
211
+
212
+ if self._http_client:
213
+ await self._http_client.aclose()
214
+ self._http_client = None
215
+
216
+ self._connected = False
217
+ self._connection_id = None
218
+ logger.info("Disconnected from Event Service")
219
+
220
+ @property
221
+ def is_connected(self) -> bool:
222
+ """Check if connected to the Event Service."""
223
+ return self._connected
224
+
225
+ # =========================================================================
226
+ # Publishing
227
+ # =========================================================================
228
+
229
+ async def publish(
230
+ self,
231
+ event_type: str,
232
+ topic: str,
233
+ data: Optional[Dict[str, Any]] = None,
234
+ correlation_id: Optional[str] = None,
235
+ subject: Optional[str] = None,
236
+ ) -> str:
237
+ """
238
+ Publish an event to the Event Service.
239
+
240
+ Args:
241
+ event_type: Event type (e.g., "research.completed")
242
+ topic: Target topic (e.g., "action-results")
243
+ data: Event payload data
244
+ correlation_id: Optional correlation ID for tracing
245
+ subject: Optional subject/resource identifier
246
+
247
+ Returns:
248
+ The event ID
249
+
250
+ Raises:
251
+ ConnectionError: If unable to publish the event
252
+ """
253
+ await self._ensure_http_client()
254
+
255
+ event_id = str(uuid4())
256
+
257
+ event = {
258
+ "id": event_id,
259
+ "source": self.source,
260
+ "specversion": "1.0",
261
+ "type": event_type,
262
+ "topic": topic,
263
+ "time": datetime.now(timezone.utc).isoformat(),
264
+ "data": data or {},
265
+ "correlation_id": correlation_id or str(uuid4()),
266
+ }
267
+
268
+ # Add optional fields
269
+ if subject:
270
+ event["subject"] = subject
271
+ if self.tenant_id:
272
+ event["tenant_id"] = self.tenant_id
273
+ if self.session_id:
274
+ event["session_id"] = self.session_id
275
+
276
+ url = f"{self.event_service_url}/v1/events/publish"
277
+
278
+ try:
279
+ response = await self._http_client.post(
280
+ url,
281
+ json={"event": event},
282
+ timeout=30.0,
283
+ )
284
+
285
+ if response.status_code != 200:
286
+ error_detail = response.text
287
+ raise ConnectionError(f"Failed to publish event: {response.status_code} - {error_detail}")
288
+
289
+ result = response.json()
290
+ logger.debug(f"Published event {event_id} to {topic}")
291
+
292
+ return result.get("event_id", event_id)
293
+
294
+ except Exception as e:
295
+ logger.error(f"Error publishing event: {e}")
296
+ raise ConnectionError(f"Failed to publish event: {e}") from e
297
+
298
+ # =========================================================================
299
+ # SSE Stream Processing
300
+ # =========================================================================
301
+
302
+ async def _run_stream(self) -> None:
303
+ """
304
+ Main loop for the SSE stream.
305
+
306
+ Handles connection, reconnection with exponential backoff, and event dispatch.
307
+ """
308
+ attempt = 0
309
+
310
+ while not self._stop_event.is_set():
311
+ try:
312
+ await self._stream_events()
313
+ # If we get here, connection closed gracefully
314
+ if self._stop_event.is_set():
315
+ break
316
+ attempt = 0 # Reset on successful connection
317
+
318
+ except asyncio.CancelledError:
319
+ break
320
+ except Exception as e:
321
+ self._connected = False
322
+
323
+ # Only log if there's meaningful error info
324
+ error_msg = str(e).strip()
325
+ if error_msg:
326
+ logger.warning(f"Stream connection issue: {error_msg}")
327
+ else:
328
+ logger.debug("Stream disconnected, reconnecting...")
329
+
330
+ # Check if we should retry
331
+ if self._max_reconnect_attempts >= 0 and attempt >= self._max_reconnect_attempts:
332
+ logger.error("Max reconnection attempts reached")
333
+ break
334
+
335
+ # Calculate backoff delay
336
+ delay = min(
337
+ self._reconnect_base_delay * (2 ** attempt),
338
+ self._reconnect_max_delay,
339
+ )
340
+
341
+ # Only log reconnection on first few attempts to reduce noise
342
+ if attempt < 3:
343
+ logger.debug(f"Reconnecting in {delay:.1f}s (attempt {attempt + 1})")
344
+
345
+ try:
346
+ await asyncio.wait_for(
347
+ self._stop_event.wait(),
348
+ timeout=delay,
349
+ )
350
+ break # Stop event was set
351
+ except asyncio.TimeoutError:
352
+ pass # Continue with retry
353
+
354
+ attempt += 1
355
+
356
+ async def _stream_events(self) -> None:
357
+ """
358
+ Connect to SSE stream and process events.
359
+ """
360
+ await self._ensure_http_client()
361
+
362
+ topics_param = ",".join(self._subscribed_topics)
363
+ # Pass source as agent_name for load balancing queue groups
364
+ url = f"{self.event_service_url}/v1/events/stream?topics={topics_param}&agent_id={self.agent_id}&agent_name={self.source}"
365
+
366
+ logger.info(f"Connecting to SSE stream: {url}")
367
+
368
+ async with self._http_client.stream("GET", url) as response:
369
+ if response.status_code != 200:
370
+ raise ConnectionError(f"SSE connection failed: {response.status_code}")
371
+
372
+ # Process SSE stream
373
+ event_type = None
374
+ data_lines: List[str] = []
375
+
376
+ async for line in response.aiter_lines():
377
+ if self._stop_event.is_set():
378
+ break
379
+
380
+ line = line.strip()
381
+
382
+ if not line:
383
+ # Empty line = end of event
384
+ if event_type and data_lines:
385
+ data = "\n".join(data_lines)
386
+ await self._handle_sse_event(event_type, data)
387
+ event_type = None
388
+ data_lines = []
389
+ continue
390
+
391
+ if line.startswith("event:"):
392
+ event_type = line[6:].strip()
393
+ elif line.startswith("data:"):
394
+ data_lines.append(line[5:].strip())
395
+ # Ignore other fields (id, retry, etc.)
396
+
397
+ async def _handle_sse_event(self, event_type: str, data: str) -> None:
398
+ """
399
+ Handle an SSE event.
400
+
401
+ Args:
402
+ event_type: SSE event type (e.g., "connected", "message", "heartbeat")
403
+ data: Event data (JSON string)
404
+ """
405
+ try:
406
+ parsed_data = json.loads(data)
407
+ except json.JSONDecodeError:
408
+ logger.warning(f"Failed to parse SSE data: {data}")
409
+ return
410
+
411
+ if event_type == "connected":
412
+ self._connected = True
413
+ self._connection_id = parsed_data.get("connection_id")
414
+ logger.info(f"Connected to Event Service (connection_id: {self._connection_id})")
415
+
416
+ elif event_type == "heartbeat":
417
+ logger.debug("Received heartbeat")
418
+
419
+ elif event_type == "message":
420
+ # Dispatch to handlers
421
+ await self._dispatch_event(parsed_data)
422
+
423
+ elif event_type == "disconnected":
424
+ self._connected = False
425
+ logger.info("Received disconnect from Event Service")
426
+
427
+ else:
428
+ logger.debug(f"Unknown SSE event type: {event_type}")
429
+
430
+ async def _dispatch_event(self, event: Dict[str, Any]) -> None:
431
+ """
432
+ Dispatch an event to registered handlers.
433
+
434
+ Args:
435
+ event: The event data
436
+ """
437
+ event_type = event.get("type", "")
438
+
439
+ # Call specific handlers
440
+ handlers = self._handlers.get(event_type, [])
441
+ for handler in handlers:
442
+ try:
443
+ await handler(event)
444
+ except Exception as e:
445
+ logger.error(f"Error in handler for {event_type}: {e}")
446
+
447
+ # Call catch-all handlers
448
+ for handler in self._catch_all_handlers:
449
+ try:
450
+ await handler(event)
451
+ except Exception as e:
452
+ logger.error(f"Error in catch-all handler: {e}")
453
+
454
+ if not handlers and not self._catch_all_handlers:
455
+ logger.debug(f"No handlers for event type: {event_type}")
456
+
457
+ # =========================================================================
458
+ # HTTP Client Management
459
+ # =========================================================================
460
+
461
+ async def _ensure_http_client(self) -> None:
462
+ """Ensure HTTP client is initialized."""
463
+ if self._http_client is None:
464
+ try:
465
+ import httpx
466
+ except ImportError:
467
+ raise ImportError(
468
+ "httpx is required for EventClient. "
469
+ "Install it with: pip install httpx"
470
+ )
471
+
472
+ # Configure timeout for SSE streaming:
473
+ # - connect: 10s for initial connection
474
+ # - read: None (no timeout) for long-lived SSE streams
475
+ # - write: 30s for publishing
476
+ # - pool: None (no timeout) for connection pool
477
+ timeout = httpx.Timeout(
478
+ connect=10.0,
479
+ read=None, # SSE streams are long-lived
480
+ write=30.0,
481
+ pool=None,
482
+ )
483
+
484
+ self._http_client = httpx.AsyncClient(timeout=timeout)
485
+
486
+ # =========================================================================
487
+ # Context Manager Support
488
+ # =========================================================================
489
+
490
+ async def __aenter__(self) -> "EventClient":
491
+ """Async context manager entry."""
492
+ return self
493
+
494
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
495
+ """Async context manager exit."""
496
+ await self.disconnect()
soorma/models.py ADDED
@@ -0,0 +1,24 @@
1
+ """
2
+ Soorma SDK Models.
3
+
4
+ This module re-exports common data models used throughout the SDK.
5
+ """
6
+ from soorma_common import (
7
+ AgentCapability,
8
+ AgentDefinition,
9
+ AgentRegistrationRequest,
10
+ AgentRegistrationResponse,
11
+ AgentQueryResponse,
12
+ EventDefinition,
13
+ BaseDTO
14
+ )
15
+
16
+ __all__ = [
17
+ "AgentCapability",
18
+ "AgentDefinition",
19
+ "AgentRegistrationRequest",
20
+ "AgentRegistrationResponse",
21
+ "AgentQueryResponse",
22
+ "EventDefinition",
23
+ "BaseDTO",
24
+ ]
@@ -0,0 +1,186 @@
1
+ """
2
+ Client library for interacting with the Registry Service.
3
+ """
4
+ from typing import List, Optional
5
+ import httpx
6
+ from soorma_common import (
7
+ EventDefinition,
8
+ EventRegistrationRequest,
9
+ EventRegistrationResponse,
10
+ EventQueryResponse,
11
+ AgentDefinition,
12
+ AgentRegistrationRequest,
13
+ AgentRegistrationResponse,
14
+ AgentQueryResponse,
15
+ )
16
+
17
+
18
+ class RegistryClient:
19
+ """
20
+ Client for interacting with the Registry Service API.
21
+
22
+ This client allows other services to register and query events and agents.
23
+ """
24
+
25
+ def __init__(self, base_url: str, timeout: float = 30.0):
26
+ """
27
+ Initialize the registry client.
28
+
29
+ Args:
30
+ base_url: Base URL of the registry service (e.g., "http://localhost:8000")
31
+ timeout: HTTP request timeout in seconds
32
+ """
33
+ self.base_url = base_url.rstrip("/")
34
+ self.timeout = timeout
35
+ self._client = httpx.AsyncClient(timeout=timeout)
36
+
37
+ async def close(self):
38
+ """Close the HTTP client."""
39
+ await self._client.aclose()
40
+
41
+ async def __aenter__(self):
42
+ """Async context manager entry."""
43
+ return self
44
+
45
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
46
+ """Async context manager exit."""
47
+ await self.close()
48
+
49
+ # Event Registry Methods
50
+
51
+ async def register_event(self, event: EventDefinition) -> EventRegistrationResponse:
52
+ """
53
+ Register a new event in the event registry.
54
+
55
+ Args:
56
+ event: Event definition to register
57
+
58
+ Returns:
59
+ EventRegistrationResponse with registration status
60
+ """
61
+ request = EventRegistrationRequest(event=event)
62
+ response = await self._client.post(
63
+ f"{self.base_url}/api/v1/events",
64
+ json=request.model_dump(by_alias=True)
65
+ )
66
+ response.raise_for_status()
67
+ return EventRegistrationResponse.model_validate(response.json())
68
+
69
+ async def get_event(self, event_name: str) -> Optional[EventDefinition]:
70
+ """
71
+ Get a specific event by name.
72
+
73
+ Args:
74
+ event_name: Name of the event to retrieve
75
+
76
+ Returns:
77
+ EventDefinition if found, None otherwise
78
+ """
79
+ response = await self._client.get(
80
+ f"{self.base_url}/api/v1/events",
81
+ params={"event_name": event_name}
82
+ )
83
+ response.raise_for_status()
84
+ result = EventQueryResponse.model_validate(response.json())
85
+ return result.events[0] if result.events else None
86
+
87
+ async def get_events_by_topic(self, topic: str) -> List[EventDefinition]:
88
+ """
89
+ Get all events for a specific topic.
90
+
91
+ Args:
92
+ topic: Topic to filter by
93
+
94
+ Returns:
95
+ List of EventDefinitions for the topic
96
+ """
97
+ response = await self._client.get(
98
+ f"{self.base_url}/api/v1/events",
99
+ params={"topic": topic}
100
+ )
101
+ response.raise_for_status()
102
+ result = EventQueryResponse.model_validate(response.json())
103
+ return result.events
104
+
105
+ async def get_all_events(self) -> List[EventDefinition]:
106
+ """
107
+ Get all registered events.
108
+
109
+ Returns:
110
+ List of all EventDefinitions
111
+ """
112
+ response = await self._client.get(f"{self.base_url}/api/v1/events")
113
+ response.raise_for_status()
114
+ result = EventQueryResponse.model_validate(response.json())
115
+ return result.events
116
+
117
+ # Agent Registry Methods
118
+
119
+ async def register_agent(self, agent: AgentDefinition) -> AgentRegistrationResponse:
120
+ """
121
+ Register a new agent in the agent registry.
122
+
123
+ Args:
124
+ agent: Agent definition to register
125
+
126
+ Returns:
127
+ AgentRegistrationResponse with registration status
128
+ """
129
+ request = AgentRegistrationRequest(agent=agent)
130
+ response = await self._client.post(
131
+ f"{self.base_url}/api/v1/agents",
132
+ json=request.model_dump(by_alias=True)
133
+ )
134
+ response.raise_for_status()
135
+ return AgentRegistrationResponse.model_validate(response.json())
136
+
137
+ async def get_agent(self, agent_id: str) -> Optional[AgentDefinition]:
138
+ """
139
+ Get a specific agent by ID.
140
+
141
+ Args:
142
+ agent_id: ID of the agent to retrieve
143
+
144
+ Returns:
145
+ AgentDefinition if found, None otherwise
146
+ """
147
+ response = await self._client.get(
148
+ f"{self.base_url}/api/v1/agents",
149
+ params={"agent_id": agent_id}
150
+ )
151
+ response.raise_for_status()
152
+ result = AgentQueryResponse.model_validate(response.json())
153
+ return result.agents[0] if result.agents else None
154
+
155
+ async def query_agents(
156
+ self,
157
+ name: Optional[str] = None,
158
+ consumed_event: Optional[str] = None,
159
+ produced_event: Optional[str] = None
160
+ ) -> List[AgentDefinition]:
161
+ """
162
+ Query agents with filters.
163
+
164
+ Args:
165
+ name: Filter by agent name
166
+ consumed_event: Filter by consumed event
167
+ produced_event: Filter by produced event
168
+
169
+ Returns:
170
+ List of matching AgentDefinitions
171
+ """
172
+ params = {}
173
+ if name:
174
+ params["name"] = name
175
+ if consumed_event:
176
+ params["consumed_event"] = consumed_event
177
+ if produced_event:
178
+ params["produced_event"] = produced_event
179
+
180
+ response = await self._client.get(
181
+ f"{self.base_url}/api/v1/agents",
182
+ params=params
183
+ )
184
+ response.raise_for_status()
185
+ result = AgentQueryResponse.model_validate(response.json())
186
+ return result.agents