Skip to content

Commit 20d5b84

Browse files
authored
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.
1 parent 9f741e5 commit 20d5b84

File tree

6 files changed

+86
-11
lines changed

6 files changed

+86
-11
lines changed

Doc/library/pathlib.rst

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1432,17 +1432,26 @@ Creating files and directories
14321432
Copying, renaming and deleting
14331433
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
14341434

1435-
.. method:: Path.copy(target)
1435+
.. method:: Path.copy(target, *, follow_symlinks=True)
14361436

14371437
Copy the contents of this file to the *target* file. If *target* specifies
14381438
a file that already exists, it will be replaced.
14391439

1440+
If *follow_symlinks* is false, and this file is a symbolic link, *target*
1441+
will be created as a symbolic link. If *follow_symlinks* is true and this
1442+
file is a symbolic link, *target* will be a copy of the symlink target.
1443+
14401444
.. note::
14411445
This method uses operating system functionality to copy file content
14421446
efficiently. The OS might also copy some metadata, such as file
14431447
permissions. After the copy is complete, users may wish to call
14441448
:meth:`Path.chmod` to set the permissions of the target file.
14451449

1450+
.. warning::
1451+
On old builds of Windows (before Windows 10 build 19041), this method
1452+
raises :exc:`OSError` when a symlink to a directory is encountered and
1453+
*follow_symlinks* is false.
1454+
14461455
.. versionadded:: 3.14
14471456

14481457

Lib/pathlib/_abc.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -790,14 +790,19 @@ def mkdir(self, mode=0o777, parents=False, exist_ok=False):
790790
"""
791791
raise UnsupportedOperation(self._unsupported_msg('mkdir()'))
792792

793-
def copy(self, target):
793+
def copy(self, target, follow_symlinks=True):
794794
"""
795-
Copy the contents of this file to the given target.
795+
Copy the contents of this file to the given target. If this file is a
796+
symlink and follow_symlinks is false, a symlink will be created at the
797+
target.
796798
"""
797799
if not isinstance(target, PathBase):
798800
target = self.with_segments(target)
799801
if self._samefile_safe(target):
800802
raise OSError(f"{self!r} and {target!r} are the same file")
803+
if not follow_symlinks and self.is_symlink():
804+
target.symlink_to(self.readlink())
805+
return
801806
with self.open('rb') as source_f:
802807
try:
803808
with target.open('wb') as target_f:

Lib/pathlib/_local.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -782,19 +782,21 @@ def mkdir(self, mode=0o777, parents=False, exist_ok=False):
782782
raise
783783

784784
if copyfile:
785-
def copy(self, target):
785+
def copy(self, target, follow_symlinks=True):
786786
"""
787-
Copy the contents of this file to the given target.
787+
Copy the contents of this file to the given target. If this file is a
788+
symlink and follow_symlinks is false, a symlink will be created at the
789+
target.
788790
"""
789791
try:
790792
target = os.fspath(target)
791793
except TypeError:
792794
if isinstance(target, PathBase):
793795
# Target is an instance of PathBase but not os.PathLike.
794796
# Use generic implementation from PathBase.
795-
return PathBase.copy(self, target)
797+
return PathBase.copy(self, target, follow_symlinks=follow_symlinks)
796798
raise
797-
copyfile(os.fspath(self), target)
799+
copyfile(os.fspath(self), target, follow_symlinks)
798800

799801
def chmod(self, mode, *, follow_symlinks=True):
800802
"""

Lib/pathlib/_os.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from errno import EBADF, EOPNOTSUPP, ETXTBSY, EXDEV
66
import os
7+
import stat
78
import sys
89
try:
910
import fcntl
@@ -91,12 +92,32 @@ def copyfd(source_fd, target_fd):
9192
copyfd = None
9293

9394

