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.
- python_discovery/__init__.py +22 -0
- python_discovery/_cache.py +153 -0
- python_discovery/_cached_py_info.py +259 -0
- python_discovery/_compat.py +29 -0
- python_discovery/_discovery.py +308 -0
- python_discovery/_py_info.py +726 -0
- python_discovery/_py_spec.py +235 -0
- python_discovery/_specifier.py +264 -0
- python_discovery/_windows/__init__.py +13 -0
- python_discovery/_windows/_pep514.py +222 -0
- python_discovery/_windows/_propose.py +53 -0
- python_discovery/py.typed +0 -0
- python_discovery-1.0.0.dist-info/METADATA +71 -0
- python_discovery-1.0.0.dist-info/RECORD +16 -0
- python_discovery-1.0.0.dist-info/WHEEL +4 -0
- python_discovery-1.0.0.dist-info/licenses/LICENSE +18 -0
|
@@ -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
|
+
]
|