Week 4 - Event Infrastructure: - Create SpaceEventType enum with 18 event types covering space lifecycle, document operations, variables, references, rendering, sync, and cache - Create SpaceEvent dataclass with serialization/deserialization - Create EventBus with sync/async handler support, priority ordering, global handlers, and optional event history - Add event factory functions for common events Week 5 - Event Integration: - Wire EventBus into SpaceService as optional dependency - Emit events for all space operations: - SPACE_CREATED, SPACE_UPDATED, SPACE_DELETED, SPACE_ACTIVATED, SPACE_ARCHIVED - DOCUMENT_ADDED, DOCUMENT_REMOVED, DOCUMENT_MOVED, DOCUMENT_CONTENT_CHANGED - VARIABLE_SET, VARIABLE_DELETED - Create integration tests for event propagation patterns Test coverage: 187 tests total - 43 unit tests for event system - 20 integration tests for event propagation - 124 existing tests continue to pass Capabilities delivered: - CAP-010: SpaceEvent base with type, payload, timestamp - CAP-011: EventBus with in-process publish/subscribe - CAP-012: Event handlers registry with priority support - CAP-013: Change detection via content hash comparison Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
403 lines
12 KiB
Python
403 lines
12 KiB
Python
"""
|
|
EventBus for Information Spaces.
|
|
|
|
This module provides an in-process publish/subscribe system
|
|
for space events. It supports both synchronous and asynchronous
|
|
event handlers.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from collections import defaultdict
|
|
from typing import Callable, Dict, List, Optional, Set, Union, Any
|
|
from dataclasses import dataclass, field
|
|
import weakref
|
|
|
|
from .models import SpaceEvent, SpaceEventType
|
|
|
|
|
|
# Type aliases for handlers
|
|
SyncHandler = Callable[[SpaceEvent], None]
|
|
AsyncHandler = Callable[[SpaceEvent], Any] # Coroutine
|
|
Handler = Union[SyncHandler, AsyncHandler]
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class HandlerRegistration:
|
|
"""
|
|
Registration info for an event handler.
|
|
|
|
Attributes:
|
|
handler: The handler function
|
|
is_async: Whether the handler is async
|
|
priority: Handler priority (lower = earlier execution)
|
|
handler_id: Unique ID for this registration
|
|
"""
|
|
|
|
handler: Handler
|
|
is_async: bool = False
|
|
priority: int = 100
|
|
handler_id: str = field(default_factory=lambda: str(id(None)))
|
|
|
|
def __post_init__(self):
|
|
self.handler_id = str(id(self.handler))
|
|
|
|
|
|
class EventBus:
|
|
"""
|
|
In-process event bus for space events.
|
|
|
|
Provides publish/subscribe functionality for space operations.
|
|
Handlers can be registered for specific event types or for all events.
|
|
|
|
Usage:
|
|
bus = EventBus()
|
|
|
|
# Register a handler
|
|
def on_space_created(event: SpaceEvent):
|
|
print(f"Space created: {event.payload['name']}")
|
|
|
|
bus.subscribe(SpaceEventType.SPACE_CREATED, on_space_created)
|
|
|
|
# Emit an event
|
|
bus.emit(SpaceEvent(
|
|
event_type=SpaceEventType.SPACE_CREATED,
|
|
space_id="space-123",
|
|
payload={"name": "my-docs"}
|
|
))
|
|
|
|
# Unsubscribe
|
|
bus.unsubscribe(SpaceEventType.SPACE_CREATED, on_space_created)
|
|
"""
|
|
|
|
def __init__(self):
|
|
"""Initialize the event bus."""
|
|
# Handlers indexed by event type
|
|
self._handlers: Dict[SpaceEventType, List[HandlerRegistration]] = defaultdict(
|
|
list
|
|
)
|
|
# Handlers that receive all events
|
|
self._global_handlers: List[HandlerRegistration] = []
|
|
# Track all handler IDs for quick lookup
|
|
self._handler_ids: Set[str] = set()
|
|
# Event history (optional, for debugging)
|
|
self._history: List[SpaceEvent] = []
|
|
self._history_enabled: bool = False
|
|
self._max_history: int = 1000
|
|
|
|
def subscribe(
|
|
self,
|
|
event_type: Optional[SpaceEventType],
|
|
handler: Handler,
|
|
priority: int = 100,
|
|
) -> str:
|
|
"""
|
|
Subscribe a handler to events.
|
|
|
|
Args:
|
|
event_type: The event type to subscribe to, or None for all events
|
|
handler: The handler function (sync or async)
|
|
priority: Handler priority (lower = earlier execution)
|
|
|
|
Returns:
|
|
Handler ID that can be used to unsubscribe
|
|
"""
|
|
is_async = asyncio.iscoroutinefunction(handler)
|
|
registration = HandlerRegistration(
|
|
handler=handler,
|
|
is_async=is_async,
|
|
priority=priority,
|
|
)
|
|
|
|
if event_type is None:
|
|
self._global_handlers.append(registration)
|
|
self._global_handlers.sort(key=lambda r: r.priority)
|
|
else:
|
|
self._handlers[event_type].append(registration)
|
|
self._handlers[event_type].sort(key=lambda r: r.priority)
|
|
|
|
self._handler_ids.add(registration.handler_id)
|
|
return registration.handler_id
|
|
|
|
def subscribe_all(self, handler: Handler, priority: int = 100) -> str:
|
|
"""
|
|
Subscribe a handler to all events.
|
|
|
|
Args:
|
|
handler: The handler function
|
|
priority: Handler priority
|
|
|
|
Returns:
|
|
Handler ID
|
|
"""
|
|
return self.subscribe(None, handler, priority)
|
|
|
|
def unsubscribe(
|
|
self,
|
|
event_type: Optional[SpaceEventType],
|
|
handler: Handler,
|
|
) -> bool:
|
|
"""
|
|
Unsubscribe a handler from events.
|
|
|
|
Args:
|
|
event_type: The event type, or None for global handlers
|
|
handler: The handler to remove
|
|
|
|
Returns:
|
|
True if handler was found and removed
|
|
"""
|
|
handler_id = str(id(handler))
|
|
|
|
if event_type is None:
|
|
for i, reg in enumerate(self._global_handlers):
|
|
if reg.handler_id == handler_id:
|
|
self._global_handlers.pop(i)
|
|
self._handler_ids.discard(handler_id)
|
|
return True
|
|
else:
|
|
handlers = self._handlers.get(event_type, [])
|
|
for i, reg in enumerate(handlers):
|
|
if reg.handler_id == handler_id:
|
|
handlers.pop(i)
|
|
self._handler_ids.discard(handler_id)
|
|
return True
|
|
|
|
return False
|
|
|
|
def unsubscribe_by_id(self, handler_id: str) -> bool:
|
|
"""
|
|
Unsubscribe a handler by its ID.
|
|
|
|
Args:
|
|
handler_id: The handler ID returned by subscribe
|
|
|
|
Returns:
|
|
True if handler was found and removed
|
|
"""
|
|
# Check global handlers
|
|
for i, reg in enumerate(self._global_handlers):
|
|
if reg.handler_id == handler_id:
|
|
self._global_handlers.pop(i)
|
|
self._handler_ids.discard(handler_id)
|
|
return True
|
|
|
|
# Check type-specific handlers
|
|
for handlers in self._handlers.values():
|
|
for i, reg in enumerate(handlers):
|
|
if reg.handler_id == handler_id:
|
|
handlers.pop(i)
|
|
self._handler_ids.discard(handler_id)
|
|
return True
|
|
|
|
return False
|
|
|
|
def emit(self, event: SpaceEvent) -> List[Exception]:
|
|
"""
|
|
Emit an event synchronously.
|
|
|
|
Calls all registered handlers for the event type.
|
|
Async handlers are run in a new event loop if needed.
|
|
|
|
Args:
|
|
event: The event to emit
|
|
|
|
Returns:
|
|
List of exceptions raised by handlers (empty if all succeeded)
|
|
"""
|
|
if self._history_enabled:
|
|
self._record_history(event)
|
|
|
|
exceptions: List[Exception] = []
|
|
|
|
# Collect all handlers to call
|
|
handlers = list(self._global_handlers)
|
|
handlers.extend(self._handlers.get(event.event_type, []))
|
|
handlers.sort(key=lambda r: r.priority)
|
|
|
|
for registration in handlers:
|
|
try:
|
|
if registration.is_async:
|
|
# Run async handler
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
loop = None
|
|
|
|
if loop and loop.is_running():
|
|
# Create a task but don't await it here
|
|
asyncio.create_task(registration.handler(event))
|
|
else:
|
|
asyncio.run(registration.handler(event))
|
|
else:
|
|
registration.handler(event)
|
|
except Exception as e:
|
|
logger.exception(
|
|
f"Handler {registration.handler_id} raised exception for event {event.event_type}"
|
|
)
|
|
exceptions.append(e)
|
|
|
|
return exceptions
|
|
|
|
async def emit_async(self, event: SpaceEvent) -> List[Exception]:
|
|
"""
|
|
Emit an event asynchronously.
|
|
|
|
Args:
|
|
event: The event to emit
|
|
|
|
Returns:
|
|
List of exceptions raised by handlers
|
|
"""
|
|
if self._history_enabled:
|
|
self._record_history(event)
|
|
|
|
exceptions: List[Exception] = []
|
|
|
|
# Collect all handlers to call
|
|
handlers = list(self._global_handlers)
|
|
handlers.extend(self._handlers.get(event.event_type, []))
|
|
handlers.sort(key=lambda r: r.priority)
|
|
|
|
for registration in handlers:
|
|
try:
|
|
if registration.is_async:
|
|
await registration.handler(event)
|
|
else:
|
|
registration.handler(event)
|
|
except Exception as e:
|
|
logger.exception(
|
|
f"Handler {registration.handler_id} raised exception for event {event.event_type}"
|
|
)
|
|
exceptions.append(e)
|
|
|
|
return exceptions
|
|
|
|
def has_handlers(self, event_type: SpaceEventType) -> bool:
|
|
"""
|
|
Check if there are any handlers for an event type.
|
|
|
|
Args:
|
|
event_type: The event type to check
|
|
|
|
Returns:
|
|
True if there are handlers (including global handlers)
|
|
"""
|
|
return bool(self._global_handlers) or bool(
|
|
self._handlers.get(event_type, [])
|
|
)
|
|
|
|
def handler_count(self, event_type: Optional[SpaceEventType] = None) -> int:
|
|
"""
|
|
Get the number of handlers registered.
|
|
|
|
Args:
|
|
event_type: Specific event type, or None for total count
|
|
|
|
Returns:
|
|
Number of handlers
|
|
"""
|
|
if event_type is None:
|
|
count = len(self._global_handlers)
|
|
for handlers in self._handlers.values():
|
|
count += len(handlers)
|
|
return count
|
|
else:
|
|
return len(self._handlers.get(event_type, [])) + len(
|
|
self._global_handlers
|
|
)
|
|
|
|
def clear(self, event_type: Optional[SpaceEventType] = None) -> int:
|
|
"""
|
|
Clear handlers.
|
|
|
|
Args:
|
|
event_type: Specific event type, or None to clear all
|
|
|
|
Returns:
|
|
Number of handlers removed
|
|
"""
|
|
if event_type is None:
|
|
count = self.handler_count()
|
|
self._handlers.clear()
|
|
self._global_handlers.clear()
|
|
self._handler_ids.clear()
|
|
return count
|
|
else:
|
|
handlers = self._handlers.pop(event_type, [])
|
|
for reg in handlers:
|
|
self._handler_ids.discard(reg.handler_id)
|
|
return len(handlers)
|
|
|
|
# History management
|
|
|
|
def enable_history(self, max_events: int = 1000) -> None:
|
|
"""Enable event history recording."""
|
|
self._history_enabled = True
|
|
self._max_history = max_events
|
|
|
|
def disable_history(self) -> None:
|
|
"""Disable event history recording."""
|
|
self._history_enabled = False
|
|
|
|
def clear_history(self) -> None:
|
|
"""Clear the event history."""
|
|
self._history.clear()
|
|
|
|
def get_history(
|
|
self,
|
|
event_type: Optional[SpaceEventType] = None,
|
|
space_id: Optional[str] = None,
|
|
limit: Optional[int] = None,
|
|
) -> List[SpaceEvent]:
|
|
"""
|
|
Get event history.
|
|
|
|
Args:
|
|
event_type: Filter by event type
|
|
space_id: Filter by space ID
|
|
limit: Maximum number of events to return
|
|
|
|
Returns:
|
|
List of events (newest first)
|
|
"""
|
|
events = list(reversed(self._history))
|
|
|
|
if event_type is not None:
|
|
events = [e for e in events if e.event_type == event_type]
|
|
|
|
if space_id is not None:
|
|
events = [e for e in events if e.space_id == space_id]
|
|
|
|
if limit is not None:
|
|
events = events[:limit]
|
|
|
|
return events
|
|
|
|
def _record_history(self, event: SpaceEvent) -> None:
|
|
"""Record an event in history."""
|
|
self._history.append(event)
|
|
if len(self._history) > self._max_history:
|
|
self._history = self._history[-self._max_history :]
|
|
|
|
|
|
# Global event bus instance (optional singleton pattern)
|
|
_default_bus: Optional[EventBus] = None
|
|
|
|
|
|
def get_event_bus() -> EventBus:
|
|
"""Get the default global event bus instance."""
|
|
global _default_bus
|
|
if _default_bus is None:
|
|
_default_bus = EventBus()
|
|
return _default_bus
|
|
|
|
|
|
def reset_event_bus() -> None:
|
|
"""Reset the default global event bus (useful for testing)."""
|
|
global _default_bus
|
|
_default_bus = None
|