94-
if _winapi and hasattr(_winapi, 'CopyFile2'):
95-
def copyfile(source, target):
95+
if _winapi and hasattr(_winapi, 'CopyFile2') and hasattr(os.stat_result, 'st_file_attributes'):
96+
def _is_dirlink(path):
97+
try:
98+
st = os.lstat(path)
99+
except (OSError, ValueError):
100+
return False
101+
return (st.st_file_attributes & stat.FILE_ATTRIBUTE_DIRECTORY and
102+
st.st_reparse_tag == stat.IO_REPARSE_TAG_SYMLINK)
103+
104+
def copyfile(source, target, follow_symlinks):
96105
"""
97106
Copy from one file to another using CopyFile2 (Windows only).
98107
"""
99-
_winapi.CopyFile2(source, target, 0)
108+
if follow_symlinks:
109+
flags = 0
110+
else:
111+
flags = _winapi.COPY_FILE_COPY_SYMLINK
112+
try:
113+
_winapi.CopyFile2(source, target, flags)
114+
return
115+
except OSError as err:
116+
# Check for ERROR_ACCESS_DENIED
117+
if err.winerror != 5 or not _is_dirlink(source):
118+
raise
119+
flags |= _winapi.COPY_FILE_DIRECTORY
120+
_winapi.CopyFile2(source, target, flags)
100121
else:
101122
copyfile = None
102123

Lib/test/test_pathlib/test_pathlib_abc.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1743,7 +1743,7 @@ def test_copy_directory(self):
17431743
source.copy(target)
17441744

17451745
@needs_symlinks
1746-
def test_copy_symlink(self):
1746+
def test_copy_symlink_follow_symlinks_true(self):
17471747
base = self.cls(self.base)
17481748
source = base / 'linkA'
17491749
target = base / 'copyA'
@@ -1752,6 +1752,26 @@ def test_copy_symlink(self):
17521752
self.assertFalse(target.is_symlink())
17531753
self.assertEqual(source.read_text(), target.read_text())
17541754

1755+
@needs_symlinks
1756+
def test_copy_symlink_follow_symlinks_false(self):
1757+
base = self.cls(self.base)
1758+
source = base / 'linkA'
1759+
target = base / 'copyA'
1760+
source.copy(target, follow_symlinks=False)
1761+
self.assertTrue(target.exists())
1762+
self.assertTrue(target.is_symlink())
1763+
self.assertEqual(source.readlink(), target.readlink())
1764+
1765+
@needs_symlinks
1766+
def test_copy_directory_symlink_follow_symlinks_false(self):
1767+
base = self.cls(self.base)
1768+
source = base / 'linkB'
1769+
target = base / 'copyA'
1770+
source.copy(target, follow_symlinks=False)
1771+
self.assertTrue(target.exists())
1772+
self.assertTrue(target.is_symlink())
1773+
self.assertEqual(source.readlink(), target.readlink())
1774+
17551775
def test_copy_to_existing_file(self):
17561776
base = self.cls(self.base)
17571777
source = base / 'fileA'
@@ -1780,6 +1800,19 @@ def test_copy_to_existing_symlink(self):
17801800
self.assertFalse(real_target.is_symlink())
17811801
self.assertEqual(source.read_text(), real_target.read_text())
17821802

1803+
@needs_symlinks
1804+
def test_copy_to_existing_symlink_follow_symlinks_false(self):
1805+
base = self.cls(self.base)
1806+
source = base / 'dirB' / 'fileB'
1807+
target = base / 'linkA'
1808+
real_target = base / 'fileA'
1809+
source.copy(target, follow_symlinks=False)
1810+
self.assertTrue(target.exists())
1811+
self.assertTrue(target.is_symlink())
1812+
self.assertTrue(real_target.exists())
1813+
self.assertFalse(real_target.is_symlink())
1814+
self.assertEqual(source.read_text(), real_target.read_text())
1815+
17831816
def test_copy_empty(self):
17841817
base = self.cls(self.base)
17851818
source = base / 'empty'

Modules/_winapi.c

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3166,6 +3166,11 @@ static int winapi_exec(PyObject *m)
31663166
#define COPY_FILE_REQUEST_COMPRESSED_TRAFFIC 0x10000000
31673167
#endif
31683168
WINAPI_CONSTANT(F_DWORD, COPY_FILE_REQUEST_COMPRESSED_TRAFFIC);
3169+
#ifndef COPY_FILE_DIRECTORY
3170+
// Only defined in newer WinSDKs
3171+
#define COPY_FILE_DIRECTORY 0x00000080
3172+
#endif
3173+
WINAPI_CONSTANT(F_DWORD, COPY_FILE_DIRECTORY);
31693174

31703175
WINAPI_CONSTANT(F_DWORD, COPYFILE2_CALLBACK_CHUNK_STARTED);
31713176
WINAPI_CONSTANT(F_DWORD, COPYFILE2_CALLBACK_CHUNK_FINISHED);

0 commit comments

Comments
 (0)