Skip to content

Commit 1d7548d

Browse files
authored
Merge pull request #2956 from esafak/refactor/2074-decouple-discovery
1 parent ec92464 commit 1d7548d

File tree

26 files changed

+625
-399
lines changed

26 files changed

+625
-399
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+
Add AppData and Cache protocols to discovery for decoupling - by :user:`esafak`.

src/virtualenv/create/via_global_ref/venv.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class Venv(ViaGlobalRefApi):
2020
def __init__(self, options, interpreter) -> None:
2121
self.describe = options.describe
2222
super().__init__(options, interpreter)
23-
current = PythonInfo.current()
23+
current = PythonInfo.current(options.app_data, options.cache)
2424
self.can_be_inline = interpreter is current and interpreter.executable == interpreter.system_executable
2525
self._context = None
2626

src/virtualenv/discovery/app_data.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from __future__ import annotations
2+
3+
from contextlib import contextmanager
4+
from typing import TYPE_CHECKING, Any, ContextManager, Protocol
5+
6+
if TYPE_CHECKING:
7+
from pathlib import Path
8+
9+
10+
class AppData(Protocol):
11+
"""Protocol for application data store."""
12+
13+
def py_info(self, path: Path) -> Any: ...
14+
15+
def py_info_clear(self) -> None: ...
16+
17+
@contextmanager
18+
def ensure_extracted(self, path: Path, to_folder: Path | None = None) -> ContextManager[Path]: ...
19+
20+
@contextmanager
21+
def extract(self, path: Path, to_folder: Path | None = None) -> ContextManager[Path]: ...
22+
23+
def close(self) -> None: ...

src/virtualenv/discovery/builtin.py

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from argparse import ArgumentParser
1919
from collections.abc import Callable, Generator, Iterable, Mapping, Sequence
2020

21-
from virtualenv.app_data.base import AppData
21+
from .app_data import AppData
2222
LOGGER = logging.getLogger(__name__)
2323

2424

@@ -27,8 +27,8 @@ class Builtin(Discover):
2727
app_data: AppData
2828
try_first_with: Sequence[str]
2929

30-
def __init__(self, options) -> None:
31-
super().__init__(options)
30+
def __init__(self, options, cache=None) -> None:
31+
super().__init__(options, cache)
3232
self.python_spec = options.python or [sys.executable]
3333
if self._env.get("VIRTUALENV_PYTHON"):
3434
self.python_spec = self.python_spec[1:] + self.python_spec[:1] # Rotate the list
@@ -60,7 +60,7 @@ def add_parser_arguments(cls, parser: ArgumentParser) -> None:
6060

6161
def run(self) -> PythonInfo | None:
6262
for python_spec in self.python_spec:
63-
result = get_interpreter(python_spec, self.try_first_with, self.app_data, self._env)
63+
result = get_interpreter(python_spec, self.try_first_with, self.app_data, self.cache, self._env)
6464
if result is not None:
6565
return result
6666
return None
@@ -71,13 +71,17 @@ def __repr__(self) -> str:
7171

7272

