Skip to content

Commit e2766c0

Browse files
authored
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.
1 parent c6661bd commit e2766c0

5 files changed

Lines changed: 81 additions & 7 deletions

File tree

‎Doc/library/shutil.rst‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ Directory and files operations
242242

243243
.. function:: copytree(src, dst, symlinks=False, ignore=None, \
244244
copy_function=copy2, ignore_dangling_symlinks=False, \
245-
dirs_exist_ok=False)
245+
dirs_exist_ok=False, *, copy_dir_stat=True)
246246
247247
Recursively copy an entire directory tree rooted at *src* to a directory
248248
named *dst* and return the destination directory. All intermediate
@@ -251,6 +251,12 @@ Directory and files operations
251251
Permissions and times of directories are copied with :func:`copystat`,
252252
individual files are copied using :func:`~shutil.copy2`.
253253

254+
If *copy_dir_stat* is false, directory metadata is not copied with
255+
:func:`copystat`, e.g. to copy a read-only tree into a writable one.
256+
It defaults to true. Note that file metadata is always handled by
257+
*copy_function*: use e.g. ``copy_function=shutil.copyfile`` to skip
258+
that as well.
259+
254260
If *symlinks* is true, symbolic links in the source tree are represented as
255261
symbolic links in the new tree and the metadata of the original links will
256262
be copied as far as the platform allows; if false or omitted, the contents
@@ -306,6 +312,9 @@ Directory and files operations
306312
.. versionchanged:: 3.8
307313
Added the *dirs_exist_ok* parameter.
308314

315+
.. versionchanged:: 3.16
316+
Added the *copy_dir_stat* parameter.
317+
309318
.. function:: rmtree(path, ignore_errors=False, onerror=None, *, onexc=None, dir_fd=None)
310319

311320
.. index:: single: directory; deleting

‎Doc/whatsnew/3.16.rst‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,15 @@ shlex
665665
(Contributed by Jay Berry in :gh:`148846`.)
666666

667667

668+
shutil
669+
------
670+
671+
* Add keyword-only *copy_dir_stat* parameter to :func:`shutil.copytree`:
672+
set it to false to skip copying directory metadata with
673+
:func:`shutil.copystat`, e.g. when copying a read-only tree.
674+
(Contributed by Łukasz Langa in :gh:`89721`.)
675+
676+
668677
socket
669678
------
670679

‎Lib/shutil.py‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -559,7 +559,8 @@ def _ignore_patterns(path, names):
559559
return _ignore_patterns
560560

561561
def _copytree(entries, src, dst, symlinks, ignore, copy_function,
562-
ignore_dangling_symlinks, dirs_exist_ok=False):
562+
ignore_dangling_symlinks, dirs_exist_ok=False,
563+
copy_dir_stat=True):
563564
if ignore is not None:
564565
ignored_names = ignore(os.fspath(src), [x.name for x in entries])
565566
else:
@@ -599,12 +600,14 @@ def _copytree(entries, src, dst, symlinks, ignore, copy_function,
599600
if srcentry.is_dir():
600601
copytree(srcobj, dstname, symlinks, ignore,
601602
copy_function, ignore_dangling_symlinks,
602-
dirs_exist_ok)
603+
dirs_exist_ok,
604+
copy_dir_stat=copy_dir_stat)
603605
else:
604606
copy_function(srcobj, dstname)
605607
elif srcentry.is_dir():
606608
copytree(srcobj, dstname, symlinks, ignore, copy_function,
607-
ignore_dangling_symlinks, dirs_exist_ok)
609+
ignore_dangling_symlinks, dirs_exist_ok,
610+
copy_dir_stat=copy_dir_stat)
608611
else:
609612
# Will raise a SpecialFileError for unsupported file types
610613
copy_function(srcobj, dstname)
@@ -615,7 +618,8 @@ def _copytree(entries, src, dst, symlinks, ignore, copy_function,
615618
except OSError as why:
616619
errors.append((srcname, dstname, str(why)))
617620
try:
618-
copystat(src, dst)
621+
if copy_dir_stat:
622+
copystat(src, dst)
619623
except OSError as why:
620624
# Copying file access times may fail on Windows
621625
if getattr(why, 'winerror', None) is None:
@@ -625,7 +629,8 @@ def _copytree(entries, src, dst, symlinks, ignore, copy_function,
625629
return dst
626630

627631
def copytree(src, dst, symlinks=False, ignore=None, copy_function=copy2,
628-
ignore_dangling_symlinks=False, dirs_exist_ok=False):
632+
ignore_dangling_symlinks=False, dirs_exist_ok=False,
633+
*, copy_dir_stat=True):
629634
"""Recursively copy a directory tree and return the destination directory.
630635
631636
If exception(s) occur, an Error is raised with a list of reasons.
@@ -663,14 +668,21 @@ def copytree(src, dst, symlinks=False, ignore=None, copy_function=copy2,
663668
operation will continue if it encounters existing directories, and files
664669
within the `dst` tree will be overwritten by corresponding files from the
665670
`src` tree.
671+
672+
If copy_dir_stat is false, directory metadata is not copied with
673+
copystat(), e.g. to copy a read-only tree into a writable one. It
674+
defaults to true. Note that file metadata is always handled by
675+
copy_function: use e.g. `copy_function=shutil.copyfile` to skip
676+
that as well.
666677
"""
667678
sys.audit("shutil.copytree", src, dst)
668679
with os.scandir(src) as itr:
669680
entries = list(itr)
670681
return _copytree(entries=entries, src=src, dst=dst, symlinks=symlinks,
671682
ignore=ignore, copy_function=copy_function,
672683
ignore_dangling_symlinks=ignore_dangling_symlinks,
673-
dirs_exist_ok=dirs_exist_ok)
684+
dirs_exist_ok=dirs_exist_ok,
685+
copy_dir_stat=copy_dir_stat)
674686

675687
if hasattr(os.stat_result, 'st_file_attributes'):
676688
def _rmtree_islink(st):

‎Lib/test/test_shutil.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -988,6 +988,47 @@ def test_copytree_retains_permissions(self):
988988
self.assertEqual(os.stat(restrictive_subdir).st_mode,
989989
os.stat(restrictive_subdir_dst).st_mode)
990990

991+
@os_helper.skip_unless_working_chmod
992+
def test_copytree_no_copy_dir_stat(self):
993+
tmp_dir = self.mkdtemp()
994+
src_dir = os.path.join(tmp_dir, 'source')
995+
sub_dir = os.path.join(src_dir, 'sub')
996+
os.makedirs(sub_dir)
997+
dst_dir = os.path.join(tmp_dir, 'destination')
998+
self.addCleanup(shutil.rmtree, tmp_dir)
999+
old_time = 1_000_000_000 # 2001-09-09, far from "now"
1000+
for path in (sub_dir, src_dir):
1001+
os.utime(path, (old_time, old_time))
1002+
os.chmod(path, 0o555)
1003+
self.addCleanup(os.chmod, path, 0o700)
1004+
1005+
shutil.copytree(src_dir, dst_dir, copy_dir_stat=False)
1006+
for src_path, dst_path in ((src_dir, dst_dir),
1007+
(sub_dir, os.path.join(dst_dir, 'sub'))):
1008+
mode = stat.S_IMODE(os.stat(dst_path).st_mode)
1009+
self.assertNotEqual(mode, 0o555)
1010+
self.assertTrue(mode & stat.S_IWUSR, oct(mode))
1011+
self.assertNotEqual(os.stat(src_path).st_mtime,
1012+
os.stat(dst_path).st_mtime)
1013+
1014+
@os_helper.skip_unless_working_chmod
1015+
def test_copytree_no_copy_dir_stat_keeps_file_metadata(self):
1016+
tmp_dir = self.mkdtemp()
1017+
src_dir = os.path.join(tmp_dir, 'source')
1018+
os.mkdir(src_dir)
1019+
dst_dir = os.path.join(tmp_dir, 'destination')
1020+
self.addCleanup(shutil.rmtree, tmp_dir)
1021+
1022+
create_file((src_dir, 'restricted.txt'), '123')
1023+
os.chmod(os.path.join(src_dir, 'restricted.txt'), 0o600)
1024+
os.chmod(src_dir, 0o555)
1025+
self.addCleanup(os.chmod, src_dir, 0o700)
1026+
1027+
shutil.copytree(src_dir, dst_dir, copy_dir_stat=False)
1028+
self.assertNotEqual(stat.S_IMODE(os.stat(dst_dir).st_mode), 0o555)
1029+
self.assertEqual(os.stat(os.path.join(src_dir, 'restricted.txt')).st_mode,
1030+
os.stat(os.path.join(dst_dir, 'restricted.txt')).st_mode)
1031+
9911032
@unittest.mock.patch('os.chmod')
9921033
def test_copytree_winerror(self, mock_patch):
9931034
# When copying to VFAT, copystat() raises OSError. On Windows, the
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Add ``copy_dir_stat`` parameter to :func:`shutil.copytree`: set it to
2+
false to skip copying permissions and times of directories with
3+
:func:`shutil.copystat`, e.g. when copying a read-only tree.

0 commit comments

Comments
 (0)