Skip to content

Commit 4c010f1

Browse files
authored
fix: watch for file move with watchdog and Claude Code (#6798)
This pull request improves the file watching functionality to better support editors that save files by moving temporary files into place, such as Claude Code and some vim configurations. Fixes ##6784
1 parent c6c2cac commit 4c010f1

File tree

6 files changed

+87
-40
lines changed

6 files changed

+87
-40
lines changed

marimo/_server/sessions.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1199,6 +1199,11 @@ def _handle_file_change(
11991199
# Get the latest codes
12001200
codes = list(session.app_file_manager.app.cell_manager.codes())
12011201
cell_ids = list(session.app_file_manager.app.cell_manager.cell_ids())
1202+
1203+
LOGGER.info(
1204+
f"File changed: {file_path}. num_cell_ids: {len(cell_ids)}, num_codes: {len(codes)}, changed_cell_ids: {changed_cell_ids}"
1205+
)
1206+
12021207
# Send the updated cell ids and codes to the frontend
12031208
session.write_operation(
12041209
UpdateCellIdsRequest(cell_ids=cell_ids),

marimo/_utils/file_watcher.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from collections import defaultdict
88
from collections.abc import Coroutine
99
from pathlib import Path
10-
from typing import Any, Callable, Optional
10+
from typing import Callable, Optional
1111

1212
from marimo import _loggers
1313
from marimo._dependencies.dependencies import DependencyManager
@@ -110,15 +110,36 @@ def __init__(
110110
self.loop = loop
111111
self.observer = watchdog.observers.Observer()
112112

113-
def on_modified(self, event: Any) -> None:
113+
def on_modified(
114+
self,
115+
event: watchdog.events.FileModifiedEvent
116+
| watchdog.events.DirModifiedEvent,
117+
) -> None:
114118
del event
115119
self.loop.create_task(self.on_file_changed())
116120

121+
def on_moved(
122+
self,
123+
event: watchdog.events.FileMovedEvent
124+
| watchdog.events.DirMovedEvent,
125+
) -> None:
126+
# Handle editors that save by creating a temp file and moving it
127+
# (e.g., Claude Code, some vim configurations)
128+
dest_path_str = (
129+
event.dest_path
130+
if isinstance(event.dest_path, str)
131+
else event.dest_path.decode("utf-8")
132+
)
133+
134+
if self.path == Path(dest_path_str):
135+
self.loop.create_task(self.on_file_changed())
136+
117137
def start(self) -> None:
118138
event_handler = watchdog.events.PatternMatchingEventHandler( # type: ignore # noqa: E501
119139
patterns=[str(self.path)]
120140
)
121141
event_handler.on_modified = self.on_modified # type: ignore
142+
event_handler.on_moved = self.on_moved # type: ignore
122143
self.observer.schedule( # type: ignore
123144
event_handler,
124145
str(self.path.parent),

tests/_server/test_sessions.py

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@
1515
from typing import Any, Callable, TypeVar
1616
from unittest.mock import MagicMock
1717

18-
import pytest
19-
2018
from marimo._ast.app import App, InternalApp
2119
from marimo._ast.app_config import _AppConfig
2220
from marimo._config.manager import (
@@ -438,10 +436,6 @@ def test_session_with_kiosk_consumers() -> None:
438436
assert session.room.main_consumer is None
439437

440438

441-
@pytest.mark.skipif(
442-
sys.version_info < (3, 10),
443-
reason="This test is flaky on Python 3.9",
444-
)
445439
@save_and_restore_main
446440
async def test_session_manager_file_watching(tmp_path: Path) -> None:
447441
# Create a temporary file
@@ -659,10 +653,6 @@ def test_watch_mode_does_not_override_config(tmp_path: Path) -> None:
659653
session_manager_no_watch.shutdown()
660654

661655

662-
@pytest.mark.skipif(
663-
sys.version_info < (3, 10),
664-
reason="This test is flaky on Python 3.9",
665-
)
666656
@save_and_restore_main
667657
async def test_watch_mode_with_watcher_on_save_config() -> None:
668658
"""Test that watch mode works correctly with watcher_on_save config."""
@@ -814,10 +804,6 @@ def __():
814804
os.remove(tmp_path)
815805

816806

817-
@pytest.mark.skipif(
818-
sys.version_info < (3, 10),
819-
reason="This test is flaky on Python 3.9",
820-
)
821807
@save_and_restore_main
822808
async def test_session_manager_file_rename() -> None:
823809
"""Test that file renaming works correctly with file watching."""

tests/_utils/test_async_path.py

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import asyncio
22
import os
3-
import sys
43
import tempfile
54
from pathlib import Path
65
from unittest.mock import patch
@@ -416,10 +415,6 @@ async def test_operations_use_asyncio_to_thread(self):
416415

417416

418417
class TestAsyncPathEdgeCases:
419-
@pytest.mark.skipif(
420-
sys.version_info < (3, 10),
421-
reason="Hardlink requires Python 3.10 or higher",
422-
)
423418
async def test_hardlink_to(self):
424419
"""Test hardlink_to creates hard link."""
425420
with tempfile.TemporaryDirectory() as tmp:
@@ -436,10 +431,6 @@ async def test_hardlink_to(self):
436431
target_stat = await target.stat()
437432
assert source_stat.st_ino == target_stat.st_ino
438433

439-
@pytest.mark.skipif(
440-
sys.version_info < (3, 10),
441-
reason="Hardlink requires Python 3.10 or higher",
442-
)
443434
async def test_write_text_with_newline(self):
444435
"""Test write_text with custom newline parameter."""
445436
with tempfile.TemporaryDirectory() as tmp:

tests/_utils/test_file_watcher.py

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33

44
import asyncio
55
import os
6-
import sys
6+
import shutil
77
from pathlib import Path
88
from tempfile import NamedTemporaryFile
99

1010
import pytest
1111

12+
from marimo._dependencies.dependencies import DependencyManager
1213
from marimo._utils.file_watcher import FileWatcherManager, PollingFileWatcher
1314

1415

@@ -45,10 +46,6 @@ async def test_callback(path: Path):
4546
assert callback_calls[0] == tmp_path
4647

4748

48-
@pytest.mark.skipif(
49-
sys.version_info < (3, 10),
50-
reason="File watcher tests require Python 3.10+",
51-
)
5249
async def test_file_watcher_manager() -> None:
5350
# Create two temporary files
5451
with (
@@ -151,3 +148,60 @@ async def callback3(path: Path) -> None:
151148
manager.stop_all()
152149
os.remove(tmp_path1)
153150
os.remove(tmp_path2)
151+
152+
153+
# This test is not working and watchdog makes CI hang in other areas
154+
# So we test this manually with `uv run --with=watchdog marimo edit nb.py --watch`
155+
@pytest.mark.xfail(reason="Test not working")
156+
@pytest.mark.skipif(
157+
not DependencyManager.watchdog.has(),
158+
reason="watchdog not installed",
159+
)
160+
async def test_watchdog_file_moved() -> None:
161+
"""Test that watchdog detects when a temp file is moved to the target path.
162+
163+
This simulates editors like Claude Code that save by creating a temp file
164+
and then moving it to the target location.
165+
"""
166+
from marimo._utils.file_watcher import _create_watchdog
167+
168+
with NamedTemporaryFile(delete=False) as tmp_file:
169+
tmp_path = Path(tmp_file.name)
170+
tmp_file.write(b"initial content")
171+
172+
callback_calls: list[Path] = []
173+
174+
async def test_callback(path: Path) -> None:
175+
callback_calls.append(path)
176+
177+
try:
178+
# Create watcher
179+
loop = asyncio.get_event_loop()
180+
watcher = _create_watchdog(tmp_path, test_callback, loop)
181+
watcher.start()
182+
183+
# Wait for watcher to be ready
184+
await asyncio.sleep(0.2)
185+
186+
# Simulate Claude Code save pattern: create temp file and move it
187+
temp_save_path = tmp_path.parent / f"{tmp_path.name}.tmp.12345"
188+
with open(temp_save_path, "w") as f: # noqa: ASYNC230
189+
f.write("modified content")
190+
191+
# Move temp file to target (simulating atomic save)
192+
shutil.move(str(temp_save_path), str(tmp_path))
193+
194+
# Wait for the watcher to detect the move
195+
await asyncio.sleep(0.3)
196+
197+
# Stop watcher
198+
watcher.stop()
199+
200+
# Assert that the callback was called
201+
assert len(callback_calls) >= 1
202+
assert callback_calls[0] == tmp_path
203+
204+
finally:
205+
# Cleanup
206+
if tmp_path.exists():
207+
os.remove(tmp_path)

tests/mocks.py

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import difflib
44
import re
5-
import sys
65
from html.parser import HTMLParser
76
from pathlib import Path
87
from typing import Callable
@@ -102,15 +101,6 @@ def write_result() -> None:
102101
)
103102

104103
if result != expected:
105-
# Old versions of markdown are allowed to have different
106-
# tags and whitespace
107-
if sys.version_info < (3, 10):
108-
if ToText.apply(result) == ToText.apply(expected):
109-
pytest.xfail(
110-
"Different tags in older markdown versions"
111-
)
112-
return
113-
114104
write_result()
115105
print("Snapshot updated")
116106

0 commit comments

Comments
 (0)