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,188 @@
1
+ """
2
+ Worker Pool Example: Multiple instances of the same plugin.
3
+
4
+ This example shows how plugins can:
5
+ 1. Create multiple instances of the same plugin
6
+ 2. Load balance work across worker instances
7
+ 3. Track metrics per worker
8
+ 4. Coordinate work distribution
9
+ """
10
+
11
+ import logging
12
+ import time
13
+ from typing import Any, Dict
14
+
15
+ from parallelazy.plugin import Plugin, PluginDescriptor
16
+ from parallelazy.plugin_worker import PluginWorker
17
+ from parallelazy.plugin_manager import PluginManager
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class TaskQueue(Plugin):
23
+ """
24
+ Task distributor that publishes tasks to workers.
25
+ """
26
+
27
+ @classmethod
28
+ def Descriptor(cls, options: Dict[str, Any] = None) -> PluginDescriptor:
29
+ return super().Descriptor(
30
+ version="1.0.0",
31
+ author="ModularNet Team",
32
+ description="Task queue distributor",
33
+ options=options or {}
34
+ )
35
+
36
+ def __init__(self, worker: PluginWorker, plugin_id: str, options: Dict[str, Any]):
37
+ super().__init__(worker, plugin_id, options)
38
+ self.task_id = 0
39
+
40
+ def on_start(self) -> None:
41
+ logger.info(f"[{self.plugin_id}] TaskQueue started")
42
+ self.env_set("queue.total_tasks", 0)
43
+
44
+ def main(self):
45
+ self.task_id += 1
46
+ # Generate a task
47
+ task = {
48
+ "id": self.task_id,
49
+ "operation": "process",
50
+ "complexity": (self.task_id % 3) + 1 # 1-3
51
+ }
52
+ # Publish to workers
53
+ self.topic_publish("tasks.queue", task)
54
+ self.env_set("queue.total_tasks", self.task_id, wait=False)
55
+
56
+ def on_stop(self) -> None:
57
+ logger.info(f"[{self.plugin_id}] TaskQueue stopped, created {self.task_id} tasks")
58
+
59
+
60
+ class Worker(Plugin):
61
+ """
62
+ Worker that processes tasks from the queue.
63
+ Multiple instances can be created.
64
+ """
65
+
66
+ @classmethod
67
+ def Descriptor(cls, options: Dict[str, Any] = None) -> PluginDescriptor:
68
+ return super().Descriptor(
69
+ version="1.0.0",
70
+ author="ModularNet Team",
71
+ description="Worker pool member",
72
+ options=options or {}
73
+ )
74
+
75
+ def __init__(self, worker: PluginWorker, plugin_id: str, options: Dict[str, Any]):
76
+ super().__init__(worker, plugin_id, options)
77
+ self.tasks_processed = 0
78
+ self.total_time = 0
79
+
80
+ def on_start(self) -> None:
81
+ logger.info(f"[{self.plugin_id}] Worker started")
82
+ self.topic_subscribe("tasks.queue", self.on_task)
83
+ # Each worker tracks its own metrics
84
+ self.env_set(f"worker.{self.plugin_id}.processed", 0)
85
+ self.env_set(f"worker.{self.plugin_id}.total_time", 0)
86
+
87
+ def on_task(self, task: Dict[str, Any]):
88
+ """Process a task from the queue."""
89
+ self.tasks_processed += 1
90
+ complexity = task["complexity"]
91
+ # Simulate work based on complexity
92
+ time_taken = complexity * 0.1
93
+ self.total_time += time_taken
94
+
95
+ logger.info(
96
+ f"[{self.plugin_id}] Task #{task['id']}: complexity={complexity}, time={time_taken:.1f}s"
97
+ )
98
+
99
+ self.env_set(f"worker.{self.plugin_id}.processed", self.tasks_processed, wait=False)
100
+ self.env_set(f"worker.{self.plugin_id}.total_time", self.total_time, wait=False)
101
+
102
+ def main(self):
103
+ pass
104
+
105
+ def on_stop(self) -> None:
106
+ logger.info(
107
+ f"[{self.plugin_id}] Worker stopped: "
108
+ f"processed={self.tasks_processed}, total_time={self.total_time:.1f}s"
109
+ )
110
+
111
+
112
+ def main():
113
+ """Run the worker pool example."""
114
+ manager = PluginManager()
115
+
116
+ # Create queue
117
+ queue_desc = TaskQueue.Descriptor()
118
+ manager.load_plugin(queue_desc)
119
+ manager.start_plugin(queue_desc.name)
120
+
121
+ # Create a pool of workers
122
+ num_workers = 3
123
+ worker_descs = []
124
+ for i in range(num_workers):
125
+ worker_desc = Worker.Descriptor()
126
+ worker_desc.name = f"worker-{i}" # Unique name per instance
127
+ manager.load_plugin(worker_desc)
128
+ manager.start_plugin(worker_desc.name)
129
+ worker_descs.append(worker_desc)
130
+
131
+ # Let them run
132
+ print(f"Queue and {num_workers} workers running... Press Ctrl+C to stop.")
133
+ time.sleep(5)
134
+ print("Stopping workers...")
135
+
136
+ # Stop all workers
137
+ for worker_desc in worker_descs:
138
+ manager.stop_plugin(worker_desc.name)
139
+
140
+ manager.stop_plugin(queue_desc.name)
141
+
142
+ time.sleep(1)
143
+
144
+ # Show worker metrics
145
+ print("\nWorker pool metrics:")
146
+ all_vars = manager.env_get()
147
+ for name, value in sorted(all_vars.items()):
148
+ if name.startswith("worker."):
149
+ print(f" {name}: {value}")
150
+
151
+ # VERIFICATION
152
+ print("\n=== VERIFICATION ===")
153
+ print("PREDICTED BEHAVIOR:")
154
+ print("1. TaskQueue generates ~5 tasks in 5 seconds")
155
+ print("2. 3 workers load-balance tasks via pub/sub")
156
+ print("3. Each worker tracks its own processed count")
157
+ print("4. Total processed across all workers >= queue total\n")
158
+
159
+ total_tasks = all_vars.get("queue.total_tasks", 0)
160
+ assert total_tasks > 0, "Queue should generate tasks"
161
+ print(f"✓ Queue generated: {total_tasks} tasks")
162
+
163
+ # Count workers and their processed tasks
164
+ workers_found = {}
165
+ for name, value in all_vars.items():
166
+ if name.startswith("worker.") and name.endswith(".processed"):
167
+ worker_name = name.replace("worker.", "").replace(".processed", "")
168
+ workers_found[worker_name] = value
169
+
170
+ assert len(workers_found) == num_workers, f"Should have {num_workers} workers, found {len(workers_found)}"
171
+ total_processed = sum(workers_found.values())
172
+ assert total_processed > 0, "Workers should process tasks"
173
+
174
+ print(f"✓ Workers found: {num_workers}")
175
+ print(f"✓ Total tasks processed across all workers: {total_processed}")
176
+ for worker_id, count in sorted(workers_found.items()):
177
+ print(f" - {worker_id}: {count} tasks")
178
+ print("✓ All worker pool assertions passed!")
179
+
180
+ # Clean up
181
+ for worker_desc in worker_descs:
182
+ manager.unload_plugin(worker_desc.name)
183
+
184
+ manager.unload_plugin(queue_desc.name)
185
+
186
+
187
+ if __name__ == "__main__":
188
+ main()
parallelazy/plugin.py ADDED
@@ -0,0 +1,223 @@
1
+ """
2
+ Base Plugin Interface: Abstract base class for all plugins.
3
+
4
+ Defines the lifecycle, subscription, and service interfaces that all
5
+ plugins must implement or can override.
6
+ """
7
+
8
+ from typing import Any, Dict, Optional, TYPE_CHECKING, Self
9
+ from enum import Enum
10
+
11
+ from parallelazy.utils import camel_to_kebab
12
+
13
+
14
+ if TYPE_CHECKING:
15
+ from parallelazy.plugin_worker import PluginWorker
16
+
17
+
18
+ class PluginState(Enum):
19
+ """Plugin lifecycle states."""
20
+ INITIALIZED = "initialized"
21
+ LOADED = "loaded"
22
+ RUNNING = "running"
23
+ STOPPED = "stopped"
24
+ ERROR = "error"
25
+
26
+ def __str__(self): return str(self.value)
27
+
28
+ """
29
+ Plugin Descriptor: Serializable metadata for dynamic plugin loading.
30
+
31
+ Describes a plugin's identity, location, and configuration options.
32
+ """
33
+
34
+ from dataclasses import dataclass, field, asdict
35
+ from typing import Any, Dict, Optional
36
+ import json
37
+
38
+
39
+ @dataclass
40
+ class PluginDescriptor:
41
+ """
42
+ Serializable descriptor for plugin metadata and instantiation.
43
+
44
+ Attributes:
45
+ name: Unique plugin identifier
46
+ version: Semantic version (e.g., "1.0.0")
47
+ package: Full package path (e.g., "modularnet.plugins.my_plugin")
48
+ class_name: Plugin class name in the package
49
+ description: Human-readable description
50
+ author: Plugin author
51
+ options: Default configuration options for the plugin
52
+ dependencies: List of required plugin names
53
+ """
54
+ name: str
55
+ version: str
56
+ package: str
57
+ class_name: str
58
+ description: str = ""
59
+ author: str = ""
60
+ options: Dict[str, Any] = field(default_factory=dict)
61
+ dependencies: list = field(default_factory=list)
62
+
63
+ def to_dict(self) -> Dict[str, Any]:
64
+ """Convert descriptor to dictionary (JSON-serializable)."""
65
+ return asdict(self)
66
+
67
+ def to_json(self) -> str:
68
+ """Serialize descriptor to JSON."""
69
+ return json.dumps(self.to_dict(), indent=2)
70
+
71
+ @classmethod
72
+ def from_dict(cls, data: Dict[str, Any]) -> "PluginDescriptor":
73
+ """Create descriptor from dictionary."""
74
+ return cls(**data)
75
+
76
+ @classmethod
77
+ def from_json(cls, json_str: str) -> "PluginDescriptor":
78
+ """Create descriptor from JSON string."""
79
+ return cls.from_dict(json.loads(json_str))
80
+
81
+ def __str__(self) -> str:
82
+ return f"{self.name}@{self.version} ({self.package}.{self.class_name})"
83
+
84
+ def __repr__(self) -> str:
85
+ return f"PluginDescriptor({self})"
86
+
87
+
88
+ class Plugin:
89
+ """
90
+ Base class for all ModularNet plugins.
91
+
92
+ Plugins run in isolated processes and communicate via:
93
+ 1. Control channel: Load/unload/configure/service commands
94
+ 2. Data plane: Pub/sub topics for high-bandwidth data exchange
95
+ 3. Env plane: Shared environment variables with change notifications
96
+ 4. Manager proxy: Direct access to manager services (get/set env, publish, invoke)
97
+
98
+ Subclasses must implement:
99
+ - on_start: Initialize plugin resources (optional)
100
+ - on_stop: Clean up resources (optional)
101
+ - main: Main execution loop (optional)
102
+ - on_error: Handle errors (optional)
103
+
104
+
105
+ Direct manager access via self.manager:
106
+ model = self.env_get("model")
107
+ self.env_set("status", "ready")
108
+ self.topic_publish("topic", data)
109
+ """
110
+
111
+ _registry: dict[str, type[Self]] = {}
112
+
113
+ def __init_subclass__(cls, plugin_name: str | None = None, **kwargs):
114
+ super().__init_subclass__(**kwargs)
115
+
116
+ # Determine registry key: custom plugin_name or default class name
117
+ key = plugin_name or cls.__name__
118
+
119
+ if key in cls._registry:
120
+ raise ValueError(f"Plugin '{key}' is already registered.")
121
+
122
+ cls._registry[key] = cls
123
+
124
+
125
+ @classmethod
126
+ def default_options(cls) -> Dict[str, Any]:
127
+ return {}
128
+
129
+ @classmethod
130
+ def Descriptor(cls, version:str, author:str, description:str=None, name:str=None, options: Dict[str, Any] = None) -> PluginDescriptor:
131
+ all_options = cls.default_options().copy()
132
+ all_options.update(options or {})
133
+
134
+
135
+ return PluginDescriptor(
136
+ version=version,
137
+ author=author,
138
+ options=all_options,
139
+ name= camel_to_kebab(name or cls.__name__),
140
+ package=f"{cls.__module__}",
141
+ class_name=cls.__name__,
142
+ description=description or f"{cls.__doc__}",
143
+
144
+ )
145
+
146
+ def __init__(self, worker: "PluginWorker", plugin_id: str, options: Optional[Dict[str, Any]] = None):
147
+ """
148
+ Initialize plugin.
149
+
150
+ Args:
151
+ plugin_id: Unique identifier for this plugin instance
152
+ config: Configuration options dictionary
153
+ worker: PluginWorker instance for managing the plugin
154
+ """
155
+ all_options = self.default_options().copy()
156
+ all_options.update(options or {})
157
+
158
+ self._worker = worker
159
+ self.plugin_id = plugin_id
160
+ self.state = PluginState.INITIALIZED
161
+ self.options = all_options
162
+ self._is_running = False
163
+
164
+
165
+ def main(self): pass
166
+
167
+ def on_start(self) -> None:
168
+ """
169
+ Called when plugin is started.
170
+
171
+ Initialize resources, open connections, start background tasks, etc.
172
+ Raise exception on failure.
173
+ """
174
+ pass
175
+
176
+ def on_stop(self) -> None:
177
+ """
178
+ Called when plugin is stopped.
179
+
180
+ Clean up resources, close connections, stop background tasks, etc.
181
+ """
182
+ pass
183
+
184
+ def on_error(self, error: Exception, source: str=None) -> None:
185
+ """ Called when an error occurs in the plugin."""
186
+ pass
187
+
188
+ @property
189
+ def is_running(self) -> bool:
190
+ """Whether plugin is currently running."""
191
+ return self._is_running
192
+
193
+ def stop(self) -> None:
194
+ """Stop the plugin."""
195
+
196
+ self._worker.stop()
197
+
198
+ def topic_subscribe(self, topic:str, callback:str) -> None:
199
+ """Subscribe to a data topic or environment variable."""
200
+ self._worker.topic_subscribe(topic, callback)
201
+
202
+ def topic_publish(self, topic:str, data:Any) -> None:
203
+ """Publish data to a topic."""
204
+ self._worker.topic_publish(topic, data)
205
+
206
+ def env_set(self, name:str, value:Any, wait: bool = True) -> None:
207
+ """Set an environment variable."""
208
+ self._worker.env_set(name, value, wait=wait)
209
+
210
+ def env_get(self, name:str=None, default:Any=None) -> dict|Any:
211
+ """Get an environment variable."""
212
+ if name is None:
213
+ return self._worker.env_get_all()
214
+
215
+ return self._worker.env_get(name) or default
216
+
217
+ def env_subscribe(self, name:str, callback:str) -> None:
218
+ """Subscribe to changes in an environment variable."""
219
+ self._worker.env_subscribe(name, callback)
220
+
221
+ def plugin_call(self, plugin_id: str, method_name: str, *args, **kwargs) -> Any:
222
+ """Call a method provided by the manager or another plugin."""
223
+ return self._worker.plugin_call(plugin_id, method_name, *args, **kwargs)