]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
GH-73991: Rework `pathlib.Path.copytree()` into `copy()` (#122369)
authorBarney Gale <barney.gale@gmail.com>
Sun, 11 Aug 2024 21:43:18 +0000 (22:43 +0100)
committerGitHub <noreply@github.com>
Sun, 11 Aug 2024 21:43:18 +0000 (22:43 +0100)
Rename `pathlib.Path.copy()` to `_copy_file()` (i.e. make it private.)

Rename `pathlib.Path.copytree()` to `copy()`, and add support for copying
non-directories. This simplifies the interface for users, and nicely
complements the upcoming `move()` and `delete()` methods (which will also
accept any type of file.)

Co-authored-by: Adam Turner <9087854+AA-Turner@users.noreply.github.com>
Doc/library/pathlib.rst
Doc/whatsnew/3.14.rst
Lib/pathlib/__init__.py
Lib/pathlib/_abc.py
Lib/pathlib/_local.py
Lib/pathlib/_os.py
Lib/test/test_pathlib/test_pathlib.py
Lib/test/test_pathlib/test_pathlib_abc.py
Misc/NEWS.d/next/Library/2024-05-15-01-36-08.gh-issue-73991.CGknDf.rst
Misc/NEWS.d/next/Library/2024-06-19-03-09-11.gh-issue-73991.lU_jK9.rst [deleted file]

index 4a339358251b7255b5dfc4e21d2b913dc8400b90..f66d36a32cbd04205ba95bf4dbf48f6d12ea7a2e 100644 (file)
@@ -1539,50 +1539,33 @@ Creating files and directories
 Copying, renaming and deleting
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
-.. method:: Path.copy(target, *, follow_symlinks=True, preserve_metadata=False)
+.. method:: Path.copy(target, *, follow_symlinks=True, dirs_exist_ok=False, \
+                      preserve_metadata=False, ignore=None, on_error=None)
 
-   Copy the contents of this file to the *target* file. If *target* specifies
-   a file that already exists, it will be replaced.
+   Copy this file or directory tree to the given *target*, and return a new
+   :class:`!Path` instance pointing to *target*.
 
-   If *follow_symlinks* is false, and this file is a symbolic link, *target*
-   will be created as a symbolic link. If *follow_symlinks* is true and this
-   file is a symbolic link, *target* will be a copy of the symlink target.
+   If the source is a file, the target will be replaced if it is an existing
+   file. If the source is a symlink and *follow_symlinks* is true (the
+   default), the symlink's target is copied. Otherwise, the symlink is
+   recreated at the destination.
 
-   If *preserve_metadata* is false (the default), only the file data is
-   guaranteed to be copied. Set *preserve_metadata* to true to ensure that the
-   file mode (permissions), flags, last access and modification times, and
-   extended attributes are copied where supported. This argument has no effect
-   on Windows, where metadata is always preserved when copying.
+   If the source is a directory and *dirs_exist_ok* is false (the default), a
+   :exc:`FileExistsError` is raised if the target is an existing directory.
+   If *dirs_exists_ok* is true, the copying operation will overwrite
+   existing files within the destination tree with corresponding files
+   from the source tree.
 
-   .. versionadded:: 3.14
-
-
-.. method:: Path.copytree(target, *, follow_symlinks=True, \
-                          preserve_metadata=False, 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 *preserve_metadata* is false (the default), only the directory structure
+   If *preserve_metadata* is false (the default), only directory structures
    and file data are guaranteed to be copied. Set *preserve_metadata* to true
    to ensure that file and directory permissions, flags, last access and
    modification times, and extended attributes are copied where supported.
-   This argument has no effect on Windows, where metadata is always preserved
-   when copying.
-
-   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.
+   This argument has no effect when copying files on Windows (where
+   metadata is always preserved).
 
    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.
+   source file or directory path. 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
index 27594c3dea8161db8af844017db9cc27a5c38e0a..3f53f6b940027b3b4c65b775d8c947206fe9bf9e 100644 (file)
@@ -146,10 +146,8 @@ pathlib
 
 * Add methods to :class:`pathlib.Path` to recursively copy or remove files:
 
-  * :meth:`~pathlib.Path.copy` copies the content of one file to another, like
-    :func:`shutil.copyfile`.
-  * :meth:`~pathlib.Path.copytree` copies one directory tree to another, like
-    :func:`shutil.copytree`.
+  * :meth:`~pathlib.Path.copy` copies a file or directory tree to a given
+    destination.
   * :meth:`~pathlib.Path.delete` removes a file or directory tree.
 
   (Contributed by Barney Gale in :gh:`73991`.)
index 2298a2495294600db41d5eefbaae212de8b0e9be..5da3acd31997e5dc6f250567c92caecbc14a6a84 100644 (file)
@@ -5,8 +5,8 @@ paths with operations that have semantics appropriate for different
 operating systems.
 """
 
-from ._os import *
-from ._local import *
+from pathlib._abc import *
+from pathlib._local import *
 
-__all__ = (_os.__all__ +
+__all__ = (_abc.__all__ +
            _local.__all__)
index 8c799196e4703aba9d62a3cbf71f19c5acfbe3df..500846d19cf81188f6776636822cc089c024a51b 100644 (file)
@@ -16,7 +16,16 @@ import operator
 import posixpath
 from glob import _GlobberBase, _no_recurse_symlinks
 from stat import S_ISDIR, S_ISLNK, S_ISREG, S_ISSOCK, S_ISBLK, S_ISCHR, S_ISFIFO
-from ._os import UnsupportedOperation, copyfileobj
+from pathlib._os import copyfileobj
+
+
+__all__ = ["UnsupportedOperation"]
+
+
+class UnsupportedOperation(NotImplementedError):
+    """An exception that is raised when an unsupported operation is attempted.
+    """
+    pass
 
 
 @functools.cache
@@ -761,6 +770,13 @@ class PathBase(PurePathBase):
         """
         raise UnsupportedOperation(self._unsupported_msg('symlink_to()'))
 
+    def _symlink_to_target_of(self, link):
+        """
+        Make this path a symlink with the same target as the given link. This
+        is used by copy().
+        """
+        self.symlink_to(link.readlink())
+
     def hardlink_to(self, target):
         """
         Make this path a hard link pointing to the same file as *target*.
@@ -806,21 +822,12 @@ class PathBase(PurePathBase):
             metadata = self._read_metadata(keys, follow_symlinks=follow_symlinks)
             target._write_metadata(metadata, follow_symlinks=follow_symlinks)
 
-    def copy(self, target, *, follow_symlinks=True, preserve_metadata=False):
+    def _copy_file(self, target):
         """
-        Copy the contents of this file to the given target. If this file is a
-        symlink and follow_symlinks is false, a symlink will be created at the
-        target.
+        Copy the contents of this file to the given target.
         """
-        if not isinstance(target, PathBase):
-            target = self.with_segments(target)
         if self._samefile_safe(target):
             raise OSError(f"{self!r} and {target!r} are the same file")
-        if not follow_symlinks and self.is_symlink():
-            target.symlink_to(self.readlink())
-            if preserve_metadata:
-                self._copy_metadata(target, follow_symlinks=False)
-            return
         with self.open('rb') as source_f:
             try:
                 with target.open('wb') as target_f:
@@ -832,42 +839,39 @@ class PathBase(PurePathBase):
                         f'Directory does not exist: {target}') from e
                 else:
                     raise
-        if preserve_metadata:
-            self._copy_metadata(target)
 
-    def copytree(self, target, *, follow_symlinks=True,
-                 preserve_metadata=False, dirs_exist_ok=False,
-                 ignore=None, on_error=None):
+    def copy(self, target, *, follow_symlinks=True, dirs_exist_ok=False,
+             preserve_metadata=False, ignore=None, on_error=None):
         """
-        Recursively copy this directory tree to the given destination.
+        Recursively copy this file or 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()
+            src, dst = stack.pop()
             try:
-                sources = source_dir.iterdir()
-                target_dir.mkdir(exist_ok=dirs_exist_ok)
-                if preserve_metadata:
-                    source_dir._copy_metadata(target_dir)
-                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,
-                                        preserve_metadata=preserve_metadata)
-                    except OSError as err:
-                        on_error(err)
+                if not follow_symlinks and src.is_symlink():
+                    dst._symlink_to_target_of(src)
+                    if preserve_metadata:
+                        src._copy_metadata(dst, follow_symlinks=False)
+                elif src.is_dir():
+                    children = src.iterdir()
+                    dst.mkdir(exist_ok=dirs_exist_ok)
+                    for child in children:
+                        if not (ignore and ignore(child)):
+                            stack.append((child, dst.joinpath(child.name)))
+                    if preserve_metadata:
+                        src._copy_metadata(dst)
+                else:
+                    src._copy_file(dst)
+                    if preserve_metadata:
+                        src._copy_metadata(dst)
             except OSError as err:
+                if on_error is None:
+                    raise
                 on_error(err)
+        return target
 
     def rename(self, target):
         """
index 6e2f88c93422a477e2a8de68381de0b9623b1527..8f5c58c16ef0d00dfa4a1abfdbeab11f616e9eb0 100644 (file)
@@ -18,9 +18,9 @@ try:
 except ImportError:
     grp = None
 
-from ._os import (UnsupportedOperation, copyfile, file_metadata_keys,
-                  read_file_metadata, write_file_metadata)
-from ._abc import PurePathBase, PathBase
+from pathlib._os import (copyfile, file_metadata_keys, read_file_metadata,
+                         write_file_metadata)
+from pathlib._abc import UnsupportedOperation, PurePathBase, PathBase
 
 
 __all__ = [
@@ -788,25 +788,18 @@ class Path(PathBase, PurePath):
     _write_metadata = write_file_metadata
 
     if copyfile:
-        def copy(self, target, *, follow_symlinks=True, preserve_metadata=False):
+        def _copy_file(self, target):
             """
-            Copy the contents of this file to the given target. If this file is a
-            symlink and follow_symlinks is false, a symlink will be created at the
-            target.
+            Copy the contents of this file to the given target.
             """
             try:
                 target = os.fspath(target)
             except TypeError:
                 if not isinstance(target, PathBase):
                     raise
+                PathBase._copy_file(self, target)
             else:
-                try:
-                    copyfile(os.fspath(self), target, follow_symlinks)
-                    return
-                except UnsupportedOperation:
-                    pass  # Fall through to generic code.
-            PathBase.copy(self, target, follow_symlinks=follow_symlinks,
-                          preserve_metadata=preserve_metadata)
+                copyfile(os.fspath(self), target)
 
     def chmod(self, mode, *, follow_symlinks=True):
         """
@@ -894,6 +887,14 @@ class Path(PathBase, PurePath):
             """
             os.symlink(target, self, target_is_directory)
 
+    if os.name == 'nt':
+        def _symlink_to_target_of(self, link):
+            """
+            Make this path a symlink with the same target as the given link.
+            This is used by copy().
+            """
+            self.symlink_to(link.readlink(), link.is_dir())
+
     if hasattr(os, "link"):
         def hardlink_to(self, target):
             """
index 164ee8e9034427c278d1769ea8ac695aad460295..63dbe131baea519922a84ac1511db114e2b92e54 100644 (file)
@@ -20,15 +20,6 @@ except ImportError:
     _winapi = None
 
 
-__all__ = ["UnsupportedOperation"]
-
-
-class UnsupportedOperation(NotImplementedError):
-    """An exception that is raised when an unsupported operation is attempted.
-    """
-    pass
-
-
 def get_copy_blocksize(infd):
     """Determine blocksize for fastcopying on Linux.
     Hopefully the whole file will be copied in a single call.
@@ -101,44 +92,12 @@ else:
     copyfd = None
 
 
-if _winapi and hasattr(_winapi, 'CopyFile2') and hasattr(os.stat_result, 'st_file_attributes'):
-    def _is_dirlink(path):
-        try:
-            st = os.lstat(path)
-        except (OSError, ValueError):
-            return False
-        return (st.st_file_attributes & stat.FILE_ATTRIBUTE_DIRECTORY and
-                st.st_reparse_tag == stat.IO_REPARSE_TAG_SYMLINK)
-
-    def copyfile(source, target, follow_symlinks):
+if _winapi and hasattr(_winapi, 'CopyFile2'):
+    def copyfile(source, target):
         """
         Copy from one file to another using CopyFile2 (Windows only).
         """
-        if follow_symlinks:
-            _winapi.CopyFile2(source, target, 0)
-        else:
-            # Use COPY_FILE_COPY_SYMLINK to copy a file symlink.
-            flags = _winapi.COPY_FILE_COPY_SYMLINK
-            try:
-                _winapi.CopyFile2(source, target, flags)
-                return
-            except OSError as err:
-                # Check for ERROR_ACCESS_DENIED
-                if err.winerror == 5 and _is_dirlink(source):
-                    pass
-                else:
-                    raise
-
-            # Add COPY_FILE_DIRECTORY to copy a directory symlink.
-            flags |= _winapi.COPY_FILE_DIRECTORY
-            try:
-                _winapi.CopyFile2(source, target, flags)
-            except OSError as err:
-                # Check for ERROR_INVALID_PARAMETER
-                if err.winerror == 87:
-                    raise UnsupportedOperation(err) from None
-                else:
-                    raise
+        _winapi.CopyFile2(source, target, 0)
 else:
     copyfile = None
 
index 9e922259cbaa6a83a24b37b02cae8f5eac5d8f1c..fa151b590d7c525224f128f5a954296a87ad18cc 100644 (file)
@@ -709,19 +709,19 @@ class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest):
 
     @unittest.skipIf(sys.platform == "win32" or sys.platform == "wasi", "directories are always readable on Windows and WASI")
     @unittest.skipIf(root_in_posix, "test fails with root privilege")
-    def test_copytree_no_read_permission(self):
+    def test_copy_dir_no_read_permission(self):
         base = self.cls(self.base)
         source = base / 'dirE'
         target = base / 'copyE'
-        self.assertRaises(PermissionError, source.copytree, target)
+        self.assertRaises(PermissionError, source.copy, target)
         self.assertFalse(target.exists())
         errors = []
-        source.copytree(target, on_error=errors.append)
+        source.copy(target, on_error=errors.append)
         self.assertEqual(len(errors), 1)
         self.assertIsInstance(errors[0], PermissionError)
         self.assertFalse(target.exists())
 
-    def test_copytree_preserve_metadata(self):
+    def test_copy_dir_preserve_metadata(self):
         base = self.cls(self.base)
         source = base / 'dirC'
         if hasattr(os, 'chmod'):
@@ -729,7 +729,7 @@ class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest):
         if hasattr(os, 'chflags') and hasattr(stat, 'UF_NODUMP'):
             os.chflags(source / 'fileC', stat.UF_NODUMP)
         target = base / 'copyA'
-        source.copytree(target, preserve_metadata=True)
+        source.copy(target, preserve_metadata=True)
 
         for subpath in ['.', 'fileC', 'dirD', 'dirD/fileD']:
             source_st = source.joinpath(subpath).stat()
@@ -741,13 +741,13 @@ class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest):
                 self.assertEqual(source_st.st_flags, target_st.st_flags)
 
     @os_helper.skip_unless_xattr
-    def test_copytree_preserve_metadata_xattrs(self):
+    def test_copy_dir_preserve_metadata_xattrs(self):
         base = self.cls(self.base)
         source = base / 'dirC'
         source_file = source.joinpath('dirD', 'fileD')
         os.setxattr(source_file, b'user.foo', b'42')
         target = base / 'copyA'
-        source.copytree(target, preserve_metadata=True)
+        source.copy(target, preserve_metadata=True)
         target_file = target.joinpath('dirD', 'fileD')
         self.assertEqual(os.getxattr(target_file, b'user.foo'), b'42')
 
index 443a4e989fb54b14869ee113e04dba94b1974a1c..629a1d4bdeb4de11163cc529daadabefecb5e478 100644 (file)
@@ -5,8 +5,7 @@ import errno
 import stat
 import unittest
 
-from pathlib._os import UnsupportedOperation
-from pathlib._abc import ParserBase, PurePathBase, PathBase
+from pathlib._abc import UnsupportedOperation, ParserBase, PurePathBase, PathBase
 import posixpath
 
 from test.support import is_wasi
@@ -1732,23 +1731,18 @@ class DummyPathTest(DummyPurePathTest):
         base = self.cls(self.base)
         source = base / 'fileA'
         target = base / 'copyA'
-        source.copy(target)
+        result = source.copy(target)
+        self.assertEqual(result, target)
         self.assertTrue(target.exists())
         self.assertEqual(source.read_text(), target.read_text())
 
-    def test_copy_directory(self):
-        base = self.cls(self.base)
-        source = base / 'dirA'
-        target = base / 'copyA'
-        with self.assertRaises(OSError):
-            source.copy(target)
-
     @needs_symlinks
     def test_copy_symlink_follow_symlinks_true(self):
         base = self.cls(self.base)
         source = base / 'linkA'
         target = base / 'copyA'
-        source.copy(target)
+        result = source.copy(target)
+        self.assertEqual(result, target)
         self.assertTrue(target.exists())
         self.assertFalse(target.is_symlink())
         self.assertEqual(source.read_text(), target.read_text())
@@ -1758,7 +1752,8 @@ class DummyPathTest(DummyPurePathTest):
         base = self.cls(self.base)
         source = base / 'linkA'
         target = base / 'copyA'
-        source.copy(target, follow_symlinks=False)
+        result = source.copy(target, follow_symlinks=False)
+        self.assertEqual(result, target)
         self.assertTrue(target.exists())
         self.assertTrue(target.is_symlink())
         self.assertEqual(source.readlink(), target.readlink())
@@ -1768,20 +1763,22 @@ class DummyPathTest(DummyPurePathTest):
         base = self.cls(self.base)
         source = base / 'linkB'
         target = base / 'copyA'
-        source.copy(target, follow_symlinks=False)
+        result = source.copy(target, follow_symlinks=False)
+        self.assertEqual(result, target)
         self.assertTrue(target.exists())
         self.assertTrue(target.is_symlink())
         self.assertEqual(source.readlink(), target.readlink())
 
-    def test_copy_to_existing_file(self):
+    def test_copy_file_to_existing_file(self):
         base = self.cls(self.base)
         source = base / 'fileA'
         target = base / 'dirB' / 'fileB'
-        source.copy(target)
+        result = source.copy(target)
+        self.assertEqual(result, target)
         self.assertTrue(target.exists())
         self.assertEqual(source.read_text(), target.read_text())
 
-    def test_copy_to_existing_directory(self):
+    def test_copy_file_to_existing_directory(self):
         base = self.cls(self.base)
         source = base / 'fileA'
         target = base / 'dirA'
@@ -1789,12 +1786,13 @@ class DummyPathTest(DummyPurePathTest):
             source.copy(target)
 
     @needs_symlinks
-    def test_copy_to_existing_symlink(self):
+    def test_copy_file_to_existing_symlink(self):
         base = self.cls(self.base)
         source = base / 'dirB' / 'fileB'
         target = base / 'linkA'
         real_target = base / 'fileA'
-        source.copy(target)
+        result = source.copy(target)
+        self.assertEqual(result, target)
         self.assertTrue(target.exists())
         self.assertTrue(target.is_symlink())
         self.assertTrue(real_target.exists())
@@ -1802,32 +1800,35 @@ class DummyPathTest(DummyPurePathTest):
         self.assertEqual(source.read_text(), real_target.read_text())
 
     @needs_symlinks
-    def test_copy_to_existing_symlink_follow_symlinks_false(self):
+    def test_copy_file_to_existing_symlink_follow_symlinks_false(self):
         base = self.cls(self.base)
         source = base / 'dirB' / 'fileB'
         target = base / 'linkA'
         real_target = base / 'fileA'
-        source.copy(target, follow_symlinks=False)
+        result = source.copy(target, follow_symlinks=False)
+        self.assertEqual(result, target)
         self.assertTrue(target.exists())
         self.assertTrue(target.is_symlink())
         self.assertTrue(real_target.exists())
         self.assertFalse(real_target.is_symlink())
         self.assertEqual(source.read_text(), real_target.read_text())
 
-    def test_copy_empty(self):
+    def test_copy_file_empty(self):
         base = self.cls(self.base)
         source = base / 'empty'
         target = base / 'copyA'
         source.write_bytes(b'')
-        source.copy(target)
+        result = source.copy(target)
+        self.assertEqual(result, target)
         self.assertTrue(target.exists())
         self.assertEqual(target.read_bytes(), b'')
 
-    def test_copytree_simple(self):
+    def test_copy_dir_simple(self):
         base = self.cls(self.base)
         source = base / 'dirC'
         target = base / 'copyC'
-        source.copytree(target)
+        result = source.copy(target)
+        self.assertEqual(result, target)
         self.assertTrue(target.is_dir())
         self.assertTrue(target.joinpath('dirD').is_dir())
         self.assertTrue(target.joinpath('dirD', 'fileD').is_file())
@@ -1837,7 +1838,7 @@ class DummyPathTest(DummyPurePathTest):
         self.assertTrue(target.joinpath('fileC').read_text(),
                         "this is file C\n")
 
-    def test_copytree_complex(self, follow_symlinks=True):
+    def test_copy_dir_complex(self, follow_symlinks=True):
         def ordered_walk(path):
             for dirpath, dirnames, filenames in path.walk(follow_symlinks=follow_symlinks):
                 dirnames.sort()
@@ -1853,7 +1854,8 @@ class DummyPathTest(DummyPurePathTest):
 
         # Perform the copy
         target = base / 'copyC'
-        source.copytree(target, follow_symlinks=follow_symlinks)
+        result = source.copy(target, follow_symlinks=follow_symlinks)
+        self.assertEqual(result, target)
 
         # Compare the source and target trees
         source_walk = ordered_walk(source)
@@ -1879,24 +1881,25 @@ class DummyPathTest(DummyPurePathTest):
                     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_copy_dir_complex_follow_symlinks_false(self):
+        self.test_copy_dir_complex(follow_symlinks=False)
 
-    def test_copytree_to_existing_directory(self):
+    def test_copy_dir_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)
+        self.assertRaises(FileExistsError, source.copy, target)
 
-    def test_copytree_to_existing_directory_dirs_exist_ok(self):
+    def test_copy_dir_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)
+        result = source.copy(target, dirs_exist_ok=True)
+        self.assertEqual(result, target)
         self.assertTrue(target.is_dir())
         self.assertTrue(target.joinpath('dirD').is_dir())
         self.assertTrue(target.joinpath('dirD', 'fileD').is_file())
@@ -1906,22 +1909,17 @@ class DummyPathTest(DummyPurePathTest):
         self.assertTrue(target.joinpath('fileC').read_text(),
                         "this is file C\n")
 
-    def test_copytree_file(self):
+    def test_copy_missing_on_error(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'
+        source = base / 'foo'
         target = base / 'copyA'
         errors = []
-        source.copytree(target, on_error=errors.append)
+        result = source.copy(target, on_error=errors.append)
+        self.assertEqual(result, target)
         self.assertEqual(len(errors), 1)
-        self.assertIsInstance(errors[0], NotADirectoryError)
+        self.assertIsInstance(errors[0], FileNotFoundError)
 
-    def test_copytree_ignore_false(self):
+    def test_copy_dir_ignore_false(self):
         base = self.cls(self.base)
         source = base / 'dirC'
         target = base / 'copyC'
@@ -1929,7 +1927,8 @@ class DummyPathTest(DummyPurePathTest):
         def ignore_false(path):
             ignores.append(path)
             return False
-        source.copytree(target, ignore=ignore_false)
+        result = source.copy(target, ignore=ignore_false)
+        self.assertEqual(result, target)
         self.assertEqual(set(ignores), {
             source / 'dirD',
             source / 'dirD' / 'fileD',
@@ -1945,7 +1944,7 @@ class DummyPathTest(DummyPurePathTest):
         self.assertTrue(target.joinpath('fileC').read_text(),
                         "this is file C\n")
 
-    def test_copytree_ignore_true(self):
+    def test_copy_dir_ignore_true(self):
         base = self.cls(self.base)
         source = base / 'dirC'
         target = base / 'copyC'
@@ -1953,7 +1952,8 @@ class DummyPathTest(DummyPurePathTest):
         def ignore_true(path):
             ignores.append(path)
             return True
-        source.copytree(target, ignore=ignore_true)
+        result = source.copy(target, ignore=ignore_true)
+        self.assertEqual(result, target)
         self.assertEqual(set(ignores), {
             source / 'dirD',
             source / 'fileC',
@@ -1965,7 +1965,7 @@ class DummyPathTest(DummyPurePathTest):
         self.assertFalse(target.joinpath('novel.txt').exists())
 
     @needs_symlinks
-    def test_copytree_dangling_symlink(self):
+    def test_copy_dangling_symlink(self):
         base = self.cls(self.base)
         source = base / 'source'
         target = base / 'target'
@@ -1973,10 +1973,11 @@ class DummyPathTest(DummyPurePathTest):
         source.mkdir()
         source.joinpath('link').symlink_to('nonexistent')
 
-        self.assertRaises(FileNotFoundError, source.copytree, target)
+        self.assertRaises(FileNotFoundError, source.copy, target)
 
         target2 = base / 'target2'
-        source.copytree(target2, follow_symlinks=False)
+        result = source.copy(target2, follow_symlinks=False)
+        self.assertEqual(result, target2)
         self.assertTrue(target2.joinpath('link').is_symlink())
         self.assertEqual(target2.joinpath('link').readlink(), self.cls('nonexistent'))
 
index c2953c65b2720f809dc467de7757504a4b731b91..d8e3bdf59ed092cb679d924cd994d9639a1ec0fb 100644 (file)
@@ -1,2 +1 @@
-Add :meth:`pathlib.Path.copy`, which copies the content of one file to another,
-like :func:`shutil.copyfile`.
+Add :meth:`pathlib.Path.copy`, which copies a file or directory to another.
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
deleted file mode 100644 (file)
index 60a1b68..0000000
+++ /dev/null
@@ -1 +0,0 @@
-Add :meth:`pathlib.Path.copytree`, which recursively copies a directory.