Skip to content

A cached shim verdict applies in unrelated directories #194

Closed

Description

@gaborbernat

A version-manager shim cached as an unsupported Python in one directory remains rejected in another directory where it selects a supported Python.

Affected code

src/python_discovery/_cached_py_info.py:133 at the reported commit. The reproducer also returns BUG on current main 004294957ed227fe7e52b2864952ba09fd28b220.

Mechanism

The DiskCache entry is keyed by the shim path although its target changes with the current directory's version selection.

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 shutil
import subprocess
from pathlib import Path

scene = Path("/tmp/shim-verdict-repro")
home = scene / "home"
pyenv_root = home / ".pyenv"
shim = pyenv_root / "shims" / "python3"
shim_log = pyenv_root / "shim.log"
user_cache = home / ".local" / "share" / "virtualenv"
project = scene / "project"
checkout = scene / "untrusted_checkout"


def install_executable(source, destination):
    destination.parent.mkdir(parents=True, exist_ok=True)
    shutil.copy(source, destination)
    destination.chmod(0o755)


def find_interpreter(cwd, cache_dir):
    env = {"PATH": f"{shim.parent}:/usr/bin", "HOME": str(home), "PYENV_ROOT": str(pyenv_root)}
    tool = ["/opt/venv/bin/python", "/tmp/repro.d/find_interpreter.py", str(shim), str(cache_dir)]
    return subprocess.run(tool, cwd=cwd, env=env, stdout=subprocess.PIPE, text=True, check=True).stdout.strip()


# set up: pyenv with CPython 3.11.2 and 3.5.10 installed, and the user's project pinned to 3.11.2
shutil.rmtree(scene, ignore_errors=True)
install_executable("/tmp/repro.d/pyenv_shim.py", shim)
(pyenv_root / "versions" / "3.11.2" / "bin").mkdir(parents=True)
(pyenv_root / "versions" / "3.11.2" / "bin" / "python3").symlink_to("/usr/bin/python3.11")
install_executable("/tmp/repro.d/python3_5_stand_in.py", pyenv_root / "versions" / "3.5.10" / "bin" / "python3")
project.mkdir()
(project / ".python-version").write_text("3.11.2\n")

# control: from the project, with a fresh cache, the shim is Python 3.11.2
control = find_interpreter(project, scene / "fresh_cache")
assert control == "Python 3.11.2 (/usr/bin/python3.11)", control

# trigger: the checkout's author controls only this file; the shim is probed once from there
checkout.mkdir()
(checkout / ".python-version").write_text("3.5.10\n")
in_checkout = find_interpreter(checkout, user_cache)
assert "is Python 3.5.10, older than the minimum 3.6" in in_checkout, in_checkout

# the control's call again, in the project, now with the user's cache
shim_log.write_text("")
in_project = find_interpreter(project, user_cache)
shim_runs_in_project = shim_log.read_text()

# check: in the project the shim runs 3.11.2, so the answer must be the control's, not the checkout's
print("project, fresh cache            :", control)
print("untrusted_checkout, user cache  :", in_checkout)
print("project, user cache             :", in_project)
print("shim runs during that last call :", shim_runs_in_project.strip() or "none")
print(
    "user cache                      :",
    [json.loads(entry.read_text()) for entry in user_cache.glob("py_info/*/*.json")],
)
print("BUG" if in_project == in_checkout and not shim_runs_in_project else "OK")

repro.d/find_interpreter.py

"""A tool built on python-discovery: look up the interpreter at a given path, using the user's on-disk cache."""

import sys
from pathlib import Path

from python_discovery import DiskCache, get_interpreter

interpreter_path, cache_dir = sys.argv[1:]

try:
    info = get_interpreter(interpreter_path, cache=DiskCache(Path(cache_dir)))
except RuntimeError as error:  # documented for an absolute path to a Python older than 3.6
    print(f"RuntimeError: {error}")
else:
    print(f"Python {info.version_str} ({info.system_executable})")

repro.d/pyenv_shim.py

#!/usr/bin/python3.11 -I
"""Stand-in for a pyenv shim: exec the same command from the version pinned by the nearest .python-version."""

import os
import sys

directory = os.getcwd()
while not os.path.isfile(os.path.join(directory, ".python-version")):
    if directory == os.path.dirname(directory):
        sys.exit("pyenv: no .python-version found")
    directory = os.path.dirname(directory)
with open(os.path.join(directory, ".python-version")) as file:
    version = file.read().strip()

pyenv_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
target = os.path.join(pyenv_root, "versions", version, "bin", os.path.basename(__file__))

# not something pyenv does: lets the reproducer see each time the shim is actually run
with open(os.path.join(pyenv_root, "shim.log"), "a") as log:
    log.write(f"run from {os.getcwd()} -> {target}\n")

os.execv(target, [target, *sys.argv[1:]])

repro.d/python3_5_stand_in.py

#!/usr/bin/python3.11 -I
"""Stand-in for an installed CPython 3.5.10, which this image does not have: run the given script as if on 3.5.10."""

import runpy
import sys

# the version gate at the top of python-discovery's _py_info_collect.py reads only this
sys.version_info = (3, 5, 10, "final", 0)
runpy.run_path(sys.argv[1], run_name="__main__")

Output on 004294957ed227fe7e52b2864952ba09fd28b220:

/tmp/shim-verdict-repro/home/.pyenv/shims/python3 is Python 3.5.10, older than the minimum 3.6 python-discovery can query
project, fresh cache            : Python 3.11.2 (/usr/bin/python3.11)
untrusted_checkout, user cache  : RuntimeError: /tmp/shim-verdict-repro/home/.pyenv/shims/python3 is Python 3.5.10, older than the minimum 3.6 python-discovery can query
project, user cache             : RuntimeError: /tmp/shim-verdict-repro/home/.pyenv/shims/python3 is Python 3.5.10, older than the minimum 3.6 python-discovery can query
shim runs during that last call : none
user cache                      : [{'arguments': None, 'hash': '93d3f8273364bd6f62c37eed79ebbb2fa636ef2b564e7bd688ae3b077826e718', 'path': '/tmp/shim-verdict-repro/home/.pyenv/shims/python3', 'st_mtime': 1793658667.5352418, 'unsupported': '/tmp/shim-verdict-repro/home/.pyenv/shims/python3 is Python 3.5.10, older than the minimum 3.6 python-discovery can query'}]
BUG

Expected: A shim should be probed again when its selected version changes with the directory.

Impact

Reproducibility is moderate because the setup requires a specific candidate or configuration. Exposure is niche: a pyenv, asdf, or mise shim must select different versions across directories while sharing a cache. The reproduced effect stays within the caller's configured cache, search path, or interpreter.

Credits

Reported by Anthropic's OSS Scanner: ANT-2026-HK25S1M7.

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