Repository navigation
gh-89721: Add copy_dir_stat parameter to shutil.copytree() #160076
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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*. | ||
|
|
||
|
Comment on lines
+254
to
+257
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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): | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
|
||||||
| """Recursively copy a directory tree and return the destination directory. | ||||||
|
|
||||||
| If exception(s) occur, an Error is raised with a list of reasons. | ||||||
|
|
@@ -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): | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
||
| 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. |
There was a problem hiding this comment.
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).