Skip to content

Commit

Permalink
Continue to watch files that are replaced. (#1781)
Browse files Browse the repository at this point in the history
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 samuelcolvin/watchfiles#224 and released
in https://github.com/samuelcolvin/watchfiles/releases/tag/v0.20.0
is present.

Co-authored-by: Jakob Schnitzer <[email protected]>
  • Loading branch information
josephw and yagebu authored Mar 31, 2024
1 parent fb59849 commit 3bc7be3
Show file tree
Hide file tree
Showing 4 changed files with 56 additions and 10 deletions.
2 changes: 1 addition & 1 deletion constraints-old.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
36 changes: 28 additions & 8 deletions src/fava/core/watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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()

Expand Down
26 changes: 26 additions & 0 deletions tests/test_core_watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

0 comments on commit 3bc7be3

Please sign in to comment.