Skip to content

Commit

Permalink
fixup! another try at macOS settle wait
Browse files Browse the repository at this point in the history
  • Loading branch information
dairiki committed Apr 23, 2023
1 parent 12984db commit 44687b8
Showing 1 changed file with 111 additions and 47 deletions.
158 changes: 111 additions & 47 deletions tests/test_watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
import shutil
import sys
import threading
import time
import warnings
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from typing import Generator

import pytest
Expand All @@ -19,26 +20,74 @@
from lektor.watcher import WatchFilter


RunInThread = Callable[[Callable[[], None]], None]


@pytest.fixture
def run_in_thread() -> RunInThread:
threads = []

def run_in_thread(target: Callable[[], None]) -> None:
t = threading.Thread(target=target)
t.start()
threads.append(t)

try:
yield run_in_thread
finally:
for t in threads:
t.join()


@dataclass
class WatchResult:
change_seen: bool = False

def __bool__(self):
return self.change_seen


@dataclass
class WatcherTest:
env: Environment

@property
def watched_path(self) -> Path:
return Path(self.env.root_path)
run_in_thread: RunInThread

@contextmanager
def __call__(
self,
should_set_event: bool = True,
timeout: float = 1.2,
) -> Generator[Path, None, None]:
) -> Generator[WatchResult, None, None]:
"""Run watch_project in a separate thread, wait for a file change event.
This is a context manager that runs watch_project in a separate thread.
After the context exits, it will wait at most ``timeout`` seconds before returning.
If a file system change is seen, it will return immediately.
The context manager returns a WatchResult value. After the context has been
exited, the result will be True-ish if a file system change was noticed,
False-ish otherwise.
"""
if sys.platform == "darwin":
# Wait a bit for the dust to settle.
# For whatever reason, on macOS, the watcher sometimes seems to trigger
# on events that happened shortly before it was started.
time.sleep(0.1)
for n in range(10):
with self.watch(timeout=0.001) as change_seen:
pass
if not change_seen:
break
warnings.warn(f"macOS settle loop {n}: {change_seen}")

with self.watch(timeout) as change_seen:
yield change_seen

@contextmanager
def watch(
self,
timeout: float,
) -> Generator[WatchResult, None, None]:
"""Run watch_project in a separate thread, wait for a file change event."""
running = threading.Event()
stop = threading.Event()
changed = threading.Event()
Expand All @@ -52,114 +101,129 @@ def run() -> None:
changed.set()
return

t = threading.Thread(target=run)
t.start()
self.run_in_thread(run)
result = WatchResult()
running.wait()
try:
yield self.watched_path
changed.wait(timeout)
yield result
result.change_seen = changed.wait(timeout)
finally:
stop.set()
t.join()

if should_set_event:
assert changed.is_set()
else:
assert not changed.is_set()

@pytest.fixture
def watcher_test(scratch_env: Environment, run_in_thread: RunInThread) -> WatcherTest:
return WatcherTest(scratch_env, run_in_thread)


@pytest.fixture
def watcher_test(scratch_env: Environment) -> WatcherTest:
return WatcherTest(scratch_env)
def watched_path(scratch_env: Environment) -> Path:
return Path(scratch_env.root_path)


def test_watcher_test(watcher_test: WatcherTest) -> None:
with watcher_test(should_set_event=False, timeout=0.2):
with watcher_test(timeout=0.2) as change_seen:
pass
assert not change_seen


def test_sees_created_file(watcher_test: WatcherTest) -> None:
with watcher_test() as watched_path:
watched_path.joinpath("created").touch()
def test_sees_created_file(watcher_test: WatcherTest, watched_path: Path) -> None:
with watcher_test() as change_seen:
Path(watched_path, "created").touch()
assert change_seen


def test_sees_deleted_file(watcher_test: WatcherTest) -> None:
deleted_path = watcher_test.watched_path / "deleted"
def test_sees_deleted_file(watcher_test: WatcherTest, watched_path: Path) -> None:
deleted_path = watched_path / "deleted"
deleted_path.touch()