7373
def get_interpreter(
74-
key, try_first_with: Iterable[str], app_data: AppData | None = None, env: Mapping[str, str] | None = None
74+
key,
75+
try_first_with: Iterable[str],
76+
app_data: AppData | None = None,
77+
cache=None,
78+
env: Mapping[str, str] | None = None,
7579
) -> PythonInfo | None:
7680
spec = PythonSpec.from_string_spec(key)
7781
LOGGER.info("find interpreter for spec %r", spec)
7882
proposed_paths = set()
7983
env = os.environ if env is None else env
80-
for interpreter, impl_must_match in propose_interpreters(spec, try_first_with, app_data, env):
84+
for interpreter, impl_must_match in propose_interpreters(spec, try_first_with, app_data, cache, env):
8185
key = interpreter.system_executable, impl_must_match
8286
if key in proposed_paths:
8387
continue
@@ -93,6 +97,7 @@ def propose_interpreters( # noqa: C901, PLR0912, PLR0915
9397
spec: PythonSpec,
9498
try_first_with: Iterable[str],
9599
app_data: AppData | None = None,
100+
cache=None,
96101
env: Mapping[str, str] | None = None,
97102
) -> Generator[tuple[PythonInfo, bool], None, None]:
98103
# 0. if it's a path and exists, and is absolute path, this is the only option we consider
@@ -108,7 +113,7 @@ def propose_interpreters( # noqa: C901, PLR0912, PLR0915
108113
exe_id = fs_path_id(exe_raw)
109114
if exe_id not in tested_exes:
110115
tested_exes.add(exe_id)
111-
yield PythonInfo.from_exe(exe_raw, app_data, env=env), True
116+
yield PythonInfo.from_exe(exe_raw, app_data, cache, env=env), True
112117
return
113118

114119
# 1. try with first
@@ -124,7 +129,7 @@ def propose_interpreters( # noqa: C901, PLR0912, PLR0915
124129
if exe_id in tested_exes:
125130
continue
126131
tested_exes.add(exe_id)
127-
yield PythonInfo.from_exe(exe_raw, app_data, env=env), True
132+
yield PythonInfo.from_exe(exe_raw, app_data, cache, env=env), True
128133

129134
# 1. if it's a path and exists
130135
if spec.path is not None:
@@ -137,12 +142,12 @@ def propose_interpreters( # noqa: C901, PLR0912, PLR0915
137142
exe_id = fs_path_id(exe_raw)
138143
if exe_id not in tested_exes:
139144
tested_exes.add(exe_id)
140-
yield PythonInfo.from_exe(exe_raw, app_data, env=env), True
145+
yield PythonInfo.from_exe(exe_raw, app_data, cache, env=env), True
141146
if spec.is_abs:
142147
return
143148
else:
144149
# 2. otherwise try with the current
145-
current_python = PythonInfo.current_system(app_data)
150+
current_python = PythonInfo.current_system(app_data, cache)
146151
exe_raw = str(current_python.executable)
147152
exe_id = fs_path_id(exe_raw)
148153
if exe_id not in tested_exes:
@@ -153,7 +158,7 @@ def propose_interpreters( # noqa: C901, PLR0912, PLR0915
153158
if IS_WIN:
154159
from .windows import propose_interpreters # noqa: PLC0415
155160

156-
for interpreter in propose_interpreters(spec, app_data, env):
161+
for interpreter in propose_interpreters(spec, app_data, cache, env):
157162
exe_raw = str(interpreter.executable)
158163
exe_id = fs_path_id(exe_raw)
159164
if exe_id in tested_exes:
@@ -171,7 +176,7 @@ def propose_interpreters( # noqa: C901, PLR0912, PLR0915
171176
if exe_id in tested_exes:
172177
continue
173178
tested_exes.add(exe_id)
174-
interpreter = PathPythonInfo.from_exe(exe_raw, app_data, raise_on_error=False, env=env)
179+
interpreter = PathPythonInfo.from_exe(exe_raw, app_data, cache, raise_on_error=False, env=env)
175180
if interpreter is not None:
176181
yield interpreter, impl_must_match
177182

@@ -184,7 +189,7 @@ def propose_interpreters( # noqa: C901, PLR0912, PLR0915
184189
uv_python_path = user_data_path("uv") / "python"
185190

186191
for exe_path in uv_python_path.glob("*/bin/python"):
187-
interpreter = PathPythonInfo.from_exe(str(exe_path), app_data, raise_on_error=False, env=env)
192+
interpreter = PathPythonInfo.from_exe(str(exe_path), app_data, cache, raise_on_error=False, env=env)
188193
if interpreter is not None:
189194
yield interpreter, True
190195

src/virtualenv/discovery/cache.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING, Any, Protocol
4+
5+
if TYPE_CHECKING:
6+
from pathlib import Path
7+
8+
9+
class Cache(Protocol):
10+
"""A protocol for a cache."""
11+
12+
def get(self, path: Path) -> Any: ...
13+
14+
def set(self, path: Path, data: Any) -> None: ...
15+
16+
def remove(self, path: Path) -> None: ...
17+
18+
def clear(self) -> None: ...

src/virtualenv/discovery/cached_py_info.py

Lines changed: 18 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,11 @@
2020
from string import ascii_lowercase, ascii_uppercase, digits
2121
from typing import TYPE_CHECKING
2222

23-
from virtualenv.app_data.na import AppDataDisabled
24-
from virtualenv.cache import FileCache
23+
from .py_info import PythonInfo
2524

2625
if TYPE_CHECKING:
27-
from virtualenv.app_data.base import AppData
28-
from virtualenv.cache import Cache
29-
from virtualenv.discovery.py_info import PythonInfo
26+
from .app_data import AppData
27+
from .cache import Cache
3028

3129
_CACHE = OrderedDict()
3230
_CACHE[Path(sys.executable)] = PythonInfo()
@@ -35,19 +33,15 @@
3533

3634
def from_exe( # noqa: PLR0913
3735
cls,
38-
app_data,
39-
exe,
40-
env=None,
36+
app_data: AppData,
37+
exe: str,
38+
env: dict[str, str] | None = None,
4139
*,
42-
raise_on_error=True,
43-
ignore_cache=False,
44-
cache: Cache | None = None,
40+
raise_on_error: bool = True,
41+
ignore_cache: bool = False,
42+
cache: Cache,
4543
) -> PythonInfo | None:
4644
env = os.environ if env is None else env
47-
if cache is None:
48-
if app_data is None:
49-
app_data = AppDataDisabled()
50-
cache = FileCache(store_factory=app_data.py_info, clearer=app_data.py_info_clear)
5145
result = _get_from_cache(cls, app_data, exe, env, cache, ignore_cache=ignore_cache)
5246
if isinstance(result, Exception):
5347
if raise_on_error:
@@ -123,7 +117,12 @@ def gen_cookie():
123117
)
124118

125119

126-
def _run_subprocess(cls, exe, app_data, env):
120+
def _run_subprocess(
121+
cls,
122+
exe: str,
123+
app_data: AppData,
124+
env: dict[str, str],
125+
) -> tuple[Exception | None, PythonInfo | None]:
127126
py_info_script = Path(os.path.abspath(__file__)).parent / "py_info.py"
128127
# Cookies allow to split the serialized stdout output generated by the script collecting the info from the output
129128
# generated by something else. The right way to deal with it is to create an anonymous pipe and pass its descriptor
@@ -135,10 +134,8 @@ def _run_subprocess(cls, exe, app_data, env):
135134

136135
start_cookie = gen_cookie()
137136
end_cookie = gen_cookie()
138-
if app_data is None:
139-
app_data = AppDataDisabled()
140-
with app_data.ensure_extracted(py_info_script) as py_info_script:
141-
cmd = [exe, str(py_info_script), start_cookie, end_cookie]
137+
with app_data.ensure_extracted(py_info_script) as py_info_script_path:
138+
cmd = [exe, str(py_info_script_path), start_cookie, end_cookie]
142139
# prevent sys.prefix from leaking into the child process - see https://bugs.python.org/issue22490
143140
env = env.copy()
144141
env.pop("__PYVENV_LAUNCHER__", None)
@@ -199,10 +196,8 @@ def __repr__(self) -> str:
199196
return cmd_repr
200197

201198

202-
def clear(app_data=None, cache=None):
199+
def clear(cache: Cache | None = None) -> None:
203200
"""Clear the cache."""
204-
if cache is None and app_data is not None:
205-
cache = FileCache(store_factory=app_data.py_info, clearer=app_data.py_info_clear)
206201
if cache is not None:
207202
cache.clear()
208203
_CACHE.clear()

src/virtualenv/discovery/discover.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ def add_parser_arguments(cls, parser):
1515
"""
1616
raise NotImplementedError
1717

18-
def __init__(self, options) -> None:
18+
def __init__(self, options, cache=None) -> None:
1919
"""
2020
Create a new discovery mechanism.
2121
@@ -24,6 +24,7 @@ def __init__(self, options) -> None:
2424
self._has_run = False
2525
self._interpreter = None
2626
self._env = options.env
27+
self.cache = cache
2728

2829
@abstractmethod
2930
def run(self):

src/virtualenv/discovery/py_info.py

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -378,11 +378,11 @@ def spec(self):
378378
)
379379

380380
@classmethod
381-
def clear_cache(cls, app_data, cache=None):
381+
def clear_cache(cls, cache=None):
382382
# this method is not used by itself, so here and called functions can import stuff locally
383383
from virtualenv.discovery.cached_py_info import clear # noqa: PLC0415
384384

385-
clear(app_data, cache)
385+
clear(cache)
386386
cls._cache_exe_discovery.clear()
387387

388388
def satisfies(self, spec, impl_must_match): # noqa: C901, PLR0911
@@ -423,7 +423,7 @@ def satisfies(self, spec, impl_must_match): # noqa: C901, PLR0911
423423
_current = None
424424

425425
@classmethod
426-
def current(cls, app_data=None, cache=None):
426+
def current(cls, app_data, cache):
427427
"""
428428
This locates the current host interpreter information. This might be different than what we run into in case
429429
the host python has been upgraded from underneath us.
@@ -432,14 +432,14 @@ def current(cls, app_data=None, cache=None):
432432
cls._current = cls.from_exe(
433433
sys.executable,
434434
app_data,
435+
cache,
435436
raise_on_error=True,
436437
resolve_to_host=False,
437-
cache=cache,
438438
)
439439
return cls._current
440440

441441
@classmethod
442-
def current_system(cls, app_data=None, cache=None) -> PythonInfo:
442+
def current_system(cls, app_data, cache) -> PythonInfo:
443443
"""
444444
This locates the current host interpreter information. This might be different than what we run into in case
445445
the host python has been upgraded from underneath us.
@@ -448,9 +448,9 @@ def current_system(cls, app_data=None, cache=None) -> PythonInfo:
448448
cls._current_system = cls.from_exe(
449449
sys.executable,
450450
app_data,
451+
cache,
451452
raise_on_error=True,
452453
resolve_to_host=True,
453-
cache=cache,
454454
)
455455
return cls._current_system
456456

@@ -467,12 +467,12 @@ def _to_dict(self):
467467
def from_exe( # noqa: PLR0913
468468
cls,
469469
exe,
470-
app_data=None,
470+
app_data,
471+
cache,
471472
raise_on_error=True, # noqa: FBT002
472473
ignore_cache=False, # noqa: FBT002
473474
resolve_to_host=True, # noqa: FBT002
474475
env=None,
475-
cache=None,
476476
):
477477
"""Given a path to an executable get the python information."""
478478
# this method is not used by itself, so here and called functions can import stuff locally
@@ -513,7 +513,7 @@ def _from_dict(cls, data):
513513
return result
514514

515515
@classmethod
516-
def _resolve_to_system(cls, app_data, target, cache=None):
516+
def _resolve_to_system(cls, app_data, target, cache):
517517
start_executable = target.executable
518518
prefixes = OrderedDict()
519519
while target.system_executable is None:
@@ -532,13 +532,13 @@ def _resolve_to_system(cls, app_data, target, cache=None):
532532
prefixes[prefix] = target
533533
target = target.discover_exe(app_data, prefix=prefix, exact=False, cache=cache)
534534
if target.executable != target.system_executable:
535-
target = cls.from_exe(target.system_executable, app_data, cache=cache)
535+
target = cls.from_exe(target.system_executable, app_data, cache)
536536
target.executable = start_executable
537537
return target
538538

539539
_cache_exe_discovery = {} # noqa: RUF012
540540

541-
def discover_exe(self, app_data, prefix, exact=True, env=None, cache=None): # noqa: FBT002
541+
def discover_exe(self, app_data, cache, prefix, exact=True, env=None): # noqa: FBT002
542542
key = prefix, exact
543543
if key in self._cache_exe_discovery and prefix:
544544
LOGGER.debug("discover exe from cache %s - exact %s: %r", prefix, exact, self._cache_exe_discovery[key])
@@ -551,7 +551,7 @@ def discover_exe(self, app_data, prefix, exact=True, env=None, cache=None): # n
551551
env = os.environ if env is None else env
552552
for folder in possible_folders:
553553
for name in possible_names:
554-
info = self._check_exe(app_data, folder, name, exact, discovered, env, cache)
554+
info = self._check_exe(app_data, cache, folder, name, exact, discovered, env)
555555
if info is not None:
556556
self._cache_exe_discovery[key] = info
557557
return info
@@ -564,17 +564,17 @@ def discover_exe(self, app_data, prefix, exact=True, env=None, cache=None): # n
564564
msg = "failed to detect {} in {}".format("|".join(possible_names), os.pathsep.join(possible_folders))
565565
raise RuntimeError(msg)
566566

567-
def _check_exe(self, app_data, folder, name, exact, discovered, env, cache): # noqa: PLR0913
567+
def _check_exe(self, app_data, cache, folder, name, exact, discovered, env): # noqa: PLR0913
568568
exe_path = os.path.join(folder, name)
569569
if not os.path.exists(exe_path):
570570
return None
571571
info = self.from_exe(
572572
exe_path,
573573
app_data,
574+
cache,
574575
resolve_to_host=False,
575576
raise_on_error=False,
576577
env=env,
577-
cache=cache,
578578
)
579579
if info is None: # ignore if for some reason we can't query
580580
return None

0 commit comments

Comments
 (0)