From 3bc7be3f79e1395f69405401a49c4d33d92e8770 Mon Sep 17 00:00:00 2001 From: Joseph Walton Date: Mon, 1 Apr 2024 01:38:40 +1100 Subject: [PATCH] Continue to watch files that are replaced. (#1781) Continue to watch files are replaced, such as by saving in vi, by watching the parent directory instead. Add a test, that fails under the previous version, for a modification to a file after it is deleted and recreated. Bump the minimum watchfiles version to ensure that ignore_permission_denied, added in https://github.com/samuelcolvin/watchfiles/pull/224 and released in https://github.com/samuelcolvin/watchfiles/releases/tag/v0.20.0 is present. Co-authored-by: Jakob Schnitzer --- constraints-old.txt | 2 +- pyproject.toml | 2 +- src/fava/core/watcher.py | 36 ++++++++++++++++++++++++++++-------- tests/test_core_watcher.py | 26 ++++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/constraints-old.txt b/constraints-old.txt index 253b0c689..d5ab3bf26 100644 --- a/constraints-old.txt +++ b/constraints-old.txt @@ -115,6 +115,6 @@ uritemplate==4.1.1 # via google-api-python-client urllib3==2.2.1 # via requests -watchfiles==0.19.0 +watchfiles==0.20.0 werkzeug==2.2.0 # via flask diff --git a/pyproject.toml b/pyproject.toml index 3770ff58d..93f2e0812 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ dependencies = [ "markdown2>=2.3.0,<3", "ply", "simplejson>=3.16.0,<4", - "watchfiles>=0.19.0", + "watchfiles>=0.20.0", ] [project.urls] diff --git a/src/fava/core/watcher.py b/src/fava/core/watcher.py index dc92c9a63..4d37707b3 100644 --- a/src/fava/core/watcher.py +++ b/src/fava/core/watcher.py @@ -6,7 +6,6 @@ import atexit import logging import threading -from itertools import chain from os import walk from pathlib import Path from time import time_ns @@ -18,28 +17,45 @@ if TYPE_CHECKING: # pragma: no cover import types + from watchfiles.main import Change + log = logging.getLogger(__name__) class _WatchfilesThread(threading.Thread): - def __init__(self, paths: list[Path], mtime: int) -> None: + def __init__( + self, files: list[Path], folders: list[Path], mtime: int + ) -> None: super().__init__(daemon=True) self._stop_event = threading.Event() - self.paths = paths self.mtime = mtime + self._file_set = {f.absolute() for f in files} + self._folder_set = {f.absolute() for f in folders} def stop(self) -> None: """Set the stop event for watchfiles and join the thread.""" self._stop_event.set() self.join() + def _is_relevant(self, _c: Change, path: str) -> bool: + return Path(path) in self._file_set or any( + Path(path).is_relative_to(d) for d in self._folder_set + ) + def run(self) -> None: """Watch for changes.""" atexit.register(self.stop) - for changes in watch(*self.paths, stop_event=self._stop_event): - for _, path in changes: + watch_paths = {f.parent for f in self._file_set} | self._folder_set + + for changes in watch( + *watch_paths, + stop_event=self._stop_event, + ignore_permission_denied=True, + watch_filter=self._is_relevant, + ): + for path in {change[1] for change in changes}: try: change_mtime = Path(path).stat().st_mtime_ns except FileNotFoundError: @@ -98,19 +114,23 @@ class WatchfilesWatcher(WatcherBase): def __init__(self) -> None: self.last_checked = 0 self.last_notified = 0 - self._paths: list[Path] = [] + self._paths: tuple[list[Path], list[Path]] = ([], []) self._thread: _WatchfilesThread | None = None def update(self, files: Iterable[Path], folders: Iterable[Path]) -> None: """Update the folders/files to watch.""" - new_paths = [p for p in chain(files, folders) if p.exists()] + existing_files = [p for p in files if p.exists()] + existing_folders = [p for p in folders if p.is_dir()] + new_paths = (existing_files, existing_folders) if self._thread and new_paths == self._paths: self.check() return self._paths = new_paths if self._thread is not None: self._thread.stop() - self._thread = _WatchfilesThread(self._paths, self.last_checked) + self._thread = _WatchfilesThread( + existing_files, existing_folders, self.last_checked + ) self._thread.start() self.check() diff --git a/tests/test_core_watcher.py b/tests/test_core_watcher.py index ab52a73b0..c7d1d3424 100644 --- a/tests/test_core_watcher.py +++ b/tests/test_core_watcher.py @@ -109,3 +109,29 @@ def test_watchfiles_watcher(watcher_paths: WatcherTestSet) -> None: # notify of deleted file watcher.notify(watcher_paths.file1) assert watcher.check() + + +def test_watchfiles_watcher_recognises_change_to_previously_deleted_file( + watcher_paths: WatcherTestSet, +) -> None: + watcher = WatchfilesWatcher() + with watcher: + assert not watcher.check() # No thread set up yet. + + with watcher: + watcher.update([watcher_paths.file1], []) + + watcher_paths.file1.unlink() + assert _watcher_check_within_one_second(watcher) + # notify of deleted file + watcher.notify(watcher_paths.file1) + # True because time_ns() used when file is absent + assert watcher.check() + + # Recreate deleted file + # sleep to ensure file stamp is greater than time_ns() taken on + # previous FileNotFoundError + time.sleep(0.01) + watcher_paths.file1.write_text("test-value-2") + assert _watcher_check_within_one_second(watcher) + assert not watcher.check()