python-discovery 1.5.0__py3-none-any.whl → 1.5.2__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.
@@ -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 = hashlib.sha256(py_info_script.read_bytes()).hexdigest()
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
- if py_info_store.exists() and (data := py_info_store.read()) is not None:
96
- of_path, of_st_mtime = data.get("path"), data.get("st_mtime")
97
- of_content, of_hash = data.get("content"), data.get("hash")
98
- if (
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
- py_info_script = Path(Path(__file__).resolve()).parent / "_py_info.py"
153
- if py_info_script.is_file():
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__, "_py_info.py")
181
+ data = pkgutil.get_data(__package__ or __name__, _PY_INFO_SCRIPT.name)
157
182
  if data is None:
158
- msg = "cannot locate _py_info.py for subprocess interrogation"
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
- msg = f"{exe} with code {code}{f' out: {out!r}' if out else ''}{f' err: {err!r}' if err else ''}"
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
@@ -4,14 +4,14 @@ import logging
4
4
  import os
5
5
  import sys
6
6
  from contextlib import suppress
7
+ from dataclasses import dataclass
7
8
  from pathlib import Path
8
9
  from typing import TYPE_CHECKING, Final
9
10
 
10
- from platformdirs import user_data_path
11
-
12
11
  from ._compat import fs_path_id
13
12
  from ._py_info import PythonInfo
14
13
  from ._py_spec import PythonSpec
14
+ from ._specifier import DC_KW, SimpleVersion
15
15
 
16
16
  if TYPE_CHECKING:
17
17
  from collections.abc import Callable, Generator, Iterable, Iterator, Mapping, Sequence
