Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 16 additions & 12 deletions src/poetry/utils/env/base_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@
PythonVersion = tuple[int, int, int, str, int]


# Executable suffixes to look for on Windows, in lookup order; `.exe` first so
# that resolution of already-working executables is unchanged. Not read from
# PATHEXT, so lookup cannot be influenced by the environment Poetry runs in.
WINDOWS_BIN_SUFFIXES = (".exe", ".cmd")


class MarkerEnv(TypedDict):
implementation_name: str
implementation_version: str
Expand Down Expand Up @@ -492,28 +498,26 @@ def _bin(self, bin: str) -> str:
"""
Return path to the given executable.
"""
if self._is_windows and not bin.endswith(".exe"):
bin_path = self._bin_dir / (bin + ".exe")
if self._is_windows and not bin.endswith(WINDOWS_BIN_SUFFIXES):
candidates = [bin + suffix for suffix in WINDOWS_BIN_SUFFIXES]
else:
bin_path = self._bin_dir / bin
candidates = [bin]

if not bin_path.exists():
directories = [self._bin_dir]
if self._is_windows:
# On Windows, some executables can be in the base path
# This is especially true when installing Python with
# the official installer, where python.exe will be at
# the root of the env path.
if self._is_windows:
if not bin.endswith(".exe"):
bin_path = self._path / (bin + ".exe")
else:
bin_path = self._path / bin
directories.append(self._path)

for candidate in candidates:
for directory in directories:
bin_path = directory / candidate
if bin_path.exists():
return str(bin_path)

return bin

return str(bin_path)
return bin

def __eq__(self, other: object) -> bool:
if not isinstance(other, Env):
Expand Down
13 changes: 13 additions & 0 deletions tests/console/commands/test_run.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import os
import subprocess

from typing import TYPE_CHECKING
Expand Down Expand Up @@ -165,6 +166,7 @@ def test_run_has_helpful_error_when_command_not_found(
def test_run_console_scripts_of_editable_dependencies_on_windows(
tmp_venv: VirtualEnv,
command_tester_factory: CommandTesterFactory,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
On Windows, Poetry installs console scripts of editable dependencies by creating
Expand All @@ -182,7 +184,18 @@ def test_run_console_scripts_of_editable_dependencies_on_windows(

This test validates that you can also run such a CMD script file via `poetry run`
just by providing the script's name without the `.cmd` extension.

PATH is padded past the ~8191 character limit at which `cmd.exe` stops resolving
unqualified command names, so that this also covers python-poetry/poetry#10482.
Poetry must therefore resolve the script to a full path itself rather than leave
the lookup to the shell.
"""
# `VirtualEnv.execute` builds the subprocess PATH from `os.environ`, so padding
# it here is what ends up reaching `cmd.exe`.
padded_path = os.pathsep.join([os.environ["PATH"], *["C:\\NotAPath"] * 1000])
assert len(padded_path) > 8191, "PATH is too short to reproduce #10482"
monkeypatch.setenv("PATH", padded_path)

tester = command_tester_factory("run", environment=tmp_venv)

cmd_script_file = tmp_venv._bin_dir / "quix.cmd"
Expand Down
45 changes: 45 additions & 0 deletions tests/utils/env/test_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,51 @@ def test_command_from_bin_preserves_relative_path(manager: EnvManager) -> None:
assert command == ["./foo.py"]


@pytest.fixture
def windows_env(tmp_path: Path) -> VirtualEnv:
"""A ``VirtualEnv`` that resolves executables using Windows rules.

``Env._is_windows`` and ``Env._bin_dir`` are derived from ``sys.platform`` in
``Env.__init__``, so they are overridden here to exercise the Windows lookup
from any host platform.
"""
path = tmp_path / "venv"
env = VirtualEnv(path, path)
env._is_windows = True
# `Env.__init__` may rewrite `_path` on Windows, so derive from it rather than
# from `path` to keep both directories consistent on every platform.
env._bin_dir = env._path / "Scripts"
env._bin_dir.mkdir(parents=True)
return env


def test_bin_prefers_exe_over_cmd_on_windows(windows_env: VirtualEnv) -> None:
exe = windows_env._bin_dir / "mytool.exe"
exe.touch()
(windows_env._bin_dir / "mytool.cmd").touch()

assert windows_env._bin("mytool") == str(exe)


def test_bin_finds_cmd_script_on_windows(windows_env: VirtualEnv) -> None:
# https://github.com/python-poetry/poetry/issues/10482
# Editable installs write a shebang script plus a `.cmd` wrapper, never a
# `.exe`, so resolution must not depend on `cmd.exe` searching PATH.
(windows_env._bin_dir / "pepscript").touch()
cmd_script = windows_env._bin_dir / "pepscript.cmd"
cmd_script.touch()

assert windows_env._bin("pepscript") == str(cmd_script)


def test_bin_accepts_explicit_suffix_on_windows(windows_env: VirtualEnv) -> None:
cmd_script = windows_env._bin_dir / "pepscript.cmd"
cmd_script.touch()

# A suffix that is already present must not have another one appended.
assert windows_env._bin("pepscript.cmd") == str(cmd_script)


@pytest.fixture
def system_env_read_only(system_env: SystemEnv, mocker: MockerFixture) -> SystemEnv:
original_is_dir_writable = is_dir_writable
Expand Down