Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/changelog/116.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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`.
1 change: 1 addition & 0 deletions docs/changelog/116.doc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Document the version floors: runs on Python 3.8+, discovers interpreters down to 3.6 - by :user:`gaborbernat`.
12 changes: 12 additions & 0 deletions docs/explanation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----------------------

Expand Down
70 changes: 41 additions & 29 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ test = [
"pytest>=8.3.5",
"pytest-mock>=3.14",
"setuptools>=75.1",
"vermin>=1.6",
]
docs = [
"furo>=2025.12.19",
Expand Down Expand Up @@ -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",
Expand All @@ -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"
Expand Down
94 changes: 65 additions & 29 deletions src/python_discovery/_cached_py_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,38 @@
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]
from typing import TYPE_CHECKING, Final

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]
Expand Down Expand Up @@ -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,
Expand All @@ -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


Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
Loading