Repository navigation
Description
Passing a virtual environment's Python through try_first_with returns its base executable. The result loses the virtual environment's site-packages.
Affected code
src/python_discovery/_discovery.py:213 at the reported commit. The reproducer also returns BUG on current main 004294957ed227fe7e52b2864952ba09fd28b220.
Mechanism
The preferred path is resolved before interrogation, so the returned executable points at the base interpreter rather than the requested venv path.
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 sys
import tempfile
# one process per call: within a process, later discovery calls mutate PythonInfo objects returned earlier
CALLER = [sys.executable, "/tmp/repro.d/find_and_run_python.py"]
with tempfile.TemporaryDirectory() as tmp:
# set up: on POSIX, <venv>/bin/python is a symlink chain ending at /usr/bin/python3.11
venv = os.path.join(os.path.realpath(tmp), "venv")
subprocess.run(["/usr/bin/python3.11", "-m", "venv", "--without-pip", venv], check=True)
venv_python = os.path.join(venv, "bin", "python")
base_python = os.path.realpath(venv_python)
print("venv python:", venv_python, "->", base_python)
# empty PATH: the venv path handed to the caller is the only place a 3.11 can come from
env = {**os.environ, "PATH": ""}
# trigger: the venv python as a try_first_with entry
out = subprocess.run([*CALLER, "3.11", venv_python], env=env, capture_output=True, text=True, check=True)
via_try_first_with = json.loads(out.stdout)
# control: the same path as an absolute spec
out = subprocess.run([*CALLER, venv_python], env=env, capture_output=True, text=True, check=True)
via_absolute_spec = json.loads(out.stdout)
# check: both routes named the same venv python, so both should return it and run inside the venv
print("get_interpreter('3.11', try_first_with=[venv python]):", via_try_first_with)
print("get_interpreter(venv python) :", via_absolute_spec)
control_keeps_venv = via_absolute_spec == {"executable": venv_python, "sys.prefix": venv}
try_first_with_loses_venv = via_try_first_with["executable"] == base_python and via_try_first_with["sys.prefix"] != venv
print("BUG" if control_keeps_venv and try_first_with_loses_venv else "OK")repro.d/find_and_run_python.py
import json
import subprocess
import sys
from python_discovery import get_interpreter
spec, *try_first_with = sys.argv[1:]
info = get_interpreter(spec, try_first_with=try_first_with)
ran = subprocess.run(
[info.executable, "-c", "import sys; print(sys.prefix)"], capture_output=True, text=True, check=True
)
print(json.dumps({"executable": info.executable, "sys.prefix": ran.stdout.strip()}))Output on 004294957ed227fe7e52b2864952ba09fd28b220:
venv python: /tmp/tmp3zw10xkm/venv/bin/python -> /usr/bin/python3.11
get_interpreter('3.11', try_first_with=[venv python]): {'executable': '/usr/bin/python3.11', 'sys.prefix': '/usr'}
get_interpreter(venv python) : {'executable': '/tmp/tmp3zw10xkm/venv/bin/python', 'sys.prefix': '/tmp/tmp3zw10xkm/venv'}
BUG
Expected: The result should retain the path passed through try_first_with.
Impact
Reproducibility is moderate because the setup requires a specific candidate or configuration. Exposure is niche: the caller must pass a virtual environment path through try_first_with. The reproduced effect stays within the caller's configured cache, search path, or interpreter.
Credits
Reported by Anthropic's OSS Scanner: ANT-2026-D6C7JUEB.
Activity