From 2e3c07599dce2572fd7c26f2f37fe812486b6f62 Mon Sep 17 00:00:00 2001 From: Timothy Poon <62692924+ptim0626@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:06:44 +0100 Subject: [PATCH 1/7] Add helper to raise other than EXDEV --- Lib/shutil.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Lib/shutil.py b/Lib/shutil.py index d6d7806802bd177..0435309c57dc3b7 100644 --- a/Lib/shutil.py +++ b/Lib/shutil.py @@ -980,6 +980,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: From 37e25432ec68bfdde6988bff3a88106302f3ad49 Mon Sep 17 00:00:00 2001 From: Timothy Poon <62692924+ptim0626@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:10:59 +0100 Subject: [PATCH 2/7] Avoid copying src other than EXDEV --- Lib/shutil.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Lib/shutil.py b/Lib/shutil.py index 0435309c57dc3b7..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 From 97bd578657d3a03738c8b56369d2497c50b42b69 Mon Sep 17 00:00:00 2001 From: Timothy Poon <62692924+ptim0626@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:12:18 +0100 Subject: [PATCH 3/7] Add tests to ensure no left over if shutil.move fails other than EXDEV --- Lib/test/test_shutil.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py index 87b6453fba80245..ba80c2ebd38d7af 100644 --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -3196,6 +3196,29 @@ def test_move_dir_permission_denied(self): os.lchflags(TESTFN_DST, stat.UF_OPAQUE) os_helper.rmtree(TESTFN_DST) + def test_no_copied_file_left_not_EXDEV(self): + os.chmod(self.src_dir, 0o500) + 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)) + + 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") + os.chmod(self.src_dir, 0o500) + 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)) + + 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) + os.chmod(self.src_dir, 0o500) + 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): From 6af4383926d8c0fcd63c930b9bd4b91528b73f3a Mon Sep 17 00:00:00 2001 From: Timothy Poon <62692924+ptim0626@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:30:53 +0100 Subject: [PATCH 4/7] Skip symlink move if not available --- Lib/test/test_shutil.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py index ba80c2ebd38d7af..6bfa66971509a69 100644 --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -3211,6 +3211,7 @@ def test_no_copied_dir_left_not_EXDEV(self): self.assertRaises(PermissionError, shutil.move, subdir, new_dir) self.assertFalse(os.path.exists(new_dir)) + @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) From eca9eddb5777b0255bc7a1fb0ed2228ba72cd70b Mon Sep 17 00:00:00 2001 From: Timothy Poon <62692924+ptim0626@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:35:45 +0100 Subject: [PATCH 5/7] Skip if chmod not working for left over copy tests --- Lib/test/test_shutil.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py index 6bfa66971509a69..5d833d557f05910 100644 --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -3196,12 +3196,14 @@ 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): os.chmod(self.src_dir, 0o500) 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) @@ -3211,6 +3213,7 @@ def test_no_copied_dir_left_not_EXDEV(self): 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") From c7fa64d6408b0a83cea70ea09c468b4f122b41e6 Mon Sep 17 00:00:00 2001 From: Timothy Poon <62692924+ptim0626@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:39:51 +0100 Subject: [PATCH 6/7] Use stat flags to set unwritable directory --- Lib/test/test_shutil.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py index 5d833d557f05910..4d70be47a9399e9 100644 --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -3198,7 +3198,8 @@ def test_move_dir_permission_denied(self): @os_helper.skip_unless_working_chmod def test_no_copied_file_left_not_EXDEV(self): - os.chmod(self.src_dir, 0o500) + 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)) @@ -3208,7 +3209,8 @@ 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") - os.chmod(self.src_dir, 0o500) + 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)) @@ -3218,7 +3220,8 @@ def test_no_copied_dir_left_not_EXDEV(self): 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) - os.chmod(self.src_dir, 0o500) + 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)) From aa9e947c5114a038224a713ecd039ed56b691b9c Mon Sep 17 00:00:00 2001 From: Timothy Poon <62692924+ptim0626@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:50:30 +0100 Subject: [PATCH 7/7] Add NEWS entry --- .../next/Library/2026-10-03-21-50-00.gh-issue-158666.D2POCj.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-10-03-21-50-00.gh-issue-158666.D2POCj.rst 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.