From ff025ff3e3563fd31c69cf9445d7726223437cce Mon Sep 17 00:00:00 2001 From: barneygale Date: Thu, 20 Jun 2024 19:10:53 +0100 Subject: [PATCH 01/12] GH-73991: Support preserving metadata in `pathlib.Path.copy()` Add *preserve_metadata* keyword-only argument to `pathlib.Path.copy()`, defaulting to false. When set to true, we copy timestamps, permissions, extended attributes and flags where available, like `shutil.copystat()`. The argument has no effect on Windows, where metadata is always copied. In the pathlib ABCs we copy the file permissions with `PathBase.chmod()` where supported. In the future we might want to support a more generic public interface for copying metadata between different types of `PathBase` object, but it would be premature here. --- Doc/library/pathlib.rst | 21 +++++--- Lib/pathlib/_abc.py | 69 ++++++++++++++++++++++----- Lib/pathlib/_local.py | 49 +++++++++++++++++-- Lib/test/test_pathlib/test_pathlib.py | 40 ++++++++++++++++ 4 files changed, 157 insertions(+), 22 deletions(-) diff --git a/Doc/library/pathlib.rst b/Doc/library/pathlib.rst index 5bfcad0dadff6a..90f51e76df5cd5 100644 --- a/Doc/library/pathlib.rst +++ b/Doc/library/pathlib.rst @@ -1432,7 +1432,7 @@ Creating files and directories Copying, renaming and deleting ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -.. method:: Path.copy(target, *, follow_symlinks=True) +.. method:: Path.copy(target, *, follow_symlinks=True, preserve_metadata=False) Copy the contents of this file to the *target* file. If *target* specifies a file that already exists, it will be replaced. @@ -1441,11 +1441,11 @@ Copying, renaming and deleting will be created as a symbolic link. If *follow_symlinks* is true and this file is a symbolic link, *target* will be a copy of the symlink target. - .. note:: - This method uses operating system functionality to copy file content - efficiently. The OS might also copy some metadata, such as file - permissions. After the copy is complete, users may wish to call - :meth:`Path.chmod` to set the permissions of the target file. + If *preserve_metadata* is false (the default), only the file data is + guaranteed to be copied. Set *preserve_metadata* to true to ensure that the + file mode (permissions), flags, last access and modification times, and + extended attributes are all copied where supported. This argument has no + effect on Windows, where metadata is always preserved when copying. .. warning:: On old builds of Windows (before Windows 10 build 19041), this method @@ -1562,6 +1562,11 @@ Other methods .. versionchanged:: 3.10 The *follow_symlinks* parameter was added. + .. versionchanged:: 3.14 + Raises :exc:`UnsupportedOperation` if *follow_symlinks* is false and + :func:`os.chmod` doesn't support this setting. In previous versions, + :exc:`NotImplementedError` was raised. + .. method:: Path.expanduser() Return a new path with expanded ``~`` and ``~user`` constructs, @@ -1598,6 +1603,10 @@ Other methods Like :meth:`Path.chmod` but, if the path points to a symbolic link, the symbolic link's mode is changed rather than its target's. + .. versionchanged:: 3.14 + Raises :exc:`UnsupportedOperation` if :func:`os.chmod` doesn't support + setting *follow_symlinks* to false. In previous versions, + :exc:`NotImplementedError` was raised. .. method:: Path.owner(*, follow_symlinks=True) diff --git a/Lib/pathlib/_abc.py b/Lib/pathlib/_abc.py index f1f350a196091a..142541f9262cab 100644 --- a/Lib/pathlib/_abc.py +++ b/Lib/pathlib/_abc.py @@ -15,7 +15,7 @@ import operator import posixpath 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, S_IMODE from ._os import copyfileobj @@ -790,7 +790,7 @@ def mkdir(self, mode=0o777, parents=False, exist_ok=False): """ raise UnsupportedOperation(self._unsupported_msg('mkdir()')) - def copy(self, target, follow_symlinks=True): + def copy(self, target, *, follow_symlinks=True, preserve_metadata=False): """ Copy the contents of this file to the given target. If this file is a symlink and follow_symlinks is false, a symlink will be created at the @@ -802,18 +802,46 @@ def copy(self, target, follow_symlinks=True): 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()) - return - with self.open('rb') as source_f: + else: + with self.open('rb') as source_f: + try: + with target.open('wb') as target_f: + copyfileobj(source_f, target_f) + except IsADirectoryError as e: + if not target.exists(): + # Raise a less confusing exception. + raise FileNotFoundError( + f'Directory does not exist: {target}') from e + else: + raise + if preserve_metadata: + # Copy timestamps + st = self.stat(follow_symlinks=follow_symlinks) + try: + target._utime(ns=(st.st_atime_ns, st.st_mtime_ns), + follow_symlinks=follow_symlinks) + except UnsupportedOperation: + pass + # Copy extended attributes (xattrs) try: - with target.open('wb') as target_f: - copyfileobj(source_f, target_f) - except IsADirectoryError as e: - if not target.exists(): - # Raise a less confusing exception. - raise FileNotFoundError( - f'Directory does not exist: {target}') from e - else: - raise + for name in self._list_xattr(follow_symlinks=follow_symlinks): + value = self._get_xattr(name, follow_symlinks=follow_symlinks) + target._set_xattr(name, value, follow_symlinks=follow_symlinks) + except UnsupportedOperation: + pass + # Copy permissions (mode) + try: + target.chmod(mode=S_IMODE(st.st_mode), + follow_symlinks=follow_symlinks) + except UnsupportedOperation: + pass + # Copy flags + if hasattr(st, 'st_flags'): + try: + target._chflags(flags=st.st_flags, + follow_symlinks=follow_symlinks) + except (UnsupportedOperation, PermissionError): + pass def rename(self, target): """ @@ -839,6 +867,18 @@ def replace(self, target): """ raise UnsupportedOperation(self._unsupported_msg('replace()')) + def _utime(self, ns, *, follow_symlinks=True): + raise UnsupportedOperation(self._unsupported_msg('_utime()')) + + def _list_xattr(self, *, follow_symlinks=True): + raise UnsupportedOperation(self._unsupported_msg('_list_xattr()')) + + def _get_xattr(self, name, *, follow_symlinks=True): + raise UnsupportedOperation(self._unsupported_msg('_get_xattr()')) + + def _set_xattr(self, name, value, *, follow_symlinks=True): + raise UnsupportedOperation(self._unsupported_msg('_set_xattr()')) + def chmod(self, mode, *, follow_symlinks=True): """ Change the permissions of the path, like os.chmod(). @@ -852,6 +892,9 @@ def lchmod(self, mode): """ self.chmod(mode, follow_symlinks=False) + def _chflags(self, flags, *, follow_symlinks=True): + raise UnsupportedOperation(self._unsupported_msg('_chflags()')) + def unlink(self, missing_ok=False): """ Remove this file or link. diff --git a/Lib/pathlib/_local.py b/Lib/pathlib/_local.py index 0105ea3042422e..31c309f3610e9a 100644 --- a/Lib/pathlib/_local.py +++ b/Lib/pathlib/_local.py @@ -1,3 +1,4 @@ +import errno import io import ntpath import operator @@ -782,7 +783,7 @@ def mkdir(self, mode=0o777, parents=False, exist_ok=False): raise if copyfile: - def copy(self, target, follow_symlinks=True): + def copy(self, target, *, follow_symlinks=True, preserve_metadata=False): """ Copy the contents of this file to the given target. If this file is a symlink and follow_symlinks is false, a symlink will be created at the @@ -794,15 +795,57 @@ def copy(self, target, follow_symlinks=True): if isinstance(target, PathBase): # Target is an instance of PathBase but not os.PathLike. # Use generic implementation from PathBase. - return PathBase.copy(self, target, follow_symlinks=follow_symlinks) + return PathBase.copy(self, target, + follow_symlinks=follow_symlinks, + preserve_metadata=preserve_metadata) raise copyfile(os.fspath(self), target, follow_symlinks) + def _utime(self, ns, *, follow_symlinks=True): + return os.utime(self, ns=ns, follow_symlinks=follow_symlinks) + + if hasattr(os, 'listxattr'): + def _list_xattr(self, *, follow_symlinks=True): + try: + return os.listxattr(self, follow_symlinks=follow_symlinks) + except OSError as err: + if err.errno in (errno.ENOTSUP, errno.ENODATA, errno.EINVAL): + raise UnsupportedOperation(str(err)) from None + return + + def _get_xattr(self, name, *, follow_symlinks=True): + try: + return os.getxattr(self, name, follow_symlinks=follow_symlinks) + except OSError as err: + if e.errno in (errno.ENOTSUP, errno.ENODATA, errno.EINVAL): + raise UnsupportedOperation(str(err)) from None + return + + def _set_xattr(self, name, value, *, follow_symlinks=True): + try: + return os.setxattr(self, name, value, follow_symlinks=follow_symlinks) + except OSError as err: + if e.errno in (errno.ENOTSUP, errno.ENODATA, errno.EINVAL): + raise UnsupportedOperation(str(err)) from None + return + def chmod(self, mode, *, follow_symlinks=True): """ Change the permissions of the path, like os.chmod(). """ - os.chmod(self, mode, follow_symlinks=follow_symlinks) + try: + os.chmod(self, mode, follow_symlinks=follow_symlinks) + except NotImplementedError as err: + raise UnsupportedOperation(str(err)) from None + + if hasattr(os, 'chflags'): + def _chflags(self, flags, *, follow_symlinks=True): + try: + os.chflags(self, flags, follow_symlinks=follow_symlinks) + except OSError as err: + if err.errno in (errno.ENOTSUP, errno.EOPNOTSUPP): + raise UnsupportedOperation(str(err)) from None + raise def unlink(self, missing_ok=False): """ diff --git a/Lib/test/test_pathlib/test_pathlib.py b/Lib/test/test_pathlib/test_pathlib.py index 89af1f7581764f..306b994def8934 100644 --- a/Lib/test/test_pathlib/test_pathlib.py +++ b/Lib/test/test_pathlib/test_pathlib.py @@ -653,6 +653,46 @@ def test_open_unbuffered(self): self.assertIsInstance(f, io.RawIOBase) self.assertEqual(f.read().strip(), b"this is file A") + def test_copy_file_preserve_metadata(self): + base = self.cls(self.base) + source = base / 'fileA' + if hasattr(os, 'setxattr'): + os.setxattr(source, b'user.foo', b'42') + if hasattr(os, 'chmod'): + os.chmod(source, stat.S_IRWXU | stat.S_IRWXO) + if hasattr(os, 'chflags') and hasattr(stat, 'UF_NODUMP'): + os.chflags(source, stat.UF_NODUMP) + source_st = source.stat() + target = base / 'copyA' + source.copy(target, preserve_metadata=True) + self.assertTrue(target.exists()) + self.assertEqual(source.read_text(), target.read_text()) + target_st = target.stat() + if hasattr(os, 'getxattr'): + self.assertEqual(os.getxattr(target, b'user.foo'), b'42') + self.assertEqual(target_st.st_mode, source_st.st_mode) + if hasattr(source_st, 'st_flags'): + self.assertEqual(source_st.st_flags, target_st.st_flags) + + @needs_symlinks + def test_copy_link_preserve_metadata(self): + base = self.cls(self.base) + source = base / 'linkA' + if hasattr(os, 'lchmod'): + os.lchmod(source, stat.S_IRWXU | stat.S_IRWXO) + if hasattr(os, 'lchflags') and hasattr(stat, 'UF_NODUMP'): + os.lchflags(source, stat.UF_NODUMP) + source_st = source.lstat() + target = base / 'copyA' + source.copy(target, follow_symlinks=False, preserve_metadata=True) + self.assertTrue(target.exists()) + self.assertTrue(target.is_symlink()) + self.assertEqual(source.readlink(), target.readlink()) + target_st = target.lstat() + self.assertEqual(target_st.st_mode, source_st.st_mode) + if hasattr(source_st, 'st_flags'): + self.assertEqual(source_st.st_flags, target_st.st_flags) + def test_resolve_nonexist_relative_issue38671(self): p = self.cls('non', 'exist') From 720a0e0f65a53db1b175fed2c4e9a4a3f711261d Mon Sep 17 00:00:00 2001 From: barneygale Date: Thu, 20 Jun 2024 19:20:52 +0100 Subject: [PATCH 02/12] Test timestamps --- Lib/test/test_pathlib/test_pathlib.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_pathlib/test_pathlib.py b/Lib/test/test_pathlib/test_pathlib.py index 306b994def8934..808af68beb91b4 100644 --- a/Lib/test/test_pathlib/test_pathlib.py +++ b/Lib/test/test_pathlib/test_pathlib.py @@ -668,9 +668,11 @@ def test_copy_file_preserve_metadata(self): self.assertTrue(target.exists()) self.assertEqual(source.read_text(), target.read_text()) target_st = target.stat() + self.assertLessEqual(source_st.st_atime, target_st.st_atime) + self.assertLessEqual(source_st.st_mtime, target_st.st_mtime) if hasattr(os, 'getxattr'): self.assertEqual(os.getxattr(target, b'user.foo'), b'42') - self.assertEqual(target_st.st_mode, source_st.st_mode) + self.assertEqual(source_st.st_mode, target_st.st_mode) if hasattr(source_st, 'st_flags'): self.assertEqual(source_st.st_flags, target_st.st_flags) @@ -689,7 +691,9 @@ def test_copy_link_preserve_metadata(self): self.assertTrue(target.is_symlink()) self.assertEqual(source.readlink(), target.readlink()) target_st = target.lstat() - self.assertEqual(target_st.st_mode, source_st.st_mode) + self.assertLessEqual(source_st.st_atime, target_st.st_atime) + self.assertLessEqual(source_st.st_mtime, target_st.st_mtime) + self.assertEqual(source_st.st_mode, target_st.st_mode) if hasattr(source_st, 'st_flags'): self.assertEqual(source_st.st_flags, target_st.st_flags) From c14d16f8a3e807a7234987531a6e8e3e755c976b Mon Sep 17 00:00:00 2001 From: barneygale Date: Fri, 21 Jun 2024 22:28:48 +0100 Subject: [PATCH 03/12] Typos --- Lib/pathlib/_local.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Lib/pathlib/_local.py b/Lib/pathlib/_local.py index 31c309f3610e9a..8d01a98f80daaa 100644 --- a/Lib/pathlib/_local.py +++ b/Lib/pathlib/_local.py @@ -811,23 +811,23 @@ def _list_xattr(self, *, follow_symlinks=True): except OSError as err: if err.errno in (errno.ENOTSUP, errno.ENODATA, errno.EINVAL): raise UnsupportedOperation(str(err)) from None - return + raise def _get_xattr(self, name, *, follow_symlinks=True): try: return os.getxattr(self, name, follow_symlinks=follow_symlinks) except OSError as err: - if e.errno in (errno.ENOTSUP, errno.ENODATA, errno.EINVAL): + if err.errno in (errno.ENOTSUP, errno.ENODATA, errno.EINVAL): raise UnsupportedOperation(str(err)) from None - return + raise def _set_xattr(self, name, value, *, follow_symlinks=True): try: return os.setxattr(self, name, value, follow_symlinks=follow_symlinks) except OSError as err: - if e.errno in (errno.ENOTSUP, errno.ENODATA, errno.EINVAL): + if err.errno in (errno.ENOTSUP, errno.ENODATA, errno.EINVAL): raise UnsupportedOperation(str(err)) from None - return + raise def chmod(self, mode, *, follow_symlinks=True): """ From f54925c8e086deb02c100f308e143678df12ded4 Mon Sep 17 00:00:00 2001 From: barneygale Date: Fri, 21 Jun 2024 22:59:07 +0100 Subject: [PATCH 04/12] Use code from shutil. --- Doc/library/pathlib.rst | 9 ----- Lib/pathlib/_abc.py | 77 ++++++++++++--------------------------- Lib/pathlib/_local.py | 46 +++--------------------- Lib/pathlib/_os.py | 77 ++++++++++++++++++++++++++++++++++++++- Lib/shutil.py | 79 ++--------------------------------------- 5 files changed, 104 insertions(+), 184 deletions(-) diff --git a/Doc/library/pathlib.rst b/Doc/library/pathlib.rst index 90f51e76df5cd5..f49aa25d735e20 100644 --- a/Doc/library/pathlib.rst +++ b/Doc/library/pathlib.rst @@ -1562,11 +1562,6 @@ Other methods .. versionchanged:: 3.10 The *follow_symlinks* parameter was added. - .. versionchanged:: 3.14 - Raises :exc:`UnsupportedOperation` if *follow_symlinks* is false and - :func:`os.chmod` doesn't support this setting. In previous versions, - :exc:`NotImplementedError` was raised. - .. method:: Path.expanduser() Return a new path with expanded ``~`` and ``~user`` constructs, @@ -1603,10 +1598,6 @@ Other methods Like :meth:`Path.chmod` but, if the path points to a symbolic link, the symbolic link's mode is changed rather than its target's. - .. versionchanged:: 3.14 - Raises :exc:`UnsupportedOperation` if :func:`os.chmod` doesn't support - setting *follow_symlinks* to false. In previous versions, - :exc:`NotImplementedError` was raised. .. method:: Path.owner(*, follow_symlinks=True) diff --git a/Lib/pathlib/_abc.py b/Lib/pathlib/_abc.py index 142541f9262cab..b379df8cb6610b 100644 --- a/Lib/pathlib/_abc.py +++ b/Lib/pathlib/_abc.py @@ -15,7 +15,7 @@ import operator import posixpath from glob import _GlobberBase, _no_recurse_symlinks -from stat import S_ISDIR, S_ISLNK, S_ISREG, S_ISSOCK, S_ISBLK, S_ISCHR, S_ISFIFO, S_IMODE +from stat import S_ISDIR, S_ISLNK, S_ISREG, S_ISSOCK, S_ISBLK, S_ISCHR, S_ISFIFO from ._os import copyfileobj @@ -802,46 +802,28 @@ def copy(self, target, *, follow_symlinks=True, preserve_metadata=False): 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()) - else: - with self.open('rb') as source_f: - try: - with target.open('wb') as target_f: - copyfileobj(source_f, target_f) - except IsADirectoryError as e: - if not target.exists(): - # Raise a less confusing exception. - raise FileNotFoundError( - f'Directory does not exist: {target}') from e - else: - raise - if preserve_metadata: - # Copy timestamps - st = self.stat(follow_symlinks=follow_symlinks) - try: - target._utime(ns=(st.st_atime_ns, st.st_mtime_ns), - follow_symlinks=follow_symlinks) - except UnsupportedOperation: - pass - # Copy extended attributes (xattrs) + if preserve_metadata: + target._set_metadata(self._get_metadata(False), False) + return + with self.open('rb') as source_f: try: - for name in self._list_xattr(follow_symlinks=follow_symlinks): - value = self._get_xattr(name, follow_symlinks=follow_symlinks) - target._set_xattr(name, value, follow_symlinks=follow_symlinks) - except UnsupportedOperation: - pass - # Copy permissions (mode) - try: - target.chmod(mode=S_IMODE(st.st_mode), - follow_symlinks=follow_symlinks) - except UnsupportedOperation: - pass - # Copy flags - if hasattr(st, 'st_flags'): - try: - target._chflags(flags=st.st_flags, - follow_symlinks=follow_symlinks) - except (UnsupportedOperation, PermissionError): - pass + with target.open('wb') as target_f: + copyfileobj(source_f, target_f) + except IsADirectoryError as e: + if not target.exists(): + # Raise a less confusing exception. + raise FileNotFoundError( + f'Directory does not exist: {target}') from e + else: + raise + if preserve_metadata: + target._set_metadata(self._get_metadata(True), True) + + def _get_metadata(self, follow_symlinks): + return {} + + def _set_metadata(self, metadata, follow_symlinks): + pass def rename(self, target): """ @@ -867,18 +849,6 @@ def replace(self, target): """ raise UnsupportedOperation(self._unsupported_msg('replace()')) - def _utime(self, ns, *, follow_symlinks=True): - raise UnsupportedOperation(self._unsupported_msg('_utime()')) - - def _list_xattr(self, *, follow_symlinks=True): - raise UnsupportedOperation(self._unsupported_msg('_list_xattr()')) - - def _get_xattr(self, name, *, follow_symlinks=True): - raise UnsupportedOperation(self._unsupported_msg('_get_xattr()')) - - def _set_xattr(self, name, value, *, follow_symlinks=True): - raise UnsupportedOperation(self._unsupported_msg('_set_xattr()')) - def chmod(self, mode, *, follow_symlinks=True): """ Change the permissions of the path, like os.chmod(). @@ -892,9 +862,6 @@ def lchmod(self, mode): """ self.chmod(mode, follow_symlinks=False) - def _chflags(self, flags, *, follow_symlinks=True): - raise UnsupportedOperation(self._unsupported_msg('_chflags()')) - def unlink(self, missing_ok=False): """ Remove this file or link. diff --git a/Lib/pathlib/_local.py b/Lib/pathlib/_local.py index 8d01a98f80daaa..5d22982e336ce2 100644 --- a/Lib/pathlib/_local.py +++ b/Lib/pathlib/_local.py @@ -1,4 +1,3 @@ -import errno import io import ntpath import operator @@ -19,7 +18,7 @@ grp = None from ._abc import UnsupportedOperation, PurePathBase, PathBase -from ._os import copyfile +from ._os import copyfile, get_file_metadata, set_file_metadata __all__ = [ @@ -801,51 +800,14 @@ def copy(self, target, *, follow_symlinks=True, preserve_metadata=False): raise copyfile(os.fspath(self), target, follow_symlinks) - def _utime(self, ns, *, follow_symlinks=True): - return os.utime(self, ns=ns, follow_symlinks=follow_symlinks) - - if hasattr(os, 'listxattr'): - def _list_xattr(self, *, follow_symlinks=True): - try: - return os.listxattr(self, follow_symlinks=follow_symlinks) - except OSError as err: - if err.errno in (errno.ENOTSUP, errno.ENODATA, errno.EINVAL): - raise UnsupportedOperation(str(err)) from None - raise - - def _get_xattr(self, name, *, follow_symlinks=True): - try: - return os.getxattr(self, name, follow_symlinks=follow_symlinks) - except OSError as err: - if err.errno in (errno.ENOTSUP, errno.ENODATA, errno.EINVAL): - raise UnsupportedOperation(str(err)) from None - raise - - def _set_xattr(self, name, value, *, follow_symlinks=True): - try: - return os.setxattr(self, name, value, follow_symlinks=follow_symlinks) - except OSError as err: - if err.errno in (errno.ENOTSUP, errno.ENODATA, errno.EINVAL): - raise UnsupportedOperation(str(err)) from None - raise + _get_metadata = get_file_metadata + _set_metadata = set_file_metadata def chmod(self, mode, *, follow_symlinks=True): """ Change the permissions of the path, like os.chmod(). """ - try: - os.chmod(self, mode, follow_symlinks=follow_symlinks) - except NotImplementedError as err: - raise UnsupportedOperation(str(err)) from None - - if hasattr(os, 'chflags'): - def _chflags(self, flags, *, follow_symlinks=True): - try: - os.chflags(self, flags, follow_symlinks=follow_symlinks) - except OSError as err: - if err.errno in (errno.ENOTSUP, errno.EOPNOTSUPP): - raise UnsupportedOperation(str(err)) from None - raise + os.chmod(self, mode, follow_symlinks=follow_symlinks) def unlink(self, missing_ok=False): """ diff --git a/Lib/pathlib/_os.py b/Lib/pathlib/_os.py index bbb019b6534503..b818a379204571 100644 --- a/Lib/pathlib/_os.py +++ b/Lib/pathlib/_os.py @@ -2,7 +2,7 @@ Low-level OS functionality wrappers used by pathlib. """ -from errno import EBADF, EOPNOTSUPP, ETXTBSY, EXDEV +from errno import * import os import stat import sys @@ -157,3 +157,78 @@ def copyfileobj(source_f, target_f): write_target = target_f.write while buf := read_source(1024 * 1024): write_target(buf) + + +def get_file_metadata(path, follow_symlinks): + if isinstance(path, os.DirEntry): + st = path.stat(follow_symlinks=follow_symlinks) + else: + st = os.stat(path, follow_symlinks=follow_symlinks) + result = { + 'mode': stat.S_IMODE(st.st_mode), + 'atime_ns': st.st_atime_ns, + 'mtime_ns': st.st_mtime_ns, + } + if hasattr(os, 'listxattr'): + try: + result['xattrs'] = [ + (attr, os.getxattr(path, attr, follow_symlinks=follow_symlinks)) + for attr in os.listxattr(path, follow_symlinks=follow_symlinks)] + except OSError as err: + if err.errno not in (EPERM, ENOTSUP, ENODATA, EINVAL, EACCES): + raise + if hasattr(st, 'st_flags'): + result['flags'] = st.st_flags + return result + + +def set_file_metadata(path, metadata, follow_symlinks): + def _nop(*args, ns=None, follow_symlinks=None): + pass + + if follow_symlinks: + # use the real function if it exists + def lookup(name): + return getattr(os, name, _nop) + else: + # use the real function only if it exists + # *and* it supports follow_symlinks + def lookup(name): + fn = getattr(os, name, _nop) + if fn in os.supports_follow_symlinks: + return fn + return _nop + + lookup("utime")(path, ns=(metadata['atime_ns'], metadata['mtime_ns']), + follow_symlinks=follow_symlinks) + # We must copy extended attributes before the file is (potentially) + # chmod()'ed read-only, otherwise setxattr() will error with -EACCES. + xattrs = metadata.get('xattrs') + if xattrs: + for attr, value in xattrs: + try: + os.setxattr(path, attr, value, follow_symlinks=follow_symlinks) + except OSError as e: + if e.errno not in (EPERM, ENOTSUP, ENODATA, EINVAL, EACCES): + raise + try: + lookup("chmod")(path, metadata['mode'], 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 + flags = metadata.get('flags') + if flags: + try: + lookup("chflags")(path, flags, follow_symlinks=follow_symlinks) + except OSError as why: + if why.errno not in (EOPNOTSUPP, ENOTSUP): + raise diff --git a/Lib/shutil.py b/Lib/shutil.py index 0235f6bae32f14..9995289701c528 100644 --- a/Lib/shutil.py +++ b/Lib/shutil.py @@ -10,6 +10,7 @@ import fnmatch import collections import errno +from pathlib._os import get_file_metadata, set_file_metadata try: import zlib @@ -317,34 +318,6 @@ def chmod_func(*args): st = stat_func(src) chmod_func(dst, stat.S_IMODE(st.st_mode)) -if hasattr(os, 'listxattr'): - def _copyxattr(src, dst, *, follow_symlinks=True): - """Copy extended filesystem attributes from `src` to `dst`. - - Overwrite existing attributes. - - If `follow_symlinks` is false, symlinks won't be followed. - - """ - - try: - names = os.listxattr(src, follow_symlinks=follow_symlinks) - except OSError as e: - if e.errno not in (errno.ENOTSUP, errno.ENODATA, errno.EINVAL): - raise - return - for name in names: - try: - value = os.getxattr(src, name, follow_symlinks=follow_symlinks) - os.setxattr(dst, name, value, follow_symlinks=follow_symlinks) - except OSError as e: - if e.errno not in (errno.EPERM, errno.ENOTSUP, errno.ENODATA, - errno.EINVAL, errno.EACCES): - raise -else: - def _copyxattr(*args, **kwargs): - pass - def copystat(src, dst, *, follow_symlinks=True): """Copy file metadata @@ -359,57 +332,9 @@ def copystat(src, dst, *, follow_symlinks=True): """ sys.audit("shutil.copystat", src, dst) - def _nop(*args, ns=None, follow_symlinks=None): - pass - # follow symlinks (aka don't not follow symlinks) follow = follow_symlinks or not (_islink(src) and os.path.islink(dst)) - if follow: - # use the real function if it exists - def lookup(name): - return getattr(os, name, _nop) - else: - # use the real function only if it exists - # *and* it supports follow_symlinks - def lookup(name): - fn = getattr(os, name, _nop) - if fn in os.supports_follow_symlinks: - return fn - return _nop - - if isinstance(src, os.DirEntry): - st = src.stat(follow_symlinks=follow) - else: - st = lookup("stat")(src, follow_symlinks=follow) - mode = stat.S_IMODE(st.st_mode) - lookup("utime")(dst, ns=(st.st_atime_ns, st.st_mtime_ns), - follow_symlinks=follow) - # We must copy extended attributes before the file is (potentially) - # chmod()'ed read-only, otherwise setxattr() will error with -EACCES. - _copyxattr(src, dst, follow_symlinks=follow) - try: - lookup("chmod")(dst, mode, follow_symlinks=follow) - 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 - if hasattr(st, 'st_flags'): - try: - lookup("chflags")(dst, st.st_flags, follow_symlinks=follow) - except OSError as why: - for err in 'EOPNOTSUPP', 'ENOTSUP': - if hasattr(errno, err) and why.errno == getattr(errno, err): - break - else: - raise + set_file_metadata(dst, get_file_metadata(src, follow), follow) def copy(src, dst, *, follow_symlinks=True): """Copy data and mode bits ("cp src dst"). Return the file's destination. From b05256b9fab329d865221b471df97c6952fa7834 Mon Sep 17 00:00:00 2001 From: barneygale Date: Sat, 22 Jun 2024 00:09:14 +0100 Subject: [PATCH 05/12] Tighten conditions. --- Lib/pathlib/_os.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/pathlib/_os.py b/Lib/pathlib/_os.py index b818a379204571..155fa04da8a2fa 100644 --- a/Lib/pathlib/_os.py +++ b/Lib/pathlib/_os.py @@ -204,7 +204,7 @@ def lookup(name): # We must copy extended attributes before the file is (potentially) # chmod()'ed read-only, otherwise setxattr() will error with -EACCES. xattrs = metadata.get('xattrs') - if xattrs: + if xattrs is not None: for attr, value in xattrs: try: os.setxattr(path, attr, value, follow_symlinks=follow_symlinks) @@ -226,7 +226,7 @@ def lookup(name): # (which is what shutil always did in this circumstance.) pass flags = metadata.get('flags') - if flags: + if flags is not None: try: lookup("chflags")(path, flags, follow_symlinks=follow_symlinks) except OSError as why: From 211b861bf35038f11deaf8151602e8329776ca64 Mon Sep 17 00:00:00 2001 From: barneygale Date: Mon, 24 Jun 2024 19:03:31 +0100 Subject: [PATCH 06/12] Undo shutil changes. --- Lib/shutil.py | 79 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/Lib/shutil.py b/Lib/shutil.py index 9995289701c528..0235f6bae32f14 100644 --- a/Lib/shutil.py +++ b/Lib/shutil.py @@ -10,7 +10,6 @@ import fnmatch import collections import errno -from pathlib._os import get_file_metadata, set_file_metadata try: import zlib @@ -318,6 +317,34 @@ def chmod_func(*args): st = stat_func(src) chmod_func(dst, stat.S_IMODE(st.st_mode)) +if hasattr(os, 'listxattr'): + def _copyxattr(src, dst, *, follow_symlinks=True): + """Copy extended filesystem attributes from `src` to `dst`. + + Overwrite existing attributes. + + If `follow_symlinks` is false, symlinks won't be followed. + + """ + + try: + names = os.listxattr(src, follow_symlinks=follow_symlinks) + except OSError as e: + if e.errno not in (errno.ENOTSUP, errno.ENODATA, errno.EINVAL): + raise + return + for name in names: + try: + value = os.getxattr(src, name, follow_symlinks=follow_symlinks) + os.setxattr(dst, name, value, follow_symlinks=follow_symlinks) + except OSError as e: + if e.errno not in (errno.EPERM, errno.ENOTSUP, errno.ENODATA, + errno.EINVAL, errno.EACCES): + raise +else: + def _copyxattr(*args, **kwargs): + pass + def copystat(src, dst, *, follow_symlinks=True): """Copy file metadata @@ -332,9 +359,57 @@ def copystat(src, dst, *, follow_symlinks=True): """ sys.audit("shutil.copystat", src, dst) + def _nop(*args, ns=None, follow_symlinks=None): + pass + # follow symlinks (aka don't not follow symlinks) follow = follow_symlinks or not (_islink(src) and os.path.islink(dst)) - set_file_metadata(dst, get_file_metadata(src, follow), follow) + if follow: + # use the real function if it exists + def lookup(name): + return getattr(os, name, _nop) + else: + # use the real function only if it exists + # *and* it supports follow_symlinks + def lookup(name): + fn = getattr(os, name, _nop) + if fn in os.supports_follow_symlinks: + return fn + return _nop + + if isinstance(src, os.DirEntry): + st = src.stat(follow_symlinks=follow) + else: + st = lookup("stat")(src, follow_symlinks=follow) + mode = stat.S_IMODE(st.st_mode) + lookup("utime")(dst, ns=(st.st_atime_ns, st.st_mtime_ns), + follow_symlinks=follow) + # We must copy extended attributes before the file is (potentially) + # chmod()'ed read-only, otherwise setxattr() will error with -EACCES. + _copyxattr(src, dst, follow_symlinks=follow) + try: + lookup("chmod")(dst, mode, follow_symlinks=follow) + 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 + if hasattr(st, 'st_flags'): + try: + lookup("chflags")(dst, st.st_flags, follow_symlinks=follow) + except OSError as why: + for err in 'EOPNOTSUPP', 'ENOTSUP': + if hasattr(errno, err) and why.errno == getattr(errno, err): + break + else: + raise def copy(src, dst, *, follow_symlinks=True): """Copy data and mode bits ("cp src dst"). Return the file's destination. From 8129c4670f33b94933cb2e44e7f01418aef5dd35 Mon Sep 17 00:00:00 2001 From: barneygale Date: Mon, 24 Jun 2024 19:15:24 +0100 Subject: [PATCH 07/12] Remove some code that's now unused. --- Lib/pathlib/_os.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/Lib/pathlib/_os.py b/Lib/pathlib/_os.py index 155fa04da8a2fa..e5be15adbfe8b9 100644 --- a/Lib/pathlib/_os.py +++ b/Lib/pathlib/_os.py @@ -160,15 +160,14 @@ def copyfileobj(source_f, target_f): def get_file_metadata(path, follow_symlinks): - if isinstance(path, os.DirEntry): - st = path.stat(follow_symlinks=follow_symlinks) - else: - st = os.stat(path, follow_symlinks=follow_symlinks) + st = os.stat(path, follow_symlinks=follow_symlinks) result = { 'mode': stat.S_IMODE(st.st_mode), 'atime_ns': st.st_atime_ns, 'mtime_ns': st.st_mtime_ns, } + if hasattr(st, 'st_flags'): + result['flags'] = st.st_flags if hasattr(os, 'listxattr'): try: result['xattrs'] = [ @@ -177,8 +176,6 @@ def get_file_metadata(path, follow_symlinks): except OSError as err: if err.errno not in (EPERM, ENOTSUP, ENODATA, EINVAL, EACCES): raise - if hasattr(st, 'st_flags'): - result['flags'] = st.st_flags return result From 15cc3db3e8ef1742b5c627578e4683ee0e994312 Mon Sep 17 00:00:00 2001 From: barneygale Date: Thu, 27 Jun 2024 19:17:43 +0100 Subject: [PATCH 08/12] docstrings, re-order methods. --- Lib/pathlib/_abc.py | 18 ++++++++++++------ Lib/pathlib/_local.py | 6 +++--- Lib/pathlib/_os.py | 6 ++++++ 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/Lib/pathlib/_abc.py b/Lib/pathlib/_abc.py index b460a4e2a669ab..59b4fa6d71fcd7 100644 --- a/Lib/pathlib/_abc.py +++ b/Lib/pathlib/_abc.py @@ -790,6 +790,18 @@ def mkdir(self, mode=0o777, parents=False, exist_ok=False): """ raise UnsupportedOperation(self._unsupported_msg('mkdir()')) + def _get_metadata(self, follow_symlinks): + """ + Returns path metadata as a dict with string keys. + """ + return {} + + def _set_metadata(self, metadata, follow_symlinks): + """ + Sets path metadata from the given dict with string keys. + """ + pass + def copy(self, target, *, follow_symlinks=True, preserve_metadata=False): """ Copy the contents of this file to the given target. If this file is a @@ -819,12 +831,6 @@ def copy(self, target, *, follow_symlinks=True, preserve_metadata=False): if preserve_metadata: target._set_metadata(self._get_metadata(True), True) - def _get_metadata(self, follow_symlinks): - return {} - - def _set_metadata(self, metadata, follow_symlinks): - pass - def copytree(self, target, *, follow_symlinks=True, dirs_exist_ok=False, ignore=None, on_error=None): """ diff --git a/Lib/pathlib/_local.py b/Lib/pathlib/_local.py index 5d22982e336ce2..c4ec725bd51400 100644 --- a/Lib/pathlib/_local.py +++ b/Lib/pathlib/_local.py @@ -781,6 +781,9 @@ def mkdir(self, mode=0o777, parents=False, exist_ok=False): if not exist_ok or not self.is_dir(): raise + _get_metadata = get_file_metadata + _set_metadata = set_file_metadata + if copyfile: def copy(self, target, *, follow_symlinks=True, preserve_metadata=False): """ @@ -800,9 +803,6 @@ def copy(self, target, *, follow_symlinks=True, preserve_metadata=False): raise copyfile(os.fspath(self), target, follow_symlinks) - _get_metadata = get_file_metadata - _set_metadata = set_file_metadata - def chmod(self, mode, *, follow_symlinks=True): """ Change the permissions of the path, like os.chmod(). diff --git a/Lib/pathlib/_os.py b/Lib/pathlib/_os.py index e5be15adbfe8b9..51f5b8b4d07df8 100644 --- a/Lib/pathlib/_os.py +++ b/Lib/pathlib/_os.py @@ -160,6 +160,9 @@ def copyfileobj(source_f, target_f): def get_file_metadata(path, follow_symlinks): + """ + Returns local path metadata as a dict with string keys. + """ st = os.stat(path, follow_symlinks=follow_symlinks) result = { 'mode': stat.S_IMODE(st.st_mode), @@ -180,6 +183,9 @@ def get_file_metadata(path, follow_symlinks): def set_file_metadata(path, metadata, follow_symlinks): + """ + Sets local path metadata from the given dict with string keys. + """ def _nop(*args, ns=None, follow_symlinks=None): pass From 89dfad5d4975241219d248d30107ca6221fe5f35 Mon Sep 17 00:00:00 2001 From: barneygale Date: Thu, 27 Jun 2024 19:47:56 +0100 Subject: [PATCH 09/12] Skip retrieving metadata if we're unable to set it on the target. --- Lib/pathlib/_abc.py | 21 ++++++++--- Lib/pathlib/_local.py | 3 +- Lib/pathlib/_os.py | 82 ++++++++++++++++++++++++++----------------- 3 files changed, 67 insertions(+), 39 deletions(-) diff --git a/Lib/pathlib/_abc.py b/Lib/pathlib/_abc.py index 59b4fa6d71fcd7..242397cd2a3efc 100644 --- a/Lib/pathlib/_abc.py +++ b/Lib/pathlib/_abc.py @@ -790,17 +790,28 @@ def mkdir(self, mode=0o777, parents=False, exist_ok=False): """ raise UnsupportedOperation(self._unsupported_msg('mkdir()')) - def _get_metadata(self, follow_symlinks): + _metadata_keys = frozenset() + + def _get_metadata(self, keys, follow_symlinks): """ Returns path metadata as a dict with string keys. """ - return {} + raise UnsupportedOperation(self._unsupported_msg('_get_metadata()')) def _set_metadata(self, metadata, follow_symlinks): """ Sets path metadata from the given dict with string keys. """ - pass + raise UnsupportedOperation(self._unsupported_msg('_set_metadata()')) + + def _copy_metadata(self, target, follow_symlinks): + """ + Copies metadata (permissions, timestamps, etc) from this path to target. + """ + metadata_keys = self._metadata_keys & target._metadata_keys + if metadata_keys: + metadata = self._get_metadata(metadata_keys, follow_symlinks) + target._set_metadata(metadata, follow_symlinks) def copy(self, target, *, follow_symlinks=True, preserve_metadata=False): """ @@ -815,7 +826,7 @@ def copy(self, target, *, follow_symlinks=True, preserve_metadata=False): if not follow_symlinks and self.is_symlink(): target.symlink_to(self.readlink()) if preserve_metadata: - target._set_metadata(self._get_metadata(False), False) + self._copy_metadata(target, False) return with self.open('rb') as source_f: try: @@ -829,7 +840,7 @@ def copy(self, target, *, follow_symlinks=True, preserve_metadata=False): else: raise if preserve_metadata: - target._set_metadata(self._get_metadata(True), True) + self._copy_metadata(target, True) def copytree(self, target, *, follow_symlinks=True, dirs_exist_ok=False, ignore=None, on_error=None): diff --git a/Lib/pathlib/_local.py b/Lib/pathlib/_local.py index c4ec725bd51400..4674587fe5f0fb 100644 --- a/Lib/pathlib/_local.py +++ b/Lib/pathlib/_local.py @@ -18,7 +18,7 @@ grp = None from ._abc import UnsupportedOperation, PurePathBase, PathBase -from ._os import copyfile, get_file_metadata, set_file_metadata +from ._os import copyfile, file_metadata_keys, get_file_metadata, set_file_metadata __all__ = [ @@ -781,6 +781,7 @@ def mkdir(self, mode=0o777, parents=False, exist_ok=False): if not exist_ok or not self.is_dir(): raise + _metadata_keys = file_metadata_keys _get_metadata = get_file_metadata _set_metadata = set_file_metadata diff --git a/Lib/pathlib/_os.py b/Lib/pathlib/_os.py index 51f5b8b4d07df8..75f9f2b270060d 100644 --- a/Lib/pathlib/_os.py +++ b/Lib/pathlib/_os.py @@ -159,26 +159,37 @@ def copyfileobj(source_f, target_f): write_target(buf) -def get_file_metadata(path, follow_symlinks): +file_metadata_keys = {'mode', 'times_ns'} +if hasattr(os.stat_result, 'st_flags'): + file_metadata_keys.add('flags') +if hasattr(os, 'listxattr'): + file_metadata_keys.add('xattrs') +file_metadata_keys = frozenset(file_metadata_keys) + + +def get_file_metadata(path, keys, follow_symlinks): """ Returns local path metadata as a dict with string keys. """ - st = os.stat(path, follow_symlinks=follow_symlinks) - result = { - 'mode': stat.S_IMODE(st.st_mode), - 'atime_ns': st.st_atime_ns, - 'mtime_ns': st.st_mtime_ns, - } - if hasattr(st, 'st_flags'): - result['flags'] = st.st_flags - if hasattr(os, 'listxattr'): - try: - result['xattrs'] = [ - (attr, os.getxattr(path, attr, follow_symlinks=follow_symlinks)) - for attr in os.listxattr(path, follow_symlinks=follow_symlinks)] - except OSError as err: - if err.errno not in (EPERM, ENOTSUP, ENODATA, EINVAL, EACCES): - raise + assert keys.issubset(file_metadata_keys) + result = {} + for key in keys: + if key == 'xattrs': + try: + result['xattrs'] = [ + (attr, os.getxattr(path, attr, follow_symlinks=follow_symlinks)) + for attr in os.listxattr(path, follow_symlinks=follow_symlinks)] + except OSError as err: + if err.errno not in (EPERM, ENOTSUP, ENODATA, EINVAL, EACCES): + raise + continue + st = os.stat(path, follow_symlinks=follow_symlinks) + if key == 'mode': + result['mode'] = stat.S_IMODE(st.st_mode) + elif key == 'times_ns': + result['times_ns'] = st.st_atime_ns, st.st_mtime_ns + elif key == 'flags': + result['flags'] = st.st_flags return result @@ -186,6 +197,8 @@ def set_file_metadata(path, metadata, follow_symlinks): """ Sets local path metadata from the given dict with string keys. """ + assert frozenset(metadata.keys()).issubset(file_metadata_keys) + def _nop(*args, ns=None, follow_symlinks=None): pass @@ -202,8 +215,9 @@ def lookup(name): return fn return _nop - lookup("utime")(path, ns=(metadata['atime_ns'], metadata['mtime_ns']), - follow_symlinks=follow_symlinks) + times_ns = metadata.get('times_ns') + if times_ns is not None: + lookup("utime")(path, ns=times_ns, follow_symlinks=follow_symlinks) # We must copy extended attributes before the file is (potentially) # chmod()'ed read-only, otherwise setxattr() will error with -EACCES. xattrs = metadata.get('xattrs') @@ -214,20 +228,22 @@ def lookup(name): except OSError as e: if e.errno not in (EPERM, ENOTSUP, ENODATA, EINVAL, EACCES): raise - try: - lookup("chmod")(path, metadata['mode'], 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 + mode = metadata.get('mode') + if mode is not None: + try: + lookup("chmod")(path, metadata['mode'], 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 flags = metadata.get('flags') if flags is not None: try: From 547a3c3a093667e665d5be78962f086f2f91fb9b Mon Sep 17 00:00:00 2001 From: barneygale Date: Wed, 3 Jul 2024 04:58:44 +0100 Subject: [PATCH 10/12] Fix double lookup. --- Lib/pathlib/_os.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/pathlib/_os.py b/Lib/pathlib/_os.py index eb80c0dc27ef86..994e5a0a8de12c 100644 --- a/Lib/pathlib/_os.py +++ b/Lib/pathlib/_os.py @@ -252,7 +252,7 @@ def lookup(name): mode = metadata.get('mode') if mode is not None: try: - lookup("chmod")(path, metadata['mode'], follow_symlinks=follow_symlinks) + lookup("chmod")(path, mode, follow_symlinks=follow_symlinks) except NotImplementedError: # if we got a NotImplementedError, it's because # * follow_symlinks=False, From f5846438af47486d493c3dffd35c64f475148897 Mon Sep 17 00:00:00 2001 From: barneygale Date: Wed, 3 Jul 2024 05:24:16 +0100 Subject: [PATCH 11/12] Rename methods. --- Doc/library/pathlib.rst | 4 ++-- Lib/pathlib/_abc.py | 17 +++++++++-------- Lib/pathlib/_local.py | 8 ++++---- Lib/pathlib/_os.py | 5 +++-- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/Doc/library/pathlib.rst b/Doc/library/pathlib.rst index 055f13eb6c8900..f139abd2454d69 100644 --- a/Doc/library/pathlib.rst +++ b/Doc/library/pathlib.rst @@ -1551,8 +1551,8 @@ Copying, renaming and deleting If *preserve_metadata* is false (the default), only the file data is guaranteed to be copied. Set *preserve_metadata* to true to ensure that the file mode (permissions), flags, last access and modification times, and - extended attributes are all copied where supported. This argument has no - effect on Windows, where metadata is always preserved when copying. + extended attributes are copied where supported. This argument has no effect + on Windows, where metadata is always preserved when copying. .. versionadded:: 3.14 diff --git a/Lib/pathlib/_abc.py b/Lib/pathlib/_abc.py index 7cfcf56ce34002..65d581a7fcc02b 100644 --- a/Lib/pathlib/_abc.py +++ b/Lib/pathlib/_abc.py @@ -781,28 +781,29 @@ def mkdir(self, mode=0o777, parents=False, exist_ok=False): """ raise UnsupportedOperation(self._unsupported_msg('mkdir()')) - _metadata_keys = frozenset() + # Metadata keys supported by this path type. + _readable_metadata = _writable_metadata = frozenset() - def _get_metadata(self, keys, follow_symlinks): + def _read_metadata(self, keys, follow_symlinks): """ Returns path metadata as a dict with string keys. """ - raise UnsupportedOperation(self._unsupported_msg('_get_metadata()')) + raise UnsupportedOperation(self._unsupported_msg('_read_metadata()')) - def _set_metadata(self, metadata, follow_symlinks): + def _write_metadata(self, metadata, follow_symlinks): """ Sets path metadata from the given dict with string keys. """ - raise UnsupportedOperation(self._unsupported_msg('_set_metadata()')) + raise UnsupportedOperation(self._unsupported_msg('_write_metadata()')) def _copy_metadata(self, target, follow_symlinks): """ Copies metadata (permissions, timestamps, etc) from this path to target. """ - metadata_keys = self._metadata_keys & target._metadata_keys + metadata_keys = self._readable_metadata & target._writable_metadata if metadata_keys: - metadata = self._get_metadata(metadata_keys, follow_symlinks) - target._set_metadata(metadata, follow_symlinks) + metadata = self._read_metadata(metadata_keys, follow_symlinks) + target._write_metadata(metadata, follow_symlinks) def copy(self, target, *, follow_symlinks=True, preserve_metadata=False): """ diff --git a/Lib/pathlib/_local.py b/Lib/pathlib/_local.py index 3e26f93b36ce80..eae8a30c876f19 100644 --- a/Lib/pathlib/_local.py +++ b/Lib/pathlib/_local.py @@ -18,7 +18,7 @@ grp = None from ._os import (UnsupportedOperation, copyfile, file_metadata_keys, - get_file_metadata, set_file_metadata) + read_file_metadata, write_file_metadata) from ._abc import PurePathBase, PathBase @@ -782,9 +782,9 @@ def mkdir(self, mode=0o777, parents=False, exist_ok=False): if not exist_ok or not self.is_dir(): raise - _metadata_keys = file_metadata_keys - _get_metadata = get_file_metadata - _set_metadata = set_file_metadata + _readable_metadata = _writable_metadata = file_metadata_keys + _read_metadata = read_file_metadata + _write_metadata = write_file_metadata if copyfile: def copy(self, target, *, follow_symlinks=True, preserve_metadata=False): diff --git a/Lib/pathlib/_os.py b/Lib/pathlib/_os.py index 994e5a0a8de12c..3bdb98570d6b76 100644 --- a/Lib/pathlib/_os.py +++ b/Lib/pathlib/_os.py @@ -180,6 +180,7 @@ def copyfileobj(source_f, target_f): write_target(buf) +# Kinds of metadata supported by the operating system. file_metadata_keys = {'mode', 'times_ns'} if hasattr(os.stat_result, 'st_flags'): file_metadata_keys.add('flags') @@ -188,7 +189,7 @@ def copyfileobj(source_f, target_f): file_metadata_keys = frozenset(file_metadata_keys) -def get_file_metadata(path, keys, follow_symlinks): +def read_file_metadata(path, keys, follow_symlinks): """ Returns local path metadata as a dict with string keys. """ @@ -214,7 +215,7 @@ def get_file_metadata(path, keys, follow_symlinks): return result -def set_file_metadata(path, metadata, follow_symlinks): +def write_file_metadata(path, metadata, follow_symlinks): """ Sets local path metadata from the given dict with string keys. """ From 3206a89bdd42b517568731708c3c306eb9ea3adb Mon Sep 17 00:00:00 2001 From: barneygale Date: Wed, 3 Jul 2024 07:33:33 +0100 Subject: [PATCH 12/12] Tweaks --- Lib/pathlib/_abc.py | 19 ++++++++++--------- Lib/pathlib/_os.py | 6 ++++-- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/Lib/pathlib/_abc.py b/Lib/pathlib/_abc.py index 65d581a7fcc02b..05f55badd77c58 100644 --- a/Lib/pathlib/_abc.py +++ b/Lib/pathlib/_abc.py @@ -784,26 +784,27 @@ def mkdir(self, mode=0o777, parents=False, exist_ok=False): # Metadata keys supported by this path type. _readable_metadata = _writable_metadata = frozenset() - def _read_metadata(self, keys, follow_symlinks): + def _read_metadata(self, keys=None, *, follow_symlinks=True): """ Returns path metadata as a dict with string keys. """ raise UnsupportedOperation(self._unsupported_msg('_read_metadata()')) - def _write_metadata(self, metadata, follow_symlinks): + def _write_metadata(self, metadata, *, follow_symlinks=True): """ Sets path metadata from the given dict with string keys. """ raise UnsupportedOperation(self._unsupported_msg('_write_metadata()')) - def _copy_metadata(self, target, follow_symlinks): + def _copy_metadata(self, target, *, follow_symlinks=True): """ Copies metadata (permissions, timestamps, etc) from this path to target. """ - metadata_keys = self._readable_metadata & target._writable_metadata - if metadata_keys: - metadata = self._read_metadata(metadata_keys, follow_symlinks) - target._write_metadata(metadata, follow_symlinks) + # Metadata types supported by both source and target. + keys = self._readable_metadata & target._writable_metadata + if keys: + metadata = self._read_metadata(keys, follow_symlinks=follow_symlinks) + target._write_metadata(metadata, follow_symlinks=follow_symlinks) def copy(self, target, *, follow_symlinks=True, preserve_metadata=False): """ @@ -818,7 +819,7 @@ def copy(self, target, *, follow_symlinks=True, preserve_metadata=False): if not follow_symlinks and self.is_symlink(): target.symlink_to(self.readlink()) if preserve_metadata: - self._copy_metadata(target, False) + self._copy_metadata(target, follow_symlinks=False) return with self.open('rb') as source_f: try: @@ -832,7 +833,7 @@ def copy(self, target, *, follow_symlinks=True, preserve_metadata=False): else: raise if preserve_metadata: - self._copy_metadata(target, True) + self._copy_metadata(target) def copytree(self, target, *, follow_symlinks=True, dirs_exist_ok=False, ignore=None, on_error=None): diff --git a/Lib/pathlib/_os.py b/Lib/pathlib/_os.py index 3bdb98570d6b76..164ee8e9034427 100644 --- a/Lib/pathlib/_os.py +++ b/Lib/pathlib/_os.py @@ -189,10 +189,12 @@ def copyfileobj(source_f, target_f): file_metadata_keys = frozenset(file_metadata_keys) -def read_file_metadata(path, keys, follow_symlinks): +def read_file_metadata(path, keys=None, *, follow_symlinks=True): """ Returns local path metadata as a dict with string keys. """ + if keys is None: + keys = file_metadata_keys assert keys.issubset(file_metadata_keys) result = {} for key in keys: @@ -215,7 +217,7 @@ def read_file_metadata(path, keys, follow_symlinks): return result -def write_file_metadata(path, metadata, follow_symlinks): +def write_file_metadata(path, metadata, *, follow_symlinks=True): """ Sets local path metadata from the given dict with string keys. """