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

1"""Plugin lifecycle management and dependency resolution. 

2 

3This module provides advanced plugin lifecycle management including 

4dependency resolution, graceful enable/disable, lazy loading, and 

5hot reload capabilities. 

6""" 

7 

8from __future__ import annotations 

9 

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 

19 

20if TYPE_CHECKING: 

21 from collections.abc import Callable 

22 

23from tracekit.plugins.base import PluginBase, PluginMetadata 

24 

25logger = logging.getLogger(__name__) 

26 

27 

28class PluginState(Enum): 

29 """Plugin lifecycle states.""" 

30 

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 

39 

40 

41@dataclass 

42class PluginLoadError: 

43 """Plugin load error details. 

44 

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 """ 

52 

53 plugin_name: str 

54 error: Exception 

55 traceback: str = "" 

56 stage: str = "load" # discovery, load, configure, enable 

57 recoverable: bool = True 

58 

59 

60@dataclass 

61class DependencyInfo: 

62 """Plugin dependency information. 

63 

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 """ 

70 

71 name: str 

72 version_spec: str = "*" 

73 optional: bool = False 

74 resolved: bool = False 

75 

76 

77@dataclass 

78class PluginHandle: 

79 """Handle for managing a plugin instance. 

80 

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 """ 

90 

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 

98 

99 

100class DependencyGraph: 

101 """Dependency resolution graph for plugins. 

102 

103 Resolves plugin dependencies using topological sort to ensure 

104 correct load order and detect cycles. 

105 

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'] 

112 

113 References: 

114 PLUG-005: Dependency Resolution 

115 """ 

116 

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]] = {} 

123 

124 def add_plugin(self, name: str) -> None: 

125 """Add plugin node to graph. 

126 

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] = [] 

134 

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. 

143 

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) 

152 

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) 

158 

159 def resolve_order(self) -> list[str]: 

160 """Resolve topological order for loading. 

161 

162 Returns: 

163 List of plugin names in load order 

164 

165 Raises: 

166 ValueError: If circular dependency detected 

167 

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 = [] 

175 

176 while queue: 

177 node = queue.pop(0) 

178 result.append(node) 

179 

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) 

185 

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) 

190 

191 raise ValueError(f"Circular dependency detected: {' -> '.join([*cycle, cycle[0]])}") 

192 

193 return result 

194 

195 def _find_cycle(self, nodes: set[str]) -> list[str]: 

196 """Find a cycle in the dependency graph. 

197 

198 Args: 

199 nodes: Set of nodes that may be in a cycle 

200 

201 Returns: 

202 List of nodes forming a cycle 

203 

204 References: 

205 PLUG-005: Dependency Resolution - circular dependency detection 

206 """ 

207 visited: set[str] = set() 

208 rec_stack: list[str] = [] 

209 

210 def dfs(node: str) -> list[str] | None: 

211 visited.add(node) 

212 rec_stack.append(node) 

213 

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 

217 

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:] 

226 

227 rec_stack.pop() 

228 return None 

229 

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 

235 

236 return [] 

237 

238 def get_dependencies(self, plugin: str) -> list[DependencyInfo]: 

239 """Get dependencies for a plugin. 

240 

241 Args: 

242 plugin: Plugin name 

243 

244 Returns: 

245 List of dependencies 

246 """ 

247 return self._nodes.get(plugin, []) 

248 

249 def get_dependents(self, plugin: str) -> list[str]: 

250 """Get plugins that depend on given plugin. 

251 

252 Args: 

253 plugin: Plugin name 

254 

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 

263 

264 

265class PluginLifecycleManager: 

266 """Manager for plugin lifecycle operations. 

267 

268 Handles plugin loading, configuration, enabling/disabling, 

269 hot reload, and graceful degradation. 

270 

271 Example: 

272 >>> manager = PluginLifecycleManager() 

273 >>> manager.discover_plugins() 

274 >>> manager.load_plugin("uart_decoder") 

275 >>> manager.enable_plugin("uart_decoder") 

