From eb5eae0ae02555056cf32a2a58a4eccfa12de973 Mon Sep 17 00:00:00 2001 From: twangboy Date: Thu, 30 Jul 2026 17:02:19 -0600 Subject: [PATCH 1/2] Fix pip 26.2 compatibility in InstallRequirement.install/install_wheel wrappers pip 26.2 added a new `script_executable` parameter to both InstallRequirement.install() and install_wheel(), as part of its new venv-based build-isolation feature. relenv's wrap_req_install patches InstallRequirement.install by counting positional arguments and replaying one of three hardcoded call shapes (7/8/9 args); none of them accept unrecognized keywords, so pip calling with script_executable= fails at the wrapper's own argument-binding with TypeError: InstallRequirement.install() got an unexpected keyword argument 'script_executable'. Worse, pip 26.2's 8-arg shape collides with pip 25.2's existing 8-arg shape (global_options vs. script_executable), so this isn't fixable by adding another argcount branch. install_wheel_wrapper has the identical hardcoded-positional-args problem one call deeper, since InstallRequirement.install() forwards script_executable straight into install_wheel(). Replace the argcount-branching in wrap_req_install with a single wrapper built from inspect.signature() of whatever install() actually is at runtime, so it tolerates any pip signature as long as `home` stays a named parameter (true across every version checked: 25.2, 26.1, 26.2) and forwards everything else untouched. Fail loudly with RuntimeError at bootstrap time if `home` ever disappears, instead of silently breaking TARGET installs. install_wheel_wrapper now forwards its trailing arguments via *args/**kwargs instead of a fixed 8-arg list, for the same reason. Verified against a real relenv-built Python upgraded to pip 26.2: `pip install 'relenv[toolchain]'` (the exact command failing in CI) now succeeds, and the existing pip-25.2/25.3 compatibility tests still pass alongside a new 26.2 case. --- relenv/runtime.py | 172 +++++++------------------------------ tests/test_runtime.py | 111 ++++++++++++++++++++++++ tests/test_verify_build.py | 2 +- 3 files changed, 141 insertions(+), 144 deletions(-) diff --git a/relenv/runtime.py b/relenv/runtime.py index 57b18ca1..46c2dd1a 100644 --- a/relenv/runtime.py +++ b/relenv/runtime.py @@ -17,6 +17,7 @@ import ctypes as _ctypes import functools import importlib as _importlib +import inspect import json as _json import os import pathlib @@ -347,10 +348,8 @@ def wrapper( wheel_path: PathType, scheme: Any, req_description: str, - pycompile: Any, - warn_script_location: Any, - direct_url: Any, - requested: Any, + *args: Any, + **kwargs: Any, ) -> Any: from zipfile import ZipFile @@ -358,16 +357,7 @@ def wrapper( with ZipFile(wheel_path) as zf: info_dir, metadata = parse_wheel(zf, name) - func( - name, - wheel_path, - scheme, - req_description, - pycompile, - warn_script_location, - direct_url, - requested, - ) + func(name, wheel_path, scheme, req_description, *args, **kwargs) if "RELENV_BUILDENV" in os.environ: plat = pathlib.Path(scheme.platlib) rootdir = relenv_root() @@ -825,141 +815,37 @@ def wrapper( def wrap_req_install(name: str) -> ModuleType: """ - Honor ignore installed option from pip cli. + Ensure installs honor the TARGET home directory, staying compatible with + whatever shape pip's InstallRequirement.install currently has (pip has + changed this signature several times across releases, most recently + adding `script_executable` in pip 26.2). """ module: ModuleType = importlib.import_module(name) mod = cast("Any", module) original = mod.InstallRequirement.install - argcount = original.__code__.co_argcount - - if argcount == 7: - - @functools.wraps(original) - def install_wrapper_pep517( - self: Any, - root: PathType | None = None, - home: PathType | None = None, - prefix: PathType | None = None, - warn_script_location: bool = True, - use_user_site: bool = False, - pycompile: bool = True, - ) -> Any: - try: - if TARGET.TARGET: - TARGET.INSTALL = True - home = _ensure_target_path() - return original( - self, - root, - home, - prefix, - warn_script_location, - use_user_site, - pycompile, - ) - finally: - TARGET.INSTALL = False - - mod.InstallRequirement.install = install_wrapper_pep517 - - elif argcount == 8: - - @functools.wraps(original) - def install_wrapper_pep517_opts( - self: Any, - global_options: Any = None, - root: PathType | None = None, - home: PathType | None = None, - prefix: PathType | None = None, - warn_script_location: bool = True, - use_user_site: bool = False, - pycompile: bool = True, - ) -> Any: - try: - if TARGET.TARGET: - TARGET.INSTALL = True - home = _ensure_target_path() - return original( - self, - global_options, - root, - home, - prefix, - warn_script_location, - use_user_site, - pycompile, - ) - finally: - TARGET.INSTALL = False - - mod.InstallRequirement.install = install_wrapper_pep517_opts - - elif argcount == 9: - - @functools.wraps(original) - def install_wrapper_legacy( - self: Any, - install_options: Any, - global_options: Any = None, - root: PathType | None = None, - home: PathType | None = None, - prefix: PathType | None = None, - warn_script_location: bool = True, - use_user_site: bool = False, - pycompile: bool = True, - ) -> Any: - try: - if TARGET.TARGET: - TARGET.INSTALL = True - home = _ensure_target_path() - return original( - self, - install_options, - global_options, - root, - home, - prefix, - warn_script_location, - use_user_site, - pycompile, - ) - finally: - TARGET.INSTALL = False - - mod.InstallRequirement.install = install_wrapper_legacy - - else: - - @functools.wraps(original) - def install_wrapper_generic( - self: Any, - global_options: Any = None, - root: PathType | None = None, - home: PathType | None = None, - prefix: PathType | None = None, - warn_script_location: bool = True, - use_user_site: bool = False, - pycompile: bool = True, - ) -> Any: - try: - if TARGET.TARGET: - TARGET.INSTALL = True - home = _ensure_target_path() - return original( - self, - global_options, - root, - home, - prefix, - warn_script_location, - use_user_site, - pycompile, - ) - finally: - TARGET.INSTALL = False + sig = inspect.signature(original) + if "home" not in sig.parameters: + raise RuntimeError( + "pip's InstallRequirement.install no longer has a 'home' " + f"parameter (signature is now {sig}); relenv's TARGET-install " + "support needs to be updated for this pip version." + ) - mod.InstallRequirement.install = install_wrapper_generic + @functools.wraps(original) + def install_wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: + try: + if TARGET.TARGET: + TARGET.INSTALL = True + bound = sig.bind_partial(self, *args, **kwargs) + bound.apply_defaults() + bound.arguments["home"] = _ensure_target_path() + return original(*bound.args, **bound.kwargs) + return original(self, *args, **kwargs) + finally: + TARGET.INSTALL = False + + mod.InstallRequirement.install = install_wrapper return module diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 1f1ae572..10fd6f8c 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -463,6 +463,58 @@ def original_install(*_args: object, **_kwargs: object) -> str: assert handled and handled[0][0].name == "libdemo.so" +def test_install_wheel_wrapper_forwards_new_kwargs(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: + """ + Pip 26.2 added a `script_executable` parameter to install_wheel; the + wrapper must forward it (and anything else pip adds) through untouched + instead of rejecting it as an unexpected keyword argument. + """ + wheel_utils = ModuleType("pip._internal.utils.wheel") + wheel_utils.parse_wheel = lambda _zf, _name: ("demo.dist-info", {}) + monkeypatch.setitem(sys.modules, wheel_utils.__name__, wheel_utils) + + class DummyZip: + def __init__(self, path: pathlib.Path) -> None: + self.path = path + + def __enter__(self) -> DummyZip: + return self + + def __exit__(self, *exc: object) -> bool: + return False + + monkeypatch.setattr("zipfile.ZipFile", DummyZip) + + install_module = ModuleType("pip._internal.operations.install.wheel") + received: dict[str, object] = {} + + def original_install( + _name: str, _wheel_path: object, _scheme: object, _req_description: str, **kwargs: object + ) -> str: + received.update(kwargs) + return "original" + + install_module.install_wheel = original_install # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, install_module.__name__, install_module) + + wrapped_module = relenv.runtime.wrap_pip_install_wheel(install_module.__name__) + + scheme = SimpleNamespace(platlib=str(tmp_path)) + wrapped_module.install_wheel( + "demo", + tmp_path / "wheel.whl", + scheme, + "desc", + pycompile=True, + warn_script_location=True, + direct_url=None, + requested=False, + script_executable="/target/bin/python3", + ) + + assert received["script_executable"] == "/target/bin/python3" + + def test_install_wheel_wrapper_missing_file(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("RELENV_BUILDENV", "1") plat_dir = tmp_path / "plat" @@ -1253,6 +1305,65 @@ def install( assert result == "installed" +def test_wrap_req_install_pip_26_2_signature(monkeypatch: pytest.MonkeyPatch) -> None: + """ + Pip 26.2 added a `script_executable` keyword argument to + InstallRequirement.install. Ensure the wrapper still overrides `home` + when a TARGET install is active, and forwards script_executable through + untouched instead of rejecting it as an unexpected keyword argument. + """ + relenv.runtime.TARGET.TARGET = True + relenv.runtime.TARGET.PATH = "/pip26/target" + + module_name = "pip._internal.req.req_install.pip262" + module = ModuleType(module_name) + + class InstallRequirement: + def install( + self, + root: object = None, + home: object = None, + prefix: object = None, + warn_script_location: bool = True, + use_user_site: bool = False, + pycompile: bool = True, + script_executable: object = None, + ) -> tuple[object, object]: + return script_executable, home + + module.InstallRequirement = InstallRequirement + monkeypatch.setitem(sys.modules, module_name, module) + monkeypatch.setattr(relenv.runtime.importlib, "import_module", lambda name: module) + + wrapped = relenv.runtime.wrap_req_install(module_name) + installer = wrapped.InstallRequirement() + script_executable, home = installer.install(script_executable="/target/bin/python3") + + assert home == relenv.runtime.TARGET.PATH + assert script_executable == "/target/bin/python3" + + +def test_wrap_req_install_missing_home_raises(monkeypatch: pytest.MonkeyPatch) -> None: + """ + If a future pip ever drops the `home` parameter entirely, relenv's + TARGET-install support can no longer work correctly. Fail loudly at + bootstrap time instead of silently doing nothing. + """ + module_name = "pip._internal.req.req_install.nohome" + module = ModuleType(module_name) + + class InstallRequirement: + def install(self, root: object = None, prefix: object = None) -> None: + return None + + module.InstallRequirement = InstallRequirement + monkeypatch.setitem(sys.modules, module_name, module) + monkeypatch.setattr(relenv.runtime.importlib, "import_module", lambda name: module) + + with pytest.raises(RuntimeError, match="home"): + relenv.runtime.wrap_req_install(module_name) + + def test_wrapsitecustomize_sanitizes_sys_path(monkeypatch: pytest.MonkeyPatch) -> None: sanitized = ["sanitized/path"] monkeypatch.setattr( diff --git a/tests/test_verify_build.py b/tests/test_verify_build.py index 1c677b69..333f0602 100644 --- a/tests/test_verify_build.py +++ b/tests/test_verify_build.py @@ -2044,7 +2044,7 @@ def test_import_ssl_module(pyexec): @pytest.mark.skip_unless_on_linux -@pytest.mark.parametrize("pip_version", ["25.2", "25.3"]) +@pytest.mark.parametrize("pip_version", ["25.2", "25.3", "26.2"]) def test_install_setuptools_25_2_to_25_3(pipexec, build, minor_version, pip_version): """ Validate we handle the changes to pip._internal.req.InstallRequirement.install signature. From daa0ac176cb012857fabf02dfe1becb72c451bf7 Mon Sep 17 00:00:00 2001 From: twangboy Date: Fri, 31 Jul 2026 11:46:59 -0600 Subject: [PATCH 2/2] Fix Windows 3.10 native builds failing on find_python.bat's EOL fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CPython's PCbuild\find_python.bat searches for a pre-installed bootstrap interpreter via specific "py -X.Y" versions before falling back to downloading one via NuGet. The 3.10 branch's copy of this script only tries "py -3.9" and "py -3.8" (3.11's tries 3.10/3.9 instead) — both 3.9 and 3.8 are now EOL and have aged out of GitHub's hosted Windows runner images, so that search always comes up empty when building 3.10. The NuGet fallback it then hits is separately broken in our CI right now, failing with "The system cannot execute the specified program" / "Cannot locate python.exe on PATH or as PYTHON variable". This reproduces 100% of the time building 3.10 on Windows and explains why retrying the CI job never helped: 3.11+ never even touch this path because they successfully match an already-present "py -3.10". find_python.bat checks a HOST_PYTHON environment variable before either the version-specific search or the NuGet fallback. Set it in populate_env() to sys.executable — the interpreter already running the build — so PCbuild always finds a usable bootstrap Python directly and never depends on legacy py-launcher registrations or a NuGet download succeeding. --- relenv/build/windows.py | 11 +++++++++++ tests/test_build.py | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/relenv/build/windows.py b/relenv/build/windows.py index 145b8d09..1a916b57 100644 --- a/relenv/build/windows.py +++ b/relenv/build/windows.py @@ -137,6 +137,17 @@ def populate_env(env: EnvMapping, dirs: Dirs) -> None: """ env["MSBUILDDISABLENODEREUSE"] = "1" + # CPython's PCbuild\find_python.bat only looks for a pre-installed + # bootstrap interpreter via specific "py -X.Y" versions (e.g. 3.10's + # copy of the script only tries 3.9/3.8), which age out of GitHub's + # hosted Windows runner images as those versions go EOL. When none of + # those match, it falls back to downloading a bootstrap Python via + # NuGet, which is an extra, flaky, network-dependent step. Point + # find_python.bat at the interpreter already running this build (it + # checks HOST_PYTHON before either of those) so it never needs to do + # either. + env["HOST_PYTHON"] = sys.executable + def find_vcvarsall(env: EnvMapping) -> pathlib.Path | None: """ diff --git a/tests/test_build.py b/tests/test_build.py index 1d24857a..17cebceb 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -3,6 +3,7 @@ import hashlib import logging import pathlib +import sys import pytest @@ -475,6 +476,24 @@ def _make_layout( return source, build_dir, dest_dir +def test_populate_env_sets_host_python() -> None: + """ + CPython's PCbuild/find_python.bat only looks for a pre-installed + bootstrap interpreter via specific "py -X.Y" versions (e.g. 3.10's copy + of the script only tries 3.9/3.8), both of which are EOL and age out of + CI images over time; when neither is found it falls back to an + unreliable NuGet download. HOST_PYTHON is checked before either of + those, so populate_env should point it at the interpreter already + running the build. + """ + from relenv.build.windows import populate_env + + dirs = Dirs(work_dirs(), "python", "amd64", "3.10.20") + env: dict[str, str] = {} + populate_env(env, dirs) + assert env["HOST_PYTHON"] == sys.executable + + def test_copy_pyconfig_h_legacy(tmp_path: pathlib.Path) -> None: """Python <= 3.12: copies PC/pyconfig.h (no pyconfig.h.in template).""" from relenv.build.windows import copy_pyconfig_h