import asyncio import bisect import fnmatch import logging from collections.abc import Callable, Coroutine from dataclasses import dataclass, field from typing import Any logger = logging.getLogger(__name__) EventHandler = Callable[..., Coroutine[Any, Any, None]] _DEFAULT_TIMEOUT = 30.0 @dataclass class Subscription: callback: EventHandler priority: int = 100 enabled: bool = True key: str = "" topic: str = "" def __hash__(self) -> int: return id(self.callback) @dataclass class PublishResult: topic: str subscriber_count: int = 0 error_count: int = 0 errors: list[str] = field(default_factory=list) class ChannelEventBus: def __init__(self) -> None: self._subscriptions: dict[str, list[Subscription]] = {} def subscribe( self, topic: str, callback: EventHandler, *, priority: int = 100, key: str = "", ) -> Subscription: sub = Subscription(callback=callback, priority=priority, key=key, topic=topic) subs = self._subscriptions.setdefault(topic, []) idx = bisect.bisect_left([s.priority for s in subs], priority) subs.insert(idx, sub) logger.debug("Subscribe: topic=%s key=%s priority=%d", topic, key, priority) return sub def unsubscribe(self, topic: str, callback: EventHandler) -> None: subs = self._subscriptions.get(topic) if subs is None: return self._subscriptions[topic] = [s for s in subs if s.callback != callback] if not self._subscriptions[topic]: del self._subscriptions[topic] logger.debug("Unsubscribe: topic=%s remaining=%d", topic, len(self._subscriptions.get(topic, []))) def enable(self, topic: str, callback: EventHandler) -> bool: for sub in self._subscriptions.get(topic, []): if sub.callback == callback: sub.enabled = True return True return False def disable(self, topic: str, callback: EventHandler) -> bool: for sub in self._subscriptions.get(topic, []): if sub.callback == callback: sub.enabled = False return True return False def _match_topics(self, topic: str) -> list[str]: if topic in self._subscriptions: return [topic] matched = [] for stored in self._subscriptions: if fnmatch.fnmatch(topic, stored) or fnmatch.fnmatch(stored, topic): matched.append(stored) return matched async def publish( self, topic: str, *args: Any, timeout: float | None = None, **kwargs: Any, ) -> PublishResult: topics = self._match_topics(topic) if not topics: return PublishResult(topic=topic) effective_timeout = timeout if timeout is not None else _DEFAULT_TIMEOUT all_subs: list[Subscription] = [] for t in topics: for sub in self._subscriptions.get(t, []): if sub.enabled: all_subs.append(sub) all_subs.sort(key=lambda s: s.priority) async def _safe_invoke(sub: Subscription) -> str | None: try: await asyncio.wait_for(sub.callback(*args, **kwargs), timeout=effective_timeout) return None except asyncio.TimeoutError: logger.error("EventBus subscriber timed out after %.1fs: topic=%s key=%s", effective_timeout, topic, sub.key) return f"timeout: {sub.key}" except Exception: logger.exception("EventBus subscriber failed: topic=%s key=%s", topic, sub.key) return f"error: {sub.key}" results = await asyncio.gather(*(_safe_invoke(s) for s in all_subs)) errors = [e for e in results if e is not None] return PublishResult( topic=topic, subscriber_count=len(all_subs), error_count=len(errors), errors=errors, ) async def publish_sequential( self, topic: str, initial: Any = None, *args: Any, timeout: float | None = None, **kwargs: Any, ) -> tuple[Any, PublishResult]: topics = self._match_topics(topic) if not topics: return initial, PublishResult(topic=topic) effective_timeout = timeout if timeout is not None else _DEFAULT_TIMEOUT all_subs: list[Subscription] = [] for t in topics: for sub in self._subscriptions.get(t, []): if sub.enabled: all_subs.append(sub) all_subs.sort(key=lambda s: s.priority) result = initial errors: list[str] = [] for sub in all_subs: try: next_result = await asyncio.wait_for(sub.callback(result, *args, **kwargs), timeout=effective_timeout) if next_result is not None: result = next_result except asyncio.TimeoutError: logger.error("EventBus sequential subscriber timed out: topic=%s key=%s", topic, sub.key) errors.append(f"timeout: {sub.key}") except Exception: logger.exception("EventBus sequential subscriber failed: topic=%s key=%s", topic, sub.key) errors.append(f"error: {sub.key}") return result, PublishResult( topic=topic, subscriber_count=len(all_subs), error_count=len(errors), errors=errors, ) async def publish_first( self, topic: str, *args: Any, timeout: float | None = None, **kwargs: Any, ) -> tuple[Any, PublishResult]: topics = self._match_topics(topic) if not topics: return None, PublishResult(topic=topic) effective_timeout = timeout if timeout is not None else _DEFAULT_TIMEOUT all_subs: list[Subscription] = [] for t in topics: for sub in self._subscriptions.get(t, []): if sub.enabled: all_subs.append(sub) all_subs.sort(key=lambda s: s.priority) for sub in all_subs: try: result = await asyncio.wait_for(sub.callback(*args, **kwargs), timeout=effective_timeout) if result is not None: return result, PublishResult(topic=topic, subscriber_count=1) except asyncio.TimeoutError: logger.error("EventBus first subscriber timed out: topic=%s key=%s", topic, sub.key) except Exception: logger.exception("EventBus first subscriber failed: topic=%s key=%s", topic, sub.key) return None, PublishResult(topic=topic, subscriber_count=len(all_subs), error_count=len(all_subs), errors=[f"all_failed: {s.key}" for s in all_subs]) def subscriber_count(self, topic: str | None = None) -> int: if topic is not None: return len(self._subscriptions.get(topic, [])) return sum(len(subs) for subs in self._subscriptions.values()) def list_subscriptions(self, topic: str | None = None) -> dict[str, list[Subscription]]: if topic is not None: subs = self._subscriptions.get(topic) return {topic: list(subs)} if subs else {} return {k: list(v) for k, v in self._subscriptions.items()} def clear(self) -> None: self._subscriptions.clear() def topics(self) -> list[str]: return list(self._subscriptions.keys())