]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
GH-73991: Add follow_symlinks argument to `pathlib.Path.copy()` (#120519)
authorBarney Gale <barney.gale@gmail.com>
Wed, 19 Jun 2024 00:59:54 +0000 (01:59 +0100)
committerGitHub <noreply@github.com>
Wed, 19 Jun 2024 00:59:54 +0000 (00:59 +0000)
Add support for not following symlinks in `pathlib.Path.copy()`.

On Windows we add the `COPY_FILE_COPY_SYMLINK` flag is following symlinks is disabled. If the source is symlink to a directory, this call will fail with `ERROR_ACCESS_DENIED`. In this case we add `COPY_FILE_DIRECTORY` to the flags and retry. This can fail on old Windowses, which we note in the docs.

No news as `copy()` was only just added.

Doc/library/pathlib.rst
Lib/pathlib/_abc.py
Lib/pathlib/_local.py
Lib/pathlib/_os.py
Lib/test/test_pathlib/test_pathlib_abc.py
Modules/_winapi.c

index c8a3272d7bab4c71b5b587104706b2c839816f4e..5bfcad0dadff6a7828c076e74cabd6387e1c0bc7 100644 (file)
@@ -1432,17 +1432,26 @@ Creating files and directories
 Copying, renaming and deleting
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
-.. method:: Path.copy(target)
+.. method:: Path.copy(target, *, follow_symlinks=True)
 
    Copy the contents of this file to the *target* file. If *target* specifies
    a file that already exists, it will be replaced.
 
+   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.
+
    .. note::
       This method uses operating system functionality to copy file content
       efficiently. The OS might also copy some metadata, such as file
       permissions. After the copy is complete, users may wish to call
       :meth:`Path.chmod` to set the permissions of the target file.
 
+   .. warning::
+      On old builds of Windows (before Windows 10 build 19041), this method
+      raises :exc:`OSError` when a symlink to a directory is encountered and
+      *follow_symlinks* is false.
+
    .. versionadded:: 3.14
 
 
index 586145ead384ea6e1d25408bbb30b97f47b0abc5..f1f350a196091a9e7ab971d0a32e0ced1a46d80a 100644 (file)
@@ -790,14 +790,19 @@ class PathBase(PurePathBase):
         """
         raise UnsupportedOperation(self._unsupported_msg('mkdir()'))
 
-    def copy(self, target):
+    def copy(self, target, follow_symlinks=True):
         """
-        Copy the contents of this file to the given 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.
         """
         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())
+            return
         with self.open('rb') as source_f:
             try:
                 with target.open('wb') as target_f:
index cffed10dbd12076cec4ca5030c6f94a10d7c0ffc..0105ea3042422e5453eb07938e56fea6552eaf5f 100644 (file)
@@ -782,9 +782,11 @@ class Path(PathBase, PurePath):
                 raise
 
     if copyfile:
-        def copy(self, target):
+        def copy(self, target, follow_symlinks=True):
             """
-            Copy the contents of this file to the given 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.
             """
             try:
                 target = os.fspath(target)
@@ -792,9 +794,9 @@ class Path(PathBase, PurePath):
                 if isinstance(target, PathBase):
                     # Target is an instance of PathBase but not os.PathLike.
                     # Use generic implementation from PathBase.
-                    return PathBase.copy(self, target)
+                    return PathBase.copy(self, target, follow_symlinks=follow_symlinks)
                 raise
-            copyfile(os.fspath(self), target)
+            copyfile(os.fspath(self), target, follow_symlinks)
 
     def chmod(self, mode, *, follow_symlinks=True):
         """
index 1771d54e4167c17a904d6e643b5b93b25e0dfd8a..bbb019b65345035a7314568baf85937a1c752bf3 100644 (file)
@@ -4,6 +4,7 @@ Low-level OS functionality wrappers used by pathlib.
 
 from errno import EBADF, EOPNOTSUPP, ETXTBSY, EXDEV
 import os
+import stat
 import sys
 try:
     import fcntl
@@ -91,12 +92,32 @@ else:
     copyfd = None
 
 
-if _winapi and hasattr(_winapi, 'CopyFile2'):
-    def copyfile(source, target):
+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):
         """
         Copy from one file to another using CopyFile2 (Windows only).
         """
-        _winapi.CopyFile2(source, target, 0)
+        if follow_symlinks:
+            flags = 0
+        else:
+            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 or not _is_dirlink(source):
+                    raise
+            flags |= _winapi.COPY_FILE_DIRECTORY
+        _winapi.CopyFile2(source, target, flags)
 else:
     copyfile = None
 
index 1c959056c16ca3fc2a6d0874fd64aef3bc7cdc9d..cd629c01871165e0a4355d2a54f3b5133432b151 100644 (file)
@@ -1743,7 +1743,7 @@ class DummyPathTest(DummyPurePathTest):
             source.copy(target)
 
     @needs_symlinks
-    def test_copy_symlink(self):
+    def test_copy_symlink_follow_symlinks_true(self):
         base = self.cls(self.base)
         source = base / 'linkA'
         target = base / 'copyA'
@@ -1752,6 +1752,26 @@ class DummyPathTest(DummyPurePathTest):
         self.assertFalse(target.is_symlink())
         self.assertEqual(source.read_text(), target.read_text())
 
+    @needs_symlinks
+    def test_copy_symlink_follow_symlinks_false(self):
+        base = self.cls(self.base)
+        source = base / 'linkA'
+        target = base / 'copyA'
+        source.copy(target, follow_symlinks=False)
+        self.assertTrue(target.exists())
+        self.assertTrue(target.is_symlink())
+        self.assertEqual(source.readlink(), target.readlink())
+
+    @needs_symlinks
+    def test_copy_directory_symlink_follow_symlinks_false(self):
+        base = self.cls(self.base)
+        source = base / 'linkB'
+        target = base / 'copyA'
+        source.copy(target, follow_symlinks=False)
+        self.assertTrue(target.exists())
+        self.assertTrue(target.is_symlink())
+        self.assertEqual(source.readlink(), target.readlink())
+
     def test_copy_to_existing_file(self):
         base = self.cls(self.base)
         source = base / 'fileA'
@@ -1780,6 +1800,19 @@ class DummyPathTest(DummyPurePathTest):
         self.assertFalse(real_target.is_symlink())
         self.assertEqual(source.read_text(), real_target.read_text())
 
+    @needs_symlinks
+    def test_copy_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)
+        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):
         base = self.cls(self.base)
         source = base / 'empty'
index 8794d568e92a367ec18e55324312ebfaf35a9731..c90d6c5a9ef3ef3ba10195359bd752f09bff1dd6 100644 (file)
@@ -3166,6 +3166,11 @@ static int winapi_exec(PyObject *m)
     #define COPY_FILE_REQUEST_COMPRESSED_TRAFFIC 0x10000000
 #endif
     WINAPI_CONSTANT(F_DWORD, COPY_FILE_REQUEST_COMPRESSED_TRAFFIC);
+#ifndef COPY_FILE_DIRECTORY
+    // Only defined in newer WinSDKs
+    #define COPY_FILE_DIRECTORY 0x00000080
+#endif
+    WINAPI_CONSTANT(F_DWORD, COPY_FILE_DIRECTORY);
 
     WINAPI_CONSTANT(F_DWORD, COPYFILE2_CALLBACK_CHUNK_STARTED);
     WINAPI_CONSTANT(F_DWORD, COPYFILE2_CALLBACK_CHUNK_FINISHED);