223 lines
6.7 KiB
Python
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()
|