From b994d2a2cf0bba05efc9474ab8468e65c69c5170 Mon Sep 17 00:00:00 2001 From: behlole Date: Thu, 14 May 2026 20:49:46 +0500 Subject: [PATCH 1/2] Resolve script paths via shutil.which on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, Env.execute() invokes subprocess.Popen with shell=True and lets cmd.exe resolve the executable from PATH. cmd.exe has an internal ~8KiB parse limit on the PATH variable (CPython gh-137254); once a project accumulates enough entries (CUDA, Node, multiple SDKs, …), cmd.exe truncates PATH silently and bare-name scripts fail to resolve. This is most visible for entries declared in [project.scripts], which are installed as .cmd shims. Env._bin's venv-relative lookup only considers .exe, so those scripts reach execute() as bare names and inherit cmd.exe's PATH limit. Resolve the full path with shutil.which(name, path=env.get('PATH')) before handing off to subprocess. shutil.which has no length limit and the resulting absolute path lets cmd.exe skip its own PATH walk entirely. When resolution fails (binary outside the venv, etc.) we fall back to the original bare-name behavior so we don't regress existing setups. Closes #10482 --- src/poetry/utils/env/base_env.py | 14 ++++++ tests/utils/env/test_env.py | 73 ++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) 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..9ceab7b1c76 100644 --- a/tests/utils/env/test_env.py +++ b/tests/utils/env/test_env.py @@ -559,6 +559,79 @@ 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``. + + ``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" + 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") # type: ignore[arg-type] + + assert rc == 0 + 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) From 6253c040d518f6c555451c5fbcb8a774da1cc7d0 Mon Sep 17 00:00:00 2001 From: behlole Date: Fri, 15 May 2026 00:15:27 +0500 Subject: [PATCH 2/2] Pin shutil.which contract in test Address sourcery-ai review feedback: assert how shutil.which is called in addition to how its result is used. Catches regressions that drop the env-aware path= kwarg or change the executable name passed to which(). --- tests/utils/env/test_env.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/utils/env/test_env.py b/tests/utils/env/test_env.py index 9ceab7b1c76..7de558a7c20 100644 --- a/tests/utils/env/test_env.py +++ b/tests/utils/env/test_env.py @@ -578,7 +578,9 @@ def get_command_from_bin(self, bin: str) -> list[str]: 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``. + 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), @@ -590,7 +592,8 @@ def test_execute_resolves_full_path_on_windows(mocker: MockerFixture) -> None: from poetry.utils.env.base_env import Env resolved = r"C:\Users\u\proj\.venv\Scripts\myscript.cmd" - mocker.patch("shutil.which", return_value=resolved) + 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 @@ -599,9 +602,14 @@ def test_execute_resolves_full_path_on_windows(mocker: MockerFixture) -> None: ) fake = _FakeWindowsSelf(command=["myscript"]) - rc = Env.execute(fake, "myscript", "arg1") # type: ignore[arg-type] + 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"