Repository navigation
Description
A request bounded below 3.12.0 can select Python 3.12.0rc1. The same spec should keep the lookup within Python 3.11.
Affected code
src/python_discovery/_specifier.py:222 at the reported commit. The reproducer also returns BUG on current main 004294957ed227fe7e52b2864952ba09fd28b220.
Mechanism
The version comparison accepts a prerelease of the excluded upper-bound release for spellings such as <3.12.0 and ~=3.11.0.
Reproduction
Check out the reported commit or current main. Save the files below as named, then build and run:
docker build -t python-discovery-audit -f - . <<'DOCKERFILE'
FROM python:3.15-bookworm
RUN python -m venv /opt/venv
ENV PATH=/opt/venv/bin:$PATH
COPY . /src
WORKDIR /src
ENV SETUPTOOLS_SCM_PRETEND_VERSION=1.9.1
RUN pip install -e . --group test
DOCKERFILE
docker run --rm --network=none --memory=2g -u 0 -w /src \
-v "$PWD/repro:/tmp/repro:ro" -v "$PWD/repro.d:/tmp/repro.d:ro" \
python-discovery-audit /tmp/reprorepro
#!/usr/bin/env python3
import json
import os
import shutil
import subprocess
import tempfile
# set up: PATH holds a CPython 3.12.0rc1 first, then the image's CPython 3.11.2
scene = tempfile.mkdtemp(prefix="pd-prerelease-")
prerelease_dir = os.path.join(scene, "prerelease")
stable_dir = os.path.join(scene, "stable")
empty_home = os.path.join(scene, "home") # so no uv store under ~/.local/share/uv is searched
os.mkdir(prerelease_dir)
os.mkdir(stable_dir)
os.mkdir(empty_home)
# /tmp/repro.d is read-only and drops mode bits: copy the stand-in out and make it executable
rc_python = os.path.join(prerelease_dir, "python3.12")
shutil.copy("/tmp/repro.d/python3.12rc1_standin.py", rc_python)
os.chmod(rc_python, 0o755)
os.symlink("/usr/bin/python3.11", os.path.join(stable_dir, "python3.11"))
search_path = prerelease_dir + os.pathsep + stable_dir
# discover: three ranges that all stop before 3.12, each asked in a fresh process (discovery caches in-process)
picked = {}
for spec in (">=3.11,<3.12", ">=3.11,<3.12.0", "~=3.11.0"):
caller = ["/opt/venv/bin/python", "/tmp/repro.d/find_interpreter.py", spec, search_path, empty_home]
picked[spec] = json.loads(subprocess.run(caller, check=True, capture_output=True, text=True).stdout)
print(f"{spec:16} -> {picked[spec]['version_info']} {picked[spec]['executable']}")
# check: PEP 440 "<3.12.0" excludes 3.12.0rc1 and "~=3.11.0" means ==3.11.*, so all three must return 3.11.2
STABLE = [3, 11, 2, "final", 0]
RC = [3, 12, 0, "candidate", 1]
assert picked[">=3.11,<3.12"]["version_info"] == STABLE, "the '<3.12' spelling did not return 3.11.2: scene is broken"
rc_accepted = [spec for spec in (">=3.11,<3.12.0", "~=3.11.0") if picked[spec]["version_info"] == RC]
print("returned 3.12.0rc1 for:", rc_accepted)
print("BUG" if rc_accepted else "OK")repro.d/find_interpreter.py
import json
import sys
from python_discovery import get_interpreter
spec, search_path, home = sys.argv[1:]
info = get_interpreter(spec, env={"PATH": search_path, "HOME": home})
print(json.dumps({"executable": info.executable, "version_info": list(info.version_info)}))repro.d/python3.12rc1_standin.py
#!/usr/local/bin/python3.15
# The image has no pre-release build: the real 3.15 answers discovery's interrogation script
# with the version_info a CPython 3.12.0rc1 reports. Nothing else is changed.
import collections
import runpy
import sys
VersionInfo = collections.namedtuple("VersionInfo", "major minor micro releaselevel serial")
sys.version_info = VersionInfo(3, 12, 0, "candidate", 1)
# a real build reports its own path; left as python3.15, discovery would go on to probe that file instead
sys.executable = sys.argv[0]
interrogation_script = sys.argv[1]
sys.argv = sys.argv[1:]
runpy.run_path(interrogation_script, run_name="__main__")Output on 004294957ed227fe7e52b2864952ba09fd28b220:
>=3.11,<3.12 -> [3, 11, 2, 'final', 0] /tmp/pd-prerelease-6al7rzem/stable/python3.11
>=3.11,<3.12.0 -> [3, 12, 0, 'candidate', 1] /tmp/pd-prerelease-6al7rzem/prerelease/python3.12
~=3.11.0 -> [3, 11, 2, 'final', 0] /tmp/pd-prerelease-6al7rzem/stable/python3.11
returned 3.12.0rc1 for: ['>=3.11,<3.12.0']
BUG
Expected: The lookup should reject 3.12.0 prereleases for those bounds.
Impact
Reproducibility is moderate because the setup requires a specific candidate or configuration. Exposure is niche: the machine must have a prerelease at the boundary and the caller must use a three-part bound. The reproduced effect stays within the caller's configured cache, search path, or interpreter.
Credits
Reported by Anthropic's OSS Scanner: ANT-2026-AQA61M67.
Activity