summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorBarney Gale <barney.gale@gmail.com>2024-06-23 21:01:12 (GMT)
committerGitHub <noreply@github.com>2024-06-23 21:01:12 (GMT)
commit35e998f5608b04cdd331e67dd80d4829df71a5fd (patch)
tree44099e8e9ae00cad3684e3adb024fadcc7770050
parentbc37ac7b440b5e816f0b3915b830404290522603 (diff)
downloadcpython-35e998f5608b04cdd331e67dd80d4829df71a5fd.zip
cpython-35e998f5608b04cdd331e67dd80d4829df71a5fd.tar.gz
cpython-35e998f5608b04cdd331e67dd80d4829df71a5fd.tar.bz2
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>
-rw-r--r--Doc/library/pathlib.rst27
-rw-r--r--Doc/whatsnew/3.14.rst3
-rw-r--r--Lib/pathlib/_abc.py30
-rw-r--r--Lib/test/test_pathlib/test_pathlib.py13
-rw-r--r--Lib/test/test_pathlib/test_pathlib_abc.py157
-rw-r--r--Misc/NEWS.d/next/Library/2024-06-19-03-09-11.gh-issue-73991.lU_jK9.rst1
6 files changed, 231 insertions, 0 deletions
diff --git a/Doc/library/pathlib.rst b/Doc/library/pathlib.rst
index 5bfcad0..e585bce 100644
--- a/Doc/library/pathlib.rst
+++ b/Doc/library/pathlib.rst
@@ -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
diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst
index 8806bbf..b134ed3 100644
--- a/Doc/whatsnew/3.14.rst
+++ b/Doc/whatsnew/3.14.rst
@@ -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
--------
diff --git a/Lib/pathlib/_abc.py b/Lib/pathlib/_abc.py
index f1f350a..7197391 100644
--- a/Lib/pathlib/_abc.py
+++ b/Lib/pathlib/_abc.py
@@ -815,6 +815,36 @@ class PathBase(PurePathBase):
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.
diff --git a/Lib/test/test_pathlib/test_pathlib.py b/Lib/test/test_pathlib/test_pathlib.py
index 89af1f7..6b5e90f 100644
--- a/Lib/test/test_pathlib/test_pathlib.py
+++ b/Lib/test/test_pathlib/test_pathlib.py
@@ -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')
diff --git a/Lib/test/test_pathlib/test_pathlib_abc.py b/Lib/test/test_pathlib/test_pathlib_abc.py
index cd629c0..ad692e8 100644
--- a/Lib/test/test_pathlib/test_pathlib_abc.py
+++ b/Lib/test/test_pathlib/test_pathlib_abc.py
@@ -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)
diff --git a/Misc/NEWS.d/next/Library/2024-06-19-03-09-11.gh-issue-73991.lU_jK9.rst b/Misc/NEWS.d/next/Library/2024-06-19-03-09-11.gh-issue-73991.lU_jK9.rst
new file mode 100644
index 0000000..60a1b68
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2024-06-19-03-09-11.gh-issue-73991.lU_jK9.rst
@@ -0,0 +1 @@
+Add :meth:`pathlib.Path.copytree`, which recursively copies a directory.