"""File watcher module for detecting changes in vault mount point. Uses watchdog library with inotify backend on Linux. """ from collections.abc import Callable from dataclasses import dataclass from enum import Enum from pathlib import Path from typing import Any from loguru import logger from watchdog.events import ( DirCreatedEvent, DirDeletedEvent, DirMovedEvent, FileClosedEvent, FileCreatedEvent, FileDeletedEvent, FileModifiedEvent, FileMovedEvent, FileSystemEvent, FileSystemEventHandler, ) from watchdog.observers import Observer class EventType(Enum): """Types of file system events.""" CREATED = "created" MODIFIED = "modified" DELETED = "deleted" MOVED = "moved" @dataclass(frozen=True) class FileEvent: """Represents a file system event.""" event_type: EventType path: str is_directory: bool dest_path: str | None = None # Only for MOVED events def __str__(self) -> str: if self.event_type == EventType.MOVED: return f"{self.event_type.value}: {self.path} -> {self.dest_path}" return f"{self.event_type.value}: {self.path}" # Type alias for file event callbacks FileEventCallback = Callable[[FileEvent], None] def _ensure_str(path: str | bytes) -> str: """Convert bytes path to str if necessary.""" if isinstance(path, bytes): return path.decode("utf-8", errors="replace") return path class VaultEventHandler(FileSystemEventHandler): """Handles file system events and converts them to FileEvent objects.""" def __init__( self, base_path: Path, callback: FileEventCallback, ignore_patterns: list[str] | None = None, ) -> None: super().__init__() self.base_path = base_path self.callback = callback self.ignore_patterns = ignore_patterns or [".vault"] def _should_ignore(self, path: str) -> bool: """Check if path should be ignored.""" rel_path = Path(path).relative_to(self.base_path) for pattern in self.ignore_patterns: if pattern in rel_path.parts: return True return False def _get_relative_path(self, path: str) -> str: """Convert absolute path to relative path from base.""" return str(Path(path).relative_to(self.base_path)) def _emit_event( self, event_type: EventType, src_path: str, is_directory: bool, dest_path: str | None = None, ) -> None: """Create and emit a FileEvent.""" if self._should_ignore(src_path): return rel_path = self._get_relative_path(src_path) rel_dest = self._get_relative_path(dest_path) if dest_path else None file_event = FileEvent( event_type=event_type, path=rel_path, is_directory=is_directory, dest_path=rel_dest, ) logger.debug(f"File event: {file_event}") self.callback(file_event) def on_created(self, event: FileSystemEvent) -> None: """Handle file/directory creation.""" if isinstance(event, (FileCreatedEvent, DirCreatedEvent)): self._emit_event( EventType.CREATED, _ensure_str(event.src_path), event.is_directory, ) def on_modified(self, event: FileSystemEvent) -> None: """Handle file modification.""" # Only track file modifications, not directory modifications if isinstance(event, FileModifiedEvent) and not event.is_directory: self._emit_event( EventType.MODIFIED, _ensure_str(event.src_path), is_directory=False, ) def on_deleted(self, event: FileSystemEvent) -> None: """Handle file/directory deletion.""" if isinstance(event, (FileDeletedEvent, DirDeletedEvent)): self._emit_event( EventType.DELETED, _ensure_str(event.src_path), event.is_directory, ) def on_moved(self, event: FileSystemEvent) -> None: """Handle file/directory move/rename.""" if isinstance(event, (FileMovedEvent, DirMovedEvent)): self._emit_event( EventType.MOVED, _ensure_str(event.src_path), event.is_directory, dest_path=_ensure_str(event.dest_path), ) def on_closed(self, event: FileSystemEvent) -> None: """Handle file close - emit as modified for write operations.""" # FileClosedEvent indicates a file was closed after writing # This is more reliable than on_modified for detecting actual saves if isinstance(event, FileClosedEvent) and not event.is_directory: # We emit MODIFIED because the file content was finalized self._emit_event( EventType.MODIFIED, _ensure_str(event.src_path), is_directory=False, ) class FileWatcher: """Watches a directory for file system changes.""" def __init__( self, watch_path: Path, callback: FileEventCallback, ignore_patterns: list[str] | None = None, ) -> None: self.watch_path = watch_path self.callback = callback self.ignore_patterns = ignore_patterns or [".vault"] self._observer: Any = None self._running = False def start(self) -> None: """Start watching for file system events.""" if self._running: logger.warning("FileWatcher already running") return if not self.watch_path.exists(): raise FileNotFoundError(f"Watch path does not exist: {self.watch_path}") handler = VaultEventHandler( base_path=self.watch_path, callback=self.callback, ignore_patterns=self.ignore_patterns, ) self._observer = Observer() self._observer.schedule(handler, str(self.watch_path), recursive=True) self._observer.start() self._running = True logger.info(f"FileWatcher started for: {self.watch_path}") def stop(self) -> None: """Stop watching for file system events.""" if not self._running or self._observer is None: return self._observer.stop() self._observer.join(timeout=5.0) self._observer = None self._running = False logger.info("FileWatcher stopped") def is_running(self) -> bool: """Check if watcher is currently running.""" return self._running def __enter__(self) -> "FileWatcher": self.start() return self def __exit__(self, exc_type: type | None, exc_val: Exception | None, exc_tb: object) -> None: self.stop()