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:
Barney Gale 2024-06-23 22:01:12 +01:00 committed by GitHub
parent bc37ac7b44
commit 35e998f560
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 231 additions and 0 deletions

View file

@ -653,6 +653,19 @@ class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest):
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')

View file

@ -1822,6 +1822,163 @@ class DummyPathTest(DummyPurePathTest):
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
# 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)