python-discovery 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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()