Skip to content

Commit 62f895d

Browse files
committed
Ensure proper cache initialization before writing
Writing cache data is interruptible; this prevents 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. Use json.dump instead of json.dumps + write for a tiny perf win. Fixes #12167.
1 parent 12e061e commit 62f895d

File tree

2 files changed

+39
-29
lines changed

2 files changed

+39
-29
lines changed

src/_pytest/cacheprovider.py

+26-20
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from .reports import CollectReport
2222
from _pytest import nodes
2323
from _pytest._io import TerminalWriter
24+
from _pytest.compat import assert_never
2425
from _pytest.config import Config
2526
from _pytest.config import ExitCode
2627
from _pytest.config import hookimpl
@@ -123,6 +124,10 @@ def warn(self, fmt: str, *, _ispytest: bool = False, **args: object) -> None:
123124
stacklevel=3,
124125
)
125126

127+
def _mkdir(self, path: Path) -> None:
128+
self._ensure_cache_dir_and_supporting_files()
129+
path.mkdir(exist_ok=True, parents=True)
130+
126131
def mkdir(self, name: str) -> Path:
127132
"""Return a directory path object with the given name.
128133
@@ -141,7 +146,7 @@ def mkdir(self, name: str) -> Path:
141146
if len(path.parts) > 1:
142147
raise ValueError("name is not allowed to contain path separators")
143148
res = self._cachedir.joinpath(self._CACHE_PREFIX_DIRS, path)
144-
res.mkdir(exist_ok=True, parents=True)
149+
self._mkdir(res)
145150
return res
146151

147152
def _getvaluepath(self, key: str) -> Path:
@@ -178,20 +183,13 @@ def set(self, key: str, value: object) -> None:
178183
"""
179184
path = self._getvaluepath(key)
180185
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)
186+
self._mkdir(path.parent)
186187
except OSError as exc:
187188
self.warn(
188189
f"could not create cache path {path}: {exc}",
189190
_ispytest=True,
190191
)
191192
return
192-
if not cache_dir_exists_already:
193-
self._ensure_supporting_files()
194-
data = json.dumps(value, ensure_ascii=False, indent=2)
195193
try:
196194
f = path.open("w", encoding="UTF-8")
197195
except OSError as exc:
@@ -201,19 +199,27 @@ def set(self, key: str, value: object) -> None:
201199
)
202200
else:
203201
with f:
204-
f.write(data)
205-
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")
202+
json.dump(value, f, ensure_ascii=False, indent=2)
210203

211-
gitignore_path = self._cachedir.joinpath(".gitignore")
212-
msg = "# Created by pytest automatically.\n*\n"
213-
gitignore_path.write_text(msg, encoding="UTF-8")
204+
def _ensure_cache_dir_and_supporting_files(self) -> None:
205+
"""Create the cache dir and its supporting files."""
206+
self._cachedir.mkdir(exist_ok=True, parents=True)
214207

215-
cachedir_tag_path = self._cachedir.joinpath("CACHEDIR.TAG")
216-
cachedir_tag_path.write_bytes(CACHEDIR_TAG_CONTENT)
208+
files: Iterable[tuple[str, str | bytes]] = (
209+
("README.md", README_CONTENT),
210+
(".gitignore", "# Created by pytest automatically.\n*\n"),
211+
("CACHEDIR.TAG", CACHEDIR_TAG_CONTENT),
212+
)
213+
for file, content in files:
214+
path = self._cachedir.joinpath(file)
215+
if path.exists():
216+
continue
217+
if isinstance(content, str):
218+
path.write_text(content, encoding="UTF-8")
219+
elif isinstance(content, bytes):
220+
path.write_bytes(content)
221+
else:
222+
assert_never(content)
217223

218224

219225
class LFPluginCollWrapper:

testing/test_cacheprovider.py

+13-9
Original file line numberDiff line numberDiff line change
@@ -1263,12 +1263,7 @@ def test_gitignore(pytester: Pytester) -> None:
12631263
cache.set("foo", "bar")
12641264
msg = "# Created by pytest automatically.\n*\n"
12651265
gitignore_path = cache._cachedir.joinpath(".gitignore")
1266-
assert gitignore_path.read_text(encoding="UTF-8") == msg
1267-
1268-
# Does not overwrite existing/custom one.
1269-
gitignore_path.write_text("custom", encoding="utf-8")
1270-
cache.set("something", "else")
1271-
assert gitignore_path.read_text(encoding="UTF-8") == "custom"
1266+
assert gitignore_path.read_text(encoding="utf-8") == msg
12721267

12731268

12741269
def test_preserve_keys_order(pytester: Pytester) -> None:
@@ -1282,9 +1277,15 @@ def test_preserve_keys_order(pytester: Pytester) -> None:
12821277
assert list(read_back.items()) == [("z", 1), ("b", 2), ("a", 3), ("d", 10)]
12831278

12841279

1285-
def test_does_not_create_boilerplate_in_existing_dirs(pytester: Pytester) -> None:
1280+
def test_does_not_overwrite_with_boilerplate(pytester: Pytester) -> None:
12861281
from _pytest.cacheprovider import Cache
12871282

1283+
files = ["README.md", ".gitignore"]
1284+
1285+
for filename in files:
1286+
with open(filename, "w", encoding="utf-8") as f:
1287+
f.write(filename)
1288+
12881289
pytester.makeini(
12891290
"""
12901291
[pytest]
@@ -1296,8 +1297,11 @@ def test_does_not_create_boilerplate_in_existing_dirs(pytester: Pytester) -> Non
12961297
cache.set("foo", "bar")
12971298

12981299
assert os.path.isdir("v") # cache contents
1299-
assert not os.path.exists(".gitignore")
1300-
assert not os.path.exists("README.md")
1300+
1301+
# unchanged
1302+
for filename in files:
1303+
with open(filename, encoding="utf-8") as f:
1304+
assert f.read() == filename
13011305

13021306

13031307
def test_cachedir_tag(pytester: Pytester) -> None:

0 commit comments

Comments
 (0)