diff --git a/Lib/shutil.py b/Lib/shutil.py index 44ccdbb503d4fb..782497d58ed204 100644 --- a/Lib/shutil.py +++ b/Lib/shutil.py @@ -553,6 +553,13 @@ def _copytree(entries, src, dst, symlinks, ignore, copy_function, errors = [] use_srcentry = copy_function is copy2 or copy_function is copy + try: + copystat(src, dst) + except OSError as why: + # Copying file access times may fail on Windows + if getattr(why, 'winerror', None) is None: + errors.append((src, dst, str(why))) + for srcentry in entries: if srcentry.name in ignored_names: continue @@ -598,12 +605,7 @@ def _copytree(entries, src, dst, symlinks, ignore, copy_function, errors.extend(err.args[0]) except OSError as why: errors.append((srcname, dstname, str(why))) - try: - copystat(src, dst) - except OSError as why: - # Copying file access times may fail on Windows - if getattr(why, 'winerror', None) is None: - errors.append((src, dst, str(why))) + if errors: raise Error(errors) return dst diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py index a4bd113bc7f1fc..8ee93f7fcb05f9 100644 --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -1099,6 +1099,25 @@ def test_copytree_subdirectory(self): rv = shutil.copytree(src_dir, dst_dir) self.assertEqual(['pol'], os.listdir(rv)) + def test_copytree_xattr(self): + # gh-144220: copytree() must call copystat() to copy extended + # attributes before copying files. + + src_dir = self.mkdtemp() + dst_dir = os.path.join(self.mkdtemp(), 'destination') + create_file((src_dir, 'test.txt'), '123') + + def copystat(src, dst, *, follow_symlinks=True): + if os.path.isdir(dst) and os.listdir(dst): + raise Exception('Directory not empty') + + with unittest.mock.patch('shutil.copystat', side_effect=copystat): + shutil.copytree(src_dir, dst_dir) + self.assertTrue(os.path.isfile(os.path.join(dst_dir, 'test.txt'))) + actual = read_file((dst_dir, 'test.txt')) + self.assertEqual(actual, '123') + + class TestCopy(BaseTest, unittest.TestCase): ### shutil.copymode diff --git a/Misc/NEWS.d/next/Library/2026-03-12-15-48-35.gh-issue-144220.bxjFNf.rst b/Misc/NEWS.d/next/Library/2026-03-12-15-48-35.gh-issue-144220.bxjFNf.rst new file mode 100644 index 00000000000000..bb5319948e4133 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-03-12-15-48-35.gh-issue-144220.bxjFNf.rst @@ -0,0 +1,3 @@ +:func:`shutil.copytree` now copies directory metadata before copying files. It +prevents an error when setting an extended attribute (``bcachefs.casefold``) on +an non-empty directory. Patch by Victor Stinner.