From 70972319cfdebacb79d17b32158137ea90a6c58a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 11 Aug 2026 17:01:42 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(py=5Finfo):=20discover=20Pyt?= =?UTF-8?q?hon=203.6=20and=203.7=20interpreters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interrogation script runs inside the candidate interpreter, so its syntax floor decides which Pythons are discoverable, independent of requires-python. Sharing one file between the library and that script tied the floor to the library's style: the standalone rewrite pulled in typing.Final and a pre-commit autoupdate added walrus operators, both 3.8-only, so 3.6 and 3.7 candidates died with a SyntaxError logged at debug level after two identical attempts (#116). Split the target-side collection into _py_info_collect.py. Collection supports 3.6+, reaching RHEL 8's system Python, while the rest of the library keeps modern style. The file parses down to Python 2.7 and a version gate above every other import reports older interpreters with a dedicated exit code plus a stderr marker, all within the single interrogation call; the host classifies only when both agree, so an errno collision or a shim echoing the phrase cannot misfire. The verdict is permanent, so it is written to the disk cache and warned about once; the retry that cannot succeed is skipped, and an absolute-path spec raises the same message. A vermin check, a gate placement test, and CI jobs against real 2.7 through 3.7 containers keep lint modernizations from raising the floor again. The cache keys entries on the script hash, so entries written by earlier releases re-query instead of misreading the new payload. PythonInfo.from_dict builds via __new__ instead of running a throwaway collection, cutting the cost of every cache hit. --- .github/workflows/check.yaml | 23 ++ README.md | 3 + docs/changelog/116.bugfix.rst | 4 + docs/changelog/116.doc.rst | 1 + docs/explanation.rst | 12 + pyproject.toml | 70 +++-- src/python_discovery/_cached_py_info.py | 94 ++++-- src/python_discovery/_py_info.py | 349 ++++------------------- src/python_discovery/_py_info_collect.py | 345 ++++++++++++++++++++++ tasks/old_target_check.py | 45 +++ tasks/old_target_check.sh | 8 + tests/py_info/test_py_info.py | 6 +- tests/test_cached_py_info.py | 98 ++++++- tests/test_py_info_collect.py | 305 ++++++++++++++++++++ tests/test_py_info_extra.py | 221 -------------- 15 files changed, 1004 insertions(+), 580 deletions(-) create mode 100644 docs/changelog/116.bugfix.rst create mode 100644 docs/changelog/116.doc.rst create mode 100644 src/python_discovery/_py_info_collect.py create mode 100644 tasks/old_target_check.py create mode 100755 tasks/old_target_check.sh create mode 100644 tests/test_py_info_collect.py 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)