GH-73991: Rework pathlib.Path.rmtree() into delete() (#122368)

Rename `pathlib.Path.rmtree()` to `delete()`, and add support for deleting
non-directories. This simplifies the interface for users, and nicely
complements the upcoming `move()` and `copy()` methods (which will also
accept any type of file.)
This commit is contained in:
Barney Gale 2024-08-07 01:34:44 +01:00 committed by GitHub
parent b5e142ba7c
commit 98dba73010
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 139 additions and 149 deletions

View file

@ -1636,7 +1636,7 @@ Copying, renaming and deleting
.. method:: Path.unlink(missing_ok=False) .. method:: Path.unlink(missing_ok=False)
Remove this file or symbolic link. If the path points to a directory, Remove this file or symbolic link. If the path points to a directory,
use :func:`Path.rmdir` instead. use :func:`Path.rmdir` or :func:`Path.delete` instead.
If *missing_ok* is false (the default), :exc:`FileNotFoundError` is If *missing_ok* is false (the default), :exc:`FileNotFoundError` is
raised if the path does not exist. raised if the path does not exist.
@ -1650,33 +1650,40 @@ Copying, renaming and deleting
.. method:: Path.rmdir() .. method:: Path.rmdir()
Remove this directory. The directory must be empty. Remove this directory. The directory must be empty; use
:meth:`Path.delete` to remove a non-empty directory.
.. method:: Path.rmtree(ignore_errors=False, on_error=None) .. method:: Path.delete(ignore_errors=False, on_error=None)
Recursively delete this entire directory tree. The path must not refer to a symlink. Delete this file or directory. If this path refers to a non-empty
directory, its files and sub-directories are deleted recursively.
If *ignore_errors* is true, errors resulting from failed removals will be If *ignore_errors* is true, errors resulting from failed deletions will be
ignored. If *ignore_errors* is false or omitted, and a function is given to ignored. If *ignore_errors* is false or omitted, and a callable is given as
*on_error*, it will be called each time an exception is raised. If neither the optional *on_error* argument, it will be called with one argument of
*ignore_errors* nor *on_error* are supplied, exceptions are propagated to type :exc:`OSError` each time an exception is raised. The callable can
the caller. handle the error to continue the deletion process or re-raise it to stop.
Note that the filename is available as the :attr:`~OSError.filename`
attribute of the exception object. If neither *ignore_errors* nor
*on_error* are supplied, exceptions are propagated to the caller.
.. note:: .. note::
On platforms that support the necessary fd-based functions, a symlink When deleting non-empty directories on platforms that lack the necessary
attack-resistant version of :meth:`~Path.rmtree` is used by default. On file descriptor-based functions, the :meth:`~Path.delete` implementation
other platforms, the :func:`~Path.rmtree` implementation is susceptible is susceptible to a symlink attack: given proper timing and
to a symlink attack: given proper timing and circumstances, attackers circumstances, attackers can manipulate symlinks on the filesystem to
can manipulate symlinks on the filesystem to delete files they would not delete files they would not be able to access otherwise. Applications
be able to access otherwise. can use the :data:`~Path.delete.avoids_symlink_attacks` method attribute
to determine whether the implementation is immune to this attack.
If the optional argument *on_error* is specified, it should be a callable; .. attribute:: delete.avoids_symlink_attacks
it will be called with one argument of type :exc:`OSError`. The
callable can handle the error to continue the deletion process or re-raise Indicates whether the current platform and implementation provides a
it to stop. Note that the filename is available as the :attr:`~OSError.filename` symlink attack resistant version of :meth:`~Path.delete`. Currently
attribute of the exception object. this is only true for platforms supporting fd-based directory access
functions.
.. versionadded:: 3.14 .. versionadded:: 3.14

View file

@ -141,8 +141,7 @@ pathlib
:func:`shutil.copyfile`. :func:`shutil.copyfile`.
* :meth:`~pathlib.Path.copytree` copies one directory tree to another, like * :meth:`~pathlib.Path.copytree` copies one directory tree to another, like
:func:`shutil.copytree`. :func:`shutil.copytree`.
* :meth:`~pathlib.Path.rmtree` recursively removes a directory tree, like * :meth:`~pathlib.Path.delete` removes a file or directory tree.
:func:`shutil.rmtree`.
(Contributed by Barney Gale in :gh:`73991`.) (Contributed by Barney Gale in :gh:`73991`.)

View file

@ -919,15 +919,15 @@ class PathBase(PurePathBase):
""" """
raise UnsupportedOperation(self._unsupported_msg('rmdir()')) raise UnsupportedOperation(self._unsupported_msg('rmdir()'))
def rmtree(self, ignore_errors=False, on_error=None): def delete(self, ignore_errors=False, on_error=None):
""" """
Recursively delete this directory tree. Delete this file or directory (including all sub-directories).
If *ignore_errors* is true, exceptions raised from scanning the tree If *ignore_errors* is true, exceptions raised from scanning the
and removing files and directories are ignored. Otherwise, if filesystem and removing files and directories are ignored. Otherwise,
*on_error* is set, it will be called to handle the error. If neither if *on_error* is set, it will be called to handle the error. If
*ignore_errors* nor *on_error* are set, exceptions are propagated to neither *ignore_errors* nor *on_error* are set, exceptions are
the caller. propagated to the caller.
""" """
if ignore_errors: if ignore_errors:
def on_error(err): def on_error(err):
@ -935,14 +935,10 @@ class PathBase(PurePathBase):
elif on_error is None: elif on_error is None:
def on_error(err): def on_error(err):
raise err raise err
try: if self.is_dir(follow_symlinks=False):
if self.is_symlink():
raise OSError("Cannot call rmtree on a symbolic link")
elif self.is_junction():
raise OSError("Cannot call rmtree on a junction")
results = self.walk( results = self.walk(
on_error=on_error, on_error=on_error,
top_down=False, # Bottom-up so we rmdir() empty directories. top_down=False, # So we rmdir() empty directories.
follow_symlinks=False) follow_symlinks=False)
for dirpath, dirnames, filenames in results: for dirpath, dirnames, filenames in results:
for name in filenames: for name in filenames:
@ -955,10 +951,15 @@ class PathBase(PurePathBase):
dirpath.joinpath(name).rmdir() dirpath.joinpath(name).rmdir()
except OSError as err: except OSError as err:
on_error(err) on_error(err)
self.rmdir() delete_self = self.rmdir
else:
delete_self = self.unlink
try:
delete_self()
except OSError as err: except OSError as err:
err.filename = str(self) err.filename = str(self)
on_error(err) on_error(err)
delete.avoids_symlink_attacks = False
def owner(self, *, follow_symlinks=True): def owner(self, *, follow_symlinks=True):
""" """

View file

@ -3,6 +3,7 @@ import ntpath
import operator import operator
import os import os
import posixpath import posixpath
import shutil
import sys import sys
from glob import _StringGlobber from glob import _StringGlobber
from itertools import chain from itertools import chain
@ -830,24 +831,34 @@ class Path(PathBase, PurePath):
""" """
os.rmdir(self) os.rmdir(self)
def rmtree(self, ignore_errors=False, on_error=None): def delete(self, ignore_errors=False, on_error=None):
""" """
Recursively delete this directory tree. Delete this file or directory (including all sub-directories).
If *ignore_errors* is true, exceptions raised from scanning the tree If *ignore_errors* is true, exceptions raised from scanning the
and removing files and directories are ignored. Otherwise, if filesystem and removing files and directories are ignored. Otherwise,
*on_error* is set, it will be called to handle the error. If neither if *on_error* is set, it will be called to handle the error. If
*ignore_errors* nor *on_error* are set, exceptions are propagated to neither *ignore_errors* nor *on_error* are set, exceptions are
the caller. propagated to the caller.
""" """
if on_error: if self.is_dir(follow_symlinks=False):
def onexc(func, filename, err):
err.filename = filename
on_error(err)
else:
onexc = None onexc = None
import shutil if on_error:
shutil.rmtree(str(self), ignore_errors, onexc=onexc) def onexc(func, filename, err):
err.filename = filename
on_error(err)
shutil.rmtree(str(self), ignore_errors, onexc=onexc)
else:
try:
self.unlink()
except OSError as err:
if not ignore_errors:
if on_error:
on_error(err)
else:
raise
delete.avoids_symlink_attacks = shutil.rmtree.avoids_symlink_attacks
def rename(self, target): def rename(self, target):
""" """

View file

@ -32,7 +32,7 @@ root_in_posix = False
if hasattr(os, 'geteuid'): if hasattr(os, 'geteuid'):
root_in_posix = (os.geteuid() == 0) root_in_posix = (os.geteuid() == 0)
rmtree_use_fd_functions = ( delete_use_fd_functions = (
{os.open, os.stat, os.unlink, os.rmdir} <= os.supports_dir_fd and {os.open, os.stat, os.unlink, os.rmdir} <= os.supports_dir_fd and
os.listdir in os.supports_fd and os.stat in os.supports_follow_symlinks) os.listdir in os.supports_fd and os.stat in os.supports_follow_symlinks)
@ -862,8 +862,9 @@ class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest):
self.assertEqual(expected_gid, gid_2) self.assertEqual(expected_gid, gid_2)
self.assertEqual(expected_name, link.group(follow_symlinks=False)) self.assertEqual(expected_name, link.group(follow_symlinks=False))
def test_rmtree_uses_safe_fd_version_if_available(self): def test_delete_uses_safe_fd_version_if_available(self):
if rmtree_use_fd_functions: if delete_use_fd_functions:
self.assertTrue(self.cls.delete.avoids_symlink_attacks)
d = self.cls(self.base, 'a') d = self.cls(self.base, 'a')
d.mkdir() d.mkdir()
try: try:
@ -876,16 +877,18 @@ class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest):
raise Called raise Called
os.open = _raiser os.open = _raiser
self.assertRaises(Called, d.rmtree) self.assertRaises(Called, d.delete)
finally: finally:
os.open = real_open os.open = real_open
else:
self.assertFalse(self.cls.delete.avoids_symlink_attacks)
@unittest.skipIf(sys.platform[:6] == 'cygwin', @unittest.skipIf(sys.platform[:6] == 'cygwin',
"This test can't be run on Cygwin (issue #1071513).") "This test can't be run on Cygwin (issue #1071513).")
@os_helper.skip_if_dac_override @os_helper.skip_if_dac_override
@os_helper.skip_unless_working_chmod @os_helper.skip_unless_working_chmod
def test_rmtree_unwritable(self): def test_delete_unwritable(self):
tmp = self.cls(self.base, 'rmtree') tmp = self.cls(self.base, 'delete')
tmp.mkdir() tmp.mkdir()
child_file_path = tmp / 'a' child_file_path = tmp / 'a'
child_dir_path = tmp / 'b' child_dir_path = tmp / 'b'
@ -902,7 +905,7 @@ class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest):
tmp.chmod(new_mode) tmp.chmod(new_mode)
errors = [] errors = []
tmp.rmtree(on_error=errors.append) tmp.delete(on_error=errors.append)
# Test whether onerror has actually been called. # Test whether onerror has actually been called.
self.assertEqual(len(errors), 3) self.assertEqual(len(errors), 3)
finally: finally:
@ -911,9 +914,9 @@ class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest):
child_dir_path.chmod(old_child_dir_mode) child_dir_path.chmod(old_child_dir_mode)
@needs_windows @needs_windows
def test_rmtree_inner_junction(self): def test_delete_inner_junction(self):
import _winapi import _winapi
tmp = self.cls(self.base, 'rmtree') tmp = self.cls(self.base, 'delete')
tmp.mkdir() tmp.mkdir()
dir1 = tmp / 'dir1' dir1 = tmp / 'dir1'
dir2 = dir1 / 'dir2' dir2 = dir1 / 'dir2'
@ -929,15 +932,15 @@ class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest):
link3 = dir1 / 'link3' link3 = dir1 / 'link3'
_winapi.CreateJunction(str(file1), str(link3)) _winapi.CreateJunction(str(file1), str(link3))
# make sure junctions are removed but not followed # make sure junctions are removed but not followed
dir1.rmtree() dir1.delete()
self.assertFalse(dir1.exists()) self.assertFalse(dir1.exists())
self.assertTrue(dir3.exists()) self.assertTrue(dir3.exists())
self.assertTrue(file1.exists()) self.assertTrue(file1.exists())
@needs_windows @needs_windows
def test_rmtree_outer_junction(self): def test_delete_outer_junction(self):
import _winapi import _winapi
tmp = self.cls(self.base, 'rmtree') tmp = self.cls(self.base, 'delete')
tmp.mkdir() tmp.mkdir()
try: try:
src = tmp / 'cheese' src = tmp / 'cheese'
@ -946,22 +949,22 @@ class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest):
spam = src / 'spam' spam = src / 'spam'
spam.write_text('') spam.write_text('')
_winapi.CreateJunction(str(src), str(dst)) _winapi.CreateJunction(str(src), str(dst))
self.assertRaises(OSError, dst.rmtree) self.assertRaises(OSError, dst.delete)
dst.rmtree(ignore_errors=True) dst.delete(ignore_errors=True)
finally: finally:
tmp.rmtree(ignore_errors=True) tmp.delete(ignore_errors=True)
@needs_windows @needs_windows
def test_rmtree_outer_junction_on_error(self): def test_delete_outer_junction_on_error(self):
import _winapi import _winapi
tmp = self.cls(self.base, 'rmtree') tmp = self.cls(self.base, 'delete')
tmp.mkdir() tmp.mkdir()
dir_ = tmp / 'dir' dir_ = tmp / 'dir'
dir_.mkdir() dir_.mkdir()
link = tmp / 'link' link = tmp / 'link'
_winapi.CreateJunction(str(dir_), str(link)) _winapi.CreateJunction(str(dir_), str(link))
try: try:
self.assertRaises(OSError, link.rmtree) self.assertRaises(OSError, link.delete)
self.assertTrue(dir_.exists()) self.assertTrue(dir_.exists())
self.assertTrue(link.exists(follow_symlinks=False)) self.assertTrue(link.exists(follow_symlinks=False))
errors = [] errors = []
@ -969,18 +972,18 @@ class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest):
def on_error(error): def on_error(error):
errors.append(error) errors.append(error)
link.rmtree(on_error=on_error) link.delete(on_error=on_error)
self.assertEqual(len(errors), 1) self.assertEqual(len(errors), 1)
self.assertIsInstance(errors[0], OSError) self.assertIsInstance(errors[0], OSError)
self.assertEqual(errors[0].filename, str(link)) self.assertEqual(errors[0].filename, str(link))
finally: finally:
os.unlink(str(link)) os.unlink(str(link))
@unittest.skipUnless(rmtree_use_fd_functions, "requires safe rmtree") @unittest.skipUnless(delete_use_fd_functions, "requires safe delete")
def test_rmtree_fails_on_close(self): def test_delete_fails_on_close(self):
# Test that the error handler is called for failed os.close() and that # Test that the error handler is called for failed os.close() and that
# os.close() is only called once for a file descriptor. # os.close() is only called once for a file descriptor.
tmp = self.cls(self.base, 'rmtree') tmp = self.cls(self.base, 'delete')
tmp.mkdir() tmp.mkdir()
dir1 = tmp / 'dir1' dir1 = tmp / 'dir1'
dir1.mkdir() dir1.mkdir()
@ -996,7 +999,7 @@ class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest):
close_count = 0 close_count = 0
with swap_attr(os, 'close', close) as orig_close: with swap_attr(os, 'close', close) as orig_close:
with self.assertRaises(OSError): with self.assertRaises(OSError):
dir1.rmtree() dir1.delete()
self.assertTrue(dir2.is_dir()) self.assertTrue(dir2.is_dir())
self.assertEqual(close_count, 2) self.assertEqual(close_count, 2)
@ -1004,7 +1007,7 @@ class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest):
errors = [] errors = []
with swap_attr(os, 'close', close) as orig_close: with swap_attr(os, 'close', close) as orig_close:
dir1.rmtree(on_error=errors.append) dir1.delete(on_error=errors.append)
self.assertEqual(len(errors), 2) self.assertEqual(len(errors), 2)
self.assertEqual(errors[0].filename, str(dir2)) self.assertEqual(errors[0].filename, str(dir2))
self.assertEqual(errors[1].filename, str(dir1)) self.assertEqual(errors[1].filename, str(dir1))
@ -1013,27 +1016,23 @@ class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest):
@unittest.skipUnless(hasattr(os, "mkfifo"), 'requires os.mkfifo()') @unittest.skipUnless(hasattr(os, "mkfifo"), 'requires os.mkfifo()')
@unittest.skipIf(sys.platform == "vxworks", @unittest.skipIf(sys.platform == "vxworks",
"fifo requires special path on VxWorks") "fifo requires special path on VxWorks")
def test_rmtree_on_named_pipe(self): def test_delete_on_named_pipe(self):
p = self.cls(self.base, 'pipe') p = self.cls(self.base, 'pipe')
os.mkfifo(p) os.mkfifo(p)
try: p.delete()
with self.assertRaises(NotADirectoryError): self.assertFalse(p.exists())
p.rmtree()
self.assertTrue(p.exists())
finally:
p.unlink()
p = self.cls(self.base, 'dir') p = self.cls(self.base, 'dir')
p.mkdir() p.mkdir()
os.mkfifo(p / 'mypipe') os.mkfifo(p / 'mypipe')
p.rmtree() p.delete()
self.assertFalse(p.exists()) self.assertFalse(p.exists())
@unittest.skipIf(sys.platform[:6] == 'cygwin', @unittest.skipIf(sys.platform[:6] == 'cygwin',
"This test can't be run on Cygwin (issue #1071513).") "This test can't be run on Cygwin (issue #1071513).")
@os_helper.skip_if_dac_override @os_helper.skip_if_dac_override
@os_helper.skip_unless_working_chmod @os_helper.skip_unless_working_chmod
def test_rmtree_deleted_race_condition(self): def test_delete_deleted_race_condition(self):
# bpo-37260 # bpo-37260
# #
# Test that a file or a directory deleted after it is enumerated # Test that a file or a directory deleted after it is enumerated
@ -1057,7 +1056,7 @@ class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest):
if p != keep: if p != keep:
p.unlink() p.unlink()
tmp = self.cls(self.base, 'rmtree') tmp = self.cls(self.base, 'delete')
tmp.mkdir() tmp.mkdir()
paths = [tmp] + [tmp / f'child{i}' for i in range(6)] paths = [tmp] + [tmp / f'child{i}' for i in range(6)]
dirs = paths[1::2] dirs = paths[1::2]
@ -1075,7 +1074,7 @@ class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest):
path.chmod(new_mode) path.chmod(new_mode)
try: try:
tmp.rmtree(on_error=on_error) tmp.delete(on_error=on_error)
except: except:
# Test failed, so cleanup artifacts. # Test failed, so cleanup artifacts.
for path, mode in zip(paths, old_modes): for path, mode in zip(paths, old_modes):
@ -1083,13 +1082,13 @@ class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest):
path.chmod(mode) path.chmod(mode)
except OSError: except OSError:
pass pass
tmp.rmtree() tmp.delete()
raise raise
def test_rmtree_does_not_choke_on_failing_lstat(self): def test_delete_does_not_choke_on_failing_lstat(self):
try: try:
orig_lstat = os.lstat orig_lstat = os.lstat
tmp = self.cls(self.base, 'rmtree') tmp = self.cls(self.base, 'delete')
def raiser(fn, *args, **kwargs): def raiser(fn, *args, **kwargs):
if fn != str(tmp): if fn != str(tmp):
@ -1102,7 +1101,7 @@ class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest):
tmp.mkdir() tmp.mkdir()
foo = tmp / 'foo' foo = tmp / 'foo'
foo.write_text('') foo.write_text('')
tmp.rmtree() tmp.delete()
finally: finally:
os.lstat = orig_lstat os.lstat = orig_lstat

