diff --git a/Doc/library/pathlib.rst b/Doc/library/pathlib.rst index 5bfcad0dadff6a..e585bcef915fbf 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 804d39ab64646d..16a63b0c00fa7f 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 f1f350a196091a..71973913921169 100644 --- a/Lib/pathlib/_abc.py +++ b/Lib/pathlib/_abc.py @@ -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. diff --git a/Lib/test/test_pathlib/test_pathlib.py b/Lib/test/test_pathlib/test_pathlib.py index 89af1f7581764f..6b5e90fbcf718e 100644 --- a/Lib/test/test_pathlib/test_pathlib.py +++ b/Lib/test/test_pathlib/test_pathlib.py @@ -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') diff --git a/Lib/test/test_pathlib/test_pathlib_abc.py b/Lib/test/test_pathlib/test_pathlib_abc.py index cd629c01871165..ad692e872ede0b 100644 --- a/Lib/test/test_pathlib/test_pathlib_abc.py +++ b/Lib/test/test_pathlib/test_pathlib_abc.py @@ -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 + # 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 00000000000000..60a1b68d5bb1a8 --- /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.