Skip to content

Commit 9064eea

Browse files
blueyednicoddemus
authored andcommitted
Improve rm_rf to handle only known functions
Warnings are emitted if we cannot safely remove paths. Fix #5626
1 parent dc6e7b9 commit 9064eea

File tree

2 files changed

+84
-42
lines changed

2 files changed

+84
-42
lines changed

src/_pytest/pathlib.py

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import sys
99
import uuid
1010
import warnings
11+
from functools import partial
1112
from os.path import expanduser
1213
from os.path import expandvars
1314
from os.path import isabs
@@ -38,38 +39,49 @@ def ensure_reset_dir(path):
3839
path.mkdir()
3940

4041

41-
def rm_rf(path):
42-
"""Remove the path contents recursively, even if some elements
43-
are read-only.
44-
"""
42+
def on_rm_rf_error(func, path: str, exc, *, start_path):
43+
"""Handles known read-only errors during rmtree."""
44+
excvalue = exc[1]
4545

46-
def chmod_w(p):
47-
import stat
46+
if not isinstance(excvalue, PermissionError):
47+
warnings.warn(
48+
PytestWarning("(rm_rf) error removing {}: {}".format(path, excvalue))
49+
)
50+
return
4851

49-
mode = os.stat(str(p)).st_mode
50-
os.chmod(str(p), mode | stat.S_IWRITE)
52+
if func not in (os.rmdir, os.remove, os.unlink):
53+
warnings.warn(
54+
PytestWarning("(rm_rf) error removing {}: {}".format(path, excvalue))
55+
)
56+
return
5157

52-
def force_writable_and_retry(function, p, excinfo):
53-
p = Path(p)
58+
# Chmod + retry.
59+
import stat
5460

55-
# for files, we need to recursively go upwards
56-
# in the directories to ensure they all are also
57-
# writable
58-
if p.is_file():
59-
for parent in p.parents:
60-
chmod_w(parent)
61-
# stop when we reach the original path passed to rm_rf
62-
if parent == path:
63-
break
61+
def chmod_rw(p: str):
62+
mode = os.stat(p).st_mode
63+
os.chmod(p, mode | stat.S_IRUSR | stat.S_IWUSR)
64+
65+
# For files, we need to recursively go upwards in the directories to
66+
# ensure they all are also writable.
67+
p = Path(path)
68+
if p.is_file():
69+
for parent in p.parents:
70+
chmod_rw(str(parent))
71+
# stop when we reach the original path passed to rm_rf
72+
if parent == start_path:
73+
break
74+
chmod_rw(str(path))
75+
76+
func(path)
6477

65-
chmod_w(p)
66-
try:
67-
# retry the function that failed
68-
function(str(p))
69-
except Exception as e:
70-
warnings.warn(PytestWarning("(rm_rf) error removing {}: {}".format(p, e)))
7178

72-
shutil.rmtree(str(path), onerror=force_writable_and_retry)
79+
def rm_rf(path: Path):
80+
"""Remove the path contents recursively, even if some elements
81+
are read-only.
82+
"""
83+
onerror = partial(on_rm_rf_error, start_path=path)
84+
shutil.rmtree(str(path), onerror=onerror)
7385

7486

7587
def find_prefixed(root, prefix):

testing/test_tmpdir.py

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,20 @@ def test_cleanup_locked(self, tmp_path):
312312
p, consider_lock_dead_if_created_before=p.stat().st_mtime + 1
313313
)
314314

315-
def test_rmtree(self, tmp_path):
315+
def test_cleanup_ignores_symlink(self, tmp_path):
316+
the_symlink = tmp_path / (self.PREFIX + "current")
317+
attempt_symlink_to(the_symlink, tmp_path / (self.PREFIX + "5"))
318+
self._do_cleanup(tmp_path)
319+
320+
def test_removal_accepts_lock(self, tmp_path):
321+
folder = pathlib.make_numbered_dir(root=tmp_path, prefix=self.PREFIX)
322+
pathlib.create_cleanup_lock(folder)
323+
pathlib.maybe_delete_a_numbered_dir(folder)
324+
assert folder.is_dir()
325+
326+
327+
class TestRmRf:
328+
def test_rm_rf(self, tmp_path):
316329
from _pytest.pathlib import rm_rf
317330

