Skip to content

Upper version bounds admit the next release's prereleases #189

Closed

Description

@gaborbernat

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

repro

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

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