GH-73991: Add `pathlib.Path.copytree()` by barneygale · Pull Request #120718 · python/cpython · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions Doc/library/pathlib.rst
3 changes: 3 additions & 0 deletions Doc/whatsnew/3.14.rst
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ pathlib
* Add :meth:`pathlib.Path.copy`, which copies the content of one file to
another, like :func:`shutil.copyfile`.
(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
--------
Expand Down
30 changes: 30 additions & 0 deletions Lib/pathlib/_abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,36 @@ def copy(self, target, follow_symlinks=True):
else:
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):
"""
Rename this path to the target path.
Expand Down
13 changes: 13 additions & 0 deletions Lib/test/test_pathlib/test_pathlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,19 @@ def test_open_unbuffered(self):
self.assertIsInstance(f, io.RawIOBase)
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):
p = self.cls('non', 'exist')

Expand Down
157 changes: 157 additions & 0 deletions Lib/test/test_pathlib/test_pathlib_abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -1822,6 +1822,163 @@ def test_copy_empty(self):
self.assertTrue(target.exists())
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
Comment thread
barneygale marked this conversation as resolved.
# 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):
P = self.cls
p = P(self.base)
Expand Down