| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640 |
- """
- 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"<TrixyPlugin name='{self.plugin_name}' state='{self._state.value}' enabled={self.enabled}>"
|