276 

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 """ 

284 

285 def __init__(self, plugin_dirs: list[Path] | None = None) -> None: 

286 """Initialize lifecycle manager. 

287 

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]] = [] 

298 

299 def discover_plugins(self) -> list[str]: 

300 """Discover available plugins. 

301 

302 Scans plugin directories for plugin manifests and Python files. 

303 

304 Returns: 

305 List of discovered plugin names 

306 

307 References: 

308 PLUG-007: Lazy Loading 

309 """ 

310 discovered = [] 

311 

312 for plugin_dir in self._plugin_dirs: 

313 if not plugin_dir.exists(): 

314 continue 

315 

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) 

327 

328 logger.info(f"Discovered {len(discovered)} plugins") 

329 return discovered 

330 

331 def _register_lazy_loader(self, name: str, path: Path) -> None: 

332 """Register lazy loader for a plugin. 

333 

334 Args: 

335 name: Plugin name 

336 path: Path to plugin 

337 

338 References: 

339 PLUG-007: Lazy Loading 

340 """ 

341 

342 def loader() -> PluginBase: 

343 return self._load_plugin_from_path(name, path) 

344 

345 self._lazy_loaders[name] = loader 

346 

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 

353 

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 

361 

362 def _load_plugin_from_path(self, name: str, path: Path) -> PluginBase: 

363 """Load plugin from path. 

364 

365 Args: 

366 name: Plugin name 

367 path: Path to plugin 

368 

369 Returns: 

370 Loaded plugin instance 

371 

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) 

379 

380 if spec is None or spec.loader is None: 

381 raise ImportError(f"Cannot load plugin from {path}") 

382 

383 module = importlib.util.module_from_spec(spec) 

384 sys.modules[name] = module 

385 spec.loader.exec_module(module) 

386 

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() 

392 

393 raise ImportError(f"No PluginBase subclass found in {path}") 

394 

395 def load_plugin( 

396 self, name: str, *, lazy: bool = True, resolve_deps: bool = True 

397 ) -> PluginHandle: 

398 """Load a plugin. 

399 

400 Args: 

401 name: Plugin name 

402 lazy: Use lazy loading if available 

403 resolve_deps: Resolve dependencies first 

404 

405 Returns: 

406 Plugin handle 

407 

408 Raises: 

409 Exception: If plugin loading or initialization fails. 

410 ValueError: If plugin not found or dependency resolution fails 

411 

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") 

420 

421 handle = self._handles[name] 

422 

423 if handle.state == PluginState.LOADED: 

424 return handle 

425 

426 # Resolve dependencies first 

427 if resolve_deps: 

428 self._resolve_dependencies(name) 

429 

430 handle.state = PluginState.LOADING 

431 self._notify_state_change(name, handle.state) 

432 

433 try: 

434 import time 

435 

436 start = time.time() 

437 

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)) 

443 

444 handle.instance = instance 

445 handle.metadata = instance.metadata 

446 handle.load_time = time.time() - start 

447 

448 # Call on_load 

449 instance.on_load() 

450 

451 handle.state = PluginState.LOADED 

452 self._notify_state_change(name, handle.state) 

453 

454 logger.info( 

455 f"Loaded plugin '{name}' v{handle.metadata.version} in {handle.load_time:.3f}s" 

456 ) 

457 

458 return handle 

459 

460 except Exception as e: 

461 import traceback 

462 

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) 

473 

474 logger.error(f"Failed to load plugin '{name}': {e}") 

475 raise 

476 

477 def _resolve_dependencies(self, name: str) -> None: 

478 """Resolve and load dependencies. 

479 

480 Args: 

481 name: Plugin name 

482 

483 Raises: 

484 ValueError: If required dependency not found 

485 

486 References: 

487 PLUG-005: Dependency Resolution 

488 """ 

489 handle = self._handles[name] 

490 

491 for dep in handle.dependencies: 

492 if dep.resolved: 

493 continue 

494 

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") 

501 

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) 

505 

506 dep.resolved = True 

