GH-73991: Support preserving metadata in `pathlib.Path.copytree()` (#121438)

Add *preserve_metadata* keyword-only argument to `pathlib.Path.copytree()`,
defaulting to false. When set to true, we copy timestamps, permissions,
extended attributes and flags where available, like `shutil.copystat()`.
This commit is contained in:
Barney Gale 2024-07-20 23:32:52 +01:00 committed by GitHub
parent 094375b9b7
commit c4c7097e64
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 45 additions and 3 deletions

View File

@ -1557,7 +1557,8 @@ Copying, renaming and deleting
.. versionadded:: 3.14 .. versionadded:: 3.14
.. method:: Path.copytree(target, *, follow_symlinks=True, dirs_exist_ok=False, \ .. method:: Path.copytree(target, *, follow_symlinks=True, \
preserve_metadata=False, dirs_exist_ok=False, \
ignore=None, on_error=None) ignore=None, on_error=None)
Recursively copy this directory tree to the given destination. Recursively copy this directory tree to the given destination.
@ -1566,6 +1567,13 @@ Copying, renaming and deleting
true (the default), the symlink's target is copied. Otherwise, the symlink true (the default), the symlink's target is copied. Otherwise, the symlink
is recreated in the destination tree. is recreated in the destination tree.
If *preserve_metadata* is false (the default), only the directory structure
and file data are guaranteed to be copied. Set *preserve_metadata* to true
to ensure that file and directory permissions, flags, last access and
modification times, and extended attributes are copied where supported.
This argument has no effect on Windows, where metadata is always preserved
when copying.
If the destination is an existing directory and *dirs_exist_ok* is false If the destination is an existing directory and *dirs_exist_ok* is false
(the default), a :exc:`FileExistsError` is raised. Otherwise, the copying (the default), a :exc:`FileExistsError` is raised. Otherwise, the copying
operation will continue if it encounters existing directories, and files operation will continue if it encounters existing directories, and files

View File

@ -835,7 +835,8 @@ class PathBase(PurePathBase):
if preserve_metadata: if preserve_metadata:
self._copy_metadata(target) self._copy_metadata(target)
def copytree(self, target, *, follow_symlinks=True, dirs_exist_ok=False, def copytree(self, target, *, follow_symlinks=True,
preserve_metadata=False, dirs_exist_ok=False,
ignore=None, on_error=None): ignore=None, on_error=None):
""" """
Recursively copy this directory tree to the given destination. Recursively copy this directory tree to the given destination.
@ -851,6 +852,8 @@ class PathBase(PurePathBase):
try: try:
sources = source_dir.iterdir() sources = source_dir.iterdir()
target_dir.mkdir(exist_ok=dirs_exist_ok) target_dir.mkdir(exist_ok=dirs_exist_ok)
if preserve_metadata:
source_dir._copy_metadata(target_dir)
for source in sources: for source in sources:
if ignore and ignore(source): if ignore and ignore(source):
continue continue
@ -859,7 +862,8 @@ class PathBase(PurePathBase):
stack.append((source, target_dir.joinpath(source.name))) stack.append((source, target_dir.joinpath(source.name)))
else: else:
source.copy(target_dir.joinpath(source.name), source.copy(target_dir.joinpath(source.name),
follow_symlinks=follow_symlinks) follow_symlinks=follow_symlinks,
preserve_metadata=preserve_metadata)
except OSError as err: except OSError as err:
on_error(err) on_error(err)
except OSError as err: except OSError as err:

View File

@ -721,6 +721,36 @@ class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest):
self.assertIsInstance(errors[0], PermissionError) self.assertIsInstance(errors[0], PermissionError)
self.assertFalse(target.exists()) self.assertFalse(target.exists())
def test_copytree_preserve_metadata(self):
base = self.cls(self.base)
source = base / 'dirC'
if hasattr(os, 'chmod'):
os.chmod(source / 'dirD', stat.S_IRWXU | stat.S_IRWXO)
if hasattr(os, 'chflags') and hasattr(stat, 'UF_NODUMP'):
os.chflags(source / 'fileC', stat.UF_NODUMP)
target = base / 'copyA'
source.copytree(target, preserve_metadata=True)
for subpath in ['.', 'fileC', 'dirD', 'dirD/fileD']:
source_st = source.joinpath(subpath).stat()
target_st = target.joinpath(subpath).stat()
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)
@os_helper.skip_unless_xattr
def test_copytree_preserve_metadata_xattrs(self):
base = self.cls(self.base)
source = base / 'dirC'
source_file = source.joinpath('dirD', 'fileD')
os.setxattr(source_file, b'user.foo', b'42')
target = base / 'copyA'
source.copytree(target, preserve_metadata=True)
target_file = target.joinpath('dirD', 'fileD')
self.assertEqual(os.getxattr(target_file, b'user.foo'), b'42')
def test_resolve_nonexist_relative_issue38671(self): def test_resolve_nonexist_relative_issue38671(self):
p = self.cls('non', 'exist') p = self.cls('non', 'exist')