Skip to content

Commit dde81b1

Browse files
Merge pull request #14121 from RonnyPfannschmidt/cachedir-fill-files
cacheprovider: simplify cache directory creation
2 parents 14ac82d + f499184 commit dde81b1

File tree

2 files changed

+76
-49
lines changed

2 files changed

+76
-49
lines changed

src/_pytest/cacheprovider.py

Lines changed: 41 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import json
1313
import os
1414
from pathlib import Path
15+
import shutil
1516
import tempfile
1617
from typing import final
1718

@@ -33,7 +34,8 @@
3334
from _pytest.reports import TestReport
3435

3536

36-
README_CONTENT = """\
37+
CACHEDIR_FILES: dict[str, bytes] = {
38+
"README.md": b"""\
3739
# pytest cache directory #
3840
3941
This directory contains data from the pytest's cache plugin,
@@ -42,14 +44,46 @@
4244
**Do not** commit this to version control.
4345
4446
See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
45-
"""
46-
47-
CACHEDIR_TAG_CONTENT = b"""\
47+
""",
48+
".gitignore": b"# Created by pytest automatically.\n*\n",
49+
"CACHEDIR.TAG": b"""\
4850
Signature: 8a477f597d28d172789f06886806bc55
4951
# This file is a cache directory tag created by pytest.
5052
# For information about cache directory tags, see:
5153
# https://bford.info/cachedir/spec.html
52-
"""
54+
""",
55+
}
56+
57+
58+
def _make_cachedir(target: Path) -> None:
59+
"""Create the pytest cache directory atomically with supporting files.
60+
61+
Creates a temporary directory with README.md, .gitignore, and CACHEDIR.TAG,
62+
then atomically renames it to the target location. If another process wins
63+
the race, the temporary directory is cleaned up.
64+
"""
65+
target.parent.mkdir(parents=True, exist_ok=True)
66+
path = Path(tempfile.mkdtemp(prefix="pytest-cache-files-", dir=target.parent))
67+
try:
68+
# Reset permissions to the default, see #12308.
69+
# Note: there's no way to get the current umask atomically, eek.
70+
umask = os.umask(0o022)
71+
os.umask(umask)
72+
path.chmod(0o777 - umask)
73+
74+
for name, content in CACHEDIR_FILES.items():
75+
path.joinpath(name).write_bytes(content)
76+
77+
path.rename(target)
78+
except OSError as e:
79+
# If 2 concurrent pytests both race to the rename, the loser
80+
# gets "Directory not empty" from the rename. In this case,
81+
# everything is handled so just continue after cleanup.
82+
# On Windows, the error is a FileExistsError which translates to EEXIST.
83+
if e.errno not in (errno.ENOTEMPTY, errno.EEXIST):
84+
raise
85+
finally:
86+
shutil.rmtree(path, ignore_errors=True)
5387

5488

5589
@final
@@ -202,48 +236,8 @@ def set(self, key: str, value: object) -> None:
202236

