From cad87090112ed897660f2f10b0f3f2a3d84adef9 Mon Sep 17 00:00:00 2001 From: barneygale Date: Sat, 6 Jul 2024 17:43:33 +0100 Subject: [PATCH 1/3] GH-73991: Support preserving metadata in `pathlib.Path.copytree()` 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 | 10 +++++++++- Lib/pathlib/_abc.py | 8 ++++++-- Lib/test/test_pathlib/test_pathlib.py | 22 ++++++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/Doc/library/pathlib.rst b/Doc/library/pathlib.rst index f139abd2454d69..ef9138c37c975d 100644 --- a/Doc/library/pathlib.rst +++ b/Doc/library/pathlib.rst @@ -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 diff --git a/Lib/pathlib/_abc.py b/Lib/pathlib/_abc.py index 05f55badd77c58..ddb8c3686155f0 100644 --- a/Lib/pathlib/_abc.py +++ b/Lib/pathlib/_abc.py @@ -835,7 +835,8 @@ def copy(self, target, *, follow_symlinks=True, preserve_metadata=False): 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 @@ def on_error(err): try: sources = source_dir.iterdir() target_dir.mkdir(exist_ok=dirs_exist_ok) + if preserve_metadata: + source_dir._copy_metadata(target_dir, follow_symlinks=True) for source in sources: if ignore and ignore(source): continue @@ -859,7 +862,8 @@ def on_error(err): 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: diff --git a/Lib/test/test_pathlib/test_pathlib.py b/Lib/test/test_pathlib/test_pathlib.py index 234e5746e544cd..edfa4a4723a011 100644 --- a/Lib/test/test_pathlib/test_pathlib.py +++ b/Lib/test/test_pathlib/test_pathlib.py @@ -711,6 +711,28 @@ def test_copytree_no_read_permission(self): 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(os, 'listxattr'): + if b'user.foo' in os.listxattr(target): + self.assertEqual(os.getxattr(target, b'user.foo'), b'42') + 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 2470fec6f3a69ef9f6b6cc8191a184b78c000e99 Mon Sep 17 00:00:00 2001 From: barneygale Date: Sat, 6 Jul 2024 17:49:24 +0100 Subject: [PATCH 2/3] Tweaks --- Lib/pathlib/_abc.py | 2 +- Lib/test/test_pathlib/test_pathlib.py | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Lib/pathlib/_abc.py b/Lib/pathlib/_abc.py index ddb8c3686155f0..e8cc03cdbc5c24 100644 --- a/Lib/pathlib/_abc.py +++ b/Lib/pathlib/_abc.py @@ -853,7 +853,7 @@ def on_error(err): sources = source_dir.iterdir() target_dir.mkdir(exist_ok=dirs_exist_ok) if preserve_metadata: - source_dir._copy_metadata(target_dir, follow_symlinks=True) + source_dir._copy_metadata(target_dir) for source in sources: if ignore and ignore(source): continue diff --git a/Lib/test/test_pathlib/test_pathlib.py b/Lib/test/test_pathlib/test_pathlib.py index edfa4a4723a011..bbbcada8d56d36 100644 --- a/Lib/test/test_pathlib/test_pathlib.py +++ b/Lib/test/test_pathlib/test_pathlib.py @@ -722,14 +722,16 @@ def test_copytree_preserve_metadata(self): 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() + source_p = source / subpath + target_p = target / subpath + if hasattr(os, 'listxattr'): + if b'user.foo' in os.listxattr(source_p): + self.assertEqual(os.getxattr(target_p, b'user.foo'), b'42') + source_st = source_p.stat() + target_st = target_p.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(os, 'listxattr'): - if b'user.foo' in os.listxattr(target): - self.assertEqual(os.getxattr(target, b'user.foo'), b'42') if hasattr(source_st, 'st_flags'): self.assertEqual(source_st.st_flags, target_st.st_flags) From 1500af715e0073dfec38e415572cc8a7edceee96 Mon Sep 17 00:00:00 2001 From: barneygale Date: Sat, 6 Jul 2024 20:20:21 +0100 Subject: [PATCH 3/3] Fix up xattrs test --- Lib/test/test_pathlib/test_pathlib.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/Lib/test/test_pathlib/test_pathlib.py b/Lib/test/test_pathlib/test_pathlib.py index bbbcada8d56d36..afd9f5b31b7967 100644 --- a/Lib/test/test_pathlib/test_pathlib.py +++ b/Lib/test/test_pathlib/test_pathlib.py @@ -722,19 +722,25 @@ def test_copytree_preserve_metadata(self): source.copytree(target, preserve_metadata=True) for subpath in ['.', 'fileC', 'dirD', 'dirD/fileD']: - source_p = source / subpath - target_p = target / subpath - if hasattr(os, 'listxattr'): - if b'user.foo' in os.listxattr(source_p): - self.assertEqual(os.getxattr(target_p, b'user.foo'), b'42') - source_st = source_p.stat() - target_st = target_p.stat() + 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')