trixy_plugin.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640
  1. """
  2. Base TrixyPlugin class and plugin state management.
  3. This module provides the base class that all Trixy plugins must extend,
  4. along with plugin state management and error handling.
  5. Features:
  6. - Base TrixyPlugin class with required interface
  7. - Plugin state management (loaded, initialized, enabled, disabled, error)
  8. - Integration with event system via @TrixyEvent decorator
  9. - Configuration management with auto-loading and type-casting
  10. - Plugin health monitoring and error isolation
  11. - Lifecycle management hooks
  12. - Thread-safe operations
  13. """
  14. import os
  15. import json
  16. import threading
  17. import time
  18. import traceback
  19. from typing import Any, Dict, Optional, Type, Union, List, Set
  20. from pathlib import Path
  21. from abc import ABC, abstractmethod
  22. from enum import Enum
  23. from dataclasses import dataclass
  24. import weakref
  25. # Import event system for decorator integration
  26. try:
  27. from ..events import (
  28. register_event_handlers,
  29. unregister_event_handlers,
  30. TrixyEvent
  31. )
  32. except ImportError as e:
  33. print(f"Warning: Could not import event system: {e}")
  34. register_event_handlers = None
  35. unregister_event_handlers = None
  36. TrixyEvent = None
  37. def pprint(message: str) -> None:
  38. """
  39. Plugin logging function that adapts based on mode.
  40. """
  41. print(f"[PLUGIN] {message}")
  42. class PluginState(Enum):
  43. """Plugin lifecycle states."""
  44. NOT_LOADED = "not_loaded"
  45. LOADING = "loading"
  46. LOADED = "loaded"
  47. INITIALIZING = "initializing"
  48. INITIALIZED = "initialized"
  49. ENABLING = "enabling"
  50. ENABLED = "enabled"
  51. DISABLING = "disabling"
  52. DISABLED = "disabled"
  53. UNLOADING = "unloading"
  54. ERROR = "error"
  55. @dataclass
  56. class PluginMetadata:
  57. """Plugin metadata extracted from config.json."""
  58. name: str
  59. version: str = "1.0.0"
  60. description: str = ""
  61. author: str = ""
  62. dependencies: List[str] = None
  63. min_trixy_version: str = "1.0.0"
  64. enabled_by_default: bool = True
  65. def __post_init__(self):
  66. if self.dependencies is None:
  67. self.dependencies = []
  68. class PluginError(Exception):
  69. """Base exception for plugin errors."""
  70. pass
  71. class PluginLoadError(PluginError):
  72. """Raised when a plugin fails to load."""
  73. pass
  74. class PluginConfigError(PluginError):
  75. """Raised when plugin configuration is invalid."""
  76. pass
  77. class PluginStateError(PluginError):
  78. """Raised when plugin is in wrong state for requested operation."""
  79. pass
  80. class TrixyPlugin(ABC):
  81. """
  82. Base class that all Trixy plugins must extend.
  83. This class provides:
  84. - Integration with application container and event system
  85. - Configuration management with auto-loading and type-casting
  86. - Plugin lifecycle management
  87. - Enable/disable functionality via config.json
  88. - Thread-safe state management
  89. - Health monitoring
  90. - Error isolation
  91. Required Properties (auto-loaded):
  92. - self.application: Application container reference
  93. - self.config: Loaded plugin configuration from config.json
  94. - self.enabled: Getter/setter for plugin enabled state
  95. Required Methods:
  96. - is_enabled(): Returns True if plugin is enabled
  97. - reload_config(): Reload configuration from config.json
  98. - save_config(): Save current configuration to config.json
  99. Plugin Directory Structure:
  100. ./plugins/plugin_name/
  101. ├── main.py # Contains plugin class extending TrixyPlugin
  102. ├── config.json # Plugin configuration with 'enabled' field
  103. └── config_view.py # Optional: Custom TUI configuration
  104. Example:
  105. class MyPlugin(TrixyPlugin):
  106. def initialize(self):
  107. pprint(f"Initializing {self.plugin_name}")
  108. # Plugin-specific initialization
  109. @TrixyEvent(["wakeword_received", "text_received"])
  110. def handle_events(self, event_name, event_data):
  111. if not self.is_enabled():
  112. return
  113. if event_name == "wakeword_received":
  114. # Handle wakeword
  115. pass
  116. elif event_name == "text_received":
  117. # Handle text
  118. pass
  119. """
  120. def __init__(
  121. self,
  122. plugin_name: str,
  123. plugin_path: Path,
  124. application: Any,
  125. initial_config: Optional[Dict[str, Any]] = None
  126. ):
  127. """
  128. Initialize the plugin base.
  129. Args:
  130. plugin_name: Name of the plugin
  131. plugin_path: Path to the plugin directory
  132. application: Application container instance
  133. initial_config: Initial configuration (optional)
  134. """
  135. # Core properties
  136. self._plugin_name = plugin_name
  137. self._plugin_path = Path(plugin_path)
  138. self._application = application
  139. self._application_ref = weakref.ref(application)
  140. # State management
  141. self._state = PluginState.LOADED
  142. self._state_lock = threading.RLock()
  143. self._last_error: Optional[str] = None
  144. self._error_count = 0
  145. self._load_time: Optional[float] = None
  146. self._init_time: Optional[float] = None
  147. # Configuration
  148. self._config: Dict[str, Any] = initial_config or {}
  149. self._config_lock = threading.RLock()
  150. self._config_file_path = self._plugin_path / "config.json"
  151. # Metadata
  152. self._metadata: Optional[PluginMetadata] = None
  153. # Event handlers tracking
  154. self._event_handlers_registered = False
  155. # Health monitoring
  156. self._last_health_check = time.time()
  157. self._health_status = "ok"
  158. pprint(f"Plugin {plugin_name} base initialized")
  159. # Required Properties (as specified in CLAUDE.md)
  160. @property
  161. def application(self) -> Any:
  162. """Get the application container instance."""
  163. app = self._application_ref()
  164. if app is None:
  165. raise PluginError(f"Application container no longer available for plugin {self.plugin_name}")
  166. return app
  167. @property
  168. def config(self) -> Dict[str, Any]:
  169. """Get the plugin configuration dictionary."""
  170. with self._config_lock:
  171. return self._config.copy()
  172. @property
  173. def enabled(self) -> bool:
  174. """Get whether the plugin is enabled."""
  175. with self._config_lock:
  176. return self._config.get('enabled', True)
  177. @enabled.setter
  178. def enabled(self, value: bool) -> None:
  179. """Set whether the plugin is enabled."""
  180. with self._config_lock:
  181. self._config['enabled'] = bool(value)
  182. # Auto-save configuration when enabled state changes
  183. self.save_config()
  184. # Plugin Information Properties
  185. @property
  186. def plugin_name(self) -> str:
  187. """Get the plugin name."""
  188. return self._plugin_name
  189. @property
  190. def plugin_path(self) -> Path:
  191. """Get the plugin directory path."""
  192. return self._plugin_path
  193. @property
  194. def state(self) -> PluginState:
  195. """Get the current plugin state."""
  196. with self._state_lock:
  197. return self._state
  198. @property
  199. def metadata(self) -> Optional[PluginMetadata]:
  200. """Get plugin metadata."""
  201. return self._metadata
  202. @property
  203. def last_error(self) -> Optional[str]:
  204. """Get the last error message."""
  205. return self._last_error
  206. @property
  207. def error_count(self) -> int:
  208. """Get the number of errors encountered."""
  209. return self._error_count
  210. @property
  211. def load_time(self) -> Optional[float]:
  212. """Get the plugin load time."""
  213. return self._load_time
  214. @property
  215. def init_time(self) -> Optional[float]:
  216. """Get the plugin initialization time."""
  217. return self._init_time
  218. # Required Methods (as specified in CLAUDE.md)
  219. def is_enabled(self) -> bool:
  220. """
  221. Check if the plugin is enabled.
  222. Returns:
  223. bool: True if plugin is enabled and in valid state
  224. """
  225. with self._state_lock:
  226. return (self.enabled and
  227. self._state in [PluginState.ENABLED, PluginState.INITIALIZED])
  228. def reload_config(self) -> None:
  229. """
  230. Reload configuration from config.json file.
  231. Raises:
  232. PluginConfigError: If configuration cannot be loaded
  233. """
  234. try:
  235. with self._config_lock:
  236. if self._config_file_path.exists():
  237. with open(self._config_file_path, 'r', encoding='utf-8') as f:
  238. config_data = json.load(f)
  239. # Preserve existing config and update with new values
  240. old_enabled = self._config.get('enabled', True)
  241. self._config.update(config_data)
  242. # Trigger state change if enabled status changed
  243. new_enabled = self._config.get('enabled', True)
  244. if old_enabled != new_enabled:
  245. pprint(f"Plugin {self.plugin_name} enabled status changed: {old_enabled} -> {new_enabled}")
  246. pprint(f"Configuration reloaded for plugin {self.plugin_name}")
  247. # Call subclass hook if available
  248. if hasattr(self, '_on_config_reloaded'):
  249. self._on_config_reloaded()
  250. else:
  251. pprint(f"No config file found for plugin {self.plugin_name}, using defaults")
  252. except Exception as e:
  253. error_msg = f"Failed to reload config for plugin {self.plugin_name}: {e}"
  254. self._record_error(error_msg)
  255. raise PluginConfigError(error_msg) from e
  256. def save_config(self) -> None:
  257. """
  258. Save current configuration to config.json file.
  259. Raises:
  260. PluginConfigError: If configuration cannot be saved
  261. """
  262. try:
  263. with self._config_lock:
  264. # Ensure plugin directory exists
  265. self._plugin_path.mkdir(parents=True, exist_ok=True)
  266. # Save configuration with proper formatting
  267. with open(self._config_file_path, 'w', encoding='utf-8') as f:
  268. json.dump(self._config, f, indent=2, sort_keys=True, ensure_ascii=False)
  269. pprint(f"Configuration saved for plugin {self.plugin_name}")
  270. except Exception as e:
  271. error_msg = f"Failed to save config for plugin {self.plugin_name}: {e}"
  272. self._record_error(error_msg)
  273. raise PluginConfigError(error_msg) from e
  274. # Lifecycle Management Methods
  275. def _set_state(self, new_state: PluginState) -> None:
  276. """Set plugin state thread-safely."""
  277. with self._state_lock:
  278. old_state = self._state
  279. self._state = new_state
  280. if old_state != new_state:
  281. pprint(f"Plugin {self.plugin_name} state: {old_state.value} -> {new_state.value}")
  282. def _record_error(self, error_message: str) -> None:
  283. """Record an error and update state."""
  284. with self._state_lock:
  285. self._last_error = error_message
  286. self._error_count += 1
  287. self._state = PluginState.ERROR
  288. self._health_status = f"error: {error_message}"
  289. pprint(f"Plugin {self.plugin_name} error: {error_message}")
  290. def _clear_error(self) -> None:
  291. """Clear error state."""
  292. with self._state_lock:
  293. self._last_error = None
  294. self._health_status = "ok"
  295. def initialize_plugin(self) -> None:
  296. """
  297. Initialize the plugin (internal lifecycle method).
  298. This method:
  299. 1. Loads/reloads configuration
  300. 2. Calls subclass initialize() method
  301. 3. Registers event handlers
  302. 4. Updates state to INITIALIZED
  303. Raises:
  304. PluginError: If initialization fails
  305. """
  306. if self._state != PluginState.LOADED:
  307. raise PluginStateError(f"Plugin {self.plugin_name} cannot be initialized from state {self._state.value}")
  308. try:
  309. self._set_state(PluginState.INITIALIZING)
  310. start_time = time.time()
  311. # Load configuration
  312. self.reload_config()
  313. # Extract metadata from config
  314. self._extract_metadata()
  315. # Call subclass initialization
  316. self.initialize()
  317. # Register event handlers
  318. if register_event_handlers:
  319. register_event_handlers(self)
  320. self._event_handlers_registered = True
  321. self._init_time = time.time() - start_time
  322. self._clear_error()
  323. self._set_state(PluginState.INITIALIZED)
  324. pprint(f"Plugin {self.plugin_name} initialized successfully in {self._init_time:.3f}s")
  325. except Exception as e:
  326. error_msg = f"Failed to initialize plugin {self.plugin_name}: {e}"
  327. self._record_error(error_msg)
  328. if hasattr(self, '_debug_mode') and self._debug_mode:
  329. pprint(traceback.format_exc())
  330. raise PluginError(error_msg) from e
  331. def enable_plugin(self) -> None:
  332. """Enable the plugin (internal lifecycle method)."""
  333. if self._state not in [PluginState.INITIALIZED, PluginState.DISABLED]:
  334. raise PluginStateError(f"Plugin {self.plugin_name} cannot be enabled from state {self._state.value}")
  335. try:
  336. self._set_state(PluginState.ENABLING)
  337. # Set enabled in config
  338. with self._config_lock:
  339. self._config['enabled'] = True
  340. # Call subclass enable hook if available
  341. if hasattr(self, 'on_enable'):
  342. self.on_enable()
  343. self._set_state(PluginState.ENABLED)
  344. pprint(f"Plugin {self.plugin_name} enabled")
  345. except Exception as e:
  346. error_msg = f"Failed to enable plugin {self.plugin_name}: {e}"
  347. self._record_error(error_msg)
  348. raise PluginError(error_msg) from e
  349. def disable_plugin(self) -> None:
  350. """Disable the plugin (internal lifecycle method)."""
  351. if self._state not in [PluginState.ENABLED, PluginState.INITIALIZED]:
  352. raise PluginStateError(f"Plugin {self.plugin_name} cannot be disabled from state {self._state.value}")
  353. try:
  354. self._set_state(PluginState.DISABLING)
  355. # Call subclass disable hook if available
  356. if hasattr(self, 'on_disable'):
  357. self.on_disable()
  358. # Set disabled in config
  359. with self._config_lock:
  360. self._config['enabled'] = False
  361. self._set_state(PluginState.DISABLED)
  362. pprint(f"Plugin {self.plugin_name} disabled")
  363. except Exception as e:
  364. error_msg = f"Failed to disable plugin {self.plugin_name}: {e}"
  365. self._record_error(error_msg)
  366. raise PluginError(error_msg) from e
  367. def unload_plugin(self) -> None:
  368. """Unload the plugin (internal lifecycle method)."""
  369. try:
  370. self._set_state(PluginState.UNLOADING)
  371. # Unregister event handlers
  372. if self._event_handlers_registered and unregister_event_handlers:
  373. unregister_event_handlers(self)
  374. self._event_handlers_registered = False
  375. # Call subclass cleanup
  376. if hasattr(self, 'cleanup'):
  377. self.cleanup()
  378. # Save final configuration
  379. self.save_config()
  380. pprint(f"Plugin {self.plugin_name} unloaded")
  381. except Exception as e:
  382. error_msg = f"Error during plugin {self.plugin_name} unload: {e}"
  383. pprint(error_msg)
  384. # Don't raise during unload to prevent cascade failures
  385. def _extract_metadata(self) -> None:
  386. """Extract metadata from configuration."""
  387. try:
  388. self._metadata = PluginMetadata(
  389. name=self._config.get('name', self.plugin_name),
  390. version=self._config.get('version', '1.0.0'),
  391. description=self._config.get('description', ''),
  392. author=self._config.get('author', ''),
  393. dependencies=self._config.get('dependencies', []),
  394. min_trixy_version=self._config.get('min_trixy_version', '1.0.0'),
  395. enabled_by_default=self._config.get('enabled_by_default', True)
  396. )
  397. except Exception as e:
  398. pprint(f"Warning: Failed to extract metadata for plugin {self.plugin_name}: {e}")
  399. def check_health(self) -> Dict[str, Any]:
  400. """
  401. Check plugin health status.
  402. Returns:
  403. Dict containing health information
  404. """
  405. self._last_health_check = time.time()
  406. return {
  407. 'plugin_name': self.plugin_name,
  408. 'state': self._state.value,
  409. 'enabled': self.enabled,
  410. 'healthy': self._state in [PluginState.ENABLED, PluginState.INITIALIZED] and self._last_error is None,
  411. 'last_error': self._last_error,
  412. 'error_count': self._error_count,
  413. 'load_time': self._load_time,
  414. 'init_time': self._init_time,
  415. 'last_health_check': self._last_health_check,
  416. 'health_status': self._health_status,
  417. 'metadata': {
  418. 'name': self._metadata.name if self._metadata else self.plugin_name,
  419. 'version': self._metadata.version if self._metadata else 'unknown',
  420. 'description': self._metadata.description if self._metadata else '',
  421. 'author': self._metadata.author if self._metadata else '',
  422. } if self._metadata else None
  423. }
  424. # Abstract Methods (must be implemented by subclasses)
  425. @abstractmethod
  426. def initialize(self) -> None:
  427. """
  428. Initialize the plugin. Must be implemented by subclasses.
  429. This method is called during plugin initialization and should:
  430. - Set up plugin-specific resources
  431. - Initialize plugin-specific state
  432. - Validate configuration
  433. - Prepare for event handling
  434. Raises:
  435. PluginError: If initialization fails
  436. """
  437. pass
  438. # Optional Hook Methods (can be overridden by subclasses)
  439. def on_enable(self) -> None:
  440. """Called when the plugin is enabled. Override if needed."""
  441. pass
  442. def on_disable(self) -> None:
  443. """Called when the plugin is disabled. Override if needed."""
  444. pass
  445. def cleanup(self) -> None:
  446. """Called when the plugin is unloaded. Override to clean up resources."""
  447. pass
  448. def get_config_schema(self) -> Dict[str, Any]:
  449. """
  450. Get the configuration schema for this plugin.
  451. Override to provide schema for configuration validation.
  452. Returns:
  453. Dict containing configuration schema
  454. """
  455. return {
  456. 'type': 'object',
  457. 'properties': {
  458. 'enabled': {
  459. 'type': 'boolean',
  460. 'default': True,
  461. 'description': 'Whether the plugin is enabled'
  462. }
  463. },
  464. 'required': ['enabled']
  465. }
  466. def validate_config(self) -> bool:
  467. """
  468. Validate the current configuration.
  469. Override to provide custom validation.
  470. Returns:
  471. bool: True if configuration is valid
  472. """
  473. # Basic validation - check that required fields exist
  474. return 'enabled' in self._config
  475. # Utility Methods
  476. def get_config_value(self, key: str, default: Any = None, type_cast: Type = None) -> Any:
  477. """
  478. Get a configuration value with optional type casting.
  479. Args:
  480. key: Configuration key
  481. default: Default value if key doesn't exist
  482. type_cast: Type to cast value to (int, float, bool, str)
  483. Returns:
  484. Configuration value
  485. """
  486. with self._config_lock:
  487. value = self._config.get(key, default)
  488. if value is not None and type_cast is not None:
  489. try:
  490. if type_cast == bool and isinstance(value, str):
  491. # Special handling for boolean strings
  492. value = value.lower() in ('true', '1', 'yes', 'on', 'enabled')
  493. else:
  494. value = type_cast(value)
  495. except (ValueError, TypeError) as e:
  496. pprint(f"Warning: Failed to cast config value {key} to {type_cast.__name__}: {e}")
  497. return default
  498. return value
  499. def set_config_value(self, key: str, value: Any, save: bool = True) -> None:
  500. """
  501. Set a configuration value.
  502. Args:
  503. key: Configuration key
  504. value: Value to set
  505. save: Whether to save configuration to file
  506. """
  507. with self._config_lock:
  508. self._config[key] = value
  509. if save:
  510. self.save_config()
  511. def __repr__(self) -> str:
  512. """String representation of the plugin."""
  513. return f"<TrixyPlugin name='{self.plugin_name}' state='{self._state.value}' enabled={self.enabled}>"