318331
adir = tmp_path / "adir"
@@ -328,7 +341,7 @@ def test_rmtree(self, tmp_path):
328341
rm_rf(adir)
329342
assert not adir.exists()
330343

331-
def test_rmtree_with_read_only_file(self, tmp_path):
344+
def test_rm_rf_with_read_only_file(self, tmp_path):
332345
"""Ensure rm_rf can remove directories with read-only files in them (#5524)"""
333346
from _pytest.pathlib import rm_rf
334347

@@ -337,38 +350,55 @@ def test_rmtree_with_read_only_file(self, tmp_path):
337350

338351
fn.touch()
339352

340-
mode = os.stat(str(fn)).st_mode
341-
os.chmod(str(fn), mode & ~stat.S_IWRITE)
353+
self.chmod_r(fn)
342354

343355
rm_rf(fn.parent)
344356

345357
assert not fn.parent.is_dir()
346358

347-
def test_rmtree_with_read_only_directory(self, tmp_path):
359+
def chmod_r(self, path):
360+
mode = os.stat(str(path)).st_mode
361+
os.chmod(str(path), mode & ~stat.S_IWRITE)
362+
363+
def test_rm_rf_with_read_only_directory(self, tmp_path):
348364
"""Ensure rm_rf can remove read-only directories (#5524)"""
349365
from _pytest.pathlib import rm_rf
350366

351367
adir = tmp_path / "dir"
352368
adir.mkdir()
353369

354370
(adir / "foo.txt").touch()
355-
mode = os.stat(str(adir)).st_mode
356-
os.chmod(str(adir), mode & ~stat.S_IWRITE)
371+
self.chmod_r(adir)
357372

358373
rm_rf(adir)
359374

360375
assert not adir.is_dir()
361376

362-
def test_cleanup_ignores_symlink(self, tmp_path):
363-
the_symlink = tmp_path / (self.PREFIX + "current")
364-
attempt_symlink_to(the_symlink, tmp_path / (self.PREFIX + "5"))
365-
self._do_cleanup(tmp_path)
377+
def test_on_rm_rf_error(self, tmp_path):
378+
from _pytest.pathlib import on_rm_rf_error
366379

367-
def test_removal_accepts_lock(self, tmp_path):
368-
folder = pathlib.make_numbered_dir(root=tmp_path, prefix=self.PREFIX)
369-
pathlib.create_cleanup_lock(folder)
370-
pathlib.maybe_delete_a_numbered_dir(folder)
371-
assert folder.is_dir()
380+
adir = tmp_path / "dir"
381+
adir.mkdir()
382+
383+
fn = adir / "foo.txt"
384+
fn.touch()
385+
self.chmod_r(fn)
386+
387+
# unknown exception
388+
with pytest.warns(pytest.PytestWarning):
389+
exc_info = (None, RuntimeError(), None)
390+
on_rm_rf_error(os.unlink, str(fn), exc_info, start_path=tmp_path)
391+
assert fn.is_file()
392+
393+
# unknown function
394+
with pytest.warns(pytest.PytestWarning):
395+
exc_info = (None, PermissionError(), None)
396+
on_rm_rf_error(None, str(fn), exc_info, start_path=tmp_path)
397+
assert fn.is_file()
398+
399+
exc_info = (None, PermissionError(), None)
400+
on_rm_rf_error(os.unlink, str(fn), exc_info, start_path=tmp_path)
401+
assert not fn.is_file()
372402

373403

374404
def attempt_symlink_to(path, to_path):

0 commit comments

Comments
 (0)