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 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 Copy this file or directory tree to the given *target*, and return a new
a file that already exists, it will be replaced. :class:`!Path` instance pointing to *target*.
If *follow_symlinks* is false, and this file is a symbolic link, *target* If the source is a file, the target will be replaced if it is an existing
will be created as a symbolic link. If *follow_symlinks* is true and this file. If the source is a symlink and *follow_symlinks* is true (the
file is a symbolic link, *target* will be a copy of the symlink target. 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 If the source is a directory and *dirs_exist_ok* is false (the default), a
guaranteed to be copied. Set *preserve_metadata* to true to ensure that the :exc:`FileExistsError` is raised if the target is an existing directory.
file mode (permissions), flags, last access and modification times, and If *dirs_exists_ok* is true, the copying operation will overwrite
extended attributes are copied where supported. This argument has no effect existing files within the destination tree with corresponding files
on Windows, where metadata is always preserved when copying. from the source tree.
.. versionadded:: 3.14 If *preserve_metadata* is false (the default), only directory structures
.. 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
and file data are guaranteed to be copied. Set *preserve_metadata* to true and file data are guaranteed to be copied. Set *preserve_metadata* to true
to ensure that file and directory permissions, flags, last access and to ensure that file and directory permissions, flags, last access and
modification times, and extended attributes are copied where supported. modification times, and extended attributes are copied where supported.
This argument has no effect on Windows, where metadata is always preserved This argument has no effect when copying files on Windows (where
when copying. metadata is always preserved).
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.
If *ignore* is given, it should be a callable accepting one argument: a 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 source file or directory path. The callable may return true to suppress
to suppress copying of the path. copying of the path.
If *on_error* is given, it should be a callable accepting one argument: an 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 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: * 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 * :meth:`~pathlib.Path.copy` copies a file or directory tree to a given
:func:`shutil.copyfile`. destination.
* :meth:`~pathlib.Path.copytree` copies one directory tree to another, like
:func:`shutil.copytree`.
* :meth:`~pathlib.Path.delete` removes a file or directory tree. * :meth:`~pathlib.Path.delete` removes a file or directory tree.
(Contributed by Barney Gale in :gh:`73991`.) (Contributed by Barney Gale in :gh:`73991`.)

View file

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

View file

@ -16,7 +16,16 @@ import operator
import posixpath import posixpath
from glob import _GlobberBase, _no_recurse_symlinks 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 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 @functools.cache
@ -761,6 +770,13 @@ class PathBase(PurePathBase):
""" """
raise UnsupportedOperation(self._unsupported_msg('symlink_to()')) 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): def hardlink_to(self, target):
""" """
Make this path a hard link pointing to the same file as *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) metadata = self._read_metadata(keys, follow_symlinks=follow_symlinks)
target._write_metadata(metadata, 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 Copy the contents of this file to the given target.
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): if self._samefile_safe(target):
raise OSError(f"{self!r} and {target!r} are the same file") 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: with self.open('rb') as source_f:
try: try:
with target.open('wb') as target_f: with target.open('wb') as target_f:
@ -832,42 +839,39 @@ class PathBase(PurePathBase):
f'Directory does not exist: {target}') from e f'Directory does not exist: {target}') from e
else: else:
raise raise
if preserve_metadata:
self._copy_metadata(target)
def copytree(self, target, *, follow_symlinks=True, def copy(self, target, *, follow_symlinks=True, dirs_exist_ok=False,
preserve_metadata=False, dirs_exist_ok=False, preserve_metadata=False, ignore=None, on_error=None):
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): if not isinstance(target, PathBase):
target = self.with_segments(target) target = self.with_segments(target)
if on_error is None:
def on_error(err):
raise err
stack = [(self, target)] stack = [(self, target)]
while stack: while stack:
source_dir, target_dir = stack.pop() src, dst = stack.pop()
try: try:
sources = source_dir.iterdir() if not follow_symlinks and src.is_symlink():
target_dir.mkdir(exist_ok=dirs_exist_ok) dst._symlink_to_target_of(src)
if preserve_metadata: if preserve_metadata:
source_dir._copy_metadata(target_dir) src._copy_metadata(dst, follow_symlinks=False)
for source in sources: elif src.is_dir():
if ignore and ignore(source): children = src.iterdir()
continue dst.mkdir(exist_ok=dirs_exist_ok)
try: for child in children:
if source.is_dir(follow_symlinks=follow_symlinks): if not (ignore and ignore(child)):
stack.append((source, target_dir.joinpath(source.name))) stack.append((child, dst.joinpath(child.name)))
else: if preserve_metadata:
source.copy(target_dir.joinpath(source.name), src._copy_metadata(dst)
follow_symlinks=follow_symlinks, else:
preserve_metadata=preserve_metadata) src._copy_file(dst)
except OSError as err: if preserve_metadata:
on_error(err) src._copy_metadata(dst)
except OSError as err: except OSError as err:
if on_error is None:
raise
on_error(err) on_error(err)
return target
def rename(self, target): def rename(self, target):
""" """

