From db514f00b31ff23e3a436068f6d4a83248af7102 Mon Sep 17 00:00:00 2001 From: Lukasz Langa Date: Wed, 21 Oct 2026 19:39:30 +0200 Subject: [PATCH 1/3] gh-89721: Add copy_dir_stat parameter to shutil.copytree() copytree() now accepts copy_dir_stat (default true). When false, directory permissions and times are not copied with copystat(), e.g. to copy a read-only tree into a writable one. The flag propagates through recursive calls; file metadata is unaffected and remains handled by copy_function. Document the parameter and cover both behaviors with tests. Co-authored-by: Muse Code (Meta Muse Spark) --- Doc/library/shutil.rst | 9 ++++- Lib/shutil.py | 22 ++++++++---- Lib/test/test_shutil.py | 34 +++++++++++++++++++ ...6-10-21-19-38-33.gh-issue-89721.uMpxRw.rst | 3 ++ 4 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-10-21-19-38-33.gh-issue-89721.uMpxRw.rst diff --git a/Doc/library/shutil.rst b/Doc/library/shutil.rst index 2822e979990c4de..db54a4a92bb535a 100644 --- a/Doc/library/shutil.rst +++ b/Doc/library/shutil.rst @@ -242,7 +242,7 @@ Directory and files operations .. function:: copytree(src, dst, symlinks=False, ignore=None, \ copy_function=copy2, ignore_dangling_symlinks=False, \ - dirs_exist_ok=False) + dirs_exist_ok=False, copy_dir_stat=True) Recursively copy an entire directory tree rooted at *src* to a directory named *dst* and return the destination directory. All intermediate @@ -251,6 +251,10 @@ Directory and files operations Permissions and times of directories are copied with :func:`copystat`, individual files are copied using :func:`~shutil.copy2`. + If *copy_dir_stat* is false, permissions and times of directories are + not copied, e.g. to copy a read-only tree into a writable one. It + defaults to true. File metadata is always handled by *copy_function*. + If *symlinks* is true, symbolic links in the source tree are represented as symbolic links in the new tree and the metadata of the original links will be copied as far as the platform allows; if false or omitted, the contents @@ -306,6 +310,9 @@ Directory and files operations .. versionchanged:: 3.8 Added the *dirs_exist_ok* parameter. + .. versionchanged:: 3.16 + Added the *copy_dir_stat* parameter. + .. function:: rmtree(path, ignore_errors=False, onerror=None, *, onexc=None, dir_fd=None) .. index:: single: directory; deleting diff --git a/Lib/shutil.py b/Lib/shutil.py index d6d7806802bd177..37783059dcc09fb 100644 --- a/Lib/shutil.py +++ b/Lib/shutil.py @@ -559,7 +559,8 @@ def _ignore_patterns(path, names): return _ignore_patterns def _copytree(entries, src, dst, symlinks, ignore, copy_function, - ignore_dangling_symlinks, dirs_exist_ok=False): + ignore_dangling_symlinks, dirs_exist_ok=False, + copy_dir_stat=True): if ignore is not None: ignored_names = ignore(os.fspath(src), [x.name for x in entries]) else: @@ -599,12 +600,13 @@ def _copytree(entries, src, dst, symlinks, ignore, copy_function, if srcentry.is_dir(): copytree(srcobj, dstname, symlinks, ignore, copy_function, ignore_dangling_symlinks, - dirs_exist_ok) + dirs_exist_ok, copy_dir_stat) else: copy_function(srcobj, dstname) elif srcentry.is_dir(): copytree(srcobj, dstname, symlinks, ignore, copy_function, - ignore_dangling_symlinks, dirs_exist_ok) + ignore_dangling_symlinks, dirs_exist_ok, + copy_dir_stat) else: # Will raise a SpecialFileError for unsupported file types copy_function(srcobj, dstname) @@ -615,7 +617,8 @@ def _copytree(entries, src, dst, symlinks, ignore, copy_function, except OSError as why: errors.append((srcname, dstname, str(why))) try: - copystat(src, dst) + if copy_dir_stat: + copystat(src, dst) except OSError as why: # Copying file access times may fail on Windows if getattr(why, 'winerror', None) is None: @@ -625,7 +628,8 @@ def _copytree(entries, src, dst, symlinks, ignore, copy_function, return dst def copytree(src, dst, symlinks=False, ignore=None, copy_function=copy2, - ignore_dangling_symlinks=False, dirs_exist_ok=False): + ignore_dangling_symlinks=False, dirs_exist_ok=False, + copy_dir_stat=True): """Recursively copy a directory tree and return the destination directory. If exception(s) occur, an Error is raised with a list of reasons. @@ -663,6 +667,11 @@ def copytree(src, dst, symlinks=False, ignore=None, copy_function=copy2, operation will continue if it encounters existing directories, and files within the `dst` tree will be overwritten by corresponding files from the `src` tree. + + If copy_dir_stat is false, permissions and times of the copied + directories are not copied, e.g. to copy a read-only tree into a + writable one. It defaults to true. File metadata is always handled + by copy_function. """ sys.audit("shutil.copytree", src, dst) with os.scandir(src) as itr: @@ -670,7 +679,8 @@ def copytree(src, dst, symlinks=False, ignore=None, copy_function=copy2, return _copytree(entries=entries, src=src, dst=dst, symlinks=symlinks, ignore=ignore, copy_function=copy_function, ignore_dangling_symlinks=ignore_dangling_symlinks, - dirs_exist_ok=dirs_exist_ok) + dirs_exist_ok=dirs_exist_ok, + copy_dir_stat=copy_dir_stat) if hasattr(os.stat_result, 'st_file_attributes'): def _rmtree_islink(st): diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py index 87b6453fba80245..93239b628d2501f 100644 --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -988,6 +988,40 @@ def test_copytree_retains_permissions(self): self.assertEqual(os.stat(restrictive_subdir).st_mode, os.stat(restrictive_subdir_dst).st_mode) + def test_copytree_no_copy_dir_stat(self): + tmp_dir = self.mkdtemp() + src_dir = os.path.join(tmp_dir, 'source') + sub_dir = os.path.join(src_dir, 'sub') + os.makedirs(sub_dir) + dst_dir = os.path.join(tmp_dir, 'destination') + self.addCleanup(shutil.rmtree, tmp_dir) + for path in (sub_dir, src_dir): + os.chmod(path, 0o555) + self.addCleanup(os.chmod, path, 0o700) + + shutil.copytree(src_dir, dst_dir, copy_dir_stat=False) + for path in (dst_dir, os.path.join(dst_dir, 'sub')): + mode = stat.S_IMODE(os.stat(path).st_mode) + self.assertNotEqual(mode, 0o555) + self.assertTrue(mode & stat.S_IWUSR, oct(mode)) + + def test_copytree_no_copy_dir_stat_keeps_file_metadata(self): + tmp_dir = self.mkdtemp() + src_dir = os.path.join(tmp_dir, 'source') + os.mkdir(src_dir) + dst_dir = os.path.join(tmp_dir, 'destination') + self.addCleanup(shutil.rmtree, tmp_dir) + + create_file((src_dir, 'restricted.txt'), '123') + os.chmod(os.path.join(src_dir, 'restricted.txt'), 0o600) + os.chmod(src_dir, 0o555) + self.addCleanup(os.chmod, src_dir, 0o700) + + shutil.copytree(src_dir, dst_dir, copy_dir_stat=False) + self.assertNotEqual(stat.S_IMODE(os.stat(dst_dir).st_mode), 0o555) + self.assertEqual(os.stat(os.path.join(src_dir, 'restricted.txt')).st_mode, + os.stat(os.path.join(dst_dir, 'restricted.txt')).st_mode) + @unittest.mock.patch('os.chmod') def test_copytree_winerror(self, mock_patch): # When copying to VFAT, copystat() raises OSError. On Windows, the diff --git a/Misc/NEWS.d/next/Library/2026-10-21-19-38-33.gh-issue-89721.uMpxRw.rst b/Misc/NEWS.d/next/Library/2026-10-21-19-38-33.gh-issue-89721.uMpxRw.rst new file mode 100644 index 000000000000000..519cf44beb54c91 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-21-19-38-33.gh-issue-89721.uMpxRw.rst @@ -0,0 +1,3 @@ +Add ``copy_dir_stat`` parameter to :func:`shutil.copytree`: set it to +false to skip copying permissions and times of directories with +:func:`shutil.copystat`, e.g. when copying a read-only tree. From 0861e82cf50f958d7de84b6f90e789948ff57cbf Mon Sep 17 00:00:00 2001 From: Lukasz Langa Date: Wed, 21 Oct 2026 20:48:04 +0200 Subject: [PATCH 2/3] gh-89721: Skip copy_dir_stat tests without working chmod WASI cannot change file mode bits, so skip the new copytree mode tests there like the other chmod-dependent tests in test_shutil. Co-authored-by: Muse Code (Meta Muse Spark) --- Lib/test/test_shutil.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py index 93239b628d2501f..e23152567b454d6 100644 --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -988,6 +988,7 @@ def test_copytree_retains_permissions(self): self.assertEqual(os.stat(restrictive_subdir).st_mode, os.stat(restrictive_subdir_dst).st_mode) + @os_helper.skip_unless_working_chmod def test_copytree_no_copy_dir_stat(self): tmp_dir = self.mkdtemp() src_dir = os.path.join(tmp_dir, 'source') @@ -1005,6 +1006,7 @@ def test_copytree_no_copy_dir_stat(self): self.assertNotEqual(mode, 0o555) self.assertTrue(mode & stat.S_IWUSR, oct(mode)) + @os_helper.skip_unless_working_chmod def test_copytree_no_copy_dir_stat_keeps_file_metadata(self): tmp_dir = self.mkdtemp() src_dir = os.path.join(tmp_dir, 'source') From 7ebe48cba256652c17e22e579bddf9658432f4e7 Mon Sep 17 00:00:00 2001 From: Lukasz Langa Date: Thu, 22 Oct 2026 19:22:51 +0200 Subject: [PATCH 3/3] gh-89721: Address review: kw-only flag, clearer docs, time asserts Make copy_dir_stat keyword-only (ignore_dangling_symlinks and dirs_exist_ok stay positional-or-keyword: they date back to 3.2 and 3.8). Convert the recursive calls accordingly. Document that copy_dir_stat=False alone does not make a read-only tree writable; file metadata stays with copy_function. Say "directory metadata" instead of the narrower "permissions and times". Assert directory mtimes are not copied either. Add a What's New entry. Co-authored-by: Muse Code (Meta Muse Spark) --- Doc/library/shutil.rst | 10 ++++++---- Doc/whatsnew/3.16.rst | 9 +++++++++ Lib/shutil.py | 16 +++++++++------- Lib/test/test_shutil.py | 9 +++++++-- 4 files changed, 31 insertions(+), 13 deletions(-) diff --git a/Doc/library/shutil.rst b/Doc/library/shutil.rst index db54a4a92bb535a..f1695a2055913a6 100644 --- a/Doc/library/shutil.rst +++ b/Doc/library/shutil.rst @@ -242,7 +242,7 @@ Directory and files operations .. function:: copytree(src, dst, symlinks=False, ignore=None, \ copy_function=copy2, ignore_dangling_symlinks=False, \ - dirs_exist_ok=False, copy_dir_stat=True) + dirs_exist_ok=False, *, copy_dir_stat=True) Recursively copy an entire directory tree rooted at *src* to a directory named *dst* and return the destination directory. All intermediate @@ -251,9 +251,11 @@ Directory and files operations Permissions and times of directories are copied with :func:`copystat`, individual files are copied using :func:`~shutil.copy2`. - If *copy_dir_stat* is false, permissions and times of directories are - not copied, e.g. to copy a read-only tree into a writable one. It - defaults to true. File metadata is always handled by *copy_function*. + If *copy_dir_stat* is false, directory metadata is not copied with + :func:`copystat`, e.g. to copy a read-only tree into a writable one. + It defaults to true. Note that file metadata is always handled by + *copy_function*: use e.g. ``copy_function=shutil.copyfile`` to skip + that as well. If *symlinks* is true, symbolic links in the source tree are represented as symbolic links in the new tree and the metadata of the original links will diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index b29170b5b0b93da..8f5f695ef842e85 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -644,6 +644,15 @@ shlex (Contributed by Jay Berry in :gh:`148846`.) +shutil +------ + +* Add keyword-only *copy_dir_stat* parameter to :func:`shutil.copytree`: + set it to false to skip copying directory metadata with + :func:`shutil.copystat`, e.g. when copying a read-only tree. + (Contributed by Ɓukasz Langa in :gh:`89721`.) + + socket ------ diff --git a/Lib/shutil.py b/Lib/shutil.py index 37783059dcc09fb..f678601e6758ebc 100644 --- a/Lib/shutil.py +++ b/Lib/shutil.py @@ -600,13 +600,14 @@ def _copytree(entries, src, dst, symlinks, ignore, copy_function, if srcentry.is_dir(): copytree(srcobj, dstname, symlinks, ignore, copy_function, ignore_dangling_symlinks, - dirs_exist_ok, copy_dir_stat) + dirs_exist_ok, + copy_dir_stat=copy_dir_stat) else: copy_function(srcobj, dstname) elif srcentry.is_dir(): copytree(srcobj, dstname, symlinks, ignore, copy_function, ignore_dangling_symlinks, dirs_exist_ok, - copy_dir_stat) + copy_dir_stat=copy_dir_stat) else: # Will raise a SpecialFileError for unsupported file types copy_function(srcobj, dstname) @@ -629,7 +630,7 @@ def _copytree(entries, src, dst, symlinks, ignore, copy_function, def copytree(src, dst, symlinks=False, ignore=None, copy_function=copy2, ignore_dangling_symlinks=False, dirs_exist_ok=False, - copy_dir_stat=True): + *, copy_dir_stat=True): """Recursively copy a directory tree and return the destination directory. If exception(s) occur, an Error is raised with a list of reasons. @@ -668,10 +669,11 @@ def copytree(src, dst, symlinks=False, ignore=None, copy_function=copy2, within the `dst` tree will be overwritten by corresponding files from the `src` tree. - If copy_dir_stat is false, permissions and times of the copied - directories are not copied, e.g. to copy a read-only tree into a - writable one. It defaults to true. File metadata is always handled - by copy_function. + If copy_dir_stat is false, directory metadata is not copied with + copystat(), e.g. to copy a read-only tree into a writable one. It + defaults to true. Note that file metadata is always handled by + copy_function: use e.g. `copy_function=shutil.copyfile` to skip + that as well. """ sys.audit("shutil.copytree", src, dst) with os.scandir(src) as itr: diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py index e23152567b454d6..1a1a77ecdaeb768 100644 --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -996,15 +996,20 @@ def test_copytree_no_copy_dir_stat(self): os.makedirs(sub_dir) dst_dir = os.path.join(tmp_dir, 'destination') self.addCleanup(shutil.rmtree, tmp_dir) + old_time = 1_000_000_000 # 2001-09-09, far from "now" for path in (sub_dir, src_dir): + os.utime(path, (old_time, old_time)) os.chmod(path, 0o555) self.addCleanup(os.chmod, path, 0o700) shutil.copytree(src_dir, dst_dir, copy_dir_stat=False) - for path in (dst_dir, os.path.join(dst_dir, 'sub')): - mode = stat.S_IMODE(os.stat(path).st_mode) + for src_path, dst_path in ((src_dir, dst_dir), + (sub_dir, os.path.join(dst_dir, 'sub'))): + mode = stat.S_IMODE(os.stat(dst_path).st_mode) self.assertNotEqual(mode, 0o555) self.assertTrue(mode & stat.S_IWUSR, oct(mode)) + self.assertNotEqual(os.stat(src_path).st_mtime, + os.stat(dst_path).st_mtime) @os_helper.skip_unless_working_chmod def test_copytree_no_copy_dir_stat_keeps_file_metadata(self):