]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
GH-73991: Support preserving metadata in `pathlib.Path.copytree()` (#121438)
authorBarney Gale <barney.gale@gmail.com>
Sat, 20 Jul 2024 22:32:52 +0000 (23:32 +0100)
committerGitHub <noreply@github.com>
Sat, 20 Jul 2024 22:32:52 +0000 (23:32 +0100)
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()`.

Doc/library/pathlib.rst
Lib/pathlib/_abc.py
Lib/test/test_pathlib/test_pathlib.py

index a74b1321cb4b1dd29313b80b0f338ad9093a760c..496a12a296e443a826468f1531f7ce9f03ebac97 100644 (file)
@@ -1557,7 +1557,8 @@ Copying, renaming and deleting
    .. 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)
 
    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
    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
    (the default), a :exc:`FileExistsError` is raised. Otherwise, the copying
    operation will continue if it encounters existing directories, and files
index 49e8e4ca13782c6b84f04143e0f274e3b0cc4550..c32e7762cefea30136a1df226ed2dd9fddc4a8bf 100644 (file)
@@ -835,7 +835,8 @@ class PathBase(PurePathBase):
         if preserve_metadata:
             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):
         """
         Recursively copy this directory tree to the given destination.
@@ -851,6 +852,8 @@ class PathBase(PurePathBase):
             try:
                 sources = source_dir.iterdir()
                 target_dir.mkdir(exist_ok=dirs_exist_ok)
+                if preserve_metadata:
+                    source_dir._copy_metadata(target_dir)
                 for source in sources:
                     if ignore and ignore(source):
                         continue
@@ -859,7 +862,8 @@ class PathBase(PurePathBase):
                             stack.append((source, target_dir.joinpath(source.name)))
                         else:
                             source.copy(target_dir.joinpath(source.name),
-                                        follow_symlinks=follow_symlinks)
+                                        follow_symlinks=follow_symlinks,
+                                        preserve_metadata=preserve_metadata)
                     except OSError as err:
                         on_error(err)
             except OSError as err:
index e17e7d71b6ab461dfa1bc1ce3b263d6aa0e425f4..5293b5c84cda14b8b40d4c8988ef9a5110c7991c 100644 (file)
@@ -721,6 +721,36 @@ class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest):
         self.assertIsInstance(errors[0], PermissionError)
         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):
         p = self.cls('non', 'exist')