plugin_manager.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592
  1. # -*- coding: utf-8 -*-
  2. """
  3. PluginManager für das dynamische Laden und Verwalten von Plugins.
  4. """
  5. import asyncio
  6. import importlib.util
  7. import json
  8. import sys
  9. from pathlib import Path
  10. from typing import Any, Awaitable, Callable, Type, TYPE_CHECKING
  11. from trixy_core.plugins.trixy_plugin import TrixyPlugin
  12. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  13. from trixy_core.utils.logging.file_router import log_as
  14. if TYPE_CHECKING:
  15. from trixy_core.application import IApplication
  16. # Lazy import für Intent-System (verhindert zirkuläre Imports)
  17. _intent_registry = None
  18. _discover_handlers = None
  19. def _get_intent_registry():
  20. """Gibt die IntentRegistry-Instanz zurück (lazy loading)."""
  21. global _intent_registry
  22. if _intent_registry is None:
  23. from trixy_core.nlp.intent_registry import IntentRegistry
  24. _intent_registry = IntentRegistry.get_instance()
  25. return _intent_registry
  26. def _get_discover_handlers():
  27. """Gibt die discover_intent_handlers Funktion zurück (lazy loading)."""
  28. global _discover_handlers
  29. if _discover_handlers is None:
  30. from trixy_core.nlp.decorators import discover_intent_handlers
  31. _discover_handlers = discover_intent_handlers
  32. return _discover_handlers
  33. class PluginManager:
  34. """
  35. Verwaltet das Laden, Aktivieren und Deaktivieren von Plugins.
  36. Plugins werden aus dem konfigurierten Verzeichnis geladen.
  37. Jedes Plugin muss in einem eigenen Unterverzeichnis liegen
  38. mit einer main.py und optionaler config.json.
  39. """
  40. DEFAULT_DIRECTORY = "plugins"
  41. def __init__(
  42. self,
  43. application: "IApplication",
  44. directory: str | Path = DEFAULT_DIRECTORY,
  45. timeout_seconds: float = 30.0,
  46. max_failures: int = 3,
  47. ) -> None:
  48. """
  49. Initialisiert den PluginManager.
  50. Args:
  51. application: Referenz zur Hauptanwendung
  52. directory: Plugin-Verzeichnis
  53. timeout_seconds: Timeout fuer Plugin-Aufrufe in Sekunden
  54. max_failures: Maximale Fehlschlaege bevor Plugin deaktiviert wird
  55. """
  56. self._application = application
  57. self._directory = Path(directory)
  58. self._plugins: dict[str, TrixyPlugin] = {}
  59. self._failed_plugins: dict[str, str] = {} # Name -> Fehler
  60. self._timeout_seconds = timeout_seconds
  61. self._max_failures = max_failures
  62. self._failure_counts: dict[str, int] = {}
  63. @property
  64. def count(self) -> int:
  65. """Anzahl geladener Plugins."""
  66. return len(self._plugins)
  67. @property
  68. def enabled_count(self) -> int:
  69. """Anzahl aktivierter Plugins."""
  70. return sum(1 for p in self._plugins.values() if p.is_enabled())
  71. def get(self, name: str) -> TrixyPlugin | None:
  72. """
  73. Gibt ein Plugin nach Namen zurück.
  74. Args:
  75. name: Plugin-Name
  76. Returns:
  77. Plugin oder None
  78. """
  79. return self._plugins.get(name)
  80. def get_all(self) -> list[TrixyPlugin]:
  81. """Gibt alle geladenen Plugins zurück."""
  82. return list(self._plugins.values())
  83. def get_enabled(self) -> list[TrixyPlugin]:
  84. """Gibt alle aktivierten Plugins zurück."""
  85. return [p for p in self._plugins.values() if p.is_enabled()]
  86. def get_failed(self) -> dict[str, str]:
  87. """Gibt fehlgeschlagene Plugins mit Fehlern zurück."""
  88. return self._failed_plugins.copy()
  89. async def _call_plugin(
  90. self,
  91. plugin: TrixyPlugin,
  92. coro_factory: Callable[[], Awaitable[Any]],
  93. operation: str,
  94. ) -> bool:
  95. """
  96. Ruft eine Plugin-Coroutine mit ContextVar-Routing und Timeout-Schutz auf.
  97. Args:
  98. plugin: Plugin-Instanz
  99. coro_factory: Factory-Funktion die die Coroutine erzeugt
  100. operation: Name der Operation (fuer Logging)
  101. Returns:
  102. True bei Erfolg, False bei Fehler
  103. """
  104. with log_as(f"plugin.{plugin.name}"):
  105. try:
  106. await asyncio.wait_for(
  107. coro_factory(),
  108. timeout=self._timeout_seconds,
  109. )
  110. # Erfolg: Fehlerzaehler zuruecksetzen
  111. self._failure_counts.pop(plugin.name, None)
  112. return True
  113. except asyncio.TimeoutError:
  114. perror(
  115. f"Plugin '{plugin.name}' Timeout bei '{operation}' "
  116. f"(>{self._timeout_seconds}s)"
  117. )
  118. self._handle_plugin_failure(plugin.name, operation)
  119. return False
  120. except Exception as e:
  121. perror(f"Plugin '{plugin.name}' Fehler bei '{operation}': {e}")
  122. self._handle_plugin_failure(plugin.name, operation)
  123. return False
  124. def _handle_plugin_failure(self, plugin_name: str, operation: str) -> None:
  125. """
  126. Behandelt Plugin-Fehlschlaege und deaktiviert nach max_failures.
  127. Args:
  128. plugin_name: Name des fehlgeschlagenen Plugins
  129. operation: Name der fehlgeschlagenen Operation
  130. """
  131. count = self._failure_counts.get(plugin_name, 0) + 1
  132. self._failure_counts[plugin_name] = count
  133. if count >= self._max_failures:
  134. plugin = self._plugins.get(plugin_name)
  135. if plugin and plugin.is_enabled():
  136. pwarn(
  137. f"Plugin '{plugin_name}' nach {count} Fehlschlaegen "
  138. f"automatisch deaktiviert (letzte: '{operation}')"
  139. )
  140. plugin.enabled = False
  141. async def load_all(self) -> int:
  142. """
  143. Lädt alle Plugins aus dem Plugin-Verzeichnis.
  144. Returns:
  145. Anzahl erfolgreich geladener Plugins
  146. """
  147. if not self._directory.exists():
  148. self._directory.mkdir(parents=True, exist_ok=True)
  149. pinfo(f"Plugin-Verzeichnis erstellt: {self._directory}")
  150. return 0
  151. loaded = 0
  152. for plugin_dir in self._directory.iterdir():
  153. if not plugin_dir.is_dir():
  154. continue
  155. if plugin_dir.name.startswith("_") or plugin_dir.name.startswith("."):
  156. continue
  157. if await self.load(plugin_dir.name):
  158. loaded += 1
  159. pinfo(f"{loaded} Plugins geladen")
  160. return loaded
  161. async def load(self, name: str) -> bool:
  162. """
  163. Lädt ein einzelnes Plugin.
  164. Args:
  165. name: Name des Plugin-Verzeichnisses
  166. Returns:
  167. True bei Erfolg
  168. """
  169. plugin_dir = self._directory / name
  170. main_file = plugin_dir / "main.py"
  171. config_file = plugin_dir / "config.json"
  172. if not plugin_dir.exists():
  173. perror(f"Plugin-Verzeichnis nicht gefunden: {plugin_dir}")
  174. self._failed_plugins[name] = "Verzeichnis nicht gefunden"
  175. return False
  176. if not main_file.exists():
  177. perror(f"main.py nicht gefunden in: {plugin_dir}")
  178. self._failed_plugins[name] = "main.py nicht gefunden"
  179. return False
  180. try:
  181. # Konfiguration laden
  182. config = {}
  183. if config_file.exists():
  184. with open(config_file, "r", encoding="utf-8") as f:
  185. config = json.load(f)
  186. # Plugin-Modul laden
  187. spec = importlib.util.spec_from_file_location(
  188. f"trixy_plugins.{name}",
  189. main_file
  190. )
  191. if spec is None or spec.loader is None:
  192. raise ImportError(f"Kann Plugin-Spec nicht erstellen: {name}")
  193. module = importlib.util.module_from_spec(spec)
  194. sys.modules[spec.name] = module
  195. spec.loader.exec_module(module)
  196. # Plugin-Klasse finden
  197. plugin_class = self._find_plugin_class(module)
  198. if plugin_class is None:
  199. raise ImportError(f"Keine TrixyPlugin-Unterklasse gefunden in: {name}")
  200. # Prüfen ob Plugin aktiviert ist
  201. is_enabled = config.get("enabled", True)
  202. # Plugin instanziieren
  203. plugin = plugin_class(
  204. application=self._application,
  205. plugin_path=plugin_dir,
  206. config=config
  207. )
  208. # enabled-Status setzen
  209. plugin.enabled = is_enabled
  210. # Nur on_load und on_enable aufrufen wenn Plugin aktiviert ist
  211. if is_enabled:
  212. if not await self._call_plugin(plugin, plugin.on_load, "on_load"):
  213. self._failed_plugins[name] = "on_load fehlgeschlagen"
  214. return False
  215. plugin._loaded = True
  216. # Event-Handler registrieren
  217. if hasattr(self._application, "events"):
  218. self._application.events.register_object(plugin)
  219. # Intent-Handler registrieren
  220. with log_as(f"plugin.{plugin.name}"):
  221. self._register_intent_handlers(plugin)
  222. # on_enable aufrufen
  223. await self._call_plugin(plugin, plugin.on_enable, "on_enable")
  224. else:
  225. plugin._loaded = False
  226. self._plugins[plugin.name] = plugin
  227. self._failed_plugins.pop(name, None)
  228. if is_enabled:
  229. pinfo(f"Plugin geladen und aktiviert: {plugin.name} v{plugin.VERSION}")
  230. # plugin_loaded Event emittieren (damit NLP-Plugin Keyword-Matcher aktualisiert)
  231. if hasattr(self._application, "events"):
  232. from trixy_core.events.event_data.basic import PluginLoaded
  233. await self._application.events.trigger(
  234. "plugin_loaded",
  235. PluginLoaded(
  236. plugin_name=plugin.name,
  237. plugin_version=plugin.VERSION,
  238. ),
  239. )
  240. else:
  241. pdebug(f"Plugin registriert (deaktiviert): {plugin.name}")
  242. return True
  243. except Exception as e:
  244. error_msg = str(e)
  245. perror(f"Fehler beim Laden von Plugin '{name}': {error_msg}")
  246. self._failed_plugins[name] = error_msg
  247. return False
  248. def _find_plugin_class(self, module: object) -> Type[TrixyPlugin] | None:
  249. """Findet die Plugin-Klasse in einem Modul."""
  250. for attr_name in dir(module):
  251. if attr_name.startswith("_"):
  252. continue
  253. attr = getattr(module, attr_name)
  254. if (
  255. isinstance(attr, type) and
  256. issubclass(attr, TrixyPlugin) and
  257. attr is not TrixyPlugin
  258. ):
  259. return attr
  260. return None
  261. def _get_language(self) -> str:
  262. """Ermittelt die konfigurierte Sprache."""
  263. if self._application and hasattr(self._application, "config_manager"):
  264. try:
  265. lang = self._application.config_manager.get("language")
  266. if lang:
  267. return lang
  268. except Exception:
  269. pass
  270. return "de"
  271. def _register_intent_handlers(self, plugin: TrixyPlugin) -> int:
  272. """
  273. Registriert alle Intent-Handler eines Plugins.
  274. Durchsucht das Plugin nach @intent dekorierten Methoden
  275. und registriert diese in der IntentRegistry. Patterns und
  276. Examples werden bevorzugt aus YAML-Dateien geladen
  277. (intents_{sprache}.yml im Plugin-Verzeichnis).
  278. Args:
  279. plugin: Plugin-Instanz
  280. Returns:
  281. Anzahl registrierter Intents
  282. """
  283. try:
  284. discover_handlers = _get_discover_handlers()
  285. registry = _get_intent_registry()
  286. # YAML-Daten fuer Plugin laden (sprachspezifisch)
  287. yaml_data: dict = {}
  288. try:
  289. from trixy_core.nlp.intent_loader import load_intent_yaml, merge_yaml_into_metadata
  290. language = self._get_language()
  291. yaml_data = load_intent_yaml(plugin.plugin_path, language)
  292. except Exception as e:
  293. pdebug(f"YAML-Intent-Laden optional fehlgeschlagen fuer '{plugin.name}': {e}")
  294. handlers = discover_handlers(plugin)
  295. count = 0
  296. for intent_name, handler, metadata in handlers:
  297. from trixy_core.nlp.intent_registry import IntentDefinition, IntentSlot
  298. # YAML-Daten mergen (haben Vorrang vor Dekoratoren)
  299. if yaml_data:
  300. from trixy_core.nlp.intent_loader import merge_yaml_into_metadata
  301. merge_yaml_into_metadata(metadata, yaml_data)
  302. # Intent-Definition erstellen
  303. intent_def = IntentDefinition(
  304. name=intent_name,
  305. handler=handler,
  306. plugin_name=plugin.name,
  307. slots=metadata.get("slots", {}),
  308. examples=metadata.get("examples", []),
  309. description=metadata.get("description", ""),
  310. priority=metadata.get("priority", 0),
  311. enabled=True,
  312. patterns=metadata.get("patterns", []),
  313. )
  314. if registry.register(intent_def):
  315. count += 1
  316. pdebug(f"Intent registriert: {intent_name} (Plugin: {plugin.name})")
  317. if count > 0:
  318. pinfo(f"{count} Intent(s) registriert für Plugin: {plugin.name}")
  319. return count
  320. except Exception as e:
  321. perror(f"Fehler beim Registrieren von Intent-Handlern für '{plugin.name}': {e}")
  322. return 0
  323. def _unregister_intent_handlers(self, plugin: TrixyPlugin) -> int:
  324. """
  325. Entfernt alle Intent-Handler eines Plugins.
  326. Args:
  327. plugin: Plugin-Instanz
  328. Returns:
  329. Anzahl entfernter Intents
  330. """
  331. try:
  332. registry = _get_intent_registry()
  333. count = registry.unregister_plugin(plugin.name)
  334. if count > 0:
  335. pdebug(f"{count} Intent(s) entfernt für Plugin: {plugin.name}")
  336. return count
  337. except Exception as e:
  338. perror(f"Fehler beim Entfernen von Intent-Handlern für '{plugin.name}': {e}")
  339. return 0
  340. async def unload(self, name: str) -> bool:
  341. """
  342. Entlädt ein Plugin.
  343. Args:
  344. name: Plugin-Name
  345. Returns:
  346. True bei Erfolg
  347. """
  348. plugin = self._plugins.get(name)
  349. if plugin is None:
  350. pwarn(f"Plugin nicht geladen: {name}")
  351. return False
  352. try:
  353. # Intent-Handler entfernen
  354. with log_as(f"plugin.{plugin.name}"):
  355. self._unregister_intent_handlers(plugin)
  356. # Event-Handler entfernen
  357. if hasattr(self._application, "events"):
  358. self._application.events.unregister_object(plugin)
  359. # on_unload aufrufen
  360. await self._call_plugin(plugin, plugin.on_unload, "on_unload")
  361. del self._plugins[name]
  362. # Modul aus sys.modules entfernen
  363. module_name = f"trixy_plugins.{name}"
  364. if module_name in sys.modules:
  365. del sys.modules[module_name]
  366. pinfo(f"Plugin entladen: {name}")
  367. # plugin_unloaded Event emittieren
  368. if hasattr(self._application, "events"):
  369. from trixy_core.events.event_data.basic import PluginUnloaded
  370. await self._application.events.trigger(
  371. "plugin_unloaded",
  372. PluginUnloaded(plugin_name=name),
  373. )
  374. return True
  375. except Exception as e:
  376. perror(f"Fehler beim Entladen von Plugin '{name}': {e}")
  377. return False
  378. async def reload(self, name: str) -> bool:
  379. """
  380. Lädt ein Plugin neu.
  381. Args:
  382. name: Plugin-Name
  383. Returns:
  384. True bei Erfolg
  385. """
  386. if name in self._plugins:
  387. await self.unload(name)
  388. return await self.load(name)
  389. async def enable(self, name: str) -> bool:
  390. """
  391. Aktiviert ein Plugin.
  392. Args:
  393. name: Plugin-Name
  394. Returns:
  395. True bei Erfolg
  396. """
  397. plugin = self._plugins.get(name)
  398. if plugin is None:
  399. pwarn(f"Plugin nicht geladen: {name}")
  400. return False
  401. if plugin.is_enabled():
  402. return True
  403. try:
  404. plugin.enabled = True
  405. # on_load aufrufen falls noch nicht geschehen
  406. # (bei Plugins die beim Start deaktiviert waren)
  407. if not hasattr(plugin, "_loaded") or not plugin._loaded:
  408. if not await self._call_plugin(plugin, plugin.on_load, "on_load"):
  409. plugin.enabled = False
  410. return False
  411. plugin._loaded = True
  412. # Event-Handler registrieren
  413. if hasattr(self._application, "events"):
  414. self._application.events.register_object(plugin)
  415. # Intent-Handler registrieren
  416. with log_as(f"plugin.{plugin.name}"):
  417. self._register_intent_handlers(plugin)
  418. await self._call_plugin(plugin, plugin.on_enable, "on_enable")
  419. pinfo(f"Plugin aktiviert: {name}")
  420. return True
  421. except Exception as e:
  422. perror(f"Fehler beim Aktivieren von Plugin '{name}': {e}")
  423. plugin.enabled = False
  424. return False
  425. async def disable(self, name: str) -> bool:
  426. """
  427. Deaktiviert ein Plugin.
  428. Args:
  429. name: Plugin-Name
  430. Returns:
  431. True bei Erfolg
  432. """
  433. plugin = self._plugins.get(name)
  434. if plugin is None:
  435. pwarn(f"Plugin nicht geladen: {name}")
  436. return False
  437. if not plugin.is_enabled():
  438. return True
  439. try:
  440. await self._call_plugin(plugin, plugin.on_disable, "on_disable")
  441. plugin.enabled = False
  442. pinfo(f"Plugin deaktiviert: {name}")
  443. return True
  444. except Exception as e:
  445. perror(f"Fehler beim Deaktivieren von Plugin '{name}': {e}")
  446. return False
  447. async def unload_all(self) -> int:
  448. """
  449. Entlädt alle Plugins.
  450. Returns:
  451. Anzahl entladener Plugins
  452. """
  453. count = 0
  454. for name in list(self._plugins.keys()):
  455. if await self.unload(name):
  456. count += 1
  457. return count
  458. def __contains__(self, name: str) -> bool:
  459. """Prüft, ob ein Plugin geladen ist."""
  460. return name in self._plugins
  461. def __getitem__(self, name: str) -> TrixyPlugin:
  462. """Gibt ein Plugin nach Namen zurück."""
  463. if name not in self._plugins:
  464. raise KeyError(f"Plugin nicht gefunden: {name}")
  465. return self._plugins[name]
  466. def __iter__(self):
  467. """Iterator über alle Plugins."""
  468. return iter(self._plugins.values())
  469. def __len__(self) -> int:
  470. """Anzahl geladener Plugins."""
  471. return len(self._plugins)