reloader.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. # -*- coding: utf-8 -*-
  2. """
  3. Plugin Hot-Reloader.
  4. Ermöglicht das automatische Neuladen von Plugins bei Änderungen.
  5. """
  6. from __future__ import annotations
  7. import asyncio
  8. from dataclasses import dataclass, field
  9. from datetime import datetime
  10. from pathlib import Path
  11. from typing import Any, Callable, TYPE_CHECKING
  12. from trixy_core.plugins.hotreload.watcher import (
  13. FileWatcher,
  14. WatchEvent,
  15. WatchEventType,
  16. )
  17. if TYPE_CHECKING:
  18. from trixy_core.plugins.plugin_manager import PluginManager
  19. @dataclass
  20. class ReloadConfig:
  21. """
  22. Konfiguration für Hot-Reload.
  23. Attributes:
  24. enabled: Hot-Reload aktiviert
  25. poll_interval: Polling-Intervall in Sekunden
  26. debounce_delay: Verzögerung vor Reload (sammelt mehrere Änderungen)
  27. extensions: Zu überwachende Dateierweiterungen
  28. auto_reload_on_error: Auch bei Fehlern neu laden versuchen
  29. preserve_state: Plugin-Zustand beim Reload erhalten
  30. """
  31. enabled: bool = True
  32. poll_interval: float = 1.0
  33. debounce_delay: float = 0.5
  34. extensions: list[str] = field(default_factory=lambda: [".py", ".json"])
  35. auto_reload_on_error: bool = False
  36. preserve_state: bool = True
  37. @dataclass
  38. class ReloadResult:
  39. """
  40. Ergebnis eines Reload-Vorgangs.
  41. Attributes:
  42. success: Reload erfolgreich
  43. plugin_name: Plugin-Name
  44. old_version: Alte Version
  45. new_version: Neue Version
  46. duration: Reload-Dauer in Sekunden
  47. error: Fehler-Nachricht bei Misserfolg
  48. state_preserved: Wurde Zustand erhalten
  49. """
  50. success: bool
  51. plugin_name: str
  52. old_version: str = ""
  53. new_version: str = ""
  54. duration: float = 0.0
  55. error: str = ""
  56. state_preserved: bool = False
  57. timestamp: datetime = field(default_factory=datetime.now)
  58. def to_dict(self) -> dict[str, Any]:
  59. """Konvertiert zu Dictionary."""
  60. return {
  61. "success": self.success,
  62. "plugin_name": self.plugin_name,
  63. "old_version": self.old_version,
  64. "new_version": self.new_version,
  65. "duration": self.duration,
  66. "error": self.error,
  67. "state_preserved": self.state_preserved,
  68. "timestamp": self.timestamp.isoformat(),
  69. }
  70. # Callback-Typen
  71. ReloadCallback = Callable[[ReloadResult], None]
  72. AsyncReloadCallback = Callable[[ReloadResult], Any]
  73. class PluginHotReloader:
  74. """
  75. Hot-Reloader für Plugins.
  76. Überwacht Plugin-Verzeichnisse und lädt Plugins automatisch
  77. neu, wenn Änderungen erkannt werden.
  78. Features:
  79. - Debouncing: Sammelt mehrere Änderungen
  80. - Zustandserhaltung: Kann Plugin-Zustand beim Reload erhalten
  81. - Fehlerbehandlung: Robuste Reload-Logik
  82. Example:
  83. # Mit PluginManager verwenden
  84. reloader = PluginHotReloader(plugin_manager)
  85. # Konfiguration anpassen
  86. reloader.config.debounce_delay = 1.0
  87. # Callback für Reload-Events
  88. @reloader.on_reload
  89. def handle_reload(result: ReloadResult):
  90. if result.success:
  91. print(f"Plugin {result.plugin_name} neu geladen")
  92. else:
  93. print(f"Reload fehlgeschlagen: {result.error}")
  94. # Starten
  95. await reloader.start()
  96. # Manueller Reload
  97. result = await reloader.reload_plugin("my_plugin")
  98. # Stoppen
  99. await reloader.stop()
  100. """
  101. def __init__(
  102. self,
  103. plugin_manager: "PluginManager",
  104. config: ReloadConfig | None = None,
  105. ) -> None:
  106. """
  107. Initialisiert den Hot-Reloader.
  108. Args:
  109. plugin_manager: Der PluginManager
  110. config: Reload-Konfiguration
  111. """
  112. self._manager = plugin_manager
  113. self._config = config or ReloadConfig()
  114. self._watcher: FileWatcher | None = None
  115. self._callbacks: list[ReloadCallback] = []
  116. self._async_callbacks: list[AsyncReloadCallback] = []
  117. self._pending_reloads: dict[str, asyncio.Task] = {}
  118. self._reload_history: list[ReloadResult] = []
  119. self._running = False
  120. @property
  121. def config(self) -> ReloadConfig:
  122. """Reload-Konfiguration."""
  123. return self._config
  124. @property
  125. def is_running(self) -> bool:
  126. """Läuft der Reloader?"""
  127. return self._running
  128. @property
  129. def history(self) -> list[ReloadResult]:
  130. """Reload-Historie."""
  131. return self._reload_history.copy()
  132. def on_reload(self, callback: ReloadCallback) -> ReloadCallback:
  133. """Decorator für Reload-Callbacks."""
  134. self._callbacks.append(callback)
  135. return callback
  136. def on_reload_async(
  137. self,
  138. callback: AsyncReloadCallback,
  139. ) -> AsyncReloadCallback:
  140. """Decorator für async Reload-Callbacks."""
  141. self._async_callbacks.append(callback)
  142. return callback
  143. async def start(self) -> None:
  144. """Startet den Hot-Reloader."""
  145. if not self._config.enabled:
  146. return
  147. if self._running:
  148. return
  149. self._running = True
  150. # FileWatcher erstellen
  151. self._watcher = FileWatcher(
  152. poll_interval=self._config.poll_interval,
  153. recursive=True,
  154. extensions=self._config.extensions,
  155. )
  156. # Plugin-Verzeichnis überwachen
  157. self._watcher.add_directory(self._manager._directory)
  158. # Callback für Änderungen
  159. self._watcher.add_async_callback(self._handle_change)
  160. await self._watcher.start()
  161. async def stop(self) -> None:
  162. """Stoppt den Hot-Reloader."""
  163. self._running = False
  164. # Pending Reloads abbrechen
  165. for task in self._pending_reloads.values():
  166. task.cancel()
  167. self._pending_reloads.clear()
  168. if self._watcher:
  169. await self._watcher.stop()
  170. self._watcher = None
  171. async def _handle_change(self, event: WatchEvent) -> None:
  172. """Behandelt Datei-Änderungen."""
  173. if not self._running:
  174. return
  175. # Plugin-Namen aus Pfad extrahieren
  176. plugin_name = self._get_plugin_from_path(event.path)
  177. if not plugin_name:
  178. return
  179. # Nur geladene Plugins berücksichtigen
  180. if plugin_name not in self._manager:
  181. return
  182. # Debouncing: Vorherigen Reload abbrechen
  183. if plugin_name in self._pending_reloads:
  184. self._pending_reloads[plugin_name].cancel()
  185. # Neuen Reload mit Verzögerung planen
  186. task = asyncio.create_task(
  187. self._debounced_reload(plugin_name)
  188. )
  189. self._pending_reloads[plugin_name] = task
  190. def _get_plugin_from_path(self, path: Path) -> str | None:
  191. """Extrahiert den Plugin-Namen aus einem Pfad."""
  192. try:
  193. # Relativen Pfad zum Plugin-Verzeichnis berechnen
  194. rel_path = path.relative_to(self._manager._directory)
  195. # Erstes Verzeichnis ist der Plugin-Name
  196. return rel_path.parts[0]
  197. except (ValueError, IndexError):
  198. return None
  199. async def _debounced_reload(self, plugin_name: str) -> None:
  200. """Führt einen verzögerten Reload durch."""
  201. try:
  202. await asyncio.sleep(self._config.debounce_delay)
  203. await self.reload_plugin(plugin_name)
  204. except asyncio.CancelledError:
  205. pass
  206. finally:
  207. self._pending_reloads.pop(plugin_name, None)
  208. async def reload_plugin(self, plugin_name: str) -> ReloadResult:
  209. """
  210. Lädt ein Plugin neu.
  211. Args:
  212. plugin_name: Name des Plugins
  213. Returns:
  214. ReloadResult
  215. """
  216. start_time = asyncio.get_event_loop().time()
  217. # Aktuelles Plugin holen
  218. old_plugin = self._manager.get(plugin_name)
  219. old_version = old_plugin.VERSION if old_plugin else ""
  220. # Zustand sichern
  221. state = None
  222. if self._config.preserve_state and old_plugin:
  223. state = self._save_plugin_state(old_plugin)
  224. # Reload durchführen
  225. try:
  226. success = await self._manager.reload(plugin_name)
  227. if not success:
  228. result = ReloadResult(
  229. success=False,
  230. plugin_name=plugin_name,
  231. old_version=old_version,
  232. error="Reload fehlgeschlagen",
  233. duration=asyncio.get_event_loop().time() - start_time,
  234. )
  235. else:
  236. new_plugin = self._manager.get(plugin_name)
  237. new_version = new_plugin.VERSION if new_plugin else ""
  238. # Zustand wiederherstellen
  239. state_preserved = False
  240. if state and new_plugin:
  241. state_preserved = self._restore_plugin_state(new_plugin, state)
  242. result = ReloadResult(
  243. success=True,
  244. plugin_name=plugin_name,
  245. old_version=old_version,
  246. new_version=new_version,
  247. duration=asyncio.get_event_loop().time() - start_time,
  248. state_preserved=state_preserved,
  249. )
  250. except Exception as e:
  251. result = ReloadResult(
  252. success=False,
  253. plugin_name=plugin_name,
  254. old_version=old_version,
  255. error=str(e),
  256. duration=asyncio.get_event_loop().time() - start_time,
  257. )
  258. # Historie aktualisieren
  259. self._reload_history.append(result)
  260. # Callbacks benachrichtigen
  261. await self._notify(result)
  262. return result
  263. def _save_plugin_state(self, plugin: Any) -> dict[str, Any]:
  264. """Sichert den Plugin-Zustand."""
  265. state = {}
  266. # Konfiguration sichern
  267. if hasattr(plugin, "config"):
  268. state["config"] = plugin.config.copy()
  269. # Enabled-Status sichern
  270. if hasattr(plugin, "enabled"):
  271. state["enabled"] = plugin.enabled
  272. # Custom-Zustand sichern wenn vorhanden
  273. if hasattr(plugin, "get_state") and callable(plugin.get_state):
  274. try:
  275. state["custom"] = plugin.get_state()
  276. except Exception:
  277. pass
  278. return state
  279. def _restore_plugin_state(
  280. self,
  281. plugin: Any,
  282. state: dict[str, Any],
  283. ) -> bool:
  284. """Stellt den Plugin-Zustand wieder her."""
  285. try:
  286. # Konfiguration wiederherstellen
  287. if "config" in state and hasattr(plugin, "config"):
  288. plugin.config.update(state["config"])
  289. # Enabled-Status wiederherstellen
  290. if "enabled" in state and hasattr(plugin, "enabled"):
  291. plugin.enabled = state["enabled"]
  292. # Custom-Zustand wiederherstellen
  293. if "custom" in state:
  294. if hasattr(plugin, "set_state") and callable(plugin.set_state):
  295. plugin.set_state(state["custom"])
  296. return True
  297. except Exception:
  298. return False
  299. async def _notify(self, result: ReloadResult) -> None:
  300. """Benachrichtigt alle Callbacks."""
  301. for callback in self._callbacks:
  302. try:
  303. callback(result)
  304. except Exception:
  305. pass
  306. for callback in self._async_callbacks:
  307. try:
  308. awaitable = callback(result)
  309. if asyncio.iscoroutine(awaitable):
  310. await awaitable
  311. except Exception:
  312. pass
  313. async def reload_all(self) -> list[ReloadResult]:
  314. """
  315. Lädt alle Plugins neu.
  316. Returns:
  317. Liste der Reload-Ergebnisse
  318. """
  319. results = []
  320. for plugin in self._manager.get_all():
  321. result = await self.reload_plugin(plugin.name)
  322. results.append(result)
  323. return results
  324. def get_pending_reloads(self) -> list[str]:
  325. """Gibt Namen der Plugins mit ausstehenden Reloads zurück."""
  326. return list(self._pending_reloads.keys())
  327. def cancel_pending(self, plugin_name: str | None = None) -> int:
  328. """
  329. Bricht ausstehende Reloads ab.
  330. Args:
  331. plugin_name: Spezifisches Plugin (None = alle)
  332. Returns:
  333. Anzahl abgebrochener Reloads
  334. """
  335. if plugin_name:
  336. if plugin_name in self._pending_reloads:
  337. self._pending_reloads[plugin_name].cancel()
  338. del self._pending_reloads[plugin_name]
  339. return 1
  340. return 0
  341. count = len(self._pending_reloads)
  342. for task in self._pending_reloads.values():
  343. task.cancel()
  344. self._pending_reloads.clear()
  345. return count
  346. def clear_history(self) -> None:
  347. """Löscht die Reload-Historie."""
  348. self._reload_history.clear()
  349. def get_statistics(self) -> dict[str, Any]:
  350. """Gibt Reload-Statistiken zurück."""
  351. total = len(self._reload_history)
  352. successful = sum(1 for r in self._reload_history if r.success)
  353. return {
  354. "total_reloads": total,
  355. "successful": successful,
  356. "failed": total - successful,
  357. "success_rate": successful / max(1, total),
  358. "pending_reloads": len(self._pending_reloads),
  359. "is_running": self._running,
  360. }