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.
This commit is contained in:
Tamir Duberstein 2024-03-29 15:51:51 +00:00
parent 381593ccf0
commit 2e65f4e3ac
No known key found for this signature in database
3 changed files with 34 additions and 19 deletions

View File

@ -0,0 +1 @@
cache: create cache directory supporting files (``CACHEDIR.TAG``, ``.gitignore``, etc.) in a temporary directory to provide atomic semantics.

View File

@ -7,6 +7,7 @@ import dataclasses
import json import json
import os import os
from pathlib import Path from pathlib import Path
import tempfile
from typing import Dict from typing import Dict
from typing import final from typing import final
from typing import Generator from typing import Generator
@ -123,6 +124,10 @@ class Cache:
stacklevel=3, stacklevel=3,
) )
def _mkdir(self, path: Path) -> None:
self._ensure_cache_dir_and_supporting_files()
path.mkdir(exist_ok=True, parents=True)
def mkdir(self, name: str) -> Path: def mkdir(self, name: str) -> Path:
"""Return a directory path object with the given name. """Return a directory path object with the given name.
@ -141,7 +146,7 @@ class Cache:
if len(path.parts) > 1: if len(path.parts) > 1:
raise ValueError("name is not allowed to contain path separators") raise ValueError("name is not allowed to contain path separators")
res = self._cachedir.joinpath(self._CACHE_PREFIX_DIRS, path) res = self._cachedir.joinpath(self._CACHE_PREFIX_DIRS, path)
res.mkdir(exist_ok=True, parents=True) self._mkdir(res)
return res return res
def _getvaluepath(self, key: str) -> Path: def _getvaluepath(self, key: str) -> Path:
@ -178,19 +183,13 @@ class Cache:
""" """
path = self._getvaluepath(key) path = self._getvaluepath(key)
try: try:
if path.parent.is_dir(): self._mkdir(path.parent)
cache_dir_exists_already = True
else:
cache_dir_exists_already = self._cachedir.exists()
path.parent.mkdir(exist_ok=True, parents=True)
except OSError as exc: except OSError as exc:
self.warn( self.warn(
f"could not create cache path {path}: {exc}", f"could not create cache path {path}: {exc}",
_ispytest=True, _ispytest=True,
) )
return return
if not cache_dir_exists_already:
self._ensure_supporting_files()
data = json.dumps(value, ensure_ascii=False, indent=2) data = json.dumps(value, ensure_ascii=False, indent=2)
try: try:
f = path.open("w", encoding="UTF-8") f = path.open("w", encoding="UTF-8")
@ -203,17 +202,32 @@ class Cache:
with f: with f:
f.write(data) f.write(data)
def _ensure_supporting_files(self) -> None: def _ensure_cache_dir_and_supporting_files(self) -> None:
"""Create supporting files in the cache dir that are not really part of the cache.""" """Create the cache dir and its supporting files."""
readme_path = self._cachedir / "README.md" if self._cachedir.is_dir():
readme_path.write_text(README_CONTENT, encoding="UTF-8") return
gitignore_path = self._cachedir.joinpath(".gitignore") self._cachedir.parent.mkdir(parents=True, exist_ok=True)
msg = "# Created by pytest automatically.\n*\n" with tempfile.TemporaryDirectory(
gitignore_path.write_text(msg, encoding="UTF-8") prefix="pytest-cache-files-",
dir=self._cachedir.parent,
) as newpath:
path = Path(newpath)
with open(path.joinpath("README.md"), "xt", encoding="UTF-8") as f:
f.write(README_CONTENT)
with open(path.joinpath(".gitignore"), "xt", encoding="UTF-8") as f:
f.write("# Created by pytest automatically.\n*\n")
with open(path.joinpath("CACHEDIR.TAG"), "xb") as f:
f.write(CACHEDIR_TAG_CONTENT)
cachedir_tag_path = self._cachedir.joinpath("CACHEDIR.TAG") path.rename(self._cachedir)
cachedir_tag_path.write_bytes(CACHEDIR_TAG_CONTENT) # Create a directory in place of the one we just moved so that `TemporaryDirectory`'s
# cleanup doesn't complain.
#
# TODO: pass ignore_cleanup_errors=True when we no longer support python < 3.10. See
# https://github.com/python/cpython/issues/74168. Note that passing delete=False would
# do the wrong thing in case of errors and isn't supported until python 3.12.
path.mkdir()
class LFPluginCollWrapper: class LFPluginCollWrapper:

View File

@ -1731,8 +1731,8 @@ class TestEarlyRewriteBailout:
import os import os
import tempfile import tempfile
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as newpath:
os.chdir(d) os.chdir(newpath)
""", """,
"test_test.py": """\ "test_test.py": """\
def test(): def test():