Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GH-73991: Add pathlib.Path.copytree() #120718

Merged
merged 4 commits into from
Jun 23, 2024
Merged
Show file tree
Hide file tree
Changes from 2 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
Original file line number Diff line number Diff line change
Expand Up @@ -1455,6 +1455,33 @@ Copying, renaming and deleting
.. 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)

Rename this file or directory to the given *target*, and return a new
Expand Down
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
20 changes: 18 additions & 2 deletions Lib/test/test_pathlib/test_pathlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -513,14 +513,17 @@ def setUp(self):
os.chmod(self.parser.join(self.base, 'dirE'), 0)

def tearDown(self):
os.chmod(self.parser.join(self.base, 'dirE'), 0o777)
try:
os.chmod(self.parser.join(self.base, 'dirE'), 0o777)
except FileNotFoundError:
pass
os_helper.rmtree(self.base)

def tempdir(self):
d = os_helper._longpath(tempfile.mkdtemp(suffix='-dirD',
dir=os.getcwd()))
self.addCleanup(os_helper.rmtree, d)
return d
return self.cls(d)

def test_matches_pathbase_api(self):
our_names = {name for name in dir(self.cls) if name[0] != '_'}
Expand Down Expand Up @@ -653,6 +656,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", "directories are always readable on Windows")
barneygale marked this conversation as resolved.
Show resolved Hide resolved
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
164 changes: 164 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,170 @@ 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):
def ordered_walk(path):
for dirpath, dirnames, filenames in path.walk(follow_symlinks=True):
dirnames.sort()
filenames.sort()
yield dirpath, dirnames, filenames
source = self.cls(self.base)
target = self.tempdir() / 'target'

# Special cases tested elsehwere
source.joinpath('dirE').rmdir()
barneygale marked this conversation as resolved.
Show resolved Hide resolved
if self.can_symlink:
source.joinpath('brokenLink').unlink()
source.joinpath('brokenLinkLoop').unlink()
source.joinpath('dirB', 'linkD').unlink()
barneygale marked this conversation as resolved.
Show resolved Hide resolved

# Perform the copy
source.copytree(target)

# 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
barneygale marked this conversation as resolved.
Show resolved Hide resolved

def test_copytree_complex_follow_symlinks_false(self):
def ordered_walk(path):
for dirpath, dirnames, filenames in path.walk(follow_symlinks=False):
dirnames.sort()
filenames.sort()
yield dirpath, dirnames, filenames
source = self.cls(self.base)
target = self.tempdir() / 'target'

# Special cases tested elsewhere
source.joinpath('dirE').rmdir()

# Perform the copy
source.copytree(target, follow_symlinks=False)

# 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

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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add :meth:`pathlib.Path.copytree`, which recursively copies a directory.
Loading