Skip to content

Commit 75eefd6

Browse files
committed
Fix rmtree to remove directories with read-only files
Fix #5524
1 parent 2c402f4 commit 75eefd6

File tree

3 files changed

+93
-3
lines changed

3 files changed

+93
-3
lines changed

changelog/5524.bugfix.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fix issue where ``tmp_path`` and ``tmpdir`` would not remove directories containing files marked as read-only,
2+
which could lead to pytest crashing when executed a second time with the ``--basetemp`` option.

src/_pytest/pathlib.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,47 @@ def ensure_reset_dir(path):
3838

3939
def rmtree(path, force=False):
4040
if force:
41-
# NOTE: ignore_errors might leave dead folders around.
42-
# Python needs a rm -rf as a followup.
43-
shutil.rmtree(str(path), ignore_errors=True)
41+
rm_rf(path)
4442
else:
4543
shutil.rmtree(str(path))
4644

4745

46+
def rm_rf(path):
47+
"""Remove the path contents recursively, even if some elements
48+
are read-only.
49+
"""
50+
51+
def chmod_w(p):
52+
import stat
53+
54+
mode = os.stat(str(p)).st_mode
55+
os.chmod(str(p), mode | stat.S_IWRITE)
56+
57+
def force_writable_and_retry(function, p, excinfo):
58+
p = Path(p)
59+
60+
# for files, we need to recursively go upwards
61+
# in the directories to ensure they all are also
62+
# writable
63+
if p.is_file():
64+
for parent in p.parents:
65+
chmod_w(parent)
66+
# stop when we reach the original path passed to rm_rf
67+
if parent == path:
68+
break
69+
70+
chmod_w(p)
71+
try:
72+
# retry the function that failed
73+
function(p)
74+
except Exception:
75+
# need to silently ignore this error to preserve the
76+
# previous behavior of rmtree(..., force=True)
77+
pass
78+
79+
shutil.rmtree(str(path), onerror=force_writable_and_retry)
80+
81+
4882
def find_prefixed(root, prefix):
4983
"""finds all elements in root that begin with the prefix, case insensitive"""
5084
l_prefix = prefix.lower()

testing/test_tmpdir.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import os
2+
import stat
13
import sys
24

35
import attr
@@ -326,6 +328,37 @@ def test_rmtree(self, tmp_path):
326328
rmtree(adir, force=True)
327329
assert not adir.exists()
328330

331+
def test_rmtree_with_read_only_file(self, tmp_path):
332+
"""Ensure rmtree can remove directories with read-only files in them (#5524)"""
333+
from _pytest.pathlib import rmtree
334+
335+
fn = tmp_path / "dir/foo.txt"
336+
fn.parent.mkdir()
337+
338+
fn.touch()
339+
340+
mode = os.stat(str(fn)).st_mode
341+
os.chmod(str(fn), mode & ~stat.S_IWRITE)
342+
343+
rmtree(fn.parent, force=True)
344+
345+
assert not fn.parent.is_dir()
346+
347+
def test_rmtree_with_read_only_directory(self, tmp_path):
348+
"""Ensure rmtree can remove read-only directories (#5524)"""
349+
from _pytest.pathlib import rmtree
350+
351+
adir = tmp_path / "dir"
352+
adir.mkdir()
353+
354+
(adir / "foo.txt").touch()
355+
mode = os.stat(str(adir)).st_mode
356+
os.chmod(str(adir), mode & ~stat.S_IWRITE)
357+
358+
rmtree(adir, force=True)
359+
360+
assert not adir.is_dir()
361+
329362
def test_cleanup_ignores_symlink(self, tmp_path):
330363
the_symlink = tmp_path / (self.PREFIX + "current")
331364
attempt_symlink_to(the_symlink, tmp_path / (self.PREFIX + "5"))
@@ -349,3 +382,24 @@ def attempt_symlink_to(path, to_path):
349382

350383
def test_tmpdir_equals_tmp_path(tmpdir, tmp_path):
351384
assert Path(tmpdir) == tmp_path
385+
386+
387+
def test_basetemp_with_read_only_files(testdir):
388+
"""Integration test for #5524"""
389+
testdir.makepyfile(
390+
"""
391+
import os
392+
import stat
393+
394+
def test(tmp_path):
395+
fn = tmp_path / 'foo.txt'
396+
fn.write_text('hello')
397+
mode = os.stat(str(fn)).st_mode
398+
os.chmod(str(fn), mode & ~stat.S_IREAD)
399+
"""
400+
)
401+
result = testdir.runpytest("--basetemp=tmp")
402+
assert result.ret == 0
403+
# running a second time and ensure we don't crash
404+
result = testdir.runpytest("--basetemp=tmp")
405+
assert result.ret == 0

0 commit comments

Comments
 (0)