From 090321de07395f4807ca50bef635515356ea4e22 Mon Sep 17 00:00:00 2001 From: Paul Smith Date: Thu, 25 Jun 2026 11:07:45 +0100 Subject: [PATCH 1/4] Add test_watch_project_tracks_new_model_through_symlink Test that the watcher picks up files created in a Context where the project is a symlinked path Signed-off-by: Paul Smith --- tests/web/test_watcher.py | 69 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/web/test_watcher.py diff --git a/tests/web/test_watcher.py b/tests/web/test_watcher.py new file mode 100644 index 0000000000..f8d13a0977 --- /dev/null +++ b/tests/web/test_watcher.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import typing as t +from pathlib import Path + +import pytest +from watchfiles import Change + +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 in a temporary directory and return its path.""" + project = tmp_path / "real_project" + (project / "models").mkdir(parents=True) + (project / "config.py").write_text( + "from sqlmesh.core.config import Config, ModelDefaultsConfig\n" + "config = Config(model_defaults=ModelDefaultsConfig(dialect=''))\n" + ) + (project / "models" / "existing.sql").write_text( + "MODEL (name existing, kind FULL);\nSELECT 1 AS c" + ) + return project + + +@pytest.fixture +def symlinked_project(tmp_path: Path, project: Path) -> Path: + """Create a symlink to a project in a temporary directory and return its path.""" + 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( + symlinked_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(symlinked_project)) + context.load() + + new_file = symlinked_project / "models" / "new_model.sql" + new_file.write_text("MODEL (name new_model, kind FULL);\nSELECT 2 AS c") + 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=symlinked_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("new_model") is not None From 4b1c0e33236f0f67932e93e6121c8d1b36b609a7 Mon Sep 17 00:00:00 2001 From: Paul Smith Date: Thu, 25 Jun 2026 11:09:42 +0100 Subject: [PATCH 2/4] Fix: detect new models in the UI when the project path is a symlink can report resolved paths even when the watched root is a symlink, so the watcher's and model-dir checks failed and new files were never tracked, meaning newly created models never showed up in the UI. Compare paths in resolved space and re-root under the project path Signed-off-by: Paul Smith --- web/server/watcher.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/web/server/watcher.py b/web/server/watcher.py index 8bc87c8719..754b85be24 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,7 +80,7 @@ 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 ) From 39ab0d5da84c443060854df3219e46a133ade46f Mon Sep 17 00:00:00 2001 From: Paul Smith Date: Thu, 25 Jun 2026 11:10:27 +0100 Subject: [PATCH 3/4] Fix the always-truthy check in watch_project Signed-off-by: Paul Smith --- web/server/watcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/server/watcher.py b/web/server/watcher.py index 754b85be24..68a422f7fd 100644 --- a/web/server/watcher.py +++ b/web/server/watcher.py @@ -85,7 +85,7 @@ async def watch_project() -> None: 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 From 8d51346a8691c94e18d926e440d6e9e9d4a2e289 Mon Sep 17 00:00:00 2001 From: Paul Smith Date: Thu, 25 Jun 2026 11:20:46 +0100 Subject: [PATCH 4/4] Simplify creating the symlinked project for testing the watcher Signed-off-by: Paul Smith --- tests/web/test_watcher.py | 31 ++++++++++--------------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/tests/web/test_watcher.py b/tests/web/test_watcher.py index f8d13a0977..c04fabbdb7 100644 --- a/tests/web/test_watcher.py +++ b/tests/web/test_watcher.py @@ -6,6 +6,7 @@ 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 @@ -15,22 +16,10 @@ @pytest.fixture def project(tmp_path: Path) -> Path: - """Create a project in a temporary directory and return its path.""" + """Create a project and a symlink to it.""" project = tmp_path / "real_project" - (project / "models").mkdir(parents=True) - (project / "config.py").write_text( - "from sqlmesh.core.config import Config, ModelDefaultsConfig\n" - "config = Config(model_defaults=ModelDefaultsConfig(dialect=''))\n" - ) - (project / "models" / "existing.sql").write_text( - "MODEL (name existing, kind FULL);\nSELECT 1 AS c" - ) - return project - - -@pytest.fixture -def symlinked_project(tmp_path: Path, project: Path) -> Path: - """Create a symlink to a project in a temporary directory and return its path.""" + project.mkdir() + init_example_project(project, engine_type="duckdb") link = tmp_path / "linked_project" link.symlink_to(project, target_is_directory=True) return link @@ -39,7 +28,7 @@ def symlinked_project(tmp_path: Path, project: Path) -> Path: @pytest.mark.parametrize("event_path_is_resolved", [True, False]) @pytest.mark.asyncio async def test_watch_project_tracks_new_model_through_symlink( - symlinked_project: Path, + project: Path, monkeypatch: pytest.MonkeyPatch, event_path_is_resolved: bool, ) -> None: @@ -50,20 +39,20 @@ async def test_watch_project_tracks_new_model_through_symlink( 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(symlinked_project)) + context = Context(paths=str(project)) context.load() - new_file = symlinked_project / "models" / "new_model.sql" - new_file.write_text("MODEL (name new_model, kind FULL);\nSELECT 2 AS c") + 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=symlinked_project)) + 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("new_model") is not None + assert context.get_model("sqlmesh_example.new_model") is not None