@@ -199,7 +199,7 @@ def propose_interpreters(
199
199
  if spec.path is not None and spec.is_abs: # pragma: no cover # relative spec.path is never abs
200
200
  return
201
201
  yield from _propose_from_path(spec, cache, env, tested_exes, all_implementations=all_implementations)
202
- yield from _propose_from_uv(cache, env, all_implementations=all_implementations)
202
+ yield from _propose_from_uv(spec, cache, env, all_implementations=all_implementations)
203
203
 
204
204
 
205
205
  def _propose_explicit(
@@ -262,30 +262,157 @@ def _propose_from_path(
262
262
 
263
263
 
264
264
  def _propose_from_uv(
265
+ spec: PythonSpec,
265
266
  cache: PyInfoCache | None,
266
267
  env: Mapping[str, str],
267
268
  *,
268
269
  all_implementations: bool = False,
269
270
  ) -> Generator[tuple[PythonInfo | None, bool], None, None]:
270
- if uv_python_dir := os.getenv("UV_PYTHON_INSTALL_DIR"):
271
- uv_python_path = Path(uv_python_dir).expanduser()
272
- elif xdg_data_home := os.getenv("XDG_DATA_HOME"):
273
- uv_python_path = Path(xdg_data_home).expanduser() / "uv" / "python"
271
+ seen_uv_paths: set[str] = set()
272
+ for root in _uv_python_roots(env):
273
+ for install in _uv_installs(root, spec, all_implementations=all_implementations):
274
+ for exe_path in _uv_executables(install):
275
+ if (resolved := str(exe_path.resolve())) in seen_uv_paths:
276
+ continue
277
+ seen_uv_paths.add(resolved)
278
+ if interpreter := PathPythonInfo.from_exe(str(exe_path), cache, raise_on_error=False, env=env):
279
+ yield interpreter, True
280
+ break # one install directory holds one interpreter
281
+
282
+
283
+ def _uv_python_roots(env: Mapping[str, str]) -> Generator[Path, None, None]:
284
+ """Yield uv's store roots, legacy first; searching both keeps a stale legacy directory from hiding the live one."""
285
+ if install_dir := env.get("UV_PYTHON_INSTALL_DIR"):
286
+ yield Path(install_dir).expanduser()
287
+ return
288
+ for state_dir in _uv_state_dirs(env):
289
+ yield state_dir / "python"
290
+
291
+
292
+ def _uv_state_dirs(env: Mapping[str, str]) -> Generator[Path, None, None]:
293
+ """Yield uv's legacy state directory then its current one, the order uv prefers them in."""
294
+ windows = sys.platform == "win32"
295
+ home = Path(env.get("USERPROFILE" if windows else "HOME") or "~").expanduser()
296
+ if windows:
297
+ app_data = Path(env["APPDATA"]) if "APPDATA" in env else home / "AppData" / "Roaming"
298
+ yield app_data / "uv" / "data"
299
+ yield app_data / "uv"
300
+ return
301
+ if sys.platform == "darwin":
302
+ yield home / "Library" / "Application Support" / "uv"
303
+ # uv ignores a relative XDG_DATA_HOME, as the XDG specification requires
304
+ if (xdg_data_home := env.get("XDG_DATA_HOME")) and (xdg := Path(xdg_data_home)).is_absolute():
305
+ yield xdg / "uv"
274
306
  else:
275
- uv_python_path = user_data_path("uv") / "python"
307
+ yield home / ".local" / "share" / "uv"
276
308
 
277
- patterns: list[str] = ["*/bin/python", "*/python.exe"]
278
- if all_implementations:
279
- patterns.extend(("*/bin/pypy*", "*/bin/graalpy", "*/pypy*.exe", "*/bin/graalpy.exe"))
280
- seen_uv_paths: set[str] = set()
281
- for pattern in patterns:
282
- for exe_path in uv_python_path.glob(pattern):
283
- resolved = str(Path(exe_path).resolve())
284
- if resolved in seen_uv_paths:
285
- continue
286
- seen_uv_paths.add(resolved)
287
- if interpreter := PathPythonInfo.from_exe(str(exe_path), cache, raise_on_error=False, env=env):
288
- yield interpreter, True
309
+
310
+ def _uv_installs(root: Path, spec: PythonSpec, *, all_implementations: bool) -> list[_UvInstall]:
311
+ try:
312
+ matching = [
313
+ install
314
+ for entry in sorted(root.iterdir())
315
+ if not entry.name.startswith(".")
316
+ and entry.is_dir()
317
+ and _uv_install_matches(install := _parse_uv_install(entry), spec, all_implementations=all_implementations)
318
+ ]
319
+ except OSError:
320
+ return []
321
+ matching.sort(key=_uv_sort_key) # stable, so equally ranked installs stay in directory name order
322
+ return matching
323
+
324
+
325
+ def _parse_uv_install(path: Path) -> _UvInstall:
326
+ implementation, _, remainder = path.name.partition("-")
327
+ version_str, _, variant = remainder.partition("-")[0].partition("+")
328
+ try:
329
+ version = SimpleVersion.from_string(version_str)
330
+ except ValueError: # not a uv key, but the directory may still hold an interpreter worth probing
331
+ return _UvInstall(path=path, key=None)
332
+ variants = variant.split("+")
333
+ key = _UvKey(
334
+ implementation=implementation.lower(),
335
+ version=version,
336
+ free_threaded="freethreaded" in variants,
337
+ debug="debug" in variants,
338
+ )
339
+ return _UvInstall(path=path, key=key)
340
+
341
+
342
+ @dataclass(**DC_KW)
343
+ class _UvKey:
344
+ """The ``<implementation>-<version>[+<variant>]-<os>-<arch>-<libc>`` name uv gives an install directory."""
345
+
346
+ implementation: str
347
+ version: SimpleVersion
348
+ free_threaded: bool
349
+ debug: bool
350
+
351
+
352
+ @dataclass(**DC_KW)
353
+ class _UvInstall:
354
+ path: Path
355
+ key: _UvKey | None
356
+
357
+
358
+ def _uv_install_matches(install: _UvInstall, spec: PythonSpec, *, all_implementations: bool) -> bool:
359
+ if (key := install.key) is None:
360
+ return True
361
+ if spec.implementation is not None:
362
+ if spec.implementation.lower() != key.implementation:
363
+ return False
364
+ elif not all_implementations and key.implementation != "cpython":
365
+ # uv resolves a bare `3.8` to `cpython-3.8`; on PATH the equivalent probe finds CPython too
366
+ return False
367
+ if spec.free_threaded is not None and spec.free_threaded != key.free_threaded:
368
+ return False
369
+ if spec.debug is not None and spec.debug != key.debug:
370
+ return False
371
+ return _uv_version_matches(key, spec)
372
+
373
+
374
+ def _uv_version_matches(key: _UvKey, spec: PythonSpec) -> bool:
375
+ # GraalPy keys carry the GraalVM version in older uv releases, so only CPython and PyPy state a Python version
376
+ if key.implementation not in {"cpython", "pypy"}:
377
+ return True
378
+ if spec.version_specifier is not None and not spec.version_specifier.contains(str(key.version)):
379
+ return False
380
+ return all(req is None or our == req for our, req in zip(key.version.release, (spec.major, spec.minor, spec.micro)))
381
+
382
+
383
+ def _uv_sort_key(install: _UvInstall) -> tuple[int, ...]:
384
+ """Rank like uv does: CPython first, newest version first, default build ahead of free-threaded and debug."""
385
+ impl_order: Final[tuple[str, ...]] = ("cpython", "pypy", "graalpy", "pyodide")
386
+ if (key := install.key) is None:
387
+ return (len(impl_order) + 1,)
388
+ version = key.version
389
+ return (
390
+ impl_order.index(key.implementation) if key.implementation in impl_order else len(impl_order),
391
+ # uv's minor version links point at a concrete install that moves on upgrade, so report the target instead
392
+ int(install.path.is_symlink()),
393
+ -version.major,
394
+ -version.minor,
395
+ -version.micro,
396
+ -{"a": 0, "b": 1, "rc": 2, None: 3}[version.pre_type],
397
+ -(version.pre_num or 0),
398
+ int(key.free_threaded),
399
+ int(key.debug),
400
+ )
401
+
402
+
403
+ def _uv_executables(install: _UvInstall) -> Generator[Path, None, None]:
404
+ # some distributions unpack into an `install` subdirectory, which uv resolves the same way
405
+ base = nested if (nested := install.path / "install").is_dir() else install.path
406
+ for pattern in (
407
+ "bin/python",
408
+ "python.exe",
409
+ "bin/pypy*",
410
+ "pypy*.exe",
411
+ "bin/graalpy",
412
+ "bin/graalpy.exe",
413
+ "python*t.exe", # free-threaded Windows builds that ship no python.exe (astral-sh/uv#8298)
414
+ ):
415
+ yield from sorted(base.glob(pattern))
289
416
 
290
417
 
291
418
  def get_paths(env: Mapping[str, str]) -> Generator[Path, None, None]:
@@ -1,4 +1,4 @@
1
- """Concrete Python interpreter information, also used as subprocess interrogation script (stdlib only)."""
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
- def __init__(self) -> None:
56
- self._init_identity()
57
- self._init_prefixes()
58
- self._init_schemes()
59
- self._init_sysconfig()
60
-
61
- def _init_identity(self) -> None:
62
- self.platform = sys.platform
63
- self.implementation = platform.python_implementation()
64
- if self.implementation == "GraalVM":
65
- self.implementation = "GraalPy"
66
- if self.implementation == "PyPy":
67
- self.pypy_version_info = tuple(sys.pypy_version_info) # ty: ignore[unresolved-attribute] # pypy only
68
-
69
- self.version_info = VersionInfo(*sys.version_info)
70
- # same as stdlib platform.architecture to account for pointer size != max int
71
- self.architecture = 32 if struct.calcsize("P") == _32BIT_POINTER_SIZE else 64
72
- self.sysconfig_platform = sysconfig.get_platform()
73
- self.version_nodot = sysconfig.get_config_var("py_version_nodot")
74
- self.version = sys.version
75
- self.os = os.name
76
- self.free_threaded = sysconfig.get_config_var("Py_GIL_DISABLED") == 1
77
- self.debug_build = bool(sysconfig.get_config_var("Py_DEBUG"))
78
-
79
- def _init_prefixes(self) -> None:
80
- def abs_path(value: str | None) -> str | None:
81
- return None if value is None else os.path.abspath(value)
82
-
83
- self.prefix = abs_path(getattr(sys, "prefix", None))
84
- self.base_prefix = abs_path(getattr(sys, "base_prefix", None))
85
- self.real_prefix = abs_path(getattr(sys, "real_prefix", None))
86
- self.base_exec_prefix = abs_path(getattr(sys, "base_exec_prefix", None))
87
- self.exec_prefix = abs_path(getattr(sys, "exec_prefix", None))
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
- # Try fallback for POSIX virtual environments
226
- return self._try_posix_fallback_executable(base_executable) # pragma: >=3.11 cover
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, object]:
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, object]) -> PythonInfo:
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
- def _main() -> None: # pragma: no cover
855
- argv = sys.argv[1:]
856
-
857
- if len(argv) >= 1:
858
- start_cookie = argv[0]
859
- argv = argv[1:]
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,345 @@
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
+ # if we're not in a virtual environment, this is already a system python, so return the original executable
202
+ # note we must choose the original and not the pure executable as shim scripts might throw us off
203
+ if not (self.real_prefix or (self.base_prefix is not None and self.base_prefix != self.prefix)):
204
+ return self._resolve_executable_symlink(self.original_executable)
205
+
206
+ # if this is NOT a virtual environment, can't determine easily, bail out
207
+ if self.real_prefix is not None:
208
+ return None
209
+
210
+ base_executable = getattr(sys, "_base_executable", None) # some platforms may set this to help us
211
+ if base_executable is None: # use the saved system executable if present
212
+ return None
213
+
214
+ # we know we're in a virtual environment, can not be us
215
+ if sys.executable == base_executable:
216
+ return None
217
+
218
+ # We're not in a venv and base_executable exists; use it directly
219
+ if os.path.exists(base_executable): # pragma: >=3.11 cover
220
+ return self._resolve_executable_symlink(base_executable)
221
+
222
+ # Try fallback for POSIX virtual environments
223
+ return self._try_posix_fallback_executable(base_executable) # pragma: >=3.11 cover
224
+
225
+ def _resolve_executable_symlink(self, path):
226
+ """
227
+ Resolve symlinks of the executable itself, but never of its parent directories.
228
+
229
+ Mirrors CPython's ``getpath.realpath`` (and ``venv`` in python/cpython#115237): an executable-only symlink
230
+ resolves to the real interpreter so its home can be located, while a fully symlinked interpreter tree is
231
+ kept as-is. Like ``getpath``, resolution stops as soon as the stdlib landmark is reachable from the current
232
+ directory - an alias such as Debian's ``/usr/bin/python3`` is a usable home and stays untouched.
233
+ """
234
+ result = os.path.abspath(path)
235
+ if self.os != "posix": # pragma: win32 cover # CPython only does this where HAVE_READLINK
236
+ return result
237
+ if sysconfig.get_config_var("PYTHONFRAMEWORK"): # macOS framework builds self-locate via dyld; e.g. Homebrew
238
+ return result # resolving would pin the versioned Cellar path into the recorded home
239
+ real_path = os.path.realpath(result)
240
+ if not os.path.exists(real_path): # symlink loop or broken symlink
241
+ return result
242
+ while os.path.islink(result):
243
+ if self._stdlib_landmark_exists(os.path.dirname(result)):
244
+ return result
245
+ link = os.readlink(result)
246
+ candidate = link if os.path.isabs(link) else os.path.normpath(os.path.join(os.path.dirname(result), link))
247
+ # normpath through a symlinked directory may point at a different file - stop resolving there
248
+ if not (os.path.exists(candidate) and os.path.samefile(real_path, candidate)):
249
+ return result
250
+ result = candidate
251
+ return result
252
+
253
+ @staticmethod
254
+ def _stdlib_landmark_exists(dir_path):
255
+ lib_name = os.path.basename(os.path.dirname(os.__file__))
256
+ return any(
257
+ os.path.exists(os.path.join(dir_path, os.pardir, lib, lib_name, "os.py")) for lib in ("lib", "lib64")
258
+ )
259
+
260
+ def _try_posix_fallback_executable(self, base_executable):
261
+ """Find a versioned Python binary as fallback for POSIX virtual environments."""
262
+ major, minor = self.version_info.major, self.version_info.minor
263
+ if self.os != "posix" or (major, minor) < (3, 11):
264
+ return None
265
+
266
+ # search relative to the directory of sys._base_executable
267
+ base_dir = os.path.dirname(base_executable)
268
+ candidates = ["python{}".format(major), "python{}.{}".format(major, minor)]
269
+ if self.implementation == "PyPy":
270
+ candidates.extend(["pypy", "pypy3", "pypy{}".format(major), "pypy{}.{}".format(major, minor)])
271
+
272
+ for candidate in candidates:
273
+ full_path = os.path.join(base_dir, candidate)
274
+ if os.path.exists(full_path):
275
+ return full_path
276
+
277
+ return None # in this case we just can't tell easily without poking around FS and calling them, bail
278
+
279
+ @staticmethod
280
+ def _distutils_install(): # pragma: <3.11 cover # 3.11+ uses the "venv" scheme instead
281
+ # use distutils primarily because that's what pip does
282
+ # https://github.com/pypa/pip/blob/main/src/pip/_internal/locations.py#L95
283
+ # note here we don't import Distribution directly to allow setuptools to patch it
284
+ with warnings.catch_warnings(): # disable warning for PEP-632
285
+ warnings.simplefilter("ignore")
286
+ try:
287
+ # ruff:ignore[import-outside-top-level]
288
+ from distutils import dist # ty: ignore[unresolved-import]
289
+
290
+ # ruff:ignore[import-outside-top-level]
291
+ from distutils.command.install import SCHEME_KEYS # ty: ignore[unresolved-import]
292
+ except ImportError: # pragma: no cover # if removed or not installed ignore
293
+ return {}
294
+
295
+ distribution = dist.Distribution({
296
+ "script_args": "--no-user-cfg",
297
+ }) # conf files not parsed so they do not hijack paths
298
+ if hasattr(sys, "_framework"): # pragma: no cover # macOS framework builds only
299
+ sys._framework = None # ruff:ignore[private-member-access] # disable macOS static paths for framework
300
+
301
+ with warnings.catch_warnings(): # disable warning for PEP-632
302
+ warnings.simplefilter("ignore")
303
+ install = distribution.get_command_obj("install", create=True)
304
+
305
+ install.prefix = os.sep # paths generated are relative to prefix that contains the path sep
306
+ install.finalize_options()
307
+ return {key: (getattr(install, "install_{}".format(key))[1:]).lstrip(os.sep) for key in SCHEME_KEYS}
308
+
309
+ def to_dict(self):
310
+ """Convert the collected information to a plain dictionary."""
311
+ data = {var: (getattr(self, var) if var != "_creators" else None) for var in vars(self)}
312
+ data["version_info"] = self.version_info._asdict()
313
+ return data
314
+
315
+
316
+ def _main(): # pragma: no cover # exercised via subprocess runs
317
+ argv = sys.argv[1:]
318
+
319
+ if len(argv) >= 1:
320
+ start_cookie = argv[0]
321
+ argv = argv[1:]
322
+ else:
323
+ start_cookie = ""
324
+
325
+ if len(argv) >= 1:
326
+ end_cookie = argv[0]
327
+ argv = argv[1:]
328
+ else:
329
+ end_cookie = ""
330
+
331
+ sys.argv = sys.argv[:1] + argv
332
+
333
+ result = json.dumps(PythonInfoCollector().to_dict(), indent=2)
334
+ sys.stdout.write("".join((start_cookie[::-1], result, end_cookie[::-1])))
335
+ sys.stdout.flush()
336
+
337
+
338
+ __all__ = [
339
+ "MIN_INTERROGATE_VERSION",
340
+ "UNSUPPORTED_MARKER",
341
+ "PythonInfoCollector",
342
+ ]
343
+
344
+ if __name__ == "__main__":
345
+ _main()
@@ -9,7 +9,9 @@ import sys
9
9
  from dataclasses import dataclass
