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,726 @@
|
|
|
1
|
+
"""Concrete Python interpreter information, also used as subprocess interrogation script (stdlib only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import re
|
|
10
|
+
import struct
|
|
11
|
+
import sys
|
|
12
|
+
import sysconfig
|
|
13
|
+
import warnings
|
|
14
|
+
from collections import OrderedDict
|
|
15
|
+
from string import digits
|
|
16
|
+
from typing import TYPE_CHECKING, ClassVar, Final, NamedTuple
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Generator, Mapping
|
|
20
|
+
|
|
21
|
+
from ._cache import PyInfoCache
|
|
22
|
+
from ._py_spec import PythonSpec
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class VersionInfo(NamedTuple):
|
|
26
|
+
major: int
|
|
27
|
+
minor: int
|
|
28
|
+
micro: int
|
|
29
|
+
releaselevel: str
|
|
30
|
+
serial: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _get_path_extensions() -> list[str]:
|
|
37
|
+
return list(OrderedDict.fromkeys(["", *os.environ.get("PATHEXT", "").lower().split(os.pathsep)]))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
EXTENSIONS: Final[list[str]] = _get_path_extensions()
|
|
41
|
+
_32BIT_POINTER_SIZE: Final[int] = 4
|
|
42
|
+
_CONF_VAR_RE: Final[re.Pattern[str]] = re.compile(
|
|
43
|
+
r"""
|
|
44
|
+
\{ \w+ } # sysconfig variable placeholder like {base}
|
|
45
|
+
""",
|
|
46
|
+
re.VERBOSE,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PythonInfo: # noqa: PLR0904
|
|
51
|
+
"""Contains information for a Python interpreter."""
|
|
52
|
+
|
|
53
|
+
def __init__(self) -> None:
|
|
54
|
+
self._init_identity()
|
|
55
|
+
self._init_prefixes()
|
|
56
|
+
self._init_schemes()
|
|
57
|
+
self._init_sysconfig()
|
|
58
|
+
|
|
59
|
+
def _init_identity(self) -> None:
|
|
60
|
+
self.platform = sys.platform
|
|
61
|
+
self.implementation = platform.python_implementation()
|
|
62
|
+
if self.implementation == "PyPy":
|
|
63
|
+
self.pypy_version_info = tuple(sys.pypy_version_info) # ty: ignore[unresolved-attribute] # pypy only
|
|
64
|
+
|
|
65
|
+
self.version_info = VersionInfo(*sys.version_info)
|
|
66
|
+
# same as stdlib platform.architecture to account for pointer size != max int
|
|
67
|
+
self.architecture = 32 if struct.calcsize("P") == _32BIT_POINTER_SIZE else 64
|
|
68
|
+
self.sysconfig_platform = sysconfig.get_platform()
|
|
69
|
+
self.version_nodot = sysconfig.get_config_var("py_version_nodot")
|
|
70
|
+
self.version = sys.version
|
|
71
|
+
self.os = os.name
|
|
72
|
+
self.free_threaded = sysconfig.get_config_var("Py_GIL_DISABLED") == 1
|
|
73
|
+
|
|
74
|
+
def _init_prefixes(self) -> None:
|
|
75
|
+
def abs_path(value: str | None) -> str | None:
|
|
76
|
+
return None if value is None else os.path.abspath(value)
|
|
77
|
+
|
|
78
|
+
self.prefix = abs_path(getattr(sys, "prefix", None))
|
|
79
|
+
self.base_prefix = abs_path(getattr(sys, "base_prefix", None))
|
|
80
|
+
self.real_prefix = abs_path(getattr(sys, "real_prefix", None))
|
|
81
|
+
self.base_exec_prefix = abs_path(getattr(sys, "base_exec_prefix", None))
|
|
82
|
+
self.exec_prefix = abs_path(getattr(sys, "exec_prefix", None))
|
|
83
|
+
|
|
84
|
+
self.executable = abs_path(sys.executable)
|
|
85
|
+
self.original_executable = abs_path(self.executable)
|
|
86
|
+
self.system_executable = self._fast_get_system_executable()
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
__import__("venv")
|
|
90
|
+
has = True
|
|
91
|
+
except ImportError: # pragma: no cover # venv is always available in standard CPython
|
|
92
|
+
has = False
|
|
93
|
+
self.has_venv = has
|
|
94
|
+
self.path = sys.path
|
|
95
|
+
self.file_system_encoding = sys.getfilesystemencoding()
|
|
96
|
+
self.stdout_encoding = getattr(sys.stdout, "encoding", None)
|
|
97
|
+
|
|
98
|
+
def _init_schemes(self) -> None:
|
|
99
|
+
scheme_names = sysconfig.get_scheme_names()
|
|
100
|
+
|
|
101
|
+
if "venv" in scheme_names: # pragma: >=3.11 cover
|
|
102
|
+
self.sysconfig_scheme = "venv"
|
|
103
|
+
self.sysconfig_paths = {
|
|
104
|
+
i: sysconfig.get_path(i, expand=False, scheme=self.sysconfig_scheme) for i in sysconfig.get_path_names()
|
|
105
|
+
}
|
|
106
|
+
self.distutils_install = {}
|
|
107
|
+
# debian / ubuntu python 3.10 without `python3-distutils` will report mangled `local/bin` / etc. names
|
|
108
|
+
elif sys.version_info[:2] == (3, 10) and "deb_system" in scheme_names: # pragma: no cover # Debian/Ubuntu 3.10
|
|
109
|
+
self.sysconfig_scheme = "posix_prefix"
|
|
110
|
+
self.sysconfig_paths = {
|
|
111
|
+
i: sysconfig.get_path(i, expand=False, scheme=self.sysconfig_scheme) for i in sysconfig.get_path_names()
|
|
112
|
+
}
|
|
113
|
+
self.distutils_install = {}
|
|
114
|
+
else: # pragma: no cover # "venv" scheme always present on Python 3.12+
|
|
115
|
+
self.sysconfig_scheme = None
|
|
116
|
+
self.sysconfig_paths = {i: sysconfig.get_path(i, expand=False) for i in sysconfig.get_path_names()}
|
|
117
|
+
self.distutils_install = self._distutils_install().copy()
|
|
118
|
+
|
|
119
|
+
def _init_sysconfig(self) -> None:
|
|
120
|
+
makefile = getattr(sysconfig, "get_makefile_filename", getattr(sysconfig, "_get_makefile_filename", None))
|
|
121
|
+
self.sysconfig = {
|
|
122
|
+
k: v
|
|
123
|
+
for k, v in [
|
|
124
|
+
("makefile_filename", makefile() if makefile is not None else None),
|
|
125
|
+
]
|
|
126
|
+
if k is not None
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
config_var_keys = set()
|
|
130
|
+
for element in self.sysconfig_paths.values():
|
|
131
|
+
config_var_keys.update(k[1:-1] for k in _CONF_VAR_RE.findall(element))
|
|
132
|
+
config_var_keys.add("PYTHONFRAMEWORK")
|
|
133
|
+
config_var_keys.update(("Py_ENABLE_SHARED", "INSTSONAME", "LIBDIR"))
|
|
134
|
+
|
|
135
|
+
self.sysconfig_vars = {i: sysconfig.get_config_var(i or "") for i in config_var_keys}
|
|
136
|
+
|
|
137
|
+
if "TCL_LIBRARY" in os.environ:
|
|
138
|
+
self.tcl_lib, self.tk_lib = self._get_tcl_tk_libs()
|
|
139
|
+
else:
|
|
140
|
+
self.tcl_lib, self.tk_lib = None, None
|
|
141
|
+
|
|
142
|
+
confs = {
|
|
143
|
+
k: (self.system_prefix if isinstance(v, str) and v.startswith(self.prefix) else v)
|
|
144
|
+
for k, v in self.sysconfig_vars.items()
|
|
145
|
+
}
|
|
146
|
+
self.system_stdlib = self.sysconfig_path("stdlib", confs)
|
|
147
|
+
self.system_stdlib_platform = self.sysconfig_path("platstdlib", confs)
|
|
148
|
+
self.max_size = getattr(sys, "maxsize", getattr(sys, "maxint", None))
|
|
149
|
+
self._creators = None # virtualenv-specific, set via monkey-patch
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def _get_tcl_tk_libs() -> tuple[
|
|
153
|
+
str | None,
|
|
154
|
+
str | None,
|
|
155
|
+
]: # pragma: no cover # tkinter availability varies; tested indirectly via __init__
|
|
156
|
+
"""Detect the tcl and tk libraries using tkinter."""
|
|
157
|
+
tcl_lib, tk_lib = None, None
|
|
158
|
+
try:
|
|
159
|
+
import tkinter as tk # noqa: PLC0415
|
|
160
|
+
except ImportError:
|
|
161
|
+
pass
|
|
162
|
+
else:
|
|
163
|
+
try:
|
|
164
|
+
tcl = tk.Tcl()
|
|
165
|
+
tcl_lib = tcl.eval("info library")
|
|
166
|
+
|
|
167
|
+
# Try to get TK library path directly first
|
|
168
|
+
try:
|
|
169
|
+
tk_lib = tcl.eval("set tk_library")
|
|
170
|
+
if tk_lib and os.path.isdir(tk_lib):
|
|
171
|
+
pass # We found it directly
|
|
172
|
+
else:
|
|
173
|
+
tk_lib = None # Reset if invalid
|
|
174
|
+
except tk.TclError:
|
|
175
|
+
tk_lib = None
|
|
176
|
+
|
|
177
|
+
# If direct query failed, try constructing the path
|
|
178
|
+
if tk_lib is None:
|
|
179
|
+
tk_version = tcl.eval("package require Tk")
|
|
180
|
+
tcl_parent = os.path.dirname(tcl_lib)
|
|
181
|
+
|
|
182
|
+
# Try different version formats
|
|
183
|
+
version_variants = [
|
|
184
|
+
tk_version, # Full version like "8.6.12"
|
|
185
|
+
".".join(tk_version.split(".")[:2]), # Major.minor like "8.6"
|
|
186
|
+
tk_version.split(".")[0], # Just major like "8"
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
for version in version_variants:
|
|
190
|
+
tk_lib_path = os.path.join(tcl_parent, f"tk{version}")
|
|
191
|
+
if not os.path.isdir(tk_lib_path):
|
|
192
|
+
continue
|
|
193
|
+
if os.path.exists(os.path.join(tk_lib_path, "tk.tcl")):
|
|
194
|
+
tk_lib = tk_lib_path
|
|
195
|
+
break
|
|
196
|
+
|
|
197
|
+
except tk.TclError:
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
return tcl_lib, tk_lib
|
|
201
|
+
|
|
202
|
+
def _fast_get_system_executable(self) -> str | None:
|
|
203
|
+
"""Try to get the system executable by just looking at properties."""
|
|
204
|
+
# if we're not in a virtual environment, this is already a system python, so return the original executable
|
|
205
|
+
# note we must choose the original and not the pure executable as shim scripts might throw us off
|
|
206
|
+
if not (self.real_prefix or (self.base_prefix is not None and self.base_prefix != self.prefix)):
|
|
207
|
+
return self.original_executable
|
|
208
|
+
|
|
209
|
+
# if this is NOT a virtual environment, can't determine easily, bail out
|
|
210
|
+
if self.real_prefix is not None:
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
base_executable = getattr(sys, "_base_executable", None) # some platforms may set this to help us
|
|
214
|
+
if base_executable is None: # use the saved system executable if present
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
# we know we're in a virtual environment, can not be us
|
|
218
|
+
if sys.executable == base_executable:
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
# We're not in a venv and base_executable exists; use it directly
|
|
222
|
+
if os.path.exists(base_executable): # pragma: >=3.11 cover
|
|
223
|
+
return base_executable
|
|
224
|
+
|
|
225
|
+
# Try fallback for POSIX virtual environments
|
|
226
|
+
return self._try_posix_fallback_executable(base_executable) # pragma: >=3.11 cover
|
|
227
|
+
|
|
228
|
+
def _try_posix_fallback_executable(self, base_executable: str) -> str | None:
|
|
229
|
+
"""Find a versioned Python binary as fallback for POSIX virtual environments."""
|
|
230
|
+
major, minor = self.version_info.major, self.version_info.minor
|
|
231
|
+
if self.os != "posix" or (major, minor) < (3, 11):
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
# search relative to the directory of sys._base_executable
|
|
235
|
+
base_dir = os.path.dirname(base_executable)
|
|
236
|
+
candidates = [f"python{major}", f"python{major}.{minor}"]
|
|
237
|
+
if self.implementation == "PyPy":
|
|
238
|
+
candidates.extend(["pypy", "pypy3", f"pypy{major}", f"pypy{major}.{minor}"])
|
|
239
|
+
|
|
240
|
+
for candidate in candidates:
|
|
241
|
+
full_path = os.path.join(base_dir, candidate)
|
|
242
|
+
if os.path.exists(full_path):
|
|
243
|
+
return full_path
|
|
244
|
+
|
|
245
|
+
return None # in this case we just can't tell easily without poking around FS and calling them, bail
|
|
246
|
+
|
|
247
|
+
def install_path(self, key: str) -> str:
|
|
248
|
+
"""Return the relative installation path for a given installation scheme *key*."""
|
|
249
|
+
result = self.distutils_install.get(key)
|
|
250
|
+
if result is None: # pragma: >=3.11 cover # distutils is empty when "venv" scheme is available
|
|
251
|
+
# set prefixes to empty => result is relative from cwd
|
|
252
|
+
prefixes = self.prefix, self.exec_prefix, self.base_prefix, self.base_exec_prefix
|
|
253
|
+
config_var = {k: "" if v in prefixes else v for k, v in self.sysconfig_vars.items()}
|
|
254
|
+
result = self.sysconfig_path(key, config_var=config_var).lstrip(os.sep)
|
|
255
|
+
return result
|
|
256
|
+
|
|
257
|
+
@staticmethod
|
|
258
|
+
def _distutils_install() -> dict[str, str]:
|
|
259
|
+
# use distutils primarily because that's what pip does
|
|
260
|
+
# https://github.com/pypa/pip/blob/main/src/pip/_internal/locations.py#L95
|
|
261
|
+
# note here we don't import Distribution directly to allow setuptools to patch it
|
|
262
|
+
with warnings.catch_warnings(): # disable warning for PEP-632
|
|
263
|
+
warnings.simplefilter("ignore")
|
|
264
|
+
try:
|
|
265
|
+
from distutils import dist # noqa: PLC0415 # ty: ignore[unresolved-import]
|
|
266
|
+
from distutils.command.install import SCHEME_KEYS # noqa: PLC0415 # ty: ignore[unresolved-import]
|
|
267
|
+
except ImportError: # pragma: no cover # if removed or not installed ignore
|
|
268
|
+
return {}
|
|
269
|
+
|
|
270
|
+
distribution = dist.Distribution({
|
|
271
|
+
"script_args": "--no-user-cfg",
|
|
272
|
+
}) # conf files not parsed so they do not hijack paths
|
|
273
|
+
if hasattr(sys, "_framework"): # pragma: no cover # macOS framework builds only
|
|
274
|
+
sys._framework = None # noqa: SLF001 # disable macOS static paths for framework
|
|
275
|
+
|
|
276
|
+
with warnings.catch_warnings(): # disable warning for PEP-632
|
|
277
|
+
warnings.simplefilter("ignore")
|
|
278
|
+
install = distribution.get_command_obj("install", create=True)
|
|
279
|
+
|
|
280
|
+
install.prefix = os.sep # paths generated are relative to prefix that contains the path sep
|
|
281
|
+
install.finalize_options()
|
|
282
|
+
return {key: (getattr(install, f"install_{key}")[1:]).lstrip(os.sep) for key in SCHEME_KEYS}
|
|
283
|
+
|
|
284
|
+
@property
|
|
285
|
+
def version_str(self) -> str:
|
|
286
|
+
"""The full version as ``major.minor.micro`` string (e.g. ``3.13.2``)."""
|
|
287
|
+
return ".".join(str(i) for i in self.version_info[0:3])
|
|
288
|
+
|
|
289
|
+
@property
|
|
290
|
+
def version_release_str(self) -> str:
|
|
291
|
+
"""The release version as ``major.minor`` string (e.g. ``3.13``)."""
|
|
292
|
+
return ".".join(str(i) for i in self.version_info[0:2])
|
|
293
|
+
|
|
294
|
+
@property
|
|
295
|
+
def python_name(self) -> str:
|
|
296
|
+
"""The python executable name as ``pythonX.Y`` (e.g. ``python3.13``)."""
|
|
297
|
+
version_info = self.version_info
|
|
298
|
+
return f"python{version_info.major}.{version_info.minor}"
|
|
299
|
+
|
|
300
|
+
@property
|
|
301
|
+
def is_old_virtualenv(self) -> bool:
|
|
302
|
+
"""``True`` if this interpreter runs inside an old-style virtualenv (has ``real_prefix``)."""
|
|
303
|
+
return self.real_prefix is not None
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def is_venv(self) -> bool:
|
|
307
|
+
"""``True`` if this interpreter runs inside a PEP 405 venv (has ``base_prefix``)."""
|
|
308
|
+
return self.base_prefix is not None
|
|
309
|
+
|
|
310
|
+
def sysconfig_path(self, key: str, config_var: dict[str, str] | None = None, sep: str = os.sep) -> str:
|
|
311
|
+
"""Return the sysconfig install path for a scheme *key*, optionally substituting config variables."""
|
|
312
|
+
pattern = self.sysconfig_paths.get(key)
|
|
313
|
+
if pattern is None:
|
|
314
|
+
return ""
|
|
315
|
+
if config_var is None:
|
|
316
|
+
config_var = self.sysconfig_vars
|
|
317
|
+
else:
|
|
318
|
+
base = self.sysconfig_vars.copy()
|
|
319
|
+
base.update(config_var)
|
|
320
|
+
config_var = base
|
|
321
|
+
return pattern.format(**config_var).replace("/", sep)
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def system_include(self) -> str:
|
|
325
|
+
"""The path to the system include directory for C headers."""
|
|
326
|
+
path = self.sysconfig_path(
|
|
327
|
+
"include",
|
|
328
|
+
{
|
|
329
|
+
k: (self.system_prefix if isinstance(v, str) and v.startswith(self.prefix) else v)
|
|
330
|
+
for k, v in self.sysconfig_vars.items()
|
|
331
|
+
},
|
|
332
|
+
)
|
|
333
|
+
if not os.path.exists(path): # pragma: no cover # broken packaging fallback
|
|
334
|
+
fallback = os.path.join(self.prefix, os.path.dirname(self.install_path("headers")))
|
|
335
|
+
if os.path.exists(fallback):
|
|
336
|
+
path = fallback
|
|
337
|
+
return path
|
|
338
|
+
|
|
339
|
+
@property
|
|
340
|
+
def system_prefix(self) -> str:
|
|
341
|
+
"""The prefix of the system Python this interpreter is based on."""
|
|
342
|
+
return self.real_prefix or self.base_prefix or self.prefix
|
|
343
|
+
|
|
344
|
+
@property
|
|
345
|
+
def system_exec_prefix(self) -> str:
|
|
346
|
+
"""The exec prefix of the system Python this interpreter is based on."""
|
|
347
|
+
return self.real_prefix or self.base_exec_prefix or self.exec_prefix
|
|
348
|
+
|
|
349
|
+
def __repr__(self) -> str:
|
|
350
|
+
return "{}({!r})".format(
|
|
351
|
+
self.__class__.__name__,
|
|
352
|
+
{k: v for k, v in self.__dict__.items() if not k.startswith("_")},
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def __str__(self) -> str:
|
|
356
|
+
return "{}({})".format(
|
|
357
|
+
self.__class__.__name__,
|
|
358
|
+
", ".join(
|
|
359
|
+
f"{k}={v}"
|
|
360
|
+
for k, v in (
|
|
361
|
+
("spec", self.spec),
|
|
362
|
+
(
|
|
363
|
+
"system"
|
|
364
|
+
if self.system_executable is not None and self.system_executable != self.executable
|
|
365
|
+
else None,
|
|
366
|
+
self.system_executable,
|
|
367
|
+
),
|
|
368
|
+
(
|
|
369
|
+
"original"
|
|
370
|
+
if self.original_executable not in {self.system_executable, self.executable}
|
|
371
|
+
else None,
|
|
372
|
+
self.original_executable,
|
|
373
|
+
),
|
|
374
|
+
("exe", self.executable),
|
|
375
|
+
("platform", self.platform),
|
|
376
|
+
("version", repr(self.version)),
|
|
377
|
+
("encoding_fs_io", f"{self.file_system_encoding}-{self.stdout_encoding}"),
|
|
378
|
+
)
|
|
379
|
+
if k is not None
|
|
380
|
+
),
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
@property
|
|
384
|
+
def machine(self) -> str:
|
|
385
|
+
"""Return the instruction set architecture (ISA) derived from :func:`sysconfig.get_platform`."""
|
|
386
|
+
plat = self.sysconfig_platform
|
|
387
|
+
if plat is None:
|
|
388
|
+
return "unknown"
|
|
389
|
+
if plat == "win32":
|
|
390
|
+
return "x86"
|
|
391
|
+
isa = plat.rsplit("-", 1)[-1]
|
|
392
|
+
if isa == "universal2":
|
|
393
|
+
isa = platform.machine().lower()
|
|
394
|
+
return normalize_isa(isa)
|
|
395
|
+
|
|
396
|
+
@property
|
|
397
|
+
def spec(self) -> str:
|
|
398
|
+
"""A specification string identifying this interpreter (e.g. ``CPython3.13.2-64-arm64``)."""
|
|
399
|
+
return "{}{}{}-{}-{}".format(
|
|
400
|
+
self.implementation,
|
|
401
|
+
".".join(str(i) for i in self.version_info),
|
|
402
|
+
"t" if self.free_threaded else "",
|
|
403
|
+
self.architecture,
|
|
404
|
+
self.machine,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
@classmethod
|
|
408
|
+
def clear_cache(cls, cache: PyInfoCache) -> None:
|
|
409
|
+
"""Clear all cached interpreter information from *cache*."""
|
|
410
|
+
from ._cached_py_info import clear # noqa: PLC0415
|
|
411
|
+
|
|
412
|
+
clear(cache)
|
|
413
|
+
cls._cache_exe_discovery.clear()
|
|
414
|
+
|
|
415
|
+
def satisfies(self, spec: PythonSpec, *, impl_must_match: bool) -> bool: # noqa: PLR0911
|
|
416
|
+
"""Check if a given specification can be satisfied by this python interpreter instance."""
|
|
417
|
+
if spec.path and not self._satisfies_path(spec):
|
|
418
|
+
return False
|
|
419
|
+
if impl_must_match and not self._satisfies_implementation(spec):
|
|
420
|
+
return False
|
|
421
|
+
if spec.architecture is not None and spec.architecture != self.architecture:
|
|
422
|
+
return False
|
|
423
|
+
if spec.machine is not None and spec.machine != self.machine:
|
|
424
|
+
return False
|
|
425
|
+
if spec.free_threaded is not None and spec.free_threaded != self.free_threaded:
|
|
426
|
+
return False
|
|
427
|
+
if spec.version_specifier is not None and not self._satisfies_version_specifier(spec):
|
|
428
|
+
return False
|
|
429
|
+
return all(
|
|
430
|
+
req is None or our is None or our == req
|
|
431
|
+
for our, req in zip(self.version_info[0:3], (spec.major, spec.minor, spec.micro))
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
def _satisfies_path(self, spec: PythonSpec) -> bool:
|
|
435
|
+
if self.executable == os.path.abspath(spec.path):
|
|
436
|
+
return True
|
|
437
|
+
if spec.is_abs:
|
|
438
|
+
return True
|
|
439
|
+
basename = os.path.basename(self.original_executable)
|
|
440
|
+
spec_path = spec.path
|
|
441
|
+
if sys.platform == "win32":
|
|
442
|
+
basename, suffix = os.path.splitext(basename)
|
|
443
|
+
spec_path = spec_path[: -len(suffix)] if suffix and spec_path.endswith(suffix) else spec_path
|
|
444
|
+
return basename == spec_path
|
|
445
|
+
|
|
446
|
+
def _satisfies_implementation(self, spec: PythonSpec) -> bool:
|
|
447
|
+
return spec.implementation is None or spec.implementation.lower() == self.implementation.lower()
|
|
448
|
+
|
|
449
|
+
def _satisfies_version_specifier(self, spec: PythonSpec) -> bool:
|
|
450
|
+
if spec.version_specifier is None: # pragma: no cover
|
|
451
|
+
return True
|
|
452
|
+
version_info = self.version_info
|
|
453
|
+
release = f"{version_info.major}.{version_info.minor}.{version_info.micro}"
|
|
454
|
+
if version_info.releaselevel != "final":
|
|
455
|
+
suffix = {"alpha": "a", "beta": "b", "candidate": "rc"}.get(version_info.releaselevel)
|
|
456
|
+
if suffix is not None: # pragma: no branch # releaselevel is always alpha/beta/candidate here
|
|
457
|
+
release = f"{release}{suffix}{version_info.serial}"
|
|
458
|
+
return spec.version_specifier.contains(release)
|
|
459
|
+
|
|
460
|
+
_current_system = None
|
|
461
|
+
_current = None
|
|
462
|
+
|
|
463
|
+
@classmethod
|
|
464
|
+
def current(cls, cache: PyInfoCache | None = None) -> PythonInfo:
|
|
465
|
+
"""Locate the current host interpreter information."""
|
|
466
|
+
if cls._current is None:
|
|
467
|
+
result = cls.from_exe(sys.executable, cache, raise_on_error=True, resolve_to_host=False)
|
|
468
|
+
if result is None:
|
|
469
|
+
msg = "failed to query current Python interpreter"
|
|
470
|
+
raise RuntimeError(msg)
|
|
471
|
+
cls._current = result
|
|
472
|
+
return cls._current
|
|
473
|
+
|
|
474
|
+
@classmethod
|
|
475
|
+
def current_system(cls, cache: PyInfoCache | None = None) -> PythonInfo:
|
|
476
|
+
"""Locate the current system interpreter information, resolving through any virtualenv layers."""
|
|
477
|
+
if cls._current_system is None:
|
|
478
|
+
result = cls.from_exe(sys.executable, cache, raise_on_error=True, resolve_to_host=True)
|
|
479
|
+
if result is None:
|
|
480
|
+
msg = "failed to query current system Python interpreter"
|
|
481
|
+
raise RuntimeError(msg)
|
|
482
|
+
cls._current_system = result
|
|
483
|
+
return cls._current_system
|
|
484
|
+
|
|
485
|
+
def to_json(self) -> str:
|
|
486
|
+
return json.dumps(self.to_dict(), indent=2)
|
|
487
|
+
|
|
488
|
+
def to_dict(self) -> dict[str, object]:
|
|
489
|
+
data = {var: (getattr(self, var) if var != "_creators" else None) for var in vars(self)}
|
|
490
|
+
version_info = data["version_info"]
|
|
491
|
+
data["version_info"] = version_info._asdict() if hasattr(version_info, "_asdict") else version_info
|
|
492
|
+
return data
|
|
493
|
+
|
|
494
|
+
@classmethod
|
|
495
|
+
def from_exe( # noqa: PLR0913
|
|
496
|
+
cls,
|
|
497
|
+
exe: str,
|
|
498
|
+
cache: PyInfoCache | None = None,
|
|
499
|
+
*,
|
|
500
|
+
raise_on_error: bool = True,
|
|
501
|
+
ignore_cache: bool = False,
|
|
502
|
+
resolve_to_host: bool = True,
|
|
503
|
+
env: Mapping[str, str] | None = None,
|
|
504
|
+
) -> PythonInfo | None:
|
|
505
|
+
"""Get the python information for a given executable path."""
|
|
506
|
+
from ._cached_py_info import from_exe # noqa: PLC0415
|
|
507
|
+
|
|
508
|
+
env = os.environ if env is None else env
|
|
509
|
+
proposed = from_exe(cls, cache, exe, env=env, raise_on_error=raise_on_error, ignore_cache=ignore_cache)
|
|
510
|
+
|
|
511
|
+
if isinstance(proposed, PythonInfo) and resolve_to_host:
|
|
512
|
+
try:
|
|
513
|
+
proposed = proposed.resolve_to_system(cache, proposed)
|
|
514
|
+
except Exception as exception:
|
|
515
|
+
if raise_on_error:
|
|
516
|
+
raise
|
|
517
|
+
_LOGGER.info("ignore %s due cannot resolve system due to %r", proposed.original_executable, exception)
|
|
518
|
+
proposed = None
|
|
519
|
+
return proposed
|
|
520
|
+
|
|
521
|
+
@classmethod
|
|
522
|
+
def from_json(cls, payload: str) -> PythonInfo:
|
|
523
|
+
raw = json.loads(payload)
|
|
524
|
+
return cls.from_dict(raw.copy())
|
|
525
|
+
|
|
526
|
+
@classmethod
|
|
527
|
+
def from_dict(cls, data: dict[str, object]) -> PythonInfo:
|
|
528
|
+
data["version_info"] = VersionInfo(**data["version_info"]) # restore this to a named tuple structure
|
|
529
|
+
result = cls()
|
|
530
|
+
result.__dict__ = data.copy()
|
|
531
|
+
return result
|
|
532
|
+
|
|
533
|
+
@classmethod
|
|
534
|
+
def resolve_to_system(cls, cache: PyInfoCache | None, target: PythonInfo) -> PythonInfo:
|
|
535
|
+
start_executable = target.executable
|
|
536
|
+
prefixes = OrderedDict()
|
|
537
|
+
while target.system_executable is None:
|
|
538
|
+
prefix = target.real_prefix or target.base_prefix or target.prefix
|
|
539
|
+
if prefix in prefixes:
|
|
540
|
+
if len(prefixes) == 1:
|
|
541
|
+
_LOGGER.info("%r links back to itself via prefixes", target)
|
|
542
|
+
target.system_executable = target.executable
|
|
543
|
+
break
|
|
544
|
+
for at, (p, t) in enumerate(prefixes.items(), start=1):
|
|
545
|
+
_LOGGER.error("%d: prefix=%s, info=%r", at, p, t)
|
|
546
|
+
_LOGGER.error("%d: prefix=%s, info=%r", len(prefixes) + 1, prefix, target)
|
|
547
|
+
msg = "prefixes are causing a circle {}".format("|".join(prefixes.keys()))
|
|
548
|
+
raise RuntimeError(msg)
|
|
549
|
+
prefixes[prefix] = target
|
|
550
|
+
target = target.discover_exe(cache, prefix=prefix, exact=False)
|
|
551
|
+
if target.executable != target.system_executable:
|
|
552
|
+
resolved = cls.from_exe(target.system_executable, cache)
|
|
553
|
+
if resolved is not None:
|
|
554
|
+
target = resolved
|
|
555
|
+
target.executable = start_executable
|
|
556
|
+
return target
|
|
557
|
+
|
|
558
|
+
_cache_exe_discovery: ClassVar[dict[tuple[str, bool], PythonInfo]] = {}
|
|
559
|
+
|
|
560
|
+
def discover_exe(
|
|
561
|
+
self,
|
|
562
|
+
cache: PyInfoCache,
|
|
563
|
+
prefix: str,
|
|
564
|
+
*,
|
|
565
|
+
exact: bool = True,
|
|
566
|
+
env: Mapping[str, str] | None = None,
|
|
567
|
+
) -> PythonInfo:
|
|
568
|
+
"""Discover a matching Python executable under a given *prefix* directory."""
|
|
569
|
+
key = prefix, exact
|
|
570
|
+
if key in self._cache_exe_discovery and prefix:
|
|
571
|
+
_LOGGER.debug("discover exe from cache %s - exact %s: %r", prefix, exact, self._cache_exe_discovery[key])
|
|
572
|
+
return self._cache_exe_discovery[key]
|
|
573
|
+
_LOGGER.debug("discover exe for %s in %s", self, prefix)
|
|
574
|
+
possible_names = self._find_possible_exe_names()
|
|
575
|
+
possible_folders = self._find_possible_folders(prefix)
|
|
576
|
+
discovered = []
|
|
577
|
+
env = os.environ if env is None else env
|
|
578
|
+
for folder in possible_folders:
|
|
579
|
+
for name in possible_names:
|
|
580
|
+
info = self._check_exe(cache, folder, name, discovered, env, exact=exact)
|
|
581
|
+
if info is not None:
|
|
582
|
+
self._cache_exe_discovery[key] = info
|
|
583
|
+
return info
|
|
584
|
+
if exact is False and discovered:
|
|
585
|
+
info = self._select_most_likely(discovered, self)
|
|
586
|
+
folders = os.pathsep.join(possible_folders)
|
|
587
|
+
self._cache_exe_discovery[key] = info
|
|
588
|
+
_LOGGER.debug("no exact match found, chosen most similar of %s within base folders %s", info, folders)
|
|
589
|
+
return info
|
|
590
|
+
msg = "failed to detect {} in {}".format("|".join(possible_names), os.pathsep.join(possible_folders))
|
|
591
|
+
raise RuntimeError(msg)
|
|
592
|
+
|
|
593
|
+
def _check_exe( # noqa: PLR0913
|
|
594
|
+
self,
|
|
595
|
+
cache: PyInfoCache | None,
|
|
596
|
+
folder: str,
|
|
597
|
+
name: str,
|
|
598
|
+
discovered: list[PythonInfo],
|
|
599
|
+
env: Mapping[str, str],
|
|
600
|
+
*,
|
|
601
|
+
exact: bool,
|
|
602
|
+
) -> PythonInfo | None:
|
|
603
|
+
exe_path = os.path.join(folder, name)
|
|
604
|
+
if not os.path.exists(exe_path):
|
|
605
|
+
return None
|
|
606
|
+
info = self.from_exe(exe_path, cache, resolve_to_host=False, raise_on_error=False, env=env)
|
|
607
|
+
if info is None: # ignore if for some reason we can't query
|
|
608
|
+
return None
|
|
609
|
+
for item in ["implementation", "architecture", "machine", "version_info"]:
|
|
610
|
+
found = getattr(info, item)
|
|
611
|
+
searched = getattr(self, item)
|
|
612
|
+
if found != searched:
|
|
613
|
+
if item == "version_info":
|
|
614
|
+
found, searched = ".".join(str(i) for i in found), ".".join(str(i) for i in searched)
|
|
615
|
+
executable = info.executable
|
|
616
|
+
_LOGGER.debug("refused interpreter %s because %s differs %s != %s", executable, item, found, searched)
|
|
617
|
+
if exact is False:
|
|
618
|
+
discovered.append(info)
|
|
619
|
+
break
|
|
620
|
+
else:
|
|
621
|
+
return info
|
|
622
|
+
return None
|
|
623
|
+
|
|
624
|
+
@staticmethod
|
|
625
|
+
def _select_most_likely(discovered: list[PythonInfo], target: PythonInfo) -> PythonInfo:
|
|
626
|
+
def sort_by(info: PythonInfo) -> int:
|
|
627
|
+
# we need to setup some priority of traits, this is as follows:
|
|
628
|
+
# implementation, major, minor, architecture, machine, micro, tag, serial
|
|
629
|
+
matches = [
|
|
630
|
+
info.implementation == target.implementation,
|
|
631
|
+
info.version_info.major == target.version_info.major,
|
|
632
|
+
info.version_info.minor == target.version_info.minor,
|
|
633
|
+
info.architecture == target.architecture,
|
|
634
|
+
info.machine == target.machine,
|
|
635
|
+
info.version_info.micro == target.version_info.micro,
|
|
636
|
+
info.version_info.releaselevel == target.version_info.releaselevel,
|
|
637
|
+
info.version_info.serial == target.version_info.serial,
|
|
638
|
+
]
|
|
639
|
+
return sum((1 << pos if match else 0) for pos, match in enumerate(reversed(matches)))
|
|
640
|
+
|
|
641
|
+
sorted_discovered = sorted(discovered, key=sort_by, reverse=True) # sort by priority in decreasing order
|
|
642
|
+
return sorted_discovered[0]
|
|
643
|
+
|
|
644
|
+
def _find_possible_folders(self, inside_folder: str) -> list[str]:
|
|
645
|
+
candidate_folder = OrderedDict()
|
|
646
|
+
executables = OrderedDict()
|
|
647
|
+
executables[os.path.realpath(self.executable)] = None
|
|
648
|
+
executables[self.executable] = None
|
|
649
|
+
executables[os.path.realpath(self.original_executable)] = None
|
|
650
|
+
executables[self.original_executable] = None
|
|
651
|
+
for exe in executables:
|
|
652
|
+
base = os.path.dirname(exe)
|
|
653
|
+
if base.startswith(self.prefix):
|
|
654
|
+
relative = base[len(self.prefix) :]
|
|
655
|
+
candidate_folder[f"{inside_folder}{relative}"] = None
|
|
656
|
+
|
|
657
|
+
# or at root level
|
|
658
|
+
candidate_folder[inside_folder] = None
|
|
659
|
+
return [i for i in candidate_folder if os.path.exists(i)]
|
|
660
|
+
|
|
661
|
+
def _find_possible_exe_names(self) -> list[str]:
|
|
662
|
+
name_candidate = OrderedDict()
|
|
663
|
+
for name in self._possible_base():
|
|
664
|
+
for at in (3, 2, 1, 0):
|
|
665
|
+
version = ".".join(str(i) for i in self.version_info[:at])
|
|
666
|
+
mods = [""]
|
|
667
|
+
if self.free_threaded:
|
|
668
|
+
mods.append("t")
|
|
669
|
+
for mod in mods:
|
|
670
|
+
for arch in [f"-{self.architecture}", ""]:
|
|
671
|
+
for ext in EXTENSIONS:
|
|
672
|
+
candidate = f"{name}{version}{mod}{arch}{ext}"
|
|
673
|
+
name_candidate[candidate] = None
|
|
674
|
+
return list(name_candidate.keys())
|
|
675
|
+
|
|
676
|
+
def _possible_base(self) -> Generator[str, None, None]:
|
|
677
|
+
possible_base = OrderedDict()
|
|
678
|
+
basename = os.path.splitext(os.path.basename(self.executable))[0].rstrip(digits)
|
|
679
|
+
possible_base[basename] = None
|
|
680
|
+
possible_base[self.implementation] = None
|
|
681
|
+
# python is always the final option as in practice is used by multiple implementation as exe name
|
|
682
|
+
if "python" in possible_base:
|
|
683
|
+
del possible_base["python"]
|
|
684
|
+
possible_base["python"] = None
|
|
685
|
+
for base in possible_base:
|
|
686
|
+
lower = base.lower()
|
|
687
|
+
yield lower
|
|
688
|
+
from ._compat import fs_is_case_sensitive # noqa: PLC0415
|
|
689
|
+
|
|
690
|
+
if fs_is_case_sensitive(): # pragma: no branch
|
|
691
|
+
if base != lower:
|
|
692
|
+
yield base
|
|
693
|
+
upper = base.upper()
|
|
694
|
+
if upper != base:
|
|
695
|
+
yield upper
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def normalize_isa(isa: str) -> str:
|
|
699
|
+
low = isa.lower()
|
|
700
|
+
return {"amd64": "x86_64", "aarch64": "arm64"}.get(low, low)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _main() -> None: # pragma: no cover
|
|
704
|
+
argv = sys.argv[1:]
|
|
705
|
+
|
|
706
|
+
if len(argv) >= 1:
|
|
707
|
+
start_cookie = argv[0]
|
|
708
|
+
argv = argv[1:]
|
|
709
|
+
else:
|
|
710
|
+
start_cookie = ""
|
|
711
|
+
|
|
712
|
+
if len(argv) >= 1:
|
|
713
|
+
end_cookie = argv[0]
|
|
714
|
+
argv = argv[1:]
|
|
715
|
+
else:
|
|
716
|
+
end_cookie = ""
|
|
717
|
+
|
|
718
|
+
sys.argv = sys.argv[:1] + argv
|
|
719
|
+
|
|
720
|
+
result = PythonInfo().to_json()
|
|
721
|
+
sys.stdout.write("".join((start_cookie[::-1], result, end_cookie[::-1])))
|
|
722
|
+
sys.stdout.flush()
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
if __name__ == "__main__":
|
|
726
|
+
_main()
|