Files
markitect-main/markitect/spaces/events/bus.py
tegwick 0a494b2011 feat(spaces): implement Phase 2 Event System
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>
2026-02-08 07:41:47 +01:00

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