Coverage for /usr/local/lib/python3.10/site-packages/opal_common-0.0.0-py3.10.egg/opal_common/topics/publisher.py: 57%
106 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 11:54 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 11:54 +0000
1import asyncio
2from typing import Any, Optional, Set
4from ddtrace import tracer
5from fastapi_websocket_pubsub import PubSubClient, PubSubEndpoint, Topic, TopicList
6from opal_common.logger import logger
9class TopicPublisher:
10 """Abstract publisher, base class for client side and server side
11 publisher."""
13 def __init__(self):
14 """Inits the publisher's asyncio tasks list."""
15 self._tasks: Set[asyncio.Task] = set()
16 self._tasks_lock = asyncio.Lock()
18 async def publish(self, topics: TopicList, data: Any = None):
19 raise NotImplementedError()
21 async def __aenter__(self):
22 self.start()
23 return self
25 async def __aexit__(self, exc_type, exc, tb):
26 await self.stop()
28 def start(self):
29 """Starts the publisher."""
30 logger.debug("started topic publisher")
32 async def _add_task(self, task: asyncio.Task):
33 async with self._tasks_lock:
34 self._tasks.add(task)
35 task.add_done_callback(self._cleanup_task)
37 async def wait(self):
38 async with self._tasks_lock:
39 await asyncio.gather(*self._tasks, return_exceptions=True)
40 self._tasks.clear()
42 async def stop(self):
43 """Stops the publisher (cancels any running publishing tasks)"""
44 logger.debug("stopping topic publisher")
45 await self.wait()
47 def _cleanup_task(self, task: asyncio.Task):
48 try:
49 self._tasks.remove(task)
50 except KeyError:
51 ...
54class PeriodicPublisher:
55 """Wrapper for a task that publishes to topic on fixed interval
56 periodically."""
58 def __init__(
59 self,
60 publisher: TopicPublisher,
61 time_interval: int,
62 topic: Topic,
63 message: Any = None,
64 task_name: str = "periodic publish task",
65 ):
66 """Inits the publisher.
68 Args:
69 publisher (TopicPublisher): can publish messages on the pub/sub channel
70 interval (int): the time interval between publishing consecutive messages
71 topic (Topic): the topic to publish on
72 message (Any): the message to publish
73 """
74 self._publisher = publisher
75 self._interval = time_interval
76 self._topic = topic
77 self._message = message
78 self._task_name = task_name
79 self._task: Optional[asyncio.Task] = None
81 async def __aenter__(self):
82 self.start()
83 return self
85 async def __aexit__(self, exc_type, exc, tb):
86 await self.stop()
88 def start(self):
89 """Starts the periodic publisher task."""
90 if self._task is not None:
91 logger.warning(f"{self._task_name} already started")
92 return
94 logger.info(
95 f"started {self._task_name}: topic is '{self._topic}', interval is {self._interval} seconds"
96 )
97 self._task = asyncio.create_task(self._publish_task())
99 async def stop(self):
100 """Stops the publisher (cancels any running publishing tasks)"""
101 if self._task is not None:
102 self._task.cancel()
103 try:
104 await self._task
105 except asyncio.CancelledError:
106 pass
107 self._task = None
108 logger.info(f"cancelled {self._task_name} to topic: {self._topic}")
110 async def wait_until_done(self):
111 await self._task
113 async def _publish_task(self):
114 while True:
115 await asyncio.sleep(self._interval)
116 logger.info(
117 f"{self._task_name}: publishing message on topic '{self._topic}', next publish is scheduled in {self._interval} seconds"
118 )
119 async with self._publisher:
120 await self._publisher.publish(topics=[self._topic], data=self._message)
123class ServerSideTopicPublisher(TopicPublisher):
124 """A simple wrapper around a PubSubEndpoint that exposes publish()."""
126 def __init__(self, endpoint: PubSubEndpoint):
127 """Inits the publisher.
129 Args:
130 endpoint (PubSubEndpoint): a pub/sub endpoint
131 """
132 self._endpoint = endpoint
133 super().__init__()
135 async def _publish_impl(self, topics: TopicList, data: Any = None):
136 with tracer.trace("topic_publisher.publish", resource=str(topics)):
137 await self._endpoint.publish(topics=topics, data=data)
139 async def publish(self, topics: TopicList, data: Any = None):
140 await self._add_task(asyncio.create_task(self._publish_impl(topics, data)))
143class ClientSideTopicPublisher(TopicPublisher):
144 """A simple wrapper around a PubSubClient that exposes publish().
146 Provides start() and stop() shortcuts that helps treat this client
147 as a separate "process" or task that runs in the background.
148 """
150 def __init__(self, client: PubSubClient, server_uri: str):
151 """Inits the publisher.
153 Args:
154 client (PubSubClient): a configured not-yet-started pub sub client
155 server_uri (str): the URI of the pub sub server we publish to
156 """
157 self._client = client
158 self._server_uri = server_uri
159 super().__init__()
161 def start(self):
162 """Starts the pub/sub client as a background asyncio task.
164 the client will attempt to connect to the pubsub server until
165 successful.
166 """
167 super().start()
168 self._client.start_client(f"{self._server_uri}")
170 async def stop(self):
171 """Stops the pubsub client, and cancels any publishing tasks."""
172 await self._client.disconnect()
173 await super().stop()
175 async def wait_until_done(self):
176 """When the publisher is a used as a context manager, this method waits
177 until the client is done (i.e: terminated) to prevent exiting the
178 context."""
179 return await self._client.wait_until_done()
181 async def publish(self, topics: TopicList, data: Any = None):
182 """Publish a message by launching a background task on the event loop.
184 Args:
185 topics (TopicList): a list of topics to publish the message to
186 data (Any): optional data to publish as part of the message
187 """
188 await self._add_task(
189 asyncio.create_task(self._publish(topics=topics, data=data))
190 )
192 async def _publish(self, topics: TopicList, data: Any = None) -> bool:
193 """Do not trigger directly, must be triggered via publish() in order to
194 run as a monitored background asyncio task."""
195 await self._client.wait_until_ready()
196 logger.info("Publishing to topics: {topics}", topics=topics)
197 return await self._client.publish(topics, data)
200class ScopedServerSideTopicPublisher(ServerSideTopicPublisher):
201 def __init__(self, endpoint: PubSubEndpoint, scope_id: str):
202 super().__init__(endpoint)
203 self._scope_id = scope_id
205 async def publish(self, topics: TopicList, data: Any = None):
206 scoped_topics = [f"{self._scope_id}:{topic}" for topic in topics]
207 logger.info("Publishing to topics: {topics}", topics=scoped_topics)
208 await super().publish(scoped_topics, data)