Skip to content

Commit 78ebc61

Browse files
authored
feat: Abstract out caching in discovery (#2946)
1 parent fa67f8d commit 78ebc61

File tree

7 files changed

+244
-68
lines changed

7 files changed

+244
-68
lines changed

docs/changelog/2074.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Abstract out caching in discovery - by :user:`esafak`.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ dependencies = [
4747
"filelock>=3.12.2,<4",
4848
"importlib-metadata>=6.6; python_version<'3.8'",
4949
"platformdirs>=3.9.1,<5",
50+
"typing-extensions>=4.13.2; python_version<'3.11'",
5051
]
5152
optional-dependencies.docs = [
5253
"furo>=2023.7.26",

src/virtualenv/discovery/cache.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from __future__ import annotations
2+
3+
from abc import ABC, abstractmethod
4+
from typing import Any
5+
6+
try:
7+
from typing import Self # pragma: ≥ 3.11 cover
8+
except ImportError:
9+
from typing_extensions import Self # pragma: < 3.11 cover
10+
11+
12+
class Cache(ABC):
13+
"""
14+
A generic cache interface.
15+
16+
Add a close() method if the cache needs to perform any cleanup actions,
17+
and an __exit__ method to allow it to be used in a context manager.
18+
"""
19+
20+
@abstractmethod
21+
def get(self, key: str) -> Any | None:
22+
"""
23+
Get a value from the cache.
24+
25+
:param key: the key to retrieve
26+
:return: the cached value, or None if not found
27+
"""
28+
raise NotImplementedError
29+
30+
@abstractmethod
31+
def set(self, key: str, value: Any) -> None:
32+
"""
33+
Set a value in the cache.
34+
35+
:param key: the key to set
36+
:param value: the value to cache
37+
"""
38+
raise NotImplementedError
39+
40+
@abstractmethod
41+
def remove(self, key: str) -> None:
42+
"""
43+
Remove a value from the cache.
44+
45+
:param key: the key to remove
46+
"""
47+
raise NotImplementedError
48+
49+
@abstractmethod
50+
def clear(self) -> None:
51+
"""Clear the entire cache."""
52+
raise NotImplementedError
53+
54+
def __enter__(self) -> Self:
55+
return self
56+
57+
58+
__all__ = [
59+
"Cache",
60+
]

src/virtualenv/discovery/cached_py_info.py

Lines changed: 45 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
from __future__ import annotations
99

10-
import hashlib
1110
import logging
1211
import os
1312
import random
@@ -17,8 +16,14 @@
1716
from shlex import quote
1817
from string import ascii_lowercase, ascii_uppercase, digits
1918
from subprocess import Popen
19+
from typing import TYPE_CHECKING
2020

21-
from virtualenv.app_data import AppDataDisabled
21+
from virtualenv.app_data.na import AppDataDisabled
22+
from virtualenv.discovery.file_cache import FileCache
23+
24+
if TYPE_CHECKING:
25+
from virtualenv.app_data.base import AppData
26+
from virtualenv.discovery.cache import Cache
2227
from virtualenv.discovery.py_info import PythonInfo
2328
from virtualenv.util.subprocess import subprocess
2429

@@ -27,9 +32,20 @@
2732
LOGGER = logging.getLogger(__name__)
2833

2934

30-
def from_exe(cls, app_data, exe, env=None, raise_on_error=True, ignore_cache=False): # noqa: FBT002, PLR0913
35+
def from_exe( # noqa: PLR0913
36+
cls,
37+
app_data,
38+
exe,
39+
env=None,
40+
*,
41+
raise_on_error=True,
42+
ignore_cache=False,
43+
cache: Cache | None = None,
44+
) -> PythonInfo | None:
3145
env = os.environ if env is None else env
32-
result = _get_from_cache(cls, app_data, exe, env, ignore_cache=ignore_cache)
46+
if cache is None:
47+
cache = FileCache(app_data)
48+
result = _get_from_cache(cls, app_data, exe, env, cache, ignore_cache=ignore_cache)
3349
if isinstance(result, Exception):
3450
if raise_on_error:
3551
raise result
@@ -38,63 +54,35 @@ def from_exe(cls, app_data, exe, env=None, raise_on_error=True, ignore_cache=Fal
3854
return result
3955

4056

41-
def _get_from_cache(cls, app_data, exe, env, ignore_cache=True): # noqa: FBT002
57+
def _get_from_cache(cls, app_data: AppData, exe: str, env, cache: Cache, *, ignore_cache: bool) -> PythonInfo: # noqa: PLR0913
4258
# note here we cannot resolve symlinks, as the symlink may trigger different prefix information if there's a
4359
# pyenv.cfg somewhere alongside on python3.5+
4460
exe_path = Path(exe)
4561
if not ignore_cache and exe_path in _CACHE: # check in the in-memory cache
4662
result = _CACHE[exe_path]
4763
else: # otherwise go through the app data cache
48-
py_info = _get_via_file_cache(cls, app_data, exe_path, exe, env)
49-
result = _CACHE[exe_path] = py_info
64+
result = _CACHE[exe_path] = _get_via_file_cache(cls, app_data, exe_path, exe, env, cache)
5065
# independent if it was from the file or in-memory cache fix the original executable location
5166
if isinstance(result, PythonInfo):
5267
result.executable = exe
5368
return result
5469

5570

56-
def _get_via_file_cache(cls, app_data, path, exe, env):
57-
path_text = str(path)
58-
try:
59-
path_modified = path.stat().st_mtime
60-
except OSError:
61-
path_modified = -1
62-
py_info_script = Path(os.path.abspath(__file__)).parent / "py_info.py"
63-
try:
64-
py_info_hash = hashlib.sha256(py_info_script.read_bytes()).hexdigest()
65-
except OSError:
66-
py_info_hash = None
67-
68-
if app_data is None:
69-
app_data = AppDataDisabled()
70-
py_info, py_info_store = None, app_data.py_info(path)
71-
with py_info_store.locked():
72-
if py_info_store.exists(): # if exists and matches load
73-
data = py_info_store.read()
74-
of_path = data.get("path")
75-
of_st_mtime = data.get("st_mtime")
76-
of_content = data.get("content")
77-
of_hash = data.get("hash")
78-
if of_path == path_text and of_st_mtime == path_modified and of_hash == py_info_hash:
79-
py_info = cls._from_dict(of_content.copy())
80-
sys_exe = py_info.system_executable
81-
if sys_exe is not None and not os.path.exists(sys_exe):
82-
py_info_store.remove()
83-
py_info = None
84-
else:
85-
py_info_store.remove()
86-
if py_info is None: # if not loaded run and save
87-
failure, py_info = _run_subprocess(cls, exe, app_data, env)
88-
if failure is None:
89-
data = {
90-
"st_mtime": path_modified,
91-
"path": path_text,
92-
"content": py_info._to_dict(), # noqa: SLF001
93-
"hash": py_info_hash,
94-
}
95-
py_info_store.write(data)
96-
else:
97-
py_info = failure
71+
def _get_via_file_cache(cls, app_data: AppData, path: Path, exe: str, env, cache: Cache) -> PythonInfo: # noqa: PLR0913
72+
py_info = cache.get(path)
73+
if py_info is not None:
74+
py_info = cls._from_dict(py_info)
75+
sys_exe = py_info.system_executable
76+
if sys_exe is not None and not os.path.exists(sys_exe):
77+
cache.remove(path)
78+
py_info = None
79+
80+
if py_info is None: # if not loaded run and save
81+
failure, py_info = _run_subprocess(cls, exe, app_data, env)
82+
if failure is None:
83+
cache.set(path, py_info._to_dict()) # noqa: SLF001
84+
else:
85+
py_info = failure
9886
return py_info
9987

10088

@@ -120,6 +108,8 @@ def _run_subprocess(cls, exe, app_data, env):
120108

121109
start_cookie = gen_cookie()
122110
end_cookie = gen_cookie()
111+
if app_data is None:
112+
app_data = AppDataDisabled()
123113
with app_data.ensure_extracted(py_info_script) as py_info_script:
124114
cmd = [exe, str(py_info_script), start_cookie, end_cookie]
125115
# prevent sys.prefix from leaking into the child process - see https://bugs.python.org/issue22490
@@ -182,8 +172,12 @@ def __repr__(self) -> str:
182172
return cmd_repr
183173

184174

185-
def clear(app_data):
186-
app_data.py_info_clear()
175+
def clear(app_data=None, cache=None):
176+
"""Clear the cache."""
177+
if cache is None and app_data is not None:
178+
cache = FileCache(app_data)
179+
if cache is not None:
180+
cache.clear()
187181
_CACHE.clear()
188182

189183

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from __future__ import annotations
2+
3+
import hashlib
4+
import logging
5+
from pathlib import Path
6+
from typing import TYPE_CHECKING
7+
8+
from virtualenv.app_data.na import AppDataDisabled
9+
from virtualenv.discovery.cache import Cache
10+
11+
if TYPE_CHECKING:
12+
from virtualenv.app_data.base import AppData
13+
14+
LOGGER = logging.getLogger(__name__)
15+
16+
17+
class FileCache(Cache):
18+
def __init__(self, app_data: AppData) -> None:
19+
self.app_data = app_data if app_data is not None else AppDataDisabled()
20+
21+
def get(self, key: Path):
22+
"""Get a value from the file cache."""
23+
py_info, py_info_store = None, self.app_data.py_info(key)
24+
with py_info_store.locked():
25+
if py_info_store.exists():
26+
py_info = self._read_from_store(py_info_store, key)
27+
return py_info
28+
29+
def set(self, key: Path, value: dict) -> None:
30+
"""Set a value in the file cache."""
31+
py_info_store = self.app_data.py_info(key)
32+
with py_info_store.locked():
33+
path_text = str(key)
34+
try:
35+
path_modified = key.stat().st_mtime
36+
except OSError:
37+
path_modified = -1
38+
39+
py_info_script = Path(__file__).parent / "py_info.py"
40+
try:
41+
py_info_hash = hashlib.sha256(py_info_script.read_bytes()).hexdigest()
42+
except OSError:
43+
py_info_hash = None
44+
45+
data = {
46+
"st_mtime": path_modified,
47+
"path": path_text,
48+
"content": value,
49+
"hash": py_info_hash,
50+
}
51+
py_info_store.write(data)
52+
53+
def remove(self, key: Path) -> None:
54+
"""Remove a value from the file cache."""
55+
py_info_store = self.app_data.py_info(key)
56+
with py_info_store.locked():
57+
if py_info_store.exists():
58+
py_info_store.remove()
59+
60+
def clear(self) -> None:
61+
"""Clear the entire file cache."""
62+
self.app_data.py_info_clear()
63+
64+
def _read_from_store(self, py_info_store, path: Path):
65+
data = py_info_store.read()
66+
path_text = str(path)
67+
try:
68+
path_modified = path.stat().st_mtime
69+
except OSError:
70+
path_modified = -1
71+
72+
py_info_script = Path(__file__).parent / "py_info.py"
73+
try:
74+
py_info_hash = hashlib.sha256(py_info_script.read_bytes()).hexdigest()
75+
except OSError:
76+
py_info_hash = None
77+
78+
of_path = data.get("path")
79+
of_st_mtime = data.get("st_mtime")
80+
of_content = data.get("content")
81+
of_hash = data.get("hash")
82+
83+
if of_path == path_text and of_st_mtime == path_modified and of_hash == py_info_hash:
84+
return of_content
85+
86+
py_info_store.remove()
87+
return None
88+
89+
90+
__all__ = [
91+
"FileCache",
92+
]

0 commit comments

Comments
 (0)