Source code for ezmsg.qt.publisher
"""EzPublisher - Send messages to ezmsg topics from Qt widgets."""
from __future__ import annotations
import asyncio
import threading
from collections import deque
from enum import Enum
from typing import Any
from typing import Literal
from typing import TYPE_CHECKING
from qtpy import QtCore
from .sidecar import normalize_topic
if TYPE_CHECKING:
from ezmsg.core.pubclient import Publisher
from .session import EzSession
QueuePolicy = Literal[
"unbounded",
"block",
"drop_oldest",
"drop_latest",
"coalesce_latest",
]
TopicLike = Enum | str | None
[docs]
class EzPublisher(QtCore.QObject):
"""
Publish messages to an ezmsg topic.
EzPublisher provides a Qt-native interface for publishing to ezmsg topics.
Messages are queued and sent asynchronously, allowing non-blocking operation
from the Qt main thread.
Example:
class MyWidget(QtWidgets.QWidget):
def __init__(self):
super().__init__()
self.settings_pub = EzPublisher(
VelocityTopic.INPUT_SETTINGS, parent=self, session=session
)
self.slider.valueChanged.connect(self.send_settings)
def send_settings(self, value):
self.settings_pub.emit(VelocitySettings(gain=value))
"""
switch_started = QtCore.Signal(object) # pyright: ignore[reportPrivateImportUsage]
topic_changed = QtCore.Signal(object) # pyright: ignore[reportPrivateImportUsage]
topic_cleared = QtCore.Signal() # pyright: ignore[reportPrivateImportUsage]
switch_failed = QtCore.Signal(object, str) # pyright: ignore[reportPrivateImportUsage]
[docs]
def __init__(
self,
topic: TopicLike = None,
parent: QtCore.QObject | None = None,
*,
session: EzSession | None = None,
queue_policy: QueuePolicy = "unbounded",
max_pending: int | None = None,
):
"""
Create a publisher for an ezmsg topic.
Args:
topic: The initial topic to publish to, or None to start unpublished.
parent: Optional parent QObject for lifecycle management.
"""
super().__init__(parent)
if queue_policy == "unbounded" and max_pending is not None:
raise ValueError("max_pending is only valid for bounded queue policies")
if queue_policy in {"block", "drop_oldest", "drop_latest", "coalesce_latest"}:
max_pending = 1 if max_pending is None else max_pending
self._topic = self._validate_topic(topic)
self._desired_topic = self._topic
self._pub: Publisher | None = None # Set by EzSession during setup
self._session: EzSession | None = None
self._queue_policy = queue_policy
self._max_pending = max_pending
self._loop: asyncio.AbstractEventLoop | None = None
self._async_queue: asyncio.Queue[Any] | None = None
self._pending: deque[Any] = deque()
self._pending_lock = threading.Lock()
if session is not None:
session.attach(self)
@property
def topic(self) -> TopicLike:
"""The topic this publisher is bound to."""
return self._topic
@property
def session(self) -> EzSession | None:
"""The session this publisher is attached to, if any."""
return self._session
[docs]
def emit(self, message: Any) -> None:
"""
Send a message to the topic.
The message is queued and sent asynchronously by the background thread.
Args:
message: The message to publish.
"""
if self._desired_topic is None and self._topic is None:
raise RuntimeError("EzPublisher has no active topic")
if self._loop is not None and self._async_queue is not None:
if self._queue_policy == "block":
asyncio.run_coroutine_threadsafe(
self._async_queue.put(message), self._loop
).result()
return
self._loop.call_soon_threadsafe(self._enqueue_async, message)
return
with self._pending_lock:
self._enqueue_pending_locked(message)
[docs]
def set_topic(self, topic: Enum | str) -> None:
"""Switch to a new topic on a running session."""
validated = self._validate_topic(topic)
session = self._require_running_session()
previous_desired = self._desired_topic
self._desired_topic = validated
self.switch_started.emit(validated)
try:
session._set_publisher_topic(self, validated)
self.topic_changed.emit(self._topic)
except Exception as exc:
self._desired_topic = previous_desired
self.switch_failed.emit(validated, str(exc))
raise
[docs]
def clear_topic(self) -> None:
"""Unpublish from the current topic on a running session."""
session = self._require_running_session()
previous_desired = self._desired_topic
self._desired_topic = None
self.switch_started.emit(None)
try:
session._set_publisher_topic(self, None)
self.topic_cleared.emit()
except Exception as exc:
self._desired_topic = previous_desired
self.switch_failed.emit(None, str(exc))
raise
def _bind_session(self, session: EzSession) -> None:
if self._session is not None and self._session is not session:
raise RuntimeError("EzPublisher is already attached to a different session")
self._session = session
def _bind_runtime(self, loop: asyncio.AbstractEventLoop) -> None:
self._loop = loop
if self._async_queue is None:
maxsize = 0 if self._max_pending is None else self._max_pending
self._async_queue = asyncio.Queue(maxsize=maxsize)
async def _get_message(self) -> Any:
if self._async_queue is None:
raise RuntimeError("Publisher runtime queue is not initialized")
return await self._async_queue.get()
def _require_running_session(self) -> EzSession:
if self._session is None:
raise RuntimeError(
"EzPublisher must be attached to a session before switching"
)
if not self._session.running:
raise RuntimeError(
"EzPublisher topic switching requires a running EzSession"
)
return self._session
@staticmethod
def _validate_topic(topic: TopicLike) -> TopicLike:
if topic is None:
return None
normalize_topic(topic)
return topic
def _enqueue_pending_locked(self, message: Any) -> None:
if self._queue_policy == "coalesce_latest":
self._pending.clear()
self._pending.append(message)
return
if self._max_pending is not None and len(self._pending) >= self._max_pending:
if self._queue_policy == "block":
raise RuntimeError(
"EzPublisher cannot block before the session runtime starts"
)
if self._queue_policy == "drop_latest":
return
if self._queue_policy == "drop_oldest":
self._pending.popleft()
self._pending.append(message)
async def _flush_pending(self) -> None:
if self._async_queue is None:
return
with self._pending_lock:
pending = list(self._pending)
self._pending.clear()
for message in pending:
if self._queue_policy == "block":
await self._async_queue.put(message)
else:
self._enqueue_async(message)
def _enqueue_async(self, message: Any) -> None:
if self._async_queue is None:
return
if self._queue_policy == "coalesce_latest":
while True:
try:
self._async_queue.get_nowait()
except asyncio.QueueEmpty:
break
self._async_queue.put_nowait(message)
return
if self._async_queue.maxsize > 0 and self._async_queue.full():
if self._queue_policy == "drop_latest":
return
if self._queue_policy == "drop_oldest":
try:
self._async_queue.get_nowait()
except asyncio.QueueEmpty:
pass
self._async_queue.put_nowait(message)