507 

508 def configure_plugin(self, name: str, config: dict[str, Any]) -> PluginHandle: 

509 """Configure a loaded plugin. 

510 

511 Args: 

512 name: Plugin name 

513 config: Configuration dictionary 

514 

515 Returns: 

516 Updated plugin handle 

517 

518 Raises: 

519 ValueError: If plugin not found or in invalid state 

520 Exception: If configuration fails 

521 

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") 

529 

530 if handle.state not in (PluginState.LOADED, PluginState.CONFIGURED): 

531 raise ValueError(f"Cannot configure plugin in state {handle.state}") 

532 

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 

540 

541 except Exception as e: 

542 import traceback 

543 

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 

554 

555 def enable_plugin(self, name: str) -> PluginHandle: 

556 """Enable a configured plugin. 

557 

558 Args: 

559 name: Plugin name 

560 

561 Returns: 

562 Updated plugin handle 

563 

564 Raises: 

565 ValueError: If plugin not found 

566 

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") 

575 

576 if handle.state == PluginState.ENABLED: 

577 return handle 

578 

579 if handle.state == PluginState.DISCOVERED: 

580 self.load_plugin(name) 

581 handle = self._handles[name] 

582 

583 if handle.state == PluginState.LOADED: 

584 self.configure_plugin(name, {}) 

585 handle = self._handles[name] 

586 

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() 

590 

591 handle.state = PluginState.ENABLED 

592 self._notify_state_change(name, handle.state) 

593 logger.info(f"Enabled plugin '{name}'") 

594 return handle 

595 

596 def disable_plugin(self, name: str, force: bool = False) -> PluginHandle: 

597 """Disable a plugin. 

598 

599 Args: 

600 name: Plugin name 

601 force: Force disable even if dependents exist 

602 

603 Returns: 

604 Updated plugin handle 

605 

606 Raises: 

607 ValueError: If dependents exist and force=False 

608 

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") 

618 

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 ] 

625 

626 if dependents and not force: 

627 raise ValueError(f"Cannot disable '{name}': required by {dependents}") 

628 

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() 

632 

633 handle.state = PluginState.DISABLED 

634 self._notify_state_change(name, handle.state) 

635 logger.info(f"Disabled plugin '{name}'") 

636 return handle 

637 

638 def unload_plugin(self, name: str, force: bool = False) -> None: 

639 """Unload a plugin completely. 

640 

641 Args: 

642 name: Plugin name 

643 force: Force unload even if enabled 

644 

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 

652 

653 if handle.state == PluginState.ENABLED and not force: 

654 self.disable_plugin(name) 

655 

656 handle.state = PluginState.UNLOADING 

657 self._notify_state_change(name, handle.state) 

658 

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}") 

664 

665 handle.instance = None 

666 handle.state = PluginState.DISCOVERED 

667 self._notify_state_change(name, handle.state) 

668 logger.info(f"Unloaded plugin '{name}'") 

669 

670 def reload_plugin(self, name: str) -> PluginHandle: 

671 """Hot reload a plugin. 

672 

673 Args: 

674 name: Plugin name 

675 

676 Returns: 

677 Updated plugin handle 

678 

679 Raises: 

680 ValueError: If plugin not found 

681 

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") 

689 

690 was_enabled = handle.state == PluginState.ENABLED 

691 config = handle.instance._config if handle.instance else {} 

692 

693 # Preserve plugin state for restoration 

694 saved_state = self._save_plugin_state(handle) 

695 

696 # Unload and cleanup old references 

697 self.unload_plugin(name, force=True) 

698 self._cleanup_plugin_references(name) 

699 

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] 

706 

707 # Reload 

708 handle = self.load_plugin(name) 

709 

710 # Restore state 

711 self._restore_plugin_state(handle, saved_state) 

712 

713 if config: 

714 self.configure_plugin(name, config) 

715 

716 if was_enabled: 

717 self.enable_plugin(name) 

718 

719 logger.info(f"Hot reloaded plugin '{name}'") 

720 return handle 

721 

722 def _save_plugin_state(self, handle: PluginHandle) -> dict[str, Any]: 

723 """Save plugin state before reload. 

