]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
GH-73991: Add `pathlib.Path.copytree()` (#120718)
authorBarney Gale <barney.gale@gmail.com>
Sun, 23 Jun 2024 21:01:12 +0000 (22:01 +0100)
committerGitHub <noreply@github.com>
Sun, 23 Jun 2024 21:01:12 +0000 (22:01 +0100)
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>
Doc/library/pathlib.rst
Doc/whatsnew/3.14.rst
Lib/pathlib/_abc.py
Lib/test/test_pathlib/test_pathlib.py
Lib/test/test_pathlib/test_pathlib_abc.py
Misc/NEWS.d/next/Library/2024-06-19-03-09-11.gh-issue-73991.lU_jK9.rst [new file with mode: 0644]

index 5bfcad0dadff6a7828c076e74cabd6387e1c0bc7..e585bcef915fbf549b0e5a43645c22890df3eb47 100644 (file)
@@ -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
index 8806bbff9c866319ef4cc288a72023c1f94cb0c7..b134ed31f6df402188d2399e240eca36ed6788a1 100644 (file)
@@ -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
 --------
index f1f350a196091a9e7ab971d0a32e0ced1a46d80a..7197391392116964a26b7ab766e1330145a7d720 100644 (file)
@@ -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.
index 89af1f7581764f921512dc0cce6229ff147715ae..6b5e90fbcf718ee2482b833e1c8634ca6ce8fa11 100644 (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')
 
index cd629c01871165e0a4355d2a54f3b5133432b151..ad692e872ede0bb618d198568eb34da20d419f24 100644 (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)
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 (file)
index 0000000..60a1b68
--- /dev/null
@@ -0,0 +1 @@
+Add :meth:`pathlib.Path.copytree`, which recursively copies a directory.