Skip to content

Commit b251d40

Browse files
authored
GH-125413: Add private pathlib.Path method to write metadata (#130238)
Replace `WritablePath._copy_writer` with a new `_write_info()` method. This method allows the target of a `copy()` to preserve metadata. Replace `pathlib._os.CopyWriter` and `LocalCopyWriter` classes with new `copy_file()` and `copy_info()` functions. The `copy_file()` function uses `source_path.info` wherever possible to save on `stat()`s.
1 parent 5ba69e7 commit b251d40

File tree

4 files changed

+122
-174
lines changed

4 files changed

+122
-174
lines changed

Lib/pathlib/_abc.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from abc import ABC, abstractmethod
1515
from glob import _PathGlobber, _no_recurse_symlinks
1616
from pathlib import PurePath, Path
17-
from pathlib._os import magic_open, CopyWriter
17+
from pathlib._os import magic_open, ensure_distinct_paths, copy_file
1818

1919

2020
def _explode_path(path):
@@ -347,13 +347,8 @@ def copy(self, target, follow_symlinks=True, dirs_exist_ok=False,
347347
"""
348348
if not hasattr(target, 'with_segments'):
349349
target = self.with_segments(target)
350-
351-
# Delegate to the target path's CopyWriter object.
352-
try:
353-
create = target._copy_writer._create
354-
except AttributeError:
355-
raise TypeError(f"Target is not writable: {target}") from None
356-
create(self, follow_symlinks, dirs_exist_ok, preserve_metadata)
350+
ensure_distinct_paths(self, target)
351+
copy_file(self, target, follow_symlinks, dirs_exist_ok, preserve_metadata)
357352
return target.joinpath() # Empty join to ensure fresh metadata.
358353

359354
def copy_into(self, target_dir, *, follow_symlinks=True,
@@ -424,7 +419,11 @@ def write_text(self, data, encoding=None, errors=None, newline=None):
424419
with magic_open(self, mode='w', encoding=encoding, errors=errors, newline=newline) as f:
425420
return f.write(data)
426421

427-
_copy_writer = property(CopyWriter)
422+
def _write_info(self, info, follow_symlinks=True):
423+
"""
424+
Write the given PathInfo to this path.
425+
"""
426+
pass
428427

429428

430429
JoinablePath.register(PurePath)

Lib/pathlib/_local.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@
1919
except ImportError:
2020
grp = None
2121

22-
from pathlib._os import LocalCopyWriter, PathInfo, DirEntryInfo, ensure_different_files
22+
from pathlib._os import (
23+
PathInfo, DirEntryInfo,
24+
ensure_different_files, ensure_distinct_paths,
25+
copy_file, copy_info,
26+
)
2327

2428

2529
__all__ = [
@@ -799,6 +803,12 @@ def write_text(self, data, encoding=None, errors=None, newline=None):
799803
with self.open(mode='w', encoding=encoding, errors=errors, newline=newline) as f:
800804
return f.write(data)
801805

806+
def _write_info(self, info, follow_symlinks=True):
807+
"""
808+
Write the given PathInfo to this path.
809+
"""
810+
copy_info(info, self, follow_symlinks=follow_symlinks)
811+
802812
_remove_leading_dot = operator.itemgetter(slice(2, None))
803813
_remove_trailing_slash = operator.itemgetter(slice(-1))
804814

@@ -1083,22 +1093,15 @@ def replace(self, target):
10831093
target = self.with_segments(target)
10841094
return target
10851095

1086-
_copy_writer = property(LocalCopyWriter)
1087-
10881096
def copy(self, target, follow_symlinks=True, dirs_exist_ok=False,
10891097
preserve_metadata=False):
10901098
"""
10911099
Recursively copy this file or directory tree to the given destination.
10921100
"""
10931101
if not hasattr(target, 'with_segments'):
10941102
target = self.with_segments(target)
1095-
1096-
# Delegate to the target path's CopyWriter object.
1097-
try:
1098-
create = target._copy_writer._create
1099-
except AttributeError:
1100-
raise TypeError(f"Target is not writable: {target}") from None
1101-
create(self, follow_symlinks, dirs_exist_ok, preserve_metadata)
1103+
ensure_distinct_paths(self, target)
1104+
copy_file(self, target, follow_symlinks, dirs_exist_ok, preserve_metadata)
11021105
return target.joinpath() # Empty join to ensure fresh metadata.
11031106

11041107
def copy_into(self, target_dir, *, follow_symlinks=True,

Lib/pathlib/_os.py

Lines changed: 99 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -102,16 +102,16 @@ def _sendfile(source_fd, target_fd):
102102

103103

104104
if _winapi and hasattr(_winapi, 'CopyFile2'):
105-
def copyfile(source, target):
105+
def _copyfile2(source, target):
106106
"""
107107
Copy from one file to another using CopyFile2 (Windows only).
108108
"""
109109
_winapi.CopyFile2(source, target, 0)
110110
else:
111-
copyfile = None
111+
_copyfile2 = None
112112

113113

114-
def copyfileobj(source_f, target_f):
114+
def _copyfileobj(source_f, target_f):
115115
"""
116116
Copy data from file-like object source_f to file-like object target_f.
117117
"""
@@ -200,70 +200,6 @@ def magic_open(path, mode='r', buffering=-1, encoding=None, errors=None,
200200
raise TypeError(f"{cls.__name__} can't be opened with mode {mode!r}")
201201

202202

203-
class CopyWriter:
204-
"""
205-
Class that implements the "write" part of copying between path objects. An
206-
instance of this class is available from the WritablePath._copy_writer
207-
property.
208-
"""
209-
__slots__ = ('_path',)
210-
211-
def __init__(self, path):
212-
self._path = path
213-
214-
def _copy_metadata(self, source, follow_symlinks=True):
215-
"""Copy metadata from the given path to our path."""
216-
pass
217-
218-
def _create(self, source, follow_symlinks, dirs_exist_ok, preserve_metadata):
219-
ensure_distinct_paths(source, self._path)
220-
if not follow_symlinks and source.is_symlink():
221-
self._create_symlink(source, preserve_metadata)
222-
elif source.is_dir():
223-
self._create_dir(source, follow_symlinks, dirs_exist_ok, preserve_metadata)
224-
else:
225-
self._create_file(source, preserve_metadata)
226-
return self._path
227-
228-
def _create_dir(self, source, follow_symlinks, dirs_exist_ok, preserve_metadata):
229-
"""Copy the given directory to our path."""
230-
children = list(source.iterdir())
231-
self._path.mkdir(exist_ok=dirs_exist_ok)
232-
for src in children:
233-
dst = self._path.joinpath(src.name)
234-
if not follow_symlinks and src.is_symlink():
235-
dst._copy_writer._create_symlink(src, preserve_metadata)
236-
elif src.is_dir():
237-
dst._copy_writer._create_dir(src, follow_symlinks, dirs_exist_ok, preserve_metadata)
238-
else:
239-
dst._copy_writer._create_file(src, preserve_metadata)
240-
241-
if preserve_metadata:
242-
self._copy_metadata(source)
243-
244-
def _create_file(self, source, preserve_metadata):
245-
"""Copy the given file to our path."""
246-
ensure_different_files(source, self._path)
247-
with magic_open(source, 'rb') as source_f:
248-
try:
249-
with magic_open(self._path, 'wb') as target_f:
250-
copyfileobj(source_f, target_f)
251-
except IsADirectoryError as e:
252-
if not self._path.exists():
253-
# Raise a less confusing exception.
254-
raise FileNotFoundError(
255-
f'Directory does not exist: {self._path}') from e
256-
raise
257-
if preserve_metadata:
258-
self._copy_metadata(source)
259-
260-
def _create_symlink(self, source, preserve_metadata):
261-
"""Copy the given symbolic link to our path."""
262-
self._path.symlink_to(source.readlink())
263-
if preserve_metadata:
264-
self._copy_metadata(source, follow_symlinks=False)
265-
266-
267203
def ensure_distinct_paths(source, target):
268204
"""
269205
Raise OSError(EINVAL) if the other path is within this path.
@@ -284,94 +220,6 @@ def ensure_distinct_paths(source, target):
284220
raise err
285221

286222

287-
class LocalCopyWriter(CopyWriter):
288-
"""This object implements the "write" part of copying local paths. Don't
289-
try to construct it yourself.
290-
"""
291-
__slots__ = ()
292-
293-
def _copy_metadata(self, source, follow_symlinks=True):
294-
"""Copy metadata from the given path to our path."""
295-
target = self._path
296-
info = source.info
297-
298-
copy_times_ns = (
299-
hasattr(info, '_access_time_ns') and
300-
hasattr(info, '_mod_time_ns') and
301-
(follow_symlinks or os.utime in os.supports_follow_symlinks))
302-
if copy_times_ns:
303-
t0 = info._access_time_ns(follow_symlinks=follow_symlinks)
304-
t1 = info._mod_time_ns(follow_symlinks=follow_symlinks)
305-
os.utime(target, ns=(t0, t1), follow_symlinks=follow_symlinks)
306-
307-
# We must copy extended attributes before the file is (potentially)
308-
# chmod()'ed read-only, otherwise setxattr() will error with -EACCES.
309-
copy_xattrs = (
310-
hasattr(info, '_xattrs') and
311-
hasattr(os, 'setxattr') and
312-
(follow_symlinks or os.setxattr in os.supports_follow_symlinks))
313-
if copy_xattrs:
314-
xattrs = info._xattrs(follow_symlinks=follow_symlinks)
315-
for attr, value in xattrs:
316-
try:
317-
os.setxattr(target, attr, value, follow_symlinks=follow_symlinks)
318-
except OSError as e:
319-
if e.errno not in (EPERM, ENOTSUP, ENODATA, EINVAL, EACCES):
320-
raise
321-
322-
copy_posix_permissions = (
323-
hasattr(info, '_posix_permissions') and
324-
(follow_symlinks or os.chmod in os.supports_follow_symlinks))
325-
if copy_posix_permissions:
326-
posix_permissions = info._posix_permissions(follow_symlinks=follow_symlinks)
327-
try:
328-
os.chmod(target, posix_permissions, follow_symlinks=follow_symlinks)
329-
except NotImplementedError:
330-
# if we got a NotImplementedError, it's because
331-
# * follow_symlinks=False,
332-
# * lchown() is unavailable, and
333-
# * either
334-
# * fchownat() is unavailable or
335-
# * fchownat() doesn't implement AT_SYMLINK_NOFOLLOW.
336-
# (it returned ENOSUP.)
337-
# therefore we're out of options--we simply cannot chown the
338-
# symlink. give up, suppress the error.
339-
# (which is what shutil always did in this circumstance.)
340-
pass
341-
342-
copy_bsd_flags = (
343-
hasattr(info, '_bsd_flags') and
344-
hasattr(os, 'chflags') and
345-
(follow_symlinks or os.chflags in os.supports_follow_symlinks))
346-
if copy_bsd_flags:
347-
bsd_flags = info._bsd_flags(follow_symlinks=follow_symlinks)
348-
try:
349-
os.chflags(target, bsd_flags, follow_symlinks=follow_symlinks)
350-
except OSError as why:
351-
if why.errno not in (EOPNOTSUPP, ENOTSUP):
352-
raise
353-
354-
if copyfile:
355-
# Use fast OS routine for local file copying where available.
356-
def _create_file(self, source, preserve_metadata):
357-
"""Copy the given file to the given target."""
358-
try:
359-
source = os.fspath(source)
360-
except TypeError:
361-
super()._create_file(source, preserve_metadata)
362-
else:
363-
copyfile(source, os.fspath(self._path))
364-
365-
if os.name == 'nt':
366-
# Windows: symlink target might not exist yet if we're copying several
367-
# files, so ensure we pass is_dir to os.symlink().
368-
def _create_symlink(self, source, preserve_metadata):
369-
"""Copy the given symlink to the given target."""
370-
self._path.symlink_to(source.readlink(), source.is_dir())
371-
if preserve_metadata:
372-
self._copy_metadata(source, follow_symlinks=False)
373-
374-
375223
def ensure_different_files(source, target):
376224
"""
377225
Raise OSError(EINVAL) if both paths refer to the same file.
@@ -394,6 +242,102 @@ def ensure_different_files(source, target):
394242
raise err
395243

396244

245+
def copy_file(source, target, follow_symlinks=True, dirs_exist_ok=False,
246+
preserve_metadata=False):
247+
"""
248+
Recursively copy the given source ReadablePath to the given target WritablePath.
249+
"""
250+
info = source.info
251+
if not follow_symlinks and info.is_symlink():
252+
target.symlink_to(source.readlink(), info.is_dir())
253+
if preserve_metadata:
254+
target._write_info(info, follow_symlinks=False)
255+
elif info.is_dir():
256+
children = source.iterdir()
257+
target.mkdir(exist_ok=dirs_exist_ok)
258+
for src in children:
259+
dst = target.joinpath(src.name)
260+
copy_file(src, dst, follow_symlinks, dirs_exist_ok, preserve_metadata)
261+
if preserve_metadata:
262+
target._write_info(info)
263+
else:
264+
if _copyfile2:
265+
# Use fast OS routine for local file copying where available.
266+
try:
267+
source_p = os.fspath(source)
268+
target_p = os.fspath(target)
269+
except TypeError:
270+
pass
271+
else:
272+
_copyfile2(source_p, target_p)
273+
return
274+
ensure_different_files(source, target)
275+
with magic_open(source, 'rb') as source_f:
276+
with magic_open(target, 'wb') as target_f:
277+
_copyfileobj(source_f, target_f)
278+
if preserve_metadata:
279+
target._write_info(info)
280+
281+
282+
def copy_info(info, target, follow_symlinks=True):
283+
"""Copy metadata from the given PathInfo to the given local path."""
284+
copy_times_ns = (
285+
hasattr(info, '_access_time_ns') and
286+
hasattr(info, '_mod_time_ns') and
287+
(follow_symlinks or os.utime in os.supports_follow_symlinks))
288+
if copy_times_ns:
289+
t0 = info._access_time_ns(follow_symlinks=follow_symlinks)
290+
t1 = info._mod_time_ns(follow_symlinks=follow_symlinks)
291+
os.utime(target, ns=(t0, t1), follow_symlinks=follow_symlinks)
292+
293+
# We must copy extended attributes before the file is (potentially)
294+
# chmod()'ed read-only, otherwise setxattr() will error with -EACCES.
295+
copy_xattrs = (
296+
hasattr(info, '_xattrs') and
297+
hasattr(os, 'setxattr') and
298+
(follow_symlinks or os.setxattr in os.supports_follow_symlinks))
299+
if copy_xattrs:
300+
xattrs = info._xattrs(follow_symlinks=follow_symlinks)
301+
for attr, value in xattrs:
302+
try:
303+
os.setxattr(target, attr, value, follow_symlinks=follow_symlinks)
304+
except OSError as e:
305+
if e.errno not in (EPERM, ENOTSUP, ENODATA, EINVAL, EACCES):
306+
raise
307+
308+
copy_posix_permissions = (
309+
hasattr(info, '_posix_permissions') and
310+
(follow_symlinks or os.chmod in os.supports_follow_symlinks))
311+
if copy_posix_permissions:
312+
posix_permissions = info._posix_permissions(follow_symlinks=follow_symlinks)
313+
try:
314+
os.chmod(target, posix_permissions, follow_symlinks=follow_symlinks)
315+
except NotImplementedError:
316+
# if we got a NotImplementedError, it's because
317+
# * follow_symlinks=False,
318+
# * lchown() is unavailable, and
319+
# * either
320+
# * fchownat() is unavailable or
321+
# * fchownat() doesn't implement AT_SYMLINK_NOFOLLOW.
322+
# (it returned ENOSUP.)
323+
# therefore we're out of options--we simply cannot chown the
324+
# symlink. give up, suppress the error.
325+
# (which is what shutil always did in this circumstance.)
326+
pass
327+
328+
copy_bsd_flags = (
329+
hasattr(info, '_bsd_flags') and
330+
hasattr(os, 'chflags') and
331+
(follow_symlinks or os.chflags in os.supports_follow_symlinks))
332+
if copy_bsd_flags:
333+
bsd_flags = info._bsd_flags(follow_symlinks=follow_symlinks)
334+
try:
335+
os.chflags(target, bsd_flags, follow_symlinks=follow_symlinks)
336+
except OSError as why:
337+
if why.errno not in (EOPNOTSUPP, ENOTSUP):
338+
raise
339+
340+
397341
class _PathInfoBase:
398342
__slots__ = ('_path', '_stat_result', '_lstat_result')
399343

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Speed up :meth:`Path.copy <pathlib.Path.copy>` by making better use of
2+
:attr:`~pathlib.Path.info` internally.

0 commit comments

Comments
 (0)