diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index a5f9b99d10..0fd16cb5fd 100644 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -111,6 +111,7 @@ ) from jupyter_server.log import log_request from jupyter_server.services.config import ConfigManager +from jupyter_server.services.contents.fileidmanager import FileIdManager from jupyter_server.services.contents.filemanager import ( AsyncFileContentsManager, FileContentsManager, @@ -1886,9 +1887,9 @@ def init_configurables(self): connection_dir=self.runtime_dir, kernel_spec_manager=self.kernel_spec_manager, ) + self.file_id_manager = FileIdManager(parent=self, log=self.log) self.contents_manager = self.contents_manager_class( - parent=self, - log=self.log, + parent=self, log=self.log, file_id_manager=self.file_id_manager ) self.session_manager = self.session_manager_class( parent=self, @@ -2508,6 +2509,11 @@ async def cleanup_extensions(self): self.log.info(extension_msg % n_extensions) await run_sync_in_loop(self.extension_manager.stop_all_extensions()) + def cleanup_file_id_manager(self): + if not getattr(self, "file_id_manager", None): + return + self.file_id_manager._cleanup() + def running_server_info(self, kernel_count=True): "Return the current working directory and the server url information" info = self.contents_manager.info_string() + "\n" @@ -2780,6 +2786,7 @@ async def _cleanup(self): self.remove_browser_open_files() await self.cleanup_extensions() await self.cleanup_kernels() + self.cleanup_file_id_manager() if getattr(self, "session_manager", None): self.session_manager.close() if getattr(self, "event_bus", None): diff --git a/jupyter_server/services/contents/filemanager.py b/jupyter_server/services/contents/filemanager.py index b04ab4a3dc..f44d3797d0 100644 --- a/jupyter_server/services/contents/filemanager.py +++ b/jupyter_server/services/contents/filemanager.py @@ -395,6 +395,10 @@ def get(self, path, content=True, type=None, format=None): if type == "directory": raise web.HTTPError(400, "%s is not a directory" % path, reason="bad type") model = self._file_model(path, content=content, format=format) + + # append file ID to model + model["id"] = self.file_id_manager.index(path) + return model def _save_directory(self, os_path, model, path=""): diff --git a/jupyter_server/services/contents/handlers.py b/jupyter_server/services/contents/handlers.py index 462cbff35e..8ad02a2b96 100644 --- a/jupyter_server/services/contents/handlers.py +++ b/jupyter_server/services/contents/handlers.py @@ -271,7 +271,7 @@ async def delete(self, path=""): if await ensure_async(cm.is_hidden(path)) and not cm.allow_hidden: raise web.HTTPError(400, f"Cannot delete file or directory {path!r}") - self.log.warning("delete %s", path) + self.log.warning("Deleting file at %s", path) await ensure_async(cm.delete(path)) self.set_status(204) self.finish() diff --git a/jupyter_server/services/contents/manager.py b/jupyter_server/services/contents/manager.py index 7bd6450803..2e4d508d9c 100644 --- a/jupyter_server/services/contents/manager.py +++ b/jupyter_server/services/contents/manager.py @@ -30,6 +30,7 @@ from ...files.handlers import FilesHandler from .checkpoints import AsyncCheckpoints, Checkpoints +from .fileidmanager import FileIdManager copy_pat = re.compile(r"\-Copy\d*\.") @@ -59,6 +60,10 @@ class ContentsManager(LoggingConfigurable): notary = Instance(sign.NotebookNotary) + file_id_manager = Instance( + FileIdManager, args=(), help="File ID manager instance to use. Defaults to `FileIdManager`." + ) + def _notary_default(self): return sign.NotebookNotary(parent=self) @@ -414,12 +419,16 @@ def delete(self, path): path = path.strip("/") if not path: raise HTTPError(400, "Can't delete root") + is_dir = self.dir_exists(path) self.delete_file(path) + self.file_id_manager.delete(path, recursive=is_dir) self.checkpoints.delete_all_checkpoints(path) def rename(self, old_path, new_path): """Rename a file and any checkpoints associated with that file.""" + is_dir = self.dir_exists(old_path) self.rename_file(old_path, new_path) + self.file_id_manager.move(old_path, new_path, recursive=is_dir) self.checkpoints.rename_all_checkpoints(old_path, new_path) def update(self, model, path): @@ -615,7 +624,9 @@ def copy(self, from_path, to_path=None): else: raise HTTPError(404, "No such directory: %s" % to_path) + is_dir = self.dir_exists(from_path) model = self.save(model, to_path) + self.file_id_manager.copy(from_path, to_path, recursive=is_dir) return model def log_info(self): @@ -817,12 +828,16 @@ async def delete(self, path): if not path: raise HTTPError(400, "Can't delete root") + is_dir = await ensure_async(self.dir_exists(path)) await self.delete_file(path) + self.file_id_manager.delete(path, recursive=is_dir) await self.checkpoints.delete_all_checkpoints(path) async def rename(self, old_path, new_path): """Rename a file and any checkpoints associated with that file.""" + is_dir = await ensure_async(self.dir_exists(old_path)) await self.rename_file(old_path, new_path) + self.file_id_manager.move(old_path, new_path, recursive=is_dir) await self.checkpoints.rename_all_checkpoints(old_path, new_path) async def update(self, model, path): @@ -984,7 +999,9 @@ async def copy(self, from_path, to_path=None): else: raise HTTPError(404, "No such directory: %s" % to_path) + is_dir = await ensure_async(self.dir_exists(from_path)) model = await self.save(model, to_path) + self.file_id_manager.copy(from_path, to_path, recursive=is_dir) return model async def trust_notebook(self, path): diff --git a/tests/services/contents/test_manager.py b/tests/services/contents/test_manager.py index d2d06a2513..074cc39ac9 100644 --- a/tests/services/contents/test_manager.py +++ b/tests/services/contents/test_manager.py @@ -2,7 +2,7 @@ import sys import time from itertools import combinations -from typing import Dict, Optional, Tuple +from typing import Any, Dict, Optional, Tuple from unittest.mock import patch import pytest @@ -28,14 +28,25 @@ (AsyncFileContentsManager, False), ] ) -def jp_contents_manager(request, tmp_path): +def jp_contents_manager(request, tmp_path, fid_manager): contents_manager, use_atomic_writing = request.param - return contents_manager(root_dir=str(tmp_path), use_atomic_writing=use_atomic_writing) + return contents_manager( + root_dir=str(tmp_path), use_atomic_writing=use_atomic_writing, file_id_manager=fid_manager + ) @pytest.fixture(params=[FileContentsManager, AsyncFileContentsManager]) -def jp_file_contents_manager_class(request, tmp_path): - return request.param +def jp_file_contents_manager_class(request, tmp_path, fid_manager): + # mypy bugs out with dynamic base class + # https://github.com/python/mypy/issues/5865 + Klass: Any = request.param + + class WrappedKlass(Klass): + file_id_manager = fid_manager + # def __init__(self, *args, **kwargs): + # return Klass(*args, file_id_manager=fid_manager, **kwargs) + + return WrappedKlass # -------------- Functions ----------------------------