Skip to content

Commit 30de669

Browse files
authored
[4.6] Fix rmtree to remove directories with read-only files (#5… (#5597)
[4.6] Fix rmtree to remove directories with read-only files (#5588)
2 parents 01655b1 + 02c737f commit 30de669

File tree

4 files changed

+99
-14
lines changed

4 files changed

+99
-14
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
@@ -21,7 +21,7 @@
2121
from .compat import _PY2 as PY2
2222
from .pathlib import Path
2323
from .pathlib import resolve_from_str
24-
from .pathlib import rmtree
24+
from .pathlib import rm_rf
2525

2626
README_CONTENT = u"""\
2727
# pytest cache directory #
@@ -51,7 +51,7 @@ class Cache(object):
5151
def for_config(cls, config):
5252
cachedir = cls.cache_dir_from_config(config)
5353
if config.getoption("cacheclear") and cachedir.exists():
54-
rmtree(cachedir, force=True)
54+
rm_rf(cachedir)
5555
cachedir.mkdir()
5656
return cls(cachedir, config)
5757

src/_pytest/pathlib.py

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
# -*- coding: utf-8 -*-
2+
from __future__ import absolute_import
3+
24
import atexit
35
import errno
46
import fnmatch
@@ -8,6 +10,7 @@
810
import shutil
911
import sys
1012
import uuid
13+
import warnings
1114
from functools import reduce
1215
from os.path import expanduser
1316
from os.path import expandvars
@@ -19,6 +22,7 @@
1922
from six.moves import map
2023

2124
from .compat import PY36
25+
from _pytest.warning_types import PytestWarning
2226

2327
if PY36:
2428
from pathlib import Path, PurePath
@@ -38,17 +42,42 @@ def ensure_reset_dir(path):
3842
ensures the given path is an empty directory
3943
"""
4044
if path.exists():
41-
rmtree(path, force=True)
45+
rm_rf(path)
4246
path.mkdir()
4347

4448

45-
def rmtree(path, force=False):
46-
if force:
47-
# NOTE: ignore_errors might leave dead folders around.
48-
# Python needs a rm -rf as a followup.
49-
shutil.rmtree(str(path), ignore_errors=True)
50-
else:
51-
shutil.rmtree(str(path))
49+
def rm_rf(path):
50+
"""Remove the path contents recursively, even if some elements
51+
are read-only.
52+
"""
53+
54+
def chmod_w(p):
55+
import stat
56+
57+
mode = os.stat(str(p)).st_mode
58+
os.chmod(str(p), mode | stat.S_IWRITE)
59+
60+
def force_writable_and_retry(function, p, excinfo):
61+
p = Path(p)
62+
63+
# for files, we need to recursively go upwards
64+
# in the directories to ensure they all are also
65+
# writable
66+
if p.is_file():
67+
for parent in p.parents:
68+
chmod_w(parent)
69+
# stop when we reach the original path passed to rm_rf
70+
if parent == path:
71+
break
72+
73+
chmod_w(p)
74+
try:
75+
# retry the function that failed
76+
function(str(p))
77+
except Exception as e:
78+
warnings.warn(PytestWarning("(rm_rf) error removing {}: {}".format(p, e)))
79+
80+
shutil.rmtree(str(path), onerror=force_writable_and_retry)
5281

5382

5483
def find_prefixed(root, prefix):
@@ -186,7 +215,7 @@ def maybe_delete_a_numbered_dir(path):
186215

187216
garbage = parent.joinpath("garbage-{}".format(uuid.uuid4()))
188217
path.rename(garbage)
189-
rmtree(garbage, force=True)
218+
rm_rf(garbage)
190219
except (OSError, EnvironmentError):
191220
# known races:
192221
# * other process did a cleanup at the same time

testing/test_tmpdir.py

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from __future__ import division
44
from __future__ import print_function
55

6+
import os
7+
import stat
68
import sys
79

810
import attr
@@ -318,21 +320,52 @@ def test_cleanup_locked(self, tmp_path):
318320
)
319321

320322
def test_rmtree(self, tmp_path):
321-
from _pytest.pathlib import rmtree
323+
from _pytest.pathlib import rm_rf
322324

323325
adir = tmp_path / "adir"
324326
adir.mkdir()
325-
rmtree(adir)
327+
rm_rf(adir)
326328

327329
assert not adir.exists()
328330

329331
adir.mkdir()
330332
afile = adir / "afile"
331333
afile.write_bytes(b"aa")
332334

333-
rmtree(adir, force=True)
335+
rm_rf(adir)
334336
assert not adir.exists()
335337

338+
def test_rmtree_with_read_only_file(self, tmp_path):
339+
"""Ensure rm_rf can remove directories with read-only files in them (#5524)"""
340+
from _pytest.pathlib import rm_rf
341+
342+
fn = tmp_path / "dir/foo.txt"
343+
fn.parent.mkdir()
344+
345+
fn.touch()
346+
347+
mode = os.stat(str(fn)).st_mode
348+
os.chmod(str(fn), mode & ~stat.S_IWRITE)
349+
350+
rm_rf(fn.parent)
351+
352+
assert not fn.parent.is_dir()
353+
354+
def test_rmtree_with_read_only_directory(self, tmp_path):
355+
"""Ensure rm_rf can remove read-only directories (#5524)"""
356+
from _pytest.pathlib import rm_rf
357+
358+
adir = tmp_path / "dir"
359+
adir.mkdir()
360+
361+
(adir / "foo.txt").touch()
362+
mode = os.stat(str(adir)).st_mode
363+
os.chmod(str(adir), mode & ~stat.S_IWRITE)
364+
365+
rm_rf(adir)
366+
367+
assert not adir.is_dir()
368+
336369
def test_cleanup_ignores_symlink(self, tmp_path):
337370
the_symlink = tmp_path / (self.PREFIX + "current")
338371
attempt_symlink_to(the_symlink, tmp_path / (self.PREFIX + "5"))
@@ -358,3 +391,24 @@ def attempt_symlink_to(path, to_path):
358391

359392
def test_tmpdir_equals_tmp_path(tmpdir, tmp_path):
360393
assert Path(tmpdir) == tmp_path
394+
395+
396+
def test_basetemp_with_read_only_files(testdir):
397+
"""Integration test for #5524"""
398+
testdir.makepyfile(
399+
"""
400+
import os
401+
import stat
402+
403+
def test(tmp_path):
404+
fn = tmp_path / 'foo.txt'
405+
fn.write_text(u'hello')
406+
mode = os.stat(str(fn)).st_mode
407+
os.chmod(str(fn), mode & ~stat.S_IREAD)
408+
"""
409+
)
410+
result = testdir.runpytest("--basetemp=tmp")
411+
assert result.ret == 0
412+
# running a second time and ensure we don't crash
413+
result = testdir.runpytest("--basetemp=tmp")
414+
assert result.ret == 0

0 commit comments

Comments
 (0)