diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index be85335..0327d11 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -55,3 +55,26 @@ jobs: env: PYTEST_ADDOPTS: "-vv --durations=20" DIFF_AGAINST: HEAD + old-target: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Install the latest version of uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + enable-cache: false + cache-dependency-glob: "pyproject.toml" + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Build the wheel + run: uv build --wheel --out-dir dist . + - name: Discover Python 3.6 + run: tasks/old_target_check.sh 3.6 discover + - name: Discover Python 3.7 + run: tasks/old_target_check.sh 3.7 discover + - name: Reject Python 3.5 + run: tasks/old_target_check.sh 3.5 rejected + - name: Reject Python 2.7 + run: tasks/old_target_check.sh 2.7 rejected diff --git a/README.md b/README.md index 2658525..cdc5964 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,9 @@ the right one for you. Give it a requirement like `python3.12` or `>=3.11,<3.13`, and it searches all known locations, verifies each candidate, and returns detailed metadata about the match. Results are cached to disk so repeated lookups are fast. +The library runs on Python 3.8+ and discovers interpreters as old as Python 3.6; anything older is skipped with a +warning naming the version it found. + ## Usage ```python diff --git a/docs/changelog/116.bugfix.rst b/docs/changelog/116.bugfix.rst new file mode 100644 index 0000000..a9a00be --- /dev/null +++ b/docs/changelog/116.bugfix.rst @@ -0,0 +1,4 @@ +Restore discovery of Python 3.6 and 3.7 interpreters: the interrogation script had grown 3.8-only syntax and is now +kept to Python 3.6. Candidates below 3.6 log one warning naming the version found instead of two debug-level query +attempts, and the verdict is cached so they are queried only once; an absolute-path spec for one raises +``RuntimeError`` with the same message - by :user:`gaborbernat`. diff --git a/docs/changelog/116.doc.rst b/docs/changelog/116.doc.rst new file mode 100644 index 0000000..a785dc2 --- /dev/null +++ b/docs/changelog/116.doc.rst @@ -0,0 +1 @@ +Document the version floors: runs on Python 3.8+, discovers interpreters down to 3.6 - by :user:`gaborbernat`. diff --git a/docs/explanation.rst b/docs/explanation.rst index c87eaa8..d32ffd6 100644 --- a/docs/explanation.rst +++ b/docs/explanation.rst @@ -243,6 +243,18 @@ The timeout applies to each individual interpreter being queried. If you set a v legitimate interpreters may be skipped; if too high, the discovery process may take longer to fail when encountering problematic interpreters. +Supported Python versions +------------------------- + +Two floors apply: python-discovery itself runs on Python 3.8+ (``requires-python``), while discovery +reaches down to Python 3.6 - old enough to cover RHEL 8's system Python. + +python-discovery skips candidates below 3.6 with one warning naming the executable, the version +found, and the floor; the verdict is cached, so the interpreter is not queried again. Candidates too +old to run the version check at all (before Python 2.7) fail as a generic query error. An +absolute-path spec for a below-floor interpreter raises :class:`RuntimeError` with the same message. +The floor moves only in a major release. + Spec format reference ----------------------- diff --git a/pyproject.toml b/pyproject.toml index c76c07f..a0ccd82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,7 @@ test = [ "pytest>=8.3.5", "pytest-mock>=3.14", "setuptools>=75.1", + "vermin>=1.6", ] docs = [ "furo>=2025.12.19", @@ -87,50 +88,57 @@ lint.select = [ "ALL", ] lint.ignore = [ - "COM812", # Conflict with formatter - "CPY", # No copyright statements - "D203", # `one-blank-line-before-class` and `no-blank-line-before-class` are incompatible - "D212", # `multi-line-summary-first-line` and `multi-line-summary-second-line` are incompatible - "DOC201", # `return` is not documented in docstring - "DOC402", # `yield` is not documented in docstring - "DOC501", # `raises` is not documented in docstring - "ISC001", # Conflict with formatter - "S104", # Possible binding to all interface + "CPY", # No copyright statements + "docstring-missing-exception", # `raises` is not documented in docstring + "docstring-missing-returns", # `return` is not documented in docstring + "docstring-missing-yields", # `yield` is not documented in docstring + "hardcoded-bind-all-interfaces", # Possible binding to all interface + "incorrect-blank-line-before-class", # `one-blank-line-before-class` and `no-blank-line-before-class` are incompatible + "missing-trailing-comma", # Conflict with formatter + "multi-line-summary-first-line", # `multi-line-summary-first-line` and `multi-line-summary-second-line` are incompatible + "single-line-implicit-string-concatenation", # Conflict with formatter ] lint.per-file-ignores."docs/**/*.py" = [ - "INP001", # no __init__.py in docs directory + "implicit-namespace-package", # no __init__.py in docs directory ] lint.per-file-ignores."src/python_discovery/_discovery.py" = [ "PTH", # shim resolution uses string-based os.path for consistency with env variables ] lint.per-file-ignores."src/python_discovery/_py_info.py" = [ - "PTH", # must use os.path — file runs as subprocess script with only stdlib + "PTH", # string-based os.path kept for parity with the interrogation script data +] +lint.per-file-ignores."src/python_discovery/_py_info_collect.py" = [ + "ANN", # file must parse on Python 2.7 for the version gate: no annotations + "missing-required-import", # `from __future__ import annotations` is a SyntaxError below 3.7 + "module-import-not-at-top-of-file", # the version gate must run before importing sysconfig, absent on 3.0/3.1 + "PTH", # must use os.path — file runs as subprocess script with only stdlib + "UP", # pyupgrade pushes syntax newer than the 2.7 parse floor, e.g. f-strings ] lint.per-file-ignores."src/python_discovery/_windows/_pep514.py" = [ "PTH", # os.path.exists is monkeypatched in tests; pathlib.Path.exists bypasses the mock ] lint.per-file-ignores."tasks/**/*.py" = [ - "D", # release helper scripts, not API - "INP001", # no __init__.py in tasks directory - "S404", # subprocess import (release tooling) - "S603", # `subprocess` call: argv built from trusted inputs (version, gh CLI) + "D", # release helper scripts, not API + "implicit-namespace-package", # no __init__.py in tasks directory + "subprocess-without-shell-equals-true", # `subprocess` call: argv built from trusted inputs (version, gh CLI) + "suspicious-subprocess-import", # subprocess import (release tooling) ] lint.per-file-ignores."tests/**/*.py" = [ - "D", # don't care about documentation in tests - "FBT", # don't care about booleans as positional arguments in tests - "INP001", # no implicit namespace - "PLC0415", # imports inside test functions (conditional on mocking) - "PLC2701", # private imports needed to test internal APIs - "PLR0913", # too many arguments (pytest fixtures) - "PLR0917", # too many positional arguments (pytest fixtures) - "PLR2004", # Magic value used in comparison - "S101", # asserts allowed in tests - "S404", # subprocess import - "S603", # `subprocess` call: check for execution of untrusted input - "SLF001", # private member access needed to test internals + "assert", # asserts allowed in tests + "D", # don't care about documentation in tests + "FBT", # don't care about booleans as positional arguments in tests + "implicit-namespace-package", # no implicit namespace + "import-outside-top-level", # imports inside test functions (conditional on mocking) + "import-private-name", # private imports needed to test internal APIs + "magic-value-comparison", # Magic value used in comparison + "private-member-access", # private member access needed to test internals + "subprocess-without-shell-equals-true", # `subprocess` call: check for execution of untrusted input + "suspicious-subprocess-import", # subprocess import + "too-many-arguments", # too many arguments (pytest fixtures) + "too-many-positional-arguments", # too many positional arguments (pytest fixtures) ] lint.per-file-ignores."tests/windows/winreg_mock_values.py" = [ - "F821", # undefined name (winreg available only on Windows) + "undefined-name", # undefined name (winreg available only on Windows) ] lint.isort = { known-first-party = [ "python_discovery", @@ -151,7 +159,11 @@ max_supported_python = "3.15" src.exclude = [ "tests/windows/winreg_mock_values.py" ] [[tool.ty.overrides]] -include = [ "src/python_discovery/_py_info.py", "src/python_discovery/_py_spec.py" ] +include = [ + "src/python_discovery/_py_info.py", + "src/python_discovery/_py_info_collect.py", + "src/python_discovery/_py_spec.py", +] rules.unused-ignore-comment = "ignore" rules.invalid-argument-type = "ignore" rules.invalid-return-type = "ignore" diff --git a/src/python_discovery/_cached_py_info.py b/src/python_discovery/_cached_py_info.py index ae1a1d2..e28aa0c 100644 --- a/src/python_discovery/_cached_py_info.py +++ b/src/python_discovery/_cached_py_info.py @@ -13,6 +13,7 @@ import tempfile from collections import OrderedDict from contextlib import contextmanager +from functools import lru_cache from pathlib import Path from shlex import quote from subprocess import Popen, TimeoutExpired # ruff:ignore[suspicious-subprocess-import] @@ -20,16 +21,30 @@ from ._cache import NoOpCache from ._py_info import PythonInfo +from ._py_info_collect import MIN_INTERROGATE_VERSION, UNSUPPORTED_EXIT_CODE, UNSUPPORTED_MARKER if TYPE_CHECKING: from collections.abc import Generator, Mapping + from typing import TypedDict from ._cache import ContentStore, PyInfoCache + class CacheEntryMeta(TypedDict): + """Identity of a cached interrogation result: the queried binary and the script that produced it.""" + + st_mtime: float + path: str + hash: str | None + _CACHE: OrderedDict[Path, PythonInfo | Exception] = OrderedDict() _CACHE[Path(sys.executable)] = PythonInfo() _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) +_PY_INFO_SCRIPT: Final[Path] = Path(__file__).resolve().parent / "_py_info_collect.py" + + +class _UnsupportedInterpreterError(RuntimeError): + """The interpreter is older than the interrogation script supports; retrying cannot help.""" def from_exe( # ruff:ignore[too-many-arguments] @@ -82,48 +97,54 @@ def _get_via_file_cache( 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() + py_info_hash: str | None = _script_hash() 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) + entry_meta: CacheEntryMeta = {"st_mtime": path_modified, "path": path_text, "hash": py_info_hash} 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() + cached = _read_cache_entry(cls, py_info_store, entry_meta) + if isinstance(cached, _UnsupportedInterpreterError): + return cached + py_info = cached if py_info is None: failure, py_info = _run_subprocess(cls, exe, env) - if failure is not None: + if failure is not None and not isinstance(failure, _UnsupportedInterpreterError): _LOGGER.debug("first subprocess attempt failed for %s (%s), retrying", exe, failure) failure, py_info = _run_subprocess(cls, exe, env) + if isinstance(failure, _UnsupportedInterpreterError): + _LOGGER.warning("%s", failure) # the verdict is permanent, warn once and remember it + py_info_store.write({**entry_meta, "unsupported": str(failure)}) + return failure 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, - }) + py_info_store.write({**entry_meta, "content": py_info.to_dict()}) if py_info is None: msg = f"{exe} failed to produce interpreter info" return RuntimeError(msg) return py_info +def _read_cache_entry( + cls: type[PythonInfo], + py_info_store: ContentStore, + entry_meta: CacheEntryMeta, +) -> PythonInfo | _UnsupportedInterpreterError | None: + if not py_info_store.exists() or (data := py_info_store.read()) is None: + return None + if all(data.get(key) == value for key, value in entry_meta.items()): + if isinstance(unsupported := data.get("unsupported"), str): + return _UnsupportedInterpreterError(unsupported) + if isinstance(content := data.get("content"), dict): + return _load_cached_py_info(cls, py_info_store, content) + py_info_store.remove() + return None + + def _load_cached_py_info( cls: type[PythonInfo], py_info_store: ContentStore, @@ -140,6 +161,11 @@ def _load_cached_py_info( return py_info +@lru_cache(maxsize=1) +def _script_hash() -> str: + return hashlib.sha256(_PY_INFO_SCRIPT.read_bytes()).hexdigest() + + COOKIE_LENGTH: Final[int] = 32 @@ -149,13 +175,12 @@ def gen_cookie() -> str: @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 + if _PY_INFO_SCRIPT.is_file(): + yield _PY_INFO_SCRIPT else: - data = pkgutil.get_data(__package__ or __name__, "_py_info.py") + data = pkgutil.get_data(__package__ or __name__, _PY_INFO_SCRIPT.name) if data is None: - msg = "cannot locate _py_info.py for subprocess interrogation" + msg = f"cannot locate {_PY_INFO_SCRIPT.name} for subprocess interrogation" raise FileNotFoundError(msg) fd, tmp = tempfile.mkstemp(suffix=".py") try: @@ -216,8 +241,7 @@ def _run_subprocess( 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 + return _query_failure(exe, out, err, code), None out, raw_out, out_starts, out_ends = _extract_between_cookies(out, start_cookie, end_cookie) try: result = cls.from_json(out) @@ -240,6 +264,18 @@ def _run_subprocess( return None, result +def _query_failure(exe: str, out: str, err: str | None, code: int | None) -> RuntimeError: + # the gate's exit code and stderr marker must both be present: either alone can collide, an errno of 79 from a + # failed exec or a shim that echoes the marker phrase, but only the gate produces the combination + if code == UNSUPPORTED_EXIT_CODE and err is not None and (at := err.find(UNSUPPORTED_MARKER)) != -1: + version = err[at + len(UNSUPPORTED_MARKER) :].split() or ["unknown"] + floor = ".".join(str(i) for i in MIN_INTERROGATE_VERSION) + msg = f"{exe} is Python {version[0]}, older than the minimum {floor} python-discovery can query" + return _UnsupportedInterpreterError(msg) + 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}") + + class LogCmd: def __init__(self, cmd: list[str], env: Mapping[str, str] | None = None) -> None: self.cmd = cmd diff --git a/src/python_discovery/_py_info.py b/src/python_discovery/_py_info.py index d26a29c..8e34e3c 100644 --- a/src/python_discovery/_py_info.py +++ b/src/python_discovery/_py_info.py @@ -1,4 +1,4 @@ -"""Concrete Python interpreter information, also used as subprocess interrogation script (stdlib only).""" +"""Concrete Python interpreter information, collected in-process or via subprocess interrogation.""" from __future__ import annotations @@ -6,23 +6,23 @@ 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 +from ._py_info_collect import PythonInfoCollector + if TYPE_CHECKING: - import tkinter as tk from collections.abc import Generator, Mapping + from typing import Union from ._cache import PyInfoCache from ._py_spec import PythonSpec + InfoValue = Union[str, int, bool, None, "tuple[int | str, ...]", "list[str]", "dict[str, str | int | None]"] + class VersionInfo(NamedTuple): major: int @@ -40,246 +40,47 @@ def _get_path_extensions() -> list[str]: 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: # ruff:ignore[too-many-public-methods] """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 # ruff:ignore[import-outside-top-level] - 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 # ruff:ignore[import-outside-top-level] - - 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) + platform: str + implementation: str + pypy_version_info: tuple[int, ...] + version_info: VersionInfo + architecture: int + sysconfig_platform: str | None + version_nodot: str | None + version: str + os: str + free_threaded: bool + debug_build: bool + prefix: str | None + base_prefix: str | None + real_prefix: str | None + base_exec_prefix: str | None + exec_prefix: str | None + executable: str + original_executable: str + system_executable: str | None + has_venv: bool + path: list[str] + file_system_encoding: str + stdout_encoding: str | None + sysconfig_scheme: str | None + sysconfig_paths: dict[str, str] + distutils_install: dict[str, str] + sysconfig: dict[str, str | None] + sysconfig_vars: dict[str, str | int | None] + tcl_lib: str | None + tk_lib: str | None + system_stdlib: str + system_stdlib_platform: str + max_size: int - # 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 __init__(self) -> None: + self.__dict__.update(vars(self.from_dict(PythonInfoCollector().to_dict()))) def install_path(self, key: str) -> str: """ @@ -295,36 +96,6 @@ def install_path(self, key: str) -> str: 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: - # ruff:ignore[import-outside-top-level] - from distutils import dist # ty: ignore[unresolved-import] - - # ruff:ignore[import-outside-top-level] - from distutils.command.install import SCHEME_KEYS # 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 # ruff:ignore[private-member-access] # 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``).""" @@ -351,7 +122,7 @@ 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: + def sysconfig_path(self, key: str, config_var: dict[str, str | int | None] | None = None, sep: str = os.sep) -> str: """ Return the sysconfig install path for a scheme *key*, optionally substituting config variables. @@ -568,7 +339,7 @@ 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]: + def to_dict(self) -> dict[str, InfoValue]: """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"] @@ -622,14 +393,14 @@ def from_json(cls, payload: str) -> PythonInfo: return cls.from_dict(raw.copy()) @classmethod - def from_dict(cls, data: dict[str, object]) -> PythonInfo: + def from_dict(cls, data: dict[str, InfoValue]) -> 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 = cls.__new__(cls) # skip __init__, data replaces the full state result.__dict__ = data.copy() return result @@ -851,27 +622,9 @@ def normalize_isa(isa: str) -> str: }.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() +__all__ = [ + "KNOWN_ARCHITECTURES", + "PythonInfo", + "VersionInfo", + "normalize_isa", +] diff --git a/src/python_discovery/_py_info_collect.py b/src/python_discovery/_py_info_collect.py new file mode 100644 index 0000000..fbcb3d2 --- /dev/null +++ b/src/python_discovery/_py_info_collect.py @@ -0,0 +1,345 @@ +""" +Collect interpreter information, also run as the subprocess interrogation script (stdlib only). + +Executed by the interpreter being probed. Collection supports Python 3.6+, but the file must parse on 2.7 so the +version gate below can report older interpreters instead of dying with a ``SyntaxError``: no f-strings, no walrus +operator, no annotations, no typing imports, no imports from this package. The gate sits above every other import +so that 3.0 and 3.1, which lack ``sysconfig``, still reach it. +""" + +import sys + +MIN_INTERROGATE_VERSION = (3, 6) +UNSUPPORTED_EXIT_CODE = 79 # first value past the sysexits.h range, so no stdlib or shell meaning +UNSUPPORTED_MARKER = "unsupported Python version " + +if __name__ == "__main__" and sys.version_info[:2] < MIN_INTERROGATE_VERSION: # pragma: no cover # needs <3.6 + sys.stderr.write(UNSUPPORTED_MARKER + ".".join(str(i) for i in sys.version_info[:3])) + raise SystemExit(UNSUPPORTED_EXIT_CODE) + +import json +import os +import platform +import re +import struct +import sysconfig +import warnings +from collections import namedtuple + +# ruff:ignore[collections-named-tuple] # typing.NamedTuple class syntax needs annotations, unavailable on 3.6 +VersionInfo = namedtuple("VersionInfo", ["major", "minor", "micro", "releaselevel", "serial"]) + +_32BIT_POINTER_SIZE = 4 +_CONF_VAR_RE = re.compile( + r""" + \{ \w+ } # sysconfig variable placeholder like {base} + """, + re.VERBOSE, +) + + +class PythonInfoCollector: + """Collects information about the currently running Python interpreter.""" + + def __init__(self): + self._init_identity() + self._init_prefixes() + self._init_schemes() + self._init_sysconfig() + + def _init_identity(self): + 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): + def abs_path(value): + 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): + 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): + 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 + + system_prefix = self.real_prefix or self.base_prefix or self.prefix + confs = { + k: (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 + + def _sysconfig_path(self, key, config_var): + pattern = self.sysconfig_paths.get(key) + if pattern is None: # custom builds may ship schemes without stdlib / platstdlib + return "" + base = self.sysconfig_vars.copy() + base.update(config_var) + return pattern.format(**base).replace("/", os.sep) + + @staticmethod + def _get_tcl_tk_libs(): # 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 # ruff:ignore[import-outside-top-level] # novermin # unreached below the version gate + except ImportError: + pass + else: + try: + tcl = tk.Tcl() + tcl_lib = tcl.eval("info library") + tk_lib = PythonInfoCollector._resolve_tk_lib(tcl, tcl_lib) + except tk.TclError: + pass + + return tcl_lib, tk_lib + + @staticmethod + def _query_tk_library(tcl): # pragma: no cover + """Try to get the TK library path directly from Tcl.""" + import tkinter as tk # ruff:ignore[import-outside-top-level] # novermin # unreached below the version gate + + try: + tk_lib = tcl.eval("set tk_library") + if tk_lib and os.path.isdir(tk_lib): + return tk_lib + except tk.TclError: + pass + return None + + @staticmethod + def _resolve_tk_lib(tcl, tcl_lib): # pragma: no cover + """Resolve the TK library path by direct query or path construction.""" + tk_lib = PythonInfoCollector._query_tk_library(tcl) + if tk_lib 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, "tk{}".format(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): + """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): + """ + 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": # pragma: win32 cover # CPython only does this where HAVE_READLINK + return result + if sysconfig.get_config_var("PYTHONFRAMEWORK"): # macOS framework builds self-locate via dyld; e.g. 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): + 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): + """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 = ["python{}".format(major), "python{}.{}".format(major, minor)] + if self.implementation == "PyPy": + candidates.extend(["pypy", "pypy3", "pypy{}".format(major), "pypy{}.{}".format(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 + + @staticmethod + def _distutils_install(): # pragma: <3.11 cover # 3.11+ uses the "venv" scheme instead + # 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: + # ruff:ignore[import-outside-top-level] + from distutils import dist # ty: ignore[unresolved-import] + + # ruff:ignore[import-outside-top-level] + from distutils.command.install import SCHEME_KEYS # 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 # ruff:ignore[private-member-access] # 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, "install_{}".format(key))[1:]).lstrip(os.sep) for key in SCHEME_KEYS} + + def to_dict(self): + """Convert the collected information to a plain dictionary.""" + data = {var: (getattr(self, var) if var != "_creators" else None) for var in vars(self)} + data["version_info"] = self.version_info._asdict() + return data + + +def _main(): # pragma: no cover # exercised via subprocess runs + 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 = json.dumps(PythonInfoCollector().to_dict(), indent=2) + sys.stdout.write("".join((start_cookie[::-1], result, end_cookie[::-1]))) + sys.stdout.flush() + + +__all__ = [ + "MIN_INTERROGATE_VERSION", + "UNSUPPORTED_MARKER", + "PythonInfoCollector", +] + +if __name__ == "__main__": + _main() diff --git a/tasks/old_target_check.py b/tasks/old_target_check.py new file mode 100644 index 0000000..9c0a21b --- /dev/null +++ b/tasks/old_target_check.py @@ -0,0 +1,45 @@ +"""Verify target-interpreter handling end to end; run inside a CI container with the target on PATH.""" + +from __future__ import annotations + +import logging +import sys + +from python_discovery import get_interpreter + + +def main(version: str, mode: str) -> None: + handler = _RecordingHandler() + logging.getLogger("python_discovery").addHandler(handler) + info = get_interpreter(version) + if mode == "discover": + if info is None: + msg = f"failed to discover Python {version}" + raise SystemExit(msg) + found = f"{info.version_info.major}.{info.version_info.minor}" + if found != version: + msg = f"discovered {found} at {info.executable} instead of {version}" + raise SystemExit(msg) + sys.stdout.write(f"discovered Python {version} at {info.executable}\n") + else: + if info is not None: + msg = f"expected Python {version} to be rejected, discovered {info.executable}" + raise SystemExit(msg) + warnings = [record for record in handler.records if record.levelno == logging.WARNING] + if not any("older than the minimum" in record.getMessage() for record in warnings): + msg = f"no warning recorded while rejecting Python {version}" + raise SystemExit(msg) + sys.stdout.write(f"rejected Python {version} with a warning\n") + + +class _RecordingHandler(logging.Handler): + def __init__(self) -> None: + super().__init__() + self.records: list[logging.LogRecord] = [] + + def emit(self, record: logging.LogRecord) -> None: + self.records.append(record) + + +if __name__ == "__main__": + main(sys.argv[1], sys.argv[2]) diff --git a/tasks/old_target_check.sh b/tasks/old_target_check.sh new file mode 100755 index 0000000..61a82f6 --- /dev/null +++ b/tasks/old_target_check.sh @@ -0,0 +1,8 @@ +#!/bin/sh +# Run old_target_check.py against a python:-slim container with the built wheel; args: . +set -eu +TARGET="$1" +MODE="$2" +docker run --rm -e TARGET="$TARGET" -e MODE="$MODE" -v "$PWD:/repo:ro" -v "$(dirname "$(command -v uv)"):/uv-bin:ro" \ + "python:$TARGET-slim" \ + sh -c '/uv-bin/uv run -q --python 3.13 --isolated --no-project --with /repo/dist/*.whl python /repo/tasks/old_target_check.py "$TARGET" "$MODE"' diff --git a/tests/py_info/test_py_info.py b/tests/py_info/test_py_info.py index c333248..ffbd8fe 100644 --- a/tests/py_info/test_py_info.py +++ b/tests/py_info/test_py_info.py @@ -224,8 +224,10 @@ def _make_py_info(of: PyInfoMock) -> PythonInfo: path = tmp_path / str(pos) path.write_text("", encoding="utf-8") py_info = _make_py_info(i) - py_info.system_executable = CURRENT.system_executable - py_info.executable = CURRENT.system_executable + system_executable = CURRENT.system_executable + assert system_executable is not None + py_info.system_executable = system_executable + py_info.executable = system_executable py_info.base_executable = str(path) # ty: ignore[unresolved-attribute] if pos == position: selected = py_info diff --git a/tests/test_cached_py_info.py b/tests/test_cached_py_info.py index dad4fc4..8325621 100644 --- a/tests/test_cached_py_info.py +++ b/tests/test_cached_py_info.py @@ -18,6 +18,7 @@ _load_cached_py_info, _resolve_py_info_script, _run_subprocess, + _script_hash, gen_cookie, ) @@ -47,7 +48,7 @@ def test_log_cmd_repr_with_env() -> None: def test_resolve_py_info_script_file_exists() -> None: with _resolve_py_info_script() as script: assert script.exists() - assert script.name == "_py_info.py" + assert script.name == "_py_info_collect.py" def test_resolve_py_info_script_fallback_to_pkgutil(mocker: MockerFixture) -> None: @@ -225,6 +226,7 @@ def test_from_exe_retry_on_first_failure( def test_get_via_file_cache_hash_oserror(tmp_path: Path, mocker: MockerFixture) -> None: cache = DiskCache(tmp_path) + _script_hash.cache_clear() mocker.patch("python_discovery._cached_py_info.Path.read_bytes", side_effect=OSError("permission denied")) result = _get_via_file_cache(PythonInfo, cache, Path(sys.executable), sys.executable, dict(os.environ)) assert isinstance(result, PythonInfo) @@ -238,3 +240,97 @@ def test_get_via_file_cache_py_info_none(tmp_path: Path, mocker: MockerFixture) ) result = _get_via_file_cache(PythonInfo, cache, Path("/fake"), "/fake", dict(os.environ)) assert isinstance(result, RuntimeError) + + +def _write_fake_python(tmp_path: Path, stderr: str, code: int = 79) -> Path: + """A stand-in interpreter: logs every invocation, then fails interrogation with the given stderr and exit code.""" + exe = tmp_path / "python" + exe.write_text(f'#!/bin/sh\necho "$@" >> "$0.log"\necho "{stderr}" >&2\nexit {code}\n') + exe.chmod(0o755) + return exe + + +def _interrogations(exe: Path) -> list[str]: + return Path(f"{exe}.log").read_text(encoding="utf-8").splitlines() + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell fake interpreter") +def test_from_exe_too_old_python_warns(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.INFO) + exe = _write_fake_python(tmp_path, stderr="unsupported Python version 3.5.10") + assert PythonInfo.from_exe(str(exe), raise_on_error=False, ignore_cache=True) is None + record = next(iter(caplog.records)) + assert record.levelno == logging.WARNING + assert record.getMessage() == f"{exe} is Python 3.5.10, older than the minimum 3.6 python-discovery can query" + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell fake interpreter") +def test_from_exe_too_old_python_skips_retry(tmp_path: Path) -> None: + exe = _write_fake_python(tmp_path, stderr="unsupported Python version 3.5.10") + PythonInfo.from_exe(str(exe), raise_on_error=False, ignore_cache=True) + assert len(_interrogations(exe)) == 1 + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell fake interpreter") +def test_from_exe_too_old_python_raises_by_default(tmp_path: Path) -> None: + exe = _write_fake_python(tmp_path, stderr="unsupported Python version 3.5.10") + with pytest.raises(RuntimeError, match=r"older than the minimum 3\.6 python-discovery can query"): + PythonInfo.from_exe(str(exe), ignore_cache=True) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell fake interpreter") +def test_from_exe_too_old_python_verdict_cached(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.INFO) + cache = DiskCache(tmp_path / "cache") + exe = _write_fake_python(tmp_path, stderr="unsupported Python version 3.5.10") + assert PythonInfo.from_exe(str(exe), cache, raise_on_error=False, ignore_cache=True) is None + assert PythonInfo.from_exe(str(exe), cache, raise_on_error=False, ignore_cache=True) is None + assert len(_interrogations(exe)) == 1 + assert len([record for record in caplog.records if record.levelno == logging.WARNING]) == 1 + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell fake interpreter") +def test_from_exe_marker_without_version(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.INFO) + exe = _write_fake_python(tmp_path, stderr="unsupported Python version ") + assert PythonInfo.from_exe(str(exe), raise_on_error=False, ignore_cache=True) is None + record = next(iter(caplog.records)) + assert record.getMessage() == f"{exe} is Python unknown, older than the minimum 3.6 python-discovery can query" + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell fake interpreter") +@pytest.mark.parametrize( + ("stderr", "code"), + [ + pytest.param("SyntaxError: invalid syntax", 1, id="syntax-error"), + pytest.param("unsupported Python version 3.5.10", 1, id="marker-wrong-exit-code"), + pytest.param("something crashed", 79, id="exit-code-without-marker"), + ], +) +def test_from_exe_query_failure_retries( + tmp_path: Path, caplog: pytest.LogCaptureFixture, stderr: str, code: int +) -> None: + caplog.set_level(logging.INFO) + exe = _write_fake_python(tmp_path, stderr=stderr, code=code) + assert PythonInfo.from_exe(str(exe), raise_on_error=False, ignore_cache=True) is None + assert not [record for record in caplog.records if record.levelno == logging.WARNING] + assert any("failed to query" in record.getMessage() for record in caplog.records) + assert len(_interrogations(exe)) == 2 + + +def test_from_exe_cache_entry_with_bad_content(tmp_path: Path) -> None: + cache = DiskCache(tmp_path) + path = Path(sys.executable) + env = dict(os.environ) + result = _get_via_file_cache(PythonInfo, cache, path, sys.executable, env) + assert isinstance(result, PythonInfo) + + store = cache.py_info(path) + data = store.read() + assert data is not None + data["content"] = "garbage" + store.write(data) + + required = _get_via_file_cache(PythonInfo, cache, path, sys.executable, env) + assert isinstance(required, PythonInfo) + assert isinstance(store.read(), dict) diff --git a/tests/test_py_info_collect.py b/tests/test_py_info_collect.py new file mode 100644 index 0000000..8110dc7 --- /dev/null +++ b/tests/test_py_info_collect.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +import ast +import json +import os +import subprocess +import sys +import sysconfig +from itertools import takewhile +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest +import vermin + +import python_discovery +from python_discovery import PythonInfo +from python_discovery._py_info import VersionInfo + +if TYPE_CHECKING: + from collections.abc import Callable + + from pytest_mock import MockerFixture + +SCRIPT = Path(python_discovery.__file__).parent / "_py_info_collect.py" + + +def test_script_parses_down_to_python27() -> None: + py2, py3 = vermin.detect(SCRIPT.read_text(encoding="utf-8")) + assert py2 is not None, "script no longer parses on Python 2.7, the version gate cannot run there" + assert py2 <= (2, 7) + assert py3 is not None + assert py3 <= (3, 6), "script grew a requirement newer than the collection floor" + + +def test_script_version_gate_precedes_imports() -> None: + body = ast.parse(SCRIPT.read_text(encoding="utf-8")).body + before_gate = takewhile(lambda node: not isinstance(node, ast.If), body) + imported = [ + alias.name for node in before_gate if isinstance(node, (ast.Import, ast.ImportFrom)) for alias in node.names + ] + assert imported == ["sys"], "only sys may load before the version gate; Python 3.0/3.1 lack sysconfig" + + +def _run_script(*args: str) -> str: + return subprocess.run([sys.executable, str(SCRIPT), *args], capture_output=True, text=True, check=True).stdout + + +@pytest.mark.parametrize( + ("args", "start", "end"), + [ + pytest.param((), "", "", id="no-cookies"), + pytest.param(("startcookie",), "startcookie", "", id="start-only"), + pytest.param(("startcookie", "endcookie"), "startcookie", "endcookie", id="both-cookies"), + ], +) +def test_script_wraps_payload_in_reversed_cookies(args: tuple[str, ...], start: str, end: str) -> None: + out = _run_script(*args) + prefix, suffix = start[::-1], end[::-1] + assert out.startswith(prefix) + assert out.endswith(suffix) + payload = json.loads(out[len(prefix) : len(out) - len(suffix)]) + assert payload["version_info"]["major"] == sys.version_info.major + + +def test_script_payload_matches_in_process_collection() -> None: + assert set(json.loads(_run_script())) == set(PythonInfo().to_dict()) + + +def test_script_payload_loads_as_python_info() -> None: + info = PythonInfo.from_json(_run_script()) + assert tuple(info.version_info[:3]) == tuple(sys.version_info[:3]) + assert info.executable == sys.executable + + +@pytest.fixture +def no_framework(mocker: MockerFixture) -> None: + get_config_var = sysconfig.get_config_var + mocker.patch.object( + sysconfig, + "get_config_var", + side_effect=lambda name: "" if name == "PYTHONFRAMEWORK" else get_config_var(name), + ) + + +@pytest.fixture +def not_a_venv(mocker: MockerFixture) -> None: + mocker.patch.object(sys, "real_prefix", None, create=True) + mocker.patch.object(sys, "base_prefix", sys.prefix) + + +def _layout_regular_file(tmp_path: Path) -> tuple[Path, Path]: + exe = tmp_path / "python" + exe.touch() + return exe, exe + + +def _layout_broken_symlink(tmp_path: Path) -> tuple[Path, Path]: + link = tmp_path / "python" + link.symlink_to(tmp_path / "missing") + return link, link + + +def _layout_absolute_symlink(tmp_path: Path) -> tuple[Path, Path]: + exe = tmp_path / "install" / "bin" / "python3.12" + exe.parent.mkdir(parents=True) + exe.touch() + link = tmp_path / "symdir" / "python3" + link.parent.mkdir() + link.symlink_to(exe) + return link, exe + + +def _layout_relative_chain(tmp_path: Path) -> tuple[Path, Path]: + exe = tmp_path / "python3.12" + exe.touch() + (tmp_path / "python3").symlink_to("python3.12") + link = tmp_path / "python" + link.symlink_to("python3") + return link, exe + + +def _layout_tree_symlink(tmp_path: Path) -> tuple[Path, Path]: + real_bin = tmp_path / "install" / "bin" + real_bin.mkdir(parents=True) + (real_bin / "python3").touch() + tree_link = tmp_path / "tree" + tree_link.symlink_to(tmp_path / "install") + via_tree = tree_link / "bin" / "python3" + return via_tree, via_tree + + +def _layout_normpath_mismatch(tmp_path: Path) -> tuple[Path, Path]: + real_dir = tmp_path / "deep" / "real" + real_dir.mkdir(parents=True) + (tmp_path / "deep" / "exe").touch() + (real_dir / "python").symlink_to("../exe") + dir_link = tmp_path / "link" + dir_link.symlink_to(real_dir) + via_link = dir_link / "python" + return via_link, via_link + + +def _layout_stdlib_landmark(tmp_path: Path) -> tuple[Path, Path]: + exe = tmp_path / "install" / "bin" / "python3.12" + exe.parent.mkdir(parents=True) + exe.touch() + alias_bin = tmp_path / "alias" / "bin" + alias_bin.mkdir(parents=True) + landmark = tmp_path / "alias" / "lib" / Path(os.__file__).parent.name / "os.py" + landmark.parent.mkdir(parents=True) + landmark.touch() + link = alias_bin / "python3" + link.symlink_to(exe) + return link, link + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX only") +@pytest.mark.usefixtures("no_framework", "not_a_venv") +@pytest.mark.parametrize( + "layout", + [ + pytest.param(_layout_regular_file, id="regular-file"), + pytest.param(_layout_broken_symlink, id="broken-symlink"), + pytest.param(_layout_absolute_symlink, id="absolute-symlink"), + pytest.param(_layout_relative_chain, id="relative-chain"), + pytest.param(_layout_tree_symlink, id="tree-preserved"), + pytest.param(_layout_normpath_mismatch, id="normpath-mismatch"), + pytest.param(_layout_stdlib_landmark, id="stdlib-landmark-kept"), + ], +) +def test_system_executable_resolves_executable_symlink_only( + tmp_path: Path, + mocker: MockerFixture, + layout: Callable[[Path], tuple[Path, Path]], +) -> None: + path, expected = layout(tmp_path) + mocker.patch.object(sys, "executable", str(path)) + assert PythonInfo().system_executable == str(expected) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX only") +@pytest.mark.usefixtures("not_a_venv") +def test_system_executable_framework_symlink_kept(tmp_path: Path, mocker: MockerFixture) -> None: + link, _exe = _layout_absolute_symlink(tmp_path) + get_config_var = sysconfig.get_config_var + mocker.patch.object( + sysconfig, + "get_config_var", + side_effect=lambda name: "Python" if name == "PYTHONFRAMEWORK" else get_config_var(name), + ) + mocker.patch.object(sys, "executable", str(link)) + assert PythonInfo().system_executable == str(link) + + +def _old_style_virtualenv(mocker: MockerFixture, _tmp_path: Path) -> None: + mocker.patch.object(sys, "real_prefix", "/some/real/prefix", create=True) + + +def _venv_without_base_executable(mocker: MockerFixture, _tmp_path: Path) -> None: + _venv(mocker) + mocker.patch.object(sys, "_base_executable", None, create=True) + + +def _venv_base_executable_is_self(mocker: MockerFixture, _tmp_path: Path) -> None: + _venv(mocker) + mocker.patch.object(sys, "_base_executable", sys.executable, create=True) + + +def _venv_missing_base_before_311(mocker: MockerFixture, tmp_path: Path) -> None: + _venv(mocker) + mocker.patch.object(sys, "_base_executable", str(tmp_path / "python"), create=True) + mocker.patch.object(sys, "version_info", VersionInfo(3, 9, 0, "final", 0)) + + +def _venv_missing_base_no_candidates(mocker: MockerFixture, tmp_path: Path) -> None: + _venv(mocker) + mocker.patch.object(sys, "_base_executable", str(tmp_path / "python"), create=True) + mocker.patch.object(sys, "version_info", VersionInfo(3, 12, 0, "final", 0)) + + +def _venv(mocker: MockerFixture) -> None: + mocker.patch.object(sys, "real_prefix", None, create=True) + mocker.patch.object(sys, "base_prefix", "/different/prefix") + + +@pytest.mark.parametrize( + "state", + [ + pytest.param(_old_style_virtualenv, id="old-style-virtualenv"), + pytest.param(_venv_without_base_executable, id="no-base-executable"), + pytest.param(_venv_base_executable_is_self, id="base-executable-is-self"), + pytest.param(_venv_missing_base_before_311, id="missing-base-before-311"), + pytest.param(_venv_missing_base_no_candidates, id="missing-base-no-candidates"), + ], +) +def test_system_executable_undetermined( + mocker: MockerFixture, tmp_path: Path, state: Callable[[MockerFixture, Path], None] +) -> None: + state(mocker, tmp_path) + assert PythonInfo().system_executable is None + + +def test_system_executable_from_existing_base(mocker: MockerFixture, tmp_path: Path) -> None: + base = tmp_path / "python3.12" + base.touch() + _venv(mocker) + mocker.patch.object(sys, "_base_executable", str(base), create=True) + assert PythonInfo().system_executable == str(base) + + +def test_system_executable_versioned_fallback(mocker: MockerFixture, tmp_path: Path) -> None: + _venv_missing_base_no_candidates(mocker, tmp_path) + versioned = tmp_path / "python3" + versioned.touch() + assert PythonInfo().system_executable == str(versioned) + + +def test_system_executable_pypy_fallback(mocker: MockerFixture, tmp_path: Path) -> None: + _venv_missing_base_no_candidates(mocker, tmp_path) + mocker.patch("platform.python_implementation", return_value="PyPy") + mocker.patch.object(sys, "pypy_version_info", (7, 3, 11, "final", 0), create=True) + pypy = tmp_path / "pypy3" + pypy.touch() + assert PythonInfo().system_executable == str(pypy) + + +def test_tcl_tk_libs_none_without_env(mocker: MockerFixture) -> None: + mocker.patch.dict(os.environ) + os.environ.pop("TCL_LIBRARY", None) + info = PythonInfo() + assert (info.tcl_lib, info.tk_lib) == (None, None) + + +class _TclError(Exception): + """Stands in for tkinter.TclError; an except clause needs a real exception type.""" + + +def _fake_tkinter(mocker: MockerFixture, eval_side_effect: object) -> None: + module = mocker.MagicMock(TclError=_TclError, **{"Tcl.return_value.eval.side_effect": eval_side_effect}) + mocker.patch.dict(sys.modules, {"tkinter": module}) + + +def test_tcl_tk_libs_queried_with_env(tmp_path: Path, mocker: MockerFixture) -> None: + tk_dir = tmp_path / "tk8.6" + tk_dir.mkdir() + responses = {"info library": "/tcl-lib", "set tk_library": str(tk_dir)} + _fake_tkinter(mocker, responses.__getitem__) + mocker.patch.dict(os.environ, {"TCL_LIBRARY": str(tmp_path)}) + info = PythonInfo() + assert (info.tcl_lib, info.tk_lib) == ("/tcl-lib", str(tk_dir)) + + +def test_tcl_tk_libs_none_on_tcl_error(tmp_path: Path, mocker: MockerFixture) -> None: + _fake_tkinter(mocker, _TclError("fail")) + mocker.patch.dict(os.environ, {"TCL_LIBRARY": str(tmp_path)}) + info = PythonInfo() + assert (info.tcl_lib, info.tk_lib) == (None, None) + + +def test_system_stdlib_empty_when_scheme_lacks_path(mocker: MockerFixture) -> None: + names = tuple(name for name in sysconfig.get_path_names() if name not in {"stdlib", "platstdlib"}) + mocker.patch.object(sysconfig, "get_path_names", return_value=names) + info = PythonInfo() + assert (info.system_stdlib, info.system_stdlib_platform) == ("", "") diff --git a/tests/test_py_info_extra.py b/tests/test_py_info_extra.py index 6cdebf1..5d6f08f 100644 --- a/tests/test_py_info_extra.py +++ b/tests/test_py_info_extra.py @@ -13,14 +13,7 @@ from python_discovery import DiskCache, PythonInfo, PythonSpec from python_discovery._py_info import VersionInfo -try: - import tkinter as tk # pragma: no cover -except ImportError: # pragma: no cover - tk = None # type: ignore[assignment] - if TYPE_CHECKING: - from collections.abc import Callable - from pytest_mock import MockerFixture CURRENT = PythonInfo.current_system() @@ -45,168 +38,6 @@ def test_has_venv_attribute() -> None: assert isinstance(info.has_venv, bool) -def test_tcl_tk_libs_with_env(mocker: MockerFixture) -> None: - mocker.patch.dict(os.environ, {"TCL_LIBRARY": "/some/path"}) - mocker.patch.object(PythonInfo, "_get_tcl_tk_libs", return_value=("/tcl", "/tk")) - info = PythonInfo() - assert info.tcl_lib == "/tcl" - assert info.tk_lib == "/tk" - - -def test_get_tcl_tk_libs_returns_tuple() -> None: - tcl_path, tk_path = PythonInfo._get_tcl_tk_libs() - assert tcl_path is None or isinstance(tcl_path, str) - assert tk_path is None or isinstance(tk_path, str) - - -@pytest.mark.skipif(tk is None, reason="tkinter not available") -def test_get_tcl_tk_libs_tcl_error(mocker: MockerFixture) -> None: # pragma: no cover - mock_tcl = MagicMock() - mock_tcl.eval.side_effect = tk.TclError("fail") - mocker.patch("tkinter.Tcl", return_value=mock_tcl) - - tcl, _tk = PythonInfo._get_tcl_tk_libs() - assert tcl is None - - -def test_fast_get_system_executable_not_venv() -> None: - info = PythonInfo() - info.real_prefix = None - info.base_prefix = info.prefix - result = info._fast_get_system_executable() - assert result is not None - assert Path(result).samefile(info.original_executable) - - -def test_fast_get_system_executable_real_prefix() -> None: - info = PythonInfo() - info.real_prefix = "/some/real/prefix" - assert info._fast_get_system_executable() is None - - -def test_fast_get_system_executable_no_base_executable(mocker: MockerFixture) -> None: - info = PythonInfo() - info.real_prefix = None - info.base_prefix = "/different/prefix" - mocker.patch.object(sys, "_base_executable", None, create=True) - assert info._fast_get_system_executable() is None - - -def test_fast_get_system_executable_same_as_current(mocker: MockerFixture) -> None: - info = PythonInfo() - info.real_prefix = None - info.base_prefix = "/different/prefix" - mocker.patch.object(sys, "_base_executable", sys.executable, create=True) - assert info._fast_get_system_executable() is None - - -@pytest.fixture -def posix_info() -> PythonInfo: - info = PythonInfo() - info.os = "posix" - return info - - -def test_resolve_executable_symlink_not_posix() -> None: - info = PythonInfo() - info.os = "nt" - assert info._resolve_executable_symlink("/some/python") == str(Path("/some/python").resolve()) - - -def _layout_regular_file(tmp_path: Path) -> tuple[Path, Path]: - exe = tmp_path / "python" - exe.touch() - return exe, exe - - -def _layout_broken_symlink(tmp_path: Path) -> tuple[Path, Path]: - link = tmp_path / "python" - link.symlink_to(tmp_path / "missing") - return link, link - - -def _layout_absolute_symlink(tmp_path: Path) -> tuple[Path, Path]: - exe = tmp_path / "install" / "bin" / "python3.12" - exe.parent.mkdir(parents=True) - exe.touch() - link = tmp_path / "symdir" / "python3" - link.parent.mkdir() - link.symlink_to(exe) - return link, exe - - -def _layout_relative_chain(tmp_path: Path) -> tuple[Path, Path]: - exe = tmp_path / "python3.12" - exe.touch() - (tmp_path / "python3").symlink_to("python3.12") - link = tmp_path / "python" - link.symlink_to("python3") - return link, exe - - -def _layout_tree_symlink(tmp_path: Path) -> tuple[Path, Path]: - real_bin = tmp_path / "install" / "bin" - real_bin.mkdir(parents=True) - (real_bin / "python3").touch() - tree_link = tmp_path / "tree" - tree_link.symlink_to(tmp_path / "install") - via_tree = tree_link / "bin" / "python3" - return via_tree, via_tree - - -def _layout_normpath_mismatch(tmp_path: Path) -> tuple[Path, Path]: - real_dir = tmp_path / "deep" / "real" - real_dir.mkdir(parents=True) - (tmp_path / "deep" / "exe").touch() - (real_dir / "python").symlink_to("../exe") - dir_link = tmp_path / "link" - dir_link.symlink_to(real_dir) - via_link = dir_link / "python" - return via_link, via_link - - -@pytest.mark.skipif(sys.platform == "win32", reason="POSIX only") -@pytest.mark.parametrize( - "layout", - [ - pytest.param(_layout_regular_file, id="regular-file"), - pytest.param(_layout_broken_symlink, id="broken-symlink"), - pytest.param(_layout_absolute_symlink, id="absolute-symlink"), - pytest.param(_layout_relative_chain, id="relative-chain"), - pytest.param(_layout_tree_symlink, id="tree-preserved"), - pytest.param(_layout_normpath_mismatch, id="normpath-mismatch"), - ], -) -def test_resolve_executable_symlink( - tmp_path: Path, - posix_info: PythonInfo, - layout: Callable[[Path], tuple[Path, Path]], -) -> None: - path, expected = layout(tmp_path) - assert posix_info._resolve_executable_symlink(str(path), framework=False) == str(expected) - - -@pytest.mark.skipif(sys.platform == "win32", reason="POSIX only") -def test_resolve_executable_symlink_framework_kept(tmp_path: Path, posix_info: PythonInfo) -> None: - link, _exe = _layout_absolute_symlink(tmp_path) - assert posix_info._resolve_executable_symlink(str(link), framework=True) == str(link) - - -@pytest.mark.skipif(sys.platform == "win32", reason="POSIX only") -def test_resolve_executable_symlink_stdlib_landmark_kept(tmp_path: Path, posix_info: PythonInfo) -> None: - exe = tmp_path / "install" / "bin" / "python3.12" - exe.parent.mkdir(parents=True) - exe.touch() - alias_bin = tmp_path / "alias" / "bin" - alias_bin.mkdir(parents=True) - landmark = tmp_path / "alias" / "lib" / Path(os.__file__).parent.name / "os.py" - landmark.parent.mkdir(parents=True) - landmark.touch() - link = alias_bin / "python3" - link.symlink_to(exe) - assert posix_info._resolve_executable_symlink(str(link), framework=False) == str(link) - - @pytest.mark.skipif(sys.platform == "win32", reason="POSIX only") @pytest.mark.skipif(bool(CURRENT.sysconfig_vars.get("PYTHONFRAMEWORK")), reason="framework builds keep recorded path") def test_from_exe_resolves_executable_only_symlink( # pragma: no cover # skipped on framework interpreter hosts @@ -224,52 +55,6 @@ def test_from_exe_resolves_executable_only_symlink( # pragma: no cover # skippe assert Path(info.system_executable).parent != tmp_path -def test_try_posix_fallback_not_posix() -> None: - info = PythonInfo() - info.os = "nt" - assert info._try_posix_fallback_executable("/some/python") is None - - -def test_try_posix_fallback_old_python() -> None: - info = PythonInfo() - info.os = "posix" - info.version_info = VersionInfo(3, 10, 0, "final", 0) - assert info._try_posix_fallback_executable("/some/python") is None - - -def test_try_posix_fallback_finds_versioned(tmp_path: Path) -> None: - info = PythonInfo() - info.os = "posix" - info.version_info = VersionInfo(3, 12, 0, "final", 0) - info.implementation = "CPython" - base_exe = str(tmp_path / "python") - versioned = tmp_path / "python3" - versioned.touch() - result = info._try_posix_fallback_executable(base_exe) - assert result == str(versioned) - - -def test_try_posix_fallback_pypy(tmp_path: Path) -> None: - info = PythonInfo() - info.os = "posix" - info.version_info = VersionInfo(3, 12, 0, "final", 0) - info.implementation = "PyPy" - base_exe = str(tmp_path / "python") - pypy = tmp_path / "pypy3" - pypy.touch() - result = info._try_posix_fallback_executable(base_exe) - assert result == str(pypy) - - -def test_try_posix_fallback_not_found(tmp_path: Path) -> None: - info = PythonInfo() - info.os = "posix" - info.version_info = VersionInfo(3, 12, 0, "final", 0) - info.implementation = "CPython" - base_exe = str(tmp_path / "python") - assert info._try_posix_fallback_executable(base_exe) is None - - def test_version_str() -> None: assert CURRENT.version_str == ".".join(str(i) for i in sys.version_info[:3]) @@ -461,12 +246,6 @@ def test_satisfies_path_win32(mocker: MockerFixture) -> None: assert info.satisfies(spec, impl_must_match=False) is True -def test_distutils_install() -> None: - info = PythonInfo() - result = info._distutils_install() - assert isinstance(result, dict) - - def test_install_path() -> None: assert isinstance(CURRENT.install_path("purelib"), str)