Coverage for src / tracekit / plugins / lifecycle.py: 94%
378 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"""Plugin lifecycle management and dependency resolution.
3This module provides advanced plugin lifecycle management including
4dependency resolution, graceful enable/disable, lazy loading, and
5hot reload capabilities.
6"""
8from __future__ import annotations
10import importlib
11import importlib.util
12import logging
13import sys
14import threading
15from dataclasses import dataclass, field
16from enum import Enum, auto
17from pathlib import Path
18from typing import TYPE_CHECKING, Any
20if TYPE_CHECKING:
21 from collections.abc import Callable
23from tracekit.plugins.base import PluginBase, PluginMetadata
25logger = logging.getLogger(__name__)
28class PluginState(Enum):
29 """Plugin lifecycle states."""
31 DISCOVERED = auto() # Found but not loaded
32 LOADING = auto() # Currently loading
33 LOADED = auto() # Loaded but not configured
34 CONFIGURED = auto() # Configured and ready
35 ENABLED = auto() # Fully enabled
36 DISABLED = auto() # Disabled by user
37 ERROR = auto() # Load/configure error
38 UNLOADING = auto() # Currently unloading
41@dataclass
42class PluginLoadError:
43 """Plugin load error details.
45 Attributes:
46 plugin_name: Name of plugin that failed
47 error: Exception that occurred
48 traceback: Traceback string
49 stage: Stage where failure occurred
50 recoverable: Whether error is recoverable
51 """
53 plugin_name: str
54 error: Exception
55 traceback: str = ""
56 stage: str = "load" # discovery, load, configure, enable
57 recoverable: bool = True
60@dataclass
61class DependencyInfo:
62 """Plugin dependency information.
64 Attributes:
65 name: Dependency plugin name
66 version_spec: Version specification (semver)
67 optional: Whether dependency is optional
68 resolved: Whether dependency has been resolved
69 """
71 name: str
72 version_spec: str = "*"
73 optional: bool = False
74 resolved: bool = False
77@dataclass
78class PluginHandle:
79 """Handle for managing a plugin instance.
81 Attributes:
82 metadata: Plugin metadata
83 instance: Plugin instance (None if not loaded)
84 state: Current lifecycle state
85 dependencies: Plugin dependencies
86 dependents: Plugins that depend on this one
87 errors: List of errors encountered
88 load_time: Time taken to load (seconds)
89 """
91 metadata: PluginMetadata
92 instance: PluginBase | None = None
93 state: PluginState = PluginState.DISCOVERED
94 dependencies: list[DependencyInfo] = field(default_factory=list)
95 dependents: list[str] = field(default_factory=list)
96 errors: list[PluginLoadError] = field(default_factory=list)
97 load_time: float = 0.0
100class DependencyGraph:
101 """Dependency resolution graph for plugins.
103 Resolves plugin dependencies using topological sort to ensure
104 correct load order and detect cycles.
106 Example:
107 >>> graph = DependencyGraph()
108 >>> graph.add_plugin("core")
109 >>> graph.add_dependency("decoder", "core", ">=1.0.0")
110 >>> order = graph.resolve_order()
111 >>> print(order) # ['core', 'decoder']
113 References:
114 PLUG-005: Dependency Resolution
115 """
117 def __init__(self) -> None:
118 """Initialize empty dependency graph."""
119 self._nodes: dict[str, list[DependencyInfo]] = {}
120 self._in_degree: dict[str, int] = {}
121 # Reverse adjacency: maps dependency -> list of dependents
122 self._reverse_adj: dict[str, list[str]] = {}
124 def add_plugin(self, name: str) -> None:
125 """Add plugin node to graph.
127 Args:
128 name: Plugin name
129 """
130 if name not in self._nodes:
131 self._nodes[name] = []
132 self._in_degree[name] = 0
133 self._reverse_adj[name] = []
135 def add_dependency(
136 self,
137 plugin: str,
138 depends_on: str,
139 version_spec: str = "*",
140 optional: bool = False,
141 ) -> None:
142 """Add dependency edge.
144 Args:
145 plugin: Plugin that has the dependency
146 depends_on: Plugin being depended on
147 version_spec: Version specification
148 optional: Whether dependency is optional
149 """
150 self.add_plugin(plugin)
151 self.add_plugin(depends_on)
153 dep = DependencyInfo(name=depends_on, version_spec=version_spec, optional=optional)
154 self._nodes[plugin].append(dep)
155 self._in_degree[plugin] += 1
156 # Track reverse edge: depends_on -> plugin (plugin depends on depends_on)
157 self._reverse_adj[depends_on].append(plugin)
159 def resolve_order(self) -> list[str]:
160 """Resolve topological order for loading.
162 Returns:
163 List of plugin names in load order
165 Raises:
166 ValueError: If circular dependency detected
168 References:
169 PLUG-005: Dependency Resolution - circular dependency detection
170 """
171 # Kahn's algorithm
172 in_degree = dict(self._in_degree)
173 queue = [n for n, d in in_degree.items() if d == 0]
174 result = []
176 while queue:
177 node = queue.pop(0)
178 result.append(node)
180 # Decrement in_degree for nodes that depend on this one
181 for dependent in self._reverse_adj.get(node, []):
182 in_degree[dependent] -= 1
183 if in_degree[dependent] == 0:
184 queue.append(dependent)
186 if len(result) != len(self._nodes):
187 # Cycle detected - find the cycle
188 remaining = set(self._nodes.keys()) - set(result)
189 cycle = self._find_cycle(remaining)
191 raise ValueError(f"Circular dependency detected: {' -> '.join([*cycle, cycle[0]])}")
193 return result
195 def _find_cycle(self, nodes: set[str]) -> list[str]:
196 """Find a cycle in the dependency graph.
198 Args:
199 nodes: Set of nodes that may be in a cycle
201 Returns:
202 List of nodes forming a cycle
204 References:
205 PLUG-005: Dependency Resolution - circular dependency detection
206 """
207 visited: set[str] = set()
208 rec_stack: list[str] = []
210 def dfs(node: str) -> list[str] | None:
211 visited.add(node)
212 rec_stack.append(node)
214 for dep in self._nodes.get(node, []): 214 ↛ 227line 214 didn't jump to line 227 because the loop on line 214 didn't complete
215 if dep.name not in nodes: 215 ↛ 216line 215 didn't jump to line 216 because the condition on line 215 was never true
216 continue
218 if dep.name not in visited:
219 cycle = dfs(dep.name)
220 if cycle: 220 ↛ 214line 220 didn't jump to line 214 because the condition on line 220 was always true
221 return cycle
222 elif dep.name in rec_stack: 222 ↛ 214line 222 didn't jump to line 214 because the condition on line 222 was always true
223 # Found cycle
224 idx = rec_stack.index(dep.name)
225 return rec_stack[idx:]
227 rec_stack.pop()
228 return None
230 for node in nodes: 230 ↛ 236line 230 didn't jump to line 236 because the loop on line 230 didn't complete
231 if node not in visited: 231 ↛ 230line 231 didn't jump to line 230 because the condition on line 231 was always true
232 cycle = dfs(node)
233 if cycle: 233 ↛ 230line 233 didn't jump to line 230 because the condition on line 233 was always true
234 return cycle
236 return []
238 def get_dependencies(self, plugin: str) -> list[DependencyInfo]:
239 """Get dependencies for a plugin.
241 Args:
242 plugin: Plugin name
244 Returns:
245 List of dependencies
246 """
247 return self._nodes.get(plugin, [])
249 def get_dependents(self, plugin: str) -> list[str]:
250 """Get plugins that depend on given plugin.
252 Args:
253 plugin: Plugin name
255 Returns:
256 List of dependent plugin names
257 """
258 dependents = []
259 for name, deps in self._nodes.items():
260 if any(d.name == plugin for d in deps):
261 dependents.append(name)
262 return dependents
265class PluginLifecycleManager:
266 """Manager for plugin lifecycle operations.
268 Handles plugin loading, configuration, enabling/disabling,
269 hot reload, and graceful degradation.
271 Example:
272 >>> manager = PluginLifecycleManager()
273 >>> manager.discover_plugins()
274 >>> manager.load_plugin("uart_decoder")
275 >>> manager.enable_plugin("uart_decoder")
277 References:
278 PLUG-004: Plugin Lifecycle (enable/disable/reload)
279 PLUG-005: Dependency Resolution
280 PLUG-006: Graceful Degradation
281 PLUG-007: Lazy Loading
282 PLUG-008: Plugin Hot Reload
283 """
285 def __init__(self, plugin_dirs: list[Path] | None = None) -> None:
286 """Initialize lifecycle manager.
288 Args:
289 plugin_dirs: Directories to search for plugins
290 """
291 self._plugin_dirs = plugin_dirs or []
292 self._handles: dict[str, PluginHandle] = {}
293 self._dependency_graph = DependencyGraph()
294 self._lock = threading.RLock()
295 self._lazy_loaders: dict[str, Callable[[], PluginBase]] = {}
296 self._file_watchers: dict[str, float] = {} # path -> mtime
297 self._lifecycle_callbacks: list[Callable[[str, PluginState], None]] = []
299 def discover_plugins(self) -> list[str]:
300 """Discover available plugins.
302 Scans plugin directories for plugin manifests and Python files.
304 Returns:
305 List of discovered plugin names
307 References:
308 PLUG-007: Lazy Loading
309 """
310 discovered = []
312 for plugin_dir in self._plugin_dirs:
313 if not plugin_dir.exists():
314 continue
316 for item in plugin_dir.iterdir():
317 if item.is_dir() and (item / "__init__.py").exists():
318 # Package plugin
319 name = item.name
320 self._register_lazy_loader(name, item)
321 discovered.append(name)
322 elif item.suffix == ".py" and not item.name.startswith("_"): 322 ↛ 316line 322 didn't jump to line 316 because the condition on line 322 was always true
323 # Single file plugin
324 name = item.stem
325 self._register_lazy_loader(name, item)
326 discovered.append(name)
328 logger.info(f"Discovered {len(discovered)} plugins")
329 return discovered
331 def _register_lazy_loader(self, name: str, path: Path) -> None:
332 """Register lazy loader for a plugin.
334 Args:
335 name: Plugin name
336 path: Path to plugin
338 References:
339 PLUG-007: Lazy Loading
340 """
342 def loader() -> PluginBase:
343 return self._load_plugin_from_path(name, path)
345 self._lazy_loaders[name] = loader
347 # Create handle in DISCOVERED state
348 handle = PluginHandle(
349 metadata=PluginMetadata(name=name, version="0.0.0"),
350 state=PluginState.DISCOVERED,
351 )
352 self._handles[name] = handle
354 # Track file for hot reload
355 if path.is_file():
356 self._file_watchers[str(path)] = path.stat().st_mtime
357 else:
358 init_path = path / "__init__.py"
359 if init_path.exists(): 359 ↛ exitline 359 didn't return from function '_register_lazy_loader' because the condition on line 359 was always true
360 self._file_watchers[str(init_path)] = init_path.stat().st_mtime
362 def _load_plugin_from_path(self, name: str, path: Path) -> PluginBase:
363 """Load plugin from path.
365 Args:
366 name: Plugin name
367 path: Path to plugin
369 Returns:
370 Loaded plugin instance
372 Raises:
373 ImportError: If plugin cannot be loaded or no PluginBase subclass found
374 """
375 if path.is_dir():
376 spec = importlib.util.spec_from_file_location(name, path / "__init__.py")
377 else:
378 spec = importlib.util.spec_from_file_location(name, path)
380 if spec is None or spec.loader is None:
381 raise ImportError(f"Cannot load plugin from {path}")
383 module = importlib.util.module_from_spec(spec)
384 sys.modules[name] = module
385 spec.loader.exec_module(module)
387 # Find PluginBase subclass
388 for attr_name in dir(module): 388 ↛ 393line 388 didn't jump to line 393 because the loop on line 388 didn't complete
389 attr = getattr(module, attr_name)
390 if isinstance(attr, type) and issubclass(attr, PluginBase) and attr is not PluginBase:
391 return attr()
393 raise ImportError(f"No PluginBase subclass found in {path}")
395 def load_plugin(
396 self, name: str, *, lazy: bool = True, resolve_deps: bool = True
397 ) -> PluginHandle:
398 """Load a plugin.
400 Args:
401 name: Plugin name
402 lazy: Use lazy loading if available
403 resolve_deps: Resolve dependencies first
405 Returns:
406 Plugin handle
408 Raises:
409 Exception: If plugin loading or initialization fails.
410 ValueError: If plugin not found or dependency resolution fails
412 References:
413 PLUG-004: Plugin Lifecycle
414 PLUG-005: Dependency Resolution
415 PLUG-007: Lazy Loading
416 """
417 with self._lock:
418 if name not in self._handles:
419 raise ValueError(f"Plugin '{name}' not discovered")
421 handle = self._handles[name]
423 if handle.state == PluginState.LOADED:
424 return handle
426 # Resolve dependencies first
427 if resolve_deps:
428 self._resolve_dependencies(name)
430 handle.state = PluginState.LOADING
431 self._notify_state_change(name, handle.state)
433 try:
434 import time
436 start = time.time()
438 # Use lazy loader if available
439 if lazy and name in self._lazy_loaders:
440 instance = self._lazy_loaders[name]()
441 else:
442 instance = self._load_plugin_from_path(name, self._get_plugin_path(name))
444 handle.instance = instance
445 handle.metadata = instance.metadata
446 handle.load_time = time.time() - start
448 # Call on_load
449 instance.on_load()
451 handle.state = PluginState.LOADED
452 self._notify_state_change(name, handle.state)
454 logger.info(
455 f"Loaded plugin '{name}' v{handle.metadata.version} in {handle.load_time:.3f}s"
456 )
458 return handle
460 except Exception as e:
461 import traceback
463 error = PluginLoadError(
464 plugin_name=name,
465 error=e,
466 traceback=traceback.format_exc(),
467 stage="load",
468 recoverable=True,
469 )
470 handle.errors.append(error)
471 handle.state = PluginState.ERROR
472 self._notify_state_change(name, handle.state)
474 logger.error(f"Failed to load plugin '{name}': {e}")
475 raise
477 def _resolve_dependencies(self, name: str) -> None:
478 """Resolve and load dependencies.
480 Args:
481 name: Plugin name
483 Raises:
484 ValueError: If required dependency not found
486 References:
487 PLUG-005: Dependency Resolution
488 """
489 handle = self._handles[name]
491 for dep in handle.dependencies:
492 if dep.resolved:
493 continue
495 if dep.name not in self._handles:
496 if dep.optional:
497 logger.warning(f"Optional dependency '{dep.name}' for '{name}' not found")
498 continue
499 else:
500 raise ValueError(f"Required dependency '{dep.name}' for '{name}' not found")
502 dep_handle = self._handles[dep.name]
503 if dep_handle.state not in (PluginState.LOADED, PluginState.ENABLED): 503 ↛ 504line 503 didn't jump to line 504 because the condition on line 503 was never true
504 self.load_plugin(dep.name)
506 dep.resolved = True
508 def configure_plugin(self, name: str, config: dict[str, Any]) -> PluginHandle:
509 """Configure a loaded plugin.
511 Args:
512 name: Plugin name
513 config: Configuration dictionary
515 Returns:
516 Updated plugin handle
518 Raises:
519 ValueError: If plugin not found or in invalid state
520 Exception: If configuration fails
522 References:
523 PLUG-004: Plugin Lifecycle
524 """
525 with self._lock:
526 handle = self._handles.get(name)
527 if handle is None:
528 raise ValueError(f"Plugin '{name}' not found")
530 if handle.state not in (PluginState.LOADED, PluginState.CONFIGURED):
531 raise ValueError(f"Cannot configure plugin in state {handle.state}")
533 try:
534 if handle.instance: 534 ↛ 536line 534 didn't jump to line 536 because the condition on line 534 was always true
535 handle.instance.on_configure(config)
536 handle.state = PluginState.CONFIGURED
537 self._notify_state_change(name, handle.state)
538 logger.info(f"Configured plugin '{name}'")
539 return handle
541 except Exception as e:
542 import traceback
544 error = PluginLoadError(
545 plugin_name=name,
546 error=e,
547 traceback=traceback.format_exc(),
548 stage="configure",
549 )
550 handle.errors.append(error)
551 handle.state = PluginState.ERROR
552 self._notify_state_change(name, handle.state)
553 raise
555 def enable_plugin(self, name: str) -> PluginHandle:
556 """Enable a configured plugin.
558 Args:
559 name: Plugin name
561 Returns:
562 Updated plugin handle
564 Raises:
565 ValueError: If plugin not found
567 References:
568 PLUG-002: Plugin Registration - lifecycle hooks
569 PLUG-004: Plugin Lifecycle
570 """
571 with self._lock:
572 handle = self._handles.get(name)
573 if handle is None:
574 raise ValueError(f"Plugin '{name}' not found")
576 if handle.state == PluginState.ENABLED:
577 return handle
579 if handle.state == PluginState.DISCOVERED:
580 self.load_plugin(name)
581 handle = self._handles[name]
583 if handle.state == PluginState.LOADED:
584 self.configure_plugin(name, {})
585 handle = self._handles[name]
587 # Call on_enable hook
588 if handle.instance: 588 ↛ 591line 588 didn't jump to line 591 because the condition on line 588 was always true
589 handle.instance.on_enable()
591 handle.state = PluginState.ENABLED
592 self._notify_state_change(name, handle.state)
593 logger.info(f"Enabled plugin '{name}'")
594 return handle
596 def disable_plugin(self, name: str, force: bool = False) -> PluginHandle:
597 """Disable a plugin.
599 Args:
600 name: Plugin name
601 force: Force disable even if dependents exist
603 Returns:
604 Updated plugin handle
606 Raises:
607 ValueError: If dependents exist and force=False
609 References:
610 PLUG-002: Plugin Registration - lifecycle hooks
611 PLUG-004: Plugin Lifecycle
612 PLUG-006: Graceful Degradation
613 """
614 with self._lock:
615 handle = self._handles.get(name)
616 if handle is None: 616 ↛ 617line 616 didn't jump to line 617 because the condition on line 616 was never true
617 raise ValueError(f"Plugin '{name}' not found")
619 # Check for dependents
620 dependents = [
621 n
622 for n, h in self._handles.items()
623 if any(d.name == name for d in h.dependencies) and h.state == PluginState.ENABLED
624 ]
626 if dependents and not force:
627 raise ValueError(f"Cannot disable '{name}': required by {dependents}")
629 # Call on_disable hook
630 if handle.instance: 630 ↛ 633line 630 didn't jump to line 633 because the condition on line 630 was always true
631 handle.instance.on_disable()
633 handle.state = PluginState.DISABLED
634 self._notify_state_change(name, handle.state)
635 logger.info(f"Disabled plugin '{name}'")
636 return handle
638 def unload_plugin(self, name: str, force: bool = False) -> None:
639 """Unload a plugin completely.
641 Args:
642 name: Plugin name
643 force: Force unload even if enabled
645 References:
646 PLUG-004: Plugin Lifecycle
647 """
648 with self._lock:
649 handle = self._handles.get(name)
650 if handle is None:
651 return
653 if handle.state == PluginState.ENABLED and not force:
654 self.disable_plugin(name)
656 handle.state = PluginState.UNLOADING
657 self._notify_state_change(name, handle.state)
659 if handle.instance: 659 ↛ 665line 659 didn't jump to line 665 because the condition on line 659 was always true
660 try:
661 handle.instance.on_unload()
662 except Exception as e:
663 logger.warning(f"Error during unload of '{name}': {e}")
665 handle.instance = None
666 handle.state = PluginState.DISCOVERED
667 self._notify_state_change(name, handle.state)
668 logger.info(f"Unloaded plugin '{name}'")
670 def reload_plugin(self, name: str) -> PluginHandle:
671 """Hot reload a plugin.
673 Args:
674 name: Plugin name
676 Returns:
677 Updated plugin handle
679 Raises:
680 ValueError: If plugin not found
682 References:
683 PLUG-006: Plugin Hot Reload - state preservation, memory leak prevention
684 """
685 with self._lock:
686 handle = self._handles.get(name)
687 if handle is None:
688 raise ValueError(f"Plugin '{name}' not found")
690 was_enabled = handle.state == PluginState.ENABLED
691 config = handle.instance._config if handle.instance else {}
693 # Preserve plugin state for restoration
694 saved_state = self._save_plugin_state(handle)
696 # Unload and cleanup old references
697 self.unload_plugin(name, force=True)
698 self._cleanup_plugin_references(name)
700 # Clear from sys.modules to force reimport
701 modules_to_clear = [mod for mod in sys.modules if mod.startswith(f"{name}.")]
702 for mod in modules_to_clear:
703 del sys.modules[mod]
704 if name in sys.modules:
705 del sys.modules[name]
707 # Reload
708 handle = self.load_plugin(name)
710 # Restore state
711 self._restore_plugin_state(handle, saved_state)
713 if config:
714 self.configure_plugin(name, config)
716 if was_enabled:
717 self.enable_plugin(name)
719 logger.info(f"Hot reloaded plugin '{name}'")
720 return handle
722 def _save_plugin_state(self, handle: PluginHandle) -> dict[str, Any]:
723 """Save plugin state before reload.
725 Args:
726 handle: Plugin handle
728 Returns:
729 Saved state dictionary
731 References:
732 PLUG-006: Plugin Hot Reload - state preservation
733 """
734 state: dict[str, Any] = {
735 "config": handle.instance._config if handle.instance else {},
736 "registered_protocols": (
737 handle.instance._registered_protocols.copy() if handle.instance else []
738 ),
739 "registered_algorithms": (
740 handle.instance._registered_algorithms.copy() if handle.instance else []
741 ),
742 }
743 return state
745 def _restore_plugin_state(self, handle: PluginHandle, state: dict[str, Any]) -> None:
746 """Restore plugin state after reload.
748 Args:
749 handle: Plugin handle
750 state: Saved state dictionary
752 References:
753 PLUG-006: Plugin Hot Reload - state preservation
754 """
755 if handle.instance: 755 ↛ exitline 755 didn't return from function '_restore_plugin_state' because the condition on line 755 was always true
756 handle.instance._config = state.get("config", {})
757 handle.instance._registered_protocols = state.get("registered_protocols", [])
758 handle.instance._registered_algorithms = state.get("registered_algorithms", [])
760 def _cleanup_plugin_references(self, name: str) -> None:
761 """Clean up plugin references to prevent memory leaks.
763 Args:
764 name: Plugin name
766 References:
767 PLUG-006: Plugin Hot Reload - memory leak prevention
768 """
769 import gc
771 # Remove from lazy loaders
772 if name in self._lazy_loaders: 772 ↛ 776line 772 didn't jump to line 776 because the condition on line 772 was always true
773 del self._lazy_loaders[name]
775 # Force garbage collection to clean up old references
776 gc.collect()
778 logger.debug(f"Cleaned up references for plugin '{name}'")
780 def check_for_changes(self) -> list[str]:
781 """Check for plugin file changes.
783 Returns:
784 List of plugin names with changed files
786 References:
787 PLUG-008: Plugin Hot Reload
788 """
789 changed = []
791 for path_str, old_mtime in self._file_watchers.items():
792 path = Path(path_str)
793 if path.exists(): 793 ↛ 791line 793 didn't jump to line 791 because the condition on line 793 was always true
794 new_mtime = path.stat().st_mtime
795 if new_mtime > old_mtime:
796 # Find plugin name
797 for name, handle in self._handles.items():
798 if handle.metadata.path and str(handle.metadata.path) in path_str: 798 ↛ 799line 798 didn't jump to line 799 because the condition on line 798 was never true
799 changed.append(name)
800 break
801 self._file_watchers[path_str] = new_mtime
803 return changed
805 def auto_reload_changed(self) -> list[str]:
806 """Automatically reload changed plugins.
808 Returns:
809 List of reloaded plugin names
811 References:
812 PLUG-008: Plugin Hot Reload
813 """
814 changed = self.check_for_changes()
815 reloaded = []
817 for name in changed:
818 try:
819 self.reload_plugin(name)
820 reloaded.append(name)
821 except Exception as e:
822 logger.error(f"Failed to auto-reload '{name}': {e}")
824 return reloaded
826 def graceful_degradation(self, name: str) -> dict[str, Any]:
827 """Handle plugin failure gracefully.
829 Returns fallback options when a plugin fails.
831 Args:
832 name: Plugin name
834 Returns:
835 Dictionary with degradation options
837 References:
838 PLUG-006: Graceful Degradation
839 """
840 handle = self._handles.get(name)
841 if handle is None:
842 return {"status": "not_found", "alternatives": []}
844 # Find alternatives
845 alternatives = []
846 if handle.instance:
847 # Look for plugins with same capabilities
848 for cap in handle.metadata.capabilities:
849 for other_name, other_handle in self._handles.items():
850 if other_name != name and other_handle.state == PluginState.ENABLED:
851 if cap in other_handle.metadata.capabilities: 851 ↛ 849line 851 didn't jump to line 849 because the condition on line 851 was always true
852 alternatives.append(other_name)
854 return {
855 "status": "degraded",
856 "plugin": name,
857 "error": str(handle.errors[-1].error) if handle.errors else None,
858 "alternatives": alternatives,
859 "recoverable": handle.errors[-1].recoverable if handle.errors else True,
860 }
862 def get_handle(self, name: str) -> PluginHandle | None:
863 """Get plugin handle.
865 Args:
866 name: Plugin name
868 Returns:
869 Plugin handle or None
870 """
871 return self._handles.get(name)
873 def get_enabled_plugins(self) -> list[str]:
874 """Get list of enabled plugins.
876 Returns:
877 List of plugin names
878 """
879 return [
880 name for name, handle in self._handles.items() if handle.state == PluginState.ENABLED
881 ]
883 def on_state_change(self, callback: Callable[[str, PluginState], None]) -> None:
884 """Register state change callback.
886 Args:
887 callback: Function called with (plugin_name, new_state)
888 """
889 self._lifecycle_callbacks.append(callback)
891 def _notify_state_change(self, name: str, state: PluginState) -> None:
892 """Notify callbacks of state change."""
893 for callback in self._lifecycle_callbacks:
894 try:
895 callback(name, state)
896 except Exception as e:
897 logger.warning(f"State change callback failed: {e}")
899 def _get_plugin_path(self, name: str) -> Path:
900 """Get path to plugin.
902 Args:
903 name: Plugin name
905 Returns:
906 Path to plugin
908 Raises:
909 ValueError: If plugin path not found
910 """
911 for plugin_dir in self._plugin_dirs:
912 # Check for package
913 pkg_path = plugin_dir / name
914 if pkg_path.is_dir() and (pkg_path / "__init__.py").exists(): 914 ↛ 915line 914 didn't jump to line 915 because the condition on line 914 was never true
915 return pkg_path
916 # Check for single file
917 file_path = plugin_dir / f"{name}.py"
918 if file_path.exists():
919 return file_path
921 raise ValueError(f"Plugin path not found for '{name}'")
924# Global lifecycle manager
925_lifecycle_manager: PluginLifecycleManager | None = None
928def get_lifecycle_manager() -> PluginLifecycleManager:
929 """Get global lifecycle manager.
931 Returns:
932 Global PluginLifecycleManager instance
933 """
934 global _lifecycle_manager
935 if _lifecycle_manager is None:
936 _lifecycle_manager = PluginLifecycleManager()
937 return _lifecycle_manager
940def set_plugin_directories(directories: list[Path]) -> None:
941 """Set plugin directories for global manager.
943 Args:
944 directories: List of plugin directories
945 """
946 global _lifecycle_manager
947 _lifecycle_manager = PluginLifecycleManager(directories)
950__all__ = [
951 "DependencyGraph",
952 "DependencyInfo",
953 "PluginHandle",
954 "PluginLifecycleManager",
955 "PluginLoadError",
956 "PluginState",
957 "get_lifecycle_manager",
958 "set_plugin_directories",
959]