实现了完整的频道事件总线系统,包含: 1. 事件主题枚举类,定义了全量可用事件主题 2. 钩子函数到事件主题的映射字典 3. 异步事件总线实现,支持并行/顺序/首结果发布 4. 订阅、取消订阅、启用禁用订阅等管理能力 5. 订阅优先级排序、超时和错误处理机制 6. 对外暴露的统一导出接口
221 lines
7.5 KiB
Python
221 lines
7.5 KiB
Python
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()) |