plugin_loader.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760
  1. """
  2. Dynamic Plugin Loader with Lifecycle Management
  3. This module provides dynamic loading of plugins from ./plugins/*/ directories with:
  4. - Safe plugin module loading and unloading
  5. - Dependency resolution and validation
  6. - Plugin class discovery and instantiation
  7. - Error isolation and recovery
  8. - Hot-reload capability for development
  9. - Module cleanup and garbage collection
  10. - Security validation and sandboxing
  11. - Plugin compatibility checking
  12. Features:
  13. - Discovers plugin directories automatically
  14. - Loads plugin modules dynamically using importlib
  15. - Instantiates plugin classes that extend TrixyPlugin
  16. - Manages plugin lifecycle (load, initialize, enable, disable, unload)
  17. - Provides hot-reload for development
  18. - Isolates plugin errors from system
  19. - Validates plugin compatibility and dependencies
  20. - Cleans up resources on unload
  21. """
  22. import os
  23. import sys
  24. import importlib
  25. import importlib.util
  26. import threading
  27. import time
  28. import traceback
  29. import inspect
  30. from typing import Dict, List, Optional, Any, Type, Set, Tuple
  31. from pathlib import Path
  32. from dataclasses import dataclass
  33. from enum import Enum
  34. import json
  35. # Import plugin base class
  36. from .trixy_plugin import TrixyPlugin, PluginState, PluginError
  37. from .config_handler import PluginConfigHandler
  38. def pprint(message: str) -> None:
  39. """Plugin loader logging function."""
  40. print(f"[LOADER] {message}")
  41. class LoadResult(Enum):
  42. """Plugin load operation results."""
  43. SUCCESS = "success"
  44. FAILED = "failed"
  45. SKIPPED = "skipped"
  46. NOT_FOUND = "not_found"
  47. INVALID = "invalid"
  48. DEPENDENCY_ERROR = "dependency_error"
  49. COMPATIBILITY_ERROR = "compatibility_error"
  50. @dataclass
  51. class PluginLoadResult:
  52. """Result of a plugin load operation."""
  53. plugin_name: str
  54. result: LoadResult
  55. plugin_instance: Optional[TrixyPlugin] = None
  56. error_message: Optional[str] = None
  57. load_time: float = 0.0
  58. warnings: List[str] = None
  59. def __post_init__(self):
  60. if self.warnings is None:
  61. self.warnings = []
  62. @property
  63. def success(self) -> bool:
  64. """Check if load was successful."""
  65. return self.result == LoadResult.SUCCESS
  66. @property
  67. def failed(self) -> bool:
  68. """Check if load failed."""
  69. return self.result == LoadResult.FAILED
  70. class PluginLoaderError(Exception):
  71. """Base exception for plugin loader errors."""
  72. pass
  73. class PluginDiscoveryError(PluginLoaderError):
  74. """Raised when plugin discovery fails."""
  75. pass
  76. class PluginModuleError(PluginLoaderError):
  77. """Raised when plugin module loading fails."""
  78. pass
  79. class PluginDependencyError(PluginLoaderError):
  80. """Raised when plugin dependencies cannot be resolved."""
  81. pass
  82. class PluginLoader:
  83. """
  84. Dynamic plugin loader with advanced lifecycle management.
  85. This class handles:
  86. - Plugin discovery in ./plugins/*/ directories
  87. - Dynamic module loading and unloading
  88. - Plugin class instantiation
  89. - Dependency resolution
  90. - Error isolation and recovery
  91. - Hot-reload for development
  92. - Resource cleanup
  93. Plugin Directory Structure:
  94. ./plugins/plugin_name/
  95. ├── main.py # Contains plugin class extending TrixyPlugin
  96. ├── config.json # Plugin configuration
  97. └── config_view.py # Optional: Custom TUI configuration
  98. Usage:
  99. loader = PluginLoader(
  100. plugins_directory=Path("./plugins"),
  101. application=application_container
  102. )
  103. # Discover plugins
  104. discovered = loader.discover_plugins()
  105. # Load a specific plugin
  106. result = loader.load_plugin("my_plugin")
  107. # Hot reload during development
  108. result = loader.reload_plugin("my_plugin")
  109. # Unload plugin
  110. loader.unload_plugin("my_plugin")
  111. """
  112. def __init__(
  113. self,
  114. plugins_directory: Path,
  115. application: Any,
  116. auto_reload: bool = False,
  117. validate_compatibility: bool = True,
  118. enable_sandboxing: bool = False
  119. ):
  120. """
  121. Initialize the plugin loader.
  122. Args:
  123. plugins_directory: Directory containing plugin subdirectories
  124. application: Application container instance
  125. auto_reload: Enable automatic reloading when files change
  126. validate_compatibility: Validate plugin compatibility
  127. enable_sandboxing: Enable plugin sandboxing (future feature)
  128. """
  129. self.plugins_directory = Path(plugins_directory)
  130. self.application = application
  131. self.auto_reload = auto_reload
  132. self.validate_compatibility = validate_compatibility
  133. self.enable_sandboxing = enable_sandboxing
  134. # Loaded plugins tracking
  135. self._loaded_plugins: Dict[str, TrixyPlugin] = {}
  136. self._loaded_modules: Dict[str, Any] = {}
  137. self._plugin_paths: Dict[str, Path] = {}
  138. self._load_lock = threading.RLock()
  139. # Dependency tracking
  140. self._plugin_dependencies: Dict[str, Set[str]] = {}
  141. self._dependency_graph: Dict[str, Set[str]] = {}
  142. # File modification tracking for hot-reload
  143. self._file_timestamps: Dict[str, float] = {}
  144. self._watch_thread: Optional[threading.Thread] = None
  145. self._watch_shutdown = threading.Event()
  146. # Statistics
  147. self._load_stats = {
  148. 'total_loads': 0,
  149. 'successful_loads': 0,
  150. 'failed_loads': 0,
  151. 'reload_count': 0,
  152. 'discovery_count': 0
  153. }
  154. # Ensure plugins directory exists
  155. self.plugins_directory.mkdir(parents=True, exist_ok=True)
  156. pprint(f"PluginLoader initialized for directory: {plugins_directory}")
  157. # Start file watcher if auto-reload enabled
  158. if self.auto_reload:
  159. self._start_file_watcher()
  160. def discover_plugins(self) -> List[str]:
  161. """
  162. Discover all available plugins in the plugins directory.
  163. Returns:
  164. List of plugin names that were discovered
  165. Raises:
  166. PluginDiscoveryError: If discovery fails
  167. """
  168. try:
  169. discovered_plugins = []
  170. if not self.plugins_directory.exists():
  171. pprint(f"Plugins directory does not exist: {self.plugins_directory}")
  172. return []
  173. # Scan for plugin directories
  174. for item in self.plugins_directory.iterdir():
  175. if item.is_dir() and not item.name.startswith('.'):
  176. plugin_name = item.name
  177. main_file = item / "main.py"
  178. if main_file.exists():
  179. discovered_plugins.append(plugin_name)
  180. self._plugin_paths[plugin_name] = item
  181. pprint(f"Discovered plugin: {plugin_name}")
  182. else:
  183. pprint(f"Skipping {plugin_name}: no main.py found")
  184. self._load_stats['discovery_count'] = len(discovered_plugins)
  185. pprint(f"Discovery complete: found {len(discovered_plugins)} plugins")
  186. return discovered_plugins
  187. except Exception as e:
  188. error_msg = f"Plugin discovery failed: {e}"
  189. pprint(error_msg)
  190. raise PluginDiscoveryError(error_msg) from e
  191. def load_plugin(self, plugin_name: str, force_reload: bool = False) -> PluginLoadResult:
  192. """
  193. Load a specific plugin.
  194. Args:
  195. plugin_name: Name of the plugin to load
  196. force_reload: Force reload if already loaded
  197. Returns:
  198. PluginLoadResult with load status and details
  199. """
  200. start_time = time.time()
  201. with self._load_lock:
  202. try:
  203. # Check if already loaded
  204. if plugin_name in self._loaded_plugins and not force_reload:
  205. return PluginLoadResult(
  206. plugin_name=plugin_name,
  207. result=LoadResult.SKIPPED,
  208. plugin_instance=self._loaded_plugins[plugin_name],
  209. warnings=["Plugin already loaded"]
  210. )
  211. # Get plugin path
  212. if plugin_name not in self._plugin_paths:
  213. # Try to discover it
  214. discovered = self.discover_plugins()
  215. if plugin_name not in discovered:
  216. return PluginLoadResult(
  217. plugin_name=plugin_name,
  218. result=LoadResult.NOT_FOUND,
  219. error_message=f"Plugin {plugin_name} not found in {self.plugins_directory}"
  220. )
  221. plugin_path = self._plugin_paths[plugin_name]
  222. # Unload if already loaded (for reload)
  223. if plugin_name in self._loaded_plugins:
  224. self._unload_plugin_internal(plugin_name)
  225. # Load plugin module
  226. module = self._load_plugin_module(plugin_name, plugin_path)
  227. if module is None:
  228. return PluginLoadResult(
  229. plugin_name=plugin_name,
  230. result=LoadResult.FAILED,
  231. error_message="Failed to load plugin module"
  232. )
  233. # Find plugin class
  234. plugin_class = self._find_plugin_class(module, plugin_name)
  235. if plugin_class is None:
  236. return PluginLoadResult(
  237. plugin_name=plugin_name,
  238. result=LoadResult.INVALID,
  239. error_message="No valid TrixyPlugin class found in main.py"
  240. )
  241. # Load plugin configuration
  242. config_handler = PluginConfigHandler(
  243. config_path=plugin_path / "config.json",
  244. plugin_name=plugin_name
  245. )
  246. initial_config = config_handler.load_config()
  247. # Validate dependencies
  248. dependencies = initial_config.get('dependencies', [])
  249. if dependencies and not self._check_dependencies(plugin_name, dependencies):
  250. return PluginLoadResult(
  251. plugin_name=plugin_name,
  252. result=LoadResult.DEPENDENCY_ERROR,
  253. error_message=f"Unmet dependencies: {dependencies}"
  254. )
  255. # Validate compatibility
  256. if self.validate_compatibility:
  257. compat_result = self._check_compatibility(plugin_name, initial_config)
  258. if not compat_result[0]:
  259. return PluginLoadResult(
  260. plugin_name=plugin_name,
  261. result=LoadResult.COMPATIBILITY_ERROR,
  262. error_message=f"Compatibility check failed: {compat_result[1]}"
  263. )
  264. # Instantiate plugin
  265. plugin_instance = plugin_class(
  266. plugin_name=plugin_name,
  267. plugin_path=plugin_path,
  268. application=self.application,
  269. initial_config=initial_config
  270. )
  271. # Store loaded plugin
  272. self._loaded_plugins[plugin_name] = plugin_instance
  273. self._loaded_modules[plugin_name] = module
  274. self._plugin_dependencies[plugin_name] = set(dependencies)
  275. # Update file timestamps for hot-reload
  276. if self.auto_reload:
  277. self._update_file_timestamps(plugin_path)
  278. # Update statistics
  279. self._load_stats['total_loads'] += 1
  280. self._load_stats['successful_loads'] += 1
  281. load_time = time.time() - start_time
  282. pprint(f"Plugin {plugin_name} loaded successfully in {load_time:.3f}s")
  283. return PluginLoadResult(
  284. plugin_name=plugin_name,
  285. result=LoadResult.SUCCESS,
  286. plugin_instance=plugin_instance,
  287. load_time=load_time
  288. )
  289. except Exception as e:
  290. self._load_stats['total_loads'] += 1
  291. self._load_stats['failed_loads'] += 1
  292. error_msg = f"Failed to load plugin {plugin_name}: {e}"
  293. pprint(error_msg)
  294. pprint(traceback.format_exc())
  295. return PluginLoadResult(
  296. plugin_name=plugin_name,
  297. result=LoadResult.FAILED,
  298. error_message=error_msg,
  299. load_time=time.time() - start_time
  300. )
  301. def load_all_plugins(self) -> Dict[str, PluginLoadResult]:
  302. """
  303. Load all discovered plugins.
  304. Returns:
  305. Dict mapping plugin names to their load results
  306. """
  307. discovered_plugins = self.discover_plugins()
  308. results = {}
  309. # Sort plugins by dependencies (load dependencies first)
  310. sorted_plugins = self._sort_plugins_by_dependencies(discovered_plugins)
  311. for plugin_name in sorted_plugins:
  312. result = self.load_plugin(plugin_name)
  313. results[plugin_name] = result
  314. if result.success:
  315. pprint(f"✓ {plugin_name}")
  316. else:
  317. pprint(f"✗ {plugin_name}: {result.error_message}")
  318. successful = len([r for r in results.values() if r.success])
  319. pprint(f"Loaded {successful}/{len(results)} plugins successfully")
  320. return results
  321. def unload_plugin(self, plugin_name: str) -> bool:
  322. """
  323. Unload a specific plugin.
  324. Args:
  325. plugin_name: Name of plugin to unload
  326. Returns:
  327. bool: True if successfully unloaded
  328. """
  329. with self._load_lock:
  330. return self._unload_plugin_internal(plugin_name)
  331. def _unload_plugin_internal(self, plugin_name: str) -> bool:
  332. """Internal plugin unloading logic."""
  333. try:
  334. if plugin_name in self._loaded_plugins:
  335. plugin_instance = self._loaded_plugins[plugin_name]
  336. # Call plugin unload lifecycle method
  337. plugin_instance.unload_plugin()
  338. # Remove from tracking
  339. del self._loaded_plugins[plugin_name]
  340. if plugin_name in self._loaded_modules:
  341. del self._loaded_modules[plugin_name]
  342. if plugin_name in self._plugin_dependencies:
  343. del self._plugin_dependencies[plugin_name]
  344. pprint(f"Plugin {plugin_name} unloaded successfully")
  345. return True
  346. else:
  347. pprint(f"Plugin {plugin_name} not loaded")
  348. return False
  349. except Exception as e:
  350. pprint(f"Error unloading plugin {plugin_name}: {e}")
  351. return False
  352. def unload_all_plugins(self) -> Dict[str, bool]:
  353. """
  354. Unload all loaded plugins.
  355. Returns:
  356. Dict mapping plugin names to unload success status
  357. """
  358. results = {}
  359. # Unload in reverse dependency order
  360. plugin_names = list(self._loaded_plugins.keys())
  361. sorted_plugins = self._sort_plugins_by_dependencies(plugin_names, reverse=True)
  362. for plugin_name in sorted_plugins:
  363. results[plugin_name] = self.unload_plugin(plugin_name)
  364. return results
  365. def reload_plugin(self, plugin_name: str) -> PluginLoadResult:
  366. """
  367. Reload a plugin (hot-reload).
  368. Args:
  369. plugin_name: Name of plugin to reload
  370. Returns:
  371. PluginLoadResult with reload status
  372. """
  373. pprint(f"Reloading plugin {plugin_name}")
  374. self._load_stats['reload_count'] += 1
  375. # Force reload
  376. return self.load_plugin(plugin_name, force_reload=True)
  377. def _load_plugin_module(self, plugin_name: str, plugin_path: Path) -> Optional[Any]:
  378. """
  379. Load a plugin module dynamically.
  380. Args:
  381. plugin_name: Name of the plugin
  382. plugin_path: Path to the plugin directory
  383. Returns:
  384. Loaded module or None if failed
  385. """
  386. try:
  387. main_file = plugin_path / "main.py"
  388. # Create module name
  389. module_name = f"trixy_plugin_{plugin_name}"
  390. # Load module spec
  391. spec = importlib.util.spec_from_file_location(module_name, main_file)
  392. if spec is None or spec.loader is None:
  393. pprint(f"Failed to create module spec for {plugin_name}")
  394. return None
  395. # Create module
  396. module = importlib.util.module_from_spec(spec)
  397. # Add to sys.modules temporarily for imports to work
  398. sys.modules[module_name] = module
  399. # Execute module
  400. spec.loader.exec_module(module)
  401. pprint(f"Module {module_name} loaded successfully")
  402. return module
  403. except Exception as e:
  404. pprint(f"Failed to load module for plugin {plugin_name}: {e}")
  405. # Clean up sys.modules if we added it
  406. module_name = f"trixy_plugin_{plugin_name}"
  407. if module_name in sys.modules:
  408. del sys.modules[module_name]
  409. return None
  410. def _find_plugin_class(self, module: Any, plugin_name: str) -> Optional[Type[TrixyPlugin]]:
  411. """
  412. Find the TrixyPlugin class in the loaded module.
  413. Args:
  414. module: Loaded plugin module
  415. plugin_name: Name of the plugin
  416. Returns:
  417. Plugin class or None if not found
  418. """
  419. try:
  420. # Look for classes that inherit from TrixyPlugin
  421. for name, obj in inspect.getmembers(module):
  422. if (inspect.isclass(obj) and
  423. issubclass(obj, TrixyPlugin) and
  424. obj != TrixyPlugin):
  425. pprint(f"Found plugin class: {name} in {plugin_name}")
  426. return obj
  427. pprint(f"No TrixyPlugin subclass found in {plugin_name}")
  428. return None
  429. except Exception as e:
  430. pprint(f"Error finding plugin class in {plugin_name}: {e}")
  431. return None
  432. def _check_dependencies(self, plugin_name: str, dependencies: List[str]) -> bool:
  433. """
  434. Check if plugin dependencies are satisfied.
  435. Args:
  436. plugin_name: Name of the plugin
  437. dependencies: List of required dependency names
  438. Returns:
  439. bool: True if all dependencies are satisfied
  440. """
  441. for dep in dependencies:
  442. if dep not in self._loaded_plugins:
  443. pprint(f"Plugin {plugin_name} missing dependency: {dep}")
  444. return False
  445. return True
  446. def _check_compatibility(self, plugin_name: str, config: Dict[str, Any]) -> Tuple[bool, str]:
  447. """
  448. Check plugin compatibility.
  449. Args:
  450. plugin_name: Name of the plugin
  451. config: Plugin configuration
  452. Returns:
  453. Tuple of (is_compatible, error_message)
  454. """
  455. try:
  456. # Check minimum Trixy version
  457. min_version = config.get('min_trixy_version', '1.0.0')
  458. current_version = getattr(self.application, 'get_version', lambda: '1.0.0')()
  459. if self._version_compare(current_version, min_version) < 0:
  460. return False, f"Requires Trixy {min_version}, current is {current_version}"
  461. # Check Python version if specified
  462. min_python = config.get('min_python_version')
  463. if min_python:
  464. current_python = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
  465. if self._version_compare(current_python, min_python) < 0:
  466. return False, f"Requires Python {min_python}, current is {current_python}"
  467. return True, ""
  468. except Exception as e:
  469. return False, f"Compatibility check error: {e}"
  470. def _version_compare(self, version1: str, version2: str) -> int:
  471. """
  472. Compare version strings.
  473. Returns:
  474. -1 if version1 < version2
  475. 0 if version1 == version2
  476. 1 if version1 > version2
  477. """
  478. try:
  479. v1_parts = [int(x) for x in version1.split('.')]
  480. v2_parts = [int(x) for x in version2.split('.')]
  481. # Pad shorter version with zeros
  482. max_len = max(len(v1_parts), len(v2_parts))
  483. v1_parts += [0] * (max_len - len(v1_parts))
  484. v2_parts += [0] * (max_len - len(v2_parts))
  485. for v1, v2 in zip(v1_parts, v2_parts):
  486. if v1 < v2:
  487. return -1
  488. elif v1 > v2:
  489. return 1
  490. return 0
  491. except Exception:
  492. return 0
  493. def _sort_plugins_by_dependencies(self, plugin_names: List[str], reverse: bool = False) -> List[str]:
  494. """
  495. Sort plugins by dependency order.
  496. Args:
  497. plugin_names: List of plugin names to sort
  498. reverse: If True, sort in reverse dependency order
  499. Returns:
  500. Sorted list of plugin names
  501. """
  502. # Simple topological sort would go here
  503. # For now, just return the original order
  504. # This could be enhanced with proper dependency resolution
  505. result = plugin_names.copy()
  506. if reverse:
  507. result.reverse()
  508. return result
  509. def _start_file_watcher(self) -> None:
  510. """Start the file watcher thread for hot-reload."""
  511. if self._watch_thread is None or not self._watch_thread.is_alive():
  512. self._watch_thread = threading.Thread(
  513. target=self._file_watcher_loop,
  514. name="PluginFileWatcher",
  515. daemon=True
  516. )
  517. self._watch_thread.start()
  518. pprint("File watcher started for hot-reload")
  519. def _file_watcher_loop(self) -> None:
  520. """File watcher loop for detecting changes."""
  521. while not self._watch_shutdown.wait(2.0): # Check every 2 seconds
  522. try:
  523. for plugin_name in list(self._loaded_plugins.keys()):
  524. if self._check_plugin_files_changed(plugin_name):
  525. pprint(f"Files changed for plugin {plugin_name}, reloading...")
  526. self.reload_plugin(plugin_name)
  527. except Exception as e:
  528. pprint(f"Error in file watcher: {e}")
  529. def _check_plugin_files_changed(self, plugin_name: str) -> bool:
  530. """Check if plugin files have been modified."""
  531. if plugin_name not in self._plugin_paths:
  532. return False
  533. plugin_path = self._plugin_paths[plugin_name]
  534. main_file = plugin_path / "main.py"
  535. config_file = plugin_path / "config.json"
  536. files_to_check = [main_file]
  537. if config_file.exists():
  538. files_to_check.append(config_file)
  539. for file_path in files_to_check:
  540. if file_path.exists():
  541. current_mtime = file_path.stat().st_mtime
  542. stored_mtime = self._file_timestamps.get(str(file_path), 0)
  543. if current_mtime > stored_mtime:
  544. self._update_file_timestamps(plugin_path)
  545. return True
  546. return False
  547. def _update_file_timestamps(self, plugin_path: Path) -> None:
  548. """Update stored file timestamps for a plugin."""
  549. main_file = plugin_path / "main.py"
  550. config_file = plugin_path / "config.json"
  551. if main_file.exists():
  552. self._file_timestamps[str(main_file)] = main_file.stat().st_mtime
  553. if config_file.exists():
  554. self._file_timestamps[str(config_file)] = config_file.stat().st_mtime
  555. def shutdown(self) -> None:
  556. """Shutdown the plugin loader and clean up resources."""
  557. pprint("Shutting down plugin loader...")
  558. # Stop file watcher
  559. if self._watch_thread:
  560. self._watch_shutdown.set()
  561. self._watch_thread.join(timeout=5.0)
  562. # Unload all plugins
  563. self.unload_all_plugins()
  564. # Clean up module references
  565. module_names = [f"trixy_plugin_{name}" for name in self._loaded_modules.keys()]
  566. for module_name in module_names:
  567. if module_name in sys.modules:
  568. del sys.modules[module_name]
  569. pprint("Plugin loader shutdown complete")
  570. # Query and Status Methods
  571. def get_loaded_plugins(self) -> Dict[str, TrixyPlugin]:
  572. """Get all loaded plugin instances."""
  573. with self._load_lock:
  574. return self._loaded_plugins.copy()
  575. def is_plugin_loaded(self, plugin_name: str) -> bool:
  576. """Check if a plugin is loaded."""
  577. return plugin_name in self._loaded_plugins
  578. def get_plugin_info(self, plugin_name: str) -> Optional[Dict[str, Any]]:
  579. """Get information about a specific plugin."""
  580. if plugin_name not in self._loaded_plugins:
  581. return None
  582. plugin = self._loaded_plugins[plugin_name]
  583. return {
  584. 'name': plugin_name,
  585. 'state': plugin.state.value,
  586. 'enabled': plugin.enabled,
  587. 'path': str(plugin.plugin_path),
  588. 'load_time': plugin.load_time,
  589. 'init_time': plugin.init_time,
  590. 'dependencies': list(self._plugin_dependencies.get(plugin_name, [])),
  591. 'metadata': plugin.metadata.__dict__ if plugin.metadata else None,
  592. 'health': plugin.check_health()
  593. }
  594. def get_loader_stats(self) -> Dict[str, Any]:
  595. """Get loader statistics."""
  596. return {
  597. 'plugins_directory': str(self.plugins_directory),
  598. 'auto_reload': self.auto_reload,
  599. 'loaded_count': len(self._loaded_plugins),
  600. 'discovered_count': len(self._plugin_paths),
  601. 'stats': self._load_stats.copy()
  602. }
  603. def __repr__(self) -> str:
  604. """String representation of the plugin loader."""
  605. return f"<PluginLoader directory='{self.plugins_directory}' loaded={len(self._loaded_plugins)}>"