juc500 정상동작
This commit is contained in:
@@ -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",
|
||||
]
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
vendor/python-windows/Lib/site-packages/python_discovery/__pycache__/_cached_py_info.cpython-311.pyc
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -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",
|
||||
]
|
||||
+13
@@ -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",
|
||||
]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+222
@@ -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()
|
||||
+53
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user