Repository: marimo-team/marimo
Pull request #4884: feat: mo.watch.{file, directory}
Author: login: dmadisetti
Created: 2025-05-12T16:51:36Z | Merged: 2025-05-13T17:49:33Z

## 📝 Summary

- [x] Docs
- [x] file / directory specific tests

fixes #3271
fixes #3258


## 📜 Reviewers

Couple notes maybe worth discussing:
 - @akshayka I did think making threads compat with state was a nice way of pushing this.
 - It's a per thread instance of file watching

For reference we have:
  - module watcher
  - `--watch`

There might be some more code reuse we could do between them, took a look but think this is still the most clean.

Linked issue #3271: Use external file contents for cache invalidation
### Description

`cache` notably fails in the cases of: randomness, network requests, and file content.
With proposed external file watching (#3258), it should be possible to leverage the contents of a watched file to invalidate 

@leventov 

### Suggested solution

Leave as is

Possibly strongly emphasize that code with side effects may produce an inconsistent cache state.

### Alternative

_No response_

### Additional context

For a broader general discussion on cache robustness see https://github.com/marimo-team/marimo/discussions/3270

Diff:
diff --git a/docs/api/index.md b/docs/api/index.md
index 4d8f6b3c5c9..4b2faf0fe30 100644
--- a/docs/api/index.md
+++ b/docs/api/index.md
@@ -26,4 +26,5 @@ Use the marimo library in marimo notebooks (`import marimo as mo`) to
 | [state](state.md)         | Synchronize multiple UI elements with `mo.state`          |
 | [app](app.md)           | Embed notebooks in other notebooks                        |
 | [cell](cell.md)          | Run cells defined in another notebook                     |
+| [watch](watch.md)         | Reactively respond to file changes on disk          |
 | [miscellaneous](miscellaneous.md) | Miscellaneous utilities                                   |
diff --git a/docs/api/watch.md b/docs/api/watch.md
new file mode 100644
index 00000000000..f47560c6e54
--- /dev/null
+++ b/docs/api/watch.md
@@ -0,0 +1,7 @@
+# Watch
+
+
+::: marimo.watch.file
+
+
+::: marimo.watch.directory
diff --git a/marimo/__init__.py b/marimo/__init__.py
index f926d434c3f..e91374af6c6 100644
--- a/marimo/__init__.py
+++ b/marimo/__init__.py
@@ -46,6 +46,7 @@
     "icon",
     "iframe",
     "image",
+    "json",
     "latex",
     "lazy",
     "left",
@@ -78,14 +79,15 @@
     "style",
     "tabs",
     "tree",
-    "json",
     "video",
     "vstack",
+    "watch",
 ]
 __version__ = "0.13.6"
 
 import marimo._ai as ai
 import marimo._islands as islands
+import marimo._runtime.watch as watch
 from marimo._ast.app import App
 from marimo._ast.cell import Cell
 from marimo._islands._island_generator import MarimoIslandGenerator
diff --git a/marimo/_runtime/primitives.py b/marimo/_runtime/primitives.py
index 375453f21dd..8308f9530d7 100644
--- a/marimo/_runtime/primitives.py
+++ b/marimo/_runtime/primitives.py
@@ -23,6 +23,8 @@
 UNCLONABLE_TYPES = [
     "marimo._runtime.state.State",
     "marimo._runtime.state.SetFunctor",
+    "marimo._runtime.watch._file.FileState",
+    "marimo._runtime.watch._directory.DirectoryState",
 ]
 
 UNCLONABLE_MODULES = set(
diff --git a/marimo/_runtime/runtime.py b/marimo/_runtime/runtime.py
index b5925c5cd5b..c065a8360dc 100644
--- a/marimo/_runtime/runtime.py
+++ b/marimo/_runtime/runtime.py
@@ -548,6 +548,7 @@ def __init__(
         self._post_execution_hooks.append(render_toplevel_defs)
 
         self._globals_lock = threading.RLock()
+        self._state_lock = threading.RLock()
         self._completion_worker_started = False
 
         self.debugger = debugger_override
@@ -1403,9 +1404,11 @@ def note_time_of_interruption(
         #                 redirected to frontend (it's printed to console),
         #                 which is incorrect
         await runner.run_all()
-        cells_with_stale_state = runner.resolve_state_updates(
-            self.state_updates
-        )
+        with self._state_lock:
+            cells_with_stale_state = runner.resolve_state_updates(
+                self.state_updates
+            )
+            self.state_updates.clear()
         self.state_updates.clear()
         return cells_with_stale_state
 
@@ -1417,9 +1420,16 @@ def register_state_update(self, state: State[Any]) -> None:
         # store the state and the currently executing cell
         ctx = get_context()
         assert ctx.execution_context is not None
-        self.state_updates[state] = ctx.execution_context.cell_id
-        # TODO(akshayka): Send VariableValues message for any globals
-        # bound to this state object (just like UI elements)
+        cell_id = ctx.execution_context.cell_id
+        with self._state_lock:
+            self.state_updates[state] = cell_id
+        to_update = self.update_stateful_values(
+            ctx.state_registry.bound_names(state), state._value
+        )
+        if self.reactive_execution_mode == "autorun":
+            # If autorun, run the cells that depend on the state
+            self.graph.set_stale(to_update)
+            self._execute_stale_cells_callback()
 
     @kernel_tracer.start_as_current_span("delete_cell")
     async def delete_cell(self, request: DeleteCellRequest) -> None:
@@ -1770,44 +1780,15 @@ async def set_ui_element_value(
                 else:
                     updated_components.append(component)
 
-            bound_names = (
+            bound_names = {
                 name
                 for name in ctx.ui_element_registry.bound_names(object_id)
                 if not is_local(name)
+            }
+            referring_cells.update(
+                self.update_stateful_values(bound_names, value)
             )
 
-            variable_values: list[VariableValue] = []
-            for name in bound_names:
-                # TODO update variable values even for namespaces? lenses? etc
-                variable_values.append(
-                    VariableValue(name=name, value=component)
-                )
-                try:
-                    # subtracting self.graph.definitions[name]: never rerun the
-                    # cell that created the name
-                    referring_cells.update(
-                        self.graph.get_referring_cells(name, language="python")
-                        - self.graph.get_defining_cells(name)
-                    )
-                except Exception:
-                    # Internal marimo error
-                    sys.stderr.write(
-                        "An exception was raised when finding cells that "
-                        f"refer to a UIElement value, for bound name {name}. "
-                        "This is a bug in marimo. "
-                        "Please copy the below traceback and paste it in an "
-                        "issue: https://github.com/marimo-team/marimo/issues\n"
-                    )
-                    tmpio = io.StringIO()
-                    traceback.print_exc(file=tmpio)
-                    tmpio.seek(0)
-                    write_traceback(tmpio.read())
-                    # Entering undefined behavior territory ...
-                    continue
-
-            if variable_values:
-                VariableValues(variables=variable_values).broadcast()
-
         if self.reactive_execution_mode == "autorun":
             await self._run_cells(referring_cells)
         else:
@@ -1855,6 +1836,41 @@ def get_ui_initial_value(self, object_id: str) -> Any:
     def reset_ui_initializers(self) -> None:
         self.ui_initializers = {}
 
+    def update_stateful_values(
+        self, bound_names: set[str], value: Any
+    ) -> set[CellId_t]:
+        variable_values: list[VariableValue] = []
+        referring_cells: set[CellId_t] = set()
+        for name in bound_names:
+            # TODO update variable values even for namespaces? lenses? etc
+            variable_values.append(VariableValue(name=name, value=value))
+            try:
+                # subtracting self.graph.definitions[name]: never rerun the
+                # cell that created the name
+                referring_cells |= self.graph.get_referring_cells(
+                    name, language="python"
+                ) - self.graph.get_defining_cells(name)
+            except Exception:
+                # Internal marimo error
+                sys.stderr.write(
+                    "An exception was raised when finding cells that "
+                    f"refer to a UIElement value, for bound name {name}. "
+                    "This is a bug in marimo. "
+                    "Please copy the below traceback and paste it in an "
+                    "issue: https://github.com/marimo-team/marimo/issues\n"
+                )
+                tmpio = io.StringIO()
+                traceback.print_exc(file=tmpio)
+                tmpio.seek(0)
+                write_traceback(tmpio.read())
+                # Entering undefined behavior territory ...
+                continue
+
+        if variable_values:
+            VariableValues(variables=variable_values).broadcast()
+
+        return referring_cells
+
     @kernel_tracer.start_as_current_span("function_call_request")
     async def function_call_request(
         self, request: FunctionCallRequest
diff --git a/marimo/_runtime/side_effect.py b/marimo/_runtime/side_effect.py
index 14249a9cd4a..f5f32d398fb 100644
--- a/marimo/_runtime/side_effect.py
+++ b/marimo/_runtime/side_effect.py
@@ -11,18 +11,20 @@
 
 
 class SideEffect(CellLifecycleItem):
-    def __init__(self, key: str) -> None:
+    def __init__(self, key: str | bytes) -> None:
         self._key = key
 
     @property
-    def key(self) -> str:
+    def key(self) -> bytes:
         assert self._key is not None
-        return self._key
+        if isinstance(self._key, bytes):
+            return self._key
+        return self._key.encode("utf-8")
 
     @property
     def hash(self) -> bytes:
         """Hash the lookup to a consistent size."""
-        return hashlib.sha256(self._key.encode()).digest()
+        return hashlib.sha256(self.key).digest()
 
     def create(self, context: RuntimeContext | None) -> None:
         """NoOp for side effect.
diff --git a/marimo/_runtime/threads.py b/marimo/_runtime/threads.py
index f57adda8a61..5fef51a6f3c 100644
--- a/marimo/_runtime/threads.py
+++ b/marimo/_runtime/threads.py
@@ -79,7 +79,10 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
 
     def run(self) -> None:
         if self._marimo_ctx is not None:
-            initialize_context(self._marimo_ctx)
+            try:
+                initialize_context(self._marimo_ctx)
+            except RuntimeError:
+                pass
         if isinstance(self._marimo_ctx, KernelRuntimeContext):
             self._marimo_ctx.execution_context = ExecutionContext(
                 cell_id=self._marimo_ctx.stream.cell_id,  # type: ignore
diff --git a/marimo/_runtime/watch/__init__.py b/marimo/_runtime/watch/__init__.py
new file mode 100644
index 00000000000..3ac28b6a1d4
--- /dev/null
+++ b/marimo/_runtime/watch/__init__.py
@@ -0,0 +1,11 @@
+# Copyright 2025 Marimo. All rights reserved.
+from marimo._runtime.watch._directory import directory
+from marimo._runtime.watch._file import file
+
+# NB. _runtime/reload captures module level changes and
+# marimo/_server/sessions.py captures notebook level changes.
+
+__all__ = [
+    "file",
+    "directory",
+]
diff --git a/marimo/_runtime/watch/_directory.py b/marimo/_runtime/watch/_directory.py
new file mode 100644
index 00000000000..814e3c97941
--- /dev/null
+++ b/marimo/_runtime/watch/_directory.py
@@ -0,0 +1,149 @@
+# Copyright 2024 Marimo. All rights reserved.
+from __future__ import annotations
+
+import hashlib
+import os
+import sys
+import time
+from pathlib import Path
+from typing import TYPE_CHECKING, Callable, cast
+
+from marimo._output.rich_help import mddoc
+from marimo._runtime.watch._path import (
+    WATCHER_SLEEP_INTERVAL,
+    PathState,
+    write_side_effect,
+)
+
+if TYPE_CHECKING:
+    import threading
+    from collections.abc import Iterable
+
+
+# For testing only - do not use in production
+_TEST_SLEEP_INTERVAL: float | None = None
+
+
+def walk(path: Path) -> Iterable[tuple[Path, list[str], list[str]]]:
+    if sys.version_info >= (3, 12):
+        return path.walk()
+    return os.walk(path)
+
+
+def _hashable_walk(
+    walked: Iterable[tuple[Path, list[str], list[str]]],
+) -> set[tuple[Path, tuple[str], tuple[str]]]:
+    return cast(
+        set[tuple[Path, tuple[str], tuple[str]]],
+        set((p, *map(tuple, r)) for p, *r in walked),
+    )
+
+
+def hashable_walk(path: Path) -> set[tuple[Path, tuple[str], tuple[str]]]:
+    return _hashable_walk(walk(path))
+
+
+def watch_directory(
+    path: Path, state: DirectoryState, should_exit: threading.Event
+) -> None:
+    """Watch a directory for changes and update the state."""
+    last_structure = hashable_walk(path)
+    current_structure = last_structure
+    sleep_interval = _TEST_SLEEP_INTERVAL or WATCHER_SLEEP_INTERVAL
+    while not should_exit.is_set():
+        time.sleep(sleep_interval)
+        try:
+            current_structure = hashable_walk(path)
+        except FileNotFoundError:
+            # Directory has been deleted, trigger a change
+            current_structure = set()
+        except Exception as e:
+            # Handle other exceptions (e.g., permission denied)
+            sys.stderr.write(f"Error watching directory {path}: {e}\n")
+            continue
+
+        if current_structure != last_structure:
+            last_structure = current_structure
+            state._set_value(path)
+
+
+class DirectoryState(PathState):
+    """Wrapper for directory state."""
+
+    _forbidden_attributes = {
+        "open",
+        "rename",
+        "replace",
+        "remove",
+        "unlink",
+        "write_text",
+        "write_bytes",
+        "read_text",
+        "read_bytes",
+        "mkdir",
+        "touch",
+    }
+    _target: Callable[[Path, DirectoryState, threading.Event], None] = (
+        staticmethod(watch_directory)
+    )
+
+    def walk(self) -> Iterable[tuple[Path, list[str], list[str]]]:
+        """Walk the directory."""
+        items = walk(self._value)
+        as_list = list(_hashable_walk(items))
+        write_side_effect(f"walk:{sorted(as_list)}")
+        return iter(items)
+
+    def iterdir(self) -> Iterable[Path]:
+        """Iterate over the directory."""
+        items = list(self._value.iterdir())
+        write_side_effect(f"iterdir:{items}")
+        return iter(items)
+
+    def glob(self, pattern: str) -> Iterable[Path]:
+        """Glob the directory."""
+        items = list(self._value.glob(pattern))
+        write_side_effect(f"glob:{items}")
+        return iter(items)
+
+    def rglob(self, pattern: str) -> Iterable[Path]:
+        """Recursive glob the directory."""
+        items = list(self._value.rglob(pattern))
+        write_side_effect(f"rglob:{items}")
+        return iter(items)
+
+    def __repr__(self) -> str:
+        """Return a string representation of the file state."""
+        _walk = self.walk()  # Call to issue side effect
+        _hash = hashlib.sha256(f"{list(_walk)}".encode()).hexdigest()
+        return f"DirectoryState({self._value}: {_hash})"
+
+
+@mddoc
+def directory(path: Path | str) -> DirectoryState:
+    """
+    A reactive wrapper for directory paths.
+
+    This function takes a directory path to watch and returns a wrapper to
+    reactively list the contents of the directory.
+
+    This object will trigger dependent cells to re-evaluate when the directory
+    structure is changed (i.e., files are added or removed).
+
+    Note:
+        This function does NOT react to file content changes, only to changes in
+        the directory structure. Utilize `mo.watch.file()` to watch for changes
+        in specific files. Additional note: this will not follow symlinks.
+
+    Args:
+      path: Path to watch.
+
+    Returns:
+        A reactive wrapper for watching the directory.
+    """
+
+    if isinstance(path, str):
+        path = Path(path)
+    if not path.is_dir():
+        raise ValueError("Path must be a directory, use mo.file()")
+    return DirectoryState(path, allow_self_loops=True)
diff --git a/marimo/_runtime/watch/_file.py b/marimo/_runtime/watch/_file.py
new file mode 100644
index 00000000000..95f3367e1ed
--- /dev/null
+++ b/marimo/_runtime/watch/_file.py
@@ -0,0 +1,148 @@
+# Copyright 2024 Marimo. All rights reserved.
+from __future__ import annotations
+
+import hashlib
+import sys
+import threading
+import time
+from pathlib import Path
+from typing import Any, Callable
+
+from marimo._output.rich_help import mddoc
+from marimo._runtime.watch._path import (
+    WATCHER_SLEEP_INTERVAL,
+    PathState,
+    write_side_effect,
+)
+
+# For testing only - do not use in production
+_TEST_SLEEP_INTERVAL: float | None = None
+
+
+def watch_file(
+    path: Path, state: FileState, should_exit: threading.Event
+) -> None:
+    """Watch a file for changes and update the state."""
+    last_mtime: float = 0
+    current_mtime = last_mtime
+    sleep_interval = _TEST_SLEEP_INTERVAL or WATCHER_SLEEP_INTERVAL
+    while not should_exit.is_set():
+        time.sleep(sleep_interval)
+        try:
+            current_mtime = path.stat().st_mtime
+        except FileNotFoundError:
+            # File has been deleted, trigger a change
+            current_mtime = 0
+        except Exception as e:
+            # Handle other exceptions (e.g., permission denied)
+            sys.stderr.write(f"Error watching file {path}: {e}\n")
+            continue
+
+        if current_mtime != last_mtime:
+            last_mtime = current_mtime
+            with state._debounce_lock:
+                if not state._debounced:
+                    state._set_value(path)
+                state._debounced = False
+
+
+class FileState(PathState):
+    """Wrapper for file state."""
+
+    _forbidden_attributes = {
+        "open",
+        "iterdir",
+        "glob",
+        "rglob",
+        "mkdir",
+        "rename",
+        "replace",
+        "walk",
+    }
+    _target: Callable[[Path, FileState, threading.Event], None] = staticmethod(
+        watch_file
+    )
+
+    def __init__(self, *args: Any, **kwargs: Any) -> None:
+        super().__init__(*args, **kwargs)
+        self._debounced = False
+        self._debounce_lock = threading.Lock()
+
+    def read_text(self) -> str:
+        """Read the file as a string."""
+        text = self._value.read_text()
+        write_side_effect(f"read_text:{text}")
+        return text
+
+    def write_text(self, value: str) -> int:
+        """Write the file as a string."""
+        response = self._value.write_text(value)
+        text = self._value.read_text()
+        write_side_effect(f"write_text:{text}")
+        with self._debounce_lock:
+            self._debounced = True
+            self._set_value(self._value)
+        return response
+
+    def read_bytes(self) -> bytes:
+        """Read the file as bytes."""
+        data = self._value.read_bytes()
+        write_side_effect(f"read_bytes:{data!r}")
+        return data
+
+    def write_bytes(self, value: bytes) -> int:
+        """Write the file as bytes."""
+        response = self._value.write_bytes(value)
+        data = self._value.read_bytes()
+        write_side_effect(f"write_bytes:{data!r}")
+        with self._debounce_lock:
+            self._debounced = True
+            self._set_value(self._value)
+        return response
+
+    def __repr__(self) -> str:
+        """Return a string representation of the file state."""
+        if not self._value.exists():
+            return f"FileState({self._value}: File not found)"
+        _hash = hashlib.sha256(self._value.read_bytes()).hexdigest()
+        return f"FileState({self._value}: {_hash})"
+
+
+@mddoc
+def file(path: Path | str) -> FileState:
+    """
+    A reactive wrapper for file paths.
+
+    This function takes a file path to watch and returns a wrapper to reactively
+    read and write from the file.
+
+    The "wrapped" file Path object exposes most of the same methods as the
+    [pathlib.Path object](https://docs.python.org/3/library/pathlib.html#pathlib.Path),
+    with a few exceptions. The following methods are not available:
+
+    - `open()`
+    - `rename()`
+    - `replace()`
+
+    This object will trigger dependent cells to re-evaluate when the file is
+    changed.
+
+    Warning:
+        It is possible to misuse this API in similar ways to `state()`. Consider
+        reading the warning and caveats in the
+        [`state()` documentation](../state), and using this function only when
+        reading file paths, and not when writing them.
+
+    Args:
+      path: Path to watch.
+
+    Returns:
+        A reactive wrapper for watching the file path.
+    """
+    if isinstance(path, str):
+        path = Path(path)
+    if path.is_dir():
+        raise ValueError(
+            "Path must be a file, not a directory, use mo.directory() instead"
+        )
+    return FileState(path, allow_self_loops=True)
diff --git a/marimo/_runtime/watch/_path.py b/marimo/_runtime/watch/_path.py
new file mode 100644
index 00000000000..589553baed0
--- /dev/null
+++ b/marimo/_runtime/watch/_path.py
@@ -0,0 +1,107 @@
+# Copyright 2024 Marimo. All rights reserved.
+from __future__ import annotations
+
+import sys
+import threading
+from pathlib import Path
+from typing import Any, Callable, TypeVar
+
+from marimo._runtime.context import (
+    ContextNotInitializedError,
+    get_context,
+    runtime_context_installed,
+)
+from marimo._runtime.side_effect import SideEffect
+from marimo._runtime.state import State
+from marimo._runtime.threads import Thread
+
+if sys.version_info >= (3, 11):
+    from typing import Self
+else:
+    from typing_extensions import Self
+
+
+T = TypeVar("T")
+
+WATCHER_SLEEP_INTERVAL = 1.0
+
+
+def write_side_effect(data: str | bytes) -> None:
+    """Write side effect to the context."""
+    try:
+        ctx = get_context()
+    except ContextNotInitializedError:
+        # Context is not initialized, nothing we can do
+        return
+    ctx.cell_lifecycle_registry.add(SideEffect(data))
+
+
+class PathState(State[Path]):
+    """Base class for path state."""
+
+    _forbidden_attributes: set[str]
+    _target: Callable[[Path, Self, threading.Event], None]
+
+    def __init__(
+        self,
+        path: Path,
+        *args: Any,
+        allow_self_loops: bool = True,
+        **kwargs: Any,
+    ) -> None:
+        if kwargs.pop("_context", None) is not None:
+            raise ValueError(
+                "The '_context' argument is not supported for this class."
+            )
+        if kwargs.pop("allow_self_loops", None) is not None:
+            raise ValueError(
+                "The 'allow_self_loops' argument is not supported for this class."
+            )
+
+        # Mypy seems to think we could provide multiple kwargs definitions here
+        # but we can't.
+        super().__init__(
+            path,
+            *args,
+            allow_self_loops=allow_self_loops,
+            _context="file",
+            **kwargs,
+        )  # type: ignore[misc]
+        self._should_exit = threading.Event()
+        # Only bother with the watcher if the context is installed
+        # State is not enabled in script mode
+        if runtime_context_installed():
+            Thread(
+                target=self._target,
+                args=(path, self, self._should_exit),
+                daemon=True,
+            ).start()
+
+    def __getattr__(self, name: str) -> Any:
+        """Get an attribute from the file path."""
+        # Disable some attributes
+        if name in self._forbidden_attributes:
+            raise AttributeError(
+                f"'{self.__class__.__name__}' does not "
+                f"expose attribute '{name}'"
+            )
+        if hasattr(self._value, name):
+            return getattr(self._value, name)
+        raise AttributeError(
+            f"'{self.__class__.__name__}' object has no attribute '{name}'"
+        )
+
+    def __del__(self) -> None:
+        self._should_exit.set()
+
+    def __repr__(self) -> str:
+        return f"{self.__class__.__name__}({self._value})"
+
+    def exists(self) -> bool:
+        """Check if the path exists."""
+        exists = self._value.exists()
+        if not exists:
+            write_side_effect(f"doesn't exists:{self._value}")
+        else:
+            _ = self.read_text()
+        return exists
diff --git a/marimo/_save/hash.py b/marimo/_save/hash.py
index 4419674b9ec..4e7d543c1db 100644
--- a/marimo/_save/hash.py
+++ b/marimo/_save/hash.py
@@ -30,6 +30,7 @@
 )
 from marimo._runtime.side_effect import SideEffect
 from marimo._runtime.state import SetFunctor, State
+from marimo._runtime.watch._path import PathState
 from marimo._save.cache import Cache, CacheType
 from marimo._types.ids import CellId_t
 
@@ -261,7 +262,7 @@ def attempt_signed_bytes(value: bytes, label: str) -> bytes:
     try:
         return type_sign(common_container_to_bytes(value), label)
     # Fallback to raw state for eval in content hash.
-    except TypeError:
+    except (TypeError, ValueError):
         return value
 
 
@@ -660,7 +661,14 @@ def extract_ref_state_and_normalize_scope(
             if value is not None and (
                 ref not in scope or isinstance(scope[ref], State)
             ):
-                scope[ref] = attempt_signed_bytes(value(), "state")
+                if isinstance(value, PathState):
+                    # Path state only contains the path as value, it should
+                    # contain the path contents.
+                    scope[ref] = attempt_signed_bytes(
+                        repr(value).encode(), "pathstate"
+                    )
+                else:
+                    scope[ref] = attempt_signed_bytes(value(), "state")
                 if ctx:
                     for state_name in ctx.state_registry.bound_names(value):
                         scope[state_name] = scope[ref]
diff --git a/tests/_runtime/watch/test_watch.py b/tests/_runtime/watch/test_watch.py
new file mode 100644
index 00000000000..3eaf79e55cb
--- /dev/null
+++ b/tests/_runtime/watch/test_watch.py
@@ -0,0 +1,159 @@
+# Copyright 2025 Marimo. All rights reserved.
+import asyncio
+from pathlib import Path
+
+import pytest
+
+from marimo._runtime.runtime import Kernel
+from tests.conftest import ExecReqProvider
+
+
+@pytest.mark.xfail(
+    True, reason="Flaky in CI, can't repro locally", strict=False
+)
+async def test_read_and_write_path(
+    execution_kernel: Kernel, exec_req: ExecReqProvider, tmp_path: Path
+) -> None:
+    k = execution_kernel
+    await k.run(
+        [
+            exec_req.get("from pathlib import Path"),
+            exec_req.get(
+                f'tmp = Path("{tmp_path.as_posix()}") / "test.txt"; tmp.write_text("0")'
+            ),
+            exec_req.get(
+                """
+                import time
+                import marimo as mo
+                mo.watch._file._TEST_SLEEP_INTERVAL = 0.05
+                """
+            ),
+            exec_req.get("path = mo.watch.file(tmp)"),
+            exec_req.get("x = path.read_text()"),
+            exec_req.get(
+                """
+                x
+                if x == "0":
+                    path.write_text("1")
+                """
+            ),
+        ]
+    )
+    await asyncio.sleep(0.1)
+
+    assert not k.stdout.messages, k.stdout
+    assert not k.stderr.messages, k.stderr
+    assert k.globals["x"] == "1"
+
+
+@pytest.mark.xfail(
+    True, reason="Flaky in CI, can't repro locally", strict=False
+)
+async def test_read_and_write_iteration(
+    execution_kernel: Kernel, exec_req: ExecReqProvider, tmp_path: Path
+) -> None:
+    k = execution_kernel
+    await k.run(
+        [
+            exec_req.get("from pathlib import Path"),
+            exec_req.get(
+                f'tmp = Path("{tmp_path.as_posix()}") / "test.txt"; tmp.touch()'
+            ),
+            exec_req.get(
+                """
+                import asyncio
+                import marimo as mo
+                mo.watch._file._TEST_SLEEP_INTERVAL = 0.01
+                """
+            ),
+            exec_req.get("path = mo.watch.file(tmp)"),
+            exec_req.get("x = path.read_bytes()"),
+            exec_req.get(
+                """
+                x
+                if len(x) < 3:
+                    print("Writing 0")
+                    path.write_bytes(b"0" * (len(x) + 1))
+                """
+            ),
+        ]
+    )
+    await asyncio.sleep(0.1)
+
+    assert not k.stderr.messages, k.stderr
+    assert k.globals["x"] == b"000"
+
+
+@pytest.mark.xfail(
+    True, reason="Flaky in CI, can't repro locally", strict=False
+)
+async def test_allow_self_loops(
+    execution_kernel: Kernel, exec_req: ExecReqProvider, tmp_path: Path
+) -> None:
+    k = execution_kernel
+    await k.run(
+        [
+            exec_req.get("from pathlib import Path"),
+            exec_req.get(
+                """
+                import asyncio
+                import marimo as mo
+                mo.watch._file._TEST_SLEEP_INTERVAL = 0.01
+                """
+            ),
+            exec_req.get(
+                f'tmp = Path("{tmp_path.as_posix()}") / "test.txt"; tmp.touch()'
+            ),
+            exec_req.get("path = mo.watch.file(tmp)"),
+            exec_req.get(
+                """
+                path() # Just returns the path
+                x = len(path.read_bytes())
+                if x < 3:
+                    path.write_bytes(b"0" * (x + 1))
+                """
+            ),
+        ]
+    )
+    await asyncio.sleep(0.1)
+    # Flakey in CI 3.13
+    await k.run([])
+
+    assert not k.stderr.messages, k.stderr
+    assert k.globals["x"] == 3
+
+
+@pytest.mark.xfail(
+    True, reason="Flaky in CI, can't repro locally", strict=False
+)
+async def test_directory_watch(
+    execution_kernel: Kernel, exec_req: ExecReqProvider, tmp_path: Path
+) -> None:
+    k = execution_kernel
+    await k.run(
+        [
+            exec_req.get("from pathlib import Path"),
+            exec_req.get(f'tmp = Path("{tmp_path.as_posix()}")'),
+            exec_req.get(
+                """
+                import time
+                import marimo as mo
+                mo.watch._directory._TEST_SLEEP_INTERVAL = 0.05
+                """
+            ),
+            exec_req.get("path = mo.watch.directory(tmp)"),
+            exec_req.get("x = len(list(path.glob('*')))"),
+            exec_req.get(
+                """
+                x
+                if x == 0:
+                    (tmp / "test.txt").write_text("1")
+                """
+            ),
+        ]
+    )
+    await asyncio.sleep(0.25)
+    await k.run([])
+
+    assert not k.stderr.messages, k.stderr
+    assert k.globals["x"] == 1
diff --git a/tests/_save/test_hash.py b/tests/_save/test_hash.py
index 0708d384ece..7215676d2b8 100644
--- a/tests/_save/test_hash.py
+++ b/tests/_save/test_hash.py
@@ -2,6 +2,7 @@
 
 from __future__ import annotations
 
+import asyncio
 from typing import Any
 
 import pytest
@@ -1143,3 +1144,177 @@ def v_cache():
         assert non_primitive[1] == 0
         assert v == 1
         assert hashes[0] != hashes[1]
+
+    @staticmethod
+    async def test_side_effect_file(
+        k: Kernel, exec_req: ExecReqProvider, tmp_path
+    ) -> None:
+        await k.run(
+            [
+                exec_req.get(
+                    f'tmp_path_fixture = Path("{tmp_path.as_posix()}")'
+                ),
+                exec_req.get("""
+                from tests._save.loaders.mocks import MockLoader
+                import marimo as mo
+                mo.watch._file._TEST_SLEEP_INTERVAL = 0.01
+                from pathlib import Path
+
+                hashes = []
+                """),
+                exec_req.get("""
+                f = mo.watch.file(tmp_path_fixture / "test.txt")
+                """),
+            ]
+        )
+        assert not k.stdout.messages, k.stdout
+        assert not k.stderr.messages, k.stderr
+        await k.run(
+            [
+                exec_req.get("""
+                f
+                non_primitive = [object(), len(hashes), f.exists()]
+                """),
+                exec_req.get("""
+                with mo.persistent_cache("get_v", save_path=tmp_path_fixture) as v_cache:
+                    v = non_primitive[1]
+                """),
+            ]
+        )
+        assert not k.stdout.messages, k.stdout
+        assert not k.stderr.messages, k.stderr
+        await k.run(
+            [
+                r := exec_req.get("""
+                if len(hashes) < 1:
+                    assert non_primitive[1] == 0
+                hashes.append((v_cache.cache_type, v_cache._cache.hash))
+                """),
+            ]
+        )
+        (tmp_path / "test.txt").touch()
+        await asyncio.sleep(0.25)
+        await k.run([])
+        assert not k.stdout.messages, k.stdout
+        assert not k.stderr.messages, k.stderr
+        v = k.globals["v"]
+        hashes = k.globals["hashes"]
+        non_primitive = k.globals["non_primitive"]
+        assert len(hashes) == 2
+        assert hashes[0] != hashes[1]
+        assert non_primitive[1] == 1 == v
+
+    @staticmethod
+    async def test_side_effect_directory(
+        k: Kernel, exec_req: ExecReqProvider, tmp_path
+    ) -> None:
+        await k.run(
+            [
+                exec_req.get(
+                    f'tmp_path_fixture = Path("{tmp_path.as_posix()}")'
+                ),
+                exec_req.get("""
+                from tests._save.loaders.mocks import MockLoader
+                import marimo as mo
+                mo.watch._directory._TEST_SLEEP_INTERVAL = 0.01
+                from pathlib import Path
+
+                hashes = []
+                """),
+                exec_req.get("""
+                (tmp_path_fixture / "test_dir").mkdir(parents=True, exist_ok=True)
+                d = mo.watch.directory(tmp_path_fixture / "test_dir")
+                """),
+            ]
+        )
+        assert not k.stdout.messages, k.stdout
+        assert not k.stderr.messages, k.stderr
+        await k.run(
+            [
+                exec_req.get("""
+                non_primitive = [object(), len(hashes), d.glob("*")]
+                """),
+                exec_req.get("""
+                with mo.persistent_cache("get_v", save_path=tmp_path_fixture) as v_cache:
+                    v = non_primitive[1]
+                """),
+            ]
+        )
+        assert not k.stdout.messages, k.stdout
+        assert not k.stderr.messages, k.stderr
+        await k.run(
+            [
+                r := exec_req.get("""
+                if len(hashes) < 1:
+                    assert non_primitive[1] == 0
+                hashes.append((v_cache.cache_type, v_cache._cache.hash))
+                """),
+            ]
+        )
+        (tmp_path / "test_dir" / "test.txt").write_text("test")
+        await asyncio.sleep(0.25)
+        await k.run([])
+        assert not k.stdout.messages, k.stdout
+        assert not k.stderr.messages, k.stderr
+        v = k.globals["v"]
+        hashes = k.globals["hashes"]
+        non_primitive = k.globals["non_primitive"]
+        assert len(hashes) == 2
+        assert hashes[0] != hashes[1]
+        assert non_primitive[1] == 1 == v
+
+    @staticmethod
+    async def test_side_effect_file_ref(
+        k: Kernel, exec_req: ExecReqProvider, tmp_path
+    ) -> None:
+        await k.run(
+            [
+                exec_req.get(
+                    f'tmp_path_fixture = Path("{tmp_path.as_posix()}")'
+                ),
+                exec_req.get("""
+                from tests._save.loaders.mocks import MockLoader
+                import marimo as mo
+                from pathlib import Path
+
+                hashes = []
+                """),
+                exec_req.get(
+                    'u = mo.watch.file(tmp_path_fixture / "test.txt")'
+                ),
+                exec_req.get("""
+                non_primitive = [object(), len(hashes)]
+                with mo.cache("prim") as prim_cache:
+                    # unused, but should trigger side effect
+                    u
+                """),
+                exec_req.get("""
+                with mo.persistent_cache("get_v", save_path=tmp_path_fixture) as v_cache:
+                    v = non_primitive[1]
+                """),
+            ]
+        )
+        assert not k.stdout.messages, k.stdout
+        assert not k.stderr.messages, k.stderr
+        await k.run(
+            [
+                exec_req.get("""
+                if len(hashes) < 1:
+                    assert non_primitive[1] == 0
+                    u.write_text("test")
+                hashes.append((v_cache.cache_type, v_cache._cache.hash))
+                hashes.append((prim_cache.cache_type, prim_cache._cache.hash))
+                """),
+            ]
+        )
+        assert not k.stdout.messages, k.stdout
+        assert not k.stderr.messages, k.stderr
+        v = k.globals["v"]
+        hashes = k.globals["hashes"]
+        non_primitive = k.globals["non_primitive"]
+        # Docs warn not to use write directly since RC can occur, causing double
+        # event.
+        assert len(hashes) == 4
+        assert hashes[1] != hashes[3]
+        assert hashes[0] != hashes[2]
+        assert non_primitive[1] == 2 == v
diff --git a/tests/snapshots/api.txt b/tests/snapshots/api.txt
index ebf1e717954..c67c9369963 100644
--- a/tests/snapshots/api.txt
+++ b/tests/snapshots/api.txt
@@ -113,4 +113,7 @@ ui
   text
   text_area
 video
-vstack
\ No newline at end of file
+vstack
+watch
+  directory
+  file
\ No newline at end of file


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR introduces reactive file and directory watching capabilities along with accompanying tests and small fixes across the codebase.  
- New tests in tests/_save/test_hash.py validate side effects from file and directory watches.  
- The core watching functionality is implemented in marimo/_runtime/watch.py, with minor improvements in exception handling and integration in runtime and threads.  
- Additional changes include updating error handling in marimo/_save/hash.py and side_effect.py and exporting the new watch API in __init__.py.

### Reviewed Changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

<details>
<summary>Show a summary per file</summary>

| File                             | Description                                                         |
| -------------------------------- | ------------------------------------------------------------------- |
| tests/_save/test_hash.py         | Added asynchronous test methods for file and directory side effects.|
| marimo/_save/hash.py             | Extended exception handling to capture ValueError in hashing.       |
| marimo/_runtime/watch.py         | Introduced file and directory watchers; implemented state update.   |
| marimo/_runtime/threads.py       | Wrapped context initialization in a try/except to handle RuntimeError.|
| marimo/_runtime/side_effect.py   | Modified type signature and error handling for SideEffect.          |
| marimo/_runtime/runtime.py       | Added a new state lock and improved state update registration.        |
| marimo/__init__.py               | Exported the new "watch" functionality alongside other APIs.        |
</details>





state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T16:52:16Z
user: login: mscolnick
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T19:19:31Z
user: login: akshayka
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T19:34:56Z
user: login: dmadisetti
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T19:51:10Z
user: login: mscolnick
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T20:04:50Z
user: login: dmadisetti
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T20:06:29Z
user: login: mscolnick
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T15:44:26Z

Review comments:
diff_hunk: @@ -0,0 +1,296 @@
+# Copyright 2024 Marimo. All rights reserved.
+from __future__ import annotations
+
+import sys
+import time
+
+from typing import TypeVar, Any, Callable
+from pathlib import Path
+import threading
+
+from marimo._output.rich_help import mddoc
+from marimo._runtime.side_effect import SideEffect
+from marimo._runtime.state import State
+from marimo._runtime.threads import Thread
+
+from marimo._runtime.context import ContextNotInitializedError, get_context, runtime_context_installed
+
+
+T = TypeVar("T")
+
+
+MODULE_WATCHER_SLEEP_INTERVAL = 1.0
+
+# For testing only - do not use in production
+_TEST_SLEEP_INTERVAL: float | None = None
+
+
+def write_side_effect(data: str | bytes) -> None:
+    """Write side effect to the context."""
+    try:
+        ctx = get_context()
+    except ContextNotInitializedError:
+        # Context is not initialized, nothing we can do
+        return
+    ctx.cell_lifecycle_registry.add(SideEffect(data))
+
+
+def watch_file(
+    path: Path, state: FileState, should_exit: threading.Event
+) -> None:
+    """Watch a file for changes and update the state."""
+    last_mtime = 0
+    current_mtime = last_mtime
+    sleep_interval = _TEST_SLEEP_INTERVAL or MODULE_WATCHER_SLEEP_INTERVAL
+    while not should_exit.is_set():
+        time.sleep(sleep_interval)
+        try:
+            current_mtime = path.stat().st_mtime
+        except FileNotFoundError:
+            # File has been deleted, trigger a change
+            current_mtime = 0
+        except Exception as e:
+            # Handle other exceptions (e.g., permission denied)
+            sys.stderr.write(f"Error watching file {path}: {e}\n")
+            continue
+
+        if current_mtime != last_mtime:
+            last_mtime = current_mtime
+            state._set_value(path)
+
+def watch_directory(
+    path: Path, state: DirectoryState, should_exit: threading.Event
+) -> None:
+    """Watch a directory for changes and update the state."""
+    print([k for k in path.walk()])
+    last_structure = set((p, *map(tuple,r)) for p, *r in path.walk())
+    current_structure = last_structure
+    sleep_interval = _TEST_SLEEP_INTERVAL or MODULE_WATCHER_SLEEP_INTERVAL
+    while not should_exit.is_set():
+        time.sleep(sleep_interval)
+        try:
+            current_structure = set((p, *map(tuple,r)) for p, *r in path.walk())
+        except FileNotFoundError:
+            # Directory has been deleted, trigger a change
+            current_mtime = 0
path: marimo/_runtime/watch.py
user: login: Copilot
body: In the 'watch_directory' function, the FileNotFoundError handler sets 'current_mtime' instead of updating 'current_structure'. Consider setting 'current_structure' to an empty set (or a suitable value) to ensure that a deletion triggers a change.
```suggestion
            current_structure = set()
```
created_at: 2025-05-12T16:52:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,339 @@
+# Copyright 2024 Marimo. All rights reserved.
+from __future__ import annotations
+
+import os
+import sys
+import threading
+import time
+from collections.abc import Iterable
+from pathlib import Path
+from typing import Any, Callable, TypeVar, cast
+
+from marimo._output.rich_help import mddoc
+from marimo._runtime.context import (
+    ContextNotInitializedError,
+    get_context,
+    runtime_context_installed,
+)
+from marimo._runtime.side_effect import SideEffect
+from marimo._runtime.state import State
+from marimo._runtime.threads import Thread
+
+if sys.version_info >= (3, 11):
+    from typing import Self
+else:
+    from typing_extensions import Self
+
+
+T = TypeVar("T")
+
+WATCHER_SLEEP_INTERVAL = 1.0
+
+# For testing only - do not use in production
+_TEST_SLEEP_INTERVAL: float | None = None
+
+
+def write_side_effect(data: str | bytes) -> None:
+    """Write side effect to the context."""
+    try:
+        ctx = get_context()
+    except ContextNotInitializedError:
+        # Context is not initialized, nothing we can do
+        return
+    ctx.cell_lifecycle_registry.add(SideEffect(data))
+
+
+def watch_file(
+    path: Path, state: FileState, should_exit: threading.Event
+) -> None:
+    """Watch a file for changes and update the state."""
+    last_mtime: float = 0
+    current_mtime = last_mtime
+    sleep_interval = _TEST_SLEEP_INTERVAL or WATCHER_SLEEP_INTERVAL
+    while not should_exit.is_set():
+        time.sleep(sleep_interval)
+        try:
+            current_mtime = path.stat().st_mtime
+        except FileNotFoundError:
+            # File has been deleted, trigger a change
+            current_mtime = 0
+        except Exception as e:
+            # Handle other exceptions (e.g., permission denied)
+            sys.stderr.write(f"Error watching file {path}: {e}\n")
+            continue
+
+        if current_mtime != last_mtime:
+            last_mtime = current_mtime
+            state._set_value(path)
+
+
+def walk(path: Path) -> Iterable[tuple[Path, list[str], list[str]]]:
+    if sys.version_info > (3, 11):
+        return path.walk()
+    return os.walk(path)
+
+
+def _hashable_walk(
+    walked: Iterable[tuple[Path, list[str], list[str]]],
+) -> set[tuple[Path, tuple[str], tuple[str]]]:
+    return cast(
+        Iterable[tuple[Path, tuple[str], tuple[str]]],
+        set((p, *map(tuple, r)) for p, *r in walked),
+    )
+
+
+def hashable_walk(path: Path) -> set[tuple[Path, tuple[str], tuple[str]]]:
+    return _hashable_walk(walk(path))
+
+
+def watch_directory(
+    path: Path, state: DirectoryState, should_exit: threading.Event
+) -> None:
+    """Watch a directory for changes and update the state."""
+    last_structure = hashable_walk(path)
+    current_structure = last_structure
+    sleep_interval = _TEST_SLEEP_INTERVAL or WATCHER_SLEEP_INTERVAL
+    while not should_exit.is_set():
+        time.sleep(sleep_interval)
+        try:
+            current_structure = hashable_walk(path)
+        except FileNotFoundError:
+            # Directory has been deleted, trigger a change
+            current_structure = set()
+        except Exception as e:
+            # Handle other exceptions (e.g., permission denied)
+            sys.stderr.write(f"Error watching directory {path}: {e}\n")
+            continue
+
+        if current_structure != last_structure:
+            last_structure = current_structure
+            state._set_value(path)
+
+
+class PathState(State[Path]):
+    """Base class for path state."""
+
+    _forbidden_attributes: set[str]
+    _target: Callable[[Path, Self, threading.Event], None]
+
+    def __init__(
+        self,
+        path: Path,
+        *args: Any,
+        allow_self_loops: bool = True,
+        **kwargs: Any,
+    ) -> None:
+        if kwargs.pop("_context", None) is not None:
+            raise ValueError(
+                "The '_context' argument is not supported for this class."
+            )
+        if kwargs.pop("allow_self_loops", None) is not None:
+            raise ValueError(
+                "The 'allow_self_loops' argument is not supported for this class."
+            )
+
+        # Mypy seems to think we could provide multiple kwargs definitions here
+        # but we can't.
+        super().__init__(
+            path,
+            *args,
+            allow_self_loops=allow_self_loops,
+            _context="file",
+            **kwargs,
+        )  # type: ignore[misc]
+        self._should_exit = threading.Event()
+        # Only bother with the watcher if the context is installed
+        # State is not enabled in script mode
+        if runtime_context_installed():
+            Thread(
+                target=self._target,
+                args=(path, self, self._should_exit),
+                daemon=True,
+            ).start()
+
+    def __getattr__(self, name: str) -> Any:
+        """Get an attribute from the file path."""
+        # Disable some attributes
+        if name in self._forbidden_attributes:
+            raise AttributeError(
+                f"'{self.__class__.__name__}' does not "
+                f"expose attribute '{name}'"
+            )
+        if hasattr(self._value, name):
+            return getattr(self._value, name)
+        raise AttributeError(
+            f"'{self.__class__.__name__}' object has no attribute '{name}'"
+        )
+
+    def __del__(self) -> None:
+        self._should_exit.set()
+
+    def __repr__(self) -> str:
+        return f"{self.__class__.__name__}({self._value})"
+
+    def exists(self) -> bool:
+        """Check if the path exists."""
+        exists = self._value.exists()
+        if not exists:
+            write_side_effect(f"doesn't exists:{self._value}")
+        else:
+            _ = self.read_text()
+        return exists
+
+
+class FileState(PathState):
+    """Wrapper for file state."""
+
+    _forbidden_attributes = {
+        "open",
+        "iterdir",
+        "glob",
+        "rglob",
+        "mkdir",
+        "rename",
+        "replace",
+        "walk",
+    }
+    _target: Callable[[Path, FileState, threading.Event], None] = staticmethod(
+        watch_file
+    )
+
+    def read_text(self) -> str:
+        """Read the file as a string."""
+        text = self._value.read_text()
+        write_side_effect(f"read_text:{text}")
+        return text
+
+    def write_text(self, value: str) -> int:
+        """Write the file as a string."""
+        response = self._value.write_text(value)
+        text = self._value.read_text()
+        write_side_effect(f"write_text:{text}")
+        return response
+
+    def read_bytes(self) -> bytes:
+        """Read the file as bytes."""
+        data = self._value.read_bytes()
+        write_side_effect(f"read_bytes:{data!r}")
+        return data
+
+    def write_bytes(self, value: bytes) -> int:
+        """Write the file as bytes."""
+        response = self._value.write_bytes(value)
+        data = self._value.read_bytes()
+        write_side_effect(f"write_bytes:{data!r}")
+        return response
+
+
+class DirectoryState(PathState):
+    """Wrapper for directory state."""
+
+    _forbidden_attributes = {
+        "open",
+        "rename",
+        "replace",
+        "remove",
+        "unlink",
+        "write_text",
+        "write_bytes",
+        "read_text",
+        "read_bytes",
+        "mkdir",
+        "touch",
+    }
+    _target: Callable[[Path, DirectoryState, threading.Event], None] = (
+        staticmethod(watch_directory)
+    )
+
+    def walk(self) -> Iterable[tuple[Path, list[str], list[str]]]:
+        """Walk the directory."""
+        items = walk(self._value)
+        as_list = list(_hashable_walk(items))
+        write_side_effect(f"walk:{sorted(as_list)}")
+        return iter(items)
+
+    def iterdir(self) -> Iterable[Path]:
+        """Iterate over the directory."""
+        items = list(self._value.iterdir())
+        write_side_effect(f"iterdir:{items}")
+        return iter(items)
+
+    def glob(self, pattern: str) -> Iterable[Path]:
+        """Glob the directory."""
+        items = list(self._value.glob(pattern))
+        write_side_effect(f"glob:{items}")
+        return iter(items)
+
+    def rglob(self, pattern: str) -> Iterable[Path]:
+        """Recursive glob the directory."""
+        items = list(self._value.rglob(pattern))
+        write_side_effect(f"rglob:{items}")
+        return iter(items)
+
+
+@mddoc
+def file(path: Path | str) -> FileState:
+    """
+    A reactive wrapper for file paths.
+
+    This function takes a file path to watch and returns a wrapper to reactively
+    read and write from the file.
+
+    The "wrapped" file Path object exposes most of the same methods as the
+    [pathlib.Path object](https://docs.python.org/3/library/pathlib.html#pathlib.Path),
+    with a few exceptions. The following methods are not available:
+    - `open()`
+    - `rename()`
+    - `replace()`
+
+    This object will trigger dependent cells to re-evaluate when the file is
+    changed.
+
+    Warning:
+        It is possible to misuse this API in similar ways to `state()`. Consider
+        reading the warning and caveats in the `state()` documentation, and
+        using this function only when reading file paths, and not when writing
+        them.
+
+    Args:
+      path: Path to watch.
+
+    Returns:
+        A reactive wrapper for watching the file path.
+    """
+    if isinstance(path, str):
+        path = Path(path)
+    if path.is_dir():
+        raise ValueError(
+            "Path must be a file, not a directory, usemo.directory()"
+        )
+    return FileState(path, allow_self_loops=True)
+
+
+@mddoc
+def directory(path: Path | str) -> DirectoryState:
+    """
+    A reactive wrapper for directory paths.
+
+    This function takes a directory path to watch and returns a wrapper to
+    reactively list the contents of the directory.
+
+    This object will trigger dependent cells to re-evaluate when the directory
+    structure is changed (i.e., files are added or removed).
+
+    NOTE: This function does NOT react to file content changes, only to changes
+    in the directory structure. Utilize `mo.file()` to watch for changes in
+    specific files. Additional note: this will not followe symlinks.
path: marimo/_runtime/watch/watch.py
user: login: akshayka
body: ```suggestion
    specific files. Additional note: this will not follow symlinks.
```
created_at: 2025-05-12T19:32:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,341 @@
+# Copyright 2024 Marimo. All rights reserved.
+from __future__ import annotations
+
+import os
+import sys
+import threading
+import time
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast
+
+from marimo._output.rich_help import mddoc
+from marimo._runtime.context import (
+    ContextNotInitializedError,
+    get_context,
+    runtime_context_installed,
+)
+from marimo._runtime.side_effect import SideEffect
+from marimo._runtime.state import State
+from marimo._runtime.threads import Thread
+
+if TYPE_CHECKING:
+    from collections.abc import Iterable
+
+if sys.version_info >= (3, 11):
+    from typing import Self
+else:
+    from typing_extensions import Self
+
+
+T = TypeVar("T")
+
+WATCHER_SLEEP_INTERVAL = 1.0
+
+# For testing only - do not use in production
+_TEST_SLEEP_INTERVAL: float | None = None
+
+
+def write_side_effect(data: str | bytes) -> None:
+    """Write side effect to the context."""
+    try:
+        ctx = get_context()
+    except ContextNotInitializedError:
+        # Context is not initialized, nothing we can do
+        return
+    ctx.cell_lifecycle_registry.add(SideEffect(data))
+
+
+def watch_file(
+    path: Path, state: FileState, should_exit: threading.Event
+) -> None:
+    """Watch a file for changes and update the state."""
+    last_mtime: float = 0
+    current_mtime = last_mtime
+    sleep_interval = _TEST_SLEEP_INTERVAL or WATCHER_SLEEP_INTERVAL
+    while not should_exit.is_set():
+        time.sleep(sleep_interval)
+        try:
+            current_mtime = path.stat().st_mtime
+        except FileNotFoundError:
+            # File has been deleted, trigger a change
+            current_mtime = 0
+        except Exception as e:
+            # Handle other exceptions (e.g., permission denied)
+            sys.stderr.write(f"Error watching file {path}: {e}\n")
+            continue
+
+        if current_mtime != last_mtime:
+            last_mtime = current_mtime
+            state._set_value(path)
+
+
+def walk(path: Path) -> Iterable[tuple[Path, list[str], list[str]]]:
+    if sys.version_info > (3, 11):
+        return path.walk()
+    return os.walk(path)
+
+
+def _hashable_walk(
+    walked: Iterable[tuple[Path, list[str], list[str]]],
+) -> set[tuple[Path, tuple[str], tuple[str]]]:
+    return cast(
+        set[tuple[Path, tuple[str], tuple[str]]],
+        set((p, *map(tuple, r)) for p, *r in walked),
+    )
+
+
+def hashable_walk(path: Path) -> set[tuple[Path, tuple[str], tuple[str]]]:
+    return _hashable_walk(walk(path))
+
+
+def watch_directory(
+    path: Path, state: DirectoryState, should_exit: threading.Event
+) -> None:
+    """Watch a directory for changes and update the state."""
+    last_structure = hashable_walk(path)
+    current_structure = last_structure
+    sleep_interval = _TEST_SLEEP_INTERVAL or WATCHER_SLEEP_INTERVAL
+    while not should_exit.is_set():
+        time.sleep(sleep_interval)
+        try:
+            current_structure = hashable_walk(path)
+        except FileNotFoundError:
+            # Directory has been deleted, trigger a change
+            current_structure = set()
+        except Exception as e:
+            # Handle other exceptions (e.g., permission denied)
+            sys.stderr.write(f"Error watching directory {path}: {e}\n")
+            continue
+
+        if current_structure != last_structure:
+            last_structure = current_structure
+            state._set_value(path)
+
+
+class PathState(State[Path]):
+    """Base class for path state."""
+
+    _forbidden_attributes: set[str]
+    _target: Callable[[Path, Self, threading.Event], None]
+
+    def __init__(
+        self,
+        path: Path,
+        *args: Any,
+        allow_self_loops: bool = True,
+        **kwargs: Any,
+    ) -> None:
+        if kwargs.pop("_context", None) is not None:
+            raise ValueError(
+                "The '_context' argument is not supported for this class."
+            )
+        if kwargs.pop("allow_self_loops", None) is not None:
+            raise ValueError(
+                "The 'allow_self_loops' argument is not supported for this class."
+            )
+
+        # Mypy seems to think we could provide multiple kwargs definitions here
+        # but we can't.
+        super().__init__(
+            path,
+            *args,
+            allow_self_loops=allow_self_loops,
+            _context="file",
+            **kwargs,
+        )  # type: ignore[misc]
+        self._should_exit = threading.Event()
+        # Only bother with the watcher if the context is installed
+        # State is not enabled in script mode
+        if runtime_context_installed():
+            Thread(
+                target=self._target,
+                args=(path, self, self._should_exit),
+                daemon=True,
+            ).start()
+
+    def __getattr__(self, name: str) -> Any:
+        """Get an attribute from the file path."""
+        # Disable some attributes
+        if name in self._forbidden_attributes:
+            raise AttributeError(
+                f"'{self.__class__.__name__}' does not "
+                f"expose attribute '{name}'"
+            )
+        if hasattr(self._value, name):
+            return getattr(self._value, name)
+        raise AttributeError(
+            f"'{self.__class__.__name__}' object has no attribute '{name}'"
+        )
+
+    def __del__(self) -> None:
+        self._should_exit.set()
+
+    def __repr__(self) -> str:
+        return f"{self.__class__.__name__}({self._value})"
+
+    def exists(self) -> bool:
+        """Check if the path exists."""
+        exists = self._value.exists()
+        if not exists:
+            write_side_effect(f"doesn't exists:{self._value}")
+        else:
+            _ = self.read_text()
+        return exists
+
+
+class FileState(PathState):
+    """Wrapper for file state."""
+
+    _forbidden_attributes = {
+        "open",
+        "iterdir",
+        "glob",
+        "rglob",
+        "mkdir",
+        "rename",
+        "replace",
+        "walk",
+    }
+    _target: Callable[[Path, FileState, threading.Event], None] = staticmethod(
+        watch_file
+    )
+
+    def read_text(self) -> str:
+        """Read the file as a string."""
+        text = self._value.read_text()
+        write_side_effect(f"read_text:{text}")
+        return text
+
+    def write_text(self, value: str) -> int:
+        """Write the file as a string."""
+        response = self._value.write_text(value)
+        text = self._value.read_text()
+        write_side_effect(f"write_text:{text}")
+        return response
+
+    def read_bytes(self) -> bytes:
+        """Read the file as bytes."""
+        data = self._value.read_bytes()
+        write_side_effect(f"read_bytes:{data!r}")
+        return data
+
+    def write_bytes(self, value: bytes) -> int:
+        """Write the file as bytes."""
+        response = self._value.write_bytes(value)
+        data = self._value.read_bytes()
+        write_side_effect(f"write_bytes:{data!r}")
+        return response
+
+
+class DirectoryState(PathState):
+    """Wrapper for directory state."""
+
+    _forbidden_attributes = {
+        "open",
+        "rename",
+        "replace",
+        "remove",
+        "unlink",
+        "write_text",
+        "write_bytes",
+        "read_text",
+        "read_bytes",
+        "mkdir",
+        "touch",
+    }
+    _target: Callable[[Path, DirectoryState, threading.Event], None] = (
+        staticmethod(watch_directory)
+    )
+
+    def walk(self) -> Iterable[tuple[Path, list[str], list[str]]]:
+        """Walk the directory."""
+        items = walk(self._value)
+        as_list = list(_hashable_walk(items))
+        write_side_effect(f"walk:{sorted(as_list)}")
+        return iter(items)
+
+    def iterdir(self) -> Iterable[Path]:
+        """Iterate over the directory."""
+        items = list(self._value.iterdir())
+        write_side_effect(f"iterdir:{items}")
+        return iter(items)
+
+    def glob(self, pattern: str) -> Iterable[Path]:
+        """Glob the directory."""
+        items = list(self._value.glob(pattern))
+        write_side_effect(f"glob:{items}")
+        return iter(items)
+
+    def rglob(self, pattern: str) -> Iterable[Path]:
+        """Recursive glob the directory."""
+        items = list(self._value.rglob(pattern))
+        write_side_effect(f"rglob:{items}")
+        return iter(items)
+
+
+@mddoc
+def file(path: Path | str) -> FileState:
+    """
+    A reactive wrapper for file paths.
+
+    This function takes a file path to watch and returns a wrapper to reactively
+    read and write from the file.
+
+    The "wrapped" file Path object exposes most of the same methods as the
+    [pathlib.Path object](https://docs.python.org/3/library/pathlib.html#pathlib.Path),
+    with a few exceptions. The following methods are not available:
+    - `open()`
+    - `rename()`
+    - `replace()`
+
+    This object will trigger dependent cells to re-evaluate when the file is
+    changed.
+
+    Warning:
+        It is possible to misuse this API in similar ways to `state()`. Consider
+        reading the warning and caveats in the `state()` documentation, and
+        using this function only when reading file paths, and not when writing
+        them.
+
+    Args:
+      path: Path to watch.
+
+    Returns:
+        A reactive wrapper for watching the file path.
+    """
+    if isinstance(path, str):
+        path = Path(path)
+    if path.is_dir():
+        raise ValueError(
+            "Path must be a file, not a directory, usemo.directory()"
path: marimo/_runtime/watch/watch.py
user: login: akshayka
body: ```suggestion
            "Path must be a file, not a directory, use mo.directory() instead"
```
created_at: 2025-05-12T19:34:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,341 @@
+# Copyright 2024 Marimo. All rights reserved.
+from __future__ import annotations
+
+import os
+import sys
+import threading
+import time
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast
+
+from marimo._output.rich_help import mddoc
+from marimo._runtime.context import (
+    ContextNotInitializedError,
+    get_context,
+    runtime_context_installed,
+)
+from marimo._runtime.side_effect import SideEffect
+from marimo._runtime.state import State
+from marimo._runtime.threads import Thread
+
+if TYPE_CHECKING:
+    from collections.abc import Iterable
+
+if sys.version_info >= (3, 11):
+    from typing import Self
+else:
+    from typing_extensions import Self
+
+
+T = TypeVar("T")
+
+WATCHER_SLEEP_INTERVAL = 1.0
+
+# For testing only - do not use in production
+_TEST_SLEEP_INTERVAL: float | None = None
+
+
+def write_side_effect(data: str | bytes) -> None:
path: marimo/_runtime/watch/watch.py
user: login: akshayka
body: Since you are exporting the entire `watch` module, the private APIs should be underscored
created_at: 2025-05-12T19:34:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,341 @@
+# Copyright 2024 Marimo. All rights reserved.
+from __future__ import annotations
+
+import os
+import sys
+import threading
+import time
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast
+
+from marimo._output.rich_help import mddoc
+from marimo._runtime.context import (
+    ContextNotInitializedError,
+    get_context,
+    runtime_context_installed,
+)
+from marimo._runtime.side_effect import SideEffect
+from marimo._runtime.state import State
+from marimo._runtime.threads import Thread
+
+if TYPE_CHECKING:
+    from collections.abc import Iterable
+
+if sys.version_info >= (3, 11):
+    from typing import Self
+else:
+    from typing_extensions import Self
+
+
+T = TypeVar("T")
+
+WATCHER_SLEEP_INTERVAL = 1.0
+
+# For testing only - do not use in production
+_TEST_SLEEP_INTERVAL: float | None = None
+
+
+def write_side_effect(data: str | bytes) -> None:
path: marimo/_runtime/watch/watch.py
user: login: dmadisetti
body: `__init__` only exposes `file` and `directory` in `__all__`

But I can break up this file into `path`, `file`, `directory` for some code cleanliness
created_at: 2025-05-12T19:51:09Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,341 @@
+# Copyright 2024 Marimo. All rights reserved.
+from __future__ import annotations
+
+import os
+import sys
+import threading
+import time
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast
+
+from marimo._output.rich_help import mddoc
+from marimo._runtime.context import (
+    ContextNotInitializedError,
+    get_context,
+    runtime_context_installed,
+)
+from marimo._runtime.side_effect import SideEffect
+from marimo._runtime.state import State
+from marimo._runtime.threads import Thread
+
+if TYPE_CHECKING:
+    from collections.abc import Iterable
+
+if sys.version_info >= (3, 11):
+    from typing import Self
+else:
+    from typing_extensions import Self
+
+
+T = TypeVar("T")
+
+WATCHER_SLEEP_INTERVAL = 1.0
+
+# For testing only - do not use in production
+_TEST_SLEEP_INTERVAL: float | None = None
+
+
+def write_side_effect(data: str | bytes) -> None:
path: marimo/_runtime/watch/watch.py
user: login: mscolnick
body: the test `tests/test_api.py::test_api` should let you know. 

i don't think `__all__` actually enforces anything
created_at: 2025-05-12T20:04:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,341 @@
+# Copyright 2024 Marimo. All rights reserved.
+from __future__ import annotations
+
+import os
+import sys
+import threading
+import time
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast
+
+from marimo._output.rich_help import mddoc
+from marimo._runtime.context import (
+    ContextNotInitializedError,
+    get_context,
+    runtime_context_installed,
+)
+from marimo._runtime.side_effect import SideEffect
+from marimo._runtime.state import State
+from marimo._runtime.threads import Thread
+
+if TYPE_CHECKING:
+    from collections.abc import Iterable
+
+if sys.version_info >= (3, 11):
+    from typing import Self
+else:
+    from typing_extensions import Self
+
+
+T = TypeVar("T")
+
+WATCHER_SLEEP_INTERVAL = 1.0
+
+# For testing only - do not use in production
+_TEST_SLEEP_INTERVAL: float | None = None
+
+
+def write_side_effect(data: str | bytes) -> None:
path: marimo/_runtime/watch/watch.py
user: login: dmadisetti
body: Nice. Glad we have that test
created_at: 2025-05-12T20:06:29Z
author_association: COLLABORATOR

PR comments:
user: login: vercel[bot]
created_at: 2025-05-12T16:51:40Z
body: [vc]: #wL7sayvN3x81+tY+aBChTSP3lj4Re3CJHJl1/QZfi8U=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJtYXJpbW8tZG9jcyIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJtYXJpbW8tZG9jcy1naXQtZG0tbW8tZmlsZS1tYXJpbW8udmVyY2VsLmFwcCJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vbWFyaW1vL21hcmltby1kb2NzLzJnNXdHYUJzR1FlNnlIeDVNeDM0VThtRE5xUjkiLCJwcmV2aWV3VXJsIjoibWFyaW1vLWRvY3MtZ2l0LWRtLW1vLWZpbGUtbWFyaW1vLnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQiLCJyb290RGlyZWN0b3J5IjoiZG9jcyJ9XX0=
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **marimo-docs** | ✅ Ready ([Inspect](https://vercel.com/marimo/marimo-docs/2g5wGaBsGQe6yHx5Mx34U8mDNqR9)) | [Visit Preview](https://marimo-docs-git-dm-mo-file-marimo.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/marimo-docs-git-dm-mo-file-marimo.vercel.app?via=pr-comment-feedback-link) | May 13, 2025 3:34pm |


author_association: NONE
user: login: dmadisetti
created_at: 2025-05-13T14:45:00Z
body: test_watch works pretty reliably locally, but at least one of the jobs seems to consistently fail- so marked them all as flaky. A little annoying, but I kept running these jobs on and off yesterday
author_association: COLLABORATOR
user: login: dmadisetti
created_at: 2025-05-13T14:46:06Z
body: Relevant docs: https://marimo-docs-63eqzh2u6-marimo.vercel.app/api/watch/

author_association: COLLABORATOR
user: login: github-actions[bot]
created_at: 2025-05-13T17:52:11Z
body: 🚀 Development release published. You may be able to view the changes at https://marimo.app?v=0.13.8-dev13
author_association: NONE