View file

@ -18,9 +18,9 @@ try:
except ImportError: except ImportError:
grp = None grp = None
from ._os import (UnsupportedOperation, copyfile, file_metadata_keys, from pathlib._os import (copyfile, file_metadata_keys, read_file_metadata,
read_file_metadata, write_file_metadata) write_file_metadata)
from ._abc import PurePathBase, PathBase from pathlib._abc import UnsupportedOperation, PurePathBase, PathBase
__all__ = [ __all__ = [
@ -788,25 +788,18 @@ class Path(PathBase, PurePath):
_write_metadata = write_file_metadata _write_metadata = write_file_metadata
if copyfile: 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 Copy the contents of this file to the given target.
symlink and follow_symlinks is false, a symlink will be created at the
target.
""" """
try: try:
target = os.fspath(target) target = os.fspath(target)
except TypeError: except TypeError:
if not isinstance(target, PathBase): if not isinstance(target, PathBase):
raise raise
PathBase._copy_file(self, target)
else: else:
try: copyfile(os.fspath(self), target)
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)
def chmod(self, mode, *, follow_symlinks=True): def chmod(self, mode, *, follow_symlinks=True):
""" """
@ -894,6 +887,14 @@ class Path(PathBase, PurePath):
""" """
os.symlink(target, self, target_is_directory) 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"): if hasattr(os, "link"):
def hardlink_to(self, target): def hardlink_to(self, target):
""" """

View file

@ -20,15 +20,6 @@ except ImportError:
_winapi = None _winapi = None
__all__ = ["UnsupportedOperation"]
class UnsupportedOperation(NotImplementedError):
"""An exception that is raised when an unsupported operation is attempted.
"""
pass
def get_copy_blocksize(infd): def get_copy_blocksize(infd):
"""Determine blocksize for fastcopying on Linux. """Determine blocksize for fastcopying on Linux.
Hopefully the whole file will be copied in a single call. Hopefully the whole file will be copied in a single call.
@ -101,44 +92,12 @@ else:
copyfd = None copyfd = None
if _winapi and hasattr(_winapi, 'CopyFile2') and hasattr(os.stat_result, 'st_file_attributes'): if _winapi and hasattr(_winapi, 'CopyFile2'):
def _is_dirlink(path): def copyfile(source, target):
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). Copy from one file to another using CopyFile2 (Windows only).
""" """
if follow_symlinks: _winapi.CopyFile2(source, target, 0)
_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
else: else:
copyfile = None 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(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") @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) base = self.cls(self.base)
source = base / 'dirE' source = base / 'dirE'
target = base / 'copyE' target = base / 'copyE'
self.assertRaises(PermissionError, source.copytree, target) self.assertRaises(PermissionError, source.copy, target)
self.assertFalse(target.exists()) self.assertFalse(target.exists())
errors = [] errors = []
source.copytree(target, on_error=errors.append) source.copy(target, on_error=errors.append)
self.assertEqual(len(errors), 1) self.assertEqual(len(errors), 1)
self.assertIsInstance(errors[0], PermissionError) self.assertIsInstance(errors[0], PermissionError)
self.assertFalse(target.exists()) self.assertFalse(target.exists())
def test_copytree_preserve_metadata(self): def test_copy_dir_preserve_metadata(self):
base = self.cls(self.base) base = self.cls(self.base)
source = base / 'dirC' source = base / 'dirC'
if hasattr(os, 'chmod'): if hasattr(os, 'chmod'):
@ -729,7 +729,7 @@ class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest):
if hasattr(os, 'chflags') and hasattr(stat, 'UF_NODUMP'): if hasattr(os, 'chflags') and hasattr(stat, 'UF_NODUMP'):
os.chflags(source / 'fileC', stat.UF_NODUMP) os.chflags(source / 'fileC', stat.UF_NODUMP)
target = base / 'copyA' target = base / 'copyA'
source.copytree(target, preserve_metadata=True) source.copy(target, preserve_metadata=True)
for subpath in ['.', 'fileC', 'dirD', 'dirD/fileD']: for subpath in ['.', 'fileC', 'dirD', 'dirD/fileD']:
source_st = source.joinpath(subpath).stat() 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) self.assertEqual(source_st.st_flags, target_st.st_flags)
@os_helper.skip_unless_xattr @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) base = self.cls(self.base)
source = base / 'dirC' source = base / 'dirC'
source_file = source.joinpath('dirD', 'fileD') source_file = source.joinpath('dirD', 'fileD')
os.setxattr(source_file, b'user.foo', b'42') os.setxattr(source_file, b'user.foo', b'42')
target = base / 'copyA' target = base / 'copyA'
source.copytree(target, preserve_metadata=True) source.copy(target, preserve_metadata=True)
target_file = target.joinpath('dirD', 'fileD') target_file = target.joinpath('dirD', 'fileD')
self.assertEqual(os.getxattr(target_file, b'user.foo'), b'42') self.assertEqual(os.getxattr(target_file, b'user.foo'), b'42')

View file

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

View file

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