diff --git a/poetry/utils/env.py b/poetry/utils/env.py index c247bf014f7..a358f106c1d 100644 --- a/poetry/utils/env.py +++ b/poetry/utils/env.py @@ -1123,8 +1123,7 @@ def _bin(self, bin): # type: (str) -> str """ Return path to the given executable. """ - bin_path = (self._bin_dir / bin).with_suffix(".exe" if self._is_windows else "") - if not bin_path.exists(): + 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 @@ -1133,14 +1132,25 @@ def _bin(self, bin): # type: (str) -> str # in normal uses but this happens in the sonnet script # that creates a fake virtual environment pointing to # a base Python install. - if self._is_windows: - bin_path = (self._path / bin).with_suffix(".exe") - if bin_path.exists(): - return str(bin_path) + # On windows we also need to manually search the %PATH% + # as the OS won't do it for us. + # Note: shutil.which always searches the current + # directory first on windows, This is the behavior + # windows users will expect. + search_path = os.pathsep.join( + (str(self._bin_dir), str(self._path), os.environ["PATH"]) + ) + found = shutil.which(bin, path=search_path) + if found: + return found - return bin + bin_path = self._bin_dir / bin - return str(bin_path) + if bin_path.exists(): + return str(bin_path) + + # Return original path if our search failed. + return bin def __eq__(self, other): # type: (Env) -> bool return other.__class__ == self.__class__ and other.path == self.path diff --git a/tests/console/commands/test_run.py b/tests/console/commands/test_run.py index 351d869d1a9..62e91fa18dc 100644 --- a/tests/console/commands/test_run.py +++ b/tests/console/commands/test_run.py @@ -1,5 +1,11 @@ +import os +import tempfile + import pytest +from poetry.utils._compat import WINDOWS +from poetry.utils._compat import Path + @pytest.fixture def tester(command_tester_factory): @@ -14,3 +20,89 @@ def patches(mocker, env): def test_run_passes_all_args(tester, env): tester.execute("python -V") assert [["python", "-V"]] == env.executed + + +@pytest.mark.skipif( + not WINDOWS, reason="This test asserts Windows-specific compatibility", +) +def test_run_console_scripts_on_windows(tmp_venv, command_tester_factory, mocker): + """Test that `poetry run` on Windows finds console scripts. + + On Windows, Poetry installs console scripts of editable + dependencies by creating in the `Scripts/` directory both: + + 1. The Bash script one expects on Linux (an extension-less file), + with a shebang to launch Python, import the given module, and call + the given function. + + 2. A Batch script (with the `.cmd` file extension) which makes + this Bash script work on Windows by calling Python directly and + then executing the script from (1). + + This works because Windows programs (like the command prompt, + PowerShell, "run" box, `start`, `where`, etc.) know to append + extensions from the `PATHEXT` environment variable when looking + for named programs. This is the Windows version of `chmod +x`. + Sine Poetry is cross-platform, `poetry run` also needs to look for + programs with this algorithm, and so this is a regression test. + + This test asserts that you can a console script via `poetry run` + just by providing its name without the `.cmd` extension (the + common use case). + + """ + new_environ = {} + new_environ.update(os.environ) + new_environ["PATHEXT"] = ".BAT;.CMD" # ensure environ vars are deterministic + mocker.patch("os.environ", new_environ) + + tester = command_tester_factory("run", environment=tmp_venv) + bat_script = tmp_venv._bin_dir / "console_script.bat" + cmd_script = tmp_venv._bin_dir / "console_script.cmd" + + cmd_script.write_text("exit 15") + bat_script.write_text("exit 30") + assert tester.execute("console_script") == 30 + assert tester.execute("console_script.bat") == 30 + assert tester.execute("console_script.cmd") == 15 + + +@pytest.mark.skipif( + not WINDOWS, reason="This test asserts Windows-specific compatibility", +) +def test_script_external_to_env(tmp_venv, command_tester_factory, mocker): + """ + If a script exists on the path outside poetry, or in the current directory, + poetry run should still work + """ + new_environ = {} + new_environ.update(os.environ) + + tester = command_tester_factory("run", environment=tmp_venv) + + # create directory and add it to the PATH + with tempfile.TemporaryDirectory() as tmp_dir_name: + # add script to current directory + script_in_cur_dir = tempfile.NamedTemporaryFile( + "w", dir=".", suffix=".CMD", delete=False + ) + script_in_cur_dir.write("exit 30") + script_in_cur_dir.close() + + try: + # add script to the new directory + script = Path(tmp_dir_name) / "console_script.cmd" + script.write_text("exit 15") + + new_environ[ + "PATHEXT" + ] = ".BAT;.CMD" # ensure environ vars are deterministic + new_environ["PATH"] = os.environ["PATH"] + os.pathsep + tmp_dir_name + mocker.patch("os.environ", new_environ) + + # poetry run will find it as it searched the path + assert tester.execute("console_script") == 15 + assert tester.execute(script_in_cur_dir.name) == 30 + + finally: + os.unlink(script_in_cur_dir.name)