"""Lightweight in-process event bus for inter-module communication. Usage: from core.event_bus import bus # Subscribe bus.on("voice.user_said", my_handler) # sync or async callable bus.on("motion.action_done", other_handler) # Publish await bus.emit("voice.user_said", text="hello") """ from __future__ import annotations import asyncio import threading from collections import defaultdict from typing import Any, Callable from Project.Sanad.core.logger import get_logger log = get_logger("event_bus", to_console=False) class EventBus: def __init__(self): self._lock = threading.Lock() self._listeners: dict[str, list[Callable]] = defaultdict(list) def on(self, event: str, callback: Callable): with self._lock: self._listeners[event].append(callback) log.debug("Subscribed %s → %s", event, callback.__qualname__) def off(self, event: str, callback: Callable): with self._lock: try: self._listeners[event].remove(callback) except ValueError: pass async def emit(self, event: str, **kwargs: Any): with self._lock: handlers = list(self._listeners.get(event, [])) for handler in handlers: try: result = handler(**kwargs) if asyncio.iscoroutine(result): await result except Exception: log.exception("Handler %s for event '%s' failed", handler.__qualname__, event) def emit_sync(self, event: str, **kwargs: Any): """Fire-and-forget from a sync context. Async handlers are scheduled on the running event loop if one exists. Otherwise they are dropped with a warning (the original silent-no-op bug — at least now it's logged). """ with self._lock: handlers = list(self._listeners.get(event, [])) for handler in handlers: try: if asyncio.iscoroutinefunction(handler): try: loop = asyncio.get_running_loop() loop.create_task(handler(**kwargs)) except RuntimeError: log.warning( "Async handler %s for '%s' dropped — no running loop", handler.__qualname__, event, ) continue result = handler(**kwargs) if asyncio.iscoroutine(result): # Sync handler returned a coroutine — schedule it try: loop = asyncio.get_running_loop() loop.create_task(result) except RuntimeError: result.close() log.warning( "Coroutine result from %s for '%s' dropped — no running loop", handler.__qualname__, event, ) except Exception: log.exception("Handler %s for event '%s' failed", handler.__qualname__, event) bus = EventBus()