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.
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
"""
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:
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)
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):
"""
from errno import EBADF, EOPNOTSUPP, ETXTBSY, EXDEV
import os
+import stat
import sys
try:
import fcntl
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
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'
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'
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'
#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);