mirror of https://github.com/python/cpython
GH-73991: Add `pathlib.Path.copytree()` (#120718)
Add `pathlib.Path.copytree()` method, which recursively copies one directory to another. This differs from `shutil.copytree()` in the following respects: 1. Our method has a *follow_symlinks* argument, whereas shutil's has a *symlinks* argument with an inverted meaning. 2. Our method lacks something like a *copy_function* argument. It always uses `Path.copy()` to copy files. 3. Our method lacks something like a *ignore_dangling_symlinks* argument. Instead, users can filter out danging symlinks with *ignore*, or ignore exceptions with *on_error* 4. Our *ignore* argument is a callable that accepts a single path object, whereas shutil's accepts a path and a list of child filenames. 5. We add an *on_error* argument, which is a callable that accepts an `OSError` instance. (`Path.walk()` also accepts such a callable). Co-authored-by: Nice Zombies <nineteendo19d0@gmail.com>
This commit is contained in:
parent
bc37ac7b44
commit
35e998f560
|
@ -1455,6 +1455,33 @@ Copying, renaming and deleting
|
||||||
.. versionadded:: 3.14
|
.. versionadded:: 3.14
|
||||||
|
|
||||||
|
|
||||||
|
.. method:: Path.copytree(target, *, follow_symlinks=True, dirs_exist_ok=False, \
|
||||||
|
ignore=None, on_error=None)
|
||||||
|
|
||||||
|
Recursively copy this directory tree to the given destination.
|
||||||
|
|
||||||
|
If a symlink is encountered in the source tree, and *follow_symlinks* is
|
||||||
|
true (the default), the symlink's target is copied. Otherwise, the symlink
|
||||||
|
is recreated in the destination tree.
|
||||||
|
|
||||||
|
If the destination is an existing directory and *dirs_exist_ok* is false
|
||||||
|
(the default), a :exc:`FileExistsError` is raised. Otherwise, the copying
|
||||||
|
operation will continue if it encounters existing directories, and files
|
||||||
|
within the destination tree will be overwritten by corresponding files from
|
||||||
|
the source tree.
|
||||||
|
|
||||||
|
If *ignore* is given, it should be a callable accepting one argument: a
|
||||||
|
file or directory path within the source tree. The callable may return true
|
||||||
|
to suppress copying of the path.
|
||||||
|
|
||||||
|
If *on_error* is given, it should be a callable accepting one argument: an
|
||||||
|
instance of :exc:`OSError`. The callable may re-raise the exception or do
|
||||||
|
nothing, in which case the copying operation continues. If *on_error* isn't
|
||||||
|
given, exceptions are propagated to the caller.
|
||||||
|
|
||||||
|
.. versionadded:: 3.14
|
||||||
|
|
||||||
|
|
||||||
.. method:: Path.rename(target)
|
.. method:: Path.rename(target)
|
||||||
|
|
||||||
Rename this file or directory to the given *target*, and return a new
|
Rename this file or directory to the given *target*, and return a new
|
||||||
|
|
|
@ -106,6 +106,9 @@ pathlib
|
||||||
* Add :meth:`pathlib.Path.copy`, which copies the content of one file to
|
* Add :meth:`pathlib.Path.copy`, which copies the content of one file to
|
||||||
another, like :func:`shutil.copyfile`.
|
another, like :func:`shutil.copyfile`.
|
||||||
(Contributed by Barney Gale in :gh:`73991`.)
|
(Contributed by Barney Gale in :gh:`73991`.)
|
||||||
|
* Add :meth:`pathlib.Path.copytree`, which copies one directory tree to
|
||||||
|
another.
|
||||||
|
(Contributed by Barney Gale in :gh:`73991`.)
|
||||||
|
|
||||||
symtable
|
symtable
|
||||||
--------
|
--------
|
||||||
|
|
|
@ -815,6 +815,36 @@ class PathBase(PurePathBase):
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def copytree(self, target, *, follow_symlinks=True, dirs_exist_ok=False,
|
||||||
|
ignore=None, on_error=None):
|
||||||
|
"""
|
||||||
|
Recursively copy this directory tree to the given destination.
|
||||||
|
"""
|
||||||
|
if not isinstance(target, PathBase):
|
||||||
|
target = self.with_segments(target)
|
||||||
|
if on_error is None:
|
||||||
|
def on_error(err):
|
||||||
|
raise err
|
||||||
|
stack = [(self, target)]
|
||||||
|
while stack:
|
||||||
|
source_dir, target_dir = stack.pop()
|
||||||
|
try:
|
||||||
|
sources = source_dir.iterdir()
|
||||||
|
target_dir.mkdir(exist_ok=dirs_exist_ok)
|
||||||
|
for source in sources:
|
||||||
|
if ignore and ignore(source):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if source.is_dir(follow_symlinks=follow_symlinks):
|
||||||
|
stack.append((source, target_dir.joinpath(source.name)))
|
||||||
|
else:
|
||||||
|
source.copy(target_dir.joinpath(source.name),
|
||||||
|
follow_symlinks=follow_symlinks)
|
||||||
|
except OSError as err:
|
||||||
|
on_error(err)
|
||||||
|
except OSError as err:
|
||||||
|
on_error(err)
|
||||||
|
|
||||||
def rename(self, target):
|
def rename(self, target):
|
||||||
"""
|
"""
|
||||||
Rename this path to the target path.
|
Rename this path to the target path.
|
||||||
|
|
|
@ -653,6 +653,19 @@ class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest):
|
||||||
self.assertIsInstance(f, io.RawIOBase)
|
self.assertIsInstance(f, io.RawIOBase)
|
||||||
self.assertEqual(f.read().strip(), b"this is file A")
|
self.assertEqual(f.read().strip(), b"this is file A")
|
||||||
|
|
||||||
|
@unittest.skipIf(sys.platform == "win32" or sys.platform == "wasi", "directories are always readable on Windows and WASI")
|
||||||
|
def test_copytree_no_read_permission(self):
|
||||||
|
base = self.cls(self.base)
|
||||||
|
source = base / 'dirE'
|
||||||
|
target = base / 'copyE'
|
||||||
|
self.assertRaises(PermissionError, source.copytree, target)
|
||||||
|
self.assertFalse(target.exists())
|
||||||
|
errors = []
|
||||||
|
source.copytree(target, on_error=errors.append)
|
||||||
|
self.assertEqual(len(errors), 1)
|
||||||
|
self.assertIsInstance(errors[0], PermissionError)
|
||||||
|
self.assertFalse(target.exists())
|
||||||
|
|
||||||
def test_resolve_nonexist_relative_issue38671(self):
|
def test_resolve_nonexist_relative_issue38671(self):
|
||||||
p = self.cls('non', 'exist')
|
p = self.cls('non', 'exist')
|
||||||
|
|
||||||
|
|
|
@ -1822,6 +1822,163 @@ class DummyPathTest(DummyPurePathTest):
|
||||||
self.assertTrue(target.exists())
|
self.assertTrue(target.exists())
|
||||||
self.assertEqual(target.read_bytes(), b'')
|
self.assertEqual(target.read_bytes(), b'')
|
||||||
|
|
||||||
|
def test_copytree_simple(self):
|
||||||
|
base = self.cls(self.base)
|
||||||
|
source = base / 'dirC'
|
||||||
|
target = base / 'copyC'
|
||||||
|
source.copytree(target)
|
||||||
|
self.assertTrue(target.is_dir())
|
||||||
|
self.assertTrue(target.joinpath('dirD').is_dir())
|
||||||
|
self.assertTrue(target.joinpath('dirD', 'fileD').is_file())
|
||||||
|
self.assertEqual(target.joinpath('dirD', 'fileD').read_text(),
|
||||||
|
"this is file D\n")
|
||||||
|
self.assertTrue(target.joinpath('fileC').is_file())
|
||||||
|
self.assertTrue(target.joinpath('fileC').read_text(),
|
||||||
|
"this is file C\n")
|
||||||
|
|
||||||
|
def test_copytree_complex(self, follow_symlinks=True):
|
||||||
|
def ordered_walk(path):
|
||||||
|
for dirpath, dirnames, filenames in path.walk(follow_symlinks=follow_symlinks):
|
||||||
|
dirnames.sort()
|
||||||
|
filenames.sort()
|
||||||
|
yield dirpath, dirnames, filenames
|
||||||
|
base = self.cls(self.base)
|
||||||
|
source = base / 'dirC'
|
||||||
|
|
||||||
|
if self.can_symlink:
|
||||||
|
# Add some symlinks
|
||||||
|
source.joinpath('linkC').symlink_to('fileC')
|
||||||
|
source.joinpath('linkD').symlink_to('dirD')
|
||||||
|
|
||||||
|
# Perform the copy
|
||||||
|
target = base / 'copyC'
|
||||||
|
source.copytree(target, follow_symlinks=follow_symlinks)
|
||||||
|
|
||||||
|
# Compare the source and target trees
|
||||||
|
source_walk = ordered_walk(source)
|
||||||
|
target_walk = ordered_walk(target)
|
||||||
|
for source_item, target_item in zip(source_walk, target_walk, strict=True):
|
||||||
|
self.assertEqual(source_item[0].relative_to(source),
|
||||||
|
target_item[0].relative_to(target)) # dirpath
|
||||||
|
self.assertEqual(source_item[1], target_item[1]) # dirnames
|
||||||
|
self.assertEqual(source_item[2], target_item[2]) # filenames
|
||||||
|
# Compare files and symlinks
|
||||||
|
for filename in source_item[2]:
|
||||||
|
source_file = source_item[0].joinpath(filename)
|
||||||
|
target_file = target_item[0].joinpath(filename)
|
||||||
|
if follow_symlinks or not source_file.is_symlink():
|
||||||
|
# Regular file.
|
||||||
|
self.assertEqual(source_file.read_bytes(), target_file.read_bytes())
|
||||||
|
elif source_file.is_dir():
|
||||||
|
# Symlink to directory.
|
||||||
|
self.assertTrue(target_file.is_dir())
|
||||||
|
self.assertEqual(source_file.readlink(), target_file.readlink())
|
||||||
|
else:
|
||||||
|
# Symlink to file.
|
||||||
|
self.assertEqual(source_file.read_bytes(), target_file.read_bytes())
|
||||||
|
self.assertEqual(source_file.readlink(), target_file.readlink())
|
||||||
|
|
||||||
|
def test_copytree_complex_follow_symlinks_false(self):
|
||||||
|
self.test_copytree_complex(follow_symlinks=False)
|
||||||
|
|
||||||
|
def test_copytree_to_existing_directory(self):
|
||||||
|
base = self.cls(self.base)
|
||||||
|
source = base / 'dirC'
|
||||||
|
target = base / 'copyC'
|
||||||
|
target.mkdir()
|
||||||
|
target.joinpath('dirD').mkdir()
|
||||||
|
self.assertRaises(FileExistsError, source.copytree, target)
|
||||||
|
|
||||||
|
def test_copytree_to_existing_directory_dirs_exist_ok(self):
|
||||||
|
base = self.cls(self.base)
|
||||||
|
source = base / 'dirC'
|
||||||
|
target = base / 'copyC'
|
||||||
|
target.mkdir()
|
||||||
|
target.joinpath('dirD').mkdir()
|
||||||
|
source.copytree(target, dirs_exist_ok=True)
|
||||||
|
self.assertTrue(target.is_dir())
|
||||||
|
self.assertTrue(target.joinpath('dirD').is_dir())
|
||||||
|
self.assertTrue(target.joinpath('dirD', 'fileD').is_file())
|
||||||
|
self.assertEqual(target.joinpath('dirD', 'fileD').read_text(),
|
||||||
|
"this is file D\n")
|
||||||
|
self.assertTrue(target.joinpath('fileC').is_file())
|
||||||
|
self.assertTrue(target.joinpath('fileC').read_text(),
|
||||||
|
"this is file C\n")
|
||||||
|
|
||||||
|
def test_copytree_file(self):
|
||||||
|
base = self.cls(self.base)
|
||||||
|
source = base / 'fileA'
|
||||||
|
target = base / 'copyA'
|
||||||
|
self.assertRaises(NotADirectoryError, source.copytree, target)
|
||||||
|
|
||||||
|
def test_copytree_file_on_error(self):
|
||||||
|
base = self.cls(self.base)
|
||||||
|
source = base / 'fileA'
|
||||||
|
target = base / 'copyA'
|
||||||
|
errors = []
|
||||||
|
source.copytree(target, on_error=errors.append)
|
||||||
|
self.assertEqual(len(errors), 1)
|
||||||
|
self.assertIsInstance(errors[0], NotADirectoryError)
|
||||||
|
|
||||||
|
def test_copytree_ignore_false(self):
|
||||||
|
base = self.cls(self.base)
|
||||||
|
source = base / 'dirC'
|
||||||
|
target = base / 'copyC'
|
||||||
|
ignores = []
|
||||||
|
def ignore_false(path):
|
||||||
|
ignores.append(path)
|
||||||
|
return False
|
||||||
|
source.copytree(target, ignore=ignore_false)
|
||||||
|
self.assertEqual(set(ignores), {
|
||||||
|
source / 'dirD',
|
||||||
|
source / 'dirD' / 'fileD',
|
||||||
|
source / 'fileC',
|
||||||
|
source / 'novel.txt',
|
||||||
|
})
|
||||||
|
self.assertTrue(target.is_dir())
|
||||||
|
self.assertTrue(target.joinpath('dirD').is_dir())
|
||||||
|
self.assertTrue(target.joinpath('dirD', 'fileD').is_file())
|
||||||
|
self.assertEqual(target.joinpath('dirD', 'fileD').read_text(),
|
||||||
|
"this is file D\n")
|
||||||
|
self.assertTrue(target.joinpath('fileC').is_file())
|
||||||
|
self.assertTrue(target.joinpath('fileC').read_text(),
|
||||||
|
"this is file C\n")
|
||||||
|
|
||||||
|
def test_copytree_ignore_true(self):
|
||||||
|
base = self.cls(self.base)
|
||||||
|
source = base / 'dirC'
|
||||||
|
target = base / 'copyC'
|
||||||
|
ignores = []
|
||||||
|
def ignore_true(path):
|
||||||
|
ignores.append(path)
|
||||||
|
return True
|
||||||
|
source.copytree(target, ignore=ignore_true)
|
||||||
|
self.assertEqual(set(ignores), {
|
||||||
|
source / 'dirD',
|
||||||
|
source / 'fileC',
|
||||||
|
source / 'novel.txt',
|
||||||
|
})
|
||||||
|
self.assertTrue(target.is_dir())
|
||||||
|
self.assertFalse(target.joinpath('dirD').exists())
|
||||||
|
self.assertFalse(target.joinpath('fileC').exists())
|
||||||
|
self.assertFalse(target.joinpath('novel.txt').exists())
|
||||||
|
|
||||||
|
@needs_symlinks
|
||||||
|
def test_copytree_dangling_symlink(self):
|
||||||
|
base = self.cls(self.base)
|
||||||
|
source = base / 'source'
|
||||||
|
target = base / 'target'
|
||||||
|
|
||||||
|
source.mkdir()
|
||||||
|
source.joinpath('link').symlink_to('nonexistent')
|
||||||
|
|
||||||
|
self.assertRaises(FileNotFoundError, source.copytree, target)
|
||||||
|
|
||||||
|
target2 = base / 'target2'
|
||||||
|
source.copytree(target2, follow_symlinks=False)
|
||||||
|
self.assertTrue(target2.joinpath('link').is_symlink())
|
||||||
|
self.assertEqual(target2.joinpath('link').readlink(), self.cls('nonexistent'))
|
||||||
|
|
||||||
def test_iterdir(self):
|
def test_iterdir(self):
|
||||||
P = self.cls
|
P = self.cls
|
||||||
p = P(self.base)
|
p = P(self.base)
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
Add :meth:`pathlib.Path.copytree`, which recursively copies a directory.
|
Loading…
Reference in New Issue