Repository: jupyter-server/jupyter_server

Issue:
Allow non-empty directory deletion for windows through settings
Hey would it be ok to allow non-empty directory deletion for windows through settings?

https://github.com/jupyter-server/jupyter_server/blob/f914126d173dde7d2b2f6a6d474648060c823419/jupyter_server/services/contents/filemanager.py#L492

Comments:

Patch:
diff --git a/jupyter_server/services/contents/filemanager.py b/jupyter_server/services/contents/filemanager.py
index 376a8db62..54e2dca38 100644
--- a/jupyter_server/services/contents/filemanager.py
+++ b/jupyter_server/services/contents/filemanager.py
@@ -119,6 +119,16 @@ class FileContentsManager(FileManagerMixin, ContentsManager):
         deleting files really deletes them.""",
     )
 
+    always_delete_dir = Bool(
+        False,
+        config=True,
+        help="""If True, deleting a non-empty directory will always be allowed.
+        WARNING this may result in files being permanently removed; e.g. on Windows,
+        if the data size is too big for the trash/recycle bin the directory will be permanently
+        deleted. If False (default), the non-empty directory will be sent to the trash only
+        if safe. And if ``delete_to_trash`` is True, the directory won't be deleted.""",
+    )
+
     @default("files_handler_class")
     def _files_handler_class_default(self):
         return AuthenticatedFileHandler
@@ -331,7 +341,10 @@ class FileContentsManager(FileManagerMixin, ContentsManager):
         if content:
             content, format = self._read_file(os_path, format)
             if model["mimetype"] is None:
