Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions tests/web/test_watcher.py
Original file line number Diff line number Diff line change
@@ -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
12 changes: 8 additions & 4 deletions web/server/watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down