Skip to content

Commit 0ff56e7

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

File tree

5 files changed

+98
-15
lines changed

5 files changed

+98
-15
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/cacheprovider.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import pytest
1515
from .pathlib import Path
1616
from .pathlib import resolve_from_str
17-
from .pathlib import rmtree
17+
from .pathlib import rm_rf
1818

1919
README_CONTENT = """\
2020
# pytest cache directory #
@@ -44,7 +44,7 @@ class Cache:
4444
def for_config(cls, config):
4545
cachedir = cls.cache_dir_from_config(config)
4646
if config.getoption("cacheclear") and cachedir.exists():
47-
rmtree(cachedir, force=True)
47+
rm_rf(cachedir)
4848
cachedir.mkdir()
4949
return cls(cachedir, config)
5050

src/_pytest/pathlib.py

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,44 @@ def ensure_reset_dir(path):
3232
ensures the given path is an empty directory
3333
"""
3434
if path.exists():
35-
rmtree(path, force=True)
35+
rm_rf(path)
3636
path.mkdir()
3737

3838

39-
def rmtree(path, force=False):
40-
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)
44-
else:
45-
shutil.rmtree(str(path))
39+
def rm_rf(path):
40+
"""Remove the path contents recursively, even if some elements
41+
are read-only.
42+
"""
43+
44+
def chmod_w(p):
45+
import stat
46+
47+
mode = os.stat(str(p)).st_mode
48+
os.chmod(str(p), mode | stat.S_IWRITE)
49+
50+
def force_writable_and_retry(function, p, excinfo):
51+
p = Path(p)
52+
53+
# for files, we need to recursively go upwards
54+
# in the directories to ensure they all are also
55+
# writable
56+
if p.is_file():
57+
for parent in p.parents:
58+
chmod_w(parent)
59+
# stop when we reach the original path passed to rm_rf
60+
if parent == path:
61+
break
62+
63+
chmod_w(p)
64+
try:
65+
# retry the function that failed
66+
function(str(p))
67+
except Exception:
68+
# need to silently ignore this error to preserve the
69+
# previous behavior of rm_rf(..., force=True)
70+
pass
71+
72+
shutil.rmtree(str(path), onerror=force_writable_and_retry)
4673

4774

4875
def find_prefixed(root, prefix):
@@ -168,7 +195,7 @@ def maybe_delete_a_numbered_dir(path):
168195

169196
garbage = parent.joinpath("garbage-{}".format(uuid.uuid4()))
170197
path.rename(garbage)
171-
rmtree(garbage, force=True)
198+
rm_rf(garbage)
172199
except (OSError, EnvironmentError):
173200
# known races:
174201
# * other process did a cleanup at the same time

testing/test_assertrewrite.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1323,7 +1323,7 @@ def test_cwd_changed(self, testdir, monkeypatch):
13231323
13241324
d = tempfile.mkdtemp()
13251325
os.chdir(d)
1326-
shutil.rmtree(d)
1326+
shutil.rm_rf(d)
13271327
""",
13281328
"test_test.py": """\
13291329
def test():

testing/test_tmpdir.py

Lines changed: 57 additions & 3 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
@@ -311,21 +313,52 @@ def test_cleanup_locked(self, tmp_path):
311313
)
312314

313315
def test_rmtree(self, tmp_path):
314-
from _pytest.pathlib import rmtree
316+
from _pytest.pathlib import rm_rf
315317

316318
adir = tmp_path / "adir"
317319
adir.mkdir()
318-
rmtree(adir)
320+
rm_rf(adir)
319321

320322
assert not adir.exists()
321323

322324
adir.mkdir()
323325
afile = adir / "afile"
324326
afile.write_bytes(b"aa")
325327

326-
rmtree(adir, force=True)
328+
rm_rf(adir)
327329
assert not adir.exists()
328330

331+
def test_rmtree_with_read_only_file(self, tmp_path):
332+
"""Ensure rm_rf can remove directories with read-only files in them (#5524)"""
333+
from _pytest.pathlib import rm_rf
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+
rm_rf(fn.parent)
344+
345+
assert not fn.parent.is_dir()
346+
347+
def test_rmtree_with_read_only_directory(self, tmp_path):
348+
"""Ensure rm_rf can remove read-only directories (#5524)"""
349+
from _pytest.pathlib import rm_rf
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+
rm_rf(adir)
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)