https://github.com/python/cpython/commit/e2766c0a00ee3da9bb232986da73f225956d7a05
commit: e2766c0a00ee3da9bb232986da73f225956d7a05
branch: main
author: Łukasz Langa <[email protected]>
committer: ambv <[email protected]>
date: 2026-10-22T15:13:33-07:00
summary:
gh-89721: Add copy_dir_stat parameter to shutil.copytree() (#160076)
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.
files:
A Misc/NEWS.d/next/Library/2026-10-21-19-38-33.gh-issue-89721.uMpxRw.rst
M Doc/library/shutil.rst
M Doc/whatsnew/3.16.rst
M Lib/shutil.py
M Lib/test/test_shutil.py
diff --git a/Doc/library/shutil.rst b/Doc/library/shutil.rst
index 2822e979990c4d..f1695a2055913a 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,12 @@ 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, 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
be copied as far as the platform allows; if false or omitted, the contents
@@ -306,6 +312,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/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst
index 7995da01285824..78b2492badba5a 100644
--- a/Doc/whatsnew/3.16.rst
+++ b/Doc/whatsnew/3.16.rst
@@ -665,6 +665,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 d6d7806802bd17..f678601e6758eb 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,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)
+ 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)
+ ignore_dangling_symlinks, dirs_exist_ok,
+ copy_dir_stat=copy_dir_stat)
else:
# Will raise a SpecialFileError for unsupported file types
copy_function(srcobj, dstname)
@@ -615,7 +618,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 +629,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 +668,12 @@ 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, 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:
@@ -670,7 +681,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 87b6453fba8024..1a1a77ecdaeb76 100644
--- a/Lib/test/test_shutil.py
+++ b/Lib/test/test_shutil.py
@@ -988,6 +988,47 @@ 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')
+ 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)
+ 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 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):
+ 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 00000000000000..519cf44beb54c9
--- /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.
_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]