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 b29170b5b0b93d..8f5f695ef842e8 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 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.