[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)
This commit is contained in:
Bruno Oliveira 2019-07-11 19:43:55 -03:00 committed by GitHub
commit 30de66944d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 99 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

@ -21,7 +21,7 @@ import pytest
from .compat import _PY2 as PY2 from .compat import _PY2 as PY2
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 = u"""\ README_CONTENT = u"""\
# pytest cache directory # # pytest cache directory #
@ -51,7 +51,7 @@ class Cache(object):
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

@ -1,4 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import absolute_import
import atexit import atexit
import errno import errno
import fnmatch import fnmatch
@ -8,6 +10,7 @@ import os
import shutil import shutil
import sys import sys
import uuid import uuid
import warnings
from functools import reduce from functools import reduce
from os.path import expanduser from os.path import expanduser
from os.path import expandvars from os.path import expandvars
@ -19,6 +22,7 @@ import six
from six.moves import map from six.moves import map
from .compat import PY36 from .compat import PY36
from _pytest.warning_types import PytestWarning
if PY36: if PY36:
from pathlib import Path, PurePath from pathlib import Path, PurePath
@ -38,17 +42,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):
@ -186,7 +215,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

@ -3,6 +3,8 @@ from __future__ import absolute_import
from __future__ import division from __future__ import division
from __future__ import print_function from __future__ import print_function
import os
import stat
import sys import sys
import attr import attr
@ -318,11 +320,11 @@ class TestNumberedDir(object):
) )
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()
@ -330,9 +332,40 @@ class TestNumberedDir(object):
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"))
@ -358,3 +391,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(u'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