_cache.py 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. """Cache Protocol and built-in implementations for Python interpreter discovery."""
  2. from __future__ import annotations
  3. import json
  4. import logging
  5. from contextlib import contextmanager, suppress
  6. from hashlib import sha256
  7. from typing import TYPE_CHECKING, Final, Protocol, runtime_checkable
  8. if TYPE_CHECKING:
  9. from collections.abc import Generator
  10. from pathlib import Path
  11. _LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
  12. @runtime_checkable
  13. class ContentStore(Protocol):
  14. """A store for reading and writing cached content."""
  15. def exists(self) -> bool:
  16. """Return whether the cached content exists."""
  17. ...
  18. def read(self) -> dict | None:
  19. """Read the cached content, or ``None`` if unavailable or corrupt."""
  20. ...
  21. def write(self, content: dict) -> None:
  22. """
  23. Persist *content* to the store.
  24. :param content: interpreter metadata to cache.
  25. """
  26. ...
  27. def remove(self) -> None:
  28. """Delete the cached content."""
  29. ...
  30. @contextmanager
  31. def locked(self) -> Generator[None]:
  32. """Context manager that acquires an exclusive lock on this store."""
  33. ...
  34. @runtime_checkable
  35. class PyInfoCache(Protocol):
  36. """Cache interface for Python interpreter information."""
  37. def py_info(self, path: Path) -> ContentStore:
  38. """
  39. Return the content store for the interpreter at *path*.
  40. :param path: absolute path to a Python executable.
  41. """
  42. ...
  43. def py_info_clear(self) -> None:
  44. """Remove all cached interpreter information."""
  45. ...
  46. class DiskContentStore:
  47. """JSON file-based content store with file locking."""
  48. def __init__(self, folder: Path, key: str) -> None:
  49. self._folder = folder
  50. self._key = key
  51. @property
  52. def _file(self) -> Path:
  53. return self._folder / f"{self._key}.json"
  54. def exists(self) -> bool:
  55. return self._file.exists()
  56. def read(self) -> dict | None:
  57. data, bad_format = None, False
  58. try:
  59. data = json.loads(self._file.read_text(encoding="utf-8"))
  60. except ValueError:
  61. bad_format = True
  62. except OSError:
  63. _LOGGER.debug("failed to read %s", self._file, exc_info=True)
  64. else:
  65. _LOGGER.debug("got python info from %s", self._file)
  66. return data
  67. if bad_format:
  68. with suppress(OSError):
  69. self.remove()
  70. return None
  71. def write(self, content: dict) -> None:
  72. self._folder.mkdir(parents=True, exist_ok=True)
  73. self._file.write_text(json.dumps(content, sort_keys=True, indent=2), encoding="utf-8")
  74. _LOGGER.debug("wrote python info at %s", self._file)
  75. def remove(self) -> None:
  76. with suppress(OSError):
  77. self._file.unlink()
  78. _LOGGER.debug("removed python info at %s", self._file)
  79. @contextmanager
  80. def locked(self) -> Generator[None]:
  81. from filelock import FileLock # noqa: PLC0415
  82. lock_path = self._folder / f"{self._key}.lock"
  83. lock_path.parent.mkdir(parents=True, exist_ok=True)
  84. with FileLock(str(lock_path)):
  85. yield
  86. class DiskCache:
  87. """
  88. File-system based Python interpreter info cache (``<root>/py_info/4/<sha256>.json``).
  89. :param root: root directory for the on-disk cache.
  90. """
  91. def __init__(self, root: Path) -> None:
  92. self._root = root
  93. @property
  94. def _py_info_dir(self) -> Path:
  95. return self._root / "py_info" / "4"
  96. def py_info(self, path: Path) -> DiskContentStore:
  97. """
  98. Return the content store for the interpreter at *path*.
  99. :param path: absolute path to a Python executable.
  100. """
  101. key = sha256(str(path).encode("utf-8")).hexdigest()
  102. return DiskContentStore(self._py_info_dir, key)
  103. def py_info_clear(self) -> None:
  104. """Remove all cached interpreter information."""
  105. folder = self._py_info_dir
  106. if folder.exists():
  107. for entry in folder.iterdir():
  108. if entry.suffix == ".json":
  109. with suppress(OSError):
  110. entry.unlink()
  111. class NoOpContentStore(ContentStore):
  112. """Content store that does nothing -- implements ContentStore protocol."""
  113. def exists(self) -> bool: # noqa: PLR6301
  114. return False
  115. def read(self) -> dict | None: # noqa: PLR6301
  116. return None
  117. def write(self, content: dict) -> None:
  118. pass
  119. def remove(self) -> None:
  120. pass
  121. @contextmanager
  122. def locked(self) -> Generator[None]: # noqa: PLR6301
  123. yield
  124. class NoOpCache(PyInfoCache):
  125. """Cache that does nothing -- implements PyInfoCache protocol."""
  126. def py_info(self, path: Path) -> NoOpContentStore: # noqa: ARG002, PLR6301
  127. return NoOpContentStore()
  128. def py_info_clear(self) -> None:
  129. pass
  130. __all__ = [
  131. "ContentStore",
  132. "DiskCache",
  133. "DiskContentStore",
  134. "NoOpCache",
  135. "NoOpContentStore",
  136. "PyInfoCache",
  137. ]