-                default_mime = {"text": "text/plain", "base64": "application/octet-stream"}[format]
+                default_mime = {
+                    "text": "text/plain",
+                    "base64": "application/octet-stream",
+                }[format]
                 model["mimetype"] = default_mime
 
             model.update(
@@ -391,7 +404,9 @@ class FileContentsManager(FileManagerMixin, ContentsManager):
         if os.path.isdir(os_path):
             if type not in (None, "directory"):
                 raise web.HTTPError(
-                    400, u"%s is a directory, not a %s" % (path, type), reason="bad type"
+                    400,
+                    u"%s is a directory, not a %s" % (path, type),
+                    reason="bad type",
                 )
             model = self._dir_model(path, content=content)
         elif type == "notebook" or (type is None and path.endswith(".ipynb")):
@@ -494,7 +509,7 @@ class FileContentsManager(FileManagerMixin, ContentsManager):
             return False
 
         if self.delete_to_trash:
-            if sys.platform == "win32" and is_non_empty_dir(os_path):
+            if not self.always_delete_dir and sys.platform == "win32" and is_non_empty_dir(os_path):
                 # send2trash can really delete files on Windows, so disallow
                 # deleting non-empty files. See Github issue 3631.
                 raise web.HTTPError(400, u"Directory %s not empty" % os_path)
@@ -507,12 +522,13 @@ class FileContentsManager(FileManagerMixin, ContentsManager):
                 return
             else:
                 self.log.warning(
-                    "Skipping trash for %s, on different device " "to home directory", os_path
+                    "Skipping trash for %s, on different device " "to home directory",
+                    os_path,
                 )
 
         if os.path.isdir(os_path):
             # Don't permanently delete non-empty directories.
-            if is_non_empty_dir(os_path):
+            if not self.always_delete_dir and is_non_empty_dir(os_path):
                 raise web.HTTPError(400, u"Directory %s not empty" % os_path)
             self.log.debug("Removing directory %s", os_path)
             with self.perm_to_403():
@@ -649,7 +665,10 @@ class AsyncFileContentsManager(FileContentsManager, AsyncFileManagerMixin, Async
         if content:
             content, format = await self._read_file(os_path, format)
             if model["mimetype"] is None:
-                default_mime = {"text": "text/plain", "base64": "application/octet-stream"}[format]
+                default_mime = {
+                    "text": "text/plain",
+                    "base64": "application/octet-stream",
+                }[format]
                 model["mimetype"] = default_mime
 
             model.update(
@@ -709,7 +728,9 @@ class AsyncFileContentsManager(FileContentsManager, AsyncFileManagerMixin, Async
         if os.path.isdir(os_path):
             if type not in (None, "directory"):
                 raise web.HTTPError(
-                    400, u"%s is a directory, not a %s" % (path, type), reason="bad type"
+                    400,
+                    u"%s is a directory, not a %s" % (path, type),
+                    reason="bad type",
                 )
             model = await self._dir_model(path, content=content)
         elif type == "notebook" or (type is None and path.endswith(".ipynb")):
@@ -813,7 +834,11 @@ class AsyncFileContentsManager(FileContentsManager, AsyncFileManagerMixin, Async
             return False
 
         if self.delete_to_trash:
-            if sys.platform == "win32" and await is_non_empty_dir(os_path):
+            if (
+                not self.always_delete_dir
+                and sys.platform == "win32"
+                and await is_non_empty_dir(os_path)
+            ):
                 # send2trash can really delete files on Windows, so disallow
                 # deleting non-empty files. See Github issue 3631.
                 raise web.HTTPError(400, u"Directory %s not empty" % os_path)
@@ -826,12 +851,13 @@ class AsyncFileContentsManager(FileContentsManager, AsyncFileManagerMixin, Async
                 return
             else:
                 self.log.warning(
-                    "Skipping trash for %s, on different device " "to home directory", os_path
+                    "Skipping trash for %s, on different device " "to home directory",
+                    os_path,
                 )
 
         if os.path.isdir(os_path):
             # Don't permanently delete non-empty directories.
-            if await is_non_empty_dir(os_path):
+            if not self.always_delete_dir and await is_non_empty_dir(os_path):
                 raise web.HTTPError(400, u"Directory %s not empty" % os_path)
             self.log.debug("Removing directory %s", os_path)
             with self.perm_to_403():


Test patch:
diff --git a/jupyter_server/tests/services/contents/test_manager.py b/jupyter_server/tests/services/contents/test_manager.py
index 9063d2249..386d3ed60 100644
--- a/jupyter_server/tests/services/contents/test_manager.py
+++ b/jupyter_server/tests/services/contents/test_manager.py
@@ -513,6 +513,47 @@ async def test_delete(jp_contents_manager):
         await ensure_async(cm.get(path))
 
 
+@pytest.mark.parametrize(
+    "delete_to_trash, always_delete, error",
+    (
+        [True, True, False],
+        # on linux test folder may not be on home folder drive
+        # => if this is the case, _check_trash will be False
+        [True, False, None],
+        [False, True, False],
+        [False, False, True],
+    ),
+)
+async def test_delete_non_empty_folder(delete_to_trash, always_delete, error, jp_contents_manager):
+    cm = jp_contents_manager
+    cm.delete_to_trash = delete_to_trash
+    cm.always_delete_dir = always_delete
+
+    dir = "to_delete"
+
+    await make_populated_dir(cm, dir)
+    await check_populated_dir_files(cm, dir)
+
+    if error is None:
+        error = False
+        if sys.platform == "win32":
+            error = True
+        elif sys.platform == "linux":
+            file_dev = os.stat(cm.root_dir).st_dev
+            home_dev = os.stat(os.path.expanduser("~")).st_dev
+            error = file_dev != home_dev
+
+    if error:
+        with pytest.raises(
+            HTTPError,
+            match=r"HTTP 400: Bad Request \(Directory .*?to_delete not empty\)",
+        ):
+            await ensure_async(cm.delete_file(dir))
+    else:
+        await ensure_async(cm.delete_file(dir))
+        assert cm.dir_exists(dir) == False
+
+
 async def test_rename(jp_contents_manager):
     cm = jp_contents_manager
     # Create a new notebook