Fix rmtree to remove directories with read-only files (#5588)

Fix rmtree to remove directories with read-only files
This commit is contained in:
Bruno Oliveira 2019-07-11 18:57:03 -03:00 committed by GitHub
commit 4027254a4b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 97 additions and 14 deletions

View File

@ -0,0 +1,2 @@
Fix issue where ``tmp_path`` and ``tmpdir`` would not remove directories containing files marked as read-only,
which could lead to pytest crashing when executed a second time with the ``--basetemp`` option.

View File

@ -14,7 +14,7 @@ import py
import pytest import pytest
from .pathlib import Path from .pathlib import Path
from .pathlib import resolve_from_str from .pathlib import resolve_from_str
from .pathlib import rmtree from .pathlib import rm_rf
README_CONTENT = """\ README_CONTENT = """\
# pytest cache directory # # pytest cache directory #
@ -44,7 +44,7 @@ class Cache:
def for_config(cls, config): def for_config(cls, config):
cachedir = cls.cache_dir_from_config(config) cachedir = cls.cache_dir_from_config(config)
if config.getoption("cacheclear") and cachedir.exists(): if config.getoption("cacheclear") and cachedir.exists():
rmtree(cachedir, force=True) rm_rf(cachedir)
cachedir.mkdir() cachedir.mkdir()
return cls(cachedir, config) return cls(cachedir, config)

View File

@ -7,12 +7,14 @@ import os
import shutil import shutil
import sys import sys
import uuid import uuid
import warnings
from os.path import expanduser from os.path import expanduser
from os.path import expandvars from os.path import expandvars
from os.path import isabs from os.path import isabs
from os.path import sep from os.path import sep
from posixpath import sep as posix_sep from posixpath import sep as posix_sep
from _pytest.warning_types import PytestWarning
if sys.version_info[:2] >= (3, 6): if sys.version_info[:2] >= (3, 6):
from pathlib import Path, PurePath from pathlib import Path, PurePath
@ -32,17 +34,42 @@ def ensure_reset_dir(path):
ensures the given path is an empty directory ensures the given path is an empty directory
""" """
if path.exists(): if path.exists():
rmtree(path, force=True) rm_rf(path)
path.mkdir() path.mkdir()
def rmtree(path, force=False): def rm_rf(path):
if force: """Remove the path contents recursively, even if some elements
# NOTE: ignore_errors might leave dead folders around. are read-only.
# Python needs a rm -rf as a followup. """
shutil.rmtree(str(path), ignore_errors=True)
else: def chmod_w(p):
shutil.rmtree(str(path)) import stat
mode = os.stat(str(p)).st_mode
os.chmod(str(p), mode | stat.S_IWRITE)
def force_writable_and_retry(function, p, excinfo):
p = Path(p)
# for files, we need to recursively go upwards
# in the directories to ensure they all are also
# writable
if p.is_file():
for parent in p.parents:
chmod_w(parent)
# stop when we reach the original path passed to rm_rf
if parent == path:
break
chmod_w(p)
try:
# retry the function that failed
function(str(p))
except Exception as e:
warnings.warn(PytestWarning("(rm_rf) error removing {}: {}".format(p, e)))
shutil.rmtree(str(path), onerror=force_writable_and_retry)
def find_prefixed(root, prefix): def find_prefixed(root, prefix):
@ -168,7 +195,7 @@ def maybe_delete_a_numbered_dir(path):
garbage = parent.joinpath("garbage-{}".format(uuid.uuid4())) garbage = parent.joinpath("garbage-{}".format(uuid.uuid4()))
path.rename(garbage) path.rename(garbage)
rmtree(garbage, force=True) rm_rf(garbage)
except (OSError, EnvironmentError): except (OSError, EnvironmentError):
# known races: # known races:
# * other process did a cleanup at the same time # * other process did a cleanup at the same time

View File

@ -1,3 +1,5 @@
import os
import stat
import sys import sys
import attr import attr
@ -311,11 +313,11 @@ class TestNumberedDir:
) )
def test_rmtree(self, tmp_path): def test_rmtree(self, tmp_path):
from _pytest.pathlib import rmtree from _pytest.pathlib import rm_rf
adir = tmp_path / "adir" adir = tmp_path / "adir"
adir.mkdir() adir.mkdir()
rmtree(adir) rm_rf(adir)
assert not adir.exists() assert not adir.exists()
@ -323,9 +325,40 @@ class TestNumberedDir:
afile = adir / "afile" afile = adir / "afile"
afile.write_bytes(b"aa") afile.write_bytes(b"aa")
rmtree(adir, force=True) rm_rf(adir)
assert not adir.exists() assert not adir.exists()
def test_rmtree_with_read_only_file(self, tmp_path):
"""Ensure rm_rf can remove directories with read-only files in them (#5524)"""
from _pytest.pathlib import rm_rf
fn = tmp_path / "dir/foo.txt"
fn.parent.mkdir()
fn.touch()
mode = os.stat(str(fn)).st_mode
os.chmod(str(fn), mode & ~stat.S_IWRITE)
rm_rf(fn.parent)
assert not fn.parent.is_dir()
def test_rmtree_with_read_only_directory(self, tmp_path):
"""Ensure rm_rf can remove read-only directories (#5524)"""
from _pytest.pathlib import rm_rf
adir = tmp_path / "dir"
adir.mkdir()
(adir / "foo.txt").touch()
mode = os.stat(str(adir)).st_mode
os.chmod(str(adir), mode & ~stat.S_IWRITE)
rm_rf(adir)
assert not adir.is_dir()
def test_cleanup_ignores_symlink(self, tmp_path): def test_cleanup_ignores_symlink(self, tmp_path):
the_symlink = tmp_path / (self.PREFIX + "current") the_symlink = tmp_path / (self.PREFIX + "current")
attempt_symlink_to(the_symlink, tmp_path / (self.PREFIX + "5")) attempt_symlink_to(the_symlink, tmp_path / (self.PREFIX + "5"))
@ -349,3 +382,24 @@ def attempt_symlink_to(path, to_path):
def test_tmpdir_equals_tmp_path(tmpdir, tmp_path): def test_tmpdir_equals_tmp_path(tmpdir, tmp_path):
assert Path(tmpdir) == tmp_path assert Path(tmpdir) == tmp_path
def test_basetemp_with_read_only_files(testdir):
"""Integration test for #5524"""
testdir.makepyfile(
"""
import os
import stat
def test(tmp_path):
fn = tmp_path / 'foo.txt'
fn.write_text('hello')
mode = os.stat(str(fn)).st_mode
os.chmod(str(fn), mode & ~stat.S_IREAD)
"""
)
result = testdir.runpytest("--basetemp=tmp")
assert result.ret == 0
# running a second time and ensure we don't crash
result = testdir.runpytest("--basetemp=tmp")
assert result.ret == 0