_py_info.py 32 KB

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