Skip to content

Commit bc37a81

Browse files
fcollonvalSteven Silvester
authored andcommitted
Allow non-empty directory deletion for windows through settings
Fixes #570 More robust unit tests
1 parent 90f619c commit bc37a81

File tree

2 files changed

+77
-10
lines changed

2 files changed

+77
-10
lines changed

jupyter_server/services/contents/filemanager.py

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,16 @@ def _checkpoints_class_default(self):
119119
deleting files really deletes them.""",
120120
)
121121

122+
always_delete_dir = Bool(
123+
False,
124+
config=True,
125+
help="""If True, deleting non-empty directory will always be allowed.
126+
WARNING this may result in files being definitely removed; e.g. on Windows
127+
if the data size is too big for the trash/recycle bin they will be really
128+
deleted. If False (default), non-empty directory will be send to trash only
129+
if safe. And if ``delete_to_trash`` is True, they won't be deleted.""",
130+
)
131+
122132
@default("files_handler_class")
123133
def _files_handler_class_default(self):
124134
return AuthenticatedFileHandler
@@ -331,7 +341,10 @@ def _file_model(self, path, content=True, format=None):
331341
if content:
332342
content, format = self._read_file(os_path, format)
333343
if model["mimetype"] is None:
334-
default_mime = {"text": "text/plain", "base64": "application/octet-stream"}[format]
344+
default_mime = {
345+
"text": "text/plain",
346+
"base64": "application/octet-stream",
347+
}[format]
335348
model["mimetype"] = default_mime
336349

337350
model.update(
@@ -391,7 +404,9 @@ def get(self, path, content=True, type=None, format=None):
391404
if os.path.isdir(os_path):
392405
if type not in (None, "directory"):
393406
raise web.HTTPError(
394-
400, u"%s is a directory, not a %s" % (path, type), reason="bad type"
407+
400,
408+
u"%s is a directory, not a %s" % (path, type),
409+
reason="bad type",
395410
)
396411
model = self._dir_model(path, content=content)
397412
elif type == "notebook" or (type is None and path.endswith(".ipynb")):
@@ -494,7 +509,7 @@ def is_non_empty_dir(os_path):
494509
return False
495510

496511
if self.delete_to_trash:
497-
if sys.platform == "win32" and is_non_empty_dir(os_path):
512+
if not self.always_delete_dir and sys.platform == "win32" and is_non_empty_dir(os_path):
498513
# send2trash can really delete files on Windows, so disallow
499514
# deleting non-empty files. See Github issue 3631.
500515
raise web.HTTPError(400, u"Directory %s not empty" % os_path)
@@ -507,12 +522,13 @@ def is_non_empty_dir(os_path):
507522
return
508523
else:
509524
self.log.warning(
510-
"Skipping trash for %s, on different device " "to home directory", os_path
525+
"Skipping trash for %s, on different device " "to home directory",
526+
os_path,
511527
)
512528

513529
if os.path.isdir(os_path):
514530
# Don't permanently delete non-empty directories.
515-
if is_non_empty_dir(os_path):
531+
if not self.always_delete_dir and is_non_empty_dir(os_path):
516532
raise web.HTTPError(400, u"Directory %s not empty" % os_path)
517533
self.log.debug("Removing directory %s", os_path)
518534
with self.perm_to_403():
@@ -649,7 +665,10 @@ async def _file_model(self, path, content=True, format=None):
649665
if content:
650666
content, format = await self._read_file(os_path, format)
651667
if model["mimetype"] is None:
652-
default_mime = {"text": "text/plain", "base64": "application/octet-stream"}[format]
668+
default_mime = {
669+
"text": "text/plain",
670+
"base64": "application/octet-stream",
671+
}[format]
653672
model["mimetype"] = default_mime
654673

655674
model.update(
@@ -709,7 +728,9 @@ async def get(self, path, content=True, type=None, format=None):
709728
if os.path.isdir(os_path):
710729
if type not in (None, "directory"):
711730
raise web.HTTPError(
712-
400, u"%s is a directory, not a %s" % (path, type), reason="bad type"
731+
400,
732+
u"%s is a directory, not a %s" % (path, type),
733+
reason="bad type",
713734
)
714735
model = await self._dir_model(path, content=content)
715736
elif type == "notebook" or (type is None and path.endswith(".ipynb")):
@@ -813,7 +834,11 @@ async def is_non_empty_dir(os_path):
813834
return False
814835

815836
if self.delete_to_trash:
816-
if sys.platform == "win32" and await is_non_empty_dir(os_path):
837+
if (
838+
not self.always_delete_dir
839+
and sys.platform == "win32"
840+
and await is_non_empty_dir(os_path)
841+
):
817842
# send2trash can really delete files on Windows, so disallow
818843
# deleting non-empty files. See Github issue 3631.
819844
raise web.HTTPError(400, u"Directory %s not empty" % os_path)
@@ -826,12 +851,13 @@ async def is_non_empty_dir(os_path):
826851
return
827852
else:
828853
self.log.warning(
829-
"Skipping trash for %s, on different device " "to home directory", os_path
854+
"Skipping trash for %s, on different device " "to home directory",
855+
os_path,
830856
)
831857

832858
if os.path.isdir(os_path):
833859
# Don't permanently delete non-empty directories.
834-
if await is_non_empty_dir(os_path):
860+
if not self.always_delete_dir and await is_non_empty_dir(os_path):
835861
raise web.HTTPError(400, u"Directory %s not empty" % os_path)
836862
self.log.debug("Removing directory %s", os_path)
837863
with self.perm_to_403():

jupyter_server/tests/services/contents/test_manager.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,47 @@ async def test_delete(jp_contents_manager):
513513
await ensure_async(cm.get(path))
514514

515515

516+
@pytest.mark.parametrize(
517+
"delete_to_trash, always_delete, error",
518+
(
519+
[True, True, False],
520+
# on linux test folder may not be on home folder drive
521+
# => if this is the case, _check_trash will be False
522+
[True, False, None],
523+
[False, True, False],
524+
[False, False, True],
525+
),
526+
)
527+
async def test_delete_non_empty_folder(delete_to_trash, always_delete, error, jp_contents_manager):
528+
cm = jp_contents_manager
529+
cm.delete_to_trash = delete_to_trash
530+
cm.always_delete_dir = always_delete
531+
532+
dir = "to_delete"
533+
534+
await make_populated_dir(cm, dir)
535+
await check_populated_dir_files(cm, dir)
536+
537+
if error is None:
538+
error = False
539+
if sys.platform == "win32":
540+
error = True
541+
elif sys.platform == "linux":
542+
file_dev = os.stat(cm.root_dir).st_dev
543+
home_dev = os.stat(os.path.expanduser("~")).st_dev
544+
error = file_dev != home_dev
545+
546+
if error:
547+
with pytest.raises(
548+
HTTPError,
549+
match=r"HTTP 400: Bad Request \(Directory .*?to_delete not empty\)",
550+
):
551+
await ensure_async(cm.delete_file(dir))
552+
else:
553+
await ensure_async(cm.delete_file(dir))
554+
assert cm.dir_exists(dir) == False
555+
556+
516557
async def test_rename(jp_contents_manager):
517558
cm = jp_contents_manager
518559
# Create a new notebook

0 commit comments

Comments
 (0)