Skip to content

Commit a6644d4

Browse files
pythonGH-73991: Rework pathlib.Path.copytree() into copy() (python#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 <[email protected]>
1 parent ea70439 commit a6644d4

File tree

10 files changed

+141
-197
lines changed

10 files changed

+141
-197
lines changed

Doc/library/pathlib.rst

+18-35
Original file line numberDiff line numberDiff line change
@@ -1539,50 +1539,33 @@ Creating files and directories
15391539
Copying, renaming and deleting
15401540
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
15411541

1542-
.. method:: Path.copy(target, *, follow_symlinks=True, preserve_metadata=False)
1542+
.. method:: Path.copy(target, *, follow_symlinks=True, dirs_exist_ok=False, \
1543+
preserve_metadata=False, ignore=None, on_error=None)
15431544

1544-
Copy the contents of this file to the *target* file. If *target* specifies
1545-
a file that already exists, it will be replaced.
1545+
Copy this file or directory tree to the given *target*, and return a new
1546+
:class:`!Path` instance pointing to *target*.
15461547

1547-
If *follow_symlinks* is false, and this file is a symbolic link, *target*
1548-
will be created as a symbolic link. If *follow_symlinks* is true and this
1549-
file is a symbolic link, *target* will be a copy of the symlink target.
1548+
If the source is a file, the target will be replaced if it is an existing
1549+
file. If the source is a symlink and *follow_symlinks* is true (the
1550+
default), the symlink's target is copied. Otherwise, the symlink is
1551+
recreated at the destination.
15501552

1551-
If *preserve_metadata* is false (the default), only the file data is
1552-
guaranteed to be copied. Set *preserve_metadata* to true to ensure that the
1553-
file mode (permissions), flags, last access and modification times, and
1554-
extended attributes are copied where supported. This argument has no effect
1555-
on Windows, where metadata is always preserved when copying.
1553+
If the source is a directory and *dirs_exist_ok* is false (the default), a
1554+
:exc:`FileExistsError` is raised if the target is an existing directory.
1555+
If *dirs_exists_ok* is true, the copying operation will overwrite
1556+
existing files within the destination tree with corresponding files
1557+
from the source tree.
15561558

1557-
.. versionadded:: 3.14
1558-
1559-
1560-
.. method:: Path.copytree(target, *, follow_symlinks=True, \
1561-
preserve_metadata=False, dirs_exist_ok=False, \
1562-
ignore=None, on_error=None)
1563-
1564-
Recursively copy this directory tree to the given destination.
1565-
1566-
If a symlink is encountered in the source tree, and *follow_symlinks* is
1567-
true (the default), the symlink's target is copied. Otherwise, the symlink
1568-
is recreated in the destination tree.
1569-
1570-
If *preserve_metadata* is false (the default), only the directory structure
1559+
If *preserve_metadata* is false (the default), only directory structures
15711560
and file data are guaranteed to be copied. Set *preserve_metadata* to true
15721561
to ensure that file and directory permissions, flags, last access and
15731562
modification times, and extended attributes are copied where supported.
1574-
This argument has no effect on Windows, where metadata is always preserved
1575-
when copying.
1576-
1577-
If the destination is an existing directory and *dirs_exist_ok* is false
1578-
(the default), a :exc:`FileExistsError` is raised. Otherwise, the copying
1579-
operation will continue if it encounters existing directories, and files
1580-
within the destination tree will be overwritten by corresponding files from
1581-
the source tree.
1563+
This argument has no effect when copying files on Windows (where
1564+
metadata is always preserved).
15821565

15831566
If *ignore* is given, it should be a callable accepting one argument: a
1584-
file or directory path within the source tree. The callable may return true
1585-
to suppress copying of the path.
1567+
source file or directory path. The callable may return true to suppress
1568+
copying of the path.
15861569

15871570
If *on_error* is given, it should be a callable accepting one argument: an
15881571
instance of :exc:`OSError`. The callable may re-raise the exception or do

Doc/whatsnew/3.14.rst

+2-4
Original file line numberDiff line numberDiff line change
@@ -146,10 +146,8 @@ pathlib
146146

147147
* Add methods to :class:`pathlib.Path` to recursively copy or remove files:
148148

149-
* :meth:`~pathlib.Path.copy` copies the content of one file to another, like
150-
:func:`shutil.copyfile`.
151-
* :meth:`~pathlib.Path.copytree` copies one directory tree to another, like
152-
:func:`shutil.copytree`.
149+
* :meth:`~pathlib.Path.copy` copies a file or directory tree to a given
150+
destination.
153151
* :meth:`~pathlib.Path.delete` removes a file or directory tree.
154152

155153
(Contributed by Barney Gale in :gh:`73991`.)

Lib/pathlib/__init__.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
operating systems.
66
"""
77

8-
from ._os import *
9-
from ._local import *
8+
from pathlib._abc import *
9+
from pathlib._local import *
1010

11-
__all__ = (_os.__all__ +
11+
__all__ = (_abc.__all__ +
1212
_local.__all__)

Lib/pathlib/_abc.py

+42-38
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,16 @@
1616
import posixpath
1717
from glob import _GlobberBase, _no_recurse_symlinks
1818
from stat import S_ISDIR, S_ISLNK, S_ISREG, S_ISSOCK, S_ISBLK, S_ISCHR, S_ISFIFO
19-
from ._os import UnsupportedOperation, copyfileobj
19+
from pathlib._os import copyfileobj
20+
21+
22+
__all__ = ["UnsupportedOperation"]
23+
24+
25+
class UnsupportedOperation(NotImplementedError):
26+
"""An exception that is raised when an unsupported operation is attempted.
27+
"""
28+
pass
2029

2130

2231
@functools.cache
@@ -761,6 +770,13 @@ def symlink_to(self, target, target_is_directory=False):
761770
"""
762771
raise UnsupportedOperation(self._unsupported_msg('symlink_to()'))
763772

773+
def _symlink_to_target_of(self, link):
774+
"""
775+
Make this path a symlink with the same target as the given link. This
776+
is used by copy().
777+
"""
778+
self.symlink_to(link.readlink())
779+
764780
def hardlink_to(self, target):
765781
"""
766782
Make this path a hard link pointing to the same file as *target*.
@@ -806,21 +822,12 @@ def _copy_metadata(self, target, *, follow_symlinks=True):
806822
metadata = self._read_metadata(keys, follow_symlinks=follow_symlinks)
807823
target._write_metadata(metadata, follow_symlinks=follow_symlinks)
808824

809-
def copy(self, target, *, follow_symlinks=True, preserve_metadata=False):
825+
def _copy_file(self, target):
810826
"""
811-
Copy the contents of this file to the given target. If this file is a
812-
symlink and follow_symlinks is false, a symlink will be created at the
813-
target.
827+
Copy the contents of this file to the given target.
814828
"""
815-
if not isinstance(target, PathBase):
816-
target = self.with_segments(target)
817829
if self._samefile_safe(target):
818830
raise OSError(f"{self!r} and {target!r} are the same file")
819-
if not follow_symlinks and self.is_symlink():
820-
target.symlink_to(self.readlink())
821-
if preserve_metadata:
822-
self._copy_metadata(target, follow_symlinks=False)
823-
return
824831
with self.open('rb') as source_f:
825832
try:
826833
with target.open('wb') as target_f:
@@ -832,42 +839,39 @@ def copy(self, target, *, follow_symlinks=True, preserve_metadata=False):
832839
f'Directory does not exist: {target}') from e
833840
else:
834841
raise
835-
if preserve_metadata:
836-
self._copy_metadata(target)
837842

838-
def copytree(self, target, *, follow_symlinks=True,
839-
preserve_metadata=False, dirs_exist_ok=False,
840-
ignore=None, on_error=None):
843+
def copy(self, target, *, follow_symlinks=True, dirs_exist_ok=False,
844+
preserve_metadata=False, ignore=None, on_error=None):
841845
"""
842-
Recursively copy this directory tree to the given destination.
846+
Recursively copy this file or directory tree to the given destination.
843847
"""
844848
if not isinstance(target, PathBase):
845849
target = self.with_segments(target)
846-
if on_error is None:
847-
def on_error(err):
848-
raise err
849850
stack = [(self, target)]
850851
while stack:
851-
source_dir, target_dir = stack.pop()
852+
src, dst = stack.pop()
852853
try:
853-
sources = source_dir.iterdir()
854-
target_dir.mkdir(exist_ok=dirs_exist_ok)
855-
if preserve_metadata:
856-
source_dir._copy_metadata(target_dir)
857-
for source in sources:
858-
if ignore and ignore(source):
859-
continue
860-
try:
861-
if source.is_dir(follow_symlinks=follow_symlinks):
862-
stack.append((source, target_dir.joinpath(source.name)))
863-
else:
864-
source.copy(target_dir.joinpath(source.name),
865-
follow_symlinks=follow_symlinks,
866-
preserve_metadata=preserve_metadata)
867-
except OSError as err:
868-
on_error(err)
854+
if not follow_symlinks and src.is_symlink():
855+
dst._symlink_to_target_of(src)
856+
if preserve_metadata:
857+
src._copy_metadata(dst, follow_symlinks=False)
858+
elif src.is_dir():
859+
children = src.iterdir()
860+
dst.mkdir(exist_ok=dirs_exist_ok)
861+
for child in children:
862+
if not (ignore and ignore(child)):
863+
stack.append((child, dst.joinpath(child.name)))
864+
if preserve_metadata:
865+
src._copy_metadata(dst)
866+
else:
867+
src._copy_file(dst)
868+
if preserve_metadata:
869+
src._copy_metadata(dst)
869870
except OSError as err:
871+
if on_error is None:
872+
raise
870873
on_error(err)
874+
return target
871875

872876
def rename(self, target):
873877
"""

Lib/pathlib/_local.py

+15-14
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@
1818
except ImportError:
1919
grp = None
2020

21-
from ._os import (UnsupportedOperation, copyfile, file_metadata_keys,
22-
read_file_metadata, write_file_metadata)
23-
from ._abc import PurePathBase, PathBase
21+
from pathlib._os import (copyfile, file_metadata_keys, read_file_metadata,
22+
write_file_metadata)
23+
from pathlib._abc import UnsupportedOperation, PurePathBase, PathBase
2424

2525

2626
__all__ = [
@@ -788,25 +788,18 @@ def mkdir(self, mode=0o777, parents=False, exist_ok=False):
788788
_write_metadata = write_file_metadata
789789

790790
if copyfile:
791-
def copy(self, target, *, follow_symlinks=True, preserve_metadata=False):
791+
def _copy_file(self, target):
792792
"""
793-
Copy the contents of this file to the given target. If this file is a
794-
symlink and follow_symlinks is false, a symlink will be created at the
795-
target.
793+
Copy the contents of this file to the given target.
796794
"""
797795
try:
798796
target = os.fspath(target)
799797
except TypeError:
800798
if not isinstance(target, PathBase):
801799
raise
800+
PathBase._copy_file(self, target)
802801
else:
803-
try:
804-
copyfile(os.fspath(self), target, follow_symlinks)
805-
return
806-
except UnsupportedOperation:
807-
pass # Fall through to generic code.
808-
PathBase.copy(self, target, follow_symlinks=follow_symlinks,
809-
preserve_metadata=preserve_metadata)
802+
copyfile(os.fspath(self), target)
810803

811804
def chmod(self, mode, *, follow_symlinks=True):
812805
"""
@@ -894,6 +887,14 @@ def symlink_to(self, target, target_is_directory=False):
894887
"""
895888
os.symlink(target, self, target_is_directory)
896889

890+
if os.name == 'nt':
891+
def _symlink_to_target_of(self, link):
892+
"""
893+
Make this path a symlink with the same target as the given link.
894+
This is used by copy().
895+
"""
896+
self.symlink_to(link.readlink(), link.is_dir())
897+
897898
if hasattr(os, "link"):
898899
def hardlink_to(self, target):
899900
"""

Lib/pathlib/_os.py

+3-44
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,6 @@
2020
_winapi = None
2121

2222

23-
__all__ = ["UnsupportedOperation"]
24-
25-
26-
class UnsupportedOperation(NotImplementedError):
27-
"""An exception that is raised when an unsupported operation is attempted.
28-
"""
29-
pass
30-
31-
3223
def get_copy_blocksize(infd):
3324
"""Determine blocksize for fastcopying on Linux.
3425
Hopefully the whole file will be copied in a single call.
@@ -101,44 +92,12 @@ def copyfd(source_fd, target_fd):
10192
copyfd = None
10293

10394

104-
if _winapi and hasattr(_winapi, 'CopyFile2') and hasattr(os.stat_result, 'st_file_attributes'):
105-
def _is_dirlink(path):
106-
try:
107-
st = os.lstat(path)
108-
except (OSError, ValueError):
109-
return False
110-
return (st.st_file_attributes & stat.FILE_ATTRIBUTE_DIRECTORY and
111-
st.st_reparse_tag == stat.IO_REPARSE_TAG_SYMLINK)
112-
113-
def copyfile(source, target, follow_symlinks):
95+
if _winapi and hasattr(_winapi, 'CopyFile2'):
96+
def copyfile(source, target):
11497
"""
11598
Copy from one file to another using CopyFile2 (Windows only).
11699
"""
117-
if follow_symlinks:
118-
_winapi.CopyFile2(source, target, 0)
119-
else:
120-
# Use COPY_FILE_COPY_SYMLINK to copy a file symlink.
121-
flags = _winapi.COPY_FILE_COPY_SYMLINK
122-
try:
123-
_winapi.CopyFile2(source, target, flags)
124-
return
125-
except OSError as err:
126-
# Check for ERROR_ACCESS_DENIED
127-
if err.winerror == 5 and _is_dirlink(source):
128-
pass
129-
else:
130-
raise
131-
132-
# Add COPY_FILE_DIRECTORY to copy a directory symlink.
133-
flags |= _winapi.COPY_FILE_DIRECTORY
134-
try:
135-
_winapi.CopyFile2(source, target, flags)
136-
except OSError as err:
137-
# Check for ERROR_INVALID_PARAMETER
138-
if err.winerror == 87:
139-
raise UnsupportedOperation(err) from None
140-
else:
141-
raise
100+
_winapi.CopyFile2(source, target, 0)
142101
else:
143102
copyfile = None
144103

Lib/test/test_pathlib/test_pathlib.py

+7-7
Original file line numberDiff line numberDiff line change
@@ -709,27 +709,27 @@ def test_copy_link_preserve_metadata(self):
709709

710710
@unittest.skipIf(sys.platform == "win32" or sys.platform == "wasi", "directories are always readable on Windows and WASI")
711711
@unittest.skipIf(root_in_posix, "test fails with root privilege")
712-
def test_copytree_no_read_permission(self):
712+
def test_copy_dir_no_read_permission(self):
713713
base = self.cls(self.base)
714714
source = base / 'dirE'
715715
target = base / 'copyE'
716-
self.assertRaises(PermissionError, source.copytree, target)
716+
self.assertRaises(PermissionError, source.copy, target)
717717
self.assertFalse(target.exists())
718718
errors = []
719-
source.copytree(target, on_error=errors.append)
719+
source.copy(target, on_error=errors.append)
720720
self.assertEqual(len(errors), 1)
721721
self.assertIsInstance(errors[0], PermissionError)
722722
self.assertFalse(target.exists())
723723

724-
def test_copytree_preserve_metadata(self):
724+
def test_copy_dir_preserve_metadata(self):
725725
base = self.cls(self.base)
726726
source = base / 'dirC'
727727
if hasattr(os, 'chmod'):
728728
os.chmod(source / 'dirD', stat.S_IRWXU | stat.S_IRWXO)
729729
if hasattr(os, 'chflags') and hasattr(stat, 'UF_NODUMP'):
730730
os.chflags(source / 'fileC', stat.UF_NODUMP)
731731
target = base / 'copyA'
732-
source.copytree(target, preserve_metadata=True)
732+
source.copy(target, preserve_metadata=True)
733733

734734
for subpath in ['.', 'fileC', 'dirD', 'dirD/fileD']:
735735
source_st = source.joinpath(subpath).stat()
@@ -741,13 +741,13 @@ def test_copytree_preserve_metadata(self):
741741
self.assertEqual(source_st.st_flags, target_st.st_flags)
742742

743743
@os_helper.skip_unless_xattr
744-
def test_copytree_preserve_metadata_xattrs(self):
744+
def test_copy_dir_preserve_metadata_xattrs(self):
745745
base = self.cls(self.base)
746746
source = base / 'dirC'
747747
source_file = source.joinpath('dirD', 'fileD')
748748
os.setxattr(source_file, b'user.foo', b'42')
749749
target = base / 'copyA'
750-
source.copytree(target, preserve_metadata=True)
750+
source.copy(target, preserve_metadata=True)
751751
target_file = target.joinpath('dirD', 'fileD')
752752
self.assertEqual(os.getxattr(target_file, b'user.foo'), b'42')
753753

0 commit comments

Comments
 (0)