View file

@ -2641,85 +2641,43 @@ class DummyPathTest(DummyPurePathTest):
self.assertFileNotFound(p.stat) self.assertFileNotFound(p.stat)
self.assertFileNotFound(p.unlink) self.assertFileNotFound(p.unlink)
def test_rmtree(self): def test_delete_file(self):
p = self.cls(self.base) / 'fileA'
p.delete()
self.assertFileNotFound(p.stat)
self.assertFileNotFound(p.unlink)
def test_delete_dir(self):
base = self.cls(self.base) base = self.cls(self.base)
base.joinpath('dirA').rmtree() base.joinpath('dirA').delete()
self.assertRaises(FileNotFoundError, base.joinpath('dirA').stat) self.assertRaises(FileNotFoundError, base.joinpath('dirA').stat)
self.assertRaises(FileNotFoundError, base.joinpath('dirA', 'linkC').lstat) self.assertRaises(FileNotFoundError, base.joinpath('dirA', 'linkC').lstat)
base.joinpath('dirB').rmtree() base.joinpath('dirB').delete()
self.assertRaises(FileNotFoundError, base.joinpath('dirB').stat) self.assertRaises(FileNotFoundError, base.joinpath('dirB').stat)
self.assertRaises(FileNotFoundError, base.joinpath('dirB', 'fileB').stat) self.assertRaises(FileNotFoundError, base.joinpath('dirB', 'fileB').stat)
self.assertRaises(FileNotFoundError, base.joinpath('dirB', 'linkD').lstat) self.assertRaises(FileNotFoundError, base.joinpath('dirB', 'linkD').lstat)
base.joinpath('dirC').rmtree() base.joinpath('dirC').delete()
self.assertRaises(FileNotFoundError, base.joinpath('dirC').stat) self.assertRaises(FileNotFoundError, base.joinpath('dirC').stat)
self.assertRaises(FileNotFoundError, base.joinpath('dirC', 'dirD').stat) self.assertRaises(FileNotFoundError, base.joinpath('dirC', 'dirD').stat)
self.assertRaises(FileNotFoundError, base.joinpath('dirC', 'dirD', 'fileD').stat) self.assertRaises(FileNotFoundError, base.joinpath('dirC', 'dirD', 'fileD').stat)
self.assertRaises(FileNotFoundError, base.joinpath('dirC', 'fileC').stat) self.assertRaises(FileNotFoundError, base.joinpath('dirC', 'fileC').stat)
self.assertRaises(FileNotFoundError, base.joinpath('dirC', 'novel.txt').stat) self.assertRaises(FileNotFoundError, base.joinpath('dirC', 'novel.txt').stat)
def test_rmtree_errors(self):
tmp = self.cls(self.base, 'rmtree')
tmp.mkdir()
# filename is guaranteed not to exist
filename = tmp / 'foo'
self.assertRaises(FileNotFoundError, filename.rmtree)
# test that ignore_errors option is honored
filename.rmtree(ignore_errors=True)
# existing file
filename = tmp / "tstfile"
filename.write_text("")
with self.assertRaises(NotADirectoryError) as cm:
filename.rmtree()
self.assertEqual(cm.exception.filename, str(filename))
self.assertTrue(filename.exists())
# test that ignore_errors option is honored
filename.rmtree(ignore_errors=True)
self.assertTrue(filename.exists())
def test_rmtree_on_error(self):
tmp = self.cls(self.base, 'rmtree')
tmp.mkdir()
filename = tmp / "tstfile"
filename.write_text("")
errors = []
def on_error(error):
errors.append(error)
filename.rmtree(on_error=on_error)
self.assertEqual(len(errors), 2)
# First from scandir()
self.assertIsInstance(errors[0], NotADirectoryError)
self.assertEqual(errors[0].filename, str(filename))
# Then from munlink()
self.assertIsInstance(errors[1], NotADirectoryError)
self.assertEqual(errors[1].filename, str(filename))
@needs_symlinks @needs_symlinks
def test_rmtree_outer_symlink(self): def test_delete_symlink(self):
tmp = self.cls(self.base, 'rmtree') tmp = self.cls(self.base, 'delete')
tmp.mkdir() tmp.mkdir()
dir_ = tmp / 'dir' dir_ = tmp / 'dir'
dir_.mkdir() dir_.mkdir()
link = tmp / 'link' link = tmp / 'link'
link.symlink_to(dir_) link.symlink_to(dir_)
self.assertRaises(OSError, link.rmtree) link.delete()
self.assertTrue(dir_.exists()) self.assertTrue(dir_.exists())
self.assertTrue(link.exists(follow_symlinks=False)) self.assertFalse(link.exists(follow_symlinks=False))
errors = []
def on_error(error):
errors.append(error)
link.rmtree(on_error=on_error)
self.assertEqual(len(errors), 1)
self.assertIsInstance(errors[0], OSError)
self.assertEqual(errors[0].filename, str(link))
@needs_symlinks @needs_symlinks
def test_rmtree_inner_symlink(self): def test_delete_inner_symlink(self):
tmp = self.cls(self.base, 'rmtree') tmp = self.cls(self.base, 'delete')
tmp.mkdir() tmp.mkdir()
dir1 = tmp / 'dir1' dir1 = tmp / 'dir1'
dir2 = dir1 / 'dir2' dir2 = dir1 / 'dir2'
@ -2735,11 +2693,26 @@ class DummyPathTest(DummyPurePathTest):
link3 = dir1 / 'link3' link3 = dir1 / 'link3'
link3.symlink_to(file1) link3.symlink_to(file1)
# make sure symlinks are removed but not followed # make sure symlinks are removed but not followed
dir1.rmtree() dir1.delete()
self.assertFalse(dir1.exists()) self.assertFalse(dir1.exists())
self.assertTrue(dir3.exists()) self.assertTrue(dir3.exists())
self.assertTrue(file1.exists()) self.assertTrue(file1.exists())
def test_delete_missing(self):
tmp = self.cls(self.base, 'delete')
tmp.mkdir()
# filename is guaranteed not to exist
filename = tmp / 'foo'
self.assertRaises(FileNotFoundError, filename.delete)
# test that ignore_errors option is honored
filename.delete(ignore_errors=True)
# test on_error
errors = []
filename.delete(on_error=errors.append)
self.assertEqual(len(errors), 1)
self.assertIsInstance(errors[0], FileNotFoundError)
self.assertEqual(errors[0].filename, str(filename))
def setUpWalk(self): def setUpWalk(self):
# Build: # Build:
# TESTFN/ # TESTFN/

View file

@ -1 +1 @@
Add :meth:`pathlib.Path.rmtree`, which recursively removes a directory. Add :meth:`pathlib.Path.delete`, which recursively removes a file or directory.