Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 1 commit into from
Jul 11, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog/5524.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions src/_pytest/cacheprovider.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import pytest
from .pathlib import Path
from .pathlib import resolve_from_str
from .pathlib import rmtree
from .pathlib import rm_rf

README_CONTENT = """\
# pytest cache directory #
Expand Down Expand Up @@ -44,7 +44,7 @@ class Cache:
def for_config(cls, config):
cachedir = cls.cache_dir_from_config(config)
if config.getoption("cacheclear") and cachedir.exists():
rmtree(cachedir, force=True)
rm_rf(cachedir)
cachedir.mkdir()
return cls(cachedir, config)

Expand Down
45 changes: 36 additions & 9 deletions src/_pytest/pathlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
import shutil
import sys
import uuid
import warnings
from os.path import expanduser
from os.path import expandvars
from os.path import isabs
from os.path import sep
from posixpath import sep as posix_sep

from _pytest.warning_types import PytestWarning

if sys.version_info[:2] >= (3, 6):
from pathlib import Path, PurePath
Expand All @@ -32,17 +34,42 @@ def ensure_reset_dir(path):
ensures the given path is an empty directory
"""
if path.exists():
rmtree(path, force=True)
rm_rf(path)
path.mkdir()


def rmtree(path, force=False):
if force:
# NOTE: ignore_errors might leave dead folders around.
# Python needs a rm -rf as a followup.
shutil.rmtree(str(path), ignore_errors=True)
else:
shutil.rmtree(str(path))
def rm_rf(path):
"""Remove the path contents recursively, even if some elements
are read-only.
"""

def chmod_w(p):
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):
Expand Down Expand Up @@ -168,7 +195,7 @@ def maybe_delete_a_numbered_dir(path):

garbage = parent.joinpath("garbage-{}".format(uuid.uuid4()))
path.rename(garbage)
rmtree(garbage, force=True)
rm_rf(garbage)
except (OSError, EnvironmentError):
# known races:
# * other process did a cleanup at the same time
Expand Down
60 changes: 57 additions & 3 deletions testing/test_tmpdir.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import os
import stat
import sys

import attr
Expand Down Expand Up @@ -311,21 +313,52 @@ def test_cleanup_locked(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.mkdir()
rmtree(adir)
rm_rf(adir)

assert not adir.exists()

adir.mkdir()
afile = adir / "afile"
afile.write_bytes(b"aa")

rmtree(adir, force=True)
rm_rf(adir)
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):
the_symlink = tmp_path / (self.PREFIX + "current")
attempt_symlink_to(the_symlink, tmp_path / (self.PREFIX + "5"))
Expand All @@ -349,3 +382,24 @@ def attempt_symlink_to(path, to_path):

def test_tmpdir_equals_tmp_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