python-discovery 1.5.1__py3-none-any.whl → 1.5.3__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/_cached_py_info.py +65 -29
- python_discovery/_py_info.py +51 -298
- python_discovery/_py_info_collect.py +370 -0
- {python_discovery-1.5.1.dist-info → python_discovery-1.5.3.dist-info}/METADATA +5 -2
- {python_discovery-1.5.1.dist-info → python_discovery-1.5.3.dist-info}/RECORD +7 -6
- {python_discovery-1.5.1.dist-info → python_discovery-1.5.3.dist-info}/WHEEL +1 -1
- {python_discovery-1.5.1.dist-info → python_discovery-1.5.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -13,6 +13,7 @@ import sys
|
|
|
13
13
|
import tempfile
|
|
14
14
|
from collections import OrderedDict
|
|
15
15
|
from contextlib import contextmanager
|
|
16
|
+
from functools import lru_cache
|
|
16
17
|
from pathlib import Path
|
|
17
18
|
from shlex import quote
|
|
18
19
|
from subprocess import Popen, TimeoutExpired # ruff:ignore[suspicious-subprocess-import]
|
|
@@ -20,16 +21,30 @@ from typing import TYPE_CHECKING, Final
|
|
|
20
21
|
|
|
21
22
|
from ._cache import NoOpCache
|
|
22
23
|
from ._py_info import PythonInfo
|
|
24
|
+
from ._py_info_collect import MIN_INTERROGATE_VERSION, UNSUPPORTED_EXIT_CODE, UNSUPPORTED_MARKER
|
|
23
25
|
|
|
24
26
|
if TYPE_CHECKING:
|
|
25
27
|
from collections.abc import Generator, Mapping
|
|
28
|
+
from typing import TypedDict
|
|
26
29
|
|
|
27
30
|
from ._cache import ContentStore, PyInfoCache
|
|
28
31
|
|
|
32
|
+
class CacheEntryMeta(TypedDict):
|
|
33
|
+
"""Identity of a cached interrogation result: the queried binary and the script that produced it."""
|
|
34
|
+
|
|
35
|
+
st_mtime: float
|
|
36
|
+
path: str
|
|
37
|
+
hash: str | None
|
|
38
|
+
|
|
29
39
|
|
|
30
40
|
_CACHE: OrderedDict[Path, PythonInfo | Exception] = OrderedDict()
|
|
31
41
|
_CACHE[Path(sys.executable)] = PythonInfo()
|
|
32
42
|
_LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
|
|
43
|
+
_PY_INFO_SCRIPT: Final[Path] = Path(__file__).resolve().parent / "_py_info_collect.py"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _UnsupportedInterpreterError(RuntimeError):
|
|
47
|
+
"""The interpreter is older than the interrogation script supports; retrying cannot help."""
|
|
33
48
|
|
|
34
49
|
|
|
35
50
|
def from_exe( # ruff:ignore[too-many-arguments]
|
|
@@ -82,48 +97,54 @@ def _get_via_file_cache(
|
|
|
82
97
|
path_modified = path.stat().st_mtime
|
|
83
98
|
except OSError:
|
|
84
99
|
path_modified = -1
|
|
85
|
-
py_info_script = Path(Path(__file__).resolve()).parent / "_py_info.py"
|
|
86
100
|
try:
|
|
87
|
-
py_info_hash: str | None =
|
|
101
|
+
py_info_hash: str | None = _script_hash()
|
|
88
102
|
except OSError:
|
|
89
103
|
py_info_hash = None
|
|
90
104
|
|
|
91
105
|
resolved_cache = cache if cache is not None else NoOpCache()
|
|
92
|
-
py_info: PythonInfo | None = None
|
|
93
106
|
py_info_store = resolved_cache.py_info(path)
|
|
107
|
+
entry_meta: CacheEntryMeta = {"st_mtime": path_modified, "path": path_text, "hash": py_info_hash}
|
|
94
108
|
with py_info_store.locked():
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
of_path == path_text
|
|
100
|
-
and of_st_mtime == path_modified
|
|
101
|
-
and of_hash == py_info_hash
|
|
102
|
-
and isinstance(of_content, dict)
|
|
103
|
-
):
|
|
104
|
-
py_info = _load_cached_py_info(cls, py_info_store, of_content)
|
|
105
|
-
else:
|
|
106
|
-
py_info_store.remove()
|
|
109
|
+
cached = _read_cache_entry(cls, py_info_store, entry_meta)
|
|
110
|
+
if isinstance(cached, _UnsupportedInterpreterError):
|
|
111
|
+
return cached
|
|
112
|
+
py_info = cached
|
|
107
113
|
if py_info is None:
|
|
108
114
|
failure, py_info = _run_subprocess(cls, exe, env)
|
|
109
|
-
if failure is not None:
|
|
115
|
+
if failure is not None and not isinstance(failure, _UnsupportedInterpreterError):
|
|
110
116
|
_LOGGER.debug("first subprocess attempt failed for %s (%s), retrying", exe, failure)
|
|
111
117
|
failure, py_info = _run_subprocess(cls, exe, env)
|
|
118
|
+
if isinstance(failure, _UnsupportedInterpreterError):
|
|
119
|
+
_LOGGER.warning("%s", failure) # the verdict is permanent, warn once and remember it
|
|
120
|
+
py_info_store.write({**entry_meta, "unsupported": str(failure)})
|
|
121
|
+
return failure
|
|
112
122
|
if failure is not None:
|
|
113
123
|
return failure
|
|
114
124
|
if py_info is not None:
|
|
115
|
-
py_info_store.write({
|
|
116
|
-
"st_mtime": path_modified,
|
|
117
|
-
"path": path_text,
|
|
118
|
-
"content": py_info.to_dict(),
|
|
119
|
-
"hash": py_info_hash,
|
|
120
|
-
})
|
|
125
|
+
py_info_store.write({**entry_meta, "content": py_info.to_dict()})
|
|
121
126
|
if py_info is None:
|
|
122
127
|
msg = f"{exe} failed to produce interpreter info"
|
|
123
128
|
return RuntimeError(msg)
|
|
124
129
|
return py_info
|
|
125
130
|
|
|
126
131
|
|
|
132
|
+
def _read_cache_entry(
|
|
133
|
+
cls: type[PythonInfo],
|
|
134
|
+
py_info_store: ContentStore,
|
|
135
|
+
entry_meta: CacheEntryMeta,
|
|
136
|
+
) -> PythonInfo | _UnsupportedInterpreterError | None:
|
|
137
|
+
if not py_info_store.exists() or (data := py_info_store.read()) is None:
|
|
138
|
+
return None
|
|
139
|
+
if all(data.get(key) == value for key, value in entry_meta.items()):
|
|
140
|
+
if isinstance(unsupported := data.get("unsupported"), str):
|
|
141
|
+
return _UnsupportedInterpreterError(unsupported)
|
|
142
|
+
if isinstance(content := data.get("content"), dict):
|
|
143
|
+
return _load_cached_py_info(cls, py_info_store, content)
|
|
144
|
+
py_info_store.remove()
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
127
148
|
def _load_cached_py_info(
|
|
128
149
|
cls: type[PythonInfo],
|
|
129
150
|
py_info_store: ContentStore,
|
|
@@ -140,6 +161,11 @@ def _load_cached_py_info(
|
|
|
140
161
|
return py_info
|
|
141
162
|
|
|
142
163
|
|
|
164
|
+
@lru_cache(maxsize=1)
|
|
165
|
+
def _script_hash() -> str:
|
|
166
|
+
return hashlib.sha256(_PY_INFO_SCRIPT.read_bytes()).hexdigest()
|
|
167
|
+
|
|
168
|
+
|
|
143
169
|
COOKIE_LENGTH: Final[int] = 32
|
|
144
170
|
|
|
145
171
|
|
|
@@ -149,13 +175,12 @@ def gen_cookie() -> str:
|
|
|
149
175
|
|
|
150
176
|
@contextmanager
|
|
151
177
|
def _resolve_py_info_script() -> Generator[Path]:
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
yield py_info_script
|
|
178
|
+
if _PY_INFO_SCRIPT.is_file():
|
|
179
|
+
yield _PY_INFO_SCRIPT
|
|
155
180
|
else:
|
|
156
|
-
data = pkgutil.get_data(__package__ or __name__,
|
|
181
|
+
data = pkgutil.get_data(__package__ or __name__, _PY_INFO_SCRIPT.name)
|
|
157
182
|
if data is None:
|
|
158
|
-
msg = "cannot locate
|
|
183
|
+
msg = f"cannot locate {_PY_INFO_SCRIPT.name} for subprocess interrogation"
|
|
159
184
|
raise FileNotFoundError(msg)
|
|
160
185
|
fd, tmp = tempfile.mkstemp(suffix=".py")
|
|
161
186
|
try:
|
|
@@ -216,8 +241,7 @@ def _run_subprocess(
|
|
|
216
241
|
except OSError as os_error:
|
|
217
242
|
out, err, code = "", os_error.strerror, os_error.errno
|
|
218
243
|
if code != 0:
|
|
219
|
-
|
|
220
|
-
return RuntimeError(f"failed to query {msg}"), None
|
|
244
|
+
return _query_failure(exe, out, err, code), None
|
|
221
245
|
out, raw_out, out_starts, out_ends = _extract_between_cookies(out, start_cookie, end_cookie)
|
|
222
246
|
try:
|
|
223
247
|
result = cls.from_json(out)
|
|
@@ -240,6 +264,18 @@ def _run_subprocess(
|
|
|
240
264
|
return None, result
|
|
241
265
|
|
|
242
266
|
|
|
267
|
+
def _query_failure(exe: str, out: str, err: str | None, code: int | None) -> RuntimeError:
|
|
268
|
+
# the gate's exit code and stderr marker must both be present: either alone can collide, an errno of 79 from a
|
|
269
|
+
# failed exec or a shim that echoes the marker phrase, but only the gate produces the combination
|
|
270
|
+
if code == UNSUPPORTED_EXIT_CODE and err is not None and (at := err.find(UNSUPPORTED_MARKER)) != -1:
|
|
271
|
+
version = err[at + len(UNSUPPORTED_MARKER) :].split() or ["unknown"]
|
|
272
|
+
floor = ".".join(str(i) for i in MIN_INTERROGATE_VERSION)
|
|
273
|
+
msg = f"{exe} is Python {version[0]}, older than the minimum {floor} python-discovery can query"
|
|
274
|
+
return _UnsupportedInterpreterError(msg)
|
|
275
|
+
msg = f"{exe} with code {code}{f' out: {out!r}' if out else ''}{f' err: {err!r}' if err else ''}"
|
|
276
|
+
return RuntimeError(f"failed to query {msg}")
|
|
277
|
+
|
|
278
|
+
|
|
243
279
|
class LogCmd:
|
|
244
280
|
def __init__(self, cmd: list[str], env: Mapping[str, str] | None = None) -> None:
|
|
245
281
|
self.cmd = cmd
|
python_discovery/_py_info.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Concrete Python interpreter information,
|
|
1
|
+
"""Concrete Python interpreter information, collected in-process or via subprocess interrogation."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -6,23 +6,23 @@ import json
|
|
|
6
6
|
import logging
|
|
7
7
|
import os
|
|
8
8
|
import platform
|
|
9
|
-
import re
|
|
10
|
-
import struct
|
|
11
9
|
import sys
|
|
12
|
-
import sysconfig
|
|
13
|
-
import warnings
|
|
14
10
|
from collections import OrderedDict
|
|
15
11
|
from itertools import product
|
|
16
12
|
from string import digits
|
|
17
13
|
from typing import TYPE_CHECKING, ClassVar, Final, NamedTuple
|
|
18
14
|
|
|
15
|
+
from ._py_info_collect import PythonInfoCollector
|
|
16
|
+
|
|
19
17
|
if TYPE_CHECKING:
|
|
20
|
-
import tkinter as tk
|
|
21
18
|
from collections.abc import Generator, Mapping
|
|
19
|
+
from typing import Union
|
|
22
20
|
|
|
23
21
|
from ._cache import PyInfoCache
|
|
24
22
|
from ._py_spec import PythonSpec
|
|
25
23
|
|
|
24
|
+
InfoValue = Union[str, int, bool, None, "tuple[int | str, ...]", "list[str]", "dict[str, str | int | None]"]
|
|
25
|
+
|
|
26
26
|
|
|
27
27
|
class VersionInfo(NamedTuple):
|
|
28
28
|
major: int
|
|
@@ -40,246 +40,47 @@ def _get_path_extensions() -> list[str]:
|
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
EXTENSIONS: Final[list[str]] = _get_path_extensions()
|
|
43
|
-
_32BIT_POINTER_SIZE: Final[int] = 4
|
|
44
|
-
_CONF_VAR_RE: Final[re.Pattern[str]] = re.compile(
|
|
45
|
-
r"""
|
|
46
|
-
\{ \w+ } # sysconfig variable placeholder like {base}
|
|
47
|
-
""",
|
|
48
|
-
re.VERBOSE,
|
|
49
|
-
)
|
|
50
43
|
|
|
51
44
|
|
|
52
45
|
class PythonInfo: # ruff:ignore[too-many-public-methods]
|
|
53
46
|
"""Contains information for a Python interpreter."""
|
|
54
47
|
|
|
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
|
-
self.executable = abs_path(sys.executable)
|
|
90
|
-
self.original_executable = abs_path(self.executable)
|
|
91
|
-
self.system_executable = self._fast_get_system_executable()
|
|
92
|
-
|
|
93
|
-
try:
|
|
94
|
-
__import__("venv")
|
|
95
|
-
has = True
|
|
96
|
-
except ImportError: # pragma: no cover # venv is always available in standard CPython
|
|
97
|
-
has = False
|
|
98
|
-
self.has_venv = has
|
|
99
|
-
self.path = sys.path
|
|
100
|
-
self.file_system_encoding = sys.getfilesystemencoding()
|
|
101
|
-
self.stdout_encoding = getattr(sys.stdout, "encoding", None)
|
|
102
|
-
|
|
103
|
-
def _init_schemes(self) -> None:
|
|
104
|
-
scheme_names = sysconfig.get_scheme_names()
|
|
105
|
-
|
|
106
|
-
if "venv" in scheme_names: # pragma: >=3.11 cover
|
|
107
|
-
self.sysconfig_scheme = "venv"
|
|
108
|
-
self.sysconfig_paths = {
|
|
109
|
-
i: sysconfig.get_path(i, expand=False, scheme=self.sysconfig_scheme) for i in sysconfig.get_path_names()
|
|
110
|
-
}
|
|
111
|
-
self.distutils_install = {}
|
|
112
|
-
# debian / ubuntu python 3.10 without `python3-distutils` will report mangled `local/bin` / etc. names
|
|
113
|
-
elif sys.version_info[:2] == (3, 10) and "deb_system" in scheme_names: # pragma: no cover # Debian/Ubuntu 3.10
|
|
114
|
-
self.sysconfig_scheme = "posix_prefix"
|
|
115
|
-
self.sysconfig_paths = {
|
|
116
|
-
i: sysconfig.get_path(i, expand=False, scheme=self.sysconfig_scheme) for i in sysconfig.get_path_names()
|
|
117
|
-
}
|
|
118
|
-
self.distutils_install = {}
|
|
119
|
-
else: # pragma: no cover # "venv" scheme always present on Python 3.12+
|
|
120
|
-
self.sysconfig_scheme = None
|
|
121
|
-
self.sysconfig_paths = {i: sysconfig.get_path(i, expand=False) for i in sysconfig.get_path_names()}
|
|
122
|
-
self.distutils_install = self._distutils_install().copy()
|
|
123
|
-
|
|
124
|
-
def _init_sysconfig(self) -> None:
|
|
125
|
-
makefile = getattr(sysconfig, "get_makefile_filename", getattr(sysconfig, "_get_makefile_filename", None))
|
|
126
|
-
self.sysconfig = {
|
|
127
|
-
k: v
|
|
128
|
-
for k, v in [
|
|
129
|
-
("makefile_filename", makefile() if makefile is not None else None),
|
|
130
|
-
]
|
|
131
|
-
if k is not None
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
config_var_keys = set()
|
|
135
|
-
for element in self.sysconfig_paths.values():
|
|
136
|
-
config_var_keys.update(k[1:-1] for k in _CONF_VAR_RE.findall(element))
|
|
137
|
-
config_var_keys.add("PYTHONFRAMEWORK")
|
|
138
|
-
config_var_keys.update(("Py_ENABLE_SHARED", "INSTSONAME", "LIBDIR"))
|
|
139
|
-
|
|
140
|
-
self.sysconfig_vars = {i: sysconfig.get_config_var(i or "") for i in config_var_keys}
|
|
141
|
-
|
|
142
|
-
if "TCL_LIBRARY" in os.environ:
|
|
143
|
-
self.tcl_lib, self.tk_lib = self._get_tcl_tk_libs()
|
|
144
|
-
else:
|
|
145
|
-
self.tcl_lib, self.tk_lib = None, None
|
|
146
|
-
|
|
147
|
-
confs = {
|
|
148
|
-
k: (self.system_prefix if isinstance(v, str) and v.startswith(self.prefix) else v)
|
|
149
|
-
for k, v in self.sysconfig_vars.items()
|
|
150
|
-
}
|
|
151
|
-
self.system_stdlib = self.sysconfig_path("stdlib", confs)
|
|
152
|
-
self.system_stdlib_platform = self.sysconfig_path("platstdlib", confs)
|
|
153
|
-
self.max_size = getattr(sys, "maxsize", getattr(sys, "maxint", None))
|
|
154
|
-
self._creators = None # virtualenv-specific, set via monkey-patch
|
|
155
|
-
|
|
156
|
-
@staticmethod
|
|
157
|
-
def _get_tcl_tk_libs() -> tuple[
|
|
158
|
-
str | None,
|
|
159
|
-
str | None,
|
|
160
|
-
]: # pragma: no cover # tkinter availability varies; tested indirectly via __init__
|
|
161
|
-
"""Detect the tcl and tk libraries using tkinter."""
|
|
162
|
-
tcl_lib, tk_lib = None, None
|
|
163
|
-
try:
|
|
164
|
-
import tkinter as tk # ruff:ignore[import-outside-top-level]
|
|
165
|
-
except ImportError:
|
|
166
|
-
pass
|
|
167
|
-
else:
|
|
168
|
-
try:
|
|
169
|
-
tcl = tk.Tcl()
|
|
170
|
-
tcl_lib = tcl.eval("info library")
|
|
171
|
-
tk_lib = PythonInfo._resolve_tk_lib(tcl, tcl_lib)
|
|
172
|
-
except tk.TclError:
|
|
173
|
-
pass
|
|
174
|
-
|
|
175
|
-
return tcl_lib, tk_lib
|
|
176
|
-
|
|
177
|
-
@staticmethod
|
|
178
|
-
def _query_tk_library(tcl: tk.Tk) -> str | None: # pragma: no cover
|
|
179
|
-
"""Try to get the TK library path directly from Tcl."""
|
|
180
|
-
import tkinter as tk # ruff:ignore[import-outside-top-level]
|
|
181
|
-
|
|
182
|
-
try:
|
|
183
|
-
if (tk_lib := tcl.eval("set tk_library")) and os.path.isdir(tk_lib):
|
|
184
|
-
return tk_lib
|
|
185
|
-
except tk.TclError:
|
|
186
|
-
pass
|
|
187
|
-
return None
|
|
188
|
-
|
|
189
|
-
@staticmethod
|
|
190
|
-
def _resolve_tk_lib(tcl: tk.Tk, tcl_lib: str) -> str | None: # pragma: no cover
|
|
191
|
-
"""Resolve the TK library path by direct query or path construction."""
|
|
192
|
-
if (tk_lib := PythonInfo._query_tk_library(tcl)) is not None:
|
|
193
|
-
return tk_lib
|
|
194
|
-
tk_version = tcl.eval("package require Tk")
|
|
195
|
-
tcl_parent = os.path.dirname(tcl_lib)
|
|
196
|
-
for version in (tk_version, ".".join(tk_version.split(".")[:2]), tk_version.split(".")[0]):
|
|
197
|
-
tk_lib_path = os.path.join(tcl_parent, f"tk{version}")
|
|
198
|
-
if os.path.isdir(tk_lib_path) and os.path.exists(os.path.join(tk_lib_path, "tk.tcl")):
|
|
199
|
-
return tk_lib_path
|
|
200
|
-
return None
|
|
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._resolve_executable_symlink(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 self._resolve_executable_symlink(base_executable)
|
|
48
|
+
platform: str
|
|
49
|
+
implementation: str
|
|
50
|
+
pypy_version_info: tuple[int, ...]
|
|
51
|
+
version_info: VersionInfo
|
|
52
|
+
architecture: int
|
|
53
|
+
sysconfig_platform: str | None
|
|
54
|
+
version_nodot: str | None
|
|
55
|
+
version: str
|
|
56
|
+
os: str
|
|
57
|
+
free_threaded: bool
|
|
58
|
+
debug_build: bool
|
|
59
|
+
prefix: str | None
|
|
60
|
+
base_prefix: str | None
|
|
61
|
+
real_prefix: str | None
|
|
62
|
+
base_exec_prefix: str | None
|
|
63
|
+
exec_prefix: str | None
|
|
64
|
+
executable: str
|
|
65
|
+
original_executable: str
|
|
66
|
+
system_executable: str | None
|
|
67
|
+
has_venv: bool
|
|
68
|
+
path: list[str]
|
|
69
|
+
file_system_encoding: str
|
|
70
|
+
stdout_encoding: str | None
|
|
71
|
+
sysconfig_scheme: str | None
|
|
72
|
+
sysconfig_paths: dict[str, str]
|
|
73
|
+
distutils_install: dict[str, str]
|
|
74
|
+
sysconfig: dict[str, str | None]
|
|
75
|
+
sysconfig_vars: dict[str, str | int | None]
|
|
76
|
+
tcl_lib: str | None
|
|
77
|
+
tk_lib: str | None
|
|
78
|
+
system_stdlib: str
|
|
79
|
+
system_stdlib_platform: str
|
|
80
|
+
max_size: int
|
|
224
81
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
def _resolve_executable_symlink(self, path: str, *, framework: bool | None = None) -> str:
|
|
229
|
-
"""
|
|
230
|
-
Resolve symlinks of the executable itself, but never of its parent directories.
|
|
231
|
-
|
|
232
|
-
Mirrors CPython's ``getpath.realpath`` (and ``venv`` in python/cpython#115237): an executable-only symlink
|
|
233
|
-
resolves to the real interpreter so its home can be located, while a fully symlinked interpreter tree is
|
|
234
|
-
kept as-is. Like ``getpath``, resolution stops as soon as the stdlib landmark is reachable from the current
|
|
235
|
-
directory - an alias such as Debian's ``/usr/bin/python3`` is a usable home and stays untouched.
|
|
236
|
-
"""
|
|
237
|
-
result = os.path.abspath(path)
|
|
238
|
-
if self.os != "posix": # CPython only does this where HAVE_READLINK
|
|
239
|
-
return result
|
|
240
|
-
if framework is None:
|
|
241
|
-
framework = bool(sysconfig.get_config_var("PYTHONFRAMEWORK"))
|
|
242
|
-
if framework: # macOS framework builds self-locate via dyld from the real binary; e.g. for Homebrew
|
|
243
|
-
return result # resolving would pin the versioned Cellar path into the recorded home
|
|
244
|
-
real_path = os.path.realpath(result)
|
|
245
|
-
if not os.path.exists(real_path): # symlink loop or broken symlink
|
|
246
|
-
return result
|
|
247
|
-
while os.path.islink(result):
|
|
248
|
-
if self._stdlib_landmark_exists(os.path.dirname(result)):
|
|
249
|
-
return result
|
|
250
|
-
link = os.readlink(result)
|
|
251
|
-
candidate = link if os.path.isabs(link) else os.path.normpath(os.path.join(os.path.dirname(result), link))
|
|
252
|
-
# normpath through a symlinked directory may point at a different file - stop resolving there
|
|
253
|
-
if not (os.path.exists(candidate) and os.path.samefile(real_path, candidate)):
|
|
254
|
-
return result
|
|
255
|
-
result = candidate
|
|
256
|
-
return result
|
|
257
|
-
|
|
258
|
-
@staticmethod
|
|
259
|
-
def _stdlib_landmark_exists(dir_path: str) -> bool:
|
|
260
|
-
lib_name = os.path.basename(os.path.dirname(os.__file__))
|
|
261
|
-
return any(
|
|
262
|
-
os.path.exists(os.path.join(dir_path, os.pardir, lib, lib_name, "os.py")) for lib in ("lib", "lib64")
|
|
263
|
-
)
|
|
264
|
-
|
|
265
|
-
def _try_posix_fallback_executable(self, base_executable: str) -> str | None:
|
|
266
|
-
"""Find a versioned Python binary as fallback for POSIX virtual environments."""
|
|
267
|
-
major, minor = self.version_info.major, self.version_info.minor
|
|
268
|
-
if self.os != "posix" or (major, minor) < (3, 11):
|
|
269
|
-
return None
|
|
270
|
-
|
|
271
|
-
# search relative to the directory of sys._base_executable
|
|
272
|
-
base_dir = os.path.dirname(base_executable)
|
|
273
|
-
candidates = [f"python{major}", f"python{major}.{minor}"]
|
|
274
|
-
if self.implementation == "PyPy":
|
|
275
|
-
candidates.extend(["pypy", "pypy3", f"pypy{major}", f"pypy{major}.{minor}"])
|
|
276
|
-
|
|
277
|
-
for candidate in candidates:
|
|
278
|
-
full_path = os.path.join(base_dir, candidate)
|
|
279
|
-
if os.path.exists(full_path):
|
|
280
|
-
return full_path
|
|
281
|
-
|
|
282
|
-
return None # in this case we just can't tell easily without poking around FS and calling them, bail
|
|
82
|
+
def __init__(self) -> None:
|
|
83
|
+
self.__dict__.update(vars(self.from_dict(PythonInfoCollector().to_dict())))
|
|
283
84
|
|
|
284
85
|
def install_path(self, key: str) -> str:
|
|
285
86
|
"""
|
|
@@ -295,36 +96,6 @@ class PythonInfo: # ruff:ignore[too-many-public-methods]
|
|
|
295
96
|
result = self.sysconfig_path(key, config_var=config_var).lstrip(os.sep)
|
|
296
97
|
return result
|
|
297
98
|
|
|
298
|
-
@staticmethod
|
|
299
|
-
def _distutils_install() -> dict[str, str]:
|
|
300
|
-
# use distutils primarily because that's what pip does
|
|
301
|
-
# https://github.com/pypa/pip/blob/main/src/pip/_internal/locations.py#L95
|
|
302
|
-
# note here we don't import Distribution directly to allow setuptools to patch it
|
|
303
|
-
with warnings.catch_warnings(): # disable warning for PEP-632
|
|
304
|
-
warnings.simplefilter("ignore")
|
|
305
|
-
try:
|
|
306
|
-
# ruff:ignore[import-outside-top-level]
|
|
307
|
-
from distutils import dist # ty: ignore[unresolved-import]
|
|
308
|
-
|
|
309
|
-
# ruff:ignore[import-outside-top-level]
|
|
310
|
-
from distutils.command.install import SCHEME_KEYS # ty: ignore[unresolved-import]
|
|
311
|
-
except ImportError: # pragma: no cover # if removed or not installed ignore
|
|
312
|
-
return {}
|
|
313
|
-
|
|
314
|
-
distribution = dist.Distribution({
|
|
315
|
-
"script_args": "--no-user-cfg",
|
|
316
|
-
}) # conf files not parsed so they do not hijack paths
|
|
317
|
-
if hasattr(sys, "_framework"): # pragma: no cover # macOS framework builds only
|
|
318
|
-
sys._framework = None # ruff:ignore[private-member-access] # disable macOS static paths for framework
|
|
319
|
-
|
|
320
|
-
with warnings.catch_warnings(): # disable warning for PEP-632
|
|
321
|
-
warnings.simplefilter("ignore")
|
|
322
|
-
install = distribution.get_command_obj("install", create=True)
|
|
323
|
-
|
|
324
|
-
install.prefix = os.sep # paths generated are relative to prefix that contains the path sep
|
|
325
|
-
install.finalize_options()
|
|
326
|
-
return {key: (getattr(install, f"install_{key}")[1:]).lstrip(os.sep) for key in SCHEME_KEYS}
|
|
327
|
-
|
|
328
99
|
@property
|
|
329
100
|
def version_str(self) -> str:
|
|
330
101
|
"""The full version as ``major.minor.micro`` string (e.g. ``3.13.2``)."""
|
|
@@ -351,7 +122,7 @@ class PythonInfo: # ruff:ignore[too-many-public-methods]
|
|
|
351
122
|
"""``True`` if this interpreter runs inside a PEP 405 venv (has ``base_prefix``)."""
|
|
352
123
|
return self.base_prefix is not None
|
|
353
124
|
|
|
354
|
-
def sysconfig_path(self, key: str, config_var: dict[str, str] | None = None, sep: str = os.sep) -> str:
|
|
125
|
+
def sysconfig_path(self, key: str, config_var: dict[str, str | int | None] | None = None, sep: str = os.sep) -> str:
|
|
355
126
|
"""
|
|
356
127
|
Return the sysconfig install path for a scheme *key*, optionally substituting config variables.
|
|
357
128
|
|
|
@@ -568,7 +339,7 @@ class PythonInfo: # ruff:ignore[too-many-public-methods]
|
|
|
568
339
|
"""Serialize this interpreter information to a JSON string."""
|
|
569
340
|
return json.dumps(self.to_dict(), indent=2)
|
|
570
341
|
|
|
571
|
-
def to_dict(self) -> dict[str,
|
|
342
|
+
def to_dict(self) -> dict[str, InfoValue]:
|
|
572
343
|
"""Convert this interpreter information to a plain dictionary."""
|
|
573
344
|
data = {var: (getattr(self, var) if var != "_creators" else None) for var in vars(self)}
|
|
574
345
|
version_info = data["version_info"]
|
|
@@ -622,14 +393,14 @@ class PythonInfo: # ruff:ignore[too-many-public-methods]
|
|
|
622
393
|
return cls.from_dict(raw.copy())
|
|
623
394
|
|
|
624
395
|
@classmethod
|
|
625
|
-
def from_dict(cls, data: dict[str,
|
|
396
|
+
def from_dict(cls, data: dict[str, InfoValue]) -> PythonInfo:
|
|
626
397
|
"""
|
|
627
398
|
Reconstruct a :class:`PythonInfo` from a plain dictionary.
|
|
628
399
|
|
|
629
400
|
:param data: dictionary produced by :meth:`to_dict`.
|
|
630
401
|
"""
|
|
631
402
|
data["version_info"] = VersionInfo(**data["version_info"]) # restore this to a named tuple structure
|
|
632
|
-
result = cls()
|
|
403
|
+
result = cls.__new__(cls) # skip __init__, data replaces the full state
|
|
633
404
|
result.__dict__ = data.copy()
|
|
634
405
|
return result
|
|
635
406
|
|
|
@@ -851,27 +622,9 @@ def normalize_isa(isa: str) -> str:
|
|
|
851
622
|
}.get(low, low)
|
|
852
623
|
|
|
853
624
|
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
else:
|
|
861
|
-
start_cookie = ""
|
|
862
|
-
|
|
863
|
-
if len(argv) >= 1:
|
|
864
|
-
end_cookie = argv[0]
|
|
865
|
-
argv = argv[1:]
|
|
866
|
-
else:
|
|
867
|
-
end_cookie = ""
|
|
868
|
-
|
|
869
|
-
sys.argv = sys.argv[:1] + argv
|
|
870
|
-
|
|
871
|
-
result = PythonInfo().to_json()
|
|
872
|
-
sys.stdout.write("".join((start_cookie[::-1], result, end_cookie[::-1])))
|
|
873
|
-
sys.stdout.flush()
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
if __name__ == "__main__":
|
|
877
|
-
_main()
|
|
625
|
+
__all__ = [
|
|
626
|
+
"KNOWN_ARCHITECTURES",
|
|
627
|
+
"PythonInfo",
|
|
628
|
+
"VersionInfo",
|
|
629
|
+
"normalize_isa",
|
|
630
|
+
]
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Collect interpreter information, also run as the subprocess interrogation script (stdlib only).
|
|
3
|
+
|
|
4
|
+
Executed by the interpreter being probed. Collection supports Python 3.6+, but the file must parse on 2.7 so the
|
|
5
|
+
version gate below can report older interpreters instead of dying with a ``SyntaxError``: no f-strings, no walrus
|
|
6
|
+
operator, no annotations, no typing imports, no imports from this package. The gate sits above every other import
|
|
7
|
+
so that 3.0 and 3.1, which lack ``sysconfig``, still reach it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
MIN_INTERROGATE_VERSION = (3, 6)
|
|
13
|
+
UNSUPPORTED_EXIT_CODE = 79 # first value past the sysexits.h range, so no stdlib or shell meaning
|
|
14
|
+
UNSUPPORTED_MARKER = "unsupported Python version "
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__" and sys.version_info[:2] < MIN_INTERROGATE_VERSION: # pragma: no cover # needs <3.6
|
|
17
|
+
sys.stderr.write(UNSUPPORTED_MARKER + ".".join(str(i) for i in sys.version_info[:3]))
|
|
18
|
+
raise SystemExit(UNSUPPORTED_EXIT_CODE)
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import platform
|
|
23
|
+
import re
|
|
24
|
+
import struct
|
|
25
|
+
import sysconfig
|
|
26
|
+
import warnings
|
|
27
|
+
from collections import namedtuple
|
|
28
|
+
|
|
29
|
+
# ruff:ignore[collections-named-tuple] # typing.NamedTuple class syntax needs annotations, unavailable on 3.6
|
|
30
|
+
VersionInfo = namedtuple("VersionInfo", ["major", "minor", "micro", "releaselevel", "serial"])
|
|
31
|
+
|
|
32
|
+
_32BIT_POINTER_SIZE = 4
|
|
33
|
+
_CONF_VAR_RE = re.compile(
|
|
34
|
+
r"""
|
|
35
|
+
\{ \w+ } # sysconfig variable placeholder like {base}
|
|
36
|
+
""",
|
|
37
|
+
re.VERBOSE,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PythonInfoCollector:
|
|
42
|
+
"""Collects information about the currently running Python interpreter."""
|
|
43
|
+
|
|
44
|
+
def __init__(self):
|
|
45
|
+
self._init_identity()
|
|
46
|
+
self._init_prefixes()
|
|
47
|
+
self._init_schemes()
|
|
48
|
+
self._init_sysconfig()
|
|
49
|
+
|
|
50
|
+
def _init_identity(self):
|
|
51
|
+
self.platform = sys.platform
|
|
52
|
+
self.implementation = platform.python_implementation()
|
|
53
|
+
if self.implementation == "GraalVM":
|
|
54
|
+
self.implementation = "GraalPy"
|
|
55
|
+
if self.implementation == "PyPy":
|
|
56
|
+
self.pypy_version_info = tuple(sys.pypy_version_info) # ty: ignore[unresolved-attribute] # pypy only
|
|
57
|
+
|
|
58
|
+
self.version_info = VersionInfo(*sys.version_info)
|
|
59
|
+
# same as stdlib platform.architecture to account for pointer size != max int
|
|
60
|
+
self.architecture = 32 if struct.calcsize("P") == _32BIT_POINTER_SIZE else 64
|
|
61
|
+
self.sysconfig_platform = sysconfig.get_platform()
|
|
62
|
+
self.version_nodot = sysconfig.get_config_var("py_version_nodot")
|
|
63
|
+
self.version = sys.version
|
|
64
|
+
self.os = os.name
|
|
65
|
+
self.free_threaded = sysconfig.get_config_var("Py_GIL_DISABLED") == 1
|
|
66
|
+
self.debug_build = bool(sysconfig.get_config_var("Py_DEBUG"))
|
|
67
|
+
|
|
68
|
+
def _init_prefixes(self):
|
|
69
|
+
def abs_path(value):
|
|
70
|
+
return None if value is None else os.path.abspath(value)
|
|
71
|
+
|
|
72
|
+
self.prefix = abs_path(getattr(sys, "prefix", None))
|
|
73
|
+
self.base_prefix = abs_path(getattr(sys, "base_prefix", None))
|
|
74
|
+
self.real_prefix = abs_path(getattr(sys, "real_prefix", None))
|
|
75
|
+
self.base_exec_prefix = abs_path(getattr(sys, "base_exec_prefix", None))
|
|
76
|
+
self.exec_prefix = abs_path(getattr(sys, "exec_prefix", None))
|
|
77
|
+
|
|
78
|
+
self.executable = abs_path(sys.executable)
|
|
79
|
+
self.original_executable = abs_path(self.executable)
|
|
80
|
+
self.system_executable = self._fast_get_system_executable()
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
__import__("venv")
|
|
84
|
+
has = True
|
|
85
|
+
except ImportError: # pragma: no cover # venv is always available in standard CPython
|
|
86
|
+
has = False
|
|
87
|
+
self.has_venv = has
|
|
88
|
+
self.path = sys.path
|
|
89
|
+
self.file_system_encoding = sys.getfilesystemencoding()
|
|
90
|
+
self.stdout_encoding = getattr(sys.stdout, "encoding", None)
|
|
91
|
+
|
|
92
|
+
def _init_schemes(self):
|
|
93
|
+
scheme_names = sysconfig.get_scheme_names()
|
|
94
|
+
|
|
95
|
+
if "venv" in scheme_names: # pragma: >=3.11 cover
|
|
96
|
+
self.sysconfig_scheme = "venv"
|
|
97
|
+
self.sysconfig_paths = {
|
|
98
|
+
i: sysconfig.get_path(i, expand=False, scheme=self.sysconfig_scheme) for i in sysconfig.get_path_names()
|
|
99
|
+
}
|
|
100
|
+
self.distutils_install = {}
|
|
101
|
+
# debian / ubuntu python 3.10 without `python3-distutils` will report mangled `local/bin` / etc. names
|
|
102
|
+
elif sys.version_info[:2] == (3, 10) and "deb_system" in scheme_names: # pragma: no cover # Debian/Ubuntu 3.10
|
|
103
|
+
self.sysconfig_scheme = "posix_prefix"
|
|
104
|
+
self.sysconfig_paths = {
|
|
105
|
+
i: sysconfig.get_path(i, expand=False, scheme=self.sysconfig_scheme) for i in sysconfig.get_path_names()
|
|
106
|
+
}
|
|
107
|
+
self.distutils_install = {}
|
|
108
|
+
else: # pragma: no cover # "venv" scheme always present on Python 3.12+
|
|
109
|
+
self.sysconfig_scheme = None
|
|
110
|
+
self.sysconfig_paths = {i: sysconfig.get_path(i, expand=False) for i in sysconfig.get_path_names()}
|
|
111
|
+
self.distutils_install = self._distutils_install().copy()
|
|
112
|
+
|
|
113
|
+
def _init_sysconfig(self):
|
|
114
|
+
makefile = getattr(sysconfig, "get_makefile_filename", getattr(sysconfig, "_get_makefile_filename", None))
|
|
115
|
+
self.sysconfig = {
|
|
116
|
+
k: v
|
|
117
|
+
for k, v in [
|
|
118
|
+
("makefile_filename", makefile() if makefile is not None else None),
|
|
119
|
+
]
|
|
120
|
+
if k is not None
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
config_var_keys = set()
|
|
124
|
+
for element in self.sysconfig_paths.values():
|
|
125
|
+
config_var_keys.update(k[1:-1] for k in _CONF_VAR_RE.findall(element))
|
|
126
|
+
config_var_keys.add("PYTHONFRAMEWORK")
|
|
127
|
+
config_var_keys.update(("Py_ENABLE_SHARED", "INSTSONAME", "LIBDIR"))
|
|
128
|
+
|
|
129
|
+
self.sysconfig_vars = {i: sysconfig.get_config_var(i or "") for i in config_var_keys}
|
|
130
|
+
|
|
131
|
+
if "TCL_LIBRARY" in os.environ:
|
|
132
|
+
self.tcl_lib, self.tk_lib = self._get_tcl_tk_libs()
|
|
133
|
+
else:
|
|
134
|
+
self.tcl_lib, self.tk_lib = None, None
|
|
135
|
+
|
|
136
|
+
system_prefix = self.real_prefix or self.base_prefix or self.prefix
|
|
137
|
+
confs = {
|
|
138
|
+
k: (system_prefix if isinstance(v, str) and v.startswith(self.prefix) else v)
|
|
139
|
+
for k, v in self.sysconfig_vars.items()
|
|
140
|
+
}
|
|
141
|
+
self.system_stdlib = self._sysconfig_path("stdlib", confs)
|
|
142
|
+
self.system_stdlib_platform = self._sysconfig_path("platstdlib", confs)
|
|
143
|
+
self.max_size = getattr(sys, "maxsize", getattr(sys, "maxint", None))
|
|
144
|
+
self._creators = None # virtualenv-specific, set via monkey-patch
|
|
145
|
+
|
|
146
|
+
def _sysconfig_path(self, key, config_var):
|
|
147
|
+
pattern = self.sysconfig_paths.get(key)
|
|
148
|
+
if pattern is None: # custom builds may ship schemes without stdlib / platstdlib
|
|
149
|
+
return ""
|
|
150
|
+
base = self.sysconfig_vars.copy()
|
|
151
|
+
base.update(config_var)
|
|
152
|
+
return pattern.format(**base).replace("/", os.sep)
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def _get_tcl_tk_libs(): # 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 # ruff:ignore[import-outside-top-level] # novermin # unreached below the version gate
|
|
160
|
+
except ImportError:
|
|
161
|
+
pass
|
|
162
|
+
else:
|
|
163
|
+
try:
|
|
164
|
+
tcl = tk.Tcl()
|
|
165
|
+
tcl_lib = tcl.eval("info library")
|
|
166
|
+
tk_lib = PythonInfoCollector._resolve_tk_lib(tcl, tcl_lib)
|
|
167
|
+
except tk.TclError:
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
return tcl_lib, tk_lib
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _query_tk_library(tcl): # pragma: no cover
|
|
174
|
+
"""Try to get the TK library path directly from Tcl."""
|
|
175
|
+
import tkinter as tk # ruff:ignore[import-outside-top-level] # novermin # unreached below the version gate
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
tk_lib = tcl.eval("set tk_library")
|
|
179
|
+
if tk_lib and os.path.isdir(tk_lib):
|
|
180
|
+
return tk_lib
|
|
181
|
+
except tk.TclError:
|
|
182
|
+
pass
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def _resolve_tk_lib(tcl, tcl_lib): # pragma: no cover
|
|
187
|
+
"""Resolve the TK library path by direct query or path construction."""
|
|
188
|
+
tk_lib = PythonInfoCollector._query_tk_library(tcl)
|
|
189
|
+
if tk_lib is not None:
|
|
190
|
+
return tk_lib
|
|
191
|
+
tk_version = tcl.eval("package require Tk")
|
|
192
|
+
tcl_parent = os.path.dirname(tcl_lib)
|
|
193
|
+
for version in (tk_version, ".".join(tk_version.split(".")[:2]), tk_version.split(".")[0]):
|
|
194
|
+
tk_lib_path = os.path.join(tcl_parent, "tk{}".format(version))
|
|
195
|
+
if os.path.isdir(tk_lib_path) and os.path.exists(os.path.join(tk_lib_path, "tk.tcl")):
|
|
196
|
+
return tk_lib_path
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
def _fast_get_system_executable(self):
|
|
200
|
+
"""Try to get the system executable by just looking at properties."""
|
|
201
|
+
# Preserve the invoked path because shims may rely on it.
|
|
202
|
+
if not (self.real_prefix or (self.base_prefix is not None and self.base_prefix != self.prefix)):
|
|
203
|
+
return self._resolve_executable_symlink(self.original_executable)
|
|
204
|
+
|
|
205
|
+
if self.real_prefix is not None:
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
base_executable = getattr(sys, "_base_executable", None)
|
|
209
|
+
if base_executable is None:
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
if sys.executable == base_executable:
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
# CPython accepts pythonX without checking its version when resolving a copied venv.
|
|
216
|
+
if os.path.exists(base_executable): # pragma: >=3.11 cover
|
|
217
|
+
major = self.version_info.major
|
|
218
|
+
ambiguous_names = {"python", "python{}".format(major)}
|
|
219
|
+
if self.implementation == "PyPy":
|
|
220
|
+
ambiguous_names.update({"pypy", "pypy3", "pypy{}".format(major)})
|
|
221
|
+
if os.path.basename(base_executable) in ambiguous_names:
|
|
222
|
+
versioned = self._try_posix_versioned_executable(base_executable)
|
|
223
|
+
if versioned is not None and not os.path.samefile(base_executable, versioned):
|
|
224
|
+
base_executable = versioned
|
|
225
|
+
return self._resolve_executable_symlink(base_executable)
|
|
226
|
+
|
|
227
|
+
# Try fallback for POSIX virtual environments
|
|
228
|
+
return self._try_posix_fallback_executable(base_executable) # pragma: >=3.11 cover
|
|
229
|
+
|
|
230
|
+
def _resolve_executable_symlink(self, path):
|
|
231
|
+
"""
|
|
232
|
+
Resolve symlinks of the executable itself, but never of its parent directories.
|
|
233
|
+
|
|
234
|
+
Mirrors CPython's ``getpath.realpath`` (and ``venv`` in python/cpython#115237): an executable-only symlink
|
|
235
|
+
resolves to the real interpreter so its home can be located, while a fully symlinked interpreter tree is
|
|
236
|
+
kept as-is. Like ``getpath``, resolution stops as soon as the stdlib landmark is reachable from the current
|
|
237
|
+
directory - an alias such as Debian's ``/usr/bin/python3`` is a usable home and stays untouched.
|
|
238
|
+
"""
|
|
239
|
+
result = os.path.abspath(path)
|
|
240
|
+
if self.os != "posix": # pragma: win32 cover # CPython only does this where HAVE_READLINK
|
|
241
|
+
return result
|
|
242
|
+
if sysconfig.get_config_var("PYTHONFRAMEWORK"): # macOS framework builds self-locate via dyld; e.g. Homebrew
|
|
243
|
+
return result # resolving would pin the versioned Cellar path into the recorded home
|
|
244
|
+
real_path = os.path.realpath(result)
|
|
245
|
+
if not os.path.exists(real_path): # symlink loop or broken symlink
|
|
246
|
+
return result
|
|
247
|
+
while os.path.islink(result):
|
|
248
|
+
if self._stdlib_landmark_exists(os.path.dirname(result)):
|
|
249
|
+
return result
|
|
250
|
+
link = os.readlink(result)
|
|
251
|
+
candidate = link if os.path.isabs(link) else os.path.normpath(os.path.join(os.path.dirname(result), link))
|
|
252
|
+
# normpath through a symlinked directory may point at a different file - stop resolving there
|
|
253
|
+
if not (os.path.exists(candidate) and os.path.samefile(real_path, candidate)):
|
|
254
|
+
return result
|
|
255
|
+
result = candidate
|
|
256
|
+
return result
|
|
257
|
+
|
|
258
|
+
@staticmethod
|
|
259
|
+
def _stdlib_landmark_exists(dir_path):
|
|
260
|
+
lib_name = os.path.basename(os.path.dirname(os.__file__))
|
|
261
|
+
return any(
|
|
262
|
+
os.path.exists(os.path.join(dir_path, os.pardir, lib, lib_name, "os.py")) for lib in ("lib", "lib64")
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def _try_posix_fallback_executable(self, base_executable):
|
|
266
|
+
"""Find a versioned Python binary as fallback for POSIX virtual environments."""
|
|
267
|
+
versioned = self._try_posix_versioned_executable(base_executable)
|
|
268
|
+
if versioned is not None:
|
|
269
|
+
return versioned
|
|
270
|
+
|
|
271
|
+
major, minor = self.version_info.major, self.version_info.minor
|
|
272
|
+
if self.os != "posix" or (major, minor) < (3, 11):
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
base_dir = os.path.dirname(base_executable)
|
|
276
|
+
candidates = ["python{}".format(major)]
|
|
277
|
+
if self.implementation == "PyPy":
|
|
278
|
+
candidates.extend(["pypy", "pypy3", "pypy{}".format(major)])
|
|
279
|
+
|
|
280
|
+
for candidate in candidates:
|
|
281
|
+
full_path = os.path.join(base_dir, candidate)
|
|
282
|
+
if os.path.exists(full_path):
|
|
283
|
+
return full_path
|
|
284
|
+
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
def _try_posix_versioned_executable(self, base_executable):
|
|
288
|
+
major, minor = self.version_info.major, self.version_info.minor
|
|
289
|
+
if self.os != "posix" or (major, minor) < (3, 11):
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
base_dir = os.path.dirname(base_executable)
|
|
293
|
+
candidates = ["python{}.{}".format(major, minor)]
|
|
294
|
+
if self.implementation == "PyPy":
|
|
295
|
+
candidates.insert(0, "pypy{}.{}".format(major, minor))
|
|
296
|
+
|
|
297
|
+
for candidate in candidates:
|
|
298
|
+
full_path = os.path.join(base_dir, candidate)
|
|
299
|
+
if os.path.exists(full_path):
|
|
300
|
+
return full_path
|
|
301
|
+
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
@staticmethod
|
|
305
|
+
def _distutils_install(): # pragma: <3.11 cover # 3.11+ uses the "venv" scheme instead
|
|
306
|
+
# use distutils primarily because that's what pip does
|
|
307
|
+
# https://github.com/pypa/pip/blob/main/src/pip/_internal/locations.py#L95
|
|
308
|
+
# Avoid importing Distribution before setuptools can patch it.
|
|
309
|
+
with warnings.catch_warnings(): # disable warning for PEP-632
|
|
310
|
+
warnings.simplefilter("ignore")
|
|
311
|
+
try:
|
|
312
|
+
# ruff:ignore[import-outside-top-level]
|
|
313
|
+
from distutils import dist # ty: ignore[unresolved-import]
|
|
314
|
+
|
|
315
|
+
# ruff:ignore[import-outside-top-level]
|
|
316
|
+
from distutils.command.install import SCHEME_KEYS # ty: ignore[unresolved-import]
|
|
317
|
+
except ImportError: # pragma: no cover # if removed or not installed ignore
|
|
318
|
+
return {}
|
|
319
|
+
|
|
320
|
+
distribution = dist.Distribution({
|
|
321
|
+
"script_args": "--no-user-cfg",
|
|
322
|
+
}) # conf files not parsed so they do not hijack paths
|
|
323
|
+
if hasattr(sys, "_framework"): # pragma: no cover # macOS framework builds only
|
|
324
|
+
sys._framework = None # ruff:ignore[private-member-access] # disable macOS static paths for framework
|
|
325
|
+
|
|
326
|
+
with warnings.catch_warnings(): # disable warning for PEP-632
|
|
327
|
+
warnings.simplefilter("ignore")
|
|
328
|
+
install = distribution.get_command_obj("install", create=True)
|
|
329
|
+
|
|
330
|
+
install.prefix = os.sep # paths generated are relative to prefix that contains the path sep
|
|
331
|
+
install.finalize_options()
|
|
332
|
+
return {key: (getattr(install, "install_{}".format(key))[1:]).lstrip(os.sep) for key in SCHEME_KEYS}
|
|
333
|
+
|
|
334
|
+
def to_dict(self):
|
|
335
|
+
"""Convert the collected information to a plain dictionary."""
|
|
336
|
+
data = {var: (getattr(self, var) if var != "_creators" else None) for var in vars(self)}
|
|
337
|
+
data["version_info"] = self.version_info._asdict()
|
|
338
|
+
return data
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _main(): # pragma: no cover # exercised via subprocess runs
|
|
342
|
+
argv = sys.argv[1:]
|
|
343
|
+
|
|
344
|
+
if len(argv) >= 1:
|
|
345
|
+
start_cookie = argv[0]
|
|
346
|
+
argv = argv[1:]
|
|
347
|
+
else:
|
|
348
|
+
start_cookie = ""
|
|
349
|
+
|
|
350
|
+
if len(argv) >= 1:
|
|
351
|
+
end_cookie = argv[0]
|
|
352
|
+
argv = argv[1:]
|
|
353
|
+
else:
|
|
354
|
+
end_cookie = ""
|
|
355
|
+
|
|
356
|
+
sys.argv = sys.argv[:1] + argv
|
|
357
|
+
|
|
358
|
+
result = json.dumps(PythonInfoCollector().to_dict(), indent=2)
|
|
359
|
+
sys.stdout.write("".join((start_cookie[::-1], result, end_cookie[::-1])))
|
|
360
|
+
sys.stdout.flush()
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
__all__ = [
|
|
364
|
+
"MIN_INTERROGATE_VERSION",
|
|
365
|
+
"UNSUPPORTED_MARKER",
|
|
366
|
+
"PythonInfoCollector",
|
|
367
|
+
]
|
|
368
|
+
|
|
369
|
+
if __name__ == "__main__":
|
|
370
|
+
_main()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: python-discovery
|
|
3
|
-
Version: 1.5.
|
|
3
|
+
Version: 1.5.3
|
|
4
4
|
Summary: Python interpreter discovery
|
|
5
5
|
Project-URL: Changelog, https://github.com/tox-dev/python-discovery/releases
|
|
6
6
|
Project-URL: Documentation, https://python-discovery.readthedocs.io
|
|
@@ -68,6 +68,9 @@ the right one for you.
|
|
|
68
68
|
Give it a requirement like `python3.12` or `>=3.11,<3.13`, and it searches all known locations, verifies each candidate,
|
|
69
69
|
and returns detailed metadata about the match. Results are cached to disk so repeated lookups are fast.
|
|
70
70
|
|
|
71
|
+
The library runs on Python 3.8+ and discovers interpreters as old as Python 3.6; anything older is skipped with a
|
|
72
|
+
warning naming the version it found.
|
|
73
|
+
|
|
71
74
|
## Usage
|
|
72
75
|
|
|
73
76
|
```python
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
python_discovery/__init__.py,sha256=Hi4j3pMvC8Fo8KoNL-wOHsPSCUHwY4c0qvv5sjnFzME,808
|
|
2
2
|
python_discovery/_cache.py,sha256=svmG2GzUQzd2xTjAbm2eX1CKxxGAglfv4kxaPuxwPIA,5147
|
|
3
|
-
python_discovery/_cached_py_info.py,sha256=
|
|
3
|
+
python_discovery/_cached_py_info.py,sha256=MKV2sjrrypq-7gcOdw9GpAooMJl9kYCTAlYeUwO3ndQ,10473
|
|
4
4
|
python_discovery/_compat.py,sha256=iqxGuhK9rPaiui80G1H1KrweoMJ16gyLYEmUrSXrp5c,709
|
|
5
5
|
python_discovery/_discovery.py,sha256=ABPD8MRweUO6B7cG3U3ZBfUIYs08rh6dYPA225EURyY,22032
|
|
6
|
-
python_discovery/_py_info.py,sha256=
|
|
6
|
+
python_discovery/_py_info.py,sha256=XX4yM6TVhTMFfmahNMekT7Y3Zy12Zy0IAfmz_xFRceU,25284
|
|
7
|
+
python_discovery/_py_info_collect.py,sha256=YhF5U-kKcwMXZMfRNDlZK6kCPJJI9tHIvd5SjQqMJqY,16126
|
|
7
8
|
python_discovery/_py_spec.py,sha256=O1RMC9JQ7m5SRzF6cEELxnIWZpadXYWO4bWDD7TemUs,10965
|
|
8
9
|
python_discovery/_specifier.py,sha256=o7MHb0z_mFSLz9gXqMwJoNkHYjgi2biq3Ibvd0_slvc,10457
|
|
9
10
|
python_discovery/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
11
|
python_discovery/_windows/__init__.py,sha256=qthcyGh36b3WkeSbZ4ECRRYLbCOCaQu5X_DRZ98tXfk,315
|
|
11
12
|
python_discovery/_windows/_pep514.py,sha256=1TqLTdofSCPu3VFE7D4u2fXweyaqhKJR7b48VmJ9AQQ,7787
|
|
12
13
|
python_discovery/_windows/_propose.py,sha256=iMJf6l0TU4SvuV8UsCHHINAe9L5dUT949xyeyMVdong,1565
|
|
13
|
-
python_discovery-1.5.
|
|
14
|
-
python_discovery-1.5.
|
|
15
|
-
python_discovery-1.5.
|
|
16
|
-
python_discovery-1.5.
|
|
14
|
+
python_discovery-1.5.3.dist-info/METADATA,sha256=RcMFPQt1KzopnhBQlUIjMC18xeM-te8dXAsUwKI6D2Y,5132
|
|
15
|
+
python_discovery-1.5.3.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
16
|
+
python_discovery-1.5.3.dist-info/licenses/LICENSE,sha256=kOJeH68qSq6o2V7o5_VziwUqGswMnFgGgQH5Mahr1Yg,1023
|
|
17
|
+
python_discovery-1.5.3.dist-info/RECORD,,
|
|
File without changes
|