with watcher_test():
with watcher_test() as change_seen:
deleted_path.unlink()
assert change_seen


def test_sees_modified_file(watcher_test: WatcherTest) -> None:
modified_path = watcher_test.watched_path / "modified"
def test_sees_modified_file(watcher_test: WatcherTest, watched_path: Path) -> None:
modified_path = watched_path / "modified"
modified_path.touch()

with watcher_test():
with watcher_test() as change_seen:
with modified_path.open("a") as fp:
fp.write("addition")
assert change_seen


def test_sees_file_moved_in(watcher_test: WatcherTest, tmp_path: Path) -> None:
def test_sees_file_moved_in(
watcher_test: WatcherTest, watched_path: Path, tmp_path: Path
) -> None:
orig_path = tmp_path / "orig_path"
orig_path.touch()
final_path = watcher_test.watched_path / "final_path"
final_path = watched_path / "final_path"

with watcher_test():
with watcher_test() as change_seen:
orig_path.rename(final_path)
assert change_seen


def test_sees_file_moved_out(watcher_test: WatcherTest, tmp_path: Path) -> None:
orig_path = watcher_test.watched_path / "orig_path"
def test_sees_file_moved_out(
watcher_test: WatcherTest, watched_path: Path, tmp_path: Path
) -> None:
orig_path = watched_path / "orig_path"
orig_path.touch()
final_path = tmp_path / "final_path"

with watcher_test():
with watcher_test() as change_seen:
orig_path.rename(final_path)
assert change_seen


def test_sees_deleted_directory(watcher_test: WatcherTest) -> None:
def test_sees_deleted_directory(watcher_test: WatcherTest, watched_path: Path) -> None:
# We only really care about deleted directories that contain at least a file.
deleted_path = watcher_test.watched_path / "deleted"
deleted_path = watched_path / "deleted"
deleted_path.mkdir()
watched_file = deleted_path / "file"
watched_file.touch()

with watcher_test():
with watcher_test() as change_seen:
shutil.rmtree(deleted_path)
assert change_seen


def test_sees_file_in_directory_moved_in(
watcher_test: WatcherTest, tmp_path: Path
watcher_test: WatcherTest, watched_path: Path, tmp_path: Path
) -> None:
# We only really care about directories that contain at least a file.
orig_dir_path = tmp_path / "orig_dir_path"
orig_dir_path.mkdir()
Path(orig_dir_path, "file").touch()
final_dir_path = watcher_test.watched_path / "final_dir_path"
final_dir_path = watched_path / "final_dir_path"

with watcher_test():
with watcher_test() as change_seen:
orig_dir_path.rename(final_dir_path)
assert change_seen


def test_sees_directory_moved_out(watcher_test: WatcherTest, tmp_path: Path) -> None:
def test_sees_directory_moved_out(
watcher_test: WatcherTest, watched_path: Path, tmp_path: Path
) -> None:
# We only really care about directories that contain at least one file.
orig_dir_path = watcher_test.watched_path / "orig_dir_path"
orig_dir_path = watched_path / "orig_dir_path"
orig_dir_path.mkdir()
Path(orig_dir_path, "file").touch()
final_dir_path = tmp_path / "final_dir_path"

with watcher_test():
with watcher_test() as change_seen:
orig_dir_path.rename(final_dir_path)
assert change_seen


def test_ignores_opened_file(watcher_test: WatcherTest) -> None:
file_path = watcher_test.watched_path / "file"
def test_ignores_opened_file(watcher_test: WatcherTest, watched_path: Path) -> None:
file_path = watched_path / "file"
file_path.touch()

with watcher_test(should_set_event=False):
with watcher_test() as change_seen:
with file_path.open() as fp:
fp.read()
assert not change_seen


@pytest.fixture(scope="session")
Expand Down

0 comments on commit 44687b8

Please sign in to comment.