GH-73991: Rework `pathlib.Path.copytree()` into `copy()` (#122369) · blhsing/cpython@cde45a6 · GitHub
Skip to content

Commit cde45a6

Browse files
barneygaleAA-Turner
authored andcommitted
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 <9087854+AA-Turner@users.noreply.github.com>
1 parent acd914b commit cde45a6

10 files changed

Lines changed: 141 additions & 197 deletions

File tree

Doc/library/pathlib.rst

Lines changed: 18 additions & 35 deletions

Doc/whatsnew/3.14.rst

Lines changed: 2 additions & 4 deletions
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

Lines changed: 3 additions & 3 deletions
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

Lines changed: 42 additions & 38 deletions
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

Lines changed: 15 additions & 14 deletions
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

Lines changed: 3 additions & 44 deletions
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

Lines changed: 7 additions & 7 deletions

0 commit comments

Comments
 (0)