From f33932b944495da865c8caf08d477be70189b21a Mon Sep 17 00:00:00 2001 From: obaseyi2 Date: Thu, 5 Nov 2026 14:19:45 +0100 Subject: [PATCH 1/3] Fix removal of empty directories beneath read-only parents --- src/_pytest/pathlib.py | 6 +++--- testing/test_tmpdir.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 8eb593d194d..07b64602082 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -158,9 +158,9 @@ def on_rm_rf_error( return False # Chmod + retry. - # For files, we need to recursively go upwards in the directories to - # ensure they all are also accessible and writable. - if p.is_file(): + # Removing a file or an empty directory requires write access to its + # parent, which may also be inaccessible through its own parents. + if p.is_file() or func is os.rmdir: for parent in p.parents: # pragma: no branch _chmod_rwx(str(parent)) # Stop when we reach the original path passed to rm_rf. diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index 5c53e59236b..35c235c6f1a 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -877,6 +877,38 @@ def test_on_rm_rf_error_chmod_retry_walks_parents(self, tmp_path: Path) -> None: on_rm_rf_error(os.unlink, str(fn), exc_info, start_path=tmp_path) assert not fn.exists() + @pytest.mark.skipif(not hasattr(os, "getuid"), reason="unix permissions") + def test_rm_rf_empty_dir_under_read_only_parent(self, tmp_path: Path) -> None: + root = tmp_path / "root" + parent = root / "parent" + empty = parent / "empty" + empty.mkdir(parents=True) + parent.chmod(0o500) + + try: + rm_rf(root) + assert not root.exists() + finally: + if parent.exists(): + parent.chmod(0o700) + + def test_on_rm_rf_error_chmods_empty_dir_parents( + self, tmp_path: Path, monkeypatch: MonkeyPatch + ) -> None: + parent = tmp_path / "parent" + empty = parent / "empty" + empty.mkdir(parents=True) + chmod_paths: list[str] = [] + + def record_chmod(path: str) -> bool: + chmod_paths.append(path) + return _chmod_rwx(path) + + monkeypatch.setattr(pathlib, "_chmod_rwx", record_chmod) + assert on_rm_rf_error(os.rmdir, str(empty), PermissionError(), start_path=tmp_path) + assert str(parent) in chmod_paths + assert not empty.exists() + def attempt_symlink_to(path, to_path): """Try to make a symlink from "path" to "to_path", skipping in case this platform From edfa26836be4bc53a83068122032d6a2a9b1a6bd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:20:46 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- testing/test_tmpdir.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index 35c235c6f1a..1c9f46f8492 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -905,7 +905,9 @@ def record_chmod(path: str) -> bool: return _chmod_rwx(path) monkeypatch.setattr(pathlib, "_chmod_rwx", record_chmod) - assert on_rm_rf_error(os.rmdir, str(empty), PermissionError(), start_path=tmp_path) + assert on_rm_rf_error( + os.rmdir, str(empty), PermissionError(), start_path=tmp_path + ) assert str(parent) in chmod_paths assert not empty.exists() From 4637c2477a02e84c968db08f9bea2c7584f96091 Mon Sep 17 00:00:00 2001 From: obaseyi2 Date: Tue, 10 Nov 2026 10:20:46 +0100 Subject: [PATCH 3/3] docs: note temporary directory cleanup fix --- changelog/15341.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/15341.bugfix.rst diff --git a/changelog/15341.bugfix.rst b/changelog/15341.bugfix.rst new file mode 100644 index 00000000000..01789cc6d85 --- /dev/null +++ b/changelog/15341.bugfix.rst @@ -0,0 +1 @@ +Temporary directory cleanup now removes empty directories beneath read-only parents instead of leaving the session's temporary files behind.