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,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
|