diff --git a/jupyter_server/services/contents/filemanager.py b/jupyter_server/services/contents/filemanager.py index 88aa0e3620..bf7ae97e53 100644 --- a/jupyter_server/services/contents/filemanager.py +++ b/jupyter_server/services/contents/filemanager.py @@ -10,6 +10,7 @@ from datetime import datetime import nbformat +import watchfiles from anyio.to_thread import run_sync from jupyter_core.paths import exists, is_file_hidden, is_hidden from send2trash import send2trash @@ -22,7 +23,7 @@ from .filecheckpoints import AsyncFileCheckpoints, FileCheckpoints from .fileio import AsyncFileManagerMixin, FileManagerMixin -from .manager import AsyncContentsManager, ContentsManager +from .manager import AsyncContentsManager, BaseWatchfiles, ContentsManager try: from os.path import samefile @@ -33,8 +34,16 @@ _script_exporter = None +class Watchfiles(BaseWatchfiles): + watch = watchfiles.watch + awatch = watchfiles.awatch + Change = watchfiles.Change + + class FileContentsManager(FileManagerMixin, ContentsManager): + watchfiles = Watchfiles + root_dir = Unicode(config=True) @default("root_dir") @@ -546,6 +555,9 @@ def get_kernel_path(self, path, model=None): class AsyncFileContentsManager(FileContentsManager, AsyncFileManagerMixin, AsyncContentsManager): + + watchfiles = Watchfiles + @default("checkpoints_class") def _checkpoints_class_default(self): return AsyncFileCheckpoints diff --git a/jupyter_server/services/contents/manager.py b/jupyter_server/services/contents/manager.py index 7bd6450803..1f902d02c8 100644 --- a/jupyter_server/services/contents/manager.py +++ b/jupyter_server/services/contents/manager.py @@ -5,6 +5,7 @@ import json import re import warnings +from enum import IntEnum from fnmatch import fnmatch from nbformat import ValidationError, sign @@ -34,6 +35,21 @@ copy_pat = re.compile(r"\-Copy\d*\.") +class BaseWatchfiles: + """File system change notifyer API + + Override these attributes in subclasses if the file system supports file change notifications. + """ + + def watch(*paths, **kwargs): + raise NotImplementedError + + async def awatch(*paths, **kwargs): + raise NotImplementedError + + Change = IntEnum + + class ContentsManager(LoggingConfigurable): """Base class for serving files and directories. @@ -409,6 +425,8 @@ def rename_file(self, old_path, new_path): # ContentsManager API part 2: methods that have useable default # implementations, but can be overridden in subclasses. + watchfiles = BaseWatchfiles + def delete(self, path): """Delete a file/directory and any associated checkpoints.""" path = path.strip("/") diff --git a/setup.cfg b/setup.cfg index 886a9a1cf5..9c0d8a8289 100644 --- a/setup.cfg +++ b/setup.cfg @@ -53,6 +53,7 @@ install_requires = terminado>=0.8.3 tornado>=6.1.0 traitlets>=5.1 + watchfiles==0.13 websocket-client [options.extras_require] diff --git a/tests/services/contents/test_largefilemanager.py b/tests/services/contents/test_largefilemanager.py index 82c5e54e78..61157fa8a5 100644 --- a/tests/services/contents/test_largefilemanager.py +++ b/tests/services/contents/test_largefilemanager.py @@ -1,3 +1,5 @@ +import asyncio + import pytest import tornado @@ -110,3 +112,29 @@ async def test_save_in_subdirectory(jp_large_contents_manager, tmp_path): assert "path" in model assert model["name"] == "Untitled.ipynb" assert model["path"] == "foo/Untitled.ipynb" + + +async def test_watch_directory(tmp_path): + cm = AsyncLargeFileManager(root_dir=str(tmp_path)) + file_path = tmp_path / "file.txt" + stop_event = asyncio.Event() + + async def change_dir(): + # let the watcher start + await asyncio.sleep(0.1) + # add file to directory + file_path.write_text("foo") + + async def timeout(): + await asyncio.sleep(10) + stop_event.set() + + asyncio.create_task(change_dir()) + asyncio.create_task(timeout()) + test_ok = False + async for change in cm.watchfiles.awatch(tmp_path, stop_event=stop_event): + if (cm.watchfiles.Change.added, str(file_path)) in change: + test_ok = True + break + + assert test_ok