Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion Doc/library/shutil.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: "permissions and times" is a subset of the metadata that's copied (or not).

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*.

Comment on lines +254 to +257

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perhaps need different phrasing or clarification that, by default, just setting this to False will not magically make a read-only tree writable -- you also need to use a custom copy function, e.g. copy_function=shutil.copyfile

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
Expand Down Expand Up @@ -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
Expand Down
22 changes: 16 additions & 6 deletions Lib/shutil.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand All @@ -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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a long args list. maybe at make new ones kw-only?

Suggested change
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.
Expand Down Expand Up @@ -663,14 +667,20 @@ 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:
entries = list(itr)
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):
Expand Down
36 changes: 36 additions & 0 deletions Lib/test/test_shutil.py
Original file line number Diff line number Diff line change
Expand Up @@ -988,6 +988,42 @@ 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: also assert on whether the times are copied or not (as per the documented behavior)

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))

@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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Loading