# -*- coding: utf-8 -*- """ PluginManager für das dynamische Laden und Verwalten von Plugins. """ import asyncio import importlib.util import json import sys from pathlib import Path from typing import Any, Awaitable, Callable, Type, TYPE_CHECKING from trixy_core.plugins.trixy_plugin import TrixyPlugin from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn from trixy_core.utils.logging.file_router import log_as if TYPE_CHECKING: from trixy_core.application import IApplication # Lazy import für Intent-System (verhindert zirkuläre Imports) _intent_registry = None _discover_handlers = None def _get_intent_registry(): """Gibt die IntentRegistry-Instanz zurück (lazy loading).""" global _intent_registry if _intent_registry is None: from trixy_core.nlp.intent_registry import IntentRegistry _intent_registry = IntentRegistry.get_instance() return _intent_registry def _get_discover_handlers(): """Gibt die discover_intent_handlers Funktion zurück (lazy loading).""" global _discover_handlers if _discover_handlers is None: from trixy_core.nlp.decorators import discover_intent_handlers _discover_handlers = discover_intent_handlers return _discover_handlers class PluginManager: """ Verwaltet das Laden, Aktivieren und Deaktivieren von Plugins. Plugins werden aus dem konfigurierten Verzeichnis geladen. Jedes Plugin muss in einem eigenen Unterverzeichnis liegen mit einer main.py und optionaler config.json. """ DEFAULT_DIRECTORY = "plugins" def __init__( self, application: "IApplication", directory: str | Path = DEFAULT_DIRECTORY, timeout_seconds: float = 30.0, max_failures: int = 3, ) -> None: """ Initialisiert den PluginManager. Args: application: Referenz zur Hauptanwendung directory: Plugin-Verzeichnis timeout_seconds: Timeout fuer Plugin-Aufrufe in Sekunden max_failures: Maximale Fehlschlaege bevor Plugin deaktiviert wird """ self._application = application self._directory = Path(directory) self._plugins: dict[str, TrixyPlugin] = {} self._failed_plugins: dict[str, str] = {} # Name -> Fehler self._timeout_seconds = timeout_seconds self._max_failures = max_failures self._failure_counts: dict[str, int] = {} @property def count(self) -> int: """Anzahl geladener Plugins.""" return len(self._plugins) @property def enabled_count(self) -> int: """Anzahl aktivierter Plugins.""" return sum(1 for p in self._plugins.values() if p.is_enabled()) def get(self, name: str) -> TrixyPlugin | None: """ Gibt ein Plugin nach Namen zurück. Args: name: Plugin-Name Returns: Plugin oder None """ return self._plugins.get(name) def get_all(self) -> list[TrixyPlugin]: """Gibt alle geladenen Plugins zurück.""" return list(self._plugins.values()) def get_enabled(self) -> list[TrixyPlugin]: """Gibt alle aktivierten Plugins zurück.""" return [p for p in self._plugins.values() if p.is_enabled()] def get_failed(self) -> dict[str, str]: """Gibt fehlgeschlagene Plugins mit Fehlern zurück.""" return self._failed_plugins.copy() async def _call_plugin( self, plugin: TrixyPlugin, coro_factory: Callable[[], Awaitable[Any]], operation: str, ) -> bool: """ Ruft eine Plugin-Coroutine mit ContextVar-Routing und Timeout-Schutz auf. Args: plugin: Plugin-Instanz coro_factory: Factory-Funktion die die Coroutine erzeugt operation: Name der Operation (fuer Logging) Returns: True bei Erfolg, False bei Fehler """ with log_as(f"plugin.{plugin.name}"): try: await asyncio.wait_for( coro_factory(), timeout=self._timeout_seconds, ) # Erfolg: Fehlerzaehler zuruecksetzen self._failure_counts.pop(plugin.name, None) return True except asyncio.TimeoutError: perror( f"Plugin '{plugin.name}' Timeout bei '{operation}' " f"(>{self._timeout_seconds}s)" ) self._handle_plugin_failure(plugin.name, operation) return False except Exception as e: perror(f"Plugin '{plugin.name}' Fehler bei '{operation}': {e}") self._handle_plugin_failure(plugin.name, operation) return False def _handle_plugin_failure(self, plugin_name: str, operation: str) -> None: """ Behandelt Plugin-Fehlschlaege und deaktiviert nach max_failures. Args: plugin_name: Name des fehlgeschlagenen Plugins operation: Name der fehlgeschlagenen Operation """ count = self._failure_counts.get(plugin_name, 0) + 1 self._failure_counts[plugin_name] = count if count >= self._max_failures: plugin = self._plugins.get(plugin_name) if plugin and plugin.is_enabled(): pwarn( f"Plugin '{plugin_name}' nach {count} Fehlschlaegen " f"automatisch deaktiviert (letzte: '{operation}')" ) plugin.enabled = False async def load_all(self) -> int: """ Lädt alle Plugins aus dem Plugin-Verzeichnis. Returns: Anzahl erfolgreich geladener Plugins """ if not self._directory.exists(): self._directory.mkdir(parents=True, exist_ok=True) pinfo(f"Plugin-Verzeichnis erstellt: {self._directory}") return 0 loaded = 0 for plugin_dir in self._directory.iterdir(): if not plugin_dir.is_dir(): continue if plugin_dir.name.startswith("_") or plugin_dir.name.startswith("."): continue if await self.load(plugin_dir.name): loaded += 1 pinfo(f"{loaded} Plugins geladen") return loaded async def load(self, name: str) -> bool: """ Lädt ein einzelnes Plugin. Args: name: Name des Plugin-Verzeichnisses Returns: True bei Erfolg """ plugin_dir = self._directory / name main_file = plugin_dir / "main.py" config_file = plugin_dir / "config.json" if not plugin_dir.exists(): perror(f"Plugin-Verzeichnis nicht gefunden: {plugin_dir}") self._failed_plugins[name] = "Verzeichnis nicht gefunden" return False if not main_file.exists(): perror(f"main.py nicht gefunden in: {plugin_dir}") self._failed_plugins[name] = "main.py nicht gefunden" return False try: # Konfiguration laden config = {} if config_file.exists(): with open(config_file, "r", encoding="utf-8") as f: config = json.load(f) # Plugin-Modul laden spec = importlib.util.spec_from_file_location( f"trixy_plugins.{name}", main_file ) if spec is None or spec.loader is None: raise ImportError(f"Kann Plugin-Spec nicht erstellen: {name}") module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module spec.loader.exec_module(module) # Plugin-Klasse finden plugin_class = self._find_plugin_class(module) if plugin_class is None: raise ImportError(f"Keine TrixyPlugin-Unterklasse gefunden in: {name}") # Prüfen ob Plugin aktiviert ist is_enabled = config.get("enabled", True) # Plugin instanziieren plugin = plugin_class( application=self._application, plugin_path=plugin_dir, config=config ) # enabled-Status setzen plugin.enabled = is_enabled # Nur on_load und on_enable aufrufen wenn Plugin aktiviert ist if is_enabled: if not await self._call_plugin(plugin, plugin.on_load, "on_load"): self._failed_plugins[name] = "on_load fehlgeschlagen" return False plugin._loaded = True # Event-Handler registrieren if hasattr(self._application, "events"): self._application.events.register_object(plugin) # Intent-Handler registrieren with log_as(f"plugin.{plugin.name}"): self._register_intent_handlers(plugin) # on_enable aufrufen await self._call_plugin(plugin, plugin.on_enable, "on_enable") else: plugin._loaded = False self._plugins[plugin.name] = plugin self._failed_plugins.pop(name, None) if is_enabled: pinfo(f"Plugin geladen und aktiviert: {plugin.name} v{plugin.VERSION}") # plugin_loaded Event emittieren (damit NLP-Plugin Keyword-Matcher aktualisiert) if hasattr(self._application, "events"): from trixy_core.events.event_data.basic import PluginLoaded await self._application.events.trigger( "plugin_loaded", PluginLoaded( plugin_name=plugin.name, plugin_version=plugin.VERSION, ), ) else: pdebug(f"Plugin registriert (deaktiviert): {plugin.name}") return True except Exception as e: error_msg = str(e) perror(f"Fehler beim Laden von Plugin '{name}': {error_msg}") self._failed_plugins[name] = error_msg return False def _find_plugin_class(self, module: object) -> Type[TrixyPlugin] | None: """Findet die Plugin-Klasse in einem Modul.""" for attr_name in dir(module): if attr_name.startswith("_"): continue attr = getattr(module, attr_name) if ( isinstance(attr, type) and issubclass(attr, TrixyPlugin) and attr is not TrixyPlugin ): return attr return None def _get_language(self) -> str: """Ermittelt die konfigurierte Sprache.""" if self._application and hasattr(self._application, "config_manager"): try: lang = self._application.config_manager.get("language") if lang: return lang except Exception: pass return "de" def _register_intent_handlers(self, plugin: TrixyPlugin) -> int: """ Registriert alle Intent-Handler eines Plugins. Durchsucht das Plugin nach @intent dekorierten Methoden und registriert diese in der IntentRegistry. Patterns und Examples werden bevorzugt aus YAML-Dateien geladen (intents_{sprache}.yml im Plugin-Verzeichnis). Args: plugin: Plugin-Instanz Returns: Anzahl registrierter Intents """ try: discover_handlers = _get_discover_handlers() registry = _get_intent_registry() # YAML-Daten fuer Plugin laden (sprachspezifisch) yaml_data: dict = {} try: from trixy_core.nlp.intent_loader import load_intent_yaml, merge_yaml_into_metadata language = self._get_language() yaml_data = load_intent_yaml(plugin.plugin_path, language) except Exception as e: pdebug(f"YAML-Intent-Laden optional fehlgeschlagen fuer '{plugin.name}': {e}") handlers = discover_handlers(plugin) count = 0 for intent_name, handler, metadata in handlers: from trixy_core.nlp.intent_registry import IntentDefinition, IntentSlot # YAML-Daten mergen (haben Vorrang vor Dekoratoren) if yaml_data: from trixy_core.nlp.intent_loader import merge_yaml_into_metadata merge_yaml_into_metadata(metadata, yaml_data) # Intent-Definition erstellen intent_def = IntentDefinition( name=intent_name, handler=handler, plugin_name=plugin.name, slots=metadata.get("slots", {}), examples=metadata.get("examples", []), description=metadata.get("description", ""), priority=metadata.get("priority", 0), enabled=True, patterns=metadata.get("patterns", []), ) if registry.register(intent_def): count += 1 pdebug(f"Intent registriert: {intent_name} (Plugin: {plugin.name})") if count > 0: pinfo(f"{count} Intent(s) registriert für Plugin: {plugin.name}") return count except Exception as e: perror(f"Fehler beim Registrieren von Intent-Handlern für '{plugin.name}': {e}") return 0 def _unregister_intent_handlers(self, plugin: TrixyPlugin) -> int: """ Entfernt alle Intent-Handler eines Plugins. Args: plugin: Plugin-Instanz Returns: Anzahl entfernter Intents """ try: registry = _get_intent_registry() count = registry.unregister_plugin(plugin.name) if count > 0: pdebug(f"{count} Intent(s) entfernt für Plugin: {plugin.name}") return count except Exception as e: perror(f"Fehler beim Entfernen von Intent-Handlern für '{plugin.name}': {e}") return 0 async def unload(self, name: str) -> bool: """ Entlädt ein Plugin. Args: name: Plugin-Name Returns: True bei Erfolg """ plugin = self._plugins.get(name) if plugin is None: pwarn(f"Plugin nicht geladen: {name}") return False try: # Intent-Handler entfernen with log_as(f"plugin.{plugin.name}"): self._unregister_intent_handlers(plugin) # Event-Handler entfernen if hasattr(self._application, "events"): self._application.events.unregister_object(plugin) # on_unload aufrufen await self._call_plugin(plugin, plugin.on_unload, "on_unload") del self._plugins[name] # Modul aus sys.modules entfernen module_name = f"trixy_plugins.{name}" if module_name in sys.modules: del sys.modules[module_name] pinfo(f"Plugin entladen: {name}") # plugin_unloaded Event emittieren if hasattr(self._application, "events"): from trixy_core.events.event_data.basic import PluginUnloaded await self._application.events.trigger( "plugin_unloaded", PluginUnloaded(plugin_name=name), ) return True except Exception as e: perror(f"Fehler beim Entladen von Plugin '{name}': {e}") return False async def reload(self, name: str) -> bool: """ Lädt ein Plugin neu. Args: name: Plugin-Name Returns: True bei Erfolg """ if name in self._plugins: await self.unload(name) return await self.load(name) async def enable(self, name: str) -> bool: """ Aktiviert ein Plugin. Args: name: Plugin-Name Returns: True bei Erfolg """ plugin = self._plugins.get(name) if plugin is None: pwarn(f"Plugin nicht geladen: {name}") return False if plugin.is_enabled(): return True try: plugin.enabled = True # on_load aufrufen falls noch nicht geschehen # (bei Plugins die beim Start deaktiviert waren) if not hasattr(plugin, "_loaded") or not plugin._loaded: if not await self._call_plugin(plugin, plugin.on_load, "on_load"): plugin.enabled = False return False plugin._loaded = True # Event-Handler registrieren if hasattr(self._application, "events"): self._application.events.register_object(plugin) # Intent-Handler registrieren with log_as(f"plugin.{plugin.name}"): self._register_intent_handlers(plugin) await self._call_plugin(plugin, plugin.on_enable, "on_enable") pinfo(f"Plugin aktiviert: {name}") return True except Exception as e: perror(f"Fehler beim Aktivieren von Plugin '{name}': {e}") plugin.enabled = False return False async def disable(self, name: str) -> bool: """ Deaktiviert ein Plugin. Args: name: Plugin-Name Returns: True bei Erfolg """ plugin = self._plugins.get(name) if plugin is None: pwarn(f"Plugin nicht geladen: {name}") return False if not plugin.is_enabled(): return True try: await self._call_plugin(plugin, plugin.on_disable, "on_disable") plugin.enabled = False pinfo(f"Plugin deaktiviert: {name}") return True except Exception as e: perror(f"Fehler beim Deaktivieren von Plugin '{name}': {e}") return False async def unload_all(self) -> int: """ Entlädt alle Plugins. Returns: Anzahl entladener Plugins """ count = 0 for name in list(self._plugins.keys()): if await self.unload(name): count += 1 return count def __contains__(self, name: str) -> bool: """Prüft, ob ein Plugin geladen ist.""" return name in self._plugins def __getitem__(self, name: str) -> TrixyPlugin: """Gibt ein Plugin nach Namen zurück.""" if name not in self._plugins: raise KeyError(f"Plugin nicht gefunden: {name}") return self._plugins[name] def __iter__(self): """Iterator über alle Plugins.""" return iter(self._plugins.values()) def __len__(self) -> int: """Anzahl geladener Plugins.""" return len(self._plugins)