"""EventBus shim — drop-in for Sanad's core/event_bus.py API across containers. Same surface as the in-process bus (`on` / `off` / `emit` / `emit_sync`) so existing Sanad call-sites change only their *import*, not their logic. When `pyzmq` is importable AND `SANAD_BUS_ADDR` is set, events are also published to / received from the central `sanad-busd` XPUB/XSUB proxy, so handlers in OTHER containers fire too. Otherwise it degrades to a pure in-process bus (identical behavior to today's monolith) — which is all P1-standalone needs. Wire format on ZMQ: multipart [topic_bytes, json(kwargs)+_origin]. Each process tags messages with a random origin id and ignores its own echoes. Env: SANAD_BUS_PUB address this process PUBLISHES to (default tcp://127.0.0.1:5560) SANAD_BUS_SUB address this process SUBSCRIBES from (default tcp://127.0.0.1:5561) SANAD_BUS_ADDR if set (any value), enable ZMQ mode using the two above Kept Python-3.8 compatible. """ from __future__ import annotations import asyncio import json import os import threading import uuid from collections import defaultdict from typing import Any, Callable, Dict, List try: import zmq # type: ignore _HAVE_ZMQ = True except Exception: _HAVE_ZMQ = False try: # reuse Sanad's logger when running inside the image; fall back to print from Project.Sanad.core.logger import get_logger # type: ignore _log = get_logger("sanad_bus", to_console=False) except Exception: # pragma: no cover class _P(object): def __getattr__(self, _n): return lambda *a, **k: None _log = _P() class Bus(object): def __init__(self) -> None: self._lock = threading.Lock() self._listeners = defaultdict(list) # type: Dict[str, List[Callable]] self._origin = uuid.uuid4().hex self._zmq_enabled = False self._pub = None self._ctx = None self._sub_thread = None self._stop = threading.Event() # ── pub/sub registration (same as core/event_bus.EventBus) ── def on(self, event: str, callback: Callable) -> None: with self._lock: self._listeners[event].append(callback) def off(self, event: str, callback: Callable) -> None: with self._lock: try: self._listeners[event].remove(callback) except ValueError: pass async def emit(self, event: str, **kwargs: Any) -> None: self._publish(event, kwargs) await self._dispatch_async(event, kwargs) def emit_sync(self, event: str, **kwargs: Any) -> None: self._publish(event, kwargs) self._dispatch_sync(event, kwargs) # ── local dispatch (mirrors core/event_bus semantics) ── def _dispatch_sync(self, event: str, kwargs: Dict[str, Any]) -> None: with self._lock: handlers = list(self._listeners.get(event, [])) for h in handlers: try: if asyncio.iscoroutinefunction(h): try: loop = asyncio.get_running_loop() loop.create_task(h(**kwargs)) except RuntimeError: _log.warning("async handler for %s dropped (no loop)", event) continue res = h(**kwargs) if asyncio.iscoroutine(res): try: asyncio.get_running_loop().create_task(res) except RuntimeError: res.close() except Exception: _log.exception("handler for %s failed", event) async def _dispatch_async(self, event: str, kwargs: Dict[str, Any]) -> None: with self._lock: handlers = list(self._listeners.get(event, [])) for h in handlers: try: res = h(**kwargs) if asyncio.iscoroutine(res): await res except Exception: _log.exception("handler for %s failed", event) # ── ZMQ transport (optional) ── def connect(self) -> bool: """Enable cross-container mode. Safe to call once at startup; no-op if pyzmq missing or SANAD_BUS_ADDR unset. Returns True if ZMQ is active.""" if self._zmq_enabled: return True if not _HAVE_ZMQ or not os.environ.get("SANAD_BUS_ADDR"): _log.info("bus: in-process mode (zmq=%s, addr=%s)", _HAVE_ZMQ, bool(os.environ.get("SANAD_BUS_ADDR"))) return False pub_addr = os.environ.get("SANAD_BUS_PUB", "tcp://127.0.0.1:5560") sub_addr = os.environ.get("SANAD_BUS_SUB", "tcp://127.0.0.1:5561") try: self._ctx = zmq.Context.instance() self._pub = self._ctx.socket(zmq.PUB) self._pub.connect(pub_addr) self._sub_thread = threading.Thread( target=self._sub_loop, args=(sub_addr,), daemon=True) self._sub_thread.start() self._zmq_enabled = True _log.info("bus: ZMQ mode pub=%s sub=%s origin=%s", pub_addr, sub_addr, self._origin[:8]) return True except Exception: _log.exception("bus: ZMQ connect failed — staying in-process") return False def _publish(self, event: str, kwargs: Dict[str, Any]) -> None: if not self._zmq_enabled or self._pub is None: return try: body = dict(kwargs) body["_origin"] = self._origin self._pub.send_multipart( [event.encode("utf-8"), json.dumps(body, default=str).encode("utf-8")]) except Exception: _log.exception("bus: publish %s failed", event) def _sub_loop(self, sub_addr: str) -> None: sub = self._ctx.socket(zmq.SUB) sub.connect(sub_addr) sub.setsockopt(zmq.SUBSCRIBE, b"") # all topics; filter locally by listeners while not self._stop.is_set(): try: if sub.poll(timeout=500): topic, raw = sub.recv_multipart() event = topic.decode("utf-8", "replace") data = json.loads(raw.decode("utf-8", "replace")) if data.pop("_origin", None) == self._origin: continue # skip our own echo self._dispatch_sync(event, data) except Exception: _log.exception("bus: sub loop error") try: sub.close(0) except Exception: pass def close(self) -> None: self._stop.set() for s in (self._pub,): try: if s is not None: s.close(0) except Exception: pass # singleton — `from sanad_pkg.bus import bus` bus = Bus()