diff --git a/docs/source/developers/contents.rst b/docs/source/developers/contents.rst index 6910535f30..fcee40ead4 100644 --- a/docs/source/developers/contents.rst +++ b/docs/source/developers/contents.rst @@ -59,6 +59,14 @@ Models may contain the following entries: | | ``None`` | if any. (:ref:`See | | | | Below`) | +--------------------+------------+-------------------------------+ +| **item_count** | int or | The number of items in a | +| | ``None`` | directory, or ``None`` for | +| | | files and notebooks. This | +| | | field is None by default | +| | | unless | +| | | ``count_directory_items`` is | +| | | set to True. | ++--------------------+------------+-------------------------------+ | **format** | unicode or | The format of ``content``, | | | ``None`` | if any. (:ref:`See | | | | Below`) | @@ -110,6 +118,9 @@ model. There are three model types: **notebook**, **file**, and **directory**. - The ``content`` field contains a list of :ref:`content-free` models representing the entities in the directory. - The ``hash`` field is always ``None``. + - The ``item_count`` field contains the number of items in the directory + which is False by default unless ``count_directory_items`` is set to + True. .. note:: diff --git a/jupyter_server/services/contents/filemanager.py b/jupyter_server/services/contents/filemanager.py index 96029d96d6..7fed4ea16c 100644 --- a/jupyter_server/services/contents/filemanager.py +++ b/jupyter_server/services/contents/filemanager.py @@ -119,6 +119,12 @@ def _checkpoints_class_default(self): if safe. And if ``delete_to_trash`` is True, the directory won't be deleted.""", ) + count_directory_items = Bool( + False, + config=True, + help="Whether to count items in directories. Disable for better performance with large/remote directories.", + ).tag(config=True) + @default("files_handler_class") def _files_handler_class_default(self): return AuthenticatedFileHandler @@ -272,6 +278,7 @@ def _base_model(self, path): model["writable"] = self.is_writable(path) model["hash"] = None model["hash_algorithm"] = None + model["item_count"] = None return model @@ -293,9 +300,25 @@ def _dir_model(self, path, content=True): model = self._base_model(path) model["type"] = "directory" model["size"] = None + os_dir = self._get_os_path(path) + dir_contents = os.listdir(os_dir) + + if self.count_directory_items: + filtered_count = 0 + for name in dir_contents: + try: + os_path = os.path.join(os_dir, name) + if self.should_list(name) and ( + self.allow_hidden or not is_file_hidden(os_path) + ): + filtered_count += 1 + except OSError as e: + self.log.warning("Error accessing %s: %s", os.path.join(os_dir, name), e) + + model["item_count"] = filtered_count + if content: model["content"] = contents = [] - os_dir = self._get_os_path(path) for name in os.listdir(os_dir): try: os_path = os.path.join(os_dir, name) @@ -334,7 +357,6 @@ def _dir_model(self, path, content=True): os_path, exc_info=True, ) - model["format"] = "json" return model @@ -470,6 +492,7 @@ def _save_directory(self, os_path, model, path=""): if not os.path.exists(os_path): with self.perm_to_403(): os.mkdir(os_path) + model["item_count"] = 0 elif not os.path.isdir(os_path): raise web.HTTPError(400, "Not a directory: %s" % (os_path)) else: @@ -765,10 +788,25 @@ async def _dir_model(self, path, content=True): model = self._base_model(path) model["type"] = "directory" model["size"] = None + os_dir = self._get_os_path(path) + dir_contents = await run_sync(os.listdir, os_dir) + + if self.count_directory_items: + filtered_count = 0 + for name in dir_contents: + try: + os_path = os.path.join(os_dir, name) + if self.should_list(name) and ( + self.allow_hidden or not is_file_hidden(os_path) + ): + filtered_count += 1 + except OSError as e: + self.log.warning("Error accessing %s: %s", os.path.join(os_dir, name), e) + + model["item_count"] = filtered_count + if content: model["content"] = contents = [] - os_dir = self._get_os_path(path) - dir_contents = await run_sync(os.listdir, os_dir) for name in dir_contents: try: os_path = os.path.join(os_dir, name) diff --git a/tests/services/contents/test_manager.py b/tests/services/contents/test_manager.py index 5c3e2eca50..23daf2ab20 100644 --- a/tests/services/contents/test_manager.py +++ b/tests/services/contents/test_manager.py @@ -549,6 +549,7 @@ async def test_modified_date(jp_contents_manager): async def test_get(jp_contents_manager): cm = jp_contents_manager + cm.count_directory_items = True # Create a notebook model = await ensure_async(cm.new_untitled(type="notebook")) name = model["name"] @@ -590,6 +591,7 @@ async def test_get(jp_contents_manager): assert isinstance(model2, dict) assert "name" in model2 assert "path" in model2 + assert "item_count" in model2 assert "content" in model2 assert model2["name"] == "Untitled.ipynb" assert model2["path"] == "{}/{}".format(sub_dir.strip("/"), name) @@ -631,6 +633,7 @@ async def test_get(jp_contents_manager): for key, value in expected_model.items(): assert file_model[key] == value assert "created" in file_model + assert "item_count" in file_model assert "last_modified" in file_model assert file_model["hash"] @@ -639,8 +642,10 @@ async def test_get(jp_contents_manager): _make_dir(cm, "foo/bar") dirmodel = await ensure_async(cm.get("foo")) assert dirmodel["type"] == "directory" + assert "item_count" in dirmodel assert isinstance(dirmodel["content"], list) assert len(dirmodel["content"]) == 3 + assert dirmodel["item_count"] == 3 assert dirmodel["path"] == "foo" assert dirmodel["name"] == "foo" @@ -649,6 +654,7 @@ async def test_get(jp_contents_manager): model2_no_content = await ensure_async(cm.get(sub_dir + name, content=False)) file_model_no_content = await ensure_async(cm.get("foo/untitled.txt", content=False)) sub_sub_dir_no_content = await ensure_async(cm.get("foo/bar", content=False)) + assert "item_count" in model2_no_content assert sub_sub_dir_no_content["path"] == "foo/bar" assert sub_sub_dir_no_content["name"] == "bar"