parallelazy 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- parallelazy/__init__.py +43 -0
- parallelazy/environment_manager.py +55 -0
- parallelazy/examples/__init__.py +0 -0
- parallelazy/examples/example_configuration.py +194 -0
- parallelazy/examples/example_count.py +84 -0
- parallelazy/examples/example_data_pipeline.py +226 -0
- parallelazy/examples/example_env_variables.py +158 -0
- parallelazy/examples/example_error_handling.py +266 -0
- parallelazy/examples/example_graceful_shutdown.py +139 -0
- parallelazy/examples/example_ml_training.py +280 -0
- parallelazy/examples/example_pubsub.py +137 -0
- parallelazy/examples/example_rpc_calls.py +182 -0
- parallelazy/examples/example_worker_pool.py +188 -0
- parallelazy/plugin.py +223 -0
- parallelazy/plugin_data.py +466 -0
- parallelazy/plugin_manager.py +617 -0
- parallelazy/plugin_rpc.py +511 -0
- parallelazy/plugin_transport.py +347 -0
- parallelazy/plugin_worker.py +700 -0
- parallelazy/tests/__init__.py +0 -0
- parallelazy/utils.py +71 -0
- parallelazy-0.0.1.dist-info/METADATA +17 -0
- parallelazy-0.0.1.dist-info/RECORD +26 -0
- parallelazy-0.0.1.dist-info/WHEEL +5 -0
- parallelazy-0.0.1.dist-info/licenses/LICENSE +339 -0
- parallelazy-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
import itertools
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Callable, Dict, Optional
|
|
6
|
+
import logging
|
|
7
|
+
from torch import multiprocessing as mp
|
|
8
|
+
import threading
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
10
|
+
from multiprocessing.connection import wait
|
|
11
|
+
from parallelazy.plugin.plugin_data import Channel, CommandType, Message, PendingRequest, Queue, Request, RequestCallback, RequestEnvGet, RequestEnvGetAll, RequestEnvSet, RequestEnvSubscribe, RequestEnvUnsubscribe, RequestEnvUpdate, RequestPluginCall, RequestPluginErrors, RequestPluginStart, RequestPluginStatus, RequestPluginStatusAll, RequestPluginStop, RequestTopicAnnounce, RequestTopicList, RequestTopicSubscribe, RequestTopicUnsubscribe, Response, Subscriber
|
|
12
|
+
from parallelazy.plugin.plugin_transport import sanitize_for_ipc
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MessageUID:
|
|
20
|
+
"""Thread-safe unique ID generator."""
|
|
21
|
+
def __init__(self, minimal=True):
|
|
22
|
+
self._counter = itertools.count()
|
|
23
|
+
self._lock = mp.Lock()
|
|
24
|
+
self.minimal = minimal
|
|
25
|
+
self._last = 0
|
|
26
|
+
|
|
27
|
+
def __next__(self) -> str:
|
|
28
|
+
with self._lock:
|
|
29
|
+
num = next(self._counter)
|
|
30
|
+
self._last = num
|
|
31
|
+
uid = f"{threading.get_ident()}_{num}"
|
|
32
|
+
if self.minimal: return uid
|
|
33
|
+
return f"{threading.current_thread().name}_{uid}"
|
|
34
|
+
|
|
35
|
+
def next(self) -> str: return self.__next__()
|
|
36
|
+
def current(self) -> str: return self._last
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PluginCoreRPC:
|
|
40
|
+
"""
|
|
41
|
+
Wraps bidirectional channel for communication between manager and plugin for a clean API interface:
|
|
42
|
+
- Environment variable access
|
|
43
|
+
- Pub/sub publishing
|
|
44
|
+
- Service invocation
|
|
45
|
+
- Manager state queries
|
|
46
|
+
|
|
47
|
+
Usage in plugin:
|
|
48
|
+
def on_start(self):
|
|
49
|
+
model = self.manager.get_env("model")
|
|
50
|
+
self.manager.set_env("training_status", "started")
|
|
51
|
+
self.manager.publish("training_topic", {"epoch": 1})
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def __init__(self, plugin_id: str, channel: Channel=None, timeout: float = 5.0):
|
|
56
|
+
"""
|
|
57
|
+
Initialize manager proxy.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
plugin_id: Plugin instance identifier
|
|
61
|
+
channel: Bidirectional channel for communication with manager
|
|
62
|
+
timeout: Request timeout in seconds
|
|
63
|
+
"""
|
|
64
|
+
self.plugin_id = plugin_id
|
|
65
|
+
self.channel = channel if channel is not None else Channel()
|
|
66
|
+
self.timeout = timeout
|
|
67
|
+
|
|
68
|
+
self.command_handlers: Dict[str, RequestCallback] = {}
|
|
69
|
+
|
|
70
|
+
self.uid = MessageUID()
|
|
71
|
+
self._running = True
|
|
72
|
+
self._shutdown = Queue()
|
|
73
|
+
|
|
74
|
+
# Request/response routing: cmd_id → Queue(maxsize=1)
|
|
75
|
+
self._pending: Dict[str, PendingRequest] = {}
|
|
76
|
+
self._pending_lock = threading.Lock()
|
|
77
|
+
|
|
78
|
+
# Reader thread routes Responses to reply slots
|
|
79
|
+
self._reader = threading.Thread(target=self._channel_listener, daemon=False)
|
|
80
|
+
self._reader.start()
|
|
81
|
+
|
|
82
|
+
# Persistent pool for executing incoming requests — avoids spawning
|
|
83
|
+
# a fresh OS thread per RPC call (was the source of the stutter).
|
|
84
|
+
self._executor = ThreadPoolExecutor(
|
|
85
|
+
max_workers=8, thread_name_prefix=f"rpc-{plugin_id}"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def shutdown(self):
|
|
89
|
+
"""Clean up resources."""
|
|
90
|
+
# Step 1: Signal reader to stop
|
|
91
|
+
self._running = False
|
|
92
|
+
self.signal_shutdown()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# Step 3: Wait for reader thread to exit
|
|
96
|
+
self._reader.join(timeout=2.0)
|
|
97
|
+
if self._reader.is_alive():
|
|
98
|
+
logger.warning("Reader thread did not exit cleanly")
|
|
99
|
+
|
|
100
|
+
# Step 3b: Stop accepting new work; let in-flight commands finish.
|
|
101
|
+
# wait=False keeps shutdown() from blocking indefinitely on a command
|
|
102
|
+
# handler that's still running — the pending-request wait loop below
|
|
103
|
+
# covers giving them time to complete.
|
|
104
|
+
self._executor.shutdown(wait=False, cancel_futures=False)
|
|
105
|
+
|
|
106
|
+
# Step 4: Wait for pending requests to be processed (with timeout)
|
|
107
|
+
timeout_start = time.time()
|
|
108
|
+
while self.has_pending_requests() and (time.time() - timeout_start) < 2.0:
|
|
109
|
+
logger.debug(f"Plugin {self.plugin_id} waiting for pending requests to complete...")
|
|
110
|
+
time.sleep(0.05)
|
|
111
|
+
|
|
112
|
+
self.channel.shutdown()
|
|
113
|
+
|
|
114
|
+
def signal_shutdown(self):
|
|
115
|
+
"""Wake the reader thread without polling."""
|
|
116
|
+
try:
|
|
117
|
+
self._shutdown.put(None)
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def forward(self, req: Request, wait=True) -> Any:
|
|
123
|
+
forward_req = req.copy()
|
|
124
|
+
|
|
125
|
+
# Forward the call to the target plugin and wait for response
|
|
126
|
+
remote_result = self.request(forward_req)
|
|
127
|
+
if not wait: return remote_result
|
|
128
|
+
|
|
129
|
+
remote_result = remote_result.wait()
|
|
130
|
+
if remote_result.error:
|
|
131
|
+
raise Exception(f"Error on forwarding {req}: {remote_result.error}")
|
|
132
|
+
return remote_result.result
|
|
133
|
+
|
|
134
|
+
def request(self, req: Request) -> PendingRequest:
|
|
135
|
+
"""
|
|
136
|
+
send a request
|
|
137
|
+
"""
|
|
138
|
+
if req.message_id is None:
|
|
139
|
+
req.message_id = self.uid.next()
|
|
140
|
+
if req.sender is None:
|
|
141
|
+
req.sender = self.plugin_id
|
|
142
|
+
|
|
143
|
+
pending_request = PendingRequest(req.message_id,)
|
|
144
|
+
with self._pending_lock:
|
|
145
|
+
self._pending[req.message_id] = pending_request
|
|
146
|
+
self.channel.send(req)
|
|
147
|
+
return pending_request
|
|
148
|
+
|
|
149
|
+
def respond(self, req: Request, result: Any = None, error: str = None) -> PendingRequest:
|
|
150
|
+
"""
|
|
151
|
+
send a response
|
|
152
|
+
"""
|
|
153
|
+
result = sanitize_for_ipc(result)
|
|
154
|
+
error = sanitize_for_ipc(error)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if not self._running:
|
|
158
|
+
logger.debug(f"Plugin {self.plugin_id} RPC is shutting down, response discarded")
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
error = str(error) if error else None
|
|
162
|
+
|
|
163
|
+
resp = Response(result=result, error=error)
|
|
164
|
+
resp.message_id = req.message_id
|
|
165
|
+
resp.sender = self.plugin_id
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
self.channel.send(resp)
|
|
169
|
+
except OSError:
|
|
170
|
+
# Channel is closed, likely during shutdown
|
|
171
|
+
logger.debug(f"Plugin {self.plugin_id} could not send response (channel closed)")
|
|
172
|
+
|
|
173
|
+
def has_pending_requests(self) -> bool:
|
|
174
|
+
"""Check if there are any pending requests."""
|
|
175
|
+
with self._pending_lock:
|
|
176
|
+
return len(self._pending) > 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _channel_listener(self) -> None:
|
|
180
|
+
"""
|
|
181
|
+
Background thread: drain incoming Responses and route to reply slots.
|
|
182
|
+
|
|
183
|
+
Puts each Response into its pending request's queue.
|
|
184
|
+
Warns on orphaned responses (request already timed out).
|
|
185
|
+
"""
|
|
186
|
+
while self._running:
|
|
187
|
+
try:
|
|
188
|
+
ready = wait([self.channel._receive._conn, self._shutdown.receiver._conn])
|
|
189
|
+
if self._shutdown.receiver._conn in ready:
|
|
190
|
+
self._shutdown.get()
|
|
191
|
+
logger.debug(f"Plugin {self.plugin_id} received shutdown sentinel on channel")
|
|
192
|
+
break
|
|
193
|
+
message = self.channel.receive()
|
|
194
|
+
if isinstance(message, Request):
|
|
195
|
+
self._handle_request(message)
|
|
196
|
+
elif isinstance(message, Response):
|
|
197
|
+
self._handle_response(message)
|
|
198
|
+
else:
|
|
199
|
+
logger.error(f"Received unknown message type: {message}")
|
|
200
|
+
except OSError:
|
|
201
|
+
# Channel closed, exit cleanly
|
|
202
|
+
break
|
|
203
|
+
except Exception as e:
|
|
204
|
+
if self._running:
|
|
205
|
+
logger.error(f"Response router error: {e}", exc_info=True)
|
|
206
|
+
else:
|
|
207
|
+
# Expected during shutdown
|
|
208
|
+
break
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _handle_request(self, req: Request) -> None:
|
|
213
|
+
"""Dispatch request to appropriate handler."""
|
|
214
|
+
try:
|
|
215
|
+
cmd = str(req.command)
|
|
216
|
+
if cmd not in CommandType.commands():
|
|
217
|
+
raise ValueError(f"Unknown command: {cmd}")
|
|
218
|
+
if cmd not in self.command_handlers:
|
|
219
|
+
raise ValueError(f"Command not registered: {cmd} (available: {list(self.command_handlers.keys())})")
|
|
220
|
+
|
|
221
|
+
self._executor.submit(self._execute_command, req)
|
|
222
|
+
except Exception as e:
|
|
223
|
+
logger.error(f"Request handler error: {e}", exc_info=True)
|
|
224
|
+
self.respond(req, error=e)
|
|
225
|
+
|
|
226
|
+
def _handle_response(self, resp: Response) -> bool:
|
|
227
|
+
"""Route incoming response to pending request."""
|
|
228
|
+
with self._pending_lock:
|
|
229
|
+
pending_request = self._pending.pop(resp.message_id, None)
|
|
230
|
+
|
|
231
|
+
if pending_request is None:
|
|
232
|
+
logger.error(f"Received response for unknown message_id={resp.message_id} {resp.__dict__()}")
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
pending_request.set_result(resp)
|
|
236
|
+
return True
|
|
237
|
+
|
|
238
|
+
def _execute_command(self, req: Request) -> Any:
|
|
239
|
+
"""
|
|
240
|
+
Execute a command and wait for response.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
command: Command name
|
|
244
|
+
*args: Positional arguments
|
|
245
|
+
**kwargs: Keyword arguments
|
|
246
|
+
"""
|
|
247
|
+
cmd = str(req.command)
|
|
248
|
+
if cmd not in self.command_handlers:
|
|
249
|
+
raise ValueError(f"Command not registered: {cmd} (available: {list(self.command_handlers.keys())})")
|
|
250
|
+
|
|
251
|
+
handler = self.command_handlers[cmd]
|
|
252
|
+
result = None
|
|
253
|
+
error = None
|
|
254
|
+
try:
|
|
255
|
+
result = handler(self, req)
|
|
256
|
+
except Exception as e:
|
|
257
|
+
import traceback
|
|
258
|
+
stacktrace = traceback.format_exc()
|
|
259
|
+
error = str(e)
|
|
260
|
+
logger.error(f"Error occurred while executing command '{cmd}': {e}\n{stacktrace}", exc_info=True)
|
|
261
|
+
|
|
262
|
+
self.respond(req, result=result, error=error)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def on_request(self, command_name: CommandType, func: RequestCallback) -> None:
|
|
266
|
+
"""
|
|
267
|
+
Register a command handler for an incoming message
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
command_name: Name of the command
|
|
271
|
+
func: Function to handle the command
|
|
272
|
+
"""
|
|
273
|
+
cmd = str(command_name)
|
|
274
|
+
self.command_handlers[cmd] = func
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class PluginLifeCycleRPC(PluginCoreRPC):
|
|
278
|
+
"""Convience class with manager-specific commands pre-registered.
|
|
279
|
+
PLUGIN_START = "plugin_start"
|
|
280
|
+
PLUGIN_STOP = "plugin_stop"
|
|
281
|
+
PLUGIN_STATUS = "plugin_status"
|
|
282
|
+
PLUGIN_STATUS_ALL = "plugin_status_all"
|
|
283
|
+
PLUGIN_ERRORS = "plugin_errors"
|
|
284
|
+
PLUGIN_CALL = "plugin_call"
|
|
285
|
+
"""
|
|
286
|
+
def plugin_start(self):
|
|
287
|
+
"""
|
|
288
|
+
Start the plugin process.
|
|
289
|
+
"""
|
|
290
|
+
try:
|
|
291
|
+
return self.request(RequestPluginStart())
|
|
292
|
+
except Exception as e:
|
|
293
|
+
logger.error(f"Failed to start plugin: {e}")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def plugin_stop(self) :
|
|
297
|
+
"""
|
|
298
|
+
Stop the plugin process.
|
|
299
|
+
"""
|
|
300
|
+
try:
|
|
301
|
+
return self.request(RequestPluginStop())
|
|
302
|
+
except Exception as e:
|
|
303
|
+
logger.error(f"Failed to stop plugin: {e}")
|
|
304
|
+
|
|
305
|
+
def plugin_call(self, plugin_id: str, method_name: str, *args, **kwargs):
|
|
306
|
+
"""
|
|
307
|
+
Call a method on the plugin.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
plugin_id: ID of the plugin to call
|
|
311
|
+
method_name: Name of the method to call
|
|
312
|
+
*args: Positional arguments
|
|
313
|
+
**kwargs: Keyword arguments
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
Method return value
|
|
317
|
+
"""
|
|
318
|
+
try:
|
|
319
|
+
return self.request(RequestPluginCall(plugin_id=plugin_id, method_name=method_name, args=args, kwargs=kwargs))
|
|
320
|
+
except Exception as e:
|
|
321
|
+
logger.error(f"Failed to call plugin method '{method_name}': {e}")
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# ========================================================================
|
|
326
|
+
# STATUS: Query manager state
|
|
327
|
+
# ========================================================================
|
|
328
|
+
|
|
329
|
+
def plugin_status(self, plugin_id: str=None) -> PendingRequest | None:
|
|
330
|
+
"""
|
|
331
|
+
Get status of another plugin.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
plugin_id: Plugin identifier
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
Status dict or None if not found
|
|
338
|
+
"""
|
|
339
|
+
try:
|
|
340
|
+
return self.request(RequestPluginStatus(plugin_id=plugin_id))
|
|
341
|
+
except Exception as e:
|
|
342
|
+
logger.debug(f"Failed to get status for {plugin_id}: {e}")
|
|
343
|
+
import traceback
|
|
344
|
+
logger.debug(traceback.format_exc())
|
|
345
|
+
|
|
346
|
+
def plugin_status_all(self):
|
|
347
|
+
"""
|
|
348
|
+
List all loaded plugins and their status.
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
Dictionary of plugin_id -> status info
|
|
352
|
+
"""
|
|
353
|
+
try:
|
|
354
|
+
return self.request(RequestPluginStatusAll())
|
|
355
|
+
except Exception as e:
|
|
356
|
+
logger.error(f"Failed to list plugins: {e}")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def plugin_errors(self, plugin_id:str = None):
|
|
360
|
+
"""
|
|
361
|
+
Get any errors from the manager or other plugins.
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
Dictionary of plugin_id -> error info
|
|
365
|
+
"""
|
|
366
|
+
try:
|
|
367
|
+
return self.request(RequestPluginErrors(plugin_id=plugin_id))
|
|
368
|
+
except Exception as e:
|
|
369
|
+
logger.error(f"Failed to get plugin errors: {e}")
|
|
370
|
+
return {}
|
|
371
|
+
|
|
372
|
+
class PluginEnvRPC(PluginCoreRPC):
|
|
373
|
+
"""Convience class with plugin-specific commands pre-registered."""
|
|
374
|
+
|
|
375
|
+
# ========================================================================
|
|
376
|
+
# ENVIRONMENT VARIABLES
|
|
377
|
+
# ========================================================================
|
|
378
|
+
|
|
379
|
+
def env_get(self, var_name: str):
|
|
380
|
+
"""
|
|
381
|
+
Get environment variable from manager.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
key: Variable key
|
|
385
|
+
default: Default value if not found
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
Variable value
|
|
389
|
+
"""
|
|
390
|
+
try:
|
|
391
|
+
return self.request(RequestEnvGet(name=var_name))
|
|
392
|
+
except Exception as e:
|
|
393
|
+
logger.error(f"Failed to get env variable '{var_name}': {e}")
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def env_set(self, var_name: str, value: Any):
|
|
397
|
+
"""
|
|
398
|
+
Set environment variable in manager (notifies subscribed plugins).
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
key: Variable key
|
|
402
|
+
value: Variable value
|
|
403
|
+
"""
|
|
404
|
+
try:
|
|
405
|
+
value = sanitize_for_ipc(value)
|
|
406
|
+
return self.request(RequestEnvSet(name=var_name, value=value))
|
|
407
|
+
except Exception as e:
|
|
408
|
+
logger.error(f"Failed to set env variable '{var_name}': {e}")
|
|
409
|
+
|
|
410
|
+
def env_get_all(self):
|
|
411
|
+
"""
|
|
412
|
+
Get all environment variables.
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
Dictionary of all env variables
|
|
416
|
+
"""
|
|
417
|
+
try:
|
|
418
|
+
return self.request(RequestEnvGetAll())
|
|
419
|
+
except Exception as e:
|
|
420
|
+
logger.error(f"Failed to get all env variables: {e}")
|
|
421
|
+
return {}
|
|
422
|
+
|
|
423
|
+
def env_subscribe(self, var_name: str):
|
|
424
|
+
"""
|
|
425
|
+
Subscribe to an environment variable.
|
|
426
|
+
|
|
427
|
+
"""
|
|
428
|
+
try:
|
|
429
|
+
return self.request(RequestEnvSubscribe(name=var_name))
|
|
430
|
+
except Exception as e:
|
|
431
|
+
logger.error(f"Failed to subscribe to env variable '{var_name}': {e}")
|
|
432
|
+
return {}
|
|
433
|
+
|
|
434
|
+
def env_unsubscribe(self, var_name: str):
|
|
435
|
+
"""
|
|
436
|
+
Unsubscribe from an environment variable.
|
|
437
|
+
|
|
438
|
+
"""
|
|
439
|
+
try:
|
|
440
|
+
return self.request(RequestEnvUnsubscribe(name=var_name))
|
|
441
|
+
except Exception as e:
|
|
442
|
+
logger.error(f"Failed to unsubscribe from env variable '{var_name}': {e}")
|
|
443
|
+
|
|
444
|
+
def env_update(self, var_name: str) :
|
|
445
|
+
"""
|
|
446
|
+
Update an environment variable.
|
|
447
|
+
|
|
448
|
+
"""
|
|
449
|
+
try:
|
|
450
|
+
return self.request(RequestEnvUpdate(name=var_name))
|
|
451
|
+
except Exception as e:
|
|
452
|
+
logger.error(f"Failed to notify update env variable '{var_name}': {e}")
|
|
453
|
+
|
|
454
|
+
class PluginTopicRPC(PluginCoreRPC):
|
|
455
|
+
# ========================================================================
|
|
456
|
+
# PUB/SUB: Publishing
|
|
457
|
+
# ========================================================================
|
|
458
|
+
|
|
459
|
+
def topic_announce(self, topic: str):
|
|
460
|
+
"""
|
|
461
|
+
Announce a topic.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
topic: Topic name
|
|
465
|
+
"""
|
|
466
|
+
try:
|
|
467
|
+
return self.request(RequestTopicAnnounce(topic=topic))
|
|
468
|
+
except Exception as e:
|
|
469
|
+
logger.error(f"Failed to announce topic '{topic}': {e}")
|
|
470
|
+
|
|
471
|
+
def topic_subscribe(self, subscriber: Subscriber):
|
|
472
|
+
"""
|
|
473
|
+
Subscribe to a topic.
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
topic: Topic name
|
|
477
|
+
callback: Function to call when a message is received
|
|
478
|
+
"""
|
|
479
|
+
try:
|
|
480
|
+
return self.request(RequestTopicSubscribe(subscriber=subscriber))
|
|
481
|
+
except Exception as e:
|
|
482
|
+
logger.error(f"Failed to subscribe to '{subscriber}': {e}")
|
|
483
|
+
|
|
484
|
+
def topic_unsubscribe(self, subscriber: Subscriber):
|
|
485
|
+
"""
|
|
486
|
+
Unsubscribe from a topic.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
topic: Topic name
|
|
490
|
+
callback: Function to remove from subscribers
|
|
491
|
+
"""
|
|
492
|
+
try:
|
|
493
|
+
return self.request(RequestTopicUnsubscribe(subscriber=subscriber))
|
|
494
|
+
except Exception as e:
|
|
495
|
+
logger.error(f"Failed to unsubscribe from '{subscriber}': {e}")
|
|
496
|
+
|
|
497
|
+
def topic_list(self, plugin_id: str=None):
|
|
498
|
+
"""
|
|
499
|
+
List all published topics.
|
|
500
|
+
Returns:
|
|
501
|
+
List of topic names
|
|
502
|
+
"""
|
|
503
|
+
try:
|
|
504
|
+
return self.request(RequestTopicList(plugin_id=plugin_id))
|
|
505
|
+
except Exception as e:
|
|
506
|
+
logger.error(f"Failed to list topics: {e}")
|
|
507
|
+
|
|
508
|
+
class PluginRPC(PluginLifeCycleRPC, PluginEnvRPC, PluginTopicRPC):
|
|
509
|
+
"""Convience class with all command categories pre-registered."""
|
|
510
|
+
pass
|
|
511
|
+
|