203237
def _ensure_cache_dir_and_supporting_files(self) -> None:
204238
"""Create the cache dir and its supporting files."""
205-
if self._cachedir.is_dir():
206-
return
207-
208-
self._cachedir.parent.mkdir(parents=True, exist_ok=True)
209-
with tempfile.TemporaryDirectory(
210-
prefix="pytest-cache-files-",
211-
dir=self._cachedir.parent,
212-
) as newpath:
213-
path = Path(newpath)
214-
215-
# Reset permissions to the default, see #12308.
216-
# Note: there's no way to get the current umask atomically, eek.
217-
umask = os.umask(0o022)
218-
os.umask(umask)
219-
path.chmod(0o777 - umask)
220-
221-
with open(path.joinpath("README.md"), "x", encoding="UTF-8") as f:
222-
f.write(README_CONTENT)
223-
with open(path.joinpath(".gitignore"), "x", encoding="UTF-8") as f:
224-
f.write("# Created by pytest automatically.\n*\n")
225-
with open(path.joinpath("CACHEDIR.TAG"), "xb") as f:
226-
f.write(CACHEDIR_TAG_CONTENT)
227-
228-
try:
229-
path.rename(self._cachedir)
230-
except OSError as e:
231-
# If 2 concurrent pytests both race to the rename, the loser
232-
# gets "Directory not empty" from the rename. In this case,
233-
# everything is handled so just continue (while letting the
234-
# temporary directory be cleaned up).
235-
# On Windows, the error is a FileExistsError which translates to EEXIST.
236-
if e.errno not in (errno.ENOTEMPTY, errno.EEXIST):
237-
raise
238-
else:
239-
# Create a directory in place of the one we just moved so that
240-
# `TemporaryDirectory`'s cleanup doesn't complain.
241-
#
242-
# TODO: pass ignore_cleanup_errors=True when we no longer support python < 3.10.
243-
# See https://github.com/python/cpython/issues/74168. Note that passing
244-
# delete=False would do the wrong thing in case of errors and isn't supported
245-
# until python 3.12.
246-
path.mkdir()
239+
if not self._cachedir.is_dir():
240+
_make_cachedir(self._cachedir)
247241

248242

249243
class LFPluginCollWrapper:

testing/test_cacheprovider.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1348,15 +1348,48 @@ def test_does_not_create_boilerplate_in_existing_dirs(pytester: Pytester) -> Non
13481348
def test_cachedir_tag(pytester: Pytester) -> None:
13491349
"""Ensure we automatically create CACHEDIR.TAG file in the pytest_cache directory (#4278)."""
13501350
from _pytest.cacheprovider import Cache
1351-
from _pytest.cacheprovider import CACHEDIR_TAG_CONTENT
1351+
from _pytest.cacheprovider import CACHEDIR_FILES
13521352

13531353
config = pytester.parseconfig()
13541354
cache = Cache.for_config(config, _ispytest=True)
13551355
cache.set("foo", "bar")
13561356
cachedir_tag_path = cache._cachedir.joinpath("CACHEDIR.TAG")
1357-
assert cachedir_tag_path.read_bytes() == CACHEDIR_TAG_CONTENT
1357+
assert cachedir_tag_path.read_bytes() == CACHEDIR_FILES["CACHEDIR.TAG"]
13581358

13591359

13601360
def test_clioption_with_cacheshow_and_help(pytester: Pytester) -> None:
13611361
result = pytester.runpytest("--cache-show", "--help")
13621362
assert result.ret == 0
1363+
1364+
1365+
def test_make_cachedir_cleans_up_on_base_exception(
1366+
tmp_path: Path,
1367+
monkeypatch: MonkeyPatch,
1368+
) -> None:
1369+
"""Ensure _make_cachedir cleans up the temp directory on BaseException.
1370+
1371+
When a BaseException (like KeyboardInterrupt) is raised during cache
1372+
directory creation, the temporary directory should be cleaned up before
1373+
re-raising the exception.
1374+
"""
1375+
from _pytest.cacheprovider import _make_cachedir
1376+
1377+
target = tmp_path / ".pytest_cache"
1378+
1379+
def raise_keyboard_interrupt(self: Path, target: Path) -> None:
1380+
raise KeyboardInterrupt("simulated interrupt")
1381+
1382+
# Patch Path.rename only for the duration of the _make_cachedir call
1383+
with monkeypatch.context() as m:
1384+
m.setattr(Path, "rename", raise_keyboard_interrupt)
1385+
1386+
# Verify the exception is re-raised
1387+
with pytest.raises(KeyboardInterrupt, match="simulated interrupt"):
1388+
_make_cachedir(target)
1389+
1390+
# Verify no temp directories were left behind
1391+
temp_dirs = list(tmp_path.glob("pytest-cache-files-*"))
1392+
assert temp_dirs == [], f"Temp directories not cleaned up: {temp_dirs}"
1393+
1394+
# Verify the target directory was not created
1395+
assert not target.exists()

0 commit comments

Comments
 (0)