watcher.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. # -*- coding: utf-8 -*-
  2. """
  3. Dateiüberwachung für Plugin Hot-Reload.
  4. Beobachtet Plugin-Verzeichnisse auf Änderungen.
  5. """
  6. from __future__ import annotations
  7. import asyncio
  8. import os
  9. from dataclasses import dataclass, field
  10. from datetime import datetime
  11. from enum import Enum, auto
  12. from pathlib import Path
  13. from typing import Any, Callable
  14. class WatchEventType(Enum):
  15. """Typ eines Datei-Events."""
  16. CREATED = auto() # Datei erstellt
  17. MODIFIED = auto() # Datei geändert
  18. DELETED = auto() # Datei gelöscht
  19. MOVED = auto() # Datei verschoben
  20. @dataclass
  21. class WatchEvent:
  22. """
  23. Ein Datei-Änderungs-Event.
  24. Attributes:
  25. event_type: Art der Änderung
  26. path: Betroffener Pfad
  27. is_directory: Ist ein Verzeichnis
  28. timestamp: Zeitpunkt
  29. old_path: Alter Pfad bei MOVED
  30. """
  31. event_type: WatchEventType
  32. path: Path
  33. is_directory: bool = False
  34. timestamp: datetime = field(default_factory=datetime.now)
  35. old_path: Path | None = None
  36. def to_dict(self) -> dict[str, Any]:
  37. """Konvertiert zu Dictionary."""
  38. return {
  39. "event_type": self.event_type.name,
  40. "path": str(self.path),
  41. "is_directory": self.is_directory,
  42. "timestamp": self.timestamp.isoformat(),
  43. "old_path": str(self.old_path) if self.old_path else None,
  44. }
  45. # Callback-Typ
  46. WatchCallback = Callable[[WatchEvent], None]
  47. AsyncWatchCallback = Callable[[WatchEvent], Any] # Koroutine
  48. class FileWatcher:
  49. """
  50. Überwacht Verzeichnisse auf Änderungen.
  51. Verwendet Polling für maximale Kompatibilität.
  52. Optional kann watchdog verwendet werden wenn verfügbar.
  53. Example:
  54. watcher = FileWatcher()
  55. # Verzeichnis hinzufügen
  56. watcher.add_directory(Path("plugins"))
  57. # Callback registrieren
  58. @watcher.on_change
  59. def handle_change(event: WatchEvent):
  60. print(f"{event.event_type.name}: {event.path}")
  61. # Überwachung starten
  62. await watcher.start()
  63. # ... später ...
  64. await watcher.stop()
  65. """
  66. DEFAULT_POLL_INTERVAL = 1.0 # Sekunden
  67. def __init__(
  68. self,
  69. poll_interval: float = DEFAULT_POLL_INTERVAL,
  70. recursive: bool = True,
  71. extensions: list[str] | None = None,
  72. ) -> None:
  73. """
  74. Initialisiert den FileWatcher.
  75. Args:
  76. poll_interval: Intervall für Polling in Sekunden
  77. recursive: Unterverzeichnisse einbeziehen
  78. extensions: Nur diese Dateiendungen (z.B. [".py", ".json"])
  79. """
  80. self._poll_interval = poll_interval
  81. self._recursive = recursive
  82. self._extensions = set(extensions) if extensions else None
  83. self._directories: set[Path] = set()
  84. self._callbacks: list[WatchCallback] = []
  85. self._async_callbacks: list[AsyncWatchCallback] = []
  86. self._running = False
  87. self._task: asyncio.Task | None = None
  88. # Snapshot für Änderungserkennung
  89. self._snapshot: dict[Path, float] = {} # path -> mtime
  90. @property
  91. def is_running(self) -> bool:
  92. """Läuft die Überwachung?"""
  93. return self._running
  94. @property
  95. def watched_directories(self) -> list[Path]:
  96. """Liste überwachter Verzeichnisse."""
  97. return list(self._directories)
  98. def add_directory(self, path: Path) -> bool:
  99. """
  100. Fügt ein Verzeichnis zur Überwachung hinzu.
  101. Args:
  102. path: Zu überwachendes Verzeichnis
  103. Returns:
  104. True wenn hinzugefügt
  105. """
  106. if not path.exists() or not path.is_dir():
  107. return False
  108. self._directories.add(path)
  109. self._update_snapshot(path)
  110. return True
  111. def remove_directory(self, path: Path) -> bool:
  112. """
  113. Entfernt ein Verzeichnis aus der Überwachung.
  114. Args:
  115. path: Zu entfernendes Verzeichnis
  116. Returns:
  117. True wenn entfernt
  118. """
  119. if path in self._directories:
  120. self._directories.discard(path)
  121. # Snapshot bereinigen
  122. to_remove = [p for p in self._snapshot if str(p).startswith(str(path))]
  123. for p in to_remove:
  124. del self._snapshot[p]
  125. return True
  126. return False
  127. def on_change(self, callback: WatchCallback) -> WatchCallback:
  128. """
  129. Decorator zum Registrieren eines Callbacks.
  130. Args:
  131. callback: Callback-Funktion
  132. Returns:
  133. Der registrierte Callback
  134. """
  135. self._callbacks.append(callback)
  136. return callback
  137. def on_change_async(
  138. self,
  139. callback: AsyncWatchCallback,
  140. ) -> AsyncWatchCallback:
  141. """Decorator für async Callbacks."""
  142. self._async_callbacks.append(callback)
  143. return callback
  144. def add_callback(self, callback: WatchCallback) -> None:
  145. """Fügt einen Callback hinzu."""
  146. self._callbacks.append(callback)
  147. def add_async_callback(self, callback: AsyncWatchCallback) -> None:
  148. """Fügt einen async Callback hinzu."""
  149. self._async_callbacks.append(callback)
  150. def remove_callback(self, callback: WatchCallback) -> bool:
  151. """Entfernt einen Callback."""
  152. try:
  153. self._callbacks.remove(callback)
  154. return True
  155. except ValueError:
  156. return False
  157. async def start(self) -> None:
  158. """Startet die Überwachung."""
  159. if self._running:
  160. return
  161. self._running = True
  162. # Initial-Snapshot erstellen
  163. for directory in self._directories:
  164. self._update_snapshot(directory)
  165. self._task = asyncio.create_task(self._watch_loop())
  166. async def stop(self) -> None:
  167. """Stoppt die Überwachung."""
  168. self._running = False
  169. if self._task:
  170. self._task.cancel()
  171. try:
  172. await self._task
  173. except asyncio.CancelledError:
  174. pass
  175. self._task = None
  176. async def _watch_loop(self) -> None:
  177. """Haupt-Überwachungs-Loop."""
  178. while self._running:
  179. try:
  180. await asyncio.sleep(self._poll_interval)
  181. for directory in list(self._directories):
  182. if directory.exists():
  183. await self._check_directory(directory)
  184. except asyncio.CancelledError:
  185. break
  186. except Exception:
  187. pass
  188. async def _check_directory(self, directory: Path) -> None:
  189. """Prüft ein Verzeichnis auf Änderungen."""
  190. current_files: dict[Path, float] = {}
  191. # Alle Dateien sammeln
  192. for path in self._get_files(directory):
  193. if self._should_watch(path):
  194. try:
  195. current_files[path] = path.stat().st_mtime
  196. except OSError:
  197. pass
  198. # Änderungen erkennen
  199. old_paths = {
  200. p for p in self._snapshot
  201. if str(p).startswith(str(directory))
  202. }
  203. new_paths = set(current_files.keys())
  204. # Gelöschte Dateien
  205. for path in old_paths - new_paths:
  206. event = WatchEvent(
  207. event_type=WatchEventType.DELETED,
  208. path=path,
  209. is_directory=False,
  210. )
  211. del self._snapshot[path]
  212. await self._notify(event)
  213. # Neue Dateien
  214. for path in new_paths - old_paths:
  215. event = WatchEvent(
  216. event_type=WatchEventType.CREATED,
  217. path=path,
  218. is_directory=path.is_dir(),
  219. )
  220. self._snapshot[path] = current_files[path]
  221. await self._notify(event)
  222. # Geänderte Dateien
  223. for path in new_paths & old_paths:
  224. if current_files[path] != self._snapshot[path]:
  225. event = WatchEvent(
  226. event_type=WatchEventType.MODIFIED,
  227. path=path,
  228. is_directory=path.is_dir(),
  229. )
  230. self._snapshot[path] = current_files[path]
  231. await self._notify(event)
  232. def _get_files(self, directory: Path) -> list[Path]:
  233. """Gibt alle Dateien im Verzeichnis zurück."""
  234. files = []
  235. if self._recursive:
  236. for root, dirs, filenames in os.walk(directory):
  237. # Versteckte Verzeichnisse überspringen
  238. dirs[:] = [d for d in dirs if not d.startswith(".")]
  239. for filename in filenames:
  240. files.append(Path(root) / filename)
  241. else:
  242. for item in directory.iterdir():
  243. if item.is_file():
  244. files.append(item)
  245. return files
  246. def _should_watch(self, path: Path) -> bool:
  247. """Prüft, ob eine Datei überwacht werden soll."""
  248. # Versteckte Dateien ignorieren
  249. if path.name.startswith("."):
  250. return False
  251. # __pycache__ ignorieren
  252. if "__pycache__" in str(path):
  253. return False
  254. # Extension-Filter
  255. if self._extensions:
  256. return path.suffix.lower() in self._extensions
  257. return True
  258. def _update_snapshot(self, directory: Path) -> None:
  259. """Aktualisiert den Snapshot für ein Verzeichnis."""
  260. for path in self._get_files(directory):
  261. if self._should_watch(path):
  262. try:
  263. self._snapshot[path] = path.stat().st_mtime
  264. except OSError:
  265. pass
  266. async def _notify(self, event: WatchEvent) -> None:
  267. """Benachrichtigt alle Callbacks."""
  268. for callback in self._callbacks:
  269. try:
  270. callback(event)
  271. except Exception:
  272. pass
  273. for callback in self._async_callbacks:
  274. try:
  275. result = callback(event)
  276. if asyncio.iscoroutine(result):
  277. await result
  278. except Exception:
  279. pass
  280. def get_snapshot(self) -> dict[str, float]:
  281. """Gibt den aktuellen Snapshot zurück."""
  282. return {str(k): v for k, v in self._snapshot.items()}
  283. def clear_callbacks(self) -> None:
  284. """Entfernt alle Callbacks."""
  285. self._callbacks.clear()
  286. self._async_callbacks.clear()