Files
Vault/src/core/file_watcher.py

223 lines
6.7 KiB
Python

"""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()