Repository navigation
Description
A spec such as ==3.11 can miss a uv-managed Python 3.11.2, although the same interpreter is accepted from PATH.
Affected code
src/python_discovery/_discovery.py:378 at the reported commit. The reproducer also returns BUG on current main 004294957ed227fe7e52b2864952ba09fd28b220.
Mechanism
The uv directory-name filter compares the full patch version before PythonInfo.satisfies() applies the spec's minor-version semantics.
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 subprocess
import tempfile
def find_interpreter(spec, path, uv_python_install_dir):
# a fresh process per call: python-discovery caches interpreter info in memory
caller = ["/opt/venv/bin/python", "/tmp/repro.d/find_interpreter.py", spec, path, uv_python_install_dir]
result = json.loads(subprocess.run(caller, check=True, capture_output=True, text=True).stdout)
print(f"{spec!r:14} -> {result['version']} {result['executable']}")
print(f"{'':14} spawned: {result['spawned']}")
return result
# set up: the image's CPython 3.11.2, reachable once through PATH and once as a uv-managed install
scene = tempfile.mkdtemp()
path_dir = os.path.join(scene, "path")
uv_dir = os.path.join(scene, "uv")
empty_dir = os.path.join(scene, "empty")
uv_python = os.path.join(uv_dir, "cpython-3.11.2-linux-x86_64-gnu", "bin", "python")
os.makedirs(path_dir)
os.makedirs(os.path.dirname(uv_python))
os.makedirs(empty_dir)
os.symlink("/usr/bin/python3.11", os.path.join(path_dir, "python3.11"))
os.symlink("/usr/bin/python3.11", uv_python)
# the caller itself runs on 3.15, which no spec below accepts, so any answer comes from PATH or the uv dir
print("uv dir, spec with both bounds written out (control):")
control = find_interpreter(">=3.11,<3.12", path=empty_dir, uv_python_install_dir=uv_dir)
assert control["executable"] == uv_python, "setup: the uv install was not found at all"
# trigger: the same spec, the same interpreter, two sources
print("PATH only:")
from_path = find_interpreter("==3.11", path=path_dir, uv_python_install_dir=empty_dir)
print("uv dir only:")
from_uv = find_interpreter("==3.11", path=empty_dir, uv_python_install_dir=uv_dir)
# check: '==3.11' accepts 3.11.2 from PATH, so it must accept it from the uv dir too
accepted_from_path = from_path["version"] == "3.11.2"
dropped_unrun_from_uv = from_uv["version"] is None and uv_python not in from_uv["spawned"]
print("BUG" if accepted_from_path and dropped_unrun_from_uv else "OK")repro.d/find_interpreter.py
import json
import sys
from python_discovery import get_interpreter
spec, path, uv_python_install_dir = sys.argv[1:]
spawned = []
sys.addaudithook(lambda event, args: spawned.append(args[0]) if event == "subprocess.Popen" else None)
info = get_interpreter(spec, env={"PATH": path, "UV_PYTHON_INSTALL_DIR": uv_python_install_dir})
print(
json.dumps({
"version": None if info is None else "{}.{}.{}".format(*info.version_info),
"executable": None if info is None else info.executable,
"spawned": spawned,
})
)Output on 004294957ed227fe7e52b2864952ba09fd28b220:
uv dir, spec with both bounds written out (control):
'>=3.11,<3.12' -> 3.11.2 /tmp/tmpt2grrx0q/uv/cpython-3.11.2-linux-x86_64-gnu/bin/python
spawned: ['/usr/local/bin/python3.15', '/tmp/tmpt2grrx0q/uv/cpython-3.11.2-linux-x86_64-gnu/bin/python', '/usr/bin/python3.11']
PATH only:
'==3.11' -> 3.11.2 /tmp/tmpt2grrx0q/path/python3.11
spawned: ['/usr/local/bin/python3.15', '/tmp/tmpt2grrx0q/path/python3.11', '/usr/bin/python3.11']
uv dir only:
'==3.11' -> None None
spawned: ['/usr/local/bin/python3.15']
BUG
Expected: The uv candidate should reach the same spec check used for PATH candidates.
Impact
Reproducibility is moderate because the setup requires a specific candidate or configuration. Exposure is niche: the matching interpreter must be in the uv store and the spec must use a two-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-IQ3HSNNR.
Activity