diff --git a/Lib/shutil.py b/Lib/shutil.py index d6d7806802bd177..45a8565eeb6bbc5 100644 --- a/Lib/shutil.py +++ b/Lib/shutil.py @@ -943,9 +943,10 @@ def move(src, dst, copy_function=copy2): raise Error("Destination path '%s' already exists" % real_dst) try: os.rename(src, real_dst) - except OSError: + except OSError as e: if os.path.islink(src): linkto = os.readlink(src) + _raise_not_EXDEV(e) os.symlink(linkto, real_dst) os.unlink(src) elif os.path.isdir(src): @@ -958,10 +959,12 @@ def move(src, dst, copy_function=copy2): raise PermissionError("Cannot move the non-empty directory " "'%s': Lacking write permission to '%s'." % (src, src)) + _raise_not_EXDEV(e) copytree(src, real_dst, copy_function=copy_function, symlinks=True) rmtree(src) else: + _raise_not_EXDEV(e) copy_function(src, real_dst) os.unlink(src) return real_dst @@ -980,6 +983,10 @@ def _is_immutable(src): immutable_states = [stat.UF_IMMUTABLE, stat.SF_IMMUTABLE] return hasattr(st, 'st_flags') and st.st_flags in immutable_states +def _raise_not_EXDEV(err): + if err.errno != errno.EXDEV: + raise err from None + def _get_gid(name): """Returns a gid, given a group name.""" if name is None: diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py index 87b6453fba80245..4d70be47a9399e9 100644 --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -3196,6 +3196,36 @@ def test_move_dir_permission_denied(self): os.lchflags(TESTFN_DST, stat.UF_OPAQUE) os_helper.rmtree(TESTFN_DST) + @os_helper.skip_unless_working_chmod + def test_no_copied_file_left_not_EXDEV(self): + mode = stat.S_IREAD|stat.S_IEXEC + os.chmod(self.src_dir, mode) + new_file = os.path.join(self.dst_dir, "bar") + self.assertRaises(PermissionError, shutil.move, self.src_file, new_file) + self.assertFalse(os.path.exists(new_file)) + + @os_helper.skip_unless_working_chmod + def test_no_copied_dir_left_not_EXDEV(self): + subdir = os.path.join(self.src_dir, "subdir") + os.mkdir(subdir) + create_file(os.path.join(subdir, "foo2"), b"another spam") + mode = stat.S_IREAD|stat.S_IEXEC + os.chmod(self.src_dir, mode) + new_dir = os.path.join(self.dst_dir, "new_dir") + self.assertRaises(PermissionError, shutil.move, subdir, new_dir) + self.assertFalse(os.path.exists(new_dir)) + + @os_helper.skip_unless_working_chmod + @os_helper.skip_unless_symlink + def test_no_copied_symlink_left_not_EXDEV(self): + old_link = os.path.join(self.src_dir, "bar") + os.symlink(self.src_file, old_link) + mode = stat.S_IREAD|stat.S_IEXEC + os.chmod(self.src_dir, mode) + new_link = os.path.join(self.dst_dir, "new_link") + self.assertRaises(PermissionError, shutil.move, old_link, new_link) + self.assertFalse(os.path.exists(new_link)) + class TestCopyFile(unittest.TestCase): diff --git a/Misc/NEWS.d/next/Library/2026-10-03-21-50-00.gh-issue-158666.D2POCj.rst b/Misc/NEWS.d/next/Library/2026-10-03-21-50-00.gh-issue-158666.D2POCj.rst new file mode 100644 index 000000000000000..ac36b39db423f33 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-03-21-50-00.gh-issue-158666.D2POCj.rst @@ -0,0 +1,2 @@ +:func:`shutil.move` won't copy the source to its destination if it fails +with errors except EXDEV. Patch by Timothy Poon.