Skip to content

Commit c4c7097

Browse files
authored
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()`.
1 parent 094375b commit c4c7097

File tree

3 files changed

+45
-3
lines changed

3 files changed

+45
-3
lines changed

Doc/library/pathlib.rst

+9-1
Original file line numberDiff line numberDiff line change
@@ -1557,7 +1557,8 @@ Copying, renaming and deleting
15571557
.. versionadded:: 3.14
15581558

15591559

1560-
.. method:: Path.copytree(target, *, follow_symlinks=True, dirs_exist_ok=False, \
1560+
.. method:: Path.copytree(target, *, follow_symlinks=True, \
1561+
preserve_metadata=False, dirs_exist_ok=False, \
15611562
ignore=None, on_error=None)
15621563

15631564
Recursively copy this directory tree to the given destination.
@@ -1566,6 +1567,13 @@ Copying, renaming and deleting
15661567
true (the default), the symlink's target is copied. Otherwise, the symlink
15671568
is recreated in the destination tree.
15681569

1570+
If *preserve_metadata* is false (the default), only the directory structure
1571+
and file data are guaranteed to be copied. Set *preserve_metadata* to true
1572+
to ensure that file and directory permissions, flags, last access and
1573+
modification times, and extended attributes are copied where supported.
1574+
This argument has no effect on Windows, where metadata is always preserved
1575+
when copying.
1576+
15691577
If the destination is an existing directory and *dirs_exist_ok* is false
15701578
(the default), a :exc:`FileExistsError` is raised. Otherwise, the copying
15711579
operation will continue if it encounters existing directories, and files

Lib/pathlib/_abc.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -835,7 +835,8 @@ def copy(self, target, *, follow_symlinks=True, preserve_metadata=False):
835835
if preserve_metadata:
836836
self._copy_metadata(target)
837837

838-
def copytree(self, target, *, follow_symlinks=True, dirs_exist_ok=False,
838+
def copytree(self, target, *, follow_symlinks=True,
839+
preserve_metadata=False, dirs_exist_ok=False,
839840
ignore=None, on_error=None):
840841
"""
841842
Recursively copy this directory tree to the given destination.
@@ -851,6 +852,8 @@ def on_error(err):
851852
try:
852853
sources = source_dir.iterdir()
853854
target_dir.mkdir(exist_ok=dirs_exist_ok)
855+
if preserve_metadata:
856+
source_dir._copy_metadata(target_dir)
854857
for source in sources:
855858
if ignore and ignore(source):
856859
continue
@@ -859,7 +862,8 @@ def on_error(err):
859862
stack.append((source, target_dir.joinpath(source.name)))
860863
else:
861864
source.copy(target_dir.joinpath(source.name),
862-
follow_symlinks=follow_symlinks)
865+
follow_symlinks=follow_symlinks,
866+
preserve_metadata=preserve_metadata)
863867
except OSError as err:
864868
on_error(err)
865869
except OSError as err:

Lib/test/test_pathlib/test_pathlib.py

+30
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,36 @@ def test_copytree_no_read_permission(self):
721721
self.assertIsInstance(errors[0], PermissionError)
722722
self.assertFalse(target.exists())
723723

724+
def test_copytree_preserve_metadata(self):
725+
base = self.cls(self.base)
726+
source = base / 'dirC'
727+
if hasattr(os, 'chmod'):
728+
os.chmod(source / 'dirD', stat.S_IRWXU | stat.S_IRWXO)
729+
if hasattr(os, 'chflags') and hasattr(stat, 'UF_NODUMP'):
730+
os.chflags(source / 'fileC', stat.UF_NODUMP)
731+
target = base / 'copyA'
732+
source.copytree(target, preserve_metadata=True)
733+
734+
for subpath in ['.', 'fileC', 'dirD', 'dirD/fileD']:
735+
source_st = source.joinpath(subpath).stat()
736+
target_st = target.joinpath(subpath).stat()
737+
self.assertLessEqual(source_st.st_atime, target_st.st_atime)
738+
self.assertLessEqual(source_st.st_mtime, target_st.st_mtime)
739+
self.assertEqual(source_st.st_mode, target_st.st_mode)
740+
if hasattr(source_st, 'st_flags'):
741+
self.assertEqual(source_st.st_flags, target_st.st_flags)
742+
743+
@os_helper.skip_unless_xattr
744+
def test_copytree_preserve_metadata_xattrs(self):
745+
base = self.cls(self.base)
746+
source = base / 'dirC'
747+
source_file = source.joinpath('dirD', 'fileD')
748+
os.setxattr(source_file, b'user.foo', b'42')
749+
target = base / 'copyA'
750+
source.copytree(target, preserve_metadata=True)
751+
target_file = target.joinpath('dirD', 'fileD')
752+
self.assertEqual(os.getxattr(target_file, b'user.foo'), b'42')
753+
724754
def test_resolve_nonexist_relative_issue38671(self):
725755
p = self.cls('non', 'exist')
726756

0 commit comments

Comments
 (0)