python-discovery 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,308 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import sys
6
+ from contextlib import suppress
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Final
9
+
10
+ from platformdirs import user_data_path
11
+
12
+ from ._compat import fs_path_id
13
+ from ._py_info import PythonInfo
14
+ from ._py_spec import PythonSpec
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Callable, Generator, Iterable, Mapping, Sequence
18
+
19
+ from ._cache import PyInfoCache
20
+
21
+ _LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
22
+ IS_WIN: Final[bool] = sys.platform == "win32"
23
+
24
+
25
+ def get_interpreter(
26
+ key: str | Sequence[str],
27
+ try_first_with: Iterable[str] | None = None,
28
+ cache: PyInfoCache | None = None,
29
+ env: Mapping[str, str] | None = None,
30
+ ) -> PythonInfo | None:
31
+ specs = [key] if isinstance(key, str) else key
32
+ for spec_str in specs:
33
+ if result := _find_interpreter(spec_str, try_first_with or (), cache, env):
34
+ return result
35
+ return None
36
+
37
+
38
+ def _find_interpreter(
39
+ key: str,
40
+ try_first_with: Iterable[str],
41
+ cache: PyInfoCache | None = None,
42
+ env: Mapping[str, str] | None = None,
43
+ ) -> PythonInfo | None:
44
+ spec = PythonSpec.from_string_spec(key)
45
+ _LOGGER.info("find interpreter for spec %r", spec)
46
+ proposed_paths: set[tuple[str | None, bool]] = set()
47
+ env = os.environ if env is None else env
48
+ for interpreter, impl_must_match in propose_interpreters(spec, try_first_with, cache, env):
49
+ if interpreter is None: # pragma: no cover
50
+ continue
51
+ proposed_key = interpreter.system_executable, impl_must_match
52
+ if proposed_key in proposed_paths:
53
+ continue
54
+ _LOGGER.info("proposed %s", interpreter)
55
+ if interpreter.satisfies(spec, impl_must_match=impl_must_match):
56
+ _LOGGER.debug("accepted %s", interpreter)
57
+ return interpreter
58
+ proposed_paths.add(proposed_key)
59
+ return None
60
+
61
+
62
+ def _check_exe(path: str, tested_exes: set[str]) -> str | None:
63
+ """Resolve *path* to an absolute path and return it if not yet tested, otherwise ``None``."""
64
+ try:
65
+ os.lstat(path)
66
+ except OSError:
67
+ return None
68
+ resolved = str(Path(path).resolve())
69
+ exe_id = fs_path_id(resolved)
70
+ if exe_id in tested_exes:
71
+ return None
72
+ tested_exes.add(exe_id)
73
+ return str(Path(path).absolute())
74
+
75
+
76
+ def _is_new_exe(exe_raw: str, tested_exes: set[str]) -> bool:
77
+ """Return ``True`` and register *exe_raw* if it hasn't been tested yet."""
78
+ exe_id = fs_path_id(exe_raw)
79
+ if exe_id in tested_exes:
80
+ return False
81
+ tested_exes.add(exe_id)
82
+ return True
83
+
84
+
85
+ def propose_interpreters(
86
+ spec: PythonSpec,
87
+ try_first_with: Iterable[str],
88
+ cache: PyInfoCache | None = None,
89
+ env: Mapping[str, str] | None = None,
90
+ ) -> Generator[tuple[PythonInfo | None, bool], None, None]:
91
+ env = os.environ if env is None else env
92
+ tested_exes: set[str] = set()
93
+ if spec.is_abs and spec.path is not None:
94
+ if exe_raw := _check_exe(spec.path, tested_exes): # pragma: no branch # first exe always new
95
+ yield PythonInfo.from_exe(exe_raw, cache, env=env), True
96
+ return
97
+
98
+ yield from _propose_explicit(spec, try_first_with, cache, env, tested_exes)
99
+ if spec.path is not None and spec.is_abs: # pragma: no cover # relative spec.path is never abs
100
+ return
101
+ yield from _propose_from_path(spec, cache, env, tested_exes)
102
+ yield from _propose_from_uv(cache, env)
103
+
104
+
105
+ def _propose_explicit(
106
+ spec: PythonSpec,
107
+ try_first_with: Iterable[str],
108
+ cache: PyInfoCache | None,
109
+ env: Mapping[str, str],
110
+ tested_exes: set[str],
111
+ ) -> Generator[tuple[PythonInfo | None, bool], None, None]:
112
+ for py_exe in try_first_with:
113
+ if exe_raw := _check_exe(str(Path(py_exe).resolve()), tested_exes):
114
+ yield PythonInfo.from_exe(exe_raw, cache, env=env), True
115
+
116
+ if spec.path is not None:
117
+ if exe_raw := _check_exe(spec.path, tested_exes): # pragma: no branch
118
+ yield PythonInfo.from_exe(exe_raw, cache, env=env), True
119
+ else:
120
+ yield from _propose_current_and_windows(spec, cache, env, tested_exes)
121
+
122
+
123
+ def _propose_current_and_windows(
124
+ spec: PythonSpec,
125
+ cache: PyInfoCache | None,
126
+ env: Mapping[str, str],
127
+ tested_exes: set[str],
128
+ ) -> Generator[tuple[PythonInfo | None, bool], None, None]:
129
+ current_python = PythonInfo.current_system(cache)
130
+ if _is_new_exe(str(current_python.executable), tested_exes):
131
+ yield current_python, True
132
+
133
+ if IS_WIN: # pragma: win32 cover
134
+ from ._windows import propose_interpreters as win_propose # noqa: PLC0415
135
+
136
+ for interpreter in win_propose(spec, cache, env):
137
+ if _is_new_exe(str(interpreter.executable), tested_exes):
138
+ yield interpreter, True
139
+
140
+
141
+ def _propose_from_path(
142
+ spec: PythonSpec,
143
+ cache: PyInfoCache | None,
144
+ env: Mapping[str, str],
145
+ tested_exes: set[str],
146
+ ) -> Generator[tuple[PythonInfo | None, bool], None, None]:
147
+ find_candidates = path_exe_finder(spec)
148
+ for pos, path in enumerate(get_paths(env)):
149
+ _LOGGER.debug(LazyPathDump(pos, path, env))
150
+ for exe, impl_must_match in find_candidates(path):
151
+ exe_raw = str(exe)
152
+ if resolved := _resolve_shim(exe_raw, env):
153
+ _LOGGER.debug("resolved shim %s to %s", exe_raw, resolved)
154
+ exe_raw = resolved
155
+ if not _is_new_exe(exe_raw, tested_exes):
156
+ continue
157
+ interpreter = PathPythonInfo.from_exe(exe_raw, cache, raise_on_error=False, env=env)
158
+ if interpreter is not None:
159
+ yield interpreter, impl_must_match
160
+
161
+
162
+ def _propose_from_uv(
163
+ cache: PyInfoCache | None,
164
+ env: Mapping[str, str],
165
+ ) -> Generator[tuple[PythonInfo | None, bool], None, None]:
166
+ if uv_python_dir := os.getenv("UV_PYTHON_INSTALL_DIR"):
167
+ uv_python_path = Path(uv_python_dir).expanduser()
168
+ elif xdg_data_home := os.getenv("XDG_DATA_HOME"):
169
+ uv_python_path = Path(xdg_data_home).expanduser() / "uv" / "python"
170
+ else:
171
+ uv_python_path = user_data_path("uv") / "python"
172
+
173
+ for exe_path in uv_python_path.glob("*/bin/python"): # pragma: no branch
174
+ interpreter = PathPythonInfo.from_exe(str(exe_path), cache, raise_on_error=False, env=env)
175
+ if interpreter is not None: # pragma: no branch
176
+ yield interpreter, True
177
+
178
+
179
+ def get_paths(env: Mapping[str, str]) -> Generator[Path, None, None]:
180
+ path = env.get("PATH", None)
181
+ if path is None:
182
+ try:
183
+ path = os.confstr("CS_PATH")
184
+ except (AttributeError, ValueError): # pragma: no cover # Windows only (no confstr)
185
+ path = os.defpath
186
+ if path:
187
+ for entry in map(Path, path.split(os.pathsep)):
188
+ with suppress(OSError):
189
+ if entry.is_dir() and next(entry.iterdir(), None):
190
+ yield entry
191
+
192
+
193
+ class LazyPathDump:
194
+ def __init__(self, pos: int, path: Path, env: Mapping[str, str]) -> None:
195
+ self.pos = pos
196
+ self.path = path
197
+ self.env = env
198
+
199
+ def __repr__(self) -> str:
200
+ content = f"discover PATH[{self.pos}]={self.path}"
201
+ if self.env.get("_VIRTUALENV_DEBUG"):
202
+ content += " with =>"
203
+ for file_path in self.path.iterdir():
204
+ try:
205
+ if file_path.is_dir():
206
+ continue
207
+ if IS_WIN: # pragma: win32 cover
208
+ pathext = self.env.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";")
209
+ if not any(file_path.name.upper().endswith(ext) for ext in pathext):
210
+ continue
211
+ elif not (file_path.stat().st_mode & os.X_OK):
212
+ continue
213
+ except OSError:
214
+ pass
215
+ content += " "
216
+ content += file_path.name
217
+ return content
218
+
219
+
220
+ def path_exe_finder(spec: PythonSpec) -> Callable[[Path], Generator[tuple[Path, bool], None, None]]:
221
+ """Given a spec, return a function that can be called on a path to find all matching files in it."""
222
+ pat = spec.generate_re(windows=sys.platform == "win32")
223
+ direct = spec.str_spec
224
+ if sys.platform == "win32": # pragma: win32 cover
225
+ direct = f"{direct}.exe"
226
+
227
+ def path_exes(path: Path) -> Generator[tuple[Path, bool], None, None]:
228
+ direct_path = path / direct
229
+ if direct_path.exists():
230
+ yield direct_path, False
231
+
232
+ for exe in path.iterdir():
233
+ match = pat.fullmatch(exe.name)
234
+ if match:
235
+ yield exe.absolute(), match["impl"] == "python"
236
+
237
+ return path_exes
238
+
239
+
240
+ def _resolve_shim(exe_path: str, env: Mapping[str, str]) -> str | None:
241
+ """Resolve a version-manager shim to the actual Python binary."""
242
+ for shims_dir_env, versions_path in _VERSION_MANAGER_LAYOUTS:
243
+ if root := env.get(shims_dir_env):
244
+ shims_dir = os.path.join(root, "shims")
245
+ if os.path.dirname(exe_path) == shims_dir:
246
+ exe_name = os.path.basename(exe_path)
247
+ versions_dir = os.path.join(root, *versions_path)
248
+ return _resolve_shim_to_binary(exe_name, versions_dir, env)
249
+ return None
250
+
251
+
252
+ _VERSION_MANAGER_LAYOUTS: list[tuple[str, tuple[str, ...]]] = [
253
+ ("PYENV_ROOT", ("versions",)),
254
+ ("MISE_DATA_DIR", ("installs", "python")),
255
+ ("ASDF_DATA_DIR", ("installs", "python")),
256
+ ]
257
+
258
+
259
+ def _resolve_shim_to_binary(exe_name: str, versions_dir: str, env: Mapping[str, str]) -> str | None:
260
+ for version in _active_versions(env):
261
+ resolved = os.path.join(versions_dir, version, "bin", exe_name)
262
+ if Path(resolved).is_file() and os.access(resolved, os.X_OK):
263
+ return resolved
264
+ return None
265
+
266
+
267
+ def _active_versions(env: Mapping[str, str]) -> Generator[str, None, None]:
268
+ """Yield active Python version strings by reading version-manager configuration."""
269
+ if pyenv_version := env.get("PYENV_VERSION"):
270
+ yield from pyenv_version.split(":")
271
+ return
272
+ if versions := _read_python_version_file(Path.cwd()):
273
+ yield from versions
274
+ return
275
+ if (pyenv_root := env.get("PYENV_ROOT")) and (
276
+ versions := _read_python_version_file(os.path.join(pyenv_root, "version"), search_parents=False)
277
+ ):
278
+ yield from versions
279
+
280
+
281
+ def _read_python_version_file(start: str | Path, *, search_parents: bool = True) -> list[str] | None:
282
+ """Read a ``.python-version`` file, optionally searching parent directories."""
283
+ current = start
284
+ while True:
285
+ candidate = os.path.join(current, ".python-version") if Path(current).is_dir() else current
286
+ if Path(candidate).is_file():
287
+ with Path(candidate).open(encoding="utf-8") as fh:
288
+ if versions := [v for line in fh if (v := line.strip()) and not v.startswith("#")]:
289
+ return versions
290
+ if not search_parents:
291
+ return None
292
+ parent = Path(current).parent
293
+ if parent == current:
294
+ return None
295
+ current = parent
296
+
297
+
298
+ class PathPythonInfo(PythonInfo):
299
+ """python info from path."""
300
+
301
+
302
+ __all__ = [
303
+ "LazyPathDump",
304
+ "PathPythonInfo",
305
+ "get_interpreter",
306
+ "get_paths",
307
+ "propose_interpreters",
308
+ ]