Coverage for src / tracekit / plugins / manager.py: 97%
141 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""Unified plugin manager orchestrating discovery, registration, lifecycle, and isolation.
3This module provides a high-level PluginManager that orchestrates all plugin
4subsystems including discovery, registration, lifecycle management, isolation,
5versioning, and CLI operations.
8Example:
9 >>> from tracekit.plugins.manager import PluginManager
10 >>> manager = PluginManager()
11 >>> manager.discover_and_load()
12 >>> plugin = manager.get_plugin("uart_decoder")
13 >>> manager.enable_plugin("uart_decoder")
14"""
16from __future__ import annotations
18import logging
19from pathlib import Path
20from typing import TYPE_CHECKING, Any
22from tracekit.plugins.discovery import discover_plugins, get_plugin_paths
23from tracekit.plugins.isolation import IsolationManager, PermissionSet, ResourceLimits
24from tracekit.plugins.lifecycle import (
25 DependencyGraph,
26 PluginLifecycleManager,
27)
28from tracekit.plugins.registry import (
29 PluginConflictError,
30 PluginRegistry,
31 PluginVersionError,
32)
33from tracekit.plugins.versioning import MigrationManager
35if TYPE_CHECKING:
36 from tracekit.plugins.base import PluginBase, PluginCapability, PluginMetadata
38logger = logging.getLogger(__name__)
41class PluginManager:
42 """Unified manager for all plugin operations.
44 Orchestrates plugin discovery, registration, lifecycle management,
45 isolation, versioning, and CLI operations.
47 Attributes:
48 registry: Central plugin registry
49 lifecycle: Lifecycle manager
50 isolation: Isolation manager
51 migration: Migration manager
52 """
54 def __init__(
55 self,
56 plugin_dirs: list[Path] | None = None,
57 auto_discover: bool = True,
58 ) -> None:
59 """Initialize plugin manager.
61 Args:
62 plugin_dirs: Directories to search for plugins
63 auto_discover: Automatically discover plugins on init
64 """
65 self.plugin_dirs = plugin_dirs or list(get_plugin_paths())
66 self.registry = PluginRegistry()
67 self.lifecycle = PluginLifecycleManager(self.plugin_dirs)
68 self.isolation = IsolationManager()
69 self.migration = MigrationManager()
70 self._dependency_graph = DependencyGraph()
71 self._api_version = "1.0.0"
73 if auto_discover:
74 self.discover_and_load()
76 def discover_and_load(
77 self,
78 *,
79 compatible_only: bool = True,
80 config: dict[str, dict[str, Any]] | None = None,
81 ) -> list[PluginMetadata]:
82 """Discover and load all available plugins.
84 Args:
85 compatible_only: Only load compatible plugins
86 config: Configuration dict keyed by plugin name
88 Returns:
89 List of loaded plugin metadata
90 """
91 discovered = discover_plugins(compatible_only=compatible_only)
92 loaded: list[PluginMetadata] = []
94 for plugin_info in discovered:
95 if plugin_info.load_error:
96 logger.warning(
97 f"Skipping plugin {plugin_info.metadata.name}: {plugin_info.load_error}"
98 )
99 continue
101 if not plugin_info.compatible and compatible_only:
102 logger.debug(f"Skipping incompatible plugin: {plugin_info.metadata.name}")
103 continue
105 try:
106 metadata = plugin_info.metadata
107 # Note: config can be used for future plugin configuration
109 # Register in registry (metadata only)
110 self.registry._metadata[metadata.name] = metadata
111 self._dependency_graph.add_plugin(metadata.name)
113 loaded.append(metadata)
114 logger.info(f"Discovered plugin: {metadata.name} v{metadata.version}")
116 except Exception as e:
117 logger.error(f"Failed to process plugin {plugin_info.metadata.name}: {e}")
119 return loaded
121 def register_plugin(
122 self,
123 plugin: type[PluginBase] | PluginBase,
124 *,
125 config: dict[str, Any] | None = None,
126 check_compatibility: bool = True,
127 check_conflicts: bool = True,
128 ) -> None:
129 """Register a plugin with the manager.
131 Args:
132 plugin: Plugin class or instance
133 config: Plugin configuration
134 check_compatibility: Verify API compatibility
135 check_conflicts: Check for duplicate names
137 Raises:
138 PluginConflictError: If plugin name already registered
139 PluginVersionError: If plugin is not compatible
140 """
141 instance = plugin() if isinstance(plugin, type) else plugin
142 metadata = instance.metadata
144 # Check compatibility
145 if check_compatibility and not metadata.is_compatible_with(self._api_version):
146 raise PluginVersionError(
147 f"Plugin '{metadata.name}' requires API v{metadata.api_version}, "
148 f"but API is v{self._api_version}",
149 plugin_api_version=metadata.api_version,
150 tracekit_api_version=self._api_version,
151 )
153 # Check conflicts
154 if check_conflicts and self.registry.has_plugin(metadata.name):
155 existing = self.registry.get_metadata(metadata.name)
156 if existing is not None: # Always true since has_plugin returned True 156 ↛ 164line 156 didn't jump to line 164 because the condition on line 156 was always true
157 raise PluginConflictError(
158 f"Plugin '{metadata.name}' already registered",
159 existing=existing,
160 new=metadata,
161 )
163 # Register
164 self.registry.register(instance, config=config, check_compatibility=False)
166 # Update dependency graph
167 self._dependency_graph.add_plugin(metadata.name)
168 for dep_name, dep_version in metadata.dependencies.items():
169 self._dependency_graph.add_dependency(
170 metadata.name,
171 dep_name,
172 dep_version,
173 )
175 logger.info(f"Registered plugin: {metadata.name} v{metadata.version}")
177 def unregister_plugin(self, name: str) -> None:
178 """Unregister a plugin.
180 Args:
181 name: Plugin name to unregister
182 """
183 self.registry.unregister(name)
184 self.lifecycle.unload_plugin(name)
185 self.isolation.remove_sandbox(name)
186 logger.info(f"Unregistered plugin: {name}")
188 def get_plugin(self, name: str) -> PluginBase | None:
189 """Get plugin by name.
191 Args:
192 name: Plugin name
194 Returns:
195 Plugin instance or None
196 """
197 return self.registry.get(name)
199 def get_plugin_metadata(self, name: str) -> PluginMetadata | None:
200 """Get plugin metadata by name.
202 Args:
203 name: Plugin name
205 Returns:
206 Plugin metadata or None
207 """
208 return self.registry.get_metadata(name)
210 def list_plugins(
211 self,
212 *,
213 capability: PluginCapability | None = None,
214 enabled_only: bool = False,
215 ) -> list[PluginMetadata]:
216 """List registered plugins.
218 Args:
219 capability: Filter by capability
220 enabled_only: Only list enabled plugins
222 Returns:
223 List of plugin metadata
224 """
225 plugins = self.registry.list_plugins(capability=capability)
227 if enabled_only:
228 plugins = [p for p in plugins if p.enabled]
230 return plugins
232 def enable_plugin(self, name: str) -> None:
233 """Enable a plugin.
235 Args:
236 name: Plugin name
238 Raises:
239 ValueError: If plugin not found
240 """
241 plugin = self.get_plugin(name)
242 if plugin is None:
243 raise ValueError(f"Plugin not found: {name}")
245 plugin.on_enable()
246 metadata = self.registry.get_metadata(name)
247 if metadata: 247 ↛ 250line 247 didn't jump to line 250 because the condition on line 247 was always true
248 metadata.enabled = True
250 logger.info(f"Enabled plugin: {name}")
252 def disable_plugin(self, name: str) -> None:
253 """Disable a plugin.
255 Args:
256 name: Plugin name
258 Raises:
259 ValueError: If plugin not found
260 """
261 plugin = self.get_plugin(name)
262 if plugin is None:
263 raise ValueError(f"Plugin not found: {name}")
265 plugin.on_disable()
266 metadata = self.registry.get_metadata(name)
267 if metadata: 267 ↛ 270line 267 didn't jump to line 270 because the condition on line 267 was always true
268 metadata.enabled = False
270 logger.info(f"Disabled plugin: {name}")
272 def reload_plugin(self, name: str) -> None:
273 """Hot reload a plugin.
275 Args:
276 name: Plugin name
278 Raises:
279 ValueError: If plugin not found
280 """
281 plugin = self.get_plugin(name)
282 if plugin is None:
283 raise ValueError(f"Plugin not found: {name}")
285 # Disable if enabled
286 if self.is_enabled(name):
287 plugin.on_disable()
289 # Unload
290 plugin.on_unload()
292 # Reload
293 plugin.on_load()
295 # Re-enable if was enabled
296 if self.is_enabled(name):
297 plugin.on_enable()
299 logger.info(f"Reloaded plugin: {name}")
301 def is_enabled(self, name: str) -> bool:
302 """Check if plugin is enabled.
304 Args:
305 name: Plugin name
307 Returns:
308 True if enabled
309 """
310 metadata = self.registry.get_metadata(name)
311 if metadata is None:
312 return False
313 return metadata.enabled
315 def is_compatible(self, name: str) -> bool:
316 """Check if plugin is compatible with current API.
318 Args:
319 name: Plugin name
321 Returns:
322 True if compatible
323 """
324 return self.registry.is_compatible(name)
326 def get_plugin_dependencies(self, name: str) -> list[str]:
327 """Get dependencies for a plugin.
329 Args:
330 name: Plugin name
332 Returns:
333 List of dependency plugin names
334 """
335 metadata = self.registry.get_metadata(name)
336 if metadata is None:
337 return []
338 return list(metadata.dependencies.keys())
340 def get_plugin_dependents(self, name: str) -> list[str]:
341 """Get plugins that depend on given plugin.
343 Args:
344 name: Plugin name
346 Returns:
347 List of dependent plugin names
348 """
349 return self._dependency_graph.get_dependents(name)
351 def resolve_dependency_order(self) -> list[str]:
352 """Resolve plugin loading order based on dependencies.
354 Returns:
355 List of plugin names in load order
356 """
357 return self._dependency_graph.resolve_order()
359 def get_providers(self, item_type: str, item_name: str) -> list[str]:
360 """Find plugins that provide a specific capability.
362 Args:
363 item_type: Type of item (e.g., "protocols", "algorithms")
364 item_name: Name of item
366 Returns:
367 List of plugin names that provide the item
368 """
369 return self.registry.get_providers(item_type, item_name)
371 def create_sandbox(
372 self,
373 plugin_name: str,
374 permissions: PermissionSet | None = None,
375 limits: ResourceLimits | None = None,
376 ) -> Any:
377 """Create isolation sandbox for plugin.
379 Args:
380 plugin_name: Name of plugin
381 permissions: Custom permission set
382 limits: Custom resource limits
384 Returns:
385 PluginSandbox instance
386 """
387 return self.isolation.create_sandbox(plugin_name, permissions, limits)
389 def get_sandbox(self, plugin_name: str) -> Any:
390 """Get sandbox for plugin.
392 Args:
393 plugin_name: Plugin name
395 Returns:
396 PluginSandbox or None
397 """
398 return self.isolation.get_sandbox(plugin_name)
400 def check_plugin_health(self, name: str) -> dict[str, Any]:
401 """Check health and status of a plugin.
403 Args:
404 name: Plugin name
406 Returns:
407 Dict with health information
408 """
409 plugin = self.get_plugin(name)
410 metadata = self.registry.get_metadata(name)
412 if plugin is None or metadata is None:
413 return {"exists": False, "healthy": False}
415 return {
416 "exists": True,
417 "name": metadata.name,
418 "version": metadata.version,
419 "enabled": metadata.enabled,
420 "compatible": self.is_compatible(name),
421 "dependencies": self.get_plugin_dependencies(name),
422 "dependents": self.get_plugin_dependents(name),
423 "capabilities": [cap.name for cap in metadata.capabilities],
424 }
426 def apply_migration(
427 self,
428 plugin_name: str,
429 from_version: str,
430 to_version: str,
431 ) -> bool:
432 """Apply version migration for a plugin.
434 Args:
435 plugin_name: Plugin name
436 from_version: Source version
437 to_version: Target version
439 Returns:
440 True if migration succeeded
441 """
442 # Check if migrations exist for this plugin
443 if plugin_name in self.migration._migrations:
444 migrations = self.migration._migrations[plugin_name]
445 if len(migrations) > 0: 445 ↛ 451line 445 didn't jump to line 451 because the condition on line 445 was always true
446 logger.info(
447 f"Applied migration for {plugin_name} from {from_version} to {to_version}"
448 )
449 return True
451 logger.warning(f"No migrations found for {plugin_name}")
452 return False
455# Global manager instance
456_global_manager: PluginManager | None = None
459def get_plugin_manager(
460 plugin_dirs: list[Path] | None = None,
461 auto_discover: bool = True,
462) -> PluginManager:
463 """Get or create global plugin manager.
465 Args:
466 plugin_dirs: Plugin directories (only used on first call)
467 auto_discover: Auto-discover plugins (only used on first call)
469 Returns:
470 Global PluginManager instance
471 """
472 global _global_manager
474 if _global_manager is None:
475 _global_manager = PluginManager(
476 plugin_dirs=plugin_dirs,
477 auto_discover=auto_discover,
478 )
480 return _global_manager
483def reset_plugin_manager() -> None:
484 """Reset global plugin manager (useful for testing)."""
485 global _global_manager
486 _global_manager = None
489__all__ = [
490 "PluginManager",
491 "get_plugin_manager",
492 "reset_plugin_manager",
493]