mirror of https://github.com/python/cpython
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:
parent
094375b9b7
commit
c4c7097e64
|
@ -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
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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')
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue