-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy path_discovery.py
More file actions
425 lines (362 loc) · 16.4 KB
/
Copy path_discovery.py
File metadata and controls
425 lines (362 loc) · 16.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
from __future__ import annotations
import logging
import os
import sys
from contextlib import suppress
from pathlib import Path
from typing import TYPE_CHECKING, Final
from platformdirs import user_data_path
from ._compat import fs_path_id
from ._py_info import PythonInfo
from ._py_spec import PythonSpec
if TYPE_CHECKING:
from collections.abc import Callable, Generator, Iterable, Iterator, Mapping, Sequence
from ._cache import PyInfoCache
_LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
IS_WIN: Final[bool] = sys.platform == "win32"
def get_interpreter(
key: str | Sequence[str],
try_first_with: Iterable[str] | None = None,
cache: PyInfoCache | None = None,
env: Mapping[str, str] | None = None,
predicate: Callable[[PythonInfo], bool] | None = None,
) -> PythonInfo | None:
"""
Find a Python interpreter matching *key*.
Iterates over one or more specification strings and returns the first interpreter that satisfies the spec and passes
the optional *predicate*.
:param key: interpreter specification string(s) — an absolute path, a version (``3.12``), an implementation prefix
(``cpython3.12``), or a
`version specifier <https://packaging.python.org/en/latest/specifications/version-specifiers/>`_
(``>=3.10``). When a sequence is given each entry is tried in order.
:param try_first_with: executables to probe before the normal discovery search.
:param cache: interpreter metadata cache; when ``None`` results are not cached.
:param env: environment mapping for ``PATH`` lookup; defaults to :data:`os.environ`.
:param predicate: optional callback applied after an interpreter matches the spec. Return ``True`` to accept the
interpreter, ``False`` to skip it and continue searching.
:return: the first matching interpreter, or ``None`` if no match is found.
"""
specs = [key] if isinstance(key, str) else key
for spec_str in specs:
if result := _find_interpreter(spec_str, try_first_with or (), cache, env, predicate):
return result
return None
def iter_interpreters(
key: str | Sequence[str] | None = None,
try_first_with: Iterable[str] | None = None,
cache: PyInfoCache | None = None,
env: Mapping[str, str] | None = None,
predicate: Callable[[PythonInfo], bool] | None = None,
) -> Iterator[PythonInfo]:
"""
Yield every interpreter on the system that satisfies *key*.
Iteration order is discovery order: ``try_first_with`` paths first, then the running interpreter, then ``PATH``
(left to right), then UV-managed installs. Results are deduplicated by the resolved real path of the underlying
system interpreter, so symlinked aliases (``/bin`` vs ``/usr/bin``) and venvs that symlink to a base interpreter
collapse to a single entry. Callers that want a different ordering should sort the result.
:param key: interpreter specification — same syntax as :func:`get_interpreter`. ``None`` enumerates every Python
implementation python-discovery knows about (see :data:`KNOWN_IMPLEMENTATIONS`).
:param try_first_with: executables to probe before the normal discovery search.
:param cache: interpreter metadata cache; when ``None`` results are not cached. Strongly recommended for
enumeration, which interrogates every candidate as a subprocess on a cold cache.
:param env: environment mapping for ``PATH`` lookup; defaults to :data:`os.environ`.
:param predicate: optional filter applied after the spec match; return ``True`` to include the interpreter.
"""
if key is None:
keys: tuple[str | None, ...] = (None,)
elif isinstance(key, str):
keys = (key,)
else:
keys = tuple(key)
first_with = tuple(try_first_with or ())
env_map = os.environ if env is None else env
seen: set[str] = set()
for spec_str in keys:
yield from _iter_for_spec(spec_str, first_with, cache, env_map, predicate, seen)
def _iter_for_spec( # noqa: PLR0913, PLR0917
spec_str: str | None,
try_first_with: tuple[str, ...],
cache: PyInfoCache | None,
env: Mapping[str, str],
predicate: Callable[[PythonInfo], bool] | None,
seen: set[str],
) -> Iterator[PythonInfo]:
if spec_str is None:
spec = PythonSpec("", None, None, None, None, None, None)
wide = True
else:
spec = PythonSpec.from_string_spec(spec_str)
wide = False
for interpreter, impl_must_match in propose_interpreters(
spec, try_first_with, cache, env, all_implementations=wide
):
if interpreter is None:
continue
if (anchor := interpreter.system_executable or interpreter.executable) is None:
continue
if (real_path := os.path.realpath(anchor)) in seen:
continue
if not interpreter.satisfies(spec, impl_must_match=impl_must_match):
continue
if predicate is not None and not predicate(interpreter):
continue
seen.add(real_path)
yield interpreter
def _find_interpreter(
key: str,
try_first_with: Iterable[str],
cache: PyInfoCache | None = None,
env: Mapping[str, str] | None = None,
predicate: Callable[[PythonInfo], bool] | None = None,
) -> PythonInfo | None:
spec = PythonSpec.from_string_spec(key)
_LOGGER.info("find interpreter for spec %r", spec)
proposed_paths: set[tuple[str | None, bool]] = set()
env = os.environ if env is None else env
for interpreter, impl_must_match in propose_interpreters(spec, try_first_with, cache, env):
if interpreter is None: # pragma: no cover
continue
proposed_key = interpreter.system_executable, impl_must_match
if proposed_key in proposed_paths:
continue
_LOGGER.info("proposed %s", interpreter)
if interpreter.satisfies(spec, impl_must_match=impl_must_match) and (
predicate is None or predicate(interpreter)
):
_LOGGER.debug("accepted %s", interpreter)
return interpreter
proposed_paths.add(proposed_key)
return None
def _check_exe(path: str, tested_exes: set[str]) -> str | None:
"""Resolve *path* to an absolute path and return it if not yet tested, otherwise ``None``."""
try:
os.lstat(path)
except OSError:
return None
resolved = str(Path(path).resolve())
exe_id = fs_path_id(resolved)
if exe_id in tested_exes:
return None
tested_exes.add(exe_id)
return str(Path(path).absolute())
def _is_new_exe(exe_raw: str, tested_exes: set[str]) -> bool:
"""Return ``True`` and register *exe_raw* if it hasn't been tested yet."""
exe_id = fs_path_id(exe_raw)
if exe_id in tested_exes:
return False
tested_exes.add(exe_id)
return True
def propose_interpreters(
spec: PythonSpec,
try_first_with: Iterable[str],
cache: PyInfoCache | None = None,
env: Mapping[str, str] | None = None,
*,
all_implementations: bool = False,
) -> Generator[tuple[PythonInfo | None, bool], None, None]:
"""
Yield ``(interpreter, impl_must_match)`` candidates for *spec*.
:param spec: the parsed interpreter specification to match against.
:param try_first_with: executable paths to probe before the standard search.
:param cache: interpreter metadata cache; when ``None`` results are not cached.
:param env: environment mapping for ``PATH`` lookup; defaults to :data:`os.environ`.
:param all_implementations: when ``True`` and *spec* does not constrain the implementation, also surface
non-CPython binaries on ``PATH`` and under UV's install directory. Used by enumeration APIs.
"""
env = os.environ if env is None else env
tested_exes: set[str] = set()
if spec.is_abs and spec.path is not None:
if exe_raw := _check_exe(spec.path, tested_exes): # pragma: no branch # first exe always new
yield PythonInfo.from_exe(exe_raw, cache, env=env), True
return
yield from _propose_explicit(spec, try_first_with, cache, env, tested_exes)
if spec.path is not None and spec.is_abs: # pragma: no cover # relative spec.path is never abs
return
yield from _propose_from_path(spec, cache, env, tested_exes, all_implementations=all_implementations)
yield from _propose_from_uv(cache, env, all_implementations=all_implementations)
def _propose_explicit(
spec: PythonSpec,
try_first_with: Iterable[str],
cache: PyInfoCache | None,
env: Mapping[str, str],
tested_exes: set[str],
) -> Generator[tuple[PythonInfo | None, bool], None, None]:
for py_exe in try_first_with:
if exe_raw := _check_exe(str(Path(py_exe).resolve()), tested_exes):
yield PythonInfo.from_exe(exe_raw, cache, env=env), True
if spec.path is not None:
if exe_raw := _check_exe(spec.path, tested_exes): # pragma: no branch
yield PythonInfo.from_exe(exe_raw, cache, env=env), True
else:
yield from _propose_current_and_windows(spec, cache, env, tested_exes)
def _propose_current_and_windows(
spec: PythonSpec,
cache: PyInfoCache | None,
env: Mapping[str, str],
tested_exes: set[str],
) -> Generator[tuple[PythonInfo | None, bool], None, None]:
current_python = PythonInfo.current_system(cache)
if _is_new_exe(str(current_python.executable), tested_exes):
yield current_python, True
if IS_WIN: # pragma: win32 cover
from ._windows import propose_interpreters as win_propose # noqa: PLC0415
for interpreter in win_propose(spec, cache, env):
if _is_new_exe(str(interpreter.executable), tested_exes):
yield interpreter, True
def _propose_from_path(
spec: PythonSpec,
cache: PyInfoCache | None,
env: Mapping[str, str],
tested_exes: set[str],
*,
all_implementations: bool = False,
) -> Generator[tuple[PythonInfo | None, bool], None, None]:
find_candidates = path_exe_finder(spec, all_implementations=all_implementations)
for pos, path in enumerate(get_paths(env)):
_LOGGER.debug(LazyPathDump(pos, path, env))
for exe, impl_must_match in find_candidates(path):
exe_raw = str(exe)
if resolved := _resolve_shim(exe_raw, env):
_LOGGER.debug("resolved shim %s to %s", exe_raw, resolved)
exe_raw = resolved
if not _is_new_exe(exe_raw, tested_exes):
continue
interpreter = PathPythonInfo.from_exe(exe_raw, cache, raise_on_error=False, env=env)
if interpreter is not None:
yield interpreter, impl_must_match
def _propose_from_uv(
cache: PyInfoCache | None,
env: Mapping[str, str],
*,
all_implementations: bool = False,
) -> Generator[tuple[PythonInfo | None, bool], None, None]:
if uv_python_dir := os.getenv("UV_PYTHON_INSTALL_DIR"):
uv_python_path = Path(uv_python_dir).expanduser()
elif xdg_data_home := os.getenv("XDG_DATA_HOME"):
uv_python_path = Path(xdg_data_home).expanduser() / "uv" / "python"
else:
uv_python_path = user_data_path("uv") / "python"
patterns: list[str] = ["*/bin/python", "*/python.exe"]
if all_implementations:
patterns.extend(("*/bin/pypy*", "*/bin/graalpy", "*/pypy*.exe", "*/bin/graalpy.exe"))
seen_uv_paths: set[str] = set()
for pattern in patterns:
for exe_path in uv_python_path.glob(pattern):
resolved = str(Path(exe_path).resolve())
if resolved in seen_uv_paths:
continue
seen_uv_paths.add(resolved)
if interpreter := PathPythonInfo.from_exe(str(exe_path), cache, raise_on_error=False, env=env):
yield interpreter, True
def get_paths(env: Mapping[str, str]) -> Generator[Path, None, None]:
path = env.get("PATH", None)
if path is None:
try:
path = os.confstr("CS_PATH")
except (AttributeError, ValueError): # pragma: no cover # Windows only (no confstr)
path = os.defpath
if path:
for entry in map(Path, path.split(os.pathsep)):
with suppress(OSError):
if entry.is_dir() and next(entry.iterdir(), None):
yield entry
class LazyPathDump:
def __init__(self, pos: int, path: Path, env: Mapping[str, str]) -> None:
self.pos = pos
self.path = path
self.env = env
def __repr__(self) -> str:
content = f"discover PATH[{self.pos}]={self.path}"
if self.env.get("_VIRTUALENV_DEBUG"):
content += " with =>"
for file_path in self.path.iterdir():
try:
if not self._is_executable(file_path):
continue
except OSError:
pass
content += " "
content += file_path.name
return content
def _is_executable(self, file_path: Path) -> bool:
if file_path.is_dir():
return False
if IS_WIN: # pragma: win32 cover
pathext = self.env.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";")
return any(file_path.name.upper().endswith(ext) for ext in pathext)
return bool(file_path.stat().st_mode & os.X_OK)
def path_exe_finder(
spec: PythonSpec, *, all_implementations: bool = False
) -> Callable[[Path], Generator[tuple[Path, bool], None, None]]:
"""Given a spec, return a function that can be called on a path to find all matching files in it."""
pat = spec.generate_re(windows=sys.platform == "win32", all_implementations=all_implementations)
direct = spec.str_spec
if sys.platform == "win32": # pragma: win32 cover
direct = f"{direct}.exe"
def path_exes(path: Path) -> Generator[tuple[Path, bool], None, None]:
direct_path = path / direct
if direct_path.exists():
yield direct_path, False
for exe in path.iterdir():
match = pat.fullmatch(exe.name)
if match:
yield exe.absolute(), match["impl"] == "python"
return path_exes
def _resolve_shim(exe_path: str, env: Mapping[str, str]) -> str | None:
"""Resolve a version-manager shim to the actual Python binary."""
for shims_dir_env, versions_path in _VERSION_MANAGER_LAYOUTS:
if root := env.get(shims_dir_env):
shims_dir = os.path.join(root, "shims")
if os.path.dirname(exe_path) == shims_dir:
exe_name = os.path.basename(exe_path)
versions_dir = os.path.join(root, *versions_path)
return _resolve_shim_to_binary(exe_name, versions_dir, env)
return None
_VERSION_MANAGER_LAYOUTS: list[tuple[str, tuple[str, ...]]] = [
("PYENV_ROOT", ("versions",)),
("MISE_DATA_DIR", ("installs", "python")),
("ASDF_DATA_DIR", ("installs", "python")),
]
def _resolve_shim_to_binary(exe_name: str, versions_dir: str, env: Mapping[str, str]) -> str | None:
for version in _active_versions(env):
resolved = os.path.join(versions_dir, version, "bin", exe_name)
if Path(resolved).is_file() and os.access(resolved, os.X_OK):
return resolved
return None
def _active_versions(env: Mapping[str, str]) -> Generator[str, None, None]:
"""Yield active Python version strings by reading version-manager configuration."""
if pyenv_version := env.get("PYENV_VERSION"):
yield from pyenv_version.split(":")
return
if versions := _read_python_version_file(Path.cwd()):
yield from versions
return
if (pyenv_root := env.get("PYENV_ROOT")) and (
versions := _read_python_version_file(os.path.join(pyenv_root, "version"), search_parents=False)
):
yield from versions
def _read_python_version_file(start: str | Path, *, search_parents: bool = True) -> list[str] | None:
"""Read a ``.python-version`` file, optionally searching parent directories."""
current = start
while True:
candidate = os.path.join(current, ".python-version") if Path(current).is_dir() else current
if Path(candidate).is_file():
with Path(candidate).open(encoding="utf-8") as fh:
if versions := [v for line in fh if (v := line.strip()) and not v.startswith("#")]:
return versions
if not search_parents:
return None
parent = Path(current).parent
if parent == current:
return None
current = parent
class PathPythonInfo(PythonInfo):
"""python info from path."""
__all__ = [
"LazyPathDump",
"PathPythonInfo",
"get_interpreter",
"get_paths",
"iter_interpreters",
"propose_interpreters",
]