GH-73991: Add follow_symlinks argument to `pathlib.Path.copy()` (#120519)

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.
This commit is contained in:
Barney Gale 2024-06-19 01:59:54 +01:00 committed by GitHub
parent 9f741e55c1
commit 20d5b84f57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 86 additions and 11 deletions

View 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

View 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:

View 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):
"""

View 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

View 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'

View 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);