juc500 정상동작

This commit is contained in:
Macbook
2026-07-21 21:04:25 +09:00
parent ff60cee1cc
commit 1388668b56
2917 changed files with 708021 additions and 651 deletions
@@ -0,0 +1,30 @@
"""Self-contained Python interpreter discovery."""
from __future__ import annotations
from importlib.metadata import version
from ._cache import ContentStore, DiskCache, PyInfoCache
from ._discovery import get_interpreter, iter_interpreters
from ._py_info import KNOWN_ARCHITECTURES, PythonInfo, normalize_isa
from ._py_spec import KNOWN_IMPLEMENTATIONS, PythonSpec
from ._specifier import SimpleSpecifier, SimpleSpecifierSet, SimpleVersion
__version__ = version("python-discovery")
__all__ = [
"KNOWN_ARCHITECTURES",
"KNOWN_IMPLEMENTATIONS",
"ContentStore",
"DiskCache",
"PyInfoCache",
"PythonInfo",
"PythonSpec",
"SimpleSpecifier",
"SimpleSpecifierSet",
"SimpleVersion",
"__version__",
"get_interpreter",
"iter_interpreters",
"normalize_isa",
]
@@ -0,0 +1,185 @@
"""Cache Protocol and built-in implementations for Python interpreter discovery."""
from __future__ import annotations
import json
import logging
from contextlib import contextmanager, suppress
from hashlib import sha256
from typing import TYPE_CHECKING, Final, Protocol, runtime_checkable
if TYPE_CHECKING:
from collections.abc import Generator
from pathlib import Path
_LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
@runtime_checkable
class ContentStore(Protocol):
"""A store for reading and writing cached content."""
def exists(self) -> bool:
"""Return whether the cached content exists."""
...
def read(self) -> dict | None:
"""Read the cached content, or ``None`` if unavailable or corrupt."""
...
def write(self, content: dict) -> None:
"""
Persist *content* to the store.
:param content: interpreter metadata to cache.
"""
...
def remove(self) -> None:
"""Delete the cached content."""
...
@contextmanager
def locked(self) -> Generator[None]:
"""Context manager that acquires an exclusive lock on this store."""
...
@runtime_checkable
class PyInfoCache(Protocol):
"""Cache interface for Python interpreter information."""
def py_info(self, path: Path) -> ContentStore:
"""
Return the content store for the interpreter at *path*.
:param path: absolute path to a Python executable.
"""
...
def py_info_clear(self) -> None:
"""Remove all cached interpreter information."""
...
class DiskContentStore:
"""JSON file-based content store with file locking."""
def __init__(self, folder: Path, key: str) -> None:
self._folder = folder
self._key = key
@property
def _file(self) -> Path:
return self._folder / f"{self._key}.json"
def exists(self) -> bool:
return self._file.exists()
def read(self) -> dict | None:
data, bad_format = None, False
try:
data = json.loads(self._file.read_text(encoding="utf-8"))
except ValueError:
bad_format = True
except OSError:
_LOGGER.debug("failed to read %s", self._file, exc_info=True)
else:
_LOGGER.debug("got python info from %s", self._file)
return data
if bad_format:
with suppress(OSError):
self.remove()
return None
def write(self, content: dict) -> None:
self._folder.mkdir(parents=True, exist_ok=True)
self._file.write_text(json.dumps(content, sort_keys=True, indent=2), encoding="utf-8")
_LOGGER.debug("wrote python info at %s", self._file)
def remove(self) -> None:
with suppress(OSError):
self._file.unlink()
_LOGGER.debug("removed python info at %s", self._file)
@contextmanager
def locked(self) -> Generator[None]:
from filelock import FileLock # noqa: PLC0415
lock_path = self._folder / f"{self._key}.lock"
lock_path.parent.mkdir(parents=True, exist_ok=True)
with FileLock(str(lock_path)):
yield
class DiskCache:
"""
File-system based Python interpreter info cache (``<root>/py_info/4/<sha256>.json``).
:param root: root directory for the on-disk cache.
"""
def __init__(self, root: Path) -> None:
self._root = root
@property
def _py_info_dir(self) -> Path:
return self._root / "py_info" / "4"
def py_info(self, path: Path) -> DiskContentStore:
"""
Return the content store for the interpreter at *path*.
:param path: absolute path to a Python executable.
"""
key = sha256(str(path).encode("utf-8")).hexdigest()
return DiskContentStore(self._py_info_dir, key)
def py_info_clear(self) -> None:
"""Remove all cached interpreter information."""
folder = self._py_info_dir
if folder.exists():
for entry in folder.iterdir():
if entry.suffix == ".json":
with suppress(OSError):
entry.unlink()
class NoOpContentStore(ContentStore):
"""Content store that does nothing -- implements ContentStore protocol."""
def exists(self) -> bool: # noqa: PLR6301
return False
def read(self) -> dict | None: # noqa: PLR6301
return None
def write(self, content: dict) -> None:
pass
def remove(self) -> None:
pass
@contextmanager
def locked(self) -> Generator[None]: # noqa: PLR6301
yield
class NoOpCache(PyInfoCache):
"""Cache that does nothing -- implements PyInfoCache protocol."""
def py_info(self, path: Path) -> NoOpContentStore: # noqa: ARG002, PLR6301
return NoOpContentStore()
def py_info_clear(self) -> None:
pass
__all__ = [
"ContentStore",
"DiskCache",
"DiskContentStore",
"NoOpCache",
"NoOpContentStore",
"PyInfoCache",
]
@@ -0,0 +1,264 @@
"""Acquire Python information via subprocess interrogation with multi-level caching."""
from __future__ import annotations
import hashlib
import json
import logging
import os
import pkgutil
import secrets
import subprocess # noqa: S404
import sys
import tempfile
from collections import OrderedDict
from contextlib import contextmanager
from pathlib import Path
from shlex import quote
from subprocess import Popen, TimeoutExpired # noqa: S404
from typing import TYPE_CHECKING, Final
from ._cache import NoOpCache
from ._py_info import PythonInfo
if TYPE_CHECKING:
from collections.abc import Generator, Mapping
from ._cache import ContentStore, PyInfoCache
_CACHE: OrderedDict[Path, PythonInfo | Exception] = OrderedDict()
_CACHE[Path(sys.executable)] = PythonInfo()
_LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
def from_exe( # noqa: PLR0913
cls: type[PythonInfo],
cache: PyInfoCache | None,
exe: str,
env: Mapping[str, str] | None = None,
*,
raise_on_error: bool = True,
ignore_cache: bool = False,
) -> PythonInfo | None:
env = os.environ if env is None else env
result = _get_from_cache(cls, cache, exe, env, ignore_cache=ignore_cache)
if isinstance(result, Exception):
if raise_on_error:
raise result
_LOGGER.info("%s", result)
result = None
return result
def _get_from_cache(
cls: type[PythonInfo],
cache: PyInfoCache | None,
exe: str,
env: Mapping[str, str],
*,
ignore_cache: bool = True,
) -> PythonInfo | Exception:
exe_path = Path(exe)
if not ignore_cache and exe_path in _CACHE:
result = _CACHE[exe_path]
else:
py_info = _get_via_file_cache(cls, cache, exe_path, exe, env)
result = _CACHE[exe_path] = py_info
if isinstance(result, PythonInfo):
result.executable = exe
return result
def _get_via_file_cache(
cls: type[PythonInfo],
cache: PyInfoCache | None,
path: Path,
exe: str,
env: Mapping[str, str],
) -> PythonInfo | Exception:
path_text = str(path)
try:
path_modified = path.stat().st_mtime
except OSError:
path_modified = -1
py_info_script = Path(Path(__file__).resolve()).parent / "_py_info.py"
try:
py_info_hash: str | None = hashlib.sha256(py_info_script.read_bytes()).hexdigest()
except OSError:
py_info_hash = None
resolved_cache = cache if cache is not None else NoOpCache()
py_info: PythonInfo | None = None
py_info_store = resolved_cache.py_info(path)
with py_info_store.locked():
if py_info_store.exists() and (data := py_info_store.read()) is not None:
of_path, of_st_mtime = data.get("path"), data.get("st_mtime")
of_content, of_hash = data.get("content"), data.get("hash")
if (
of_path == path_text
and of_st_mtime == path_modified
and of_hash == py_info_hash
and isinstance(of_content, dict)
):
py_info = _load_cached_py_info(cls, py_info_store, of_content)
else:
py_info_store.remove()
if py_info is None:
failure, py_info = _run_subprocess(cls, exe, env)
if failure is not None:
_LOGGER.debug("first subprocess attempt failed for %s (%s), retrying", exe, failure)
failure, py_info = _run_subprocess(cls, exe, env)
if failure is not None:
return failure
if py_info is not None:
py_info_store.write({
"st_mtime": path_modified,
"path": path_text,
"content": py_info.to_dict(),
"hash": py_info_hash,
})
if py_info is None:
msg = f"{exe} failed to produce interpreter info"
return RuntimeError(msg)
return py_info
def _load_cached_py_info(
cls: type[PythonInfo],
py_info_store: ContentStore,
content: dict,
) -> PythonInfo | None:
try:
py_info = cls.from_dict(content.copy())
except (KeyError, TypeError):
py_info_store.remove()
return None
if (sys_exe := py_info.system_executable) is not None and not Path(sys_exe).exists():
py_info_store.remove()
return None
return py_info
COOKIE_LENGTH: Final[int] = 32
def gen_cookie() -> str:
return secrets.token_hex(COOKIE_LENGTH // 2)
@contextmanager
def _resolve_py_info_script() -> Generator[Path]:
py_info_script = Path(Path(__file__).resolve()).parent / "_py_info.py"
if py_info_script.is_file():
yield py_info_script
else:
data = pkgutil.get_data(__package__ or __name__, "_py_info.py")
if data is None:
msg = "cannot locate _py_info.py for subprocess interrogation"
raise FileNotFoundError(msg)
fd, tmp = tempfile.mkstemp(suffix=".py")
try:
os.write(fd, data)
os.close(fd)
yield Path(tmp)
finally:
Path(tmp).unlink()
def _extract_between_cookies(out: str, start_cookie: str, end_cookie: str) -> tuple[str, str, int, int]:
"""Extract payload between reversed cookie markers, forwarding any surrounding output to stdout."""
raw_out = out
out_starts = out.find(start_cookie[::-1])
if out_starts > -1:
if pre_cookie := out[:out_starts]:
sys.stdout.write(pre_cookie)
out = out[out_starts + COOKIE_LENGTH :]
out_ends = out.find(end_cookie[::-1])
if out_ends > -1:
if post_cookie := out[out_ends + COOKIE_LENGTH :]:
sys.stdout.write(post_cookie)
out = out[:out_ends]
return out, raw_out, out_starts, out_ends
def _run_subprocess(
cls: type[PythonInfo],
exe: str,
env: Mapping[str, str],
) -> tuple[Exception | None, PythonInfo | None]:
start_cookie = gen_cookie()
end_cookie = gen_cookie()
timeout = float(env.get("PY_DISCOVERY_TIMEOUT", "15"))
with _resolve_py_info_script() as py_info_script:
cmd = [exe, str(py_info_script), start_cookie, end_cookie]
env = dict(env)
env.pop("__PYVENV_LAUNCHER__", None)
env["PYTHONUTF8"] = "1"
_LOGGER.debug("get interpreter info via cmd: %s", LogCmd(cmd))
try:
process = Popen( # noqa: S603
cmd,
universal_newlines=True,
stdin=subprocess.PIPE,
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
env=env,
encoding="utf-8",
errors="backslashreplace",
)
out, err = process.communicate(timeout=timeout)
code = process.returncode
except TimeoutExpired:
process.kill()
process.communicate()
out, err, code = "", "timed out", -1
except OSError as os_error:
out, err, code = "", os_error.strerror, os_error.errno
if code != 0:
msg = f"{exe} with code {code}{f' out: {out!r}' if out else ''}{f' err: {err!r}' if err else ''}"
return RuntimeError(f"failed to query {msg}"), None
out, raw_out, out_starts, out_ends = _extract_between_cookies(out, start_cookie, end_cookie)
try:
result = cls.from_json(out)
result.executable = exe
except json.JSONDecodeError as exc:
_LOGGER.warning(
"subprocess %s returned invalid JSON; raw stdout %d chars, start cookie %s, end cookie %s, "
"parsed output %d chars: %r",
exe,
len(raw_out),
"found" if out_starts > -1 else "missing",
"found" if out_ends > -1 else "missing",
len(out),
out[:200] if out else "<empty>",
)
msg = f"{exe} returned invalid JSON (exit code {code}){f', stderr: {err!r}' if err else ''}"
failure = RuntimeError(msg)
failure.__cause__ = exc
return failure, None
return None, result
class LogCmd:
def __init__(self, cmd: list[str], env: Mapping[str, str] | None = None) -> None:
self.cmd = cmd
self.env = env
def __repr__(self) -> str:
cmd_repr = " ".join(quote(str(c)) for c in self.cmd)
if self.env is not None:
cmd_repr = f"{cmd_repr} env of {self.env!r}"
return cmd_repr
def clear(cache: PyInfoCache) -> None:
cache.py_info_clear()
_CACHE.clear()
__all__ = [
"LogCmd",
"clear",
"from_exe",
]
@@ -0,0 +1,29 @@
"""Platform compatibility utilities for Python discovery."""
from __future__ import annotations
import functools
import logging
import pathlib
import tempfile
from typing import Final
_LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
@functools.lru_cache(maxsize=1)
def fs_is_case_sensitive() -> bool:
with tempfile.NamedTemporaryFile(prefix="TmP") as tmp_file:
result = not pathlib.Path(tmp_file.name.lower()).exists()
_LOGGER.debug("filesystem is %scase-sensitive", "" if result else "not ")
return result
def fs_path_id(path: str) -> str:
return path.casefold() if not fs_is_case_sensitive() else path
__all__ = [
"fs_is_case_sensitive",
"fs_path_id",
]
@@ -0,0 +1,425 @@
from __future__ import annotations
import logging
import os
import sys
from contextlib import suppress
from pathlib import Path
from typing import TYPE_CHECKING, Final
from platformdirs import user_data_path
from ._compat import fs_path_id
from ._py_info import PythonInfo
from ._py_spec import PythonSpec
if TYPE_CHECKING:
from collections.abc import Callable, Generator, Iterable, Iterator, Mapping, Sequence
from ._cache import PyInfoCache
_LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
IS_WIN: Final[bool] = sys.platform == "win32"
def get_interpreter(
key: str | Sequence[str],
try_first_with: Iterable[str] | None = None,
cache: PyInfoCache | None = None,
env: Mapping[str, str] | None = None,
predicate: Callable[[PythonInfo], bool] | None = None,
) -> PythonInfo | None:
"""
Find a Python interpreter matching *key*.
Iterates over one or more specification strings and returns the first interpreter that satisfies the spec and passes
the optional *predicate*.
:param key: interpreter specification string(s) — an absolute path, a version (``3.12``), an implementation prefix
(``cpython3.12``), or a
`version specifier <https://packaging.python.org/en/latest/specifications/version-specifiers/>`_
(``>=3.10``). When a sequence is given each entry is tried in order.
:param try_first_with: executables to probe before the normal discovery search.
:param cache: interpreter metadata cache; when ``None`` results are not cached.
:param env: environment mapping for ``PATH`` lookup; defaults to :data:`os.environ`.
:param predicate: optional callback applied after an interpreter matches the spec. Return ``True`` to accept the
interpreter, ``False`` to skip it and continue searching.
:return: the first matching interpreter, or ``None`` if no match is found.
"""
specs = [key] if isinstance(key, str) else key
for spec_str in specs:
if result := _find_interpreter(spec_str, try_first_with or (), cache, env, predicate):
return result
return None
def iter_interpreters(
key: str | Sequence[str] | None = None,
try_first_with: Iterable[str] | None = None,
cache: PyInfoCache | None = None,
env: Mapping[str, str] | None = None,
predicate: Callable[[PythonInfo], bool] | None = None,
) -> Iterator[PythonInfo]:
"""
Yield every interpreter on the system that satisfies *key*.
Iteration order is discovery order: ``try_first_with`` paths first, then the running interpreter, then ``PATH``
(left to right), then UV-managed installs. Results are deduplicated by the resolved real path of the underlying
system interpreter, so symlinked aliases (``/bin`` vs ``/usr/bin``) and venvs that symlink to a base interpreter
collapse to a single entry. Callers that want a different ordering should sort the result.
:param key: interpreter specification — same syntax as :func:`get_interpreter`. ``None`` enumerates every Python
implementation python-discovery knows about (see :data:`KNOWN_IMPLEMENTATIONS`).
:param try_first_with: executables to probe before the normal discovery search.
:param cache: interpreter metadata cache; when ``None`` results are not cached. Strongly recommended for
enumeration, which interrogates every candidate as a subprocess on a cold cache.
:param env: environment mapping for ``PATH`` lookup; defaults to :data:`os.environ`.
:param predicate: optional filter applied after the spec match; return ``True`` to include the interpreter.
"""
if key is None:
keys: tuple[str | None, ...] = (None,)
elif isinstance(key, str):
keys = (key,)
else:
keys = tuple(key)
first_with = tuple(try_first_with or ())
env_map = os.environ if env is None else env
seen: set[str] = set()
for spec_str in keys:
yield from _iter_for_spec(spec_str, first_with, cache, env_map, predicate, seen)
def _iter_for_spec( # noqa: PLR0913, PLR0917
spec_str: str | None,
try_first_with: tuple[str, ...],
cache: PyInfoCache | None,
env: Mapping[str, str],
predicate: Callable[[PythonInfo], bool] | None,
seen: set[str],
) -> Iterator[PythonInfo]:
if spec_str is None:
spec = PythonSpec("", None, None, None, None, None, None)
wide = True
else:
spec = PythonSpec.from_string_spec(spec_str)
wide = False
for interpreter, impl_must_match in propose_interpreters(
spec, try_first_with, cache, env, all_implementations=wide
):
if interpreter is None:
continue
if (anchor := interpreter.system_executable or interpreter.executable) is None:
continue
if (real_path := os.path.realpath(anchor)) in seen:
continue
if not interpreter.satisfies(spec, impl_must_match=impl_must_match):
continue
if predicate is not None and not predicate(interpreter):
continue
seen.add(real_path)
yield interpreter
def _find_interpreter(
key: str,
try_first_with: Iterable[str],
cache: PyInfoCache | None = None,
env: Mapping[str, str] | None = None,
predicate: Callable[[PythonInfo], bool] | None = None,
) -> PythonInfo | None:
spec = PythonSpec.from_string_spec(key)
_LOGGER.info("find interpreter for spec %r", spec)
proposed_paths: set[tuple[str | None, bool]] = set()
env = os.environ if env is None else env
for interpreter, impl_must_match in propose_interpreters(spec, try_first_with, cache, env):
if interpreter is None: # pragma: no cover
continue
proposed_key = interpreter.system_executable, impl_must_match
if proposed_key in proposed_paths:
continue
_LOGGER.info("proposed %s", interpreter)
if interpreter.satisfies(spec, impl_must_match=impl_must_match) and (
predicate is None or predicate(interpreter)
):
_LOGGER.debug("accepted %s", interpreter)
return interpreter
proposed_paths.add(proposed_key)
return None
def _check_exe(path: str, tested_exes: set[str]) -> str | None:
"""Resolve *path* to an absolute path and return it if not yet tested, otherwise ``None``."""
try:
os.lstat(path)
except OSError:
return None
resolved = str(Path(path).resolve())
exe_id = fs_path_id(resolved)
if exe_id in tested_exes:
return None
tested_exes.add(exe_id)
return str(Path(path).absolute())
def _is_new_exe(exe_raw: str, tested_exes: set[str]) -> bool:
"""Return ``True`` and register *exe_raw* if it hasn't been tested yet."""
exe_id = fs_path_id(exe_raw)
if exe_id in tested_exes:
return False
tested_exes.add(exe_id)
return True
def propose_interpreters(
spec: PythonSpec,
try_first_with: Iterable[str],
cache: PyInfoCache | None = None,
env: Mapping[str, str] | None = None,
*,
all_implementations: bool = False,
) -> Generator[tuple[PythonInfo | None, bool], None, None]:
"""
Yield ``(interpreter, impl_must_match)`` candidates for *spec*.
:param spec: the parsed interpreter specification to match against.
:param try_first_with: executable paths to probe before the standard search.
:param cache: interpreter metadata cache; when ``None`` results are not cached.
:param env: environment mapping for ``PATH`` lookup; defaults to :data:`os.environ`.
:param all_implementations: when ``True`` and *spec* does not constrain the implementation, also surface
non-CPython binaries on ``PATH`` and under UV's install directory. Used by enumeration APIs.
"""
env = os.environ if env is None else env
tested_exes: set[str] = set()
if spec.is_abs and spec.path is not None:
if exe_raw := _check_exe(spec.path, tested_exes): # pragma: no branch # first exe always new
yield PythonInfo.from_exe(exe_raw, cache, env=env), True
return
yield from _propose_explicit(spec, try_first_with, cache, env, tested_exes)
if spec.path is not None and spec.is_abs: # pragma: no cover # relative spec.path is never abs
return
yield from _propose_from_path(spec, cache, env, tested_exes, all_implementations=all_implementations)
yield from _propose_from_uv(cache, env, all_implementations=all_implementations)
def _propose_explicit(
spec: PythonSpec,
try_first_with: Iterable[str],
cache: PyInfoCache | None,
env: Mapping[str, str],
tested_exes: set[str],
) -> Generator[tuple[PythonInfo | None, bool], None, None]:
for py_exe in try_first_with:
if exe_raw := _check_exe(str(Path(py_exe).resolve()), tested_exes):
yield PythonInfo.from_exe(exe_raw, cache, env=env), True
if spec.path is not None:
if exe_raw := _check_exe(spec.path, tested_exes): # pragma: no branch
yield PythonInfo.from_exe(exe_raw, cache, env=env), True
else:
yield from _propose_current_and_windows(spec, cache, env, tested_exes)
def _propose_current_and_windows(
spec: PythonSpec,
cache: PyInfoCache | None,
env: Mapping[str, str],
tested_exes: set[str],
) -> Generator[tuple[PythonInfo | None, bool], None, None]:
current_python = PythonInfo.current_system(cache)
if _is_new_exe(str(current_python.executable), tested_exes):
yield current_python, True
if IS_WIN: # pragma: win32 cover
from ._windows import propose_interpreters as win_propose # noqa: PLC0415
for interpreter in win_propose(spec, cache, env):
if _is_new_exe(str(interpreter.executable), tested_exes):
yield interpreter, True
def _propose_from_path(
spec: PythonSpec,
cache: PyInfoCache | None,
env: Mapping[str, str],
tested_exes: set[str],
*,
all_implementations: bool = False,
) -> Generator[tuple[PythonInfo | None, bool], None, None]:
find_candidates = path_exe_finder(spec, all_implementations=all_implementations)
for pos, path in enumerate(get_paths(env)):
_LOGGER.debug(LazyPathDump(pos, path, env))
for exe, impl_must_match in find_candidates(path):
exe_raw = str(exe)
if resolved := _resolve_shim(exe_raw, env):
_LOGGER.debug("resolved shim %s to %s", exe_raw, resolved)
exe_raw = resolved
if not _is_new_exe(exe_raw, tested_exes):
continue
interpreter = PathPythonInfo.from_exe(exe_raw, cache, raise_on_error=False, env=env)
if interpreter is not None:
yield interpreter, impl_must_match
def _propose_from_uv(
cache: PyInfoCache | None,
env: Mapping[str, str],
*,
all_implementations: bool = False,
) -> Generator[tuple[PythonInfo | None, bool], None, None]:
if uv_python_dir := os.getenv("UV_PYTHON_INSTALL_DIR"):
uv_python_path = Path(uv_python_dir).expanduser()
elif xdg_data_home := os.getenv("XDG_DATA_HOME"):
uv_python_path = Path(xdg_data_home).expanduser() / "uv" / "python"
else:
uv_python_path = user_data_path("uv") / "python"
patterns: list[str] = ["*/bin/python", "*/python.exe"]
if all_implementations:
patterns.extend(("*/bin/pypy*", "*/bin/graalpy", "*/pypy*.exe", "*/bin/graalpy.exe"))
seen_uv_paths: set[str] = set()
for pattern in patterns:
for exe_path in uv_python_path.glob(pattern):
resolved = str(Path(exe_path).resolve())
if resolved in seen_uv_paths:
continue
seen_uv_paths.add(resolved)
if interpreter := PathPythonInfo.from_exe(str(exe_path), cache, raise_on_error=False, env=env):
yield interpreter, True
def get_paths(env: Mapping[str, str]) -> Generator[Path, None, None]:
path = env.get("PATH", None)
if path is None:
try:
path = os.confstr("CS_PATH")
except (AttributeError, ValueError): # pragma: no cover # Windows only (no confstr)
path = os.defpath
if path:
for entry in map(Path, path.split(os.pathsep)):
with suppress(OSError):
if entry.is_dir() and next(entry.iterdir(), None):
yield entry
class LazyPathDump:
def __init__(self, pos: int, path: Path, env: Mapping[str, str]) -> None:
self.pos = pos
self.path = path
self.env = env
def __repr__(self) -> str:
content = f"discover PATH[{self.pos}]={self.path}"
if self.env.get("_VIRTUALENV_DEBUG"):
content += " with =>"
for file_path in self.path.iterdir():
try:
if not self._is_executable(file_path):
continue
except OSError:
pass
content += " "
content += file_path.name
return content
def _is_executable(self, file_path: Path) -> bool:
if file_path.is_dir():
return False
if IS_WIN: # pragma: win32 cover
pathext = self.env.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";")
return any(file_path.name.upper().endswith(ext) for ext in pathext)
return bool(file_path.stat().st_mode & os.X_OK)
def path_exe_finder(
spec: PythonSpec, *, all_implementations: bool = False
) -> Callable[[Path], Generator[tuple[Path, bool], None, None]]:
"""Given a spec, return a function that can be called on a path to find all matching files in it."""
pat = spec.generate_re(windows=sys.platform == "win32", all_implementations=all_implementations)
direct = spec.str_spec
if sys.platform == "win32": # pragma: win32 cover
direct = f"{direct}.exe"
def path_exes(path: Path) -> Generator[tuple[Path, bool], None, None]:
direct_path = path / direct
if direct_path.exists():
yield direct_path, False
for exe in path.iterdir():
match = pat.fullmatch(exe.name)
if match:
yield exe.absolute(), match["impl"] == "python"
return path_exes
def _resolve_shim(exe_path: str, env: Mapping[str, str]) -> str | None:
"""Resolve a version-manager shim to the actual Python binary."""
for shims_dir_env, versions_path in _VERSION_MANAGER_LAYOUTS:
if root := env.get(shims_dir_env):
shims_dir = os.path.join(root, "shims")
if os.path.dirname(exe_path) == shims_dir:
exe_name = os.path.basename(exe_path)
versions_dir = os.path.join(root, *versions_path)
return _resolve_shim_to_binary(exe_name, versions_dir, env)
return None
_VERSION_MANAGER_LAYOUTS: list[tuple[str, tuple[str, ...]]] = [
("PYENV_ROOT", ("versions",)),
("MISE_DATA_DIR", ("installs", "python")),
("ASDF_DATA_DIR", ("installs", "python")),
]
def _resolve_shim_to_binary(exe_name: str, versions_dir: str, env: Mapping[str, str]) -> str | None:
for version in _active_versions(env):
resolved = os.path.join(versions_dir, version, "bin", exe_name)
if Path(resolved).is_file() and os.access(resolved, os.X_OK):
return resolved
return None
def _active_versions(env: Mapping[str, str]) -> Generator[str, None, None]:
"""Yield active Python version strings by reading version-manager configuration."""
if pyenv_version := env.get("PYENV_VERSION"):
yield from pyenv_version.split(":")
return
if versions := _read_python_version_file(Path.cwd()):
yield from versions
return
if (pyenv_root := env.get("PYENV_ROOT")) and (
versions := _read_python_version_file(os.path.join(pyenv_root, "version"), search_parents=False)
):
yield from versions
def _read_python_version_file(start: str | Path, *, search_parents: bool = True) -> list[str] | None:
"""Read a ``.python-version`` file, optionally searching parent directories."""
current = start
while True:
candidate = os.path.join(current, ".python-version") if Path(current).is_dir() else current
if Path(candidate).is_file():
with Path(candidate).open(encoding="utf-8") as fh:
if versions := [v for line in fh if (v := line.strip()) and not v.startswith("#")]:
return versions
if not search_parents:
return None
parent = Path(current).parent
if parent == current:
return None
current = parent
class PathPythonInfo(PythonInfo):
"""python info from path."""
__all__ = [
"LazyPathDump",
"PathPythonInfo",
"get_interpreter",
"get_paths",
"iter_interpreters",
"propose_interpreters",
]
@@ -0,0 +1,874 @@
"""Concrete Python interpreter information, also used as subprocess interrogation script (stdlib only)."""
from __future__ import annotations
import json
import logging
import os
import platform
import re
import struct
import sys
import sysconfig
import warnings
from collections import OrderedDict
from itertools import product
from string import digits
from typing import TYPE_CHECKING, ClassVar, Final, NamedTuple
if TYPE_CHECKING:
import tkinter as tk
from collections.abc import Generator, Mapping
from ._cache import PyInfoCache
from ._py_spec import PythonSpec
class VersionInfo(NamedTuple):
major: int
minor: int
micro: int
releaselevel: str
serial: int
_LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
def _get_path_extensions() -> list[str]:
return list(OrderedDict.fromkeys(["", *os.environ.get("PATHEXT", "").lower().split(os.pathsep)]))
EXTENSIONS: Final[list[str]] = _get_path_extensions()
_32BIT_POINTER_SIZE: Final[int] = 4
_CONF_VAR_RE: Final[re.Pattern[str]] = re.compile(
r"""
\{ \w+ } # sysconfig variable placeholder like {base}
""",
re.VERBOSE,
)
class PythonInfo: # noqa: PLR0904
"""Contains information for a Python interpreter."""
def __init__(self) -> None:
self._init_identity()
self._init_prefixes()
self._init_schemes()
self._init_sysconfig()
def _init_identity(self) -> None:
self.platform = sys.platform
self.implementation = platform.python_implementation()
if self.implementation == "GraalVM":
self.implementation = "GraalPy"
if self.implementation == "PyPy":
self.pypy_version_info = tuple(sys.pypy_version_info) # ty: ignore[unresolved-attribute] # pypy only
self.version_info = VersionInfo(*sys.version_info)
# same as stdlib platform.architecture to account for pointer size != max int
self.architecture = 32 if struct.calcsize("P") == _32BIT_POINTER_SIZE else 64
self.sysconfig_platform = sysconfig.get_platform()
self.version_nodot = sysconfig.get_config_var("py_version_nodot")
self.version = sys.version
self.os = os.name
self.free_threaded = sysconfig.get_config_var("Py_GIL_DISABLED") == 1
self.debug_build = bool(sysconfig.get_config_var("Py_DEBUG"))
def _init_prefixes(self) -> None:
def abs_path(value: str | None) -> str | None:
return None if value is None else os.path.abspath(value)
self.prefix = abs_path(getattr(sys, "prefix", None))
self.base_prefix = abs_path(getattr(sys, "base_prefix", None))
self.real_prefix = abs_path(getattr(sys, "real_prefix", None))
self.base_exec_prefix = abs_path(getattr(sys, "base_exec_prefix", None))
self.exec_prefix = abs_path(getattr(sys, "exec_prefix", None))
self.executable = abs_path(sys.executable)
self.original_executable = abs_path(self.executable)
self.system_executable = self._fast_get_system_executable()
try:
__import__("venv")
has = True
except ImportError: # pragma: no cover # venv is always available in standard CPython
has = False
self.has_venv = has
self.path = sys.path
self.file_system_encoding = sys.getfilesystemencoding()
self.stdout_encoding = getattr(sys.stdout, "encoding", None)
def _init_schemes(self) -> None:
scheme_names = sysconfig.get_scheme_names()
if "venv" in scheme_names: # pragma: >=3.11 cover
self.sysconfig_scheme = "venv"
self.sysconfig_paths = {
i: sysconfig.get_path(i, expand=False, scheme=self.sysconfig_scheme) for i in sysconfig.get_path_names()
}
self.distutils_install = {}
# debian / ubuntu python 3.10 without `python3-distutils` will report mangled `local/bin` / etc. names
elif sys.version_info[:2] == (3, 10) and "deb_system" in scheme_names: # pragma: no cover # Debian/Ubuntu 3.10
self.sysconfig_scheme = "posix_prefix"
self.sysconfig_paths = {
i: sysconfig.get_path(i, expand=False, scheme=self.sysconfig_scheme) for i in sysconfig.get_path_names()
}
self.distutils_install = {}
else: # pragma: no cover # "venv" scheme always present on Python 3.12+
self.sysconfig_scheme = None
self.sysconfig_paths = {i: sysconfig.get_path(i, expand=False) for i in sysconfig.get_path_names()}
self.distutils_install = self._distutils_install().copy()
def _init_sysconfig(self) -> None:
makefile = getattr(sysconfig, "get_makefile_filename", getattr(sysconfig, "_get_makefile_filename", None))
self.sysconfig = {
k: v
for k, v in [
("makefile_filename", makefile() if makefile is not None else None),
]
if k is not None
}
config_var_keys = set()
for element in self.sysconfig_paths.values():
config_var_keys.update(k[1:-1] for k in _CONF_VAR_RE.findall(element))
config_var_keys.add("PYTHONFRAMEWORK")
config_var_keys.update(("Py_ENABLE_SHARED", "INSTSONAME", "LIBDIR"))
self.sysconfig_vars = {i: sysconfig.get_config_var(i or "") for i in config_var_keys}
if "TCL_LIBRARY" in os.environ:
self.tcl_lib, self.tk_lib = self._get_tcl_tk_libs()
else:
self.tcl_lib, self.tk_lib = None, None
confs = {
k: (self.system_prefix if isinstance(v, str) and v.startswith(self.prefix) else v)
for k, v in self.sysconfig_vars.items()
}
self.system_stdlib = self.sysconfig_path("stdlib", confs)
self.system_stdlib_platform = self.sysconfig_path("platstdlib", confs)
self.max_size = getattr(sys, "maxsize", getattr(sys, "maxint", None))
self._creators = None # virtualenv-specific, set via monkey-patch
@staticmethod
def _get_tcl_tk_libs() -> tuple[
str | None,
str | None,
]: # pragma: no cover # tkinter availability varies; tested indirectly via __init__
"""Detect the tcl and tk libraries using tkinter."""
tcl_lib, tk_lib = None, None
try:
import tkinter as tk # noqa: PLC0415
except ImportError:
pass
else:
try:
tcl = tk.Tcl()
tcl_lib = tcl.eval("info library")
tk_lib = PythonInfo._resolve_tk_lib(tcl, tcl_lib)
except tk.TclError:
pass
return tcl_lib, tk_lib
@staticmethod
def _query_tk_library(tcl: tk.Tk) -> str | None: # pragma: no cover
"""Try to get the TK library path directly from Tcl."""
import tkinter as tk # noqa: PLC0415
try:
if (tk_lib := tcl.eval("set tk_library")) and os.path.isdir(tk_lib):
return tk_lib
except tk.TclError:
pass
return None
@staticmethod
def _resolve_tk_lib(tcl: tk.Tk, tcl_lib: str) -> str | None: # pragma: no cover
"""Resolve the TK library path by direct query or path construction."""
if (tk_lib := PythonInfo._query_tk_library(tcl)) is not None:
return tk_lib
tk_version = tcl.eval("package require Tk")
tcl_parent = os.path.dirname(tcl_lib)
for version in (tk_version, ".".join(tk_version.split(".")[:2]), tk_version.split(".")[0]):
tk_lib_path = os.path.join(tcl_parent, f"tk{version}")
if os.path.isdir(tk_lib_path) and os.path.exists(os.path.join(tk_lib_path, "tk.tcl")):
return tk_lib_path
return None
def _fast_get_system_executable(self) -> str | None:
"""Try to get the system executable by just looking at properties."""
# if we're not in a virtual environment, this is already a system python, so return the original executable
# note we must choose the original and not the pure executable as shim scripts might throw us off
if not (self.real_prefix or (self.base_prefix is not None and self.base_prefix != self.prefix)):
return self._resolve_executable_symlink(self.original_executable)
# if this is NOT a virtual environment, can't determine easily, bail out
if self.real_prefix is not None:
return None
base_executable = getattr(sys, "_base_executable", None) # some platforms may set this to help us
if base_executable is None: # use the saved system executable if present
return None
# we know we're in a virtual environment, can not be us
if sys.executable == base_executable:
return None
# We're not in a venv and base_executable exists; use it directly
if os.path.exists(base_executable): # pragma: >=3.11 cover
return self._resolve_executable_symlink(base_executable)
# Try fallback for POSIX virtual environments
return self._try_posix_fallback_executable(base_executable) # pragma: >=3.11 cover
def _resolve_executable_symlink(self, path: str, *, framework: bool | None = None) -> str:
"""
Resolve symlinks of the executable itself, but never of its parent directories.
Mirrors CPython's ``getpath.realpath`` (and ``venv`` in python/cpython#115237): an executable-only symlink
resolves to the real interpreter so its home can be located, while a fully symlinked interpreter tree is
kept as-is. Like ``getpath``, resolution stops as soon as the stdlib landmark is reachable from the current
directory - an alias such as Debian's ``/usr/bin/python3`` is a usable home and stays untouched.
"""
result = os.path.abspath(path)
if self.os != "posix": # CPython only does this where HAVE_READLINK
return result
if framework is None:
framework = bool(sysconfig.get_config_var("PYTHONFRAMEWORK"))
if framework: # macOS framework builds self-locate via dyld from the real binary; e.g. for Homebrew
return result # resolving would pin the versioned Cellar path into the recorded home
real_path = os.path.realpath(result)
if not os.path.exists(real_path): # symlink loop or broken symlink
return result
while os.path.islink(result):
if self._stdlib_landmark_exists(os.path.dirname(result)):
return result
link = os.readlink(result)
candidate = link if os.path.isabs(link) else os.path.normpath(os.path.join(os.path.dirname(result), link))
# normpath through a symlinked directory may point at a different file - stop resolving there
if not (os.path.exists(candidate) and os.path.samefile(real_path, candidate)):
return result
result = candidate
return result
@staticmethod
def _stdlib_landmark_exists(dir_path: str) -> bool:
lib_name = os.path.basename(os.path.dirname(os.__file__))
return any(
os.path.exists(os.path.join(dir_path, os.pardir, lib, lib_name, "os.py")) for lib in ("lib", "lib64")
)
def _try_posix_fallback_executable(self, base_executable: str) -> str | None:
"""Find a versioned Python binary as fallback for POSIX virtual environments."""
major, minor = self.version_info.major, self.version_info.minor
if self.os != "posix" or (major, minor) < (3, 11):
return None
# search relative to the directory of sys._base_executable
base_dir = os.path.dirname(base_executable)
candidates = [f"python{major}", f"python{major}.{minor}"]
if self.implementation == "PyPy":
candidates.extend(["pypy", "pypy3", f"pypy{major}", f"pypy{major}.{minor}"])
for candidate in candidates:
full_path = os.path.join(base_dir, candidate)
if os.path.exists(full_path):
return full_path
return None # in this case we just can't tell easily without poking around FS and calling them, bail
def install_path(self, key: str) -> str:
"""
Return the relative installation path for a given installation scheme *key*.
:param key: sysconfig installation scheme key (e.g. ``"scripts"``, ``"purelib"``).
"""
result = self.distutils_install.get(key)
if result is None: # pragma: >=3.11 cover # distutils is empty when "venv" scheme is available
# set prefixes to empty => result is relative from cwd
prefixes = self.prefix, self.exec_prefix, self.base_prefix, self.base_exec_prefix
config_var = {k: "" if v in prefixes else v for k, v in self.sysconfig_vars.items()}
result = self.sysconfig_path(key, config_var=config_var).lstrip(os.sep)
return result
@staticmethod
def _distutils_install() -> dict[str, str]:
# use distutils primarily because that's what pip does
# https://github.com/pypa/pip/blob/main/src/pip/_internal/locations.py#L95
# note here we don't import Distribution directly to allow setuptools to patch it
with warnings.catch_warnings(): # disable warning for PEP-632
warnings.simplefilter("ignore")
try:
from distutils import dist # noqa: PLC0415 # ty: ignore[unresolved-import]
from distutils.command.install import SCHEME_KEYS # noqa: PLC0415 # ty: ignore[unresolved-import]
except ImportError: # pragma: no cover # if removed or not installed ignore
return {}
distribution = dist.Distribution({
"script_args": "--no-user-cfg",
}) # conf files not parsed so they do not hijack paths
if hasattr(sys, "_framework"): # pragma: no cover # macOS framework builds only
sys._framework = None # noqa: SLF001 # disable macOS static paths for framework
with warnings.catch_warnings(): # disable warning for PEP-632
warnings.simplefilter("ignore")
install = distribution.get_command_obj("install", create=True)
install.prefix = os.sep # paths generated are relative to prefix that contains the path sep
install.finalize_options()
return {key: (getattr(install, f"install_{key}")[1:]).lstrip(os.sep) for key in SCHEME_KEYS}
@property
def version_str(self) -> str:
"""The full version as ``major.minor.micro`` string (e.g. ``3.13.2``)."""
return ".".join(str(i) for i in self.version_info[0:3])
@property
def version_release_str(self) -> str:
"""The release version as ``major.minor`` string (e.g. ``3.13``)."""
return ".".join(str(i) for i in self.version_info[0:2])
@property
def python_name(self) -> str:
"""The python executable name as ``pythonX.Y`` (e.g. ``python3.13``)."""
version_info = self.version_info
return f"python{version_info.major}.{version_info.minor}"
@property
def is_old_virtualenv(self) -> bool:
"""``True`` if this interpreter runs inside an old-style virtualenv (has ``real_prefix``)."""
return self.real_prefix is not None
@property
def is_venv(self) -> bool:
"""``True`` if this interpreter runs inside a PEP 405 venv (has ``base_prefix``)."""
return self.base_prefix is not None
def sysconfig_path(self, key: str, config_var: dict[str, str] | None = None, sep: str = os.sep) -> str:
"""
Return the sysconfig install path for a scheme *key*, optionally substituting config variables.
:param key: sysconfig path key (e.g. ``"purelib"``, ``"include"``).
:param config_var: replacement mapping for sysconfig variables; when ``None`` uses the interpreter's own values.
:param sep: path separator to use in the result.
"""
pattern = self.sysconfig_paths.get(key)
if pattern is None:
return ""
if config_var is None:
config_var = self.sysconfig_vars
else:
base = self.sysconfig_vars.copy()
base.update(config_var)
config_var = base
return pattern.format(**config_var).replace("/", sep)
@property
def system_include(self) -> str:
"""The path to the system include directory for C headers."""
path = self.sysconfig_path(
"include",
{
k: (self.system_prefix if isinstance(v, str) and v.startswith(self.prefix) else v)
for k, v in self.sysconfig_vars.items()
},
)
if not os.path.exists(path): # pragma: no cover # broken packaging fallback
fallback = os.path.join(self.prefix, os.path.dirname(self.install_path("headers")))
if os.path.exists(fallback):
path = fallback
return path
@property
def system_prefix(self) -> str:
"""The prefix of the system Python this interpreter is based on."""
return self.real_prefix or self.base_prefix or self.prefix
@property
def system_exec_prefix(self) -> str:
"""The exec prefix of the system Python this interpreter is based on."""
return self.real_prefix or self.base_exec_prefix or self.exec_prefix
def __repr__(self) -> str:
return "{}({!r})".format(
self.__class__.__name__,
{k: v for k, v in self.__dict__.items() if not k.startswith("_")},
)
def __str__(self) -> str:
return "{}({})".format(
self.__class__.__name__,
", ".join(
f"{k}={v}"
for k, v in (
("spec", self.spec),
(
"system"
if self.system_executable is not None and self.system_executable != self.executable
else None,
self.system_executable,
),
(
"original"
if self.original_executable not in {self.system_executable, self.executable}
else None,
self.original_executable,
),
("exe", self.executable),
("platform", self.platform),
("version", repr(self.version)),
("encoding_fs_io", f"{self.file_system_encoding}-{self.stdout_encoding}"),
)
if k is not None
),
)
@property
def machine(self) -> str:
"""The instruction set architecture (ISA) derived from :func:`sysconfig.get_platform`."""
plat = self.sysconfig_platform
if plat is None:
return "unknown"
if plat == "win32":
return "x86"
isa = plat.rsplit("-", 1)[-1]
if isa == "universal2":
isa = platform.machine().lower()
return normalize_isa(isa)
@property
def spec(self) -> str:
"""A specification string identifying this interpreter (e.g. ``CPython3.13.2-64-arm64``)."""
return "{}{}{}{}-{}-{}".format(
self.implementation,
".".join(str(i) for i in self.version_info),
"t" if self.free_threaded else "",
"d" if self.debug_build else "",
self.architecture,
self.machine,
)
@classmethod
def clear_cache(cls, cache: PyInfoCache) -> None:
"""
Clear all cached interpreter information from *cache*.
:param cache: the cache store to clear.
"""
from ._cached_py_info import clear # noqa: PLC0415
clear(cache)
cls._cache_exe_discovery.clear()
def satisfies(self, spec: PythonSpec, *, impl_must_match: bool) -> bool: # noqa: PLR0911
"""
Check if a given specification can be satisfied by this python interpreter instance.
:param spec: the specification to check against.
:param impl_must_match: when ``True``, the implementation name must match exactly.
"""
if spec.path and not self._satisfies_path(spec):
return False
if impl_must_match and not self._satisfies_implementation(spec):
return False
if spec.architecture is not None and spec.architecture != self.architecture:
return False
if spec.machine is not None and spec.machine != self.machine:
return False
if spec.free_threaded is not None and spec.free_threaded != self.free_threaded:
return False
if spec.debug is not None and spec.debug != self.debug_build:
return False
if spec.version_specifier is not None and not self._satisfies_version_specifier(spec):
return False
return all(
req is None or our is None or our == req
for our, req in zip(self.version_info[0:3], (spec.major, spec.minor, spec.micro))
)
def _satisfies_path(self, spec: PythonSpec) -> bool:
if self.executable == os.path.abspath(spec.path):
return True
if spec.is_abs:
return True
basename = os.path.basename(self.original_executable)
spec_path = spec.path
if sys.platform == "win32":
basename, suffix = os.path.splitext(basename)
spec_path = spec_path[: -len(suffix)] if suffix and spec_path.endswith(suffix) else spec_path
return basename == spec_path
def _satisfies_implementation(self, spec: PythonSpec) -> bool:
return spec.implementation is None or spec.implementation.lower() == self.implementation.lower()
def _satisfies_version_specifier(self, spec: PythonSpec) -> bool:
if spec.version_specifier is None: # pragma: no cover
return True
version_info = self.version_info
for specifier in spec.version_specifier:
assert specifier.version is not None # noqa: S101
numeric_version = specifier.version_str
for prefix in ("rc", "b", "a"):
if prefix in numeric_version:
numeric_version = numeric_version.split(prefix)[0]
break
precision = numeric_version.count(".") + 1
release = ".".join(str(c) for c in [version_info.major, version_info.minor, version_info.micro][:precision])
if (
version_info.releaselevel != "final"
and (precision == 3 or specifier.version.pre_type is not None) # noqa: PLR2004
and (suffix := {"alpha": "a", "beta": "b", "candidate": "rc"}.get(version_info.releaselevel))
):
release = f"{release}{suffix}{version_info.serial}"
if not specifier.contains(release):
return False
return True
_current_system = None
_current = None
@classmethod
def current(cls, cache: PyInfoCache | None = None) -> PythonInfo:
"""
Locate the current host interpreter information.
:param cache: interpreter metadata cache; when ``None`` results are not cached.
"""
if cls._current is None:
result = cls.from_exe(sys.executable, cache, raise_on_error=True, resolve_to_host=False)
if result is None:
msg = "failed to query current Python interpreter"
raise RuntimeError(msg)
cls._current = result
return cls._current
@classmethod
def current_system(cls, cache: PyInfoCache | None = None) -> PythonInfo:
"""
Locate the current system interpreter information, resolving through any virtualenv layers.
:param cache: interpreter metadata cache; when ``None`` results are not cached.
"""
if cls._current_system is None:
result = cls.from_exe(sys.executable, cache, raise_on_error=True, resolve_to_host=True)
if result is None:
msg = "failed to query current system Python interpreter"
raise RuntimeError(msg)
cls._current_system = result
return cls._current_system
def to_json(self) -> str:
"""Serialize this interpreter information to a JSON string."""
return json.dumps(self.to_dict(), indent=2)
def to_dict(self) -> dict[str, object]:
"""Convert this interpreter information to a plain dictionary."""
data = {var: (getattr(self, var) if var != "_creators" else None) for var in vars(self)}
version_info = data["version_info"]
data["version_info"] = version_info._asdict() if hasattr(version_info, "_asdict") else version_info
return data
@classmethod
def from_exe( # noqa: PLR0913
cls,
exe: str,
cache: PyInfoCache | None = None,
*,
raise_on_error: bool = True,
ignore_cache: bool = False,
resolve_to_host: bool = True,
env: Mapping[str, str] | None = None,
) -> PythonInfo | None:
"""
Get the python information for a given executable path.
:param exe: path to the Python executable.
:param cache: interpreter metadata cache; when ``None`` results are not cached.
:param raise_on_error: raise on failure instead of returning ``None``.
:param ignore_cache: bypass the cache and re-query the interpreter.
:param resolve_to_host: resolve through virtualenv layers to the system interpreter.
:param env: environment mapping; defaults to :data:`os.environ`.
"""
from ._cached_py_info import from_exe # noqa: PLC0415
env = os.environ if env is None else env
proposed = from_exe(cls, cache, exe, env=env, raise_on_error=raise_on_error, ignore_cache=ignore_cache)
if isinstance(proposed, PythonInfo) and resolve_to_host:
try:
proposed = proposed.resolve_to_system(cache, proposed)
except Exception as exception:
if raise_on_error:
raise
_LOGGER.info("ignore %s due cannot resolve system due to %r", proposed.original_executable, exception)
proposed = None
return proposed
@classmethod
def from_json(cls, payload: str) -> PythonInfo:
"""
Deserialize interpreter information from a JSON string.
:param payload: JSON produced by :meth:`to_json`.
"""
raw = json.loads(payload)
return cls.from_dict(raw.copy())
@classmethod
def from_dict(cls, data: dict[str, object]) -> PythonInfo:
"""
Reconstruct a :class:`PythonInfo` from a plain dictionary.
:param data: dictionary produced by :meth:`to_dict`.
"""
data["version_info"] = VersionInfo(**data["version_info"]) # restore this to a named tuple structure
result = cls()
result.__dict__ = data.copy()
return result
@classmethod
def resolve_to_system(cls, cache: PyInfoCache | None, target: PythonInfo) -> PythonInfo:
"""
Walk virtualenv/venv prefix chains to find the underlying system interpreter.
:param cache: interpreter metadata cache; when ``None`` results are not cached.
:param target: the interpreter to resolve.
"""
start_executable = target.executable
prefixes = OrderedDict()
while target.system_executable is None:
prefix = target.real_prefix or target.base_prefix or target.prefix
if prefix in prefixes:
if len(prefixes) == 1:
_LOGGER.info("%r links back to itself via prefixes", target)
target.system_executable = target.executable
break
for at, (p, t) in enumerate(prefixes.items(), start=1):
_LOGGER.error("%d: prefix=%s, info=%r", at, p, t)
_LOGGER.error("%d: prefix=%s, info=%r", len(prefixes) + 1, prefix, target)
msg = "prefixes are causing a circle {}".format("|".join(prefixes.keys()))
raise RuntimeError(msg)
prefixes[prefix] = target
target = target.discover_exe(cache, prefix=prefix, exact=False)
if target.executable != target.system_executable:
resolved = cls.from_exe(target.system_executable, cache)
if resolved is not None:
target = resolved
target.executable = start_executable
return target
_cache_exe_discovery: ClassVar[dict[tuple[str, bool], PythonInfo]] = {}
def discover_exe(
self,
cache: PyInfoCache,
prefix: str,
*,
exact: bool = True,
env: Mapping[str, str] | None = None,
) -> PythonInfo:
"""
Discover a matching Python executable under a given *prefix* directory.
:param cache: interpreter metadata cache.
:param prefix: directory prefix to search under.
:param exact: when ``True``, require an exact version match.
:param env: environment mapping; defaults to :data:`os.environ`.
"""
key = prefix, exact
if key in self._cache_exe_discovery and prefix:
_LOGGER.debug("discover exe from cache %s - exact %s: %r", prefix, exact, self._cache_exe_discovery[key])
return self._cache_exe_discovery[key]
_LOGGER.debug("discover exe for %s in %s", self, prefix)
possible_names = self._find_possible_exe_names()
possible_folders = self._find_possible_folders(prefix)
discovered = []
env = os.environ if env is None else env
for folder in possible_folders:
for name in possible_names:
info = self._check_exe(cache, folder, name, discovered, env, exact=exact)
if info is not None:
self._cache_exe_discovery[key] = info
return info
if exact is False and discovered:
info = self._select_most_likely(discovered, self)
folders = os.pathsep.join(possible_folders)
self._cache_exe_discovery[key] = info
_LOGGER.debug("no exact match found, chosen most similar of %s within base folders %s", info, folders)
return info
msg = "failed to detect {} in {}".format("|".join(possible_names), os.pathsep.join(possible_folders))
raise RuntimeError(msg)
def _check_exe( # noqa: PLR0913
self,
cache: PyInfoCache | None,
folder: str,
name: str,
discovered: list[PythonInfo],
env: Mapping[str, str],
*,
exact: bool,
) -> PythonInfo | None:
exe_path = os.path.join(folder, name)
if not os.path.exists(exe_path):
return None
info = self.from_exe(exe_path, cache, resolve_to_host=False, raise_on_error=False, env=env)
if info is None: # ignore if for some reason we can't query
return None
for item in ["implementation", "architecture", "machine", "version_info", "free_threaded", "debug_build"]:
found = getattr(info, item)
searched = getattr(self, item)
if found != searched:
if item == "version_info":
found, searched = ".".join(str(i) for i in found), ".".join(str(i) for i in searched)
executable = info.executable
_LOGGER.debug("refused interpreter %s because %s differs %s != %s", executable, item, found, searched)
if exact is False:
discovered.append(info)
break
else:
return info
return None
@staticmethod
def _select_most_likely(discovered: list[PythonInfo], target: PythonInfo) -> PythonInfo:
def sort_by(info: PythonInfo) -> int:
# we need to setup some priority of traits, this is as follows:
# implementation, major, minor, architecture, machine, micro, tag, serial
matches = [
info.implementation == target.implementation,
info.version_info.major == target.version_info.major,
info.version_info.minor == target.version_info.minor,
info.architecture == target.architecture,
info.machine == target.machine,
info.version_info.micro == target.version_info.micro,
info.version_info.releaselevel == target.version_info.releaselevel,
info.version_info.serial == target.version_info.serial,
]
return sum((1 << pos if match else 0) for pos, match in enumerate(reversed(matches)))
sorted_discovered = sorted(discovered, key=sort_by, reverse=True) # sort by priority in decreasing order
return sorted_discovered[0]
def _find_possible_folders(self, inside_folder: str) -> list[str]:
candidate_folder = OrderedDict()
executables = OrderedDict()
executables[os.path.realpath(self.executable)] = None
executables[self.executable] = None
executables[os.path.realpath(self.original_executable)] = None
executables[self.original_executable] = None
for exe in executables:
base = os.path.dirname(exe)
if base.startswith(self.prefix):
relative = base[len(self.prefix) :]
candidate_folder[f"{inside_folder}{relative}"] = None
# or at root level
candidate_folder[inside_folder] = None
return [i for i in candidate_folder if os.path.exists(i)]
def _find_possible_exe_names(self) -> list[str]:
name_candidate = OrderedDict()
mods = ["", "t"] if self.free_threaded else [""]
# _d (Windows python_d.exe), -dbg (Debian's own name), d (the abiflags form used by
# upstream/Fedora and also shipped by Debian, so pythonX.Yd / pythonX.Ytd resolve too)
debug_suffixes = ["_d", "-dbg", "d", ""] if self.debug_build else [""]
archs = [f"-{self.architecture}", ""]
for name in self._possible_base():
for at in (3, 2, 1, 0):
version = ".".join(str(i) for i in self.version_info[:at])
for mod, debug, arch, ext in product(mods, debug_suffixes, archs, EXTENSIONS):
name_candidate[f"{name}{version}{mod}{debug}{arch}{ext}"] = None
return list(name_candidate.keys())
def _possible_base(self) -> Generator[str, None, None]:
possible_base = OrderedDict()
basename = os.path.splitext(os.path.basename(self.executable))[0].rstrip(digits)
possible_base[basename] = None
possible_base[self.implementation] = None
# python is always the final option as in practice is used by multiple implementation as exe name
if "python" in possible_base:
del possible_base["python"]
possible_base["python"] = None
for base in possible_base:
lower = base.lower()
yield lower
from ._compat import fs_is_case_sensitive # noqa: PLC0415
if fs_is_case_sensitive(): # pragma: no branch
if base != lower:
yield base
upper = base.upper()
if upper != base:
yield upper
KNOWN_ARCHITECTURES: frozenset[str] = frozenset({
"arm64",
"loongarch64",
"ppc",
"ppc64",
"ppc64le",
"riscv64",
"s390x",
"sparc64",
"x86",
"x86_64",
})
"""Known CPU architecture (ISA) values after normalization.
.. deprecated::
Use :func:`normalize_isa` instead, which handles both known and unknown architectures.
"""
def normalize_isa(isa: str) -> str:
"""
Normalize an ISA (instruction set architecture) string to a canonical form.
Known aliases are mapped (e.g. ``amd64`` → ``x86_64``, ``aarch64`` → ``arm64``).
Unrecognized values are lowercased and returned as-is.
"""
low = isa.lower()
return {
"amd64": "x86_64",
"aarch64": "arm64",
"i386": "x86",
"i486": "x86",
"i586": "x86",
"i686": "x86",
"powerpc": "ppc",
"powerpc64": "ppc64",
"powerpc64le": "ppc64le",
"sparcv9": "sparc64",
}.get(low, low)
def _main() -> None: # pragma: no cover
argv = sys.argv[1:]
if len(argv) >= 1:
start_cookie = argv[0]
argv = argv[1:]
else:
start_cookie = ""
if len(argv) >= 1:
end_cookie = argv[0]
argv = argv[1:]
else:
end_cookie = ""
sys.argv = sys.argv[:1] + argv
result = PythonInfo().to_json()
sys.stdout.write("".join((start_cookie[::-1], result, end_cookie[::-1])))
sys.stdout.flush()
if __name__ == "__main__":
_main()
@@ -0,0 +1,287 @@
"""A Python specification is an abstract requirement definition of an interpreter."""
from __future__ import annotations
import contextlib
import pathlib
import re
from typing import Final
from ._py_info import normalize_isa
from ._specifier import SimpleSpecifier, SimpleSpecifierSet, SimpleVersion
PATTERN = re.compile(
r"""
^
(?P<impl>[a-zA-Z]+)? # implementation (e.g. cpython, pypy)
(?P<version>[0-9.]+)? # version (e.g. 3.12, 3.12.1)
(?P<threaded>t)? # free-threaded flag
(?P<debug>d|(?:-dbg|-debug)(?=-|$))? # debug build flag (d, -dbg or -debug)
(?:-(?P<arch>32|64))? # architecture bitness
(?:-(?P<machine>[a-zA-Z0-9_.]+))? # ISA (e.g. arm64, x86_64, i86pc.64bit)
$
""",
re.VERBOSE,
)
SPECIFIER_PATTERN = re.compile(
r"""
^
(?:(?P<impl>[A-Za-z]+)\s*)? # optional implementation prefix
(?P<spec>(?:===|==|~=|!=|<=|>=|<|>).+) # PEP 440 version specifier
$
""",
re.VERBOSE,
)
_MAX_VERSION_PARTS: Final[int] = 3
_SINGLE_DIGIT_MAX: Final[int] = 9
KNOWN_IMPLEMENTATIONS: Final[tuple[str, ...]] = ("python", "cpython", "pypy", "graalpy")
SpecifierSet = SimpleSpecifierSet
Version = SimpleVersion
InvalidSpecifier = ValueError
InvalidVersion = ValueError
def _int_or_none(val: str | None) -> int | None:
return None if val is None else int(val)
def _parse_version_parts(version: str) -> tuple[int | None, int | None, int | None]:
versions = tuple(int(i) for i in version.split(".") if i)
if len(versions) > _MAX_VERSION_PARTS:
msg = "too many version parts"
raise ValueError(msg)
if len(versions) == _MAX_VERSION_PARTS:
return versions[0], versions[1], versions[2]
if len(versions) == 2: # noqa: PLR2004
return versions[0], versions[1], None
version_data = versions[0]
major = int(str(version_data)[0])
minor = int(str(version_data)[1:]) if version_data > _SINGLE_DIGIT_MAX else None
return major, minor, None
def _parse_spec_pattern(string_spec: str) -> PythonSpec | None:
if not (match := re.match(PATTERN, string_spec)):
return None
groups = match.groupdict()
version = groups["version"]
major, minor, micro, threaded = None, None, None, None
debug = True if groups["debug"] else None # unconstrained unless an explicit d/-dbg/-debug marker is present
if version is not None:
try:
major, minor, micro = _parse_version_parts(version)
except ValueError:
return None
threaded = bool(groups["threaded"])
impl = groups["impl"]
if impl in {"py", "python"}:
impl = None
if impl == "graalvm":
impl = "graalpy"
arch = _int_or_none(groups["arch"])
machine = groups.get("machine")
if machine is not None:
machine = normalize_isa(machine)
return PythonSpec(
string_spec, impl, major, minor, micro, arch, None, free_threaded=threaded, machine=machine, debug=debug
)
def _parse_specifier(string_spec: str) -> PythonSpec | None:
if not (specifier_match := SPECIFIER_PATTERN.match(string_spec.strip())):
return None
if SpecifierSet is None: # pragma: no cover
return None
impl = specifier_match.group("impl")
spec_text = specifier_match.group("spec").strip()
try:
version_specifier = SpecifierSet.from_string(spec_text)
except InvalidSpecifier: # pragma: no cover
return None
if impl in {"py", "python"}:
impl = None
if impl == "graalvm":
impl = "graalpy"
return PythonSpec(string_spec, impl, None, None, None, None, None, version_specifier=version_specifier)
class PythonSpec:
"""
Contains specification about a Python Interpreter.
:param str_spec: the raw specification string as provided by the caller.
:param implementation: interpreter implementation name (e.g. ``"cpython"``, ``"pypy"``), or ``None`` for any.
:param major: required major version, or ``None`` for any.
:param minor: required minor version, or ``None`` for any.
:param micro: required micro (patch) version, or ``None`` for any.
:param architecture: required pointer-size bitness (``32`` or ``64``), or ``None`` for any.
:param path: filesystem path to a specific interpreter, or ``None``.
:param free_threaded: whether a free-threaded build is required, or ``None`` for any.
:param machine: required ISA (e.g. ``"arm64"``), or ``None`` for any.
:param debug: whether a debug (``Py_DEBUG``) build is required, or ``None`` for any.
:param version_specifier:
`version specifier <https://packaging.python.org/en/latest/specifications/version-specifiers/>`_
constraints, or ``None``.
"""
def __init__( # noqa: PLR0913, PLR0917
self,
str_spec: str,
implementation: str | None,
major: int | None,
minor: int | None,
micro: int | None,
architecture: int | None,
path: str | None,
*,
free_threaded: bool | None = None,
machine: str | None = None,
debug: bool | None = None,
version_specifier: SpecifierSet | None = None,
) -> None:
self.str_spec = str_spec
self.implementation = implementation
self.major = major
self.minor = minor
self.micro = micro
self.free_threaded = free_threaded
self.architecture = architecture
self.machine = machine
self.debug = debug
self.path = path
self.version_specifier = version_specifier
@classmethod
def from_string_spec(cls, string_spec: str) -> PythonSpec:
"""
Parse a string specification into a :class:`PythonSpec`.
:param string_spec: an interpreter spec — an absolute path, a version string, an implementation prefix,
or a `version specifier <https://packaging.python.org/en/latest/specifications/version-specifiers/>`_.
"""
if pathlib.Path(string_spec).is_absolute():
return cls(string_spec, None, None, None, None, None, string_spec)
if result := _parse_spec_pattern(string_spec):
return result
if result := _parse_specifier(string_spec):
return result
return cls(string_spec, None, None, None, None, None, string_spec)
def generate_re(self, *, windows: bool, all_implementations: bool = False) -> re.Pattern:
"""
Generate a regular expression for matching interpreter filenames.
:param windows: if ``True``, require a ``.exe`` suffix.
:param all_implementations: when ``True`` and the spec does not constrain the implementation, match every
filename in :data:`KNOWN_IMPLEMENTATIONS` instead of only ``python``. Used by enumeration APIs.
"""
version = r"{}(\.{}(\.{})?)?".format(
*(r"\d+" if v is None else v for v in (self.major, self.minor, self.micro)),
)
if self.implementation is not None:
impl = f"python|{re.escape(self.implementation)}"
elif all_implementations:
impl = "|".join(re.escape(i) for i in KNOWN_IMPLEMENTATIONS)
else:
impl = "python"
mod = "t?" if self.free_threaded else ""
dbg = "(?:d|-dbg|-debug)?" if self.debug else ""
suffix = r"\.exe" if windows else ""
version_conditional = "?" if windows or self.major is None else ""
return re.compile(
rf"(?P<impl>{impl})(?P<v>{version}{mod}){version_conditional}{dbg}{suffix}$",
flags=re.IGNORECASE,
)
@property
def is_abs(self) -> bool:
"""``True`` if the spec refers to an absolute filesystem path."""
return self.path is not None and pathlib.Path(self.path).is_absolute()
def _check_version_specifier(self, spec: PythonSpec) -> bool:
"""Check if version specifier is satisfied."""
components: list[int] = []
for part in (self.major, self.minor, self.micro):
if part is None:
break
components.append(part)
if not components:
return True
version_str = ".".join(str(part) for part in components)
if spec.version_specifier is None:
return True
with contextlib.suppress(InvalidVersion):
Version.from_string(version_str)
for item in spec.version_specifier:
required_precision = self._get_required_precision(item)
if required_precision is None or len(components) < required_precision:
continue
if not item.contains(version_str):
return False
return True
@staticmethod
def _get_required_precision(item: SimpleSpecifier) -> int | None:
"""Get the required precision for a specifier item."""
if item.version is None:
return None
with contextlib.suppress(AttributeError, ValueError):
return len(item.version.release)
return None
def satisfies(self, spec: PythonSpec) -> bool: # noqa: PLR0911
"""
Check if this spec is compatible with the given *spec* (e.g. PEP-514 on Windows).
:param spec: the requirement to check against.
"""
if spec.is_abs and self.is_abs and self.path != spec.path:
return False
if (
spec.implementation is not None
and self.implementation is not None
and spec.implementation.lower() != self.implementation.lower()
):
return False
if spec.architecture is not None and spec.architecture != self.architecture:
return False
if spec.machine is not None and self.machine is not None and spec.machine != self.machine:
return False
if spec.free_threaded is not None and spec.free_threaded != self.free_threaded:
return False
if spec.version_specifier is not None and not self._check_version_specifier(spec):
return False
return all(
req is None or our is None or our == req
for our, req in zip((self.major, self.minor, self.micro), (spec.major, spec.minor, spec.micro))
)
def __repr__(self) -> str:
name = type(self).__name__
params = (
"implementation",
"major",
"minor",
"micro",
"architecture",
"machine",
"path",
"free_threaded",
"debug",
"version_specifier",
)
return f"{name}({', '.join(f'{k}={getattr(self, k)}' for k in params if getattr(self, k) is not None)})"
__all__ = [
"KNOWN_IMPLEMENTATIONS",
"InvalidSpecifier",
"InvalidVersion",
"PythonSpec",
"SpecifierSet",
"Version",
]
@@ -0,0 +1,311 @@
"""Version specifier support using only standard library (PEP 440 compatible)."""
from __future__ import annotations
import contextlib
import operator
import re
import sys
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final
_DC_KW = {"frozen": True, "kw_only": True, "slots": True} if sys.version_info >= (3, 10) else {"frozen": True}
if TYPE_CHECKING:
from collections.abc import Iterator
_VERSION_RE: Final[re.Pattern[str]] = re.compile(
r"""
^
(\d+) # major
(?:\.(\d+))? # optional minor
(?:\.(\d+))? # optional micro
(?:(a|b|rc)(\d+))? # optional pre-release suffix
$
""",
re.VERBOSE,
)
_SPECIFIER_RE: Final[re.Pattern[str]] = re.compile(
r"""
^
(===|==|~=|!=|<=|>=|<|>) # operator
\s*
(.+) # version string
$
""",
re.VERBOSE,
)
_PRE_ORDER: Final[dict[str, int]] = {"a": 1, "b": 2, "rc": 3}
@dataclass(**_DC_KW)
class SimpleVersion:
"""
Simple PEP 440-like version parser using only standard library.
:param version_str: the original version string.
:param major: major version number.
:param minor: minor version number.
:param micro: micro (patch) version number.
:param pre_type: pre-release label (``"a"``, ``"b"``, or ``"rc"``), or ``None``.
:param pre_num: pre-release sequence number, or ``None``.
:param release: the ``(major, minor, micro)`` tuple.
"""
version_str: str
major: int
minor: int
micro: int
pre_type: str | None
pre_num: int | None
release: tuple[int, int, int]
@classmethod
def from_string(cls, version_str: str) -> SimpleVersion:
"""
Parse a PEP 440 version string (e.g. ``3.12.1``).
:param version_str: the version string to parse.
"""
stripped = version_str.strip()
if not (match := _VERSION_RE.match(stripped)):
msg = f"Invalid version: {version_str}"
raise ValueError(msg)
major = int(match.group(1))
minor = int(match.group(2)) if match.group(2) else 0
micro = int(match.group(3)) if match.group(3) else 0
return cls(
version_str=stripped,
major=major,
minor=minor,
micro=micro,
pre_type=match.group(4),
pre_num=int(match.group(5)) if match.group(5) else None,
release=(major, minor, micro),
)
def __eq__(self, other: object) -> bool:
if not isinstance(other, SimpleVersion):
return NotImplemented
return self.release == other.release and self.pre_type == other.pre_type and self.pre_num == other.pre_num
def __hash__(self) -> int:
return hash((self.release, self.pre_type, self.pre_num))
def __lt__(self, other: object) -> bool: # noqa: PLR0911
if not isinstance(other, SimpleVersion):
return NotImplemented
if self.release != other.release:
return self.release < other.release
if self.pre_type is None and other.pre_type is None:
return False
if self.pre_type is None:
return False
if other.pre_type is None:
return True
if _PRE_ORDER[self.pre_type] != _PRE_ORDER[other.pre_type]:
return _PRE_ORDER[self.pre_type] < _PRE_ORDER[other.pre_type]
return (self.pre_num or 0) < (other.pre_num or 0)
def __le__(self, other: object) -> bool:
return self == other or self < other
def __gt__(self, other: object) -> bool:
if not isinstance(other, SimpleVersion):
return NotImplemented
return not self <= other
def __ge__(self, other: object) -> bool:
return not self < other
def __str__(self) -> str:
return self.version_str
def __repr__(self) -> str:
return f"SimpleVersion('{self.version_str}')"
@dataclass(**_DC_KW)
class SimpleSpecifier:
"""
Simple PEP 440-like version specifier using only standard library.
:param spec_str: the original specifier string (e.g. ``>=3.10``).
:param operator: the comparison operator (``==``, ``>=``, ``<``, etc.).
:param version_str: the version portion of the specifier, without the operator.
:param is_wildcard: ``True`` if the specifier uses a wildcard suffix (``.*``).
:param wildcard_precision: number of version components before the wildcard, or ``None``.
:param version: the parsed version, or ``None`` if parsing failed.
"""
spec_str: str
operator: str
version_str: str
is_wildcard: bool
wildcard_precision: int | None
version: SimpleVersion | None
@classmethod
def from_string(cls, spec_str: str) -> SimpleSpecifier:
"""
Parse a single PEP 440 specifier (e.g. ``>=3.10``).
:param spec_str: the specifier string to parse.
"""
stripped = spec_str.strip()
if not (match := _SPECIFIER_RE.match(stripped)):
msg = f"Invalid specifier: {spec_str}"
raise ValueError(msg)
op = match.group(1)
version_str = match.group(2).strip()
is_wildcard = version_str.endswith(".*")
wildcard_precision: int | None = None
if is_wildcard:
version_str = version_str[:-2]
wildcard_precision = len(version_str.split("."))
try:
version = SimpleVersion.from_string(version_str)
except ValueError:
version = None
return cls(
spec_str=stripped,
operator=op,
version_str=version_str,
is_wildcard=is_wildcard,
wildcard_precision=wildcard_precision,
version=version,
)
def contains(self, version_str: str) -> bool:
"""
Check if a version string satisfies this specifier.
:param version_str: the version string to test.
"""
try:
candidate = SimpleVersion.from_string(version_str) if isinstance(version_str, str) else version_str
except ValueError:
return False
if self.version is None:
return False
if self.is_wildcard:
return self._check_wildcard(candidate)
return self._check_standard(candidate)
def _check_wildcard(self, candidate: SimpleVersion) -> bool:
if self.version is None: # pragma: no branch
return False # pragma: no cover
if self.operator == "==":
return candidate.release[: self.wildcard_precision] == self.version.release[: self.wildcard_precision]
if self.operator == "!=":
return candidate.release[: self.wildcard_precision] != self.version.release[: self.wildcard_precision]
return False
def _check_standard(self, candidate: SimpleVersion) -> bool:
if self.version is None: # pragma: no branch
return False # pragma: no cover
if self.operator == "===":
return str(candidate) == str(self.version)
if self.operator == "~=":
return self._check_compatible_release(candidate)
cmp_ops = {
"==": operator.eq,
"!=": operator.ne,
"<": operator.lt,
"<=": operator.le,
">": operator.gt,
">=": operator.ge,
}
if self.operator in cmp_ops:
return cmp_ops[self.operator](candidate, self.version)
return False
def _check_compatible_release(self, candidate: SimpleVersion) -> bool:
if self.version is None:
return False
if candidate < self.version:
return False
if len(self.version.release) >= 2: # noqa: PLR2004 # pragma: no branch # SimpleVersion always has 3-part release
upper_parts = list(self.version.release[:-1])
upper_parts[-1] += 1
upper = SimpleVersion.from_string(".".join(str(p) for p in upper_parts))
return candidate < upper
return True # pragma: no cover
def __eq__(self, other: object) -> bool:
if not isinstance(other, SimpleSpecifier):
return NotImplemented
return self.spec_str == other.spec_str
def __hash__(self) -> int:
return hash(self.spec_str)
def __str__(self) -> str:
return self.spec_str
def __repr__(self) -> str:
return f"SimpleSpecifier('{self.spec_str}')"
@dataclass(**_DC_KW)
class SimpleSpecifierSet:
"""
Simple PEP 440-like specifier set using only standard library.
:param specifiers_str: the original comma-separated specifier string.
:param specifiers: the parsed individual specifiers.
"""
specifiers_str: str
specifiers: tuple[SimpleSpecifier, ...]
@classmethod
def from_string(cls, specifiers_str: str = "") -> SimpleSpecifierSet:
"""
Parse a comma-separated PEP 440 specifier string (e.g. ``>=3.10,<4``).
:param specifiers_str: the specifier string to parse.
"""
stripped = specifiers_str.strip()
specs: list[SimpleSpecifier] = []
if stripped:
for spec_item in stripped.split(","):
item = spec_item.strip()
if item:
with contextlib.suppress(ValueError):
specs.append(SimpleSpecifier.from_string(item))
return cls(specifiers_str=stripped, specifiers=tuple(specs))
def contains(self, version_str: str) -> bool:
"""
Check if a version satisfies all specifiers in the set.
:param version_str: the version string to test.
"""
if not self.specifiers:
return True
return all(spec.contains(version_str) for spec in self.specifiers)
def __iter__(self) -> Iterator[SimpleSpecifier]:
return iter(self.specifiers)
def __eq__(self, other: object) -> bool:
if not isinstance(other, SimpleSpecifierSet):
return NotImplemented
return self.specifiers_str == other.specifiers_str
def __hash__(self) -> int:
return hash(self.specifiers_str)
def __str__(self) -> str:
return self.specifiers_str
def __repr__(self) -> str:
return f"SimpleSpecifierSet('{self.specifiers_str}')"
__all__ = [
"SimpleSpecifier",
"SimpleSpecifierSet",
"SimpleVersion",
]
@@ -0,0 +1,13 @@
"""Windows-specific Python discovery via PEP 514 registry entries."""
from __future__ import annotations
from ._pep514 import _run, discover_pythons
from ._propose import Pep514PythonInfo, propose_interpreters
__all__ = [
"Pep514PythonInfo",
"_run",
"discover_pythons",
"propose_interpreters",
]
@@ -0,0 +1,222 @@
"""Implement https://www.python.org/dev/peps/pep-0514/ to discover interpreters - Windows only."""
from __future__ import annotations
import logging
import os
import re
import sys
import winreg
from logging import basicConfig, getLogger
from typing import TYPE_CHECKING, Any, Final
if TYPE_CHECKING:
from collections.abc import Generator
_RegistrySpec = tuple[str, int | None, int | None, int, bool, str, str | None]
_LOGGER: Final[logging.Logger] = getLogger(__name__)
_ARCH_RE: Final[re.Pattern[str]] = re.compile(
r"""
^
(\d+) # bitness number
bit # literal suffix
$
""",
re.VERBOSE,
)
_VERSION_RE: Final[re.Pattern[str]] = re.compile(
r"""
^
(\d+) # major
(?:\.(\d+))? # optional minor
(?:\.(\d+))? # optional micro
$
""",
re.VERBOSE,
)
_THREADED_TAG_RE: Final[re.Pattern[str]] = re.compile(
r"""
^
\d+ # major
(\.\d+){0,2} # optional minor/micro
t # free-threaded flag
$
""",
re.VERBOSE | re.IGNORECASE,
)
def enum_keys(key: Any) -> Generator[str, None, None]: # noqa: ANN401
at = 0
while True:
try:
yield winreg.EnumKey(key, at) # ty: ignore[unresolved-attribute]
except OSError:
break
at += 1
def get_value(key: Any, value_name: str | None) -> Any: # noqa: ANN401
try:
return winreg.QueryValueEx(key, value_name)[0] # ty: ignore[unresolved-attribute]
except OSError:
return None
def discover_pythons() -> Generator[_RegistrySpec, None, None]:
for hive, hive_name, key, flags, default_arch in [
(winreg.HKEY_CURRENT_USER, "HKEY_CURRENT_USER", r"Software\Python", 0, 64), # ty: ignore[unresolved-attribute]
(winreg.HKEY_LOCAL_MACHINE, "HKEY_LOCAL_MACHINE", r"Software\Python", winreg.KEY_WOW64_64KEY, 64), # ty: ignore[unresolved-attribute]
(winreg.HKEY_LOCAL_MACHINE, "HKEY_LOCAL_MACHINE", r"Software\Python", winreg.KEY_WOW64_32KEY, 32), # ty: ignore[unresolved-attribute]
]:
yield from process_set(hive, hive_name, key, flags, default_arch)
def process_set(
hive: int,
hive_name: str,
key: str,
flags: int,
default_arch: int,
) -> Generator[_RegistrySpec, None, None]:
try:
with winreg.OpenKeyEx(hive, key, 0, winreg.KEY_READ | flags) as root_key: # ty: ignore[unresolved-attribute]
for company in enum_keys(root_key):
if company == "PyLauncher": # reserved
continue
yield from process_company(hive_name, company, root_key, default_arch)
except OSError:
pass
def process_company(
hive_name: str,
company: str,
root_key: Any, # noqa: ANN401
default_arch: int,
) -> Generator[_RegistrySpec, None, None]:
with winreg.OpenKeyEx(root_key, company) as company_key: # ty: ignore[unresolved-attribute]
for tag in enum_keys(company_key):
spec = process_tag(hive_name, company, company_key, tag, default_arch)
if spec is not None:
yield spec
def process_tag(hive_name: str, company: str, company_key: Any, tag: str, default_arch: int) -> _RegistrySpec | None: # noqa: ANN401
with winreg.OpenKeyEx(company_key, tag) as tag_key: # ty: ignore[unresolved-attribute]
version = load_version_data(hive_name, company, tag, tag_key)
if version is not None: # if failed to get version bail
major, minor, _ = version
arch = load_arch_data(hive_name, company, tag, tag_key, default_arch)
if arch is not None:
exe_data = load_exe(hive_name, company, company_key, tag)
if exe_data is not None:
exe, args = exe_data
threaded = load_threaded(hive_name, company, tag, tag_key)
return company, major, minor, arch, threaded, exe, args
return None
return None
return None
def load_exe(hive_name: str, company: str, company_key: Any, tag: str) -> tuple[str, str | None] | None: # noqa: ANN401
key_path = f"{hive_name}/{company}/{tag}"
try:
with winreg.OpenKeyEx(company_key, rf"{tag}\InstallPath") as ip_key, ip_key: # ty: ignore[unresolved-attribute]
if (exe := _resolve_exe(ip_key, key_path)) is not None and os.path.exists(exe):
return exe, get_value(ip_key, "ExecutableArguments")
msg(key_path, f"could not load exe with value {exe}")
except OSError:
msg(f"{key_path}/InstallPath", "missing")
return None
def _resolve_exe(ip_key: Any, key_path: str) -> str | None: # noqa: ANN401
if (exe := get_value(ip_key, "ExecutablePath")) is not None:
return exe
if (ip := get_value(ip_key, None)) is None:
msg(key_path, "no ExecutablePath or default for it")
return None
return os.path.join(ip, "python.exe")
def load_arch_data(hive_name: str, company: str, tag: str, tag_key: Any, default_arch: int) -> int | None: # noqa: ANN401
arch_str = get_value(tag_key, "SysArchitecture")
if arch_str is not None:
key_path = f"{hive_name}/{company}/{tag}/SysArchitecture"
try:
return parse_arch(arch_str)
except ValueError as sys_arch:
msg(key_path, sys_arch)
return default_arch
def parse_arch(arch_str: Any) -> int: # noqa: ANN401
if isinstance(arch_str, str):
if match := _ARCH_RE.match(arch_str):
return int(next(iter(match.groups())))
error = f"invalid format {arch_str}"
else:
error = f"arch is not string: {arch_str!r}"
raise ValueError(error)
def load_version_data(
hive_name: str,
company: str,
tag: str,
tag_key: Any, # noqa: ANN401
) -> tuple[int | None, int | None, int | None] | None:
for candidate, key_path in [
(get_value(tag_key, "SysVersion"), f"{hive_name}/{company}/{tag}/SysVersion"),
(tag, f"{hive_name}/{company}/{tag}"),
]:
if candidate is not None:
try:
return parse_version(candidate)
except ValueError as sys_version:
msg(key_path, sys_version)
return None
def parse_version(version_str: Any) -> tuple[int | None, int | None, int | None]: # noqa: ANN401
if isinstance(version_str, str):
if match := _VERSION_RE.match(version_str):
g1, g2, g3 = match.groups()
return (
int(g1) if g1 is not None else None,
int(g2) if g2 is not None else None,
int(g3) if g3 is not None else None,
)
error = f"invalid format {version_str}"
else:
error = f"version is not string: {version_str!r}"
raise ValueError(error)
def load_threaded(hive_name: str, company: str, tag: str, tag_key: Any) -> bool: # noqa: ANN401
display_name = get_value(tag_key, "DisplayName")
if display_name is not None:
if isinstance(display_name, str):
if "freethreaded" in display_name.lower():
return True
else:
key_path = f"{hive_name}/{company}/{tag}/DisplayName"
msg(key_path, f"display name is not string: {display_name!r}")
return bool(_THREADED_TAG_RE.match(tag))
def msg(path: str, what: object) -> None:
_LOGGER.warning("PEP-514 violation in Windows Registry at %s error: %s", path, what)
def _run() -> None:
basicConfig()
interpreters = [repr(spec) for spec in discover_pythons()]
sys.stdout.write("\n".join(sorted(interpreters)))
sys.stdout.write("\n")
if __name__ == "__main__":
_run()
@@ -0,0 +1,53 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from python_discovery._py_info import PythonInfo
from python_discovery._py_spec import PythonSpec
from ._pep514 import discover_pythons
if TYPE_CHECKING:
from collections.abc import Generator, Mapping
from python_discovery._cache import PyInfoCache
_IMPLEMENTATION_BY_ORG: dict[str, str] = {
"ContinuumAnalytics": "CPython",
"PythonCore": "CPython",
}
class Pep514PythonInfo(PythonInfo):
"""A Python information acquired from PEP-514."""
def propose_interpreters(
spec: PythonSpec,
cache: PyInfoCache | None,
env: Mapping[str, str],
) -> Generator[PythonInfo, None, None]:
existing = list(discover_pythons())
existing.sort(
key=lambda i: (
*tuple(-1 if j is None else j for j in i[1:4]),
1 if i[0] == "PythonCore" else 0,
),
reverse=True,
)
for name, major, minor, arch, threaded, exe, _ in existing:
implementation = _IMPLEMENTATION_BY_ORG.get(name, name)
skip_pre_filter = implementation.lower() != "cpython"
registry_spec = PythonSpec("", implementation, major, minor, None, arch, exe, free_threaded=threaded)
if skip_pre_filter or registry_spec.satisfies(spec):
interpreter = Pep514PythonInfo.from_exe(exe, cache, env=env, raise_on_error=False)
if interpreter is not None and interpreter.satisfies(spec, impl_must_match=True):
yield interpreter
__all__ = [
"Pep514PythonInfo",
"propose_interpreters",
]