724 

725 Args: 

726 handle: Plugin handle 

727 

728 Returns: 

729 Saved state dictionary 

730 

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 

744 

745 def _restore_plugin_state(self, handle: PluginHandle, state: dict[str, Any]) -> None: 

746 """Restore plugin state after reload. 

747 

748 Args: 

749 handle: Plugin handle 

750 state: Saved state dictionary 

751 

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", []) 

759 

760 def _cleanup_plugin_references(self, name: str) -> None: 

761 """Clean up plugin references to prevent memory leaks. 

762 

763 Args: 

764 name: Plugin name 

765 

766 References: 

767 PLUG-006: Plugin Hot Reload - memory leak prevention 

768 """ 

769 import gc 

770 

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] 

774 

775 # Force garbage collection to clean up old references 

776 gc.collect() 

777 

778 logger.debug(f"Cleaned up references for plugin '{name}'") 

779 

780 def check_for_changes(self) -> list[str]: 

781 """Check for plugin file changes. 

782 

783 Returns: 

784 List of plugin names with changed files 

785 

786 References: 

787 PLUG-008: Plugin Hot Reload 

788 """ 

789 changed = [] 

790 

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 

802 

803 return changed 

804 

805 def auto_reload_changed(self) -> list[str]: 

806 """Automatically reload changed plugins. 

807 

808 Returns: 

809 List of reloaded plugin names 

810 

811 References: 

812 PLUG-008: Plugin Hot Reload 

813 """ 

814 changed = self.check_for_changes() 

815 reloaded = [] 

816 

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}") 

823 

824 return reloaded 

825 

826 def graceful_degradation(self, name: str) -> dict[str, Any]: 

827 """Handle plugin failure gracefully. 

828 

829 Returns fallback options when a plugin fails. 

830 

831 Args: 

832 name: Plugin name 

833 

834 Returns: 

835 Dictionary with degradation options 

836 

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": []} 

843 

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) 

853 

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 } 

861 

862 def get_handle(self, name: str) -> PluginHandle | None: 

863 """Get plugin handle. 

864 

865 Args: 

866 name: Plugin name 

867 

868 Returns: 

869 Plugin handle or None 

870 """ 

871 return self._handles.get(name) 

872 

873 def get_enabled_plugins(self) -> list[str]: 

874 """Get list of enabled plugins. 

875 

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 ] 

882 

883 def on_state_change(self, callback: Callable[[str, PluginState], None]) -> None: 

884 """Register state change callback. 

885 

886 Args: 

887 callback: Function called with (plugin_name, new_state) 

888 """ 

889 self._lifecycle_callbacks.append(callback) 

890 

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}") 

898 

899 def _get_plugin_path(self, name: str) -> Path: 

900 """Get path to plugin. 

901 

902 Args: 

903 name: Plugin name 

904 

905 Returns: 

906 Path to plugin 

907 

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 

920 

921 raise ValueError(f"Plugin path not found for '{name}'") 

922 

923 

924# Global lifecycle manager 

925_lifecycle_manager: PluginLifecycleManager | None = None 

926 

927 

928def get_lifecycle_manager() -> PluginLifecycleManager: 

929 """Get global lifecycle manager. 

930 

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 

938 

939 

940def set_plugin_directories(directories: list[Path]) -> None: 

941 """Set plugin directories for global manager. 

942 

943 Args: 

944 directories: List of plugin directories 

945 """ 

946 global _lifecycle_manager 

947 _lifecycle_manager = PluginLifecycleManager(directories) 

948 

949 

950__all__ = [ 

951 "DependencyGraph", 

952 "DependencyInfo", 

953 "PluginHandle", 

954 "PluginLifecycleManager", 

955 "PluginLoadError", 

956 "PluginState", 

957 "get_lifecycle_manager", 

958 "set_plugin_directories", 

959]