https://github.com/python/cpython/commit/b251d409f9cf6a84e6d7a78d284467f57c97a63a
commit: b251d409f9cf6a84e6d7a78d284467f57c97a63a
branch: main
author: Barney Gale <barney.g...@gmail.com>
committer: barneygale <barney.g...@gmail.com>
date: 2025-02-26T21:07:27Z
summary:

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.

files:
A Misc/NEWS.d/next/Library/2025-02-21-20-16-32.gh-issue-125413.YJ7Msf.rst
M Lib/pathlib/_abc.py
M Lib/pathlib/_local.py
M Lib/pathlib/_os.py

diff --git a/Lib/pathlib/_abc.py b/Lib/pathlib/_abc.py
index 97d78f557d14dc..6b46aea9aea4b9 100644
--- a/Lib/pathlib/_abc.py
+++ b/Lib/pathlib/_abc.py
@@ -14,7 +14,7 @@
 from abc import ABC, abstractmethod
 from glob import _PathGlobber, _no_recurse_symlinks
 from pathlib import PurePath, Path
-from pathlib._os import magic_open, CopyWriter
+from pathlib._os import magic_open, ensure_distinct_paths, copy_file
 
 
 def _explode_path(path):
@@ -347,13 +347,8 @@ def copy(self, target, follow_symlinks=True, 
dirs_exist_ok=False,
         """
         if not hasattr(target, 'with_segments'):
             target = self.with_segments(target)
-
-        # Delegate to the target path's CopyWriter object.
-        try:
-            create = target._copy_writer._create
-        except AttributeError:
-            raise TypeError(f"Target is not writable: {target}") from None
-        create(self, follow_symlinks, dirs_exist_ok, preserve_metadata)
+        ensure_distinct_paths(self, target)
+        copy_file(self, target, follow_symlinks, dirs_exist_ok, 
preserve_metadata)
         return target.joinpath()  # Empty join to ensure fresh metadata.
 
     def copy_into(self, target_dir, *, follow_symlinks=True,
@@ -424,7 +419,11 @@ def write_text(self, data, encoding=None, errors=None, 
newline=None):
         with magic_open(self, mode='w', encoding=encoding, errors=errors, 
newline=newline) as f:
             return f.write(data)
 
-    _copy_writer = property(CopyWriter)
+    def _write_info(self, info, follow_symlinks=True):
+        """
+        Write the given PathInfo to this path.
+        """
+        pass
 
 
 JoinablePath.register(PurePath)
diff --git a/Lib/pathlib/_local.py b/Lib/pathlib/_local.py
index 0ae85a68b3b2c1..ba81c3de1ccb8b 100644
--- a/Lib/pathlib/_local.py
+++ b/Lib/pathlib/_local.py
@@ -19,7 +19,11 @@
 except ImportError:
     grp = None
 
-from pathlib._os import LocalCopyWriter, PathInfo, DirEntryInfo, 
ensure_different_files
+from pathlib._os import (
+    PathInfo, DirEntryInfo,
+    ensure_different_files, ensure_distinct_paths,
+    copy_file, copy_info,
+)
 
 
 __all__ = [
@@ -799,6 +803,12 @@ def write_text(self, data, encoding=None, errors=None, 
newline=None):
         with self.open(mode='w', encoding=encoding, errors=errors, 
newline=newline) as f:
             return f.write(data)
 
+    def _write_info(self, info, follow_symlinks=True):
+        """
+        Write the given PathInfo to this path.
+        """
+        copy_info(info, self, follow_symlinks=follow_symlinks)
+
     _remove_leading_dot = operator.itemgetter(slice(2, None))
     _remove_trailing_slash = operator.itemgetter(slice(-1))
 
@@ -1083,8 +1093,6 @@ def replace(self, target):
             target = self.with_segments(target)
         return target
 
-    _copy_writer = property(LocalCopyWriter)
-
     def copy(self, target, follow_symlinks=True, dirs_exist_ok=False,
              preserve_metadata=False):
         """
@@ -1092,13 +1100,8 @@ def copy(self, target, follow_symlinks=True, 
dirs_exist_ok=False,
         """
         if not hasattr(target, 'with_segments'):
             target = self.with_segments(target)
-
-        # Delegate to the target path's CopyWriter object.
-        try:
-            create = target._copy_writer._create
-        except AttributeError:
-            raise TypeError(f"Target is not writable: {target}") from None
-        create(self, follow_symlinks, dirs_exist_ok, preserve_metadata)
+        ensure_distinct_paths(self, target)
+        copy_file(self, target, follow_symlinks, dirs_exist_ok, 
preserve_metadata)
         return target.joinpath()  # Empty join to ensure fresh metadata.
 
     def copy_into(self, target_dir, *, follow_symlinks=True,
diff --git a/Lib/pathlib/_os.py b/Lib/pathlib/_os.py
index e5b894b524ca7e..a741f201b6d925 100644
--- a/Lib/pathlib/_os.py
+++ b/Lib/pathlib/_os.py
@@ -102,16 +102,16 @@ def _sendfile(source_fd, target_fd):
 
 
 if _winapi and hasattr(_winapi, 'CopyFile2'):
-    def copyfile(source, target):
+    def _copyfile2(source, target):
         """
         Copy from one file to another using CopyFile2 (Windows only).
         """
         _winapi.CopyFile2(source, target, 0)
 else:
-    copyfile = None
+    _copyfile2 = None
 
 
-def copyfileobj(source_f, target_f):
+def _copyfileobj(source_f, target_f):
     """
     Copy data from file-like object source_f to file-like object target_f.
     """
@@ -200,70 +200,6 @@ def magic_open(path, mode='r', buffering=-1, 
encoding=None, errors=None,
     raise TypeError(f"{cls.__name__} can't be opened with mode {mode!r}")
 
 
-class CopyWriter:
-    """
-    Class that implements the "write" part of copying between path objects. An
-    instance of this class is available from the WritablePath._copy_writer
-    property.
-    """
-    __slots__ = ('_path',)
-
-    def __init__(self, path):
-        self._path = path
-
-    def _copy_metadata(self, source, follow_symlinks=True):
-        """Copy metadata from the given path to our path."""
-        pass
-
-    def _create(self, source, follow_symlinks, dirs_exist_ok, 
preserve_metadata):
-        ensure_distinct_paths(source, self._path)
-        if not follow_symlinks and source.is_symlink():
-            self._create_symlink(source, preserve_metadata)
-        elif source.is_dir():
-            self._create_dir(source, follow_symlinks, dirs_exist_ok, 
preserve_metadata)
-        else:
-            self._create_file(source, preserve_metadata)
-        return self._path
-
-    def _create_dir(self, source, follow_symlinks, dirs_exist_ok, 
preserve_metadata):
-        """Copy the given directory to our path."""
-        children = list(source.iterdir())
-        self._path.mkdir(exist_ok=dirs_exist_ok)
-        for src in children:
-            dst = self._path.joinpath(src.name)
-            if not follow_symlinks and src.is_symlink():
-                dst._copy_writer._create_symlink(src, preserve_metadata)
-            elif src.is_dir():
-                dst._copy_writer._create_dir(src, follow_symlinks, 
dirs_exist_ok, preserve_metadata)
-            else:
-                dst._copy_writer._create_file(src, preserve_metadata)
-
-        if preserve_metadata:
-            self._copy_metadata(source)
-
-    def _create_file(self, source, preserve_metadata):
-        """Copy the given file to our path."""
-        ensure_different_files(source, self._path)
-        with magic_open(source, 'rb') as source_f:
-            try:
-                with magic_open(self._path, 'wb') as target_f:
-                    copyfileobj(source_f, target_f)
-            except IsADirectoryError as e:
-                if not self._path.exists():
-                    # Raise a less confusing exception.
-                    raise FileNotFoundError(
-                        f'Directory does not exist: {self._path}') from e
-                raise
-        if preserve_metadata:
-            self._copy_metadata(source)
-
-    def _create_symlink(self, source, preserve_metadata):
-        """Copy the given symbolic link to our path."""
-        self._path.symlink_to(source.readlink())
-        if preserve_metadata:
-            self._copy_metadata(source, follow_symlinks=False)
-
-
 def ensure_distinct_paths(source, target):
     """
     Raise OSError(EINVAL) if the other path is within this path.
@@ -284,94 +220,6 @@ def ensure_distinct_paths(source, target):
     raise err
 
 
-class LocalCopyWriter(CopyWriter):
-    """This object implements the "write" part of copying local paths. Don't
-    try to construct it yourself.
-    """
-    __slots__ = ()
-
-    def _copy_metadata(self, source, follow_symlinks=True):
-        """Copy metadata from the given path to our path."""
-        target = self._path
-        info = source.info
-
-        copy_times_ns = (
-            hasattr(info, '_access_time_ns') and
-            hasattr(info, '_mod_time_ns') and
-            (follow_symlinks or os.utime in os.supports_follow_symlinks))
-        if copy_times_ns:
-            t0 = info._access_time_ns(follow_symlinks=follow_symlinks)
-            t1 = info._mod_time_ns(follow_symlinks=follow_symlinks)
-            os.utime(target, ns=(t0, t1), follow_symlinks=follow_symlinks)
-
-        # We must copy extended attributes before the file is (potentially)
-        # chmod()'ed read-only, otherwise setxattr() will error with -EACCES.
-        copy_xattrs = (
-            hasattr(info, '_xattrs') and
-            hasattr(os, 'setxattr') and
-            (follow_symlinks or os.setxattr in os.supports_follow_symlinks))
-        if copy_xattrs:
-            xattrs = info._xattrs(follow_symlinks=follow_symlinks)
-            for attr, value in xattrs:
-                try:
-                    os.setxattr(target, attr, value, 
follow_symlinks=follow_symlinks)
-                except OSError as e:
-                    if e.errno not in (EPERM, ENOTSUP, ENODATA, EINVAL, 
EACCES):
-                        raise
-
-        copy_posix_permissions = (
-            hasattr(info, '_posix_permissions') and
-            (follow_symlinks or os.chmod in os.supports_follow_symlinks))
-        if copy_posix_permissions:
-            posix_permissions = 
info._posix_permissions(follow_symlinks=follow_symlinks)
-            try:
-                os.chmod(target, posix_permissions, 
follow_symlinks=follow_symlinks)
-            except NotImplementedError:
-                # if we got a NotImplementedError, it's because
-                #   * follow_symlinks=False,
-                #   * lchown() is unavailable, and
-                #   * either
-                #       * fchownat() is unavailable or
-                #       * fchownat() doesn't implement AT_SYMLINK_NOFOLLOW.
-                #         (it returned ENOSUP.)
-                # therefore we're out of options--we simply cannot chown the
-                # symlink.  give up, suppress the error.
-                # (which is what shutil always did in this circumstance.)
-                pass
-
-        copy_bsd_flags = (
-            hasattr(info, '_bsd_flags') and
-            hasattr(os, 'chflags') and
-            (follow_symlinks or os.chflags in os.supports_follow_symlinks))
-        if copy_bsd_flags:
-            bsd_flags = info._bsd_flags(follow_symlinks=follow_symlinks)
-            try:
-                os.chflags(target, bsd_flags, follow_symlinks=follow_symlinks)
-            except OSError as why:
-                if why.errno not in (EOPNOTSUPP, ENOTSUP):
-                    raise
-
-    if copyfile:
-        # Use fast OS routine for local file copying where available.
-        def _create_file(self, source, preserve_metadata):
-            """Copy the given file to the given target."""
-            try:
-                source = os.fspath(source)
-            except TypeError:
-                super()._create_file(source, preserve_metadata)
-            else:
-                copyfile(source, os.fspath(self._path))
-
-    if os.name == 'nt':
-        # Windows: symlink target might not exist yet if we're copying several
-        # files, so ensure we pass is_dir to os.symlink().
-        def _create_symlink(self, source, preserve_metadata):
-            """Copy the given symlink to the given target."""
-            self._path.symlink_to(source.readlink(), source.is_dir())
-            if preserve_metadata:
-                self._copy_metadata(source, follow_symlinks=False)
-
-
 def ensure_different_files(source, target):
     """
     Raise OSError(EINVAL) if both paths refer to the same file.
@@ -394,6 +242,102 @@ def ensure_different_files(source, target):
     raise err
 
 
+def copy_file(source, target, follow_symlinks=True, dirs_exist_ok=False,
+              preserve_metadata=False):
+    """
+    Recursively copy the given source ReadablePath to the given target 
WritablePath.
+    """
+    info = source.info
+    if not follow_symlinks and info.is_symlink():
+        target.symlink_to(source.readlink(), info.is_dir())
+        if preserve_metadata:
+            target._write_info(info, follow_symlinks=False)
+    elif info.is_dir():
+        children = source.iterdir()
+        target.mkdir(exist_ok=dirs_exist_ok)
+        for src in children:
+            dst = target.joinpath(src.name)
+            copy_file(src, dst, follow_symlinks, dirs_exist_ok, 
preserve_metadata)
+        if preserve_metadata:
+            target._write_info(info)
+    else:
+        if _copyfile2:
+            # Use fast OS routine for local file copying where available.
+            try:
+                source_p = os.fspath(source)
+                target_p = os.fspath(target)
+            except TypeError:
+                pass
+            else:
+                _copyfile2(source_p, target_p)
+                return
+        ensure_different_files(source, target)
+        with magic_open(source, 'rb') as source_f:
+            with magic_open(target, 'wb') as target_f:
+                _copyfileobj(source_f, target_f)
+        if preserve_metadata:
+            target._write_info(info)
+
+
+def copy_info(info, target, follow_symlinks=True):
+    """Copy metadata from the given PathInfo to the given local path."""
+    copy_times_ns = (
+        hasattr(info, '_access_time_ns') and
+        hasattr(info, '_mod_time_ns') and
+        (follow_symlinks or os.utime in os.supports_follow_symlinks))
+    if copy_times_ns:
+        t0 = info._access_time_ns(follow_symlinks=follow_symlinks)
+        t1 = info._mod_time_ns(follow_symlinks=follow_symlinks)
+        os.utime(target, ns=(t0, t1), follow_symlinks=follow_symlinks)
+
+    # We must copy extended attributes before the file is (potentially)
+    # chmod()'ed read-only, otherwise setxattr() will error with -EACCES.
+    copy_xattrs = (
+        hasattr(info, '_xattrs') and
+        hasattr(os, 'setxattr') and
+        (follow_symlinks or os.setxattr in os.supports_follow_symlinks))
+    if copy_xattrs:
+        xattrs = info._xattrs(follow_symlinks=follow_symlinks)
+        for attr, value in xattrs:
+            try:
+                os.setxattr(target, attr, value, 
follow_symlinks=follow_symlinks)
+            except OSError as e:
+                if e.errno not in (EPERM, ENOTSUP, ENODATA, EINVAL, EACCES):
+                    raise
+
+    copy_posix_permissions = (
+        hasattr(info, '_posix_permissions') and
+        (follow_symlinks or os.chmod in os.supports_follow_symlinks))
+    if copy_posix_permissions:
+        posix_permissions = 
info._posix_permissions(follow_symlinks=follow_symlinks)
+        try:
+            os.chmod(target, posix_permissions, 
follow_symlinks=follow_symlinks)
+        except NotImplementedError:
+            # if we got a NotImplementedError, it's because
+            #   * follow_symlinks=False,
+            #   * lchown() is unavailable, and
+            #   * either
+            #       * fchownat() is unavailable or
+            #       * fchownat() doesn't implement AT_SYMLINK_NOFOLLOW.
+            #         (it returned ENOSUP.)
+            # therefore we're out of options--we simply cannot chown the
+            # symlink.  give up, suppress the error.
+            # (which is what shutil always did in this circumstance.)
+            pass
+
+    copy_bsd_flags = (
+        hasattr(info, '_bsd_flags') and
+        hasattr(os, 'chflags') and
+        (follow_symlinks or os.chflags in os.supports_follow_symlinks))
+    if copy_bsd_flags:
+        bsd_flags = info._bsd_flags(follow_symlinks=follow_symlinks)
+        try:
+            os.chflags(target, bsd_flags, follow_symlinks=follow_symlinks)
+        except OSError as why:
+            if why.errno not in (EOPNOTSUPP, ENOTSUP):
+                raise
+
+
 class _PathInfoBase:
     __slots__ = ('_path', '_stat_result', '_lstat_result')
 
diff --git 
a/Misc/NEWS.d/next/Library/2025-02-21-20-16-32.gh-issue-125413.YJ7Msf.rst 
b/Misc/NEWS.d/next/Library/2025-02-21-20-16-32.gh-issue-125413.YJ7Msf.rst
new file mode 100644
index 00000000000000..b28c22faaf5034
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2025-02-21-20-16-32.gh-issue-125413.YJ7Msf.rst
@@ -0,0 +1,2 @@
+Speed up :meth:`Path.copy <pathlib.Path.copy>` by making better use of
+:attr:`~pathlib.Path.info` internally.

_______________________________________________
Python-checkins mailing list -- python-checkins@python.org
To unsubscribe send an email to python-checkins-le...@python.org
https://mail.python.org/mailman3/lists/python-checkins.python.org/
Member address: arch...@mail-archive.com

Reply via email to