10
10
  from typing import TYPE_CHECKING, Final
11
11
 
12
- _DC_KW = {"frozen": True, "kw_only": True, "slots": True} if sys.version_info >= (3, 10) else {"frozen": True}
12
+ DC_KW: Final[dict[str, bool]] = (
13
+ {"frozen": True, "kw_only": True, "slots": True} if sys.version_info >= (3, 10) else {"frozen": True}
14
+ )
13
15
 
14
16
  if TYPE_CHECKING:
15
17
  from collections.abc import Iterator
@@ -38,7 +40,7 @@ _SPECIFIER_RE: Final[re.Pattern[str]] = re.compile(
38
40
  _PRE_ORDER: Final[dict[str, int]] = {"a": 1, "b": 2, "rc": 3}
39
41
 
40
42
 
41
- @dataclass(**_DC_KW)
43
+ @dataclass(**DC_KW)
42
44
  class SimpleVersion:
43
45
  """
44
46
  Simple PEP 440-like version parser using only standard library.
@@ -125,7 +127,7 @@ class SimpleVersion:
125
127
  return f"SimpleVersion('{self.version_str}')"
126
128
 
127
129
 
128
- @dataclass(**_DC_KW)
130
+ @dataclass(**DC_KW)
129
131
  class SimpleSpecifier:
130
132
  """
131
133
  Simple PEP 440-like version specifier using only standard library.
@@ -247,7 +249,7 @@ class SimpleSpecifier:
247
249
  return f"SimpleSpecifier('{self.spec_str}')"
248
250
 
249
251
 
250
- @dataclass(**_DC_KW)
252
+ @dataclass(**DC_KW)
251
253
  class SimpleSpecifierSet:
252
254
  """
253
255
  Simple PEP 440-like specifier set using only standard library.
@@ -305,6 +307,7 @@ class SimpleSpecifierSet:
305
307
 
306
308
 
307
309
  __all__ = [
310
+ "DC_KW",
308
311
  "SimpleSpecifier",
309
312
  "SimpleSpecifierSet",
310
313
  "SimpleVersion",
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: python-discovery
3
- Version: 1.5.0
3
+ Version: 1.5.2
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
@@ -48,7 +48,6 @@ Classifier: Topic :: Software Development :: Libraries
48
48
  Classifier: Topic :: Utilities
49
49
  Requires-Python: >=3.8
50
50
  Requires-Dist: filelock>=3.15.4
51
- Requires-Dist: platformdirs<5,>=4.3.6
52
51
  Description-Content-Type: text/markdown
53
52
 
54
53
  # [`python-discovery`](https://python-discovery.readthedocs.io/en/latest/)
@@ -69,6 +68,9 @@ the right one for you.
69
68
  Give it a requirement like `python3.12` or `>=3.11,<3.13`, and it searches all known locations, verifies each candidate,
70
69
  and returns detailed metadata about the match. Results are cached to disk so repeated lookups are fast.
71
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
+
72
74
  ## Usage
73
75
 
74
76
  ```python
@@ -0,0 +1,17 @@
1
+ python_discovery/__init__.py,sha256=Hi4j3pMvC8Fo8KoNL-wOHsPSCUHwY4c0qvv5sjnFzME,808
2
+ python_discovery/_cache.py,sha256=svmG2GzUQzd2xTjAbm2eX1CKxxGAglfv4kxaPuxwPIA,5147
3
+ python_discovery/_cached_py_info.py,sha256=MKV2sjrrypq-7gcOdw9GpAooMJl9kYCTAlYeUwO3ndQ,10473
4
+ python_discovery/_compat.py,sha256=iqxGuhK9rPaiui80G1H1KrweoMJ16gyLYEmUrSXrp5c,709
5
+ python_discovery/_discovery.py,sha256=ABPD8MRweUO6B7cG3U3ZBfUIYs08rh6dYPA225EURyY,22032
6
+ python_discovery/_py_info.py,sha256=XX4yM6TVhTMFfmahNMekT7Y3Zy12Zy0IAfmz_xFRceU,25284
7
+ python_discovery/_py_info_collect.py,sha256=NSh00CS1UHI6_fbXrXUi-vlvFAEOix2NnC7Is10Taeg,15439
8
+ python_discovery/_py_spec.py,sha256=O1RMC9JQ7m5SRzF6cEELxnIWZpadXYWO4bWDD7TemUs,10965
9
+ python_discovery/_specifier.py,sha256=o7MHb0z_mFSLz9gXqMwJoNkHYjgi2biq3Ibvd0_slvc,10457
10
+ python_discovery/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ python_discovery/_windows/__init__.py,sha256=qthcyGh36b3WkeSbZ4ECRRYLbCOCaQu5X_DRZ98tXfk,315
12
+ python_discovery/_windows/_pep514.py,sha256=1TqLTdofSCPu3VFE7D4u2fXweyaqhKJR7b48VmJ9AQQ,7787
13
+ python_discovery/_windows/_propose.py,sha256=iMJf6l0TU4SvuV8UsCHHINAe9L5dUT949xyeyMVdong,1565
14
+ python_discovery-1.5.2.dist-info/METADATA,sha256=4kPFZVceJ_w4hP8AKPig_59eT502_O4NEzjeTyjMECw,5132
15
+ python_discovery-1.5.2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
16
+ python_discovery-1.5.2.dist-info/licenses/LICENSE,sha256=kOJeH68qSq6o2V7o5_VziwUqGswMnFgGgQH5Mahr1Yg,1023
17
+ python_discovery-1.5.2.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.31.0
2
+ Generator: hatchling 1.32.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,16 +0,0 @@
1
- python_discovery/__init__.py,sha256=Hi4j3pMvC8Fo8KoNL-wOHsPSCUHwY4c0qvv5sjnFzME,808
2
- python_discovery/_cache.py,sha256=svmG2GzUQzd2xTjAbm2eX1CKxxGAglfv4kxaPuxwPIA,5147
3
- python_discovery/_cached_py_info.py,sha256=l9Y9UJGjKjp4M7iR9HfXYzW3hWdQ3KyNOD5IbSEphLw,8659
4
- python_discovery/_compat.py,sha256=iqxGuhK9rPaiui80G1H1KrweoMJ16gyLYEmUrSXrp5c,709
5
- python_discovery/_discovery.py,sha256=HF_TIuH6WLpvHU1KQiK9_yWfJ-np1eB9OSJwI1xRgdc,16894
6
- python_discovery/_py_info.py,sha256=sXsOX4epGDRhrLvEcpDThh3r1yKMf5KZVaF2ZegoSOA,37311
7
- python_discovery/_py_spec.py,sha256=O1RMC9JQ7m5SRzF6cEELxnIWZpadXYWO4bWDD7TemUs,10965
8
- python_discovery/_specifier.py,sha256=Mu9Wp0tgY3WhXNffsG8Je8K3uc-vxqadEdvvQzWIanA,10416
9
- python_discovery/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- python_discovery/_windows/__init__.py,sha256=qthcyGh36b3WkeSbZ4ECRRYLbCOCaQu5X_DRZ98tXfk,315
11
- python_discovery/_windows/_pep514.py,sha256=1TqLTdofSCPu3VFE7D4u2fXweyaqhKJR7b48VmJ9AQQ,7787
12
- python_discovery/_windows/_propose.py,sha256=iMJf6l0TU4SvuV8UsCHHINAe9L5dUT949xyeyMVdong,1565
13
- python_discovery-1.5.0.dist-info/METADATA,sha256=aDIMeMkRg8kO2fVDwL41lD5DgT_u9Iu8tokFsq-0UPY,5018
14
- python_discovery-1.5.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
15
- python_discovery-1.5.0.dist-info/licenses/LICENSE,sha256=kOJeH68qSq6o2V7o5_VziwUqGswMnFgGgQH5Mahr1Yg,1023
16
- python_discovery-1.5.0.dist-info/RECORD,,