diff --git a/cibuildwheel/venv.py b/cibuildwheel/venv.py index 2867edd7..4253f7bc 100644 --- a/cibuildwheel/venv.py +++ b/cibuildwheel/venv.py @@ -152,6 +152,10 @@ def virtualenv( venv. Otherwise, pip is installed. """ + # the unresolved path, so e.g. the python.org framework bin directory is + # used rather than the internal location of the real binary + base_python_bin_dir = None if _IS_WIN else python.parent + # virtualenv may fail if this is a symlink. python = python.resolve() @@ -189,7 +193,7 @@ def virtualenv( python, venv_path, ) - venv_env = activate_virtualenv(venv_path, env=env) + venv_env = activate_virtualenv(venv_path, env=env, base_python_bin_dir=base_python_bin_dir) if not use_uv and pip_version == "embed": call( "python", @@ -208,11 +212,19 @@ def virtualenv( def activate_virtualenv( venv_path: Path, env: dict[str, str] | None = None, + base_python_bin_dir: Path | None = None, ) -> dict[str, str]: """ Return a copy of the environment with the virtualenv at `venv_path` activated. + + If given, `base_python_bin_dir` is placed on PATH right after the venv, so + that scripts installed alongside the base interpreter that aren't copied + into the venv (such as python3-config, see #2021) resolve to the matching + interpreter. """ paths = [str(venv_path), str(venv_path / "Scripts")] if _IS_WIN else [str(venv_path / "bin")] + if base_python_bin_dir is not None: + paths.append(str(base_python_bin_dir)) venv_env = os.environ.copy() if env is None else env.copy() venv_env["PATH"] = os.pathsep.join([*paths, venv_env["PATH"]]) venv_env["VIRTUAL_ENV"] = str(venv_path) diff --git a/unit_test/venv_test.py b/unit_test/venv_test.py new file mode 100644 index 00000000..8cd905a1 --- /dev/null +++ b/unit_test/venv_test.py @@ -0,0 +1,60 @@ +import os +import sys +from pathlib import Path + +import pytest + +import cibuildwheel.venv +from cibuildwheel.venv import activate_virtualenv, find_uv, virtualenv + + +def test_activate_virtualenv(tmp_path: Path) -> None: + venv_path = tmp_path / "venv" + env = activate_virtualenv(venv_path, env={"PATH": "/usr/bin"}) + paths = env["PATH"].split(os.pathsep) + if sys.platform == "win32": + assert paths[:2] == [str(venv_path), str(venv_path / "Scripts")] + else: + assert paths[0] == str(venv_path / "bin") + assert paths[-1] == "/usr/bin" + assert env["VIRTUAL_ENV"] == str(venv_path) + + +def test_activate_virtualenv_base_python_bin_dir(tmp_path: Path) -> None: + venv_path = tmp_path / "venv" + base_bin = tmp_path / "base" / "bin" + env = activate_virtualenv(venv_path, env={"PATH": "/usr/bin"}, base_python_bin_dir=base_bin) + paths = env["PATH"].split(os.pathsep) + # the base interpreter's bin dir comes right after the venv, so scripts + # like python3-config resolve to the matching interpreter (see #2021) + if sys.platform == "win32": + assert paths[:3] == [str(venv_path), str(venv_path / "Scripts"), str(base_bin)] + else: + assert paths[:2] == [str(venv_path / "bin"), str(base_bin)] + assert paths[-1] == "/usr/bin" + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only behavior") +@pytest.mark.skipif(find_uv() is None, reason="requires uv") +def test_virtualenv_puts_base_python_bin_dir_on_path(tmp_path: Path) -> None: + version = "{}.{}".format(*sys.version_info[:2]) + venv_path = tmp_path / "venv" + env = virtualenv( + version, Path(sys.executable), venv_path, None, use_uv=True, env={"PATH": "/usr/bin"} + ) + paths = env["PATH"].split(os.pathsep) + assert paths == [str(venv_path / "bin"), str(Path(sys.executable).parent), "/usr/bin"] + + +@pytest.mark.skipif(find_uv() is None, reason="requires uv") +def test_virtualenv_no_base_python_bin_dir_on_windows( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(cibuildwheel.venv, "_IS_WIN", True) + version = "{}.{}".format(*sys.version_info[:2]) + venv_path = tmp_path / "venv" + env = virtualenv( + version, Path(sys.executable), venv_path, None, use_uv=True, env={"PATH": "/usr/bin"} + ) + paths = env["PATH"].split(os.pathsep) + assert str(Path(sys.executable).parent) not in paths