Add configuration options to control how `tmp_path` directories are kept (#10442)

Close #8141
This commit is contained in:
Yusuke Kadowaki 2022-11-15 21:11:39 +09:00 committed by GitHub
parent 69e3973d86
commit cca029d55e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 216 additions and 10 deletions

View File

@ -374,6 +374,7 @@ Xixi Zhao
Xuan Luong Xuan Luong
Xuecong Liao Xuecong Liao
Yoav Caspi Yoav Caspi
Yusuke Kadowaki
Yuval Shimon Yuval Shimon
Zac Hatfield-Dodds Zac Hatfield-Dodds
Zachary Kneupper Zachary Kneupper

View File

@ -0,0 +1,2 @@
Added :confval:`tmp_path_retention_count` and :confval:`tmp_path_retention_policy` configuration options to control how directories created by the :fixture:`tmp_path` fixture are kept.
The default behavior has changed to keep only directories for failed tests, equivalent to `tmp_path_retention_policy="failed"`.

View File

@ -1723,6 +1723,40 @@ passed multiple times. The expected format is ``name=value``. For example::
directories when executing from the root directory. directories when executing from the root directory.
.. confval:: tmp_path_retention_count
How many sessions should we keep the `tmp_path` directories,
according to `tmp_path_retention_policy`.
.. code-block:: ini
[pytest]
tmp_path_retention_count = 3
Default: 3
.. confval:: tmp_path_retention_policy
Controls which directories created by the `tmp_path` fixture are kept around,
based on test outcome.
* `all`: retains directories for all tests, regardless of the outcome.
* `failed`: retains directories only for tests with outcome `error` or `failed`.
* `none`: directories are always removed after each test ends, regardless of the outcome.
.. code-block:: ini
[pytest]
tmp_path_retention_policy = "all"
Default: failed
.. confval:: usefixtures .. confval:: usefixtures
List of fixtures that will be applied to all test functions; this is semantically the same to apply List of fixtures that will be applied to all test functions; this is semantically the same to apply

View File

@ -335,15 +335,26 @@ def cleanup_candidates(root: Path, prefix: str, keep: int) -> Iterator[Path]:
yield path yield path
def cleanup_dead_symlink(root: Path):
for left_dir in root.iterdir():
if left_dir.is_symlink():
if not left_dir.resolve().exists():
left_dir.unlink()
def cleanup_numbered_dir( def cleanup_numbered_dir(
root: Path, prefix: str, keep: int, consider_lock_dead_if_created_before: float root: Path, prefix: str, keep: int, consider_lock_dead_if_created_before: float
) -> None: ) -> None:
"""Cleanup for lock driven numbered directories.""" """Cleanup for lock driven numbered directories."""
if not root.exists():
return
for path in cleanup_candidates(root, prefix, keep): for path in cleanup_candidates(root, prefix, keep):
try_cleanup(path, consider_lock_dead_if_created_before) try_cleanup(path, consider_lock_dead_if_created_before)
for path in root.glob("garbage-*"): for path in root.glob("garbage-*"):
try_cleanup(path, consider_lock_dead_if_created_before) try_cleanup(path, consider_lock_dead_if_created_before)
cleanup_dead_symlink(root)
def make_numbered_dir_with_cleanup( def make_numbered_dir_with_cleanup(
root: Path, root: Path,
@ -357,8 +368,10 @@ def make_numbered_dir_with_cleanup(
for i in range(10): for i in range(10):
try: try:
p = make_numbered_dir(root, prefix, mode) p = make_numbered_dir(root, prefix, mode)
lock_path = create_cleanup_lock(p) # Only lock the current dir when keep is not 0
register_cleanup_lock_removal(lock_path) if keep != 0:
lock_path = create_cleanup_lock(p)
register_cleanup_lock_removal(lock_path)
except Exception as exc: except Exception as exc:
e = exc e = exc
else: else:

View File

@ -4,16 +4,30 @@ import re
import sys import sys
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from shutil import rmtree
from typing import Generator
from typing import Optional from typing import Optional
from typing import TYPE_CHECKING
from typing import Union
if TYPE_CHECKING:
from typing_extensions import Literal
RetentionType = Literal["all", "failed", "none"]
import attr import attr
from _pytest.config.argparsing import Parser
from .pathlib import LOCK_TIMEOUT from .pathlib import LOCK_TIMEOUT
from .pathlib import make_numbered_dir from .pathlib import make_numbered_dir
from .pathlib import make_numbered_dir_with_cleanup from .pathlib import make_numbered_dir_with_cleanup
from .pathlib import rm_rf from .pathlib import rm_rf
from .pathlib import cleanup_dead_symlink
from _pytest.compat import final from _pytest.compat import final
from _pytest.config import Config from _pytest.config import Config
from _pytest.config import ExitCode
from _pytest.config import hookimpl
from _pytest.deprecated import check_ispytest from _pytest.deprecated import check_ispytest
from _pytest.fixtures import fixture from _pytest.fixtures import fixture
from _pytest.fixtures import FixtureRequest from _pytest.fixtures import FixtureRequest
@ -31,10 +45,14 @@ class TempPathFactory:
_given_basetemp = attr.ib(type=Optional[Path]) _given_basetemp = attr.ib(type=Optional[Path])
_trace = attr.ib() _trace = attr.ib()
_basetemp = attr.ib(type=Optional[Path]) _basetemp = attr.ib(type=Optional[Path])
_retention_count = attr.ib(type=int)
_retention_policy = attr.ib(type="RetentionType")
def __init__( def __init__(
self, self,
given_basetemp: Optional[Path], given_basetemp: Optional[Path],
retention_count: int,
retention_policy: "RetentionType",
trace, trace,
basetemp: Optional[Path] = None, basetemp: Optional[Path] = None,
*, *,
@ -49,6 +67,8 @@ class TempPathFactory:
# Path.absolute() exists, but it is not public (see https://bugs.python.org/issue25012). # Path.absolute() exists, but it is not public (see https://bugs.python.org/issue25012).
self._given_basetemp = Path(os.path.abspath(str(given_basetemp))) self._given_basetemp = Path(os.path.abspath(str(given_basetemp)))
self._trace = trace self._trace = trace
self._retention_count = retention_count
self._retention_policy = retention_policy
self._basetemp = basetemp self._basetemp = basetemp
@classmethod @classmethod
@ -63,9 +83,23 @@ class TempPathFactory:
:meta private: :meta private:
""" """
check_ispytest(_ispytest) check_ispytest(_ispytest)
count = int(config.getini("tmp_path_retention_count"))
if count < 0:
raise ValueError(
f"tmp_path_retention_count must be >= 0. Current input: {count}."
)
policy = config.getini("tmp_path_retention_policy")
if policy not in ("all", "failed", "none"):
raise ValueError(
f"tmp_path_retention_policy must be either all, failed, none. Current intput: {policy}."
)
return cls( return cls(
given_basetemp=config.option.basetemp, given_basetemp=config.option.basetemp,
trace=config.trace.get("tmpdir"), trace=config.trace.get("tmpdir"),
retention_count=count,
retention_policy=policy,
_ispytest=True, _ispytest=True,
) )
@ -146,10 +180,13 @@ class TempPathFactory:
) )
if (rootdir_stat.st_mode & 0o077) != 0: if (rootdir_stat.st_mode & 0o077) != 0:
os.chmod(rootdir, rootdir_stat.st_mode & ~0o077) os.chmod(rootdir, rootdir_stat.st_mode & ~0o077)
keep = self._retention_count
if self._retention_policy == "none":
keep = 0
basetemp = make_numbered_dir_with_cleanup( basetemp = make_numbered_dir_with_cleanup(
prefix="pytest-", prefix="pytest-",
root=rootdir, root=rootdir,
keep=3, keep=keep,
lock_timeout=LOCK_TIMEOUT, lock_timeout=LOCK_TIMEOUT,
mode=0o700, mode=0o700,
) )
@ -184,6 +221,21 @@ def pytest_configure(config: Config) -> None:
mp.setattr(config, "_tmp_path_factory", _tmp_path_factory, raising=False) mp.setattr(config, "_tmp_path_factory", _tmp_path_factory, raising=False)
def pytest_addoption(parser: Parser) -> None:
parser.addini(
"tmp_path_retention_count",
help="How many sessions should we keep the `tmp_path` directories, according to `tmp_path_retention_policy`.",
default=3,
)
parser.addini(
"tmp_path_retention_policy",
help="Controls which directories created by the `tmp_path` fixture are kept around, based on test outcome. "
"(all/failed/none)",
default="failed",
)
@fixture(scope="session") @fixture(scope="session")
def tmp_path_factory(request: FixtureRequest) -> TempPathFactory: def tmp_path_factory(request: FixtureRequest) -> TempPathFactory:
"""Return a :class:`pytest.TempPathFactory` instance for the test session.""" """Return a :class:`pytest.TempPathFactory` instance for the test session."""
@ -200,7 +252,9 @@ def _mk_tmp(request: FixtureRequest, factory: TempPathFactory) -> Path:
@fixture @fixture
def tmp_path(request: FixtureRequest, tmp_path_factory: TempPathFactory) -> Path: def tmp_path(
request: FixtureRequest, tmp_path_factory: TempPathFactory
) -> Generator[Path, None, None]:
"""Return a temporary directory path object which is unique to each test """Return a temporary directory path object which is unique to each test
function invocation, created as a sub directory of the base temporary function invocation, created as a sub directory of the base temporary
directory. directory.
@ -213,4 +267,46 @@ def tmp_path(request: FixtureRequest, tmp_path_factory: TempPathFactory) -> Path
The returned object is a :class:`pathlib.Path` object. The returned object is a :class:`pathlib.Path` object.
""" """
return _mk_tmp(request, tmp_path_factory) path = _mk_tmp(request, tmp_path_factory)
yield path
# Remove the tmpdir if the policy is "failed" and the test passed.
tmp_path_factory: TempPathFactory = request.session.config._tmp_path_factory # type: ignore
policy = tmp_path_factory._retention_policy
if policy == "failed" and request.node._tmp_path_result_call.passed:
# We do a "best effort" to remove files, but it might not be possible due to some leaked resource,
# permissions, etc, in which case we ignore it.
rmtree(path, ignore_errors=True)
# remove dead symlink
basetemp = tmp_path_factory._basetemp
if basetemp is None:
return
cleanup_dead_symlink(basetemp)
def pytest_sessionfinish(session, exitstatus: Union[int, ExitCode]):
"""After each session, remove base directory if all the tests passed,
the policy is "failed", and the basetemp is not specified by a user.
"""
tmp_path_factory: TempPathFactory = session.config._tmp_path_factory
if tmp_path_factory._basetemp is None:
return
policy = tmp_path_factory._retention_policy
if (
exitstatus == 0
and policy == "failed"
and tmp_path_factory._given_basetemp is None
):
passed_dir = tmp_path_factory._basetemp
if passed_dir.exists():
# We do a "best effort" to remove files, but it might not be possible due to some leaked resource,
# permissions, etc, in which case we ignore it.
rmtree(passed_dir, ignore_errors=True)
@hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
result = outcome.get_result()
setattr(item, "_tmp_path_result_" + result.when, result)

View File

@ -42,6 +42,14 @@ class FakeConfig:
def get(self, key): def get(self, key):
return lambda *k: None return lambda *k: None
def getini(self, name):
if name == "tmp_path_retention_count":
return 3
elif name == "tmp_path_retention_policy":
return "failed"
else:
assert False
@property @property
def option(self): def option(self):
return self return self
@ -84,6 +92,53 @@ class TestConfigTmpPath:
assert mytemp.exists() assert mytemp.exists()
assert not mytemp.joinpath("hello").exists() assert not mytemp.joinpath("hello").exists()
def test_policy_failed_removes_only_passed_dir(self, pytester: Pytester) -> None:
p = pytester.makepyfile(
"""
def test_1(tmp_path):
assert 0 == 0
def test_2(tmp_path):
assert 0 == 1
"""
)
pytester.inline_run(p)
root = pytester._test_tmproot
for child in root.iterdir():
base_dir = list(
filter(lambda x: x.is_dir() and not x.is_symlink(), child.iterdir())
)
assert len(base_dir) == 1
test_dir = list(
filter(
lambda x: x.is_dir() and not x.is_symlink(), base_dir[0].iterdir()
)
)
# Check only the failed one remains
assert len(test_dir) == 1
assert test_dir[0].name == "test_20"
def test_policy_failed_removes_basedir_when_all_passed(
self, pytester: Pytester
) -> None:
p = pytester.makepyfile(
"""
def test_1(tmp_path):
assert 0 == 0
"""
)
pytester.inline_run(p)
root = pytester._test_tmproot
for child in root.iterdir():
# This symlink will be deleted by cleanup_numbered_dir **after**
# the test finishes because it's triggered by atexit.
# So it has to be ignored here.
base_dir = filter(lambda x: not x.is_symlink(), child.iterdir())
# Check the base dir itself is gone
assert len(list(base_dir)) == 0
testdata = [ testdata = [
("mypath", True), ("mypath", True),
@ -275,12 +330,12 @@ class TestNumberedDir:
assert not lock.exists() assert not lock.exists()
def _do_cleanup(self, tmp_path: Path) -> None: def _do_cleanup(self, tmp_path: Path, keep: int = 2) -> None:
self.test_make(tmp_path) self.test_make(tmp_path)
cleanup_numbered_dir( cleanup_numbered_dir(
root=tmp_path, root=tmp_path,
prefix=self.PREFIX, prefix=self.PREFIX,
keep=2, keep=keep,
consider_lock_dead_if_created_before=0, consider_lock_dead_if_created_before=0,
) )
@ -289,6 +344,11 @@ class TestNumberedDir:
a, b = (x for x in tmp_path.iterdir() if not x.is_symlink()) a, b = (x for x in tmp_path.iterdir() if not x.is_symlink())
print(a, b) print(a, b)
def test_cleanup_keep_0(self, tmp_path: Path):
self._do_cleanup(tmp_path, 0)
dir_num = len(list(tmp_path.iterdir()))
assert dir_num == 0
def test_cleanup_locked(self, tmp_path): def test_cleanup_locked(self, tmp_path):
p = make_numbered_dir(root=tmp_path, prefix=self.PREFIX) p = make_numbered_dir(root=tmp_path, prefix=self.PREFIX)
@ -446,7 +506,7 @@ def test_tmp_path_factory_create_directory_with_safe_permissions(
"""Verify that pytest creates directories under /tmp with private permissions.""" """Verify that pytest creates directories under /tmp with private permissions."""
# Use the test's tmp_path as the system temproot (/tmp). # Use the test's tmp_path as the system temproot (/tmp).
monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path)) monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path))
tmp_factory = TempPathFactory(None, lambda *args: None, _ispytest=True) tmp_factory = TempPathFactory(None, 3, "failed", lambda *args: None, _ispytest=True)
basetemp = tmp_factory.getbasetemp() basetemp = tmp_factory.getbasetemp()
# No world-readable permissions. # No world-readable permissions.
@ -466,14 +526,14 @@ def test_tmp_path_factory_fixes_up_world_readable_permissions(
""" """
# Use the test's tmp_path as the system temproot (/tmp). # Use the test's tmp_path as the system temproot (/tmp).
monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path)) monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path))
tmp_factory = TempPathFactory(None, lambda *args: None, _ispytest=True) tmp_factory = TempPathFactory(None, 3, "failed", lambda *args: None, _ispytest=True)
basetemp = tmp_factory.getbasetemp() basetemp = tmp_factory.getbasetemp()
# Before - simulate bad perms. # Before - simulate bad perms.
os.chmod(basetemp.parent, 0o777) os.chmod(basetemp.parent, 0o777)
assert (basetemp.parent.stat().st_mode & 0o077) != 0 assert (basetemp.parent.stat().st_mode & 0o077) != 0
tmp_factory = TempPathFactory(None, lambda *args: None, _ispytest=True) tmp_factory = TempPathFactory(None, 3, "failed", lambda *args: None, _ispytest=True)
basetemp = tmp_factory.getbasetemp() basetemp = tmp_factory.getbasetemp()
# After - fixed. # After - fixed.