Skip to content

Phase 3: Shared Filesystem Watcher (_watching.py) #87

Description

@tinkermonkey

Implement the shared filesystem watcher utility used by both the Rich Filesystem and Obsidian adapters. This module wraps watchdog behind a unified interface with debouncing and atomic-save protection.

Requirements

  • Support watchdog as the primary backend; attempt watchfiles fallback if watchdog is not installed
  • Each adapter must instantiate its own FileSystemWatcher scoped to its own path — no shared singleton
  • Translate watchdog filesystem event objects into SourceRef-compatible path values via a FileEvent dataclass
  • Map filesystem events to PollStrategy.PUSH
  • Expose PollStrategy enum (from models.py) for use in calling code
  • Implement debouncing: buffer events for 500ms, coalesce same-path events before dispatching
  • Handle editor atomic-save patterns (rename-based saves that produce deleted + created sequences) — must NOT emit spurious deletion events for the original file path
  • Filter events by file extension when an extensions parameter is provided
  • Add watchdog to dev dependencies in pyproject.toml

Design Guidance

File to create: src/context_library/adapters/_watching.py

The underscore prefix marks this as an internal module not part of the public API.

FileEvent dataclass:

from dataclasses import dataclass
from pathlib import Path

@dataclass
class FileEvent:
    path: Path
    event_type: str  # 'created', 'modified', 'deleted'

FileSystemWatcher class:

class FileSystemWatcher:
    def __init__(
        self,
        watch_path: Path,
        callback: Callable[[FileEvent], None],
        extensions: set[str] | None = None,
        debounce_ms: int = 500,
    ) -> None: ...

    def start(self) -> None: ...
    def stop(self) -> None: ...

Debouncing implementation:

  • Maintain a pending-events buffer dict keyed by path
  • Use a threading.Timer that fires after debounce_ms to flush the buffer
  • On each raw event: cancel the timer, update the buffer entry (later events overwrite earlier ones for the same path), restart the timer
  • On timer fire: call callback for each buffered event, clear the buffer

Atomic save handling (FR-3.6):

  • Many editors (vim, emacs, VS Code) save via: write to temp file → rename temp to original → this produces deleted(original) + created(original) sequence
  • Detection: when a deleted event is followed within debounce window by a created event for the same path, coalesce to a single modified event
  • In practice, debouncing the 500ms window achieves this because both events land in the same buffer flush cycle

Watchdog integration:

from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler, FileSystemEvent

class _WatchdogHandler(FileSystemEventHandler):
    def __init__(self, buffer_callback): ...
    def on_created(self, event): ...
    def on_modified(self, event): ...
    def on_deleted(self, event): ...
    def on_moved(self, event): ...  # treat as modified on dest path

pyproject.toml change:

[project.optional-dependencies]
dev = ["ruff", "mypy", "pytest", "watchdog"]

For production use by adapters, watchdog should also be added to a watching optional group or the adapter-specific groups (rich-fs, obsidian).

Acceptance Criteria

  • FileSystemWatcher(path, callback) instantiates without error when watchdog is installed
  • start() begins observing the watch_path; stop() halts the observer
  • A created event on a file with a matching extension triggers callback with FileEvent(path=..., event_type='created')
  • A modified event triggers callback with event_type='modified'
  • Two events for the same path within the debounce window result in exactly ONE callback invocation
  • A deleted + created sequence for the same path within the debounce window does NOT result in a deleted callback — only a modified or created callback
  • When extensions={'.md'} is set, events for .txt files are not dispatched to callback
  • Two separate FileSystemWatcher instances on different paths operate independently (no shared state)
  • stop() on an unstarted watcher does not raise an exception
  • Module is importable as from context_library.adapters._watching import FileSystemWatcher, FileEvent

Dependencies

Phase 1: Model Additions (PollStrategy + MessageMetadata)

Parent Issue

Part of #83

Discussion

This work is detailed in discussion 84

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions