fix(vault): the watcher ignores obsidian and lock files before it wakes
This commit is contained in:
@@ -19,12 +19,14 @@ from pathlib import Path
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from watchfiles import awatch
|
from watchfiles import DefaultFilter, awatch
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
|
|
||||||
__all__ = ["Change", "VaultWatch", "WatchRules"]
|
from watchfiles import Change as FileChange
|
||||||
|
|
||||||
|
__all__ = ["Change", "VaultFilter", "VaultWatch", "WatchRules"]
|
||||||
|
|
||||||
_log = logging.getLogger("beaver_gateway.vault.watch")
|
_log = logging.getLogger("beaver_gateway.vault.watch")
|
||||||
|
|
||||||
@@ -74,6 +76,26 @@ class _Seen:
|
|||||||
count: int
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
IGNORE_DIRS = (".obsidian", ".trash", ".git")
|
||||||
|
"""Housekeeping directories whose churn never means an edit of the vault."""
|
||||||
|
|
||||||
|
IGNORE_NAMES = (".sync.lock", "*.lock", "*.tmp", "*~")
|
||||||
|
"""Lock and scratch file names, matched against the entity name."""
|
||||||
|
|
||||||
|
|
||||||
|
class VaultFilter(DefaultFilter):
|
||||||
|
"""Drops housekeeping paths inside ``awatch``, before the loop is woken."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(ignore_dirs=(*DefaultFilter.ignore_dirs, *IGNORE_DIRS))
|
||||||
|
|
||||||
|
def __call__(self, change: FileChange, path: str) -> bool:
|
||||||
|
name = Path(path).name
|
||||||
|
if any(fnmatch.fnmatchcase(name, pattern) for pattern in IGNORE_NAMES):
|
||||||
|
return False
|
||||||
|
return super().__call__(change, path)
|
||||||
|
|
||||||
|
|
||||||
class VaultWatch:
|
class VaultWatch:
|
||||||
def __init__(
|
def __init__(
|
||||||
self, root: Path, rules: WatchRules, *, tz: str = "UTC", debounce: float = 4.0
|
self, root: Path, rules: WatchRules, *, tz: str = "UTC", debounce: float = 4.0
|
||||||
@@ -98,6 +120,7 @@ class VaultWatch:
|
|||||||
_log.info("watching %s (%d files in snapshot)", self.root, len(self._seen))
|
_log.info("watching %s (%d files in snapshot)", self.root, len(self._seen))
|
||||||
async for changes in awatch(
|
async for changes in awatch(
|
||||||
self.root,
|
self.root,
|
||||||
|
watch_filter=VaultFilter(),
|
||||||
debounce=int(self._debounce * 1000),
|
debounce=int(self._debounce * 1000),
|
||||||
stop_event=self._stop,
|
stop_event=self._stop,
|
||||||
ignore_permission_denied=True,
|
ignore_permission_denied=True,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import tempfile
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from watchfiles import Change as FileChange
|
||||||
|
|
||||||
|
from beaver_gateway.vault import watch as watch_module
|
||||||
|
from beaver_gateway.vault.watch import VaultFilter, VaultWatch, WatchRules
|
||||||
|
|
||||||
|
VAULT = Path("/vault")
|
||||||
|
FILTER = VaultFilter()
|
||||||
|
|
||||||
|
|
||||||
|
def wakes(rel: str) -> bool:
|
||||||
|
return FILTER(FileChange.modified, str(VAULT / rel))
|
||||||
|
|
||||||
|
|
||||||
|
def test_obsidian_never_wakes_the_watcher() -> None:
|
||||||
|
assert not wakes(".obsidian/.sync.lock")
|
||||||
|
assert not wakes(".obsidian/.sync.lock/data")
|
||||||
|
assert not wakes(".obsidian")
|
||||||
|
assert not wakes(".obsidian/workspace.json")
|
||||||
|
assert not wakes(".obsidian/plugins/obsidian-git/data.json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_other_service_paths_never_wake_the_watcher() -> None:
|
||||||
|
assert not wakes(".trash/📅 дни/2026-09-01.md")
|
||||||
|
assert not wakes(".git/index")
|
||||||
|
|
||||||
|
|
||||||
|
def test_notes_still_wake_the_watcher() -> None:
|
||||||
|
assert wakes("📅 дни/2026-09-09.md")
|
||||||
|
assert wakes("мета/бобер/состояние.md")
|
||||||
|
assert wakes("заметка.md")
|
||||||
|
assert wakes("💻 проекты/бобер/план.md")
|
||||||
|
|
||||||
|
|
||||||
|
def test_lock_beside_a_note_is_quiet_but_the_note_is_not() -> None:
|
||||||
|
assert not wakes("📅 дни/.2026-09-09.md.lock")
|
||||||
|
assert not wakes("📅 дни/2026-09-09.md.tmp")
|
||||||
|
assert not wakes("📅 дни/2026-09-09.md~")
|
||||||
|
assert wakes("📅 дни/2026-09-09.md")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_filter_is_applied_by_awatch_itself(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
seen: dict[str, object] = {}
|
||||||
|
|
||||||
|
async def fake_awatch(
|
||||||
|
*paths: object, **kwargs: object
|
||||||
|
) -> AsyncIterator[set[object]]:
|
||||||
|
seen.update(kwargs)
|
||||||
|
seen["paths"] = paths
|
||||||
|
for change in ():
|
||||||
|
yield change
|
||||||
|
|
||||||
|
monkeypatch.setattr(watch_module, "awatch", fake_awatch)
|
||||||
|
root = Path(tempfile.mkdtemp(prefix="beaver-vault-"))
|
||||||
|
watch = VaultWatch(root, WatchRules(names=("**",)), tz="UTC")
|
||||||
|
await watch.run()
|
||||||
|
assert isinstance(seen["watch_filter"], VaultFilter)
|
||||||
Reference in New Issue
Block a user