Skip to content
Merged
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
5 changes: 5 additions & 0 deletions marimo/_server/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1199,6 +1199,11 @@ def _handle_file_change(
# Get the latest codes
codes = list(session.app_file_manager.app.cell_manager.codes())
cell_ids = list(session.app_file_manager.app.cell_manager.cell_ids())

LOGGER.info(
f"File changed: {file_path}. num_cell_ids: {len(cell_ids)}, num_codes: {len(codes)}, changed_cell_ids: {changed_cell_ids}"
)

# Send the updated cell ids and codes to the frontend
session.write_operation(
UpdateCellIdsRequest(cell_ids=cell_ids),
Expand Down
25 changes: 23 additions & 2 deletions marimo/_utils/file_watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from collections import defaultdict
from collections.abc import Coroutine
from pathlib import Path
from typing import Any, Callable, Optional
from typing import Callable, Optional

from marimo import _loggers
from marimo._dependencies.dependencies import DependencyManager
Expand Down Expand Up @@ -110,15 +110,36 @@ def __init__(
self.loop = loop
self.observer = watchdog.observers.Observer()

def on_modified(self, event: Any) -> None:
def on_modified(
self,
event: watchdog.events.FileModifiedEvent
| watchdog.events.DirModifiedEvent,
) -> None:
del event
self.loop.create_task(self.on_file_changed())

def on_moved(
self,
event: watchdog.events.FileMovedEvent
| watchdog.events.DirMovedEvent,
) -> None:
# Handle editors that save by creating a temp file and moving it
# (e.g., Claude Code, some vim configurations)
dest_path_str = (
event.dest_path
if isinstance(event.dest_path, str)
else event.dest_path.decode("utf-8")
)

if self.path == Path(dest_path_str):
self.loop.create_task(self.on_file_changed())

def start(self) -> None:
event_handler = watchdog.events.PatternMatchingEventHandler( # type: ignore # noqa: E501
patterns=[str(self.path)]
)
event_handler.on_modified = self.on_modified # type: ignore
event_handler.on_moved = self.on_moved # type: ignore
self.observer.schedule( # type: ignore
event_handler,
str(self.path.parent),
Expand Down
14 changes: 0 additions & 14 deletions tests/_server/test_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@
from typing import Any, Callable, TypeVar
from unittest.mock import MagicMock

import pytest

from marimo._ast.app import App, InternalApp
from marimo._ast.app_config import _AppConfig
from marimo._config.manager import (
Expand Down Expand Up @@ -438,10 +436,6 @@ def test_session_with_kiosk_consumers() -> None:
assert session.room.main_consumer is None


@pytest.mark.skipif(
sys.version_info < (3, 10),
reason="This test is flaky on Python 3.9",
)
@save_and_restore_main
async def test_session_manager_file_watching(tmp_path: Path) -> None:
# Create a temporary file
Expand Down Expand Up @@ -659,10 +653,6 @@ def test_watch_mode_does_not_override_config(tmp_path: Path) -> None:
session_manager_no_watch.shutdown()


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


@pytest.mark.skipif(
sys.version_info < (3, 10),
reason="This test is flaky on Python 3.9",
)
@save_and_restore_main
async def test_session_manager_file_rename() -> None:
"""Test that file renaming works correctly with file watching."""
Expand Down
9 changes: 0 additions & 9 deletions tests/_utils/test_async_path.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import asyncio
import os
import sys
import tempfile
from pathlib import Path
from unittest.mock import patch
Expand Down Expand Up @@ -416,10 +415,6 @@ async def test_operations_use_asyncio_to_thread(self):


class TestAsyncPathEdgeCases:
@pytest.mark.skipif(
sys.version_info < (3, 10),
reason="Hardlink requires Python 3.10 or higher",
)
async def test_hardlink_to(self):
"""Test hardlink_to creates hard link."""
with tempfile.TemporaryDirectory() as tmp:
Expand All @@ -436,10 +431,6 @@ async def test_hardlink_to(self):
target_stat = await target.stat()
assert source_stat.st_ino == target_stat.st_ino

@pytest.mark.skipif(
sys.version_info < (3, 10),
reason="Hardlink requires Python 3.10 or higher",
)
async def test_write_text_with_newline(self):
"""Test write_text with custom newline parameter."""
with tempfile.TemporaryDirectory() as tmp:
Expand Down
64 changes: 59 additions & 5 deletions tests/_utils/test_file_watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@

import asyncio
import os
import sys
import shutil
from pathlib import Path
from tempfile import NamedTemporaryFile

import pytest

from marimo._dependencies.dependencies import DependencyManager
from marimo._utils.file_watcher import FileWatcherManager, PollingFileWatcher


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


@pytest.mark.skipif(
sys.version_info < (3, 10),
reason="File watcher tests require Python 3.10+",
)
async def test_file_watcher_manager() -> None:
# Create two temporary files
with (
Expand Down Expand Up @@ -151,3 +148,60 @@ async def callback3(path: Path) -> None:
manager.stop_all()
os.remove(tmp_path1)
os.remove(tmp_path2)


# This test is not working and watchdog makes CI hang in other areas
# So we test this manually with `uv run --with=watchdog marimo edit nb.py --watch`
@pytest.mark.xfail(reason="Test not working")
@pytest.mark.skipif(
not DependencyManager.watchdog.has(),
reason="watchdog not installed",
)
async def test_watchdog_file_moved() -> None:
"""Test that watchdog detects when a temp file is moved to the target path.

This simulates editors like Claude Code that save by creating a temp file
and then moving it to the target location.
"""
from marimo._utils.file_watcher import _create_watchdog

with NamedTemporaryFile(delete=False) as tmp_file:
tmp_path = Path(tmp_file.name)
tmp_file.write(b"initial content")

callback_calls: list[Path] = []

async def test_callback(path: Path) -> None:
callback_calls.append(path)

try:
# Create watcher
loop = asyncio.get_event_loop()
watcher = _create_watchdog(tmp_path, test_callback, loop)
watcher.start()

# Wait for watcher to be ready
await asyncio.sleep(0.2)

# Simulate Claude Code save pattern: create temp file and move it
temp_save_path = tmp_path.parent / f"{tmp_path.name}.tmp.12345"
with open(temp_save_path, "w") as f: # noqa: ASYNC230
f.write("modified content")

# Move temp file to target (simulating atomic save)
shutil.move(str(temp_save_path), str(tmp_path))

# Wait for the watcher to detect the move
await asyncio.sleep(0.3)

# Stop watcher
watcher.stop()

# Assert that the callback was called
assert len(callback_calls) >= 1
assert callback_calls[0] == tmp_path

finally:
# Cleanup
if tmp_path.exists():
os.remove(tmp_path)
10 changes: 0 additions & 10 deletions tests/mocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import difflib
import re
import sys
from html.parser import HTMLParser
from pathlib import Path
from typing import Callable
Expand Down Expand Up @@ -102,15 +101,6 @@ def write_result() -> None:
)

if result != expected:
# Old versions of markdown are allowed to have different
# tags and whitespace
if sys.version_info < (3, 10):
if ToText.apply(result) == ToText.apply(expected):
pytest.xfail(
"Different tags in older markdown versions"
)
return

write_result()
print("Snapshot updated")

Expand Down
Loading