kollabor 0.4.9__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.
- core/__init__.py +18 -0
- core/application.py +578 -0
- core/cli.py +193 -0
- core/commands/__init__.py +43 -0
- core/commands/executor.py +277 -0
- core/commands/menu_renderer.py +319 -0
- core/commands/parser.py +186 -0
- core/commands/registry.py +331 -0
- core/commands/system_commands.py +479 -0
- core/config/__init__.py +7 -0
- core/config/llm_task_config.py +110 -0
- core/config/loader.py +501 -0
- core/config/manager.py +112 -0
- core/config/plugin_config_manager.py +346 -0
- core/config/plugin_schema.py +424 -0
- core/config/service.py +399 -0
- core/effects/__init__.py +1 -0
- core/events/__init__.py +12 -0
- core/events/bus.py +129 -0
- core/events/executor.py +154 -0
- core/events/models.py +258 -0
- core/events/processor.py +176 -0
- core/events/registry.py +289 -0
- core/fullscreen/__init__.py +19 -0
- core/fullscreen/command_integration.py +290 -0
- core/fullscreen/components/__init__.py +12 -0
- core/fullscreen/components/animation.py +258 -0
- core/fullscreen/components/drawing.py +160 -0
- core/fullscreen/components/matrix_components.py +177 -0
- core/fullscreen/manager.py +302 -0
- core/fullscreen/plugin.py +204 -0
- core/fullscreen/renderer.py +282 -0
- core/fullscreen/session.py +324 -0
- core/io/__init__.py +52 -0
- core/io/buffer_manager.py +362 -0
- core/io/config_status_view.py +272 -0
- core/io/core_status_views.py +410 -0
- core/io/input_errors.py +313 -0
- core/io/input_handler.py +2655 -0
- core/io/input_mode_manager.py +402 -0
- core/io/key_parser.py +344 -0
- core/io/layout.py +587 -0
- core/io/message_coordinator.py +204 -0
- core/io/message_renderer.py +601 -0
- core/io/modal_interaction_handler.py +315 -0
- core/io/raw_input_processor.py +946 -0
- core/io/status_renderer.py +845 -0
- core/io/terminal_renderer.py +586 -0
- core/io/terminal_state.py +551 -0
- core/io/visual_effects.py +734 -0
- core/llm/__init__.py +26 -0
- core/llm/api_communication_service.py +863 -0
- core/llm/conversation_logger.py +473 -0
- core/llm/conversation_manager.py +414 -0
- core/llm/file_operations_executor.py +1401 -0
- core/llm/hook_system.py +402 -0
- core/llm/llm_service.py +1629 -0
- core/llm/mcp_integration.py +386 -0
- core/llm/message_display_service.py +450 -0
- core/llm/model_router.py +214 -0
- core/llm/plugin_sdk.py +396 -0
- core/llm/response_parser.py +848 -0
- core/llm/response_processor.py +364 -0
- core/llm/tool_executor.py +520 -0
- core/logging/__init__.py +19 -0
- core/logging/setup.py +208 -0
- core/models/__init__.py +5 -0
- core/models/base.py +23 -0
- core/plugins/__init__.py +13 -0
- core/plugins/collector.py +212 -0
- core/plugins/discovery.py +386 -0
- core/plugins/factory.py +263 -0
- core/plugins/registry.py +152 -0
- core/storage/__init__.py +5 -0
- core/storage/state_manager.py +84 -0
- core/ui/__init__.py +6 -0
- core/ui/config_merger.py +176 -0
- core/ui/config_widgets.py +369 -0
- core/ui/live_modal_renderer.py +276 -0
- core/ui/modal_actions.py +162 -0
- core/ui/modal_overlay_renderer.py +373 -0
- core/ui/modal_renderer.py +591 -0
- core/ui/modal_state_manager.py +443 -0
- core/ui/widget_integration.py +222 -0
- core/ui/widgets/__init__.py +27 -0
- core/ui/widgets/base_widget.py +136 -0
- core/ui/widgets/checkbox.py +85 -0
- core/ui/widgets/dropdown.py +140 -0
- core/ui/widgets/label.py +78 -0
- core/ui/widgets/slider.py +185 -0
- core/ui/widgets/text_input.py +224 -0
- core/utils/__init__.py +11 -0
- core/utils/config_utils.py +656 -0
- core/utils/dict_utils.py +212 -0
- core/utils/error_utils.py +275 -0
- core/utils/key_reader.py +171 -0
- core/utils/plugin_utils.py +267 -0
- core/utils/prompt_renderer.py +151 -0
- kollabor-0.4.9.dist-info/METADATA +298 -0
- kollabor-0.4.9.dist-info/RECORD +128 -0
- kollabor-0.4.9.dist-info/WHEEL +5 -0
- kollabor-0.4.9.dist-info/entry_points.txt +2 -0
- kollabor-0.4.9.dist-info/licenses/LICENSE +21 -0
- kollabor-0.4.9.dist-info/top_level.txt +4 -0
- kollabor_cli_main.py +20 -0
- plugins/__init__.py +1 -0
- plugins/enhanced_input/__init__.py +18 -0
- plugins/enhanced_input/box_renderer.py +103 -0
- plugins/enhanced_input/box_styles.py +142 -0
- plugins/enhanced_input/color_engine.py +165 -0
- plugins/enhanced_input/config.py +150 -0
- plugins/enhanced_input/cursor_manager.py +72 -0
- plugins/enhanced_input/geometry.py +81 -0
- plugins/enhanced_input/state.py +130 -0
- plugins/enhanced_input/text_processor.py +115 -0
- plugins/enhanced_input_plugin.py +385 -0
- plugins/fullscreen/__init__.py +9 -0
- plugins/fullscreen/example_plugin.py +327 -0
- plugins/fullscreen/matrix_plugin.py +132 -0
- plugins/hook_monitoring_plugin.py +1299 -0
- plugins/query_enhancer_plugin.py +350 -0
- plugins/save_conversation_plugin.py +502 -0
- plugins/system_commands_plugin.py +93 -0
- plugins/tmux_plugin.py +795 -0
- plugins/workflow_enforcement_plugin.py +629 -0
- system_prompt/default.md +1286 -0
- system_prompt/default_win.md +265 -0
- system_prompt/example_with_trender.md +47 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Plugin discovery for file system scanning and module loading."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import inspect
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Type, Optional
|
|
10
|
+
|
|
11
|
+
from ..utils.plugin_utils import has_method, get_plugin_config_safely
|
|
12
|
+
from ..utils.error_utils import safe_execute
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# Platform check
|
|
17
|
+
IS_WINDOWS = sys.platform == "win32"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PluginDiscovery:
|
|
21
|
+
"""Handles plugin discovery and module loading from the file system.
|
|
22
|
+
|
|
23
|
+
This class is responsible for scanning directories for plugin files,
|
|
24
|
+
loading Python modules, and extracting plugin classes and configurations.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, plugins_dir: Path):
|
|
28
|
+
"""Initialize plugin discovery.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
plugins_dir: Directory containing plugin modules.
|
|
32
|
+
"""
|
|
33
|
+
self.plugins_dir = plugins_dir
|
|
34
|
+
self.discovered_modules: List[str] = []
|
|
35
|
+
self.loaded_classes: Dict[str, Type] = {}
|
|
36
|
+
self.plugin_configs: Dict[str, Dict[str, Any]] = {}
|
|
37
|
+
|
|
38
|
+
# Security validation patterns
|
|
39
|
+
self.valid_plugin_name_pattern = re.compile(r'^[a-zA-Z][a-zA-Z0-9_]*$')
|
|
40
|
+
self.max_plugin_name_length = 50
|
|
41
|
+
self.blocked_names = {
|
|
42
|
+
'__init__', '__pycache__', 'system', 'os', 'sys', 'subprocess',
|
|
43
|
+
'eval', 'exec', 'compile', 'open', 'file', 'input', 'raw_input'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
logger.info(f"PluginDiscovery initialized with directory: {plugins_dir}")
|
|
47
|
+
|
|
48
|
+
def _sanitize_plugin_name(self, plugin_name: str) -> Optional[str]:
|
|
49
|
+
"""Sanitize and validate plugin name for security."""
|
|
50
|
+
if not plugin_name:
|
|
51
|
+
logger.warning("Empty plugin name rejected")
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
# Length check
|
|
55
|
+
if len(plugin_name) > self.max_plugin_name_length:
|
|
56
|
+
logger.warning(f"Plugin name too long: {plugin_name}")
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
# Pattern validation (letters, numbers, underscores only)
|
|
60
|
+
if not self.valid_plugin_name_pattern.match(plugin_name):
|
|
61
|
+
logger.warning(f"Invalid plugin name pattern: {plugin_name}")
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
# Block dangerous names
|
|
65
|
+
if plugin_name.lower() in self.blocked_names:
|
|
66
|
+
logger.warning(f"Blocked plugin name: {plugin_name}")
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
# Block path traversal attempts
|
|
70
|
+
if '..' in plugin_name or '/' in plugin_name or '\\' in plugin_name:
|
|
71
|
+
logger.warning(f"Path traversal attempt in plugin name: {plugin_name}")
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
# Block shell metacharacters
|
|
75
|
+
if any(char in plugin_name for char in [';', '&', '|', '`', '$', '"', "'"]):
|
|
76
|
+
logger.warning(f"Shell metacharacters in plugin name: {plugin_name}")
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
return plugin_name
|
|
80
|
+
|
|
81
|
+
def _verify_plugin_location(self, plugin_name: str) -> bool:
|
|
82
|
+
"""Verify plugin file exists in expected location."""
|
|
83
|
+
try:
|
|
84
|
+
# Construct expected file path (plugin_name already includes _plugin suffix)
|
|
85
|
+
plugin_file = self.plugins_dir / f"{plugin_name}.py"
|
|
86
|
+
|
|
87
|
+
# Resolve to absolute path to prevent symlink attacks
|
|
88
|
+
plugin_file = plugin_file.resolve()
|
|
89
|
+
|
|
90
|
+
# Verify it's within the plugins directory
|
|
91
|
+
plugins_dir = self.plugins_dir.resolve()
|
|
92
|
+
if not str(plugin_file).startswith(str(plugins_dir)):
|
|
93
|
+
logger.error(f"Plugin file outside plugins directory: {plugin_file}")
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
# Verify file exists and is a regular file
|
|
97
|
+
if not plugin_file.is_file():
|
|
98
|
+
logger.error(f"Plugin file not found: {plugin_file}")
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
# Additional security: check file permissions (Unix only)
|
|
102
|
+
if not IS_WINDOWS:
|
|
103
|
+
if plugin_file.stat().st_mode & 0o777 != 0o644:
|
|
104
|
+
logger.warning(f"Plugin file has unusual permissions: {plugin_file}")
|
|
105
|
+
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
except Exception as e:
|
|
109
|
+
logger.error(f"Error verifying plugin location: {e}")
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
def _verify_loaded_module(self, module, plugin_name: str) -> bool:
|
|
113
|
+
"""Verify the loaded module is actually our plugin."""
|
|
114
|
+
try:
|
|
115
|
+
# Check module name matches
|
|
116
|
+
expected_module_name = f"plugins.{plugin_name}"
|
|
117
|
+
if module.__name__ != expected_module_name:
|
|
118
|
+
logger.error(f"Module name mismatch: {module.__name__} != {expected_module_name}")
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
# Check module file location
|
|
122
|
+
if hasattr(module, '__file__'):
|
|
123
|
+
module_file = Path(module.__file__).resolve()
|
|
124
|
+
plugins_dir = self.plugins_dir.resolve()
|
|
125
|
+
|
|
126
|
+
if not str(module_file).startswith(str(plugins_dir)):
|
|
127
|
+
logger.error(f"Module file outside plugins directory: {module_file}")
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
# Verify module has expected plugin attributes
|
|
131
|
+
if not hasattr(module, '__dict__'):
|
|
132
|
+
logger.error(f"Module {plugin_name} has no __dict__ attribute")
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
except Exception as e:
|
|
138
|
+
logger.error(f"Error verifying loaded module {plugin_name}: {e}")
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
def scan_plugin_files(self) -> List[str]:
|
|
142
|
+
"""Scan the plugins directory for plugin files with security validation.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
List of discovered plugin module names.
|
|
146
|
+
"""
|
|
147
|
+
discovered = []
|
|
148
|
+
|
|
149
|
+
if not self.plugins_dir.exists():
|
|
150
|
+
logger.warning(f"Plugins directory does not exist: {self.plugins_dir}")
|
|
151
|
+
return discovered
|
|
152
|
+
|
|
153
|
+
# Resolve plugins directory to prevent symlink attacks
|
|
154
|
+
try:
|
|
155
|
+
plugins_dir = self.plugins_dir.resolve()
|
|
156
|
+
|
|
157
|
+
# Verify directory permissions (Unix only - Windows doesn't use these bits)
|
|
158
|
+
if not IS_WINDOWS:
|
|
159
|
+
if plugins_dir.stat().st_mode & 0o002:
|
|
160
|
+
logger.error(f"Plugins directory is world-writable: {plugins_dir}")
|
|
161
|
+
return discovered
|
|
162
|
+
|
|
163
|
+
except Exception as e:
|
|
164
|
+
logger.error(f"Error resolving plugins directory: {e}")
|
|
165
|
+
return discovered
|
|
166
|
+
|
|
167
|
+
for plugin_file in plugins_dir.glob("*_plugin.py"):
|
|
168
|
+
try:
|
|
169
|
+
# Extract module name from file (KEEP _plugin suffix for import)
|
|
170
|
+
module_name = plugin_file.stem # e.g., "enhanced_input_plugin"
|
|
171
|
+
|
|
172
|
+
# Apply security validation
|
|
173
|
+
safe_name = self._sanitize_plugin_name(module_name)
|
|
174
|
+
if not safe_name:
|
|
175
|
+
logger.warning(f"Skipping invalid plugin: {module_name}")
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
# Verify plugin location
|
|
179
|
+
if not self._verify_plugin_location(safe_name):
|
|
180
|
+
logger.warning(f"Plugin location verification failed: {safe_name}")
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
discovered.append(safe_name)
|
|
184
|
+
logger.debug(f"Discovered valid plugin: {safe_name}")
|
|
185
|
+
|
|
186
|
+
except Exception as e:
|
|
187
|
+
logger.error(f"Error processing plugin file {plugin_file}: {e}")
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
self.discovered_modules = discovered
|
|
191
|
+
logger.info(f"Discovered {len(discovered)} validated plugin modules")
|
|
192
|
+
return discovered
|
|
193
|
+
|
|
194
|
+
def load_module(self, module_name: str) -> bool:
|
|
195
|
+
"""Load a single plugin module and extract plugin classes.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
module_name: Name of the plugin module to load.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
True if module loaded successfully, False otherwise.
|
|
202
|
+
"""
|
|
203
|
+
# Validate module name before loading
|
|
204
|
+
safe_name = self._sanitize_plugin_name(module_name)
|
|
205
|
+
if not safe_name:
|
|
206
|
+
logger.error(f"Invalid plugin name rejected: {module_name}")
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
# Verify plugin location again for safety
|
|
210
|
+
if not self._verify_plugin_location(safe_name):
|
|
211
|
+
logger.error(f"Plugin location verification failed during loading: {safe_name}")
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
def _import_and_extract():
|
|
215
|
+
# Import the plugin module with security validation
|
|
216
|
+
module_path = f"plugins.{safe_name}"
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
module = importlib.import_module(module_path)
|
|
220
|
+
except ImportError as e:
|
|
221
|
+
logger.error(f"Failed to import plugin module {safe_name}: {e}")
|
|
222
|
+
raise
|
|
223
|
+
except Exception as e:
|
|
224
|
+
logger.error(f"Unexpected error importing plugin module {safe_name}: {e}")
|
|
225
|
+
raise
|
|
226
|
+
|
|
227
|
+
# Verify the loaded module is actually our plugin
|
|
228
|
+
if not self._verify_loaded_module(module, safe_name):
|
|
229
|
+
raise ValueError(f"Module verification failed: {safe_name}")
|
|
230
|
+
|
|
231
|
+
# Find classes that look like plugins (end with 'Plugin')
|
|
232
|
+
found_plugins = False
|
|
233
|
+
for name, obj in inspect.getmembers(module, inspect.isclass):
|
|
234
|
+
if name.endswith('Plugin') and has_method(obj, 'get_default_config'):
|
|
235
|
+
# Store the plugin class
|
|
236
|
+
self.loaded_classes[name] = obj
|
|
237
|
+
|
|
238
|
+
# Get and store plugin configuration
|
|
239
|
+
config = get_plugin_config_safely(obj)
|
|
240
|
+
self.plugin_configs[name] = config
|
|
241
|
+
|
|
242
|
+
if config:
|
|
243
|
+
logger.info(f"Loaded plugin class: {name} with config keys: {list(config.keys())}")
|
|
244
|
+
else:
|
|
245
|
+
logger.info(f"Loaded plugin class: {name} with no configuration")
|
|
246
|
+
|
|
247
|
+
found_plugins = True
|
|
248
|
+
|
|
249
|
+
return found_plugins
|
|
250
|
+
|
|
251
|
+
result = safe_execute(
|
|
252
|
+
_import_and_extract,
|
|
253
|
+
f"loading plugin module {module_name}",
|
|
254
|
+
default=False,
|
|
255
|
+
logger_instance=logger
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
return result
|
|
259
|
+
|
|
260
|
+
def load_all_modules(self) -> int:
|
|
261
|
+
"""Load all discovered plugin modules.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Number of successfully loaded plugin classes.
|
|
265
|
+
"""
|
|
266
|
+
initial_count = len(self.loaded_classes)
|
|
267
|
+
|
|
268
|
+
for module_name in self.discovered_modules:
|
|
269
|
+
self.load_module(module_name)
|
|
270
|
+
|
|
271
|
+
loaded_count = len(self.loaded_classes) - initial_count
|
|
272
|
+
logger.info(f"Loaded {loaded_count} plugin classes from {len(self.discovered_modules)} modules")
|
|
273
|
+
|
|
274
|
+
return loaded_count
|
|
275
|
+
|
|
276
|
+
def discover_and_load(self) -> Dict[str, Type]:
|
|
277
|
+
"""Perform complete discovery and loading process.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Dictionary mapping plugin names to their classes.
|
|
281
|
+
"""
|
|
282
|
+
# Scan for plugin files
|
|
283
|
+
self.scan_plugin_files()
|
|
284
|
+
|
|
285
|
+
# Load all discovered modules
|
|
286
|
+
self.load_all_modules()
|
|
287
|
+
|
|
288
|
+
logger.info(f"Discovery complete: {len(self.loaded_classes)} plugins loaded")
|
|
289
|
+
return self.loaded_classes
|
|
290
|
+
|
|
291
|
+
def get_plugin_class(self, plugin_name: str) -> Type:
|
|
292
|
+
"""Get a loaded plugin class by name.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
plugin_name: Name of the plugin class.
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
Plugin class if found.
|
|
299
|
+
|
|
300
|
+
Raises:
|
|
301
|
+
KeyError: If plugin class not found.
|
|
302
|
+
"""
|
|
303
|
+
if plugin_name not in self.loaded_classes:
|
|
304
|
+
raise KeyError(f"Plugin class '{plugin_name}' not found")
|
|
305
|
+
|
|
306
|
+
return self.loaded_classes[plugin_name]
|
|
307
|
+
|
|
308
|
+
def get_plugin_config(self, plugin_name: str) -> Dict[str, Any]:
|
|
309
|
+
"""Get configuration for a specific plugin.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
plugin_name: Name of the plugin.
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
Plugin configuration dictionary, or empty dict if not found.
|
|
316
|
+
"""
|
|
317
|
+
return self.plugin_configs.get(plugin_name, {})
|
|
318
|
+
|
|
319
|
+
def get_all_configs(self) -> Dict[str, Dict[str, Any]]:
|
|
320
|
+
"""Get configurations for all loaded plugins.
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
Dictionary mapping plugin names to their configurations.
|
|
324
|
+
"""
|
|
325
|
+
return self.plugin_configs.copy()
|
|
326
|
+
|
|
327
|
+
def get_discovery_stats(self) -> Dict[str, Any]:
|
|
328
|
+
"""Get statistics about the discovery process.
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
Dictionary with discovery statistics.
|
|
332
|
+
"""
|
|
333
|
+
return {
|
|
334
|
+
"plugins_directory": str(self.plugins_dir),
|
|
335
|
+
"directory_exists": self.plugins_dir.exists(),
|
|
336
|
+
"discovered_modules": len(self.discovered_modules),
|
|
337
|
+
"loaded_classes": len(self.loaded_classes),
|
|
338
|
+
"plugins_with_config": sum(1 for c in self.plugin_configs.values() if c),
|
|
339
|
+
"module_names": self.discovered_modules,
|
|
340
|
+
"class_names": list(self.loaded_classes.keys())
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
def has_plugin_method(self, plugin_name: str, method_name: str) -> bool:
|
|
344
|
+
"""Check if a loaded plugin has a specific method.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
plugin_name: Name of the plugin class.
|
|
348
|
+
method_name: Name of the method to check for.
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
True if plugin has the method, False otherwise.
|
|
352
|
+
"""
|
|
353
|
+
if plugin_name not in self.loaded_classes:
|
|
354
|
+
return False
|
|
355
|
+
|
|
356
|
+
plugin_class = self.loaded_classes[plugin_name]
|
|
357
|
+
return has_method(plugin_class, method_name)
|
|
358
|
+
|
|
359
|
+
def call_plugin_method(self, plugin_name: str, method_name: str, *args, **kwargs) -> Any:
|
|
360
|
+
"""Safely call a method on a loaded plugin class.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
plugin_name: Name of the plugin class.
|
|
364
|
+
method_name: Name of the method to call.
|
|
365
|
+
*args: Positional arguments to pass.
|
|
366
|
+
**kwargs: Keyword arguments to pass.
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
Method result or None if method doesn't exist or call failed.
|
|
370
|
+
"""
|
|
371
|
+
if plugin_name not in self.loaded_classes:
|
|
372
|
+
logger.warning(f"Plugin {plugin_name} not found for method call: {method_name}")
|
|
373
|
+
return None
|
|
374
|
+
|
|
375
|
+
plugin_class = self.loaded_classes[plugin_name]
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
if has_method(plugin_class, method_name):
|
|
379
|
+
method = getattr(plugin_class, method_name)
|
|
380
|
+
return method(*args, **kwargs)
|
|
381
|
+
else:
|
|
382
|
+
logger.debug(f"Plugin {plugin_name} has no method: {method_name}")
|
|
383
|
+
return None
|
|
384
|
+
except Exception as e:
|
|
385
|
+
logger.error(f"Failed to call {plugin_name}.{method_name}: {e}")
|
|
386
|
+
return None
|
core/plugins/factory.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Plugin factory for instantiating plugin classes with dependencies."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, Dict, List, Type
|
|
5
|
+
|
|
6
|
+
from ..utils.plugin_utils import has_method, instantiate_plugin_safely
|
|
7
|
+
from ..utils.error_utils import ErrorAccumulator, safe_execute
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PluginFactory:
|
|
13
|
+
"""Handles plugin instantiation with dependency injection.
|
|
14
|
+
|
|
15
|
+
This class is responsible for creating instances of plugin classes,
|
|
16
|
+
managing their dependencies, and handling instantiation errors.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self):
|
|
20
|
+
"""Initialize the plugin factory."""
|
|
21
|
+
self.plugin_instances: Dict[str, Any] = {}
|
|
22
|
+
self.instantiation_errors: Dict[str, str] = {}
|
|
23
|
+
logger.info("PluginFactory initialized")
|
|
24
|
+
|
|
25
|
+
def instantiate_plugin(
|
|
26
|
+
self,
|
|
27
|
+
plugin_class: Type,
|
|
28
|
+
plugin_name: str,
|
|
29
|
+
state_manager: Any,
|
|
30
|
+
event_bus: Any,
|
|
31
|
+
renderer: Any,
|
|
32
|
+
config: Any
|
|
33
|
+
) -> Any:
|
|
34
|
+
"""Instantiate a single plugin with dependencies.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
plugin_class: The plugin class to instantiate.
|
|
38
|
+
plugin_name: Name of the plugin.
|
|
39
|
+
state_manager: State management system.
|
|
40
|
+
event_bus: Event bus for hook registration.
|
|
41
|
+
renderer: Terminal renderer.
|
|
42
|
+
config: Configuration manager.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Plugin instance if successful, None otherwise.
|
|
46
|
+
"""
|
|
47
|
+
# Check if the plugin class has an __init__ method
|
|
48
|
+
if not has_method(plugin_class, '__init__'):
|
|
49
|
+
logger.debug(f"Plugin {plugin_name} is not instantiable (no __init__ method)")
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
# Try to instantiate the plugin
|
|
53
|
+
# Clean plugin name: remove 'Plugin' suffix if present and use as name
|
|
54
|
+
clean_name = plugin_name
|
|
55
|
+
if plugin_name.endswith('Plugin'):
|
|
56
|
+
clean_name = plugin_name[:-6].lower()
|
|
57
|
+
|
|
58
|
+
instance = instantiate_plugin_safely(
|
|
59
|
+
plugin_class,
|
|
60
|
+
name=clean_name,
|
|
61
|
+
state_manager=state_manager,
|
|
62
|
+
event_bus=event_bus,
|
|
63
|
+
renderer=renderer,
|
|
64
|
+
config=config
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if instance:
|
|
68
|
+
# Store with both the class name and the clean name for compatibility
|
|
69
|
+
self.plugin_instances[plugin_name] = instance
|
|
70
|
+
self.plugin_instances[clean_name] = instance
|
|
71
|
+
logger.info(f"Successfully instantiated plugin: {plugin_name} (as '{clean_name}')")
|
|
72
|
+
else:
|
|
73
|
+
self.instantiation_errors[plugin_name] = "Failed to instantiate"
|
|
74
|
+
logger.warning(f"Failed to instantiate plugin: {plugin_name}")
|
|
75
|
+
|
|
76
|
+
return instance
|
|
77
|
+
|
|
78
|
+
def instantiate_all(
|
|
79
|
+
self,
|
|
80
|
+
plugin_classes: Dict[str, Type],
|
|
81
|
+
state_manager: Any,
|
|
82
|
+
event_bus: Any,
|
|
83
|
+
renderer: Any,
|
|
84
|
+
config: Any
|
|
85
|
+
) -> Dict[str, Any]:
|
|
86
|
+
"""Instantiate all provided plugin classes.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
plugin_classes: Dictionary mapping plugin names to classes.
|
|
90
|
+
state_manager: State management system.
|
|
91
|
+
event_bus: Event bus for hook registration.
|
|
92
|
+
renderer: Terminal renderer.
|
|
93
|
+
config: Configuration manager.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Dictionary mapping plugin names to their instances.
|
|
97
|
+
"""
|
|
98
|
+
error_accumulator = ErrorAccumulator(logger)
|
|
99
|
+
|
|
100
|
+
for plugin_name, plugin_class in plugin_classes.items():
|
|
101
|
+
instance = self.instantiate_plugin(
|
|
102
|
+
plugin_class,
|
|
103
|
+
plugin_name,
|
|
104
|
+
state_manager,
|
|
105
|
+
event_bus,
|
|
106
|
+
renderer,
|
|
107
|
+
config
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if not instance:
|
|
111
|
+
error_accumulator.add_warning(
|
|
112
|
+
f"instantiating plugin {plugin_name}",
|
|
113
|
+
"Plugin instantiation failed"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
error_accumulator.report_summary()
|
|
117
|
+
logger.info(f"Instantiated {len(self.plugin_instances)} plugins out of {len(plugin_classes)}")
|
|
118
|
+
|
|
119
|
+
return self.plugin_instances
|
|
120
|
+
|
|
121
|
+
def get_instance(self, plugin_name: str) -> Any:
|
|
122
|
+
"""Get a plugin instance by name.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
plugin_name: Name of the plugin.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Plugin instance if found, None otherwise.
|
|
129
|
+
"""
|
|
130
|
+
return self.plugin_instances.get(plugin_name)
|
|
131
|
+
|
|
132
|
+
def get_all_instances(self) -> Dict[str, Any]:
|
|
133
|
+
"""Get all plugin instances.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Dictionary mapping plugin names to instances.
|
|
137
|
+
"""
|
|
138
|
+
return self.plugin_instances.copy()
|
|
139
|
+
|
|
140
|
+
def get_instantiation_errors(self) -> Dict[str, str]:
|
|
141
|
+
"""Get errors from failed instantiations.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Dictionary mapping plugin names to error messages.
|
|
145
|
+
"""
|
|
146
|
+
return self.instantiation_errors.copy()
|
|
147
|
+
|
|
148
|
+
def initialize_plugin(self, plugin_name: str) -> bool:
|
|
149
|
+
"""Initialize a plugin instance if it has an initialize method.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
plugin_name: Name of the plugin to initialize.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
True if initialization successful, False otherwise.
|
|
156
|
+
"""
|
|
157
|
+
instance = self.plugin_instances.get(plugin_name)
|
|
158
|
+
if not instance:
|
|
159
|
+
logger.warning(f"Cannot initialize non-existent plugin: {plugin_name}")
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
if not has_method(instance, 'initialize'):
|
|
163
|
+
logger.debug(f"Plugin {plugin_name} has no initialize method")
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
def _initialize():
|
|
167
|
+
return instance.initialize()
|
|
168
|
+
|
|
169
|
+
result = safe_execute(
|
|
170
|
+
_initialize,
|
|
171
|
+
f"initializing plugin {plugin_name}",
|
|
172
|
+
default=False,
|
|
173
|
+
logger_instance=logger
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
return result is not False
|
|
177
|
+
|
|
178
|
+
def initialize_all_plugins(self) -> Dict[str, bool]:
|
|
179
|
+
"""Initialize all plugin instances.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Dictionary mapping plugin names to initialization success status.
|
|
183
|
+
"""
|
|
184
|
+
initialization_results = {}
|
|
185
|
+
|
|
186
|
+
for plugin_name in self.plugin_instances:
|
|
187
|
+
success = self.initialize_plugin(plugin_name)
|
|
188
|
+
initialization_results[plugin_name] = success
|
|
189
|
+
|
|
190
|
+
if not success:
|
|
191
|
+
logger.warning(f"Failed to initialize plugin: {plugin_name}")
|
|
192
|
+
|
|
193
|
+
successful = sum(1 for s in initialization_results.values() if s)
|
|
194
|
+
logger.info(f"Initialized {successful}/{len(initialization_results)} plugins")
|
|
195
|
+
|
|
196
|
+
return initialization_results
|
|
197
|
+
|
|
198
|
+
def shutdown_plugin(self, plugin_name: str) -> bool:
|
|
199
|
+
"""Shutdown a plugin instance if it has a shutdown method.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
plugin_name: Name of the plugin to shutdown.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
True if shutdown successful, False otherwise.
|
|
206
|
+
"""
|
|
207
|
+
instance = self.plugin_instances.get(plugin_name)
|
|
208
|
+
if not instance:
|
|
209
|
+
logger.warning(f"Cannot shutdown non-existent plugin: {plugin_name}")
|
|
210
|
+
return False
|
|
211
|
+
|
|
212
|
+
if not has_method(instance, 'shutdown'):
|
|
213
|
+
logger.debug(f"Plugin {plugin_name} has no shutdown method")
|
|
214
|
+
return True
|
|
215
|
+
|
|
216
|
+
def _shutdown():
|
|
217
|
+
return instance.shutdown()
|
|
218
|
+
|
|
219
|
+
result = safe_execute(
|
|
220
|
+
_shutdown,
|
|
221
|
+
f"shutting down plugin {plugin_name}",
|
|
222
|
+
default=False,
|
|
223
|
+
logger_instance=logger
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return result is not False
|
|
227
|
+
|
|
228
|
+
def shutdown_all_plugins(self) -> Dict[str, bool]:
|
|
229
|
+
"""Shutdown all plugin instances.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
Dictionary mapping plugin names to shutdown success status.
|
|
233
|
+
"""
|
|
234
|
+
shutdown_results = {}
|
|
235
|
+
|
|
236
|
+
for plugin_name in self.plugin_instances:
|
|
237
|
+
success = self.shutdown_plugin(plugin_name)
|
|
238
|
+
shutdown_results[plugin_name] = success
|
|
239
|
+
|
|
240
|
+
if not success:
|
|
241
|
+
logger.warning(f"Failed to shutdown plugin: {plugin_name}")
|
|
242
|
+
|
|
243
|
+
successful = sum(1 for s in shutdown_results.values() if s)
|
|
244
|
+
logger.info(f"Shutdown {successful}/{len(shutdown_results)} plugins")
|
|
245
|
+
|
|
246
|
+
return shutdown_results
|
|
247
|
+
|
|
248
|
+
def get_factory_stats(self) -> Dict[str, Any]:
|
|
249
|
+
"""Get statistics about the factory's operations.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
Dictionary with factory statistics.
|
|
253
|
+
"""
|
|
254
|
+
return {
|
|
255
|
+
"total_instances": len(self.plugin_instances),
|
|
256
|
+
"instantiation_errors": len(self.instantiation_errors),
|
|
257
|
+
"plugin_names": list(self.plugin_instances.keys()),
|
|
258
|
+
"error_plugins": list(self.instantiation_errors.keys()),
|
|
259
|
+
"instance_types": {
|
|
260
|
+
name: type(instance).__name__
|
|
261
|
+
for name, instance in self.plugin_instances.items()
|
|
262
|
+
}
|
|
263
|
+
}
|