Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
GH-73991: Add pathlib.Path.copy() method.
Add a `Path.copy()` method that copies a file to a target file or directory
using `shutil.copy2()`.

In the private pathlib ABCs, we add a version that supports copying from
one instance of `PathBase` to another. We don't copy metadata, because
doing so probably requires new APIs that haven't been designed yet.
  • Loading branch information
barneygale committed May 14, 2024
commit 7128a7ac445166fabb9b68b38dcc6af15e6c1113
17 changes: 17 additions & 0 deletions Doc/library/pathlib.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1398,6 +1398,23 @@ call fails (for example because the path doesn't exist).
available. In previous versions, :exc:`NotImplementedError` was raised.


.. method:: Path.copy(target, *, follow_symlinks=True)

Copy this file and its metadata to the file or directory *target*. If
*target* specifies a directory, the file will be copied into *target* using
this file's :attr:`~PurePath.name`. If *target* specifies a file that
already exists, it will be replaced. Returns the path to the newly created
file.

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.

This method calls :func:`shutil.copy2` internally.

.. versionadded:: 3.14


.. method:: Path.rename(target)

Rename this file or directory to the given *target*, and return a new Path
Expand Down
7 changes: 7 additions & 0 deletions Doc/whatsnew/3.14.rst
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ New Modules
Improved Modules
================

pathlib
-------

* Add :meth:`pathlib.Path.copy`, which copies a file to a target file or
directory, like :func:`shutil.copy2`.
(Contributed by Barney Gale in :gh:`73991`.)


Optimizations
=============
Expand Down
35 changes: 35 additions & 0 deletions Lib/pathlib/_abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import functools
from glob import _Globber, _no_recurse_symlinks
from errno import ENOTDIR, ELOOP
from shutil import copyfileobj
from stat import S_ISDIR, S_ISLNK, S_ISREG, S_ISSOCK, S_ISBLK, S_ISCHR, S_ISFIFO


Expand Down Expand Up @@ -539,6 +540,14 @@ def samefile(self, other_path):
return (st.st_ino == other_st.st_ino and
st.st_dev == other_st.st_dev)

def _samefile_safe(self, other_path):
"""Like samefile(), but returns False rather than raising OSError.
"""
try:
return self.samefile(other_path)
except (OSError, ValueError):
return False

def open(self, mode='r', buffering=-1, encoding=None,
errors=None, newline=None):
"""
Expand Down Expand Up @@ -757,6 +766,32 @@ def mkdir(self, mode=0o777, parents=False, exist_ok=False):
"""
raise UnsupportedOperation(self._unsupported_msg('mkdir()'))

def copy(self, target, follow_symlinks=True):
"""
Copy this file and its metadata to the given target. Returns the path
of the new file.

If this file is a symlink and *follow_symlinks* is true (the default),
the symlink's target is copied. Otherwise, the symlink is recreated at
the target.
"""
if not isinstance(target, PathBase):
target = self.with_segments(target)
if target.is_dir():
target /= self.name
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 target

with self.open('rb') as f_source:
with target.open('wb') as f_target:
copyfileobj(f_source, f_target)

# FIXME: how do we copy metadata between PathBase instances?
return target

def rename(self, target):
"""
Rename this path to the target path.
Expand Down
17 changes: 17 additions & 0 deletions Lib/pathlib/_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import operator
import os
import posixpath
import shutil
import sys
import warnings
from glob import _StringGlobber
Expand Down Expand Up @@ -751,6 +752,22 @@ def mkdir(self, mode=0o777, parents=False, exist_ok=False):
if not exist_ok or not self.is_dir():
raise

def copy(self, target, follow_symlinks=True):
"""
Copy this file and its metadata to the given target. Returns the path
of the new file.

If this file is a symlink and *follow_symlinks* is true (the default),
the symlink's target is copied. Otherwise, the symlink is recreated at
the target.
"""
if not isinstance(target, PathBase):
target = self.with_segments(target)
if isinstance(target, Path):
target = shutil.copy2(self, target, follow_symlinks=follow_symlinks)
return self.with_segments(target)
return PathBase.copy(self, target, follow_symlinks=follow_symlinks)

def chmod(self, mode, *, follow_symlinks=True):
"""
Change the permissions of the path, like os.chmod().
Expand Down
67 changes: 67 additions & 0 deletions Lib/test/test_pathlib/test_pathlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,73 @@ def test_hardlink_to_unsupported(self):
with self.assertRaises(pathlib.UnsupportedOperation):
q.hardlink_to(p)

@unittest.skipUnless(hasattr(os, 'utime'), 'requires os.utime')
def test_copy(self):
base = self.cls(self.base)
source = base / 'fileA'
source_stat = source.stat()
target = source.copy(base / 'copyA')
target_stat = target.stat()
self.assertTrue(target.exists())
self.assertEqual(source_stat.st_mode, target_stat.st_mode)
self.assertEqual(source.read_text(), target.read_text())
for attr in 'st_atime', 'st_mtime':
# The modification times may be truncated in the new file.
self.assertLessEqual(getattr(source_stat, attr),
getattr(target_stat, attr) + 1)
if hasattr(os, 'chflags') and hasattr(source_stat, 'st_flags'):
self.assertEqual(getattr(source_stat, 'st_flags'),
getattr(target_stat, 'st_flags'))

@needs_symlinks
def test_copy_follow_symlinks_false(self):
base = self.cls(self.base)
source = base / 'linkA'
source_stat = source.stat()
source_lstat = source.lstat()
if hasattr(os, 'lchmod'):
os.lchmod(source, stat.S_IRWXU | stat.S_IRWXO)
if hasattr(os, 'lchflags') and hasattr(stat, 'UF_NODUMP'):
os.lchflags(source, stat.UF_NODUMP)
target = source.copy(base / 'copyA', follow_symlinks=False)
target_lstat = target.lstat()
self.assertTrue(target.exists())
self.assertTrue(target.is_symlink())
self.assertEqual(source.readlink(), target.readlink())
if os.utime in os.supports_follow_symlinks:
for attr in 'st_atime', 'st_mtime':
# The modification times may be truncated in the new file.
self.assertLessEqual(getattr(source_lstat, attr),
getattr(target_lstat, attr) + 1)
if hasattr(os, 'lchmod'):
self.assertEqual(source_lstat.st_mode, target_lstat.st_mode)
self.assertNotEqual(source_stat.st_mode, target_lstat.st_mode)
if hasattr(os, 'lchflags') and hasattr(source_lstat, 'st_flags'):
self.assertEqual(source_lstat.st_flags, target_lstat.st_flags)

@os_helper.skip_unless_xattr
def test_copy_xattr(self):
base = self.cls(self.base)
source = base / 'fileA'
os.setxattr(source, 'user.foo', b'42')
target = source.copy(base / 'copyA')
self.assertEqual(
os.getxattr(source, 'user.foo'),
os.getxattr(target, 'user.foo'))

def test_copy_dir(self):
base = self.cls(self.base)
source = base / 'dirA'
target = base / 'copyA'
if sys.platform == "win32":
err = PermissionError
else:
err = IsADirectoryError
with self.assertRaises(err):
source.copy(target)
with self.assertRaises(err):
source.copy(target / 'does_not_exist/')

def test_rename(self):
P = self.cls(self.base)
p = P / 'fileA'
Expand Down
41 changes: 41 additions & 0 deletions Lib/test/test_pathlib/test_pathlib_abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -1678,6 +1678,47 @@ def test_write_text_with_newlines(self):
self.assertEqual((p / 'fileA').read_bytes(),
b'abcde' + os_linesep_byte + b'fghlk' + os_linesep_byte + b'\rmnopq')

def test_copy(self):
base = self.cls(self.base)
source = base / 'fileA'
target = source.copy(base / 'copyA')
self.assertTrue(target.exists())
self.assertEqual(source.stat().st_mode, target.stat().st_mode)
self.assertEqual(source.read_text(), target.read_text())

@needs_symlinks
def test_copy_follow_symlinks_true(self):
base = self.cls(self.base)
source = base / 'linkA'
target = source.copy(base / 'copyA')
self.assertTrue(target.exists())
self.assertFalse(target.is_symlink())
self.assertEqual(source.read_text(), target.read_text())

@needs_symlinks
def test_copy_follow_symlinks_false(self):
base = self.cls(self.base)
source = base / 'linkA'
target = source.copy(base / 'copyA', follow_symlinks=False)
self.assertTrue(target.exists())
self.assertTrue(target.is_symlink())
self.assertEqual(source.readlink(), target.readlink())

def test_copy_return_value(self):
base = self.cls(self.base)
source = base / 'fileA'
self.assertEqual(source.copy(base / 'dirA'), base / 'dirA' / 'fileA')
self.assertEqual(source.copy(base / 'dirA' / 'copyA'), base / 'dirA' / 'copyA')

def test_copy_dir(self):
base = self.cls(self.base)
source = base / 'dirA'
target = base / 'copyA'
with self.assertRaises(OSError):
source.copy(target)
with self.assertRaises(OSError):
source.copy(target / 'does_not_exist/')

def test_iterdir(self):
P = self.cls
p = P(self.base)
Expand Down