diff --git a/tests/web/test_watcher.py b/tests/web/test_watcher.py new file mode 100644 index 0000000000..c04fabbdb7 --- /dev/null +++ b/tests/web/test_watcher.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import typing as t +from pathlib import Path + +import pytest +from watchfiles import Change + +from sqlmesh.cli.project_init import init_example_project +from sqlmesh.core.context import Context +from web.server import watcher as watcher_module +from web.server.settings import Settings + +pytestmark = pytest.mark.web + + +@pytest.fixture +def project(tmp_path: Path) -> Path: + """Create a project and a symlink to it.""" + project = tmp_path / "real_project" + project.mkdir() + init_example_project(project, engine_type="duckdb") + link = tmp_path / "linked_project" + link.symlink_to(project, target_is_directory=True) + return link + + +@pytest.mark.parametrize("event_path_is_resolved", [True, False]) +@pytest.mark.asyncio +async def test_watch_project_tracks_new_model_through_symlink( + project: Path, + monkeypatch: pytest.MonkeyPatch, + event_path_is_resolved: bool, +) -> None: + """ + Test thata a model added while the project is reached through a symlink + is picked up by ``context.refresh()`` without crashing the watcher. + + event_path_is_resolved is True on macOS/FSEvents and False on Linux/inotify, + so we need to test both cases. + """ + context = Context(paths=str(project)) + context.load() + + new_file = project / "models" / "new_model.sql" + new_file.write_text("MODEL (name sqlmesh_example.new_model, kind FULL);\nSELECT 1 AS id") + event_path = str(new_file.resolve()) if event_path_is_resolved else str(new_file) + + async def fake_awatch(*args: t.Any, **kwargs: t.Any) -> t.AsyncIterator[t.Set[t.Any]]: + yield {(Change.added, event_path)} + + monkeypatch.setattr(watcher_module, "get_settings", lambda: Settings(project_path=project)) + monkeypatch.setattr(watcher_module, "get_context", lambda *a, **k: context) + monkeypatch.setattr(watcher_module, "awatch", fake_awatch) + + await watcher_module.watch_project() + context.refresh() + assert context.get_model("sqlmesh_example.new_model") is not None diff --git a/web/server/watcher.py b/web/server/watcher.py index 8bc87c8719..68a422f7fd 100644 --- a/web/server/watcher.py +++ b/web/server/watcher.py @@ -21,6 +21,7 @@ async def watch_project() -> None: settings = get_settings() context = get_context(settings) + project_root = settings.project_path.resolve() paths = [ (settings.project_path / c.AUDITS).resolve(), (settings.project_path / c.MACROS).resolve(), @@ -48,8 +49,11 @@ async def watch_project() -> None: changes: t.List[models.ArtifactChange] = [] directories: t.Dict[str, models.Directory] = {} for change, path_str in entries: - path = Path(path_str) - relative_path = path.relative_to(settings.project_path) + event_path = Path(path_str) + # `awatch` may yield resolved paths even when the watched root is a symlink, + # so compare in resolved space and convert back to the symlinked project path + relative_path = event_path.resolve().relative_to(project_root) + path = settings.project_path / relative_path try: if change == Change.deleted or not path.exists(): changes.append( @@ -76,12 +80,12 @@ async def watch_project() -> None: ) ) if context: - in_paths = any(is_relative_to(path, p) for p in paths) + in_paths = any(is_relative_to(path.resolve(), p) for p in paths) is_modified_new_file = change == Change.modified and any( path not in loader._path_mtimes for loader in context._loaders ) should_track_file = path.is_file() and in_paths - should_reset_mtime = Change.added or is_modified_new_file + should_reset_mtime = change == Change.added or is_modified_new_file if should_track_file and should_reset_mtime: for loader in context._loaders: loader._path_mtimes[path] = 0