""" Base TrixyPlugin class and plugin state management. This module provides the base class that all Trixy plugins must extend, along with plugin state management and error handling. Features: - Base TrixyPlugin class with required interface - Plugin state management (loaded, initialized, enabled, disabled, error) - Integration with event system via @TrixyEvent decorator - Configuration management with auto-loading and type-casting - Plugin health monitoring and error isolation - Lifecycle management hooks - Thread-safe operations """ import os import json import threading import time import traceback from typing import Any, Dict, Optional, Type, Union, List, Set from pathlib import Path from abc import ABC, abstractmethod from enum import Enum from dataclasses import dataclass import weakref # Import event system for decorator integration try: from ..events import ( register_event_handlers, unregister_event_handlers, TrixyEvent ) except ImportError as e: print(f"Warning: Could not import event system: {e}") register_event_handlers = None unregister_event_handlers = None TrixyEvent = None def pprint(message: str) -> None: """ Plugin logging function that adapts based on mode. """ print(f"[PLUGIN] {message}") class PluginState(Enum): """Plugin lifecycle states.""" NOT_LOADED = "not_loaded" LOADING = "loading" LOADED = "loaded" INITIALIZING = "initializing" INITIALIZED = "initialized" ENABLING = "enabling" ENABLED = "enabled" DISABLING = "disabling" DISABLED = "disabled" UNLOADING = "unloading" ERROR = "error" @dataclass class PluginMetadata: """Plugin metadata extracted from config.json.""" name: str version: str = "1.0.0" description: str = "" author: str = "" dependencies: List[str] = None min_trixy_version: str = "1.0.0" enabled_by_default: bool = True def __post_init__(self): if self.dependencies is None: self.dependencies = [] class PluginError(Exception): """Base exception for plugin errors.""" pass class PluginLoadError(PluginError): """Raised when a plugin fails to load.""" pass class PluginConfigError(PluginError): """Raised when plugin configuration is invalid.""" pass class PluginStateError(PluginError): """Raised when plugin is in wrong state for requested operation.""" pass class TrixyPlugin(ABC): """ Base class that all Trixy plugins must extend. This class provides: - Integration with application container and event system - Configuration management with auto-loading and type-casting - Plugin lifecycle management - Enable/disable functionality via config.json - Thread-safe state management - Health monitoring - Error isolation Required Properties (auto-loaded): - self.application: Application container reference - self.config: Loaded plugin configuration from config.json - self.enabled: Getter/setter for plugin enabled state Required Methods: - is_enabled(): Returns True if plugin is enabled - reload_config(): Reload configuration from config.json - save_config(): Save current configuration to config.json Plugin Directory Structure: ./plugins/plugin_name/ ├── main.py # Contains plugin class extending TrixyPlugin ├── config.json # Plugin configuration with 'enabled' field └── config_view.py # Optional: Custom TUI configuration Example: class MyPlugin(TrixyPlugin): def initialize(self): pprint(f"Initializing {self.plugin_name}") # Plugin-specific initialization @TrixyEvent(["wakeword_received", "text_received"]) def handle_events(self, event_name, event_data): if not self.is_enabled(): return if event_name == "wakeword_received": # Handle wakeword pass elif event_name == "text_received": # Handle text pass """ def __init__( self, plugin_name: str, plugin_path: Path, application: Any, initial_config: Optional[Dict[str, Any]] = None ): """ Initialize the plugin base. Args: plugin_name: Name of the plugin plugin_path: Path to the plugin directory application: Application container instance initial_config: Initial configuration (optional) """ # Core properties self._plugin_name = plugin_name self._plugin_path = Path(plugin_path) self._application = application self._application_ref = weakref.ref(application) # State management self._state = PluginState.LOADED self._state_lock = threading.RLock() self._last_error: Optional[str] = None self._error_count = 0 self._load_time: Optional[float] = None self._init_time: Optional[float] = None # Configuration self._config: Dict[str, Any] = initial_config or {} self._config_lock = threading.RLock() self._config_file_path = self._plugin_path / "config.json" # Metadata self._metadata: Optional[PluginMetadata] = None # Event handlers tracking self._event_handlers_registered = False # Health monitoring self._last_health_check = time.time() self._health_status = "ok" pprint(f"Plugin {plugin_name} base initialized") # Required Properties (as specified in CLAUDE.md) @property def application(self) -> Any: """Get the application container instance.""" app = self._application_ref() if app is None: raise PluginError(f"Application container no longer available for plugin {self.plugin_name}") return app @property def config(self) -> Dict[str, Any]: """Get the plugin configuration dictionary.""" with self._config_lock: return self._config.copy() @property def enabled(self) -> bool: """Get whether the plugin is enabled.""" with self._config_lock: return self._config.get('enabled', True) @enabled.setter def enabled(self, value: bool) -> None: """Set whether the plugin is enabled.""" with self._config_lock: self._config['enabled'] = bool(value) # Auto-save configuration when enabled state changes self.save_config() # Plugin Information Properties @property def plugin_name(self) -> str: """Get the plugin name.""" return self._plugin_name @property def plugin_path(self) -> Path: """Get the plugin directory path.""" return self._plugin_path @property def state(self) -> PluginState: """Get the current plugin state.""" with self._state_lock: return self._state @property def metadata(self) -> Optional[PluginMetadata]: """Get plugin metadata.""" return self._metadata @property def last_error(self) -> Optional[str]: """Get the last error message.""" return self._last_error @property def error_count(self) -> int: """Get the number of errors encountered.""" return self._error_count @property def load_time(self) -> Optional[float]: """Get the plugin load time.""" return self._load_time @property def init_time(self) -> Optional[float]: """Get the plugin initialization time.""" return self._init_time # Required Methods (as specified in CLAUDE.md) def is_enabled(self) -> bool: """ Check if the plugin is enabled. Returns: bool: True if plugin is enabled and in valid state """ with self._state_lock: return (self.enabled and self._state in [PluginState.ENABLED, PluginState.INITIALIZED]) def reload_config(self) -> None: """ Reload configuration from config.json file. Raises: PluginConfigError: If configuration cannot be loaded """ try: with self._config_lock: if self._config_file_path.exists(): with open(self._config_file_path, 'r', encoding='utf-8') as f: config_data = json.load(f) # Preserve existing config and update with new values old_enabled = self._config.get('enabled', True) self._config.update(config_data) # Trigger state change if enabled status changed new_enabled = self._config.get('enabled', True) if old_enabled != new_enabled: pprint(f"Plugin {self.plugin_name} enabled status changed: {old_enabled} -> {new_enabled}") pprint(f"Configuration reloaded for plugin {self.plugin_name}") # Call subclass hook if available if hasattr(self, '_on_config_reloaded'): self._on_config_reloaded() else: pprint(f"No config file found for plugin {self.plugin_name}, using defaults") except Exception as e: error_msg = f"Failed to reload config for plugin {self.plugin_name}: {e}" self._record_error(error_msg) raise PluginConfigError(error_msg) from e def save_config(self) -> None: """ Save current configuration to config.json file. Raises: PluginConfigError: If configuration cannot be saved """ try: with self._config_lock: # Ensure plugin directory exists self._plugin_path.mkdir(parents=True, exist_ok=True) # Save configuration with proper formatting with open(self._config_file_path, 'w', encoding='utf-8') as f: json.dump(self._config, f, indent=2, sort_keys=True, ensure_ascii=False) pprint(f"Configuration saved for plugin {self.plugin_name}") except Exception as e: error_msg = f"Failed to save config for plugin {self.plugin_name}: {e}" self._record_error(error_msg) raise PluginConfigError(error_msg) from e # Lifecycle Management Methods def _set_state(self, new_state: PluginState) -> None: """Set plugin state thread-safely.""" with self._state_lock: old_state = self._state self._state = new_state if old_state != new_state: pprint(f"Plugin {self.plugin_name} state: {old_state.value} -> {new_state.value}") def _record_error(self, error_message: str) -> None: """Record an error and update state.""" with self._state_lock: self._last_error = error_message self._error_count += 1 self._state = PluginState.ERROR self._health_status = f"error: {error_message}" pprint(f"Plugin {self.plugin_name} error: {error_message}") def _clear_error(self) -> None: """Clear error state.""" with self._state_lock: self._last_error = None self._health_status = "ok" def initialize_plugin(self) -> None: """ Initialize the plugin (internal lifecycle method). This method: 1. Loads/reloads configuration 2. Calls subclass initialize() method 3. Registers event handlers 4. Updates state to INITIALIZED Raises: PluginError: If initialization fails """ if self._state != PluginState.LOADED: raise PluginStateError(f"Plugin {self.plugin_name} cannot be initialized from state {self._state.value}") try: self._set_state(PluginState.INITIALIZING) start_time = time.time() # Load configuration self.reload_config() # Extract metadata from config self._extract_metadata() # Call subclass initialization self.initialize() # Register event handlers if register_event_handlers: register_event_handlers(self) self._event_handlers_registered = True self._init_time = time.time() - start_time self._clear_error() self._set_state(PluginState.INITIALIZED) pprint(f"Plugin {self.plugin_name} initialized successfully in {self._init_time:.3f}s") except Exception as e: error_msg = f"Failed to initialize plugin {self.plugin_name}: {e}" self._record_error(error_msg) if hasattr(self, '_debug_mode') and self._debug_mode: pprint(traceback.format_exc()) raise PluginError(error_msg) from e def enable_plugin(self) -> None: """Enable the plugin (internal lifecycle method).""" if self._state not in [PluginState.INITIALIZED, PluginState.DISABLED]: raise PluginStateError(f"Plugin {self.plugin_name} cannot be enabled from state {self._state.value}") try: self._set_state(PluginState.ENABLING) # Set enabled in config with self._config_lock: self._config['enabled'] = True # Call subclass enable hook if available if hasattr(self, 'on_enable'): self.on_enable() self._set_state(PluginState.ENABLED) pprint(f"Plugin {self.plugin_name} enabled") except Exception as e: error_msg = f"Failed to enable plugin {self.plugin_name}: {e}" self._record_error(error_msg) raise PluginError(error_msg) from e def disable_plugin(self) -> None: """Disable the plugin (internal lifecycle method).""" if self._state not in [PluginState.ENABLED, PluginState.INITIALIZED]: raise PluginStateError(f"Plugin {self.plugin_name} cannot be disabled from state {self._state.value}") try: self._set_state(PluginState.DISABLING) # Call subclass disable hook if available if hasattr(self, 'on_disable'): self.on_disable() # Set disabled in config with self._config_lock: self._config['enabled'] = False self._set_state(PluginState.DISABLED) pprint(f"Plugin {self.plugin_name} disabled") except Exception as e: error_msg = f"Failed to disable plugin {self.plugin_name}: {e}" self._record_error(error_msg) raise PluginError(error_msg) from e def unload_plugin(self) -> None: """Unload the plugin (internal lifecycle method).""" try: self._set_state(PluginState.UNLOADING) # Unregister event handlers if self._event_handlers_registered and unregister_event_handlers: unregister_event_handlers(self) self._event_handlers_registered = False # Call subclass cleanup if hasattr(self, 'cleanup'): self.cleanup() # Save final configuration self.save_config() pprint(f"Plugin {self.plugin_name} unloaded") except Exception as e: error_msg = f"Error during plugin {self.plugin_name} unload: {e}" pprint(error_msg) # Don't raise during unload to prevent cascade failures def _extract_metadata(self) -> None: """Extract metadata from configuration.""" try: self._metadata = PluginMetadata( name=self._config.get('name', self.plugin_name), version=self._config.get('version', '1.0.0'), description=self._config.get('description', ''), author=self._config.get('author', ''), dependencies=self._config.get('dependencies', []), min_trixy_version=self._config.get('min_trixy_version', '1.0.0'), enabled_by_default=self._config.get('enabled_by_default', True) ) except Exception as e: pprint(f"Warning: Failed to extract metadata for plugin {self.plugin_name}: {e}") def check_health(self) -> Dict[str, Any]: """ Check plugin health status. Returns: Dict containing health information """ self._last_health_check = time.time() return { 'plugin_name': self.plugin_name, 'state': self._state.value, 'enabled': self.enabled, 'healthy': self._state in [PluginState.ENABLED, PluginState.INITIALIZED] and self._last_error is None, 'last_error': self._last_error, 'error_count': self._error_count, 'load_time': self._load_time, 'init_time': self._init_time, 'last_health_check': self._last_health_check, 'health_status': self._health_status, 'metadata': { 'name': self._metadata.name if self._metadata else self.plugin_name, 'version': self._metadata.version if self._metadata else 'unknown', 'description': self._metadata.description if self._metadata else '', 'author': self._metadata.author if self._metadata else '', } if self._metadata else None } # Abstract Methods (must be implemented by subclasses) @abstractmethod def initialize(self) -> None: """ Initialize the plugin. Must be implemented by subclasses. This method is called during plugin initialization and should: - Set up plugin-specific resources - Initialize plugin-specific state - Validate configuration - Prepare for event handling Raises: PluginError: If initialization fails """ pass # Optional Hook Methods (can be overridden by subclasses) def on_enable(self) -> None: """Called when the plugin is enabled. Override if needed.""" pass def on_disable(self) -> None: """Called when the plugin is disabled. Override if needed.""" pass def cleanup(self) -> None: """Called when the plugin is unloaded. Override to clean up resources.""" pass def get_config_schema(self) -> Dict[str, Any]: """ Get the configuration schema for this plugin. Override to provide schema for configuration validation. Returns: Dict containing configuration schema """ return { 'type': 'object', 'properties': { 'enabled': { 'type': 'boolean', 'default': True, 'description': 'Whether the plugin is enabled' } }, 'required': ['enabled'] } def validate_config(self) -> bool: """ Validate the current configuration. Override to provide custom validation. Returns: bool: True if configuration is valid """ # Basic validation - check that required fields exist return 'enabled' in self._config # Utility Methods def get_config_value(self, key: str, default: Any = None, type_cast: Type = None) -> Any: """ Get a configuration value with optional type casting. Args: key: Configuration key default: Default value if key doesn't exist type_cast: Type to cast value to (int, float, bool, str) Returns: Configuration value """ with self._config_lock: value = self._config.get(key, default) if value is not None and type_cast is not None: try: if type_cast == bool and isinstance(value, str): # Special handling for boolean strings value = value.lower() in ('true', '1', 'yes', 'on', 'enabled') else: value = type_cast(value) except (ValueError, TypeError) as e: pprint(f"Warning: Failed to cast config value {key} to {type_cast.__name__}: {e}") return default return value def set_config_value(self, key: str, value: Any, save: bool = True) -> None: """ Set a configuration value. Args: key: Configuration key value: Value to set save: Whether to save configuration to file """ with self._config_lock: self._config[key] = value if save: self.save_config() def __repr__(self) -> str: """String representation of the plugin.""" return f""