diff --git a/src/poetry/utils/env/base_env.py b/src/poetry/utils/env/base_env.py index 1a8ff54bd26..77c914a676e 100644 --- a/src/poetry/utils/env/base_env.py +++ b/src/poetry/utils/env/base_env.py @@ -3,6 +3,7 @@ import contextlib import os import re +import shutil import subprocess import sys import sysconfig @@ -461,6 +462,19 @@ def execute(self, bin: str, *args: str, **kwargs: Any) -> int: if not self._is_windows: return os.execvpe(command[0], command, env=env) + # On Windows, resolve the executable against the env's ``PATH`` + # ourselves before handing the command to ``subprocess`` with + # ``shell=True``. ``cmd.exe`` can fail to resolve a bare name when + # ``PATH`` exceeds its ~8KiB parsing limit (see + # https://github.com/python/cpython/issues/137254), but + # :func:`shutil.which` works for paths of any length. This mainly + # affects entries from ``[project.scripts]``, which are installed + # as ``.cmd`` shims rather than ``.exe`` and therefore aren't + # picked up by the venv-relative lookup in :meth:`_bin`. + resolved = shutil.which(command[0], path=env.get("PATH")) + if resolved is not None: + command[0] = resolved + kwargs["shell"] = True exe = subprocess.Popen(command, env=env, **kwargs) exe.communicate() diff --git a/tests/utils/env/test_env.py b/tests/utils/env/test_env.py index f1064580998..7de558a7c20 100644 --- a/tests/utils/env/test_env.py +++ b/tests/utils/env/test_env.py @@ -559,6 +559,87 @@ def test_env_scheme_dict_returns_modified_when_read_only( ) +class _FakeWindowsSelf: + """Lightweight stand-in used to drive ``Env.execute`` on a non-Windows + host without touching the venv-creation machinery in :class:`Env`. + + ``Env.execute`` only reads ``self._is_windows`` and calls + ``self.get_command_from_bin``, so a duck-typed object is enough. + """ + + _is_windows = True + + def __init__(self, command: list[str]) -> None: + self._command = command + + def get_command_from_bin(self, bin: str) -> list[str]: + return self._command + + +def test_execute_resolves_full_path_on_windows(mocker: MockerFixture) -> None: + """On Windows, ``Env.execute`` must resolve the executable's full path + via ``shutil.which`` before invoking ``subprocess.Popen``, passing the + env-aware ``PATH`` so the lookup honors the caller-supplied environment + (and bypasses ``cmd.exe``'s ~8 KiB ``PATH`` parse limit). + + ``cmd.exe`` fails to resolve bare names when ``PATH`` exceeds its + ~8 KiB parse limit (https://github.com/python/cpython/issues/137254), + affecting ``[project.scripts]`` entries (installed as ``.cmd`` shims) + that aren't found by ``Env._bin``'s venv-relative ``.exe`` lookup. + + Regression test for https://github.com/python-poetry/poetry/issues/10482. + """ + from poetry.utils.env.base_env import Env + + resolved = r"C:\Users\u\proj\.venv\Scripts\myscript.cmd" + env_vars = {"PATH": r"C:\foo;C:\bar"} + which_mock = mocker.patch("shutil.which", return_value=resolved) + popen_mock = mocker.MagicMock() + popen_mock.communicate.return_value = (b"", b"") + popen_mock.returncode = 0 + popen_cls = mocker.patch( + "poetry.utils.env.base_env.subprocess.Popen", return_value=popen_mock + ) + + fake = _FakeWindowsSelf(command=["myscript"]) + rc = Env.execute(fake, "myscript", "arg1", env=env_vars) # type: ignore[arg-type] + + assert rc == 0 + # Pin down the ``shutil.which`` contract: bare executable + env-aware + # PATH. Regressions that drop the ``path=`` kwarg or pass the wrong + # name would silently re-introduce the cmd.exe PATH-length failure + # this PR is fixing. + which_mock.assert_called_once_with("myscript", path=env_vars["PATH"]) + call_command = popen_cls.call_args.args[0] + assert call_command[0] == resolved + assert call_command[1] == "arg1" + + +def test_execute_falls_back_to_bare_name_when_unresolved( + mocker: MockerFixture, +) -> None: + """If ``shutil.which`` cannot resolve the executable, ``Env.execute`` + must still call ``subprocess.Popen`` so the shell's own resolution + path can take over (preserving existing behavior for cases where the + binary lives outside the venv but is reachable via ``PATH``).""" + from poetry.utils.env.base_env import Env + + mocker.patch("shutil.which", return_value=None) + popen_mock = mocker.MagicMock() + popen_mock.communicate.return_value = (b"", b"") + popen_mock.returncode = 0 + popen_cls = mocker.patch( + "poetry.utils.env.base_env.subprocess.Popen", return_value=popen_mock + ) + + fake = _FakeWindowsSelf(command=["unknown"]) + rc = Env.execute(fake, "unknown") # type: ignore[arg-type] + + assert rc == 0 + call_command = popen_cls.call_args.args[0] + assert call_command[0] == "unknown" + + def test_marker_env_is_equal_for_all_envs(tmp_path: Path, manager: EnvManager) -> None: venv_path = tmp_path / "Virtual Env" manager.build_venv(venv_path)