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.
@@ -0,0 +1,700 @@
1
+ """
2
+ Plugin Worker: Subprocess handler for plugin execution.
3
+
4
+ PluginWorker.run() is the entry point for each plugin subprocess.
5
+ It creates plugin instance, handles incoming requests, and routes to handlers.
6
+
7
+ Command dispatch:
8
+ - All incoming requests routed via @command_handler decorator
9
+ - Services invoked via reflection (self.plugin.method_name(*args, **kwargs))
10
+ - No manual if/elif chains
11
+ """
12
+
13
+
14
+ import logging
15
+ import importlib
16
+ from queue import Full
17
+ import threading
18
+ import time
19
+
20
+
21
+ from parallelazy.utils.utils import init_debugger, process_start_spawn
22
+ from parallelazy.plugin.plugin_data import Channel, CommandType, Queue,QueueDescriptor, PendingRequest, PluginStatus, RequestPluginErrors, RequestPluginStart, RequestPluginStatus, RequestPluginStop, RequestTopicList, Subscriber, Subscription
23
+ from parallelazy.plugin.plugin_base import Plugin, PluginDescriptor, PluginState
24
+
25
+ from parallelazy.plugin.plugin_rpc import PluginRPC, RequestEnvUpdate, RequestPluginCall, RequestTopicList, RequestTopicSubscribe, RequestTopicUnsubscribe, sanitize_for_ipc
26
+ from multiprocessing.synchronize import Event
27
+
28
+
29
+ #typechecking imports
30
+ from typing import Any, Callable, Dict, Optional, TYPE_CHECKING
31
+
32
+
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # ============================================================================
37
+ # PLUGIN WORKER
38
+ # ============================================================================
39
+
40
+ process_start_spawn()
41
+
42
+
43
+
44
+
45
+ class PluginWorker:
46
+ """
47
+ Subprocess worker for running a plugin.
48
+
49
+ PluginWorker.run() is called in a new subprocess and:
50
+ 1. Instantiates the plugin
51
+ 2. Runs an event loop waiting for requests
52
+ 3. Routes requests to command handlers
53
+ 4. Sends responses back
54
+ """
55
+
56
+ @staticmethod
57
+ def run(
58
+ plugin_id: str,
59
+ descriptor_dict: Dict[str, Any],
60
+ options: Dict[str, Any],
61
+ queue_descriptor: QueueDescriptor,
62
+ initialized: Event,
63
+ pydebug: bool = False
64
+ ) -> None:
65
+ """
66
+ Entry point for plugin subprocess.
67
+
68
+ Args:
69
+ plugin_id: Plugin instance identifier
70
+ descriptor_dict: Serialized plugin descriptor
71
+ options: Plugin options
72
+ queue_descriptor: Queue descriptor for communication
73
+ initialized: Event to signal initialization completion
74
+ """
75
+ if pydebug:
76
+ logger.setLevel(logging.DEBUG)
77
+ init_debugger(plugin_id)
78
+
79
+ worker = PluginWorker(
80
+ plugin_id,
81
+ descriptor_dict,
82
+ options,
83
+ queue_descriptor,
84
+ initialized
85
+ )
86
+ worker.main()
87
+
88
+ def __init__(
89
+ self,
90
+ plugin_id: str,
91
+ descriptor_dict: Dict[str, Any],
92
+ options: Dict[str, Any],
93
+ queue_descriptor: QueueDescriptor,
94
+ initialized: Event
95
+ ):
96
+ """Initialize worker."""
97
+
98
+ self.plugin_id = plugin_id
99
+ self.descriptor = PluginDescriptor.from_dict(descriptor_dict)
100
+ self.options = options
101
+ self._is_running = False
102
+ self._initialized: Event = initialized
103
+
104
+ self.did_start = threading.Event()
105
+ self.did_start.clear()
106
+
107
+ self.did_stop = threading.Event()
108
+ self.did_stop.clear()
109
+
110
+ self.can_shutdown = threading.Event()
111
+ self.can_shutdown.clear()
112
+
113
+ # Topic subscriptions: topic_name -> queue
114
+
115
+ self.topic_published = set()
116
+ self.topic_subscriptions: Dict[str, Subscription] = {}
117
+ self.topic_subscribers: Dict[str, Dict[str,Subscriber]] = {}
118
+ # Topic subscriptions: topic_name -> queue
119
+ self.env_subscriptions: Dict[str, Callable] = {}
120
+
121
+ # Error log
122
+ self.error_log: list = []
123
+ # Instantiate the plugin
124
+ self.plugin: Optional[Plugin] = None
125
+
126
+
127
+ self.main_thread: Optional[threading.Thread] = None
128
+
129
+ # Create manager proxy for direct manager access+
130
+ channel = Channel(queue_descriptor)
131
+ self.rpc = PluginRPC(self.plugin_id, channel)
132
+
133
+ self._init_plugin()
134
+ self._init_rpc()
135
+
136
+
137
+
138
+ ## ========================================================================
139
+ # Worker Main Loop
140
+ # ========================================================================
141
+
142
+ def main(self) -> None:
143
+ """Main event loop: wait for requests and topic messages."""
144
+ logger.info(f"Plugin {self.plugin_id} worker started")
145
+ cmds = list(self.rpc.command_handlers.keys())
146
+ logger.info(f"Plugin {self.plugin_id} registered commands: {cmds}")
147
+
148
+
149
+ self._initialized.set() # Signal to manager that initialization is complete
150
+ self._is_running = True
151
+ self.did_start.wait()
152
+
153
+ self.main_thread = threading.Thread(target=self._plugin_main, daemon=True)
154
+ self.main_thread.start()
155
+
156
+ self.did_stop.wait()
157
+
158
+
159
+ logger.info(f"Plugin {self.plugin_id} worker stopped")
160
+
161
+ self.shutdown()
162
+
163
+ def _plugin_main(self):
164
+ logger.debug(f"[{self.plugin_id}] Plugin main loop started")
165
+ min_interval = 0.001 # floor between iterations; tune per-plugin if needed
166
+
167
+ while self._is_running:
168
+ start = time.perf_counter()
169
+ try:
170
+ self.plugin.main()
171
+ except KeyboardInterrupt:
172
+ logger.info(f"Plugin {self.plugin_id} interrupted")
173
+ self._is_running = False
174
+ except Exception as e:
175
+ logger.error(f"Plugin {self.plugin_id} error: {e}", exc_info=True)
176
+ import traceback
177
+ traceback.print_exc()
178
+ self.error_log.append(str(e))
179
+
180
+ elapsed = time.perf_counter() - start
181
+ remaining = min_interval - elapsed
182
+ if remaining > 0:
183
+ time.sleep(remaining)
184
+ # else: plugin.main() already took longer than min_interval,
185
+ # so we don't add any extra pause — go straight to the next call.
186
+
187
+
188
+ def start(self):
189
+ """Start the plugin."""
190
+ try:
191
+ self.plugin.on_start()
192
+ self.plugin._is_running = True
193
+ self.plugin.state = PluginState.RUNNING
194
+ self.did_start.set()
195
+ except Exception as e:
196
+ logger.error(f"Failed to start plugin: {e}")
197
+ raise
198
+
199
+ def stop(self):
200
+ """Stop the plugin."""
201
+ logger.debug(f"[{self.plugin_id}] Stopping plugin...")
202
+ try:
203
+ self.plugin.on_stop()
204
+ except Exception as e:
205
+ logger.error(f"Failed to stop plugin: {e}")
206
+
207
+ self._is_running = False
208
+ self.state = PluginState.STOPPED
209
+ self.rpc.signal_shutdown()
210
+
211
+
212
+ for topic, subscription in list(self.topic_subscriptions.items()):
213
+ try:
214
+ subscription.queue.put(None) # Unblock any waiting get() calls
215
+ except Exception as e:
216
+ logger.error(f"Error closing queue for topic '{topic}': {e}")
217
+
218
+ self.did_stop.set()
219
+ logger.debug(f"[{self.plugin_id}] Plugin stopped successfully.")
220
+
221
+ def shutdown(self) -> None:
222
+ """Clean up resources on shutdown."""
223
+ logger.info(f"Plugin {self.plugin_id} shutting down")
224
+ self._is_running = False
225
+ self.state = PluginState.STOPPED
226
+ try:
227
+ self.rpc.shutdown()
228
+ except Exception as e:
229
+ logger.error(f"Error closing RPC: {e}")
230
+
231
+
232
+
233
+
234
+
235
+
236
+ ## ========================================================================
237
+ # Initialization
238
+ # ========================================================================
239
+
240
+ def _init_plugin(self) -> None:
241
+ """Instantiate plugin from descriptor."""
242
+ try:
243
+ # Import plugin class
244
+
245
+ module = importlib.import_module(self.descriptor.package)
246
+ plugin_class: Plugin = getattr(module, self.descriptor.class_name)
247
+
248
+
249
+ # Instantiate plugin with proxy
250
+ self.plugin = plugin_class(self, self.plugin_id, self.options )
251
+ logger.info(f"Plugin {self.plugin_id} instantiated")
252
+ except Exception as e:
253
+ logger.error(f"Failed to init plugin {self.plugin_id}: {e}")
254
+ raise
255
+
256
+ def _init_rpc(self) -> None:
257
+ """Initialize command handlers from plugin methods.
258
+
259
+ + on request
260
+ > method
261
+
262
+
263
+ + PLUGIN_START = "plugin_start"
264
+ + PLUGIN_STOP = "plugin_stop"
265
+ + PLUGIN_STATUS = "plugin_status"
266
+ - PLUGIN_STATUS_ALL = "plugin_status_all"
267
+ + PLUGIN_ERRORS = "plugin_errors"
268
+ > PLUGIN_CALL = "plugin_call"
269
+
270
+ > ENV_SET = "env_set"
271
+ > ENV_GET = "env_get"
272
+ > ENV_GET_ALL = "env_get_all"
273
+ > ENV_SUBSCRIBE = "env_subscribe"
274
+ > ENV_UNSUBSCRIBE = "env_unsubscribe"
275
+ + ENV_UPDATE = "env_update"
276
+
277
+ * TOPIC_SUBSCRIBE = "topic_subscribe"
278
+ * TOPIC_UNSUBSCRIBE = "topic_unsubscribe"
279
+ * TOPIC_PUBLISH = "topic_publish"
280
+ * TOPIC_LIST = "topic_list"
281
+ """
282
+ try:
283
+ self.rpc.on_request(CommandType.PLUGIN_START, self._respond_start)
284
+ self.rpc.on_request(CommandType.PLUGIN_STOP, self._respond_stop)
285
+ self.rpc.on_request(CommandType.PLUGIN_CALL, self._respond_call)
286
+ self.rpc.on_request(CommandType.PLUGIN_STATUS, self._respond_status)
287
+ self.rpc.on_request(CommandType.PLUGIN_ERRORS, self._respond_errors)
288
+
289
+ self.rpc.on_request(CommandType.ENV_UPDATE, self._respond_env_update)
290
+
291
+ self.rpc.on_request(CommandType.TOPIC_SUBSCRIBE, self._respond_topic_subscribe)
292
+ self.rpc.on_request(CommandType.TOPIC_UNSUBSCRIBE, self._respond_topic_unsubscribe)
293
+ self.rpc.on_request(CommandType.TOPIC_LIST, self._request_topic_list)
294
+
295
+ except Exception as e:
296
+ logger.error(f"Failed to init RPC: {e}")
297
+ raise
298
+
299
+
300
+
301
+
302
+ # ========================================================================
303
+ # Commands to send
304
+ # ========================================================================
305
+
306
+ """Commands that plugin can send to manager or other plugins.
307
+ + PLUGIN_START = "plugin_start"
308
+ + PLUGIN_STOP = "plugin_stop"
309
+ + PLUGIN_STATUS = "plugin_status"
310
+ - PLUGIN_STATUS_ALL = "plugin_status_all"
311
+ + PLUGIN_ERRORS = "plugin_errors"
312
+ > PLUGIN_CALL = "plugin_call"
313
+ """
314
+ def plugin_call(self, plugin_id: str, method_name: str, *args, wait: bool=True, **kwargs) -> PendingRequest|Any:
315
+ """Invoke a method on the plugin via RPC."""
316
+ pending: PendingRequest = self.rpc.plugin_call(plugin_id, method_name, *args, **kwargs)
317
+ if not wait: return pending
318
+
319
+ # Wait for response if no callback provided
320
+ response = pending.wait()
321
+ if response.error:
322
+ logger.error(f"Error calling plugin method '{method_name}': {response.error}")
323
+ self.error_log.append(str(response.error))
324
+ return None
325
+ return response.result
326
+
327
+
328
+ """Commands that plugin can send to manager or other plugins.
329
+ > ENV_SET = "env_set"
330
+ > ENV_GET = "env_get"
331
+ > ENV_GET_ALL = "env_get_all"
332
+ > ENV_SUBSCRIBE = "env_subscribe"
333
+ > ENV_UNSUBSCRIBE = "env_unsubscribe"
334
+ """
335
+ def env_set(self, var_name: str, value: Any, wait: bool=True) -> PendingRequest|bool:
336
+ """Set an environment variable."""
337
+ pending: PendingRequest = self.rpc.env_set( var_name, value)
338
+ if not wait: return pending
339
+
340
+ # Wait for response if no callback provided
341
+ response = pending.wait()
342
+ if response.error:
343
+ logger.error(f"Error setting env variable: {response.error}")
344
+ self.error_log.append(str(response.error))
345
+ return False
346
+ return response.result
347
+
348
+ def env_get(self, var_name: str, wait: bool=True) -> PendingRequest|Any:
349
+ """Get an environment variable."""
350
+ pending: PendingRequest = self.rpc.env_get(var_name)
351
+ if not wait: return pending
352
+
353
+ # Wait for response if no callback provided
354
+ response = pending.wait()
355
+ if response.error:
356
+ logger.error(f"Error getting env variable: {response.error}")
357
+ self.error_log.append(str(response.error))
358
+ return None
359
+ return response.result
360
+
361
+ def env_get_all(self, wait: bool=True) -> PendingRequest|dict:
362
+ """Get all environment variables."""
363
+ pending: PendingRequest = self.rpc.env_get_all()
364
+ if not wait: return pending
365
+
366
+ # Wait for response if no callback provided
367
+ response = pending.wait()
368
+ if response.error:
369
+ logger.error(f"Error getting all env variables: {response.error}")
370
+ self.error_log.append(str(response.error))
371
+ return None
372
+ return response.result
373
+
374
+ def env_subscribe(self, var_name: str, callback: Callable) -> bool:
375
+ """Get all environment variables."""
376
+
377
+ pending: PendingRequest = self.rpc.env_subscribe(var_name)
378
+
379
+ # Wait for response if no callback provided
380
+ response = pending.wait()
381
+ if response.error:
382
+ logger.error(f"Error getting all env variables: {response.error}")
383
+ self.error_log.append(str(response.error))
384
+ return False
385
+
386
+ self.env_subscriptions[var_name] = callback
387
+
388
+
389
+ callback(var_name) # Trigger callback for initial value
390
+
391
+ return True
392
+
393
+ def env_unsubscribe(self, var_name: str, wait: bool=True) -> bool:
394
+ """Get all environment variables."""
395
+ pending: PendingRequest = self.rpc.env_unsubscribe(var_name)
396
+
397
+ # Wait for response if no callback provided
398
+ response = pending.wait()
399
+ if response.error:
400
+ logger.error(f"Error getting all env variables: {response.error}")
401
+ self.error_log.append(str(response.error))
402
+ return False
403
+
404
+ if var_name in self.env_subscriptions:
405
+ del self.env_subscriptions[var_name]
406
+ return True
407
+
408
+
409
+ """
410
+ Commands that plugin can send to manager or other plugins.
411
+ * TOPIC_SUBSCRIBE = "topic_subscribe"
412
+ * TOPIC_UNSUBSCRIBE = "topic_unsubscribe"
413
+ * TOPIC_ANNOUNCE = "topic_announce"
414
+ * TOPIC_LIST = "topic_list"
415
+ """
416
+
417
+ def topic_subscribe(self, topic: str, callback: Callable) -> bool:
418
+ """Get all environment variables."""
419
+ subscription = Subscription(topic, callback)
420
+
421
+ desc = subscription.queue.descriptor()
422
+ subscriber = Subscriber(self.plugin_id, topic, desc )
423
+
424
+
425
+ pending: PendingRequest = self.rpc.topic_subscribe(subscriber)
426
+ subscriber.queue = Queue( desc )
427
+ # Wait for response if no callback provided
428
+ response = pending.wait()
429
+ if response.error:
430
+ logger.error(f"Error subscribing to topic '{topic}': {response.error}")
431
+ self.error_log.append(str(response.error))
432
+ return False
433
+ if response.result:
434
+ subscription.thread = threading.Thread(target=self._topic_listener, args=(topic,), daemon=True)
435
+ self.topic_subscriptions[topic] = subscription
436
+ subscription.thread.start()
437
+ return response.result
438
+
439
+ def topic_unsubscribe(self, topic: str) -> bool:
440
+ """Get all environment variables."""
441
+ if not topic in self.topic_subscriptions:
442
+ logger.warning(f"Plugin {self.plugin_id} not subscribed to topic '{topic}'")
443
+ return True
444
+
445
+ subscription = self.topic_subscriptions[topic]
446
+ desc = subscription.queue.descriptor()
447
+ subscriber = Subscriber(self.plugin_id, topic, desc )
448
+
449
+
450
+ pending: PendingRequest = self.rpc.topic_unsubscribe(subscriber)
451
+ # Wait for response if no callback provided
452
+ response = pending.wait()
453
+ if response.error:
454
+ logger.error(f"Error unsubscribing to topic '{topic}': {response.error}")
455
+ self.error_log.append(str(response.error))
456
+ return False
457
+
458
+ if response.result:
459
+ del self.topic_subscriptions[topic]
460
+ subscription.queue.close()
461
+ subscription.thread.join(timeout=1.0)
462
+
463
+
464
+ return response.result
465
+
466
+
467
+ def topic_list(self, wait: bool=True) -> PendingRequest|list:
468
+ """Get all environment variables."""
469
+ pending: PendingRequest = self.rpc.topic_list()
470
+ if not wait: return pending
471
+
472
+ # Wait for response if no callback provided
473
+ response = pending.wait()
474
+ if response.error:
475
+ logger.error(f"Error getting all env variables: {response.error}")
476
+ self.error_log.append(str(response.error))
477
+ return None
478
+ return response.result
479
+
480
+
481
+ def topic_announce(self, topic: str) -> None:
482
+ """ Announce a topic to manager and sync subscribers."""
483
+ # first time publishing this topic
484
+ if topic in self.topic_published: return True
485
+
486
+ if not topic in self.topic_subscribers:
487
+ self.topic_subscribers[topic] = {}
488
+
489
+ topic_subscribers: dict[str, Subscriber] = self.topic_subscribers.get(topic)
490
+ reponse = self.rpc.topic_announce(topic).wait()
491
+ if reponse.error:
492
+ logger.error(f"Error registering topic '{topic}': {reponse.error}")
493
+ self.error_log.append(str(reponse.error))
494
+ return False
495
+
496
+
497
+ self.topic_published.add(topic)
498
+
499
+ manager_subscribers: Dict[str, Subscriber] = reponse.result
500
+
501
+
502
+ diff_subs = []
503
+ for sub in manager_subscribers.values():
504
+ if sub.plugin_id not in topic_subscribers:
505
+ sub.queue = Queue(sub.queue_descriptor)
506
+ diff_subs.append(sub)
507
+ topic_subscribers[sub.plugin_id] = sub
508
+
509
+ topic_subscribers.update(manager_subscribers)
510
+ #print("topic_announce: manager_subscribers: ", self.topic_published, manager_subscribers )
511
+
512
+
513
+ return True
514
+
515
+ def topic_publish(self, topic: str, data: Any) -> None:
516
+ data = sanitize_for_ipc(data)
517
+
518
+ self.topic_announce(topic)
519
+
520
+ topic_subscribers = self.topic_subscribers.get(topic, None)
521
+ if topic_subscribers is None: return
522
+
523
+
524
+ #print("topic_publish: topic_subscribers: ",self.topic_subscribers)
525
+
526
+ for subscriber in topic_subscribers.values():
527
+ #print("----")
528
+
529
+ try:
530
+ subscriber.queue.put_nowait(data)
531
+ except Full:
532
+ logger.warning(f"Subscriber {subscriber.plugin_id} on topic '{topic}' is full — dropping message")
533
+ return False
534
+ except Exception as e:
535
+ logger.error(f"[ERROR] Failed to publish to subscriber {subscriber.plugin_id} on topic '{topic}':", exc_info=True)
536
+ logger.error(f"[ERROR]\n{e}\n---")
537
+ return False
538
+
539
+ return True
540
+
541
+
542
+
543
+ # ========================================================================
544
+ # On Command Recievd
545
+ # ========================================================================
546
+
547
+ def _respond_start(self, rpc: PluginRPC, req: RequestPluginStart) -> bool:
548
+ """Start plugin."""
549
+
550
+ try:
551
+ self.start()
552
+ return True
553
+ except Exception as e:
554
+ logger.error(f"Failed to start plugin: {e}")
555
+ raise
556
+
557
+ def _respond_stop(self, rpc: PluginRPC, req: RequestPluginStop) -> bool:
558
+ """Stop plugin."""
559
+ try:
560
+ self.stop()
561
+ return True
562
+ except Exception as e:
563
+ logger.error(f"Failed to stop plugin: {e}")
564
+ raise
565
+
566
+
567
+ def _respond_status(self, rpc: PluginRPC, req: RequestPluginStatus) -> Dict[str, Any]:
568
+ """Get plugin status."""
569
+ num_subscription = len(self.topic_subscriptions.keys())
570
+ num_subscribers = sum(len(subs) for subs in self.topic_subscribers.values())
571
+ num_published = len(self.topic_published)
572
+ num_errors = len(self.error_log)
573
+
574
+ status = PluginStatus(
575
+ plugin_id=self.plugin_id,
576
+ state=str(self.plugin.state),
577
+ is_running=self.plugin.is_running,
578
+ subscriptions=num_subscription,
579
+ subscribers=num_subscribers,
580
+ published=num_published,
581
+ errors=num_errors
582
+ )
583
+ return status
584
+
585
+ def _respond_errors(self, rpc: PluginRPC, req: RequestPluginErrors) -> list:
586
+ """Get error log."""
587
+ return self.error_log.copy()
588
+
589
+
590
+ def _respond_call(self, rpc: PluginRPC, req: RequestPluginCall) -> Any:
591
+ """Invoke service method on plugin via reflection."""
592
+ method_name = req.method_name
593
+ args = req.args
594
+ kwargs = req.kwargs
595
+
596
+ method = getattr(self.plugin, method_name)
597
+
598
+ result = method(*args, **kwargs)
599
+ result = sanitize_for_ipc(result)
600
+
601
+ return result
602
+
603
+
604
+
605
+
606
+ def _respond_env_update(self, rpc: PluginRPC, req: RequestEnvUpdate) -> None:
607
+ """Receive environment variable change notification."""
608
+ var_name = req.name
609
+ callback = self.env_subscriptions[var_name]
610
+ callback(var_name)
611
+ return True
612
+
613
+
614
+ # ========================================================================
615
+ # Topic Subscription Handling
616
+ # ========================================================================
617
+
618
+ def _topic_listener(self, topic: str) -> None:
619
+ """Background thread: listen for messages from topic subscriptions. one thread each"""
620
+ self.did_start.wait() # Ensure plugin has started before processing messages
621
+ registered = self.topic_subscriptions.get(topic)
622
+
623
+ while True:
624
+
625
+ if self._is_running is False:
626
+ logger.info(f"Plugin {self.plugin_id} worker stopping, exiting topic listener for '{topic}'")
627
+ break
628
+
629
+ # check is subscription still active (in case unsubscribed while waiting for data)
630
+ cur = self.topic_subscriptions.get(topic,None)
631
+ if cur is None or cur != registered:
632
+ logger.info(f"Subscription for topic '{topic}' no longer registered, stopping listener thread")
633
+ break
634
+
635
+
636
+ # pull data from subscription queue (blocking)
637
+ try:
638
+ data = registered.queue.get() # Blocking wait for new data
639
+ if data is None:
640
+ logger.info(f"Plugin {self.plugin_id} received shutdown sentinel for topic '{topic}'")
641
+ break
642
+ except ValueError as e:
643
+ if registered.queue.closed():
644
+ logger.info(f"Subscription for topic '{topic}' queue closed, stopping listener thread")
645
+ break
646
+ else:
647
+ logger.error(f"Error getting data from topic '{topic}' queue: {e}")
648
+ self.error_log.append(str(e))
649
+ continue
650
+ except (EOFError, OSError) as e:
651
+ logger.info(f"Subscription for topic '{topic}' closed, stopping listener thread: {e}")
652
+ break
653
+ except Exception as e:
654
+ logger.error(f"Error in topic '{topic}' listener: {e}", exc_info=True)
655
+ self.error_log.append(str(e))
656
+ continue
657
+
658
+ # Call the plugin's callback with the new data
659
+ try:
660
+ registered.callback(data)
661
+ except Exception as e:
662
+ logger.error(f"Error in topic '{topic}' callback: {e}", exc_info=True)
663
+ self.error_log.append(str(e))
664
+ continue
665
+
666
+ def _respond_topic_subscribe(self, rpc: PluginRPC, req: RequestTopicSubscribe) -> bool:
667
+ """Handle topic subscribe request."""
668
+ plugin_id = req.sender
669
+ sub = req.subscriber
670
+ topic = sub.topic
671
+ sub.queue = Queue(req.subscriber.queue_descriptor)
672
+
673
+ if topic not in self.topic_subscribers:
674
+ self.topic_subscribers[topic] = {}
675
+ self.topic_subscribers[topic][plugin_id] = sub
676
+
677
+ # logger.info('_respond: topic_subscribers: %s', self.topic_subscribers)
678
+
679
+ return True
680
+
681
+ def _respond_topic_unsubscribe(self, rpc: PluginRPC, req: RequestTopicUnsubscribe) -> bool:
682
+ """Handle topic unsubscribe request."""
683
+ plugin_id = req.sender
684
+ topic = req.subscriber.topic
685
+
686
+ if topic not in self.topic_subscribers: return True
687
+ if plugin_id not in self.topic_subscribers[topic]: return True
688
+ sub = self.topic_subscribers[topic].pop(plugin_id)
689
+
690
+ try:
691
+ sub.queue.close()
692
+ except Exception as e:
693
+ logger.info(f"Error closing queue for topic '{topic}': {e}", exc_info=True)
694
+
695
+ return True
696
+
697
+ def _request_topic_list(self, rpc: PluginRPC, req: RequestTopicList) -> list:
698
+ """Handle topic list request."""
699
+ return self.topic_published.copy()
700
+
File without changes