Skip to content

try_first_with loses the selected virtual environment #191

Closed

Description

@gaborbernat

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/repro

repro

#!/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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions