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. 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..1c9f46f8492 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -877,6 +877,40 @@ 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