GH-73991: Rework `pathlib.Path.copytree()` into `copy()` (#122369)

Rename `pathlib.Path.copy()` to `_copy_file()` (i.e. make it private.)

Rename `pathlib.Path.copytree()` to `copy()`, and add support for copying
non-directories. This simplifies the interface for users, and nicely
complements the upcoming `move()` and `delete()` methods (which will also
accept any type of file.)

Co-authored-by: Adam Turner <9087854+AA-Turner@users.noreply.github.com>
This commit is contained in:
Barney Gale 2024-08-11 22:43:18 +01:00 committed by GitHub
parent ea70439bd2
commit a6644d4464
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 141 additions and 197 deletions

View File

@ -1539,50 +1539,33 @@ Creating files and directories
Copying, renaming and deleting
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. method:: Path.copy(target, *, follow_symlinks=True, preserve_metadata=False)
.. method:: Path.copy(target, *, follow_symlinks=True, dirs_exist_ok=False, \
preserve_metadata=False, ignore=None, on_error=None)
Copy the contents of this file to the *target* file. If *target* specifies
a file that already exists, it will be replaced.
Copy this file or directory tree to the given *target*, and return a new
:class:`!Path` instance pointing to *target*.
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.
If the source is a file, the target will be replaced if it is an existing
file. If the source is a symlink and *follow_symlinks* is true (the
default), the symlink's target is copied. Otherwise, the symlink is
recreated at the destination.
If *preserve_metadata* is false (the default), only the file data is
guaranteed to be copied. Set *preserve_metadata* to true to ensure that the
file mode (permissions), flags, last access and modification times, and
extended attributes are copied where supported. This argument has no effect
on Windows, where metadata is always preserved when copying.
If the source is a directory and *dirs_exist_ok* is false (the default), a
:exc:`FileExistsError` is raised if the target is an existing directory.
If *dirs_exists_ok* is true, the copying operation will overwrite
existing files within the destination tree with corresponding files
from the source tree.
.. versionadded:: 3.14
.. method:: Path.copytree(target, *, follow_symlinks=True, \
preserve_metadata=False, dirs_exist_ok=False, \
ignore=None, on_error=None)
Recursively copy this directory tree to the given destination.
If a symlink is encountered in the source tree, and *follow_symlinks* is
true (the default), the symlink's target is copied. Otherwise, the symlink
is recreated in the destination tree.
If *preserve_metadata* is false (the default), only the directory structure
If *preserve_metadata* is false (the default), only directory structures
and file data are guaranteed to be copied. Set *preserve_metadata* to true
to ensure that file and directory permissions, flags, last access and
modification times, and extended attributes are copied where supported.
This argument has no effect on Windows, where metadata is always preserved
when copying.
If the destination is an existing directory and *dirs_exist_ok* is false
(the default), a :exc:`FileExistsError` is raised. Otherwise, the copying
operation will continue if it encounters existing directories, and files
within the destination tree will be overwritten by corresponding files from
the source tree.
This argument has no effect when copying files on Windows (where
metadata is always preserved).
If *ignore* is given, it should be a callable accepting one argument: a
file or directory path within the source tree. The callable may return true
to suppress copying of the path.
source file or directory path. The callable may return true to suppress
copying of the path.
If *on_error* is given, it should be a callable accepting one argument: an
instance of :exc:`OSError`. The callable may re-raise the exception or do

View File

@ -146,10 +146,8 @@ pathlib
* Add methods to :class:`pathlib.Path` to recursively copy or remove files:
* :meth:`~pathlib.Path.copy` copies the content of one file to another, like
:func:`shutil.copyfile`.
* :meth:`~pathlib.Path.copytree` copies one directory tree to another, like
:func:`shutil.copytree`.
* :meth:`~pathlib.Path.copy` copies a file or directory tree to a given
destination.
* :meth:`~pathlib.Path.delete` removes a file or directory tree.
(Contributed by Barney Gale in :gh:`73991`.)

View File

@ -5,8 +5,8 @@ paths with operations that have semantics appropriate for different
operating systems.
"""
from ._os import *
from ._local import *
from pathlib._abc import *
from pathlib._local import *
__all__ = (_os.__all__ +
__all__ = (_abc.__all__ +
_local.__all__)

View File

@ -16,7 +16,16 @@ import operator
import posixpath
from glob import _GlobberBase, _no_recurse_symlinks
from stat import S_ISDIR, S_ISLNK, S_ISREG, S_ISSOCK, S_ISBLK, S_ISCHR, S_ISFIFO
from ._os import UnsupportedOperation, copyfileobj
from pathlib._os import copyfileobj
__all__ = ["UnsupportedOperation"]
class UnsupportedOperation(NotImplementedError):
"""An exception that is raised when an unsupported operation is attempted.
"""
pass
@functools.cache
@ -761,6 +770,13 @@ class PathBase(PurePathBase):
"""
raise UnsupportedOperation(self._unsupported_msg('symlink_to()'))
def _symlink_to_target_of(self, link):
"""
Make this path a symlink with the same target as the given link. This
is used by copy().
"""
self.symlink_to(link.readlink())
def hardlink_to(self, target):
"""
Make this path a hard link pointing to the same file as *target*.
@ -806,21 +822,12 @@ class PathBase(PurePathBase):
metadata = self._read_metadata(keys, follow_symlinks=follow_symlinks)
target._write_metadata(metadata, follow_symlinks=follow_symlinks)
def copy(self, target, *, follow_symlinks=True, preserve_metadata=False):
def _copy_file(self, 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.
Copy the contents of this file to the given 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())
if preserve_metadata:
self._copy_metadata(target, follow_symlinks=False)
return
with self.open('rb') as source_f:
try:
with target.open('wb') as target_f:
@ -832,42 +839,39 @@ class PathBase(PurePathBase):
f'Directory does not exist: {target}') from e
else:
raise
if preserve_metadata:
self._copy_metadata(target)
def copytree(self, target, *, follow_symlinks=True,
preserve_metadata=False, dirs_exist_ok=False,
ignore=None, on_error=None):
def copy(self, target, *, follow_symlinks=True, dirs_exist_ok=False,
preserve_metadata=False, ignore=None, on_error=None):
"""
Recursively copy this directory tree to the given destination.
Recursively copy this file or directory tree to the given destination.
"""
if not isinstance(target, PathBase):
target = self.with_segments(target)
if on_error is None:
def on_error(err):
raise err
stack = [(self, target)]
while stack:
source_dir, target_dir = stack.pop()
src, dst = stack.pop()
try:
sources = source_dir.iterdir()
target_dir.mkdir(exist_ok=dirs_exist_ok)
if preserve_metadata:
source_dir._copy_metadata(target_dir)
for source in sources:
if ignore and ignore(source):
continue
try:
if source.is_dir(follow_symlinks=follow_symlinks):
stack.append((source, target_dir.joinpath(source.name)))
else:
source.copy(target_dir.joinpath(source.name),
follow_symlinks=follow_symlinks,
preserve_metadata=preserve_metadata)
except OSError as err:
on_error(err)
if not follow_symlinks and src.is_symlink():
dst._symlink_to_target_of(src)
if preserve_metadata:
src._copy_metadata(dst, follow_symlinks=False)
elif src.is_dir():
children = src.iterdir()
dst.mkdir(exist_ok=dirs_exist_ok)
for child in children:
if not (ignore and ignore(child)):
stack.append((child, dst.joinpath(child.name)))
if preserve_metadata:
src._copy_metadata(dst)
else:
src._copy_file(dst)
if preserve_metadata:
src._copy_metadata(dst)
except OSError as err:
if on_error is None:
raise
on_error(err)
return target
def rename(self, target):
"""

View File

@ -18,9 +18,9 @@ try:
except ImportError:
grp = None
from ._os import (UnsupportedOperation, copyfile, file_metadata_keys,
read_file_metadata, write_file_metadata)
from ._abc import PurePathBase, PathBase
from pathlib._os import (copyfile, file_metadata_keys, read_file_metadata,
write_file_metadata)
from pathlib._abc import UnsupportedOperation, PurePathBase, PathBase
__all__ = [
@ -788,25 +788,18 @@ class Path(PathBase, PurePath):
_write_metadata = write_file_metadata
if copyfile:
def copy(self, target, *, follow_symlinks=True, preserve_metadata=False):
def _copy_file(self, 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.
Copy the contents of this file to the given target.
"""
try:
target = os.fspath(target)
except TypeError:
if not isinstance(target, PathBase):
raise
PathBase._copy_file(self, target)
else:
try:
copyfile(os.fspath(self), target, follow_symlinks)
return
except UnsupportedOperation:
pass # Fall through to generic code.
PathBase.copy(self, target, follow_symlinks=follow_symlinks,
preserve_metadata=preserve_metadata)
copyfile(os.fspath(self), target)
def chmod(self, mode, *, follow_symlinks=True):
"""
@ -894,6 +887,14 @@ class Path(PathBase, PurePath):
"""
os.symlink(target, self, target_is_directory)
if os.name == 'nt':
def _symlink_to_target_of(self, link):
"""
Make this path a symlink with the same target as the given link.
This is used by copy().
"""
self.symlink_to(link.readlink(), link.is_dir())
if hasattr(os, "link"):
def hardlink_to(self, target):
"""

View File

@ -20,15 +20,6 @@ except ImportError:
_winapi = None
__all__ = ["UnsupportedOperation"]
class UnsupportedOperation(NotImplementedError):
"""An exception that is raised when an unsupported operation is attempted.
"""
pass
def get_copy_blocksize(infd):
"""Determine blocksize for fastcopying on Linux.
Hopefully the whole file will be copied in a single call.
@ -101,44 +92,12 @@ else:
copyfd = None
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):
if _winapi and hasattr(_winapi, 'CopyFile2'):
def copyfile(source, target):
"""
Copy from one file to another using CopyFile2 (Windows only).
"""
if follow_symlinks:
_winapi.CopyFile2(source, target, 0)
else:
# Use COPY_FILE_COPY_SYMLINK to copy a file symlink.
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 and _is_dirlink(source):
pass
else:
raise
# Add COPY_FILE_DIRECTORY to copy a directory symlink.
flags |= _winapi.COPY_FILE_DIRECTORY
try:
_winapi.CopyFile2(source, target, flags)
except OSError as err:
# Check for ERROR_INVALID_PARAMETER
if err.winerror == 87:
raise UnsupportedOperation(err) from None
else:
raise
_winapi.CopyFile2(source, target, 0)
else:
copyfile = None

View File

@ -709,19 +709,19 @@ class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest):
@unittest.skipIf(sys.platform == "win32" or sys.platform == "wasi", "directories are always readable on Windows and WASI")
@unittest.skipIf(root_in_posix, "test fails with root privilege")
def test_copytree_no_read_permission(self):
def test_copy_dir_no_read_permission(self):
base = self.cls(self.base)
source = base / 'dirE'
target = base / 'copyE'
self.assertRaises(PermissionError, source.copytree, target)
self.assertRaises(PermissionError, source.copy, target)
self.assertFalse(target.exists())
errors = []
source.copytree(target, on_error=errors.append)
source.copy(target, on_error=errors.append)
self.assertEqual(len(errors), 1)
self.assertIsInstance(errors[0], PermissionError)
self.assertFalse(target.exists())
def test_copytree_preserve_metadata(self):
def test_copy_dir_preserve_metadata(self):
base = self.cls(self.base)
source = base / 'dirC'
if hasattr(os, 'chmod'):
@ -729,7 +729,7 @@ class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest):
if hasattr(os, 'chflags') and hasattr(stat, 'UF_NODUMP'):
os.chflags(source / 'fileC', stat.UF_NODUMP)
target = base / 'copyA'
source.copytree(target, preserve_metadata=True)
source.copy(target, preserve_metadata=True)
for subpath in ['.', 'fileC', 'dirD', 'dirD/fileD']:
source_st = source.joinpath(subpath).stat()
@ -741,13 +741,13 @@ class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest):
self.assertEqual(source_st.st_flags, target_st.st_flags)
@os_helper.skip_unless_xattr
def test_copytree_preserve_metadata_xattrs(self):
def test_copy_dir_preserve_metadata_xattrs(self):
base = self.cls(self.base)
source = base / 'dirC'
source_file = source.joinpath('dirD', 'fileD')
os.setxattr(source_file, b'user.foo', b'42')
target = base / 'copyA'
source.copytree(target, preserve_metadata=True)
source.copy(target, preserve_metadata=True)
target_file = target.joinpath('dirD', 'fileD')
self.assertEqual(os.getxattr(target_file, b'user.foo'), b'42')

View File

@ -5,8 +5,7 @@ import errno
import stat
import unittest
from pathlib._os import UnsupportedOperation
from pathlib._abc import ParserBase, PurePathBase, PathBase
from pathlib._abc import UnsupportedOperation, ParserBase, PurePathBase, PathBase
import posixpath
from test.support import is_wasi
@ -1732,23 +1731,18 @@ class DummyPathTest(DummyPurePathTest):
base = self.cls(self.base)
source = base / 'fileA'
target = base / 'copyA'
source.copy(target)
result = source.copy(target)
self.assertEqual(result, target)
self.assertTrue(target.exists())
self.assertEqual(source.read_text(), target.read_text())
def test_copy_directory(self):
base = self.cls(self.base)
source = base / 'dirA'
target = base / 'copyA'
with self.assertRaises(OSError):
source.copy(target)
@needs_symlinks
def test_copy_symlink_follow_symlinks_true(self):
base = self.cls(self.base)
source = base / 'linkA'
target = base / 'copyA'
source.copy(target)
result = source.copy(target)
self.assertEqual(result, target)
self.assertTrue(target.exists())
self.assertFalse(target.is_symlink())
self.assertEqual(source.read_text(), target.read_text())
@ -1758,7 +1752,8 @@ class DummyPathTest(DummyPurePathTest):
base = self.cls(self.base)
source = base / 'linkA'
target = base / 'copyA'
source.copy(target, follow_symlinks=False)
result = source.copy(target, follow_symlinks=False)
self.assertEqual(result, target)
self.assertTrue(target.exists())
self.assertTrue(target.is_symlink())
self.assertEqual(source.readlink(), target.readlink())
@ -1768,20 +1763,22 @@ class DummyPathTest(DummyPurePathTest):
base = self.cls(self.base)
source = base / 'linkB'
target = base / 'copyA'
source.copy(target, follow_symlinks=False)
result = source.copy(target, follow_symlinks=False)
self.assertEqual(result, target)
self.assertTrue(target.exists())
self.assertTrue(target.is_symlink())
self.assertEqual(source.readlink(), target.readlink())
def test_copy_to_existing_file(self):
def test_copy_file_to_existing_file(self):
base = self.cls(self.base)
source = base / 'fileA'
target = base / 'dirB' / 'fileB'
source.copy(target)
result = source.copy(target)
self.assertEqual(result, target)
self.assertTrue(target.exists())
self.assertEqual(source.read_text(), target.read_text())
def test_copy_to_existing_directory(self):
def test_copy_file_to_existing_directory(self):
base = self.cls(self.base)
source = base / 'fileA'
target = base / 'dirA'
@ -1789,12 +1786,13 @@ class DummyPathTest(DummyPurePathTest):
source.copy(target)
@needs_symlinks
def test_copy_to_existing_symlink(self):
def test_copy_file_to_existing_symlink(self):
base = self.cls(self.base)
source = base / 'dirB' / 'fileB'
target = base / 'linkA'
real_target = base / 'fileA'
source.copy(target)
result = source.copy(target)
self.assertEqual(result, target)
self.assertTrue(target.exists())
self.assertTrue(target.is_symlink())
self.assertTrue(real_target.exists())
@ -1802,32 +1800,35 @@ class DummyPathTest(DummyPurePathTest):
self.assertEqual(source.read_text(), real_target.read_text())
@needs_symlinks
def test_copy_to_existing_symlink_follow_symlinks_false(self):
def test_copy_file_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)
result = source.copy(target, follow_symlinks=False)
self.assertEqual(result, target)
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):
def test_copy_file_empty(self):
base = self.cls(self.base)
source = base / 'empty'
target = base / 'copyA'
source.write_bytes(b'')
source.copy(target)
result = source.copy(target)
self.assertEqual(result, target)
self.assertTrue(target.exists())
self.assertEqual(target.read_bytes(), b'')
def test_copytree_simple(self):
def test_copy_dir_simple(self):
base = self.cls(self.base)
source = base / 'dirC'
target = base / 'copyC'
source.copytree(target)
result = source.copy(target)
self.assertEqual(result, target)
self.assertTrue(target.is_dir())
self.assertTrue(target.joinpath('dirD').is_dir())
self.assertTrue(target.joinpath('dirD', 'fileD').is_file())
@ -1837,7 +1838,7 @@ class DummyPathTest(DummyPurePathTest):
self.assertTrue(target.joinpath('fileC').read_text(),
"this is file C\n")
def test_copytree_complex(self, follow_symlinks=True):
def test_copy_dir_complex(self, follow_symlinks=True):
def ordered_walk(path):
for dirpath, dirnames, filenames in path.walk(follow_symlinks=follow_symlinks):
dirnames.sort()
@ -1853,7 +1854,8 @@ class DummyPathTest(DummyPurePathTest):
# Perform the copy
target = base / 'copyC'
source.copytree(target, follow_symlinks=follow_symlinks)
result = source.copy(target, follow_symlinks=follow_symlinks)
self.assertEqual(result, target)
# Compare the source and target trees
source_walk = ordered_walk(source)
@ -1879,24 +1881,25 @@ class DummyPathTest(DummyPurePathTest):
self.assertEqual(source_file.read_bytes(), target_file.read_bytes())
self.assertEqual(source_file.readlink(), target_file.readlink())
def test_copytree_complex_follow_symlinks_false(self):
self.test_copytree_complex(follow_symlinks=False)
def test_copy_dir_complex_follow_symlinks_false(self):
self.test_copy_dir_complex(follow_symlinks=False)
def test_copytree_to_existing_directory(self):
def test_copy_dir_to_existing_directory(self):
base = self.cls(self.base)
source = base / 'dirC'
target = base / 'copyC'
target.mkdir()
target.joinpath('dirD').mkdir()
self.assertRaises(FileExistsError, source.copytree, target)
self.assertRaises(FileExistsError, source.copy, target)
def test_copytree_to_existing_directory_dirs_exist_ok(self):
def test_copy_dir_to_existing_directory_dirs_exist_ok(self):
base = self.cls(self.base)
source = base / 'dirC'
target = base / 'copyC'
target.mkdir()
target.joinpath('dirD').mkdir()
source.copytree(target, dirs_exist_ok=True)
result = source.copy(target, dirs_exist_ok=True)
self.assertEqual(result, target)
self.assertTrue(target.is_dir())
self.assertTrue(target.joinpath('dirD').is_dir())
self.assertTrue(target.joinpath('dirD', 'fileD').is_file())
@ -1906,22 +1909,17 @@ class DummyPathTest(DummyPurePathTest):
self.assertTrue(target.joinpath('fileC').read_text(),
"this is file C\n")
def test_copytree_file(self):
def test_copy_missing_on_error(self):
base = self.cls(self.base)
source = base / 'fileA'
target = base / 'copyA'
self.assertRaises(NotADirectoryError, source.copytree, target)
def test_copytree_file_on_error(self):
base = self.cls(self.base)
source = base / 'fileA'
source = base / 'foo'
target = base / 'copyA'
errors = []
source.copytree(target, on_error=errors.append)
result = source.copy(target, on_error=errors.append)
self.assertEqual(result, target)
self.assertEqual(len(errors), 1)
self.assertIsInstance(errors[0], NotADirectoryError)
self.assertIsInstance(errors[0], FileNotFoundError)
def test_copytree_ignore_false(self):
def test_copy_dir_ignore_false(self):
base = self.cls(self.base)
source = base / 'dirC'
target = base / 'copyC'
@ -1929,7 +1927,8 @@ class DummyPathTest(DummyPurePathTest):
def ignore_false(path):
ignores.append(path)
return False
source.copytree(target, ignore=ignore_false)
result = source.copy(target, ignore=ignore_false)
self.assertEqual(result, target)
self.assertEqual(set(ignores), {
source / 'dirD',
source / 'dirD' / 'fileD',
@ -1945,7 +1944,7 @@ class DummyPathTest(DummyPurePathTest):
self.assertTrue(target.joinpath('fileC').read_text(),
"this is file C\n")
def test_copytree_ignore_true(self):
def test_copy_dir_ignore_true(self):
base = self.cls(self.base)
source = base / 'dirC'
target = base / 'copyC'
@ -1953,7 +1952,8 @@ class DummyPathTest(DummyPurePathTest):
def ignore_true(path):
ignores.append(path)
return True
source.copytree(target, ignore=ignore_true)
result = source.copy(target, ignore=ignore_true)
self.assertEqual(result, target)
self.assertEqual(set(ignores), {
source / 'dirD',
source / 'fileC',
@ -1965,7 +1965,7 @@ class DummyPathTest(DummyPurePathTest):
self.assertFalse(target.joinpath('novel.txt').exists())
@needs_symlinks
def test_copytree_dangling_symlink(self):
def test_copy_dangling_symlink(self):
base = self.cls(self.base)
source = base / 'source'
target = base / 'target'
@ -1973,10 +1973,11 @@ class DummyPathTest(DummyPurePathTest):
source.mkdir()
source.joinpath('link').symlink_to('nonexistent')
self.assertRaises(FileNotFoundError, source.copytree, target)
self.assertRaises(FileNotFoundError, source.copy, target)
target2 = base / 'target2'
source.copytree(target2, follow_symlinks=False)
result = source.copy(target2, follow_symlinks=False)
self.assertEqual(result, target2)
self.assertTrue(target2.joinpath('link').is_symlink())
self.assertEqual(target2.joinpath('link').readlink(), self.cls('nonexistent'))

View File

@ -1,2 +1 @@
Add :meth:`pathlib.Path.copy`, which copies the content of one file to another,
like :func:`shutil.copyfile`.
Add :meth:`pathlib.Path.copy`, which copies a file or directory to another.

View File

@ -1 +0,0 @@
Add :meth:`pathlib.Path.copytree`, which recursively copies a directory.