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/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_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 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.