Skip to content

Commit cca029d

Browse files
authoredNov 15, 2022
Add configuration options to control how tmp_path directories are kept (#10442)
Close #8141
1 parent 69e3973 commit cca029d

File tree

6 files changed

+216
-10
lines changed

6 files changed

+216
-10
lines changed
 

‎AUTHORS

+1
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,7 @@ Xixi Zhao
374374
Xuan Luong
375375
Xuecong Liao
376376
Yoav Caspi
377+
Yusuke Kadowaki
377378
Yuval Shimon
378379
Zac Hatfield-Dodds
379380
Zachary Kneupper

‎changelog/8141.feature.rst

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
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.
2+
The default behavior has changed to keep only directories for failed tests, equivalent to `tmp_path_retention_policy="failed"`.

‎doc/en/reference/reference.rst

+34
Original file line numberDiff line numberDiff line change
@@ -1723,6 +1723,40 @@ passed multiple times. The expected format is ``name=value``. For example::
17231723
directories when executing from the root directory.
17241724

17251725

1726+
.. confval:: tmp_path_retention_count
1727+
1728+
1729+
1730+
How many sessions should we keep the `tmp_path` directories,
1731+
according to `tmp_path_retention_policy`.
1732+
1733+
.. code-block:: ini
1734+
1735+
[pytest]
1736+
tmp_path_retention_count = 3
1737+
1738+
Default: 3
1739+
1740+
1741+
.. confval:: tmp_path_retention_policy
1742+
1743+
1744+
1745+
Controls which directories created by the `tmp_path` fixture are kept around,
1746+
based on test outcome.
1747+
1748+
* `all`: retains directories for all tests, regardless of the outcome.
1749+
* `failed`: retains directories only for tests with outcome `error` or `failed`.
1750+
* `none`: directories are always removed after each test ends, regardless of the outcome.
1751+
1752+
.. code-block:: ini
1753+
1754+
[pytest]
1755+
tmp_path_retention_policy = "all"
1756+
1757+
Default: failed
1758+
1759+
17261760
.. confval:: usefixtures
17271761

17281762
List of fixtures that will be applied to all test functions; this is semantically the same to apply

‎src/_pytest/pathlib.py

+15-2
Original file line numberDiff line numberDiff line change
@@ -335,15 +335,26 @@ def cleanup_candidates(root: Path, prefix: str, keep: int) -> Iterator[Path]:
335335
yield path
336336

337337

338+
def cleanup_dead_symlink(root: Path):
339+
for left_dir in root.iterdir():
340+
if left_dir.is_symlink():
341+
if not left_dir.resolve().exists():
342+
left_dir.unlink()
343+
344+
338345
def cleanup_numbered_dir(
339346
root: Path, prefix: str, keep: int, consider_lock_dead_if_created_before: float
340347
) -> None:
341348
"""Cleanup for lock driven numbered directories."""
349+
if not root.exists():
350+
return
342351
for path in cleanup_candidates(root, prefix, keep):
343352
try_cleanup(path, consider_lock_dead_if_created_before)
344353
for path in root.glob("garbage-*"):
345354
try_cleanup(path, consider_lock_dead_if_created_before)
346355

356+
cleanup_dead_symlink(root)
357+
347358

348359
def make_numbered_dir_with_cleanup(
349360
root: Path,
@@ -357,8 +368,10 @@ def make_numbered_dir_with_cleanup(
357368
for i in range(10):
358369
try:
359370
p = make_numbered_dir(root, prefix, mode)
360-
lock_path = create_cleanup_lock(p)
361-
register_cleanup_lock_removal(lock_path)
371+
# Only lock the current dir when keep is not 0
372+
if keep != 0:
373+
lock_path = create_cleanup_lock(p)
374+
register_cleanup_lock_removal(lock_path)
362375
except Exception as exc:
363376
e = exc
364377
else:

‎src/_pytest/tmpdir.py

+99-3
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,30 @@
44
import sys
55
import tempfile
66
from pathlib import Path
7+
from shutil import rmtree
8+
from typing import Generator
79
from typing import Optional
10+
from typing import TYPE_CHECKING
11+
from typing import Union
12+
13+
if TYPE_CHECKING:
14+
from typing_extensions import Literal
15+
16+
RetentionType = Literal["all", "failed", "none"]
17+
818

919
import attr
20+
from _pytest.config.argparsing import Parser
1021

1122
from .pathlib import LOCK_TIMEOUT
1223
from .pathlib import make_numbered_dir
1324
from .pathlib import make_numbered_dir_with_cleanup
1425
from .pathlib import rm_rf
26+
from .pathlib import cleanup_dead_symlink
1527
from _pytest.compat import final
1628
from _pytest.config import Config
29+
from _pytest.config import ExitCode
30+
from _pytest.config import hookimpl
1731
from _pytest.deprecated import check_ispytest
1832
from _pytest.fixtures import fixture
1933
from _pytest.fixtures import FixtureRequest
@@ -31,10 +45,14 @@ class TempPathFactory:
3145
_given_basetemp = attr.ib(type=Optional[Path])
3246
_trace = attr.ib()
3347
_basetemp = attr.ib(type=Optional[Path])
48+
_retention_count = attr.ib(type=int)
49+
_retention_policy = attr.ib(type="RetentionType")
3450

3551
def __init__(
3652
self,
3753
given_basetemp: Optional[Path],
54+
retention_count: int,
55+
retention_policy: "RetentionType",
3856
trace,
3957
basetemp: Optional[Path] = None,
4058
*,
@@ -49,6 +67,8 @@ def __init__(
4967
# Path.absolute() exists, but it is not public (see https://bugs.python.org/issue25012).
5068
self._given_basetemp = Path(os.path.abspath(str(given_basetemp)))
5169
self._trace = trace
70+
self._retention_count = retention_count
71+
self._retention_policy = retention_policy
5272
self._basetemp = basetemp
5373

5474
@classmethod
@@ -63,9 +83,23 @@ def from_config(
6383
:meta private:
6484
"""
6585
check_ispytest(_ispytest)
86+
count = int(config.getini("tmp_path_retention_count"))
87+
if count < 0:
88+
raise ValueError(
89+
f"tmp_path_retention_count must be >= 0. Current input: {count}."
90+
)
91+
92+
policy = config.getini("tmp_path_retention_policy")
93+
if policy not in ("all", "failed", "none"):
94+
raise ValueError(
95+
f"tmp_path_retention_policy must be either all, failed, none. Current intput: {policy}."
96+
)
97+
6698
return cls(
6799
given_basetemp=config.option.basetemp,
68100
trace=config.trace.get("tmpdir"),
101+
retention_count=count,
102+
retention_policy=policy,
69103
_ispytest=True,
70104
)
71105

@@ -146,10 +180,13 @@ def getbasetemp(self) -> Path:
146180
)
147181
if (rootdir_stat.st_mode & 0o077) != 0:
148182
os.chmod(rootdir, rootdir_stat.st_mode & ~0o077)
183+
keep = self._retention_count
184+
if self._retention_policy == "none":
185+
keep = 0
149186
basetemp = make_numbered_dir_with_cleanup(
150187
prefix="pytest-",
151188
root=rootdir,
152-
keep=3,
189+
keep=keep,
153190
lock_timeout=LOCK_TIMEOUT,
154191
mode=0o700,
155192
)
@@ -184,6 +221,21 @@ def pytest_configure(config: Config) -> None:
184221
mp.setattr(config, "_tmp_path_factory", _tmp_path_factory, raising=False)
185222

186223

224+
def pytest_addoption(parser: Parser) -> None:
225+
parser.addini(
226+
"tmp_path_retention_count",
227+
help="How many sessions should we keep the `tmp_path` directories, according to `tmp_path_retention_policy`.",
228+
default=3,
229+
)
230+
231+
parser.addini(
232+
"tmp_path_retention_policy",
233+
help="Controls which directories created by the `tmp_path` fixture are kept around, based on test outcome. "
234+
"(all/failed/none)",
235+
default="failed",
236+
)
237+
238+
187239
@fixture(scope="session")
188240
def tmp_path_factory(request: FixtureRequest) -> TempPathFactory:
189241
"""Return a :class:`pytest.TempPathFactory` instance for the test session."""
@@ -200,7 +252,9 @@ def _mk_tmp(request: FixtureRequest, factory: TempPathFactory) -> Path:
200252

201253

202254
@fixture
203-
def tmp_path(request: FixtureRequest, tmp_path_factory: TempPathFactory) -> Path:
255+
def tmp_path(
256+
request: FixtureRequest, tmp_path_factory: TempPathFactory
257+
) -> Generator[Path, None, None]:
204258
"""Return a temporary directory path object which is unique to each test
205259
function invocation, created as a sub directory of the base temporary
206260
directory.
@@ -213,4 +267,46 @@ def tmp_path(request: FixtureRequest, tmp_path_factory: TempPathFactory) -> Path
213267
The returned object is a :class:`pathlib.Path` object.
214268
"""
215269

216-
return _mk_tmp(request, tmp_path_factory)
270+
path = _mk_tmp(request, tmp_path_factory)
271+
yield path
272+
273+
# Remove the tmpdir if the policy is "failed" and the test passed.
274+
tmp_path_factory: TempPathFactory = request.session.config._tmp_path_factory # type: ignore
275+
policy = tmp_path_factory._retention_policy
276+
if policy == "failed" and request.node._tmp_path_result_call.passed:
277+
# We do a "best effort" to remove files, but it might not be possible due to some leaked resource,
278+
# permissions, etc, in which case we ignore it.
279+
rmtree(path, ignore_errors=True)
280+
281+
# remove dead symlink
282+
basetemp = tmp_path_factory._basetemp
283+
if basetemp is None:
284+
return
285+
cleanup_dead_symlink(basetemp)
286+
287+
288+
def pytest_sessionfinish(session, exitstatus: Union[int, ExitCode]):
289+
"""After each session, remove base directory if all the tests passed,
290+
the policy is "failed", and the basetemp is not specified by a user.
291+
"""
292+
tmp_path_factory: TempPathFactory = session.config._tmp_path_factory
293+
if tmp_path_factory._basetemp is None:
294+
return
295+
policy = tmp_path_factory._retention_policy
296+
if (
297+
exitstatus == 0
298+
and policy == "failed"
299+
and tmp_path_factory._given_basetemp is None
300+
):
301+
passed_dir = tmp_path_factory._basetemp
302+
if passed_dir.exists():
303+
# We do a "best effort" to remove files, but it might not be possible due to some leaked resource,
304+
# permissions, etc, in which case we ignore it.
305+
rmtree(passed_dir, ignore_errors=True)
306+
307+
308+
@hookimpl(tryfirst=True, hookwrapper=True)
309+
def pytest_runtest_makereport(item, call):
310+
outcome = yield
311+
result = outcome.get_result()
312+
setattr(item, "_tmp_path_result_" + result.when, result)

‎testing/test_tmpdir.py

+65-5
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ def trace(self):
4242
def get(self, key):
4343
return lambda *k: None
4444

45+
def getini(self, name):
46+
if name == "tmp_path_retention_count":
47+
return 3
48+
elif name == "tmp_path_retention_policy":
49+
return "failed"
50+
else:
51+
assert False
52+
4553
@property
4654
def option(self):
4755
return self
@@ -84,6 +92,53 @@ def test_1(tmp_path):
8492
assert mytemp.exists()
8593
assert not mytemp.joinpath("hello").exists()
8694

95+
def test_policy_failed_removes_only_passed_dir(self, pytester: Pytester) -> None:
96+
p = pytester.makepyfile(
97+
"""
98+
def test_1(tmp_path):
99+
assert 0 == 0
100+
def test_2(tmp_path):
101+
assert 0 == 1
102+
"""
103+
)
104+
105+
pytester.inline_run(p)
106+
root = pytester._test_tmproot
107+
108+
for child in root.iterdir():
109+
base_dir = list(
110+
filter(lambda x: x.is_dir() and not x.is_symlink(), child.iterdir())
111+
)
112+
assert len(base_dir) == 1
113+
test_dir = list(
114+
filter(
115+
lambda x: x.is_dir() and not x.is_symlink(), base_dir[0].iterdir()
116+
)
117+
)
118+
# Check only the failed one remains
119+
assert len(test_dir) == 1
120+
assert test_dir[0].name == "test_20"
121+
122+
def test_policy_failed_removes_basedir_when_all_passed(
123+
self, pytester: Pytester
124+
) -> None:
125+
p = pytester.makepyfile(
126+
"""
127+
def test_1(tmp_path):
128+
assert 0 == 0
129+
"""
130+
)
131+
132+
pytester.inline_run(p)
133+
root = pytester._test_tmproot
134+
for child in root.iterdir():
135+
# This symlink will be deleted by cleanup_numbered_dir **after**
136+
# the test finishes because it's triggered by atexit.
137+
# So it has to be ignored here.
138+
base_dir = filter(lambda x: not x.is_symlink(), child.iterdir())
139+
# Check the base dir itself is gone
140+
assert len(list(base_dir)) == 0
141+
87142

88143
testdata = [
89144
("mypath", True),
@@ -275,12 +330,12 @@ def test_lock_register_cleanup_removal(self, tmp_path: Path) -> None:
275330

276331
assert not lock.exists()
277332

278-
def _do_cleanup(self, tmp_path: Path) -> None:
333+
def _do_cleanup(self, tmp_path: Path, keep: int = 2) -> None:
279334
self.test_make(tmp_path)
280335
cleanup_numbered_dir(
281336
root=tmp_path,
282337
prefix=self.PREFIX,
283-
keep=2,
338+
keep=keep,
284339
consider_lock_dead_if_created_before=0,
285340
)
286341

@@ -289,6 +344,11 @@ def test_cleanup_keep(self, tmp_path):
289344
a, b = (x for x in tmp_path.iterdir() if not x.is_symlink())
290345
print(a, b)
291346

347+
def test_cleanup_keep_0(self, tmp_path: Path):
348+
self._do_cleanup(tmp_path, 0)
349+
dir_num = len(list(tmp_path.iterdir()))
350+
assert dir_num == 0
351+
292352
def test_cleanup_locked(self, tmp_path):
293353
p = make_numbered_dir(root=tmp_path, prefix=self.PREFIX)
294354

@@ -446,7 +506,7 @@ def test_tmp_path_factory_create_directory_with_safe_permissions(
446506
"""Verify that pytest creates directories under /tmp with private permissions."""
447507
# Use the test's tmp_path as the system temproot (/tmp).
448508
monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path))
449-
tmp_factory = TempPathFactory(None, lambda *args: None, _ispytest=True)
509+
tmp_factory = TempPathFactory(None, 3, "failed", lambda *args: None, _ispytest=True)
450510
basetemp = tmp_factory.getbasetemp()
451511

452512
# No world-readable permissions.
@@ -466,14 +526,14 @@ def test_tmp_path_factory_fixes_up_world_readable_permissions(
466526
"""
467527
# Use the test's tmp_path as the system temproot (/tmp).
468528
monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path))
469-
tmp_factory = TempPathFactory(None, lambda *args: None, _ispytest=True)
529+
tmp_factory = TempPathFactory(None, 3, "failed", lambda *args: None, _ispytest=True)
470530
basetemp = tmp_factory.getbasetemp()
471531

472532
# Before - simulate bad perms.
473533
os.chmod(basetemp.parent, 0o777)
474534
assert (basetemp.parent.stat().st_mode & 0o077) != 0
475535

476-
tmp_factory = TempPathFactory(None, lambda *args: None, _ispytest=True)
536+
tmp_factory = TempPathFactory(None, 3, "failed", lambda *args: None, _ispytest=True)
477537
basetemp = tmp_factory.getbasetemp()
478538

479539
# After - fixed.

0 commit comments

Comments
 (0)