| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356 |
- # -*- coding: utf-8 -*-
- """
- Dateiüberwachung für Plugin Hot-Reload.
- Beobachtet Plugin-Verzeichnisse auf Änderungen.
- """
- from __future__ import annotations
- import asyncio
- import os
- from dataclasses import dataclass, field
- from datetime import datetime
- from enum import Enum, auto
- from pathlib import Path
- from typing import Any, Callable
- class WatchEventType(Enum):
- """Typ eines Datei-Events."""
- CREATED = auto() # Datei erstellt
- MODIFIED = auto() # Datei geändert
- DELETED = auto() # Datei gelöscht
- MOVED = auto() # Datei verschoben
- @dataclass
- class WatchEvent:
- """
- Ein Datei-Änderungs-Event.
- Attributes:
- event_type: Art der Änderung
- path: Betroffener Pfad
- is_directory: Ist ein Verzeichnis
- timestamp: Zeitpunkt
- old_path: Alter Pfad bei MOVED
- """
- event_type: WatchEventType
- path: Path
- is_directory: bool = False
- timestamp: datetime = field(default_factory=datetime.now)
- old_path: Path | None = None
- def to_dict(self) -> dict[str, Any]:
- """Konvertiert zu Dictionary."""
- return {
- "event_type": self.event_type.name,
- "path": str(self.path),
- "is_directory": self.is_directory,
- "timestamp": self.timestamp.isoformat(),
- "old_path": str(self.old_path) if self.old_path else None,
- }
- # Callback-Typ
- WatchCallback = Callable[[WatchEvent], None]
- AsyncWatchCallback = Callable[[WatchEvent], Any] # Koroutine
- class FileWatcher:
- """
- Überwacht Verzeichnisse auf Änderungen.
- Verwendet Polling für maximale Kompatibilität.
- Optional kann watchdog verwendet werden wenn verfügbar.
- Example:
- watcher = FileWatcher()
- # Verzeichnis hinzufügen
- watcher.add_directory(Path("plugins"))
- # Callback registrieren
- @watcher.on_change
- def handle_change(event: WatchEvent):
- print(f"{event.event_type.name}: {event.path}")
- # Überwachung starten
- await watcher.start()
- # ... später ...
- await watcher.stop()
- """
- DEFAULT_POLL_INTERVAL = 1.0 # Sekunden
- def __init__(
- self,
- poll_interval: float = DEFAULT_POLL_INTERVAL,
- recursive: bool = True,
- extensions: list[str] | None = None,
- ) -> None:
- """
- Initialisiert den FileWatcher.
- Args:
- poll_interval: Intervall für Polling in Sekunden
- recursive: Unterverzeichnisse einbeziehen
- extensions: Nur diese Dateiendungen (z.B. [".py", ".json"])
- """
- self._poll_interval = poll_interval
- self._recursive = recursive
- self._extensions = set(extensions) if extensions else None
- self._directories: set[Path] = set()
- self._callbacks: list[WatchCallback] = []
- self._async_callbacks: list[AsyncWatchCallback] = []
- self._running = False
- self._task: asyncio.Task | None = None
- # Snapshot für Änderungserkennung
- self._snapshot: dict[Path, float] = {} # path -> mtime
- @property
- def is_running(self) -> bool:
- """Läuft die Überwachung?"""
- return self._running
- @property
- def watched_directories(self) -> list[Path]:
- """Liste überwachter Verzeichnisse."""
- return list(self._directories)
- def add_directory(self, path: Path) -> bool:
- """
- Fügt ein Verzeichnis zur Überwachung hinzu.
- Args:
- path: Zu überwachendes Verzeichnis
- Returns:
- True wenn hinzugefügt
- """
- if not path.exists() or not path.is_dir():
- return False
- self._directories.add(path)
- self._update_snapshot(path)
- return True
- def remove_directory(self, path: Path) -> bool:
- """
- Entfernt ein Verzeichnis aus der Überwachung.
- Args:
- path: Zu entfernendes Verzeichnis
- Returns:
- True wenn entfernt
- """
- if path in self._directories:
- self._directories.discard(path)
- # Snapshot bereinigen
- to_remove = [p for p in self._snapshot if str(p).startswith(str(path))]
- for p in to_remove:
- del self._snapshot[p]
- return True
- return False
- def on_change(self, callback: WatchCallback) -> WatchCallback:
- """
- Decorator zum Registrieren eines Callbacks.
- Args:
- callback: Callback-Funktion
- Returns:
- Der registrierte Callback
- """
- self._callbacks.append(callback)
- return callback
- def on_change_async(
- self,
- callback: AsyncWatchCallback,
- ) -> AsyncWatchCallback:
- """Decorator für async Callbacks."""
- self._async_callbacks.append(callback)
- return callback
- def add_callback(self, callback: WatchCallback) -> None:
- """Fügt einen Callback hinzu."""
- self._callbacks.append(callback)
- def add_async_callback(self, callback: AsyncWatchCallback) -> None:
- """Fügt einen async Callback hinzu."""
- self._async_callbacks.append(callback)
- def remove_callback(self, callback: WatchCallback) -> bool:
- """Entfernt einen Callback."""
- try:
- self._callbacks.remove(callback)
- return True
- except ValueError:
- return False
- async def start(self) -> None:
- """Startet die Überwachung."""
- if self._running:
- return
- self._running = True
- # Initial-Snapshot erstellen
- for directory in self._directories:
- self._update_snapshot(directory)
- self._task = asyncio.create_task(self._watch_loop())
- async def stop(self) -> None:
- """Stoppt die Überwachung."""
- self._running = False
- if self._task:
- self._task.cancel()
- try:
- await self._task
- except asyncio.CancelledError:
- pass
- self._task = None
- async def _watch_loop(self) -> None:
- """Haupt-Überwachungs-Loop."""
- while self._running:
- try:
- await asyncio.sleep(self._poll_interval)
- for directory in list(self._directories):
- if directory.exists():
- await self._check_directory(directory)
- except asyncio.CancelledError:
- break
- except Exception:
- pass
- async def _check_directory(self, directory: Path) -> None:
- """Prüft ein Verzeichnis auf Änderungen."""
- current_files: dict[Path, float] = {}
- # Alle Dateien sammeln
- for path in self._get_files(directory):
- if self._should_watch(path):
- try:
- current_files[path] = path.stat().st_mtime
- except OSError:
- pass
- # Änderungen erkennen
- old_paths = {
- p for p in self._snapshot
- if str(p).startswith(str(directory))
- }
- new_paths = set(current_files.keys())
- # Gelöschte Dateien
- for path in old_paths - new_paths:
- event = WatchEvent(
- event_type=WatchEventType.DELETED,
- path=path,
- is_directory=False,
- )
- del self._snapshot[path]
- await self._notify(event)
- # Neue Dateien
- for path in new_paths - old_paths:
- event = WatchEvent(
- event_type=WatchEventType.CREATED,
- path=path,
- is_directory=path.is_dir(),
- )
- self._snapshot[path] = current_files[path]
- await self._notify(event)
- # Geänderte Dateien
- for path in new_paths & old_paths:
- if current_files[path] != self._snapshot[path]:
- event = WatchEvent(
- event_type=WatchEventType.MODIFIED,
- path=path,
- is_directory=path.is_dir(),
- )
- self._snapshot[path] = current_files[path]
- await self._notify(event)
- def _get_files(self, directory: Path) -> list[Path]:
- """Gibt alle Dateien im Verzeichnis zurück."""
- files = []
- if self._recursive:
- for root, dirs, filenames in os.walk(directory):
- # Versteckte Verzeichnisse überspringen
- dirs[:] = [d for d in dirs if not d.startswith(".")]
- for filename in filenames:
- files.append(Path(root) / filename)
- else:
- for item in directory.iterdir():
- if item.is_file():
- files.append(item)
- return files
- def _should_watch(self, path: Path) -> bool:
- """Prüft, ob eine Datei überwacht werden soll."""
- # Versteckte Dateien ignorieren
- if path.name.startswith("."):
- return False
- # __pycache__ ignorieren
- if "__pycache__" in str(path):
- return False
- # Extension-Filter
- if self._extensions:
- return path.suffix.lower() in self._extensions
- return True
- def _update_snapshot(self, directory: Path) -> None:
- """Aktualisiert den Snapshot für ein Verzeichnis."""
- for path in self._get_files(directory):
- if self._should_watch(path):
- try:
- self._snapshot[path] = path.stat().st_mtime
- except OSError:
- pass
- async def _notify(self, event: WatchEvent) -> None:
- """Benachrichtigt alle Callbacks."""
- for callback in self._callbacks:
- try:
- callback(event)
- except Exception:
- pass
- for callback in self._async_callbacks:
- try:
- result = callback(event)
- if asyncio.iscoroutine(result):
- await result
- except Exception:
- pass
- def get_snapshot(self) -> dict[str, float]:
- """Gibt den aktuellen Snapshot zurück."""
- return {str(k): v for k, v in self._snapshot.items()}
- def clear_callbacks(self) -> None:
- """Entfernt alle Callbacks."""
- self._callbacks.clear()
- self._async_callbacks.clear()
|