Skip to content

Commit 9057525

Browse files
committed
Initialize cache directory in isolation
Creating and initializing the cache directory is interruptible; this avoids a pathological case where interrupting a cache write can cause the cache directory to never be properly initialized with its supporting files. Unify `Cache.mkdir` with `Cache.set` while I'm here so the former also properly initializes the cache directory. Closes #12167.
1 parent 4489528 commit 9057525

File tree

2 files changed

+31
-17
lines changed

2 files changed

+31
-17
lines changed

changelog/12167.trivial.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
cache: create supporting files (``CACHEDIR.TAG``, ``.gitignore``, etc.) in the cache directory before writing cache data, reducing the probability of those files being lost.

src/_pytest/cacheprovider.py

+30-17
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,23 @@
77
import json
88
import os
99
from pathlib import Path
10+
import tempfile
1011
from typing import Dict
1112
from typing import final
1213
from typing import Generator
1314
from typing import Iterable
1415
from typing import List
1516
from typing import Optional
1617
from typing import Set
18+
from typing import Tuple
1719
from typing import Union
1820

1921
from .pathlib import resolve_from_str
2022
from .pathlib import rm_rf
2123
from .reports import CollectReport
2224
from _pytest import nodes
2325
from _pytest._io import TerminalWriter
26+
from _pytest.compat import assert_never
2427
from _pytest.config import Config
2528
from _pytest.config import ExitCode
2629
from _pytest.config import hookimpl
@@ -123,6 +126,10 @@ def warn(self, fmt: str, *, _ispytest: bool = False, **args: object) -> None:
123126
stacklevel=3,
124127
)
125128

129+
def _mkdir(self, path: Path) -> None:
130+
self._ensure_cache_dir_and_supporting_files()
131+
path.mkdir(exist_ok=True, parents=True)
132+
126133
def mkdir(self, name: str) -> Path:
127134
"""Return a directory path object with the given name.
128135
@@ -141,7 +148,7 @@ def mkdir(self, name: str) -> Path:
141148
if len(path.parts) > 1:
142149
raise ValueError("name is not allowed to contain path separators")
143150
res = self._cachedir.joinpath(self._CACHE_PREFIX_DIRS, path)
144-
res.mkdir(exist_ok=True, parents=True)
151+
self._mkdir(res)
145152
return res
146153

147154
def _getvaluepath(self, key: str) -> Path:
@@ -178,19 +185,13 @@ def set(self, key: str, value: object) -> None:
178185
"""
179186
path = self._getvaluepath(key)
180187
try:
181-
if path.parent.is_dir():
182-
cache_dir_exists_already = True
183-
else:
184-
cache_dir_exists_already = self._cachedir.exists()
185-
path.parent.mkdir(exist_ok=True, parents=True)
188+
self._mkdir(path.parent)
186189
except OSError as exc:
187190
self.warn(
188191
f"could not create cache path {path}: {exc}",
189192
_ispytest=True,
190193
)
191194
return
192-
if not cache_dir_exists_already:
193-
self._ensure_supporting_files()
194195
data = json.dumps(value, ensure_ascii=False, indent=2)
195196
try:
196197
f = path.open("w", encoding="UTF-8")
@@ -203,17 +204,29 @@ def set(self, key: str, value: object) -> None:
203204
with f:
204205
f.write(data)
205206

206-
def _ensure_supporting_files(self) -> None:
207-
"""Create supporting files in the cache dir that are not really part of the cache."""
208-
readme_path = self._cachedir / "README.md"
209-
readme_path.write_text(README_CONTENT, encoding="UTF-8")
207+
def _ensure_cache_dir_and_supporting_files(self) -> None:
208+
"""Create the cache dir and its supporting files."""
209+
if self._cachedir.is_dir():
210+
return
210211

211-
gitignore_path = self._cachedir.joinpath(".gitignore")
212-
msg = "# Created by pytest automatically.\n*\n"
213-
gitignore_path.write_text(msg, encoding="UTF-8")
212+
files: Iterable[Tuple[str, Union[str, bytes]]] = (
213+
("README.md", README_CONTENT),
214+
(".gitignore", "# Created by pytest automatically.\n*\n"),
215+
("CACHEDIR.TAG", CACHEDIR_TAG_CONTENT),
216+
)
214217

215-
cachedir_tag_path = self._cachedir.joinpath("CACHEDIR.TAG")
216-
cachedir_tag_path.write_bytes(CACHEDIR_TAG_CONTENT)
218+
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as d:
219+
for file, content in files:
220+
file = os.path.join(d, file)
221+
if isinstance(content, str):
222+
with open(file, "xt", encoding="UTF-8") as f:
223+
f.write(content)
224+
elif isinstance(content, bytes):
225+
with open(file, "xb") as f:
226+
f.write(content)
227+
else:
228+
assert_never(content)
229+
os.renames(d, self._cachedir)
217230

218231

219232
class LFPluginCollWrapper:

0 commit comments

Comments
 (0)