diff --git a/src/poetry/utils/env.py b/src/poetry/utils/env.py
index d5a96fac4c2..91a35f212e7 100644
--- a/src/poetry/utils/env.py
+++ b/src/poetry/utils/env.py
@@ -9,6 +9,7 @@
import platform
import plistlib
import re
+import shutil
import subprocess
import sys
import sysconfig
@@ -472,6 +473,11 @@ def __init__(self, e: CalledProcessError, input: str | None = None) -> None:
super().__init__("\n\n".join(message_parts))
+class PythonVersionNotFound(EnvError):
+ def __init__(self, expected: str) -> None:
+ super().__init__(f"Could not find the python executable {expected}")
+
+
class NoCompatiblePythonVersionFound(EnvError):
def __init__(self, expected: str, given: str | None = None) -> None:
if given:
@@ -517,34 +523,39 @@ def __init__(self, poetry: Poetry, io: None | IO = None) -> None:
self._io = io or NullIO()
@staticmethod
- def _full_python_path(python: str) -> Path:
+ def _full_python_path(python: str) -> Path | None:
+ # eg first find pythonXY.bat on windows.
+ path_python = shutil.which(python)
+ if path_python is None:
+ return None
+
try:
executable = decode(
subprocess.check_output(
- [python, "-c", "import sys; print(sys.executable)"],
+ [path_python, "-c", "import sys; print(sys.executable)"],
).strip()
)
- except CalledProcessError as e:
- raise EnvCommandError(e)
+ return Path(executable)
- return Path(executable)
+ except CalledProcessError:
+ return None
@staticmethod
def _detect_active_python(io: None | IO = None) -> Path | None:
io = io or NullIO()
- executable = None
+ io.write_error_line(
+ (
+ "Trying to detect current active python executable as specified in"
+ " the config."
+ ),
+ verbosity=Verbosity.VERBOSE,
+ )
- try:
- io.write_error_line(
- (
- "Trying to detect current active python executable as specified in"
- " the config."
- ),
- verbosity=Verbosity.VERBOSE,
- )
- executable = EnvManager._full_python_path("python")
+ executable = EnvManager._full_python_path("python")
+
+ if executable is not None:
io.write_error_line(f"Found: {executable}", verbosity=Verbosity.VERBOSE)
- except EnvCommandError:
+ else:
io.write_error_line(
(
"Unable to detect the current active python executable. Falling"
@@ -552,6 +563,7 @@ def _detect_active_python(io: None | IO = None) -> Path | None:
),
verbosity=Verbosity.VERBOSE,
)
+
return executable
@staticmethod
@@ -592,6 +604,8 @@ def activate(self, python: str) -> Env:
pass
python_path = self._full_python_path(python)
+ if python_path is None:
+ raise PythonVersionNotFound(python)
try:
python_version_string = decode(
@@ -949,25 +963,26 @@ def create_venv(
"Trying to find and use a compatible version. "
)
- for python_to_try in sorted(
+ for suffix in sorted(
self._poetry.package.AVAILABLE_PYTHONS,
key=lambda v: (v.startswith("3"), -len(v), v),
reverse=True,
):
- if len(python_to_try) == 1:
- if not parse_constraint(f"^{python_to_try}.0").allows_any(
+ if len(suffix) == 1:
+ if not parse_constraint(f"^{suffix}.0").allows_any(
supported_python
):
continue
- elif not supported_python.allows_any(
- parse_constraint(python_to_try + ".*")
- ):
+ elif not supported_python.allows_any(parse_constraint(suffix + ".*")):
continue
- python = "python" + python_to_try
-
+ python_name = f"python{suffix}"
if self._io.is_debug():
- self._io.write_error_line(f"Trying {python}")
+ self._io.write_error_line(f"Trying {python_name}")
+
+ python = self._full_python_path(python_name)
+ if python is None:
+ continue
try:
python_patch = decode(
@@ -979,14 +994,11 @@ def create_venv(
except CalledProcessError:
continue
- if not python_patch:
- continue
-
if supported_python.allows(Version.parse(python_patch)):
self._io.write_error_line(
- f"Using {python} ({python_patch})"
+ f"Using {python_name} ({python_patch})"
)
- executable = self._full_python_path(python)
+ executable = python
python_minor = ".".join(python_patch.split(".")[:2])
break
diff --git a/tests/console/commands/env/helpers.py b/tests/console/commands/env/helpers.py
index 0a067b3c430..942c27243d4 100644
--- a/tests/console/commands/env/helpers.py
+++ b/tests/console/commands/env/helpers.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+import os
+
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Any
@@ -28,9 +30,11 @@ def check_output(cmd: list[str], *args: Any, **kwargs: Any) -> str:
elif "sys.version_info[:2]" in python_cmd:
return f"{version.major}.{version.minor}"
elif "import sys; print(sys.executable)" in python_cmd:
- return f"/usr/bin/{cmd[0]}"
+ executable = cmd[0]
+ basename = os.path.basename(executable)
+ return f"/usr/bin/{basename}"
else:
assert "import sys; print(sys.prefix)" in python_cmd
- return str(Path("/prefix"))
+ return "/prefix"
return check_output
diff --git a/tests/console/commands/env/test_use.py b/tests/console/commands/env/test_use.py
index 72df646d97c..d0abd38f8c4 100644
--- a/tests/console/commands/env/test_use.py
+++ b/tests/console/commands/env/test_use.py
@@ -56,6 +56,7 @@ def test_activate_activates_non_existing_virtualenv_no_envs_file(
venv_name: str,
venvs_in_cache_config: None,
) -> None:
+ mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
mocker.patch(
"subprocess.check_output",
side_effect=check_output_wrapper(),
@@ -94,6 +95,7 @@ def test_activate_activates_non_existing_virtualenv_no_envs_file(
def test_get_prefers_explicitly_activated_virtualenvs_over_env_var(
+ mocker: MockerFixture,
tester: CommandTester,
current_python: tuple[int, int, int],
venv_cache: Path,
@@ -112,6 +114,8 @@ def test_get_prefers_explicitly_activated_virtualenvs_over_env_var(
doc[venv_name] = {"minor": python_minor, "patch": python_patch}
envs_file.write(doc)
+ mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
+
tester.execute(python_minor)
expected = f"""\
@@ -134,6 +138,7 @@ def test_get_prefers_explicitly_activated_non_existing_virtualenvs_over_env_var(
python_minor = ".".join(str(v) for v in current_python[:2])
venv_dir = venv_cache / f"{venv_name}-py{python_minor}"
+ mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
mocker.patch(
"poetry.utils.env.EnvManager._env",
new_callable=mocker.PropertyMock,
diff --git a/tests/utils/test_env.py b/tests/utils/test_env.py
index b41d22e6b53..5e1fb3cdbb9 100644
--- a/tests/utils/test_env.py
+++ b/tests/utils/test_env.py
@@ -27,6 +27,7 @@
from poetry.utils.env import InvalidCurrentPythonVersionError
from poetry.utils.env import MockEnv
from poetry.utils.env import NoCompatiblePythonVersionFound
+from poetry.utils.env import PythonVersionNotFound
from poetry.utils.env import SystemEnv
from poetry.utils.env import VirtualEnv
from poetry.utils.env import build_environment
@@ -197,10 +198,12 @@ def check_output(cmd: list[str], *args: Any, **kwargs: Any) -> str:
elif "sys.version_info[:2]" in python_cmd:
return f"{version.major}.{version.minor}"
elif "import sys; print(sys.executable)" in python_cmd:
- return f"/usr/bin/{cmd[0]}"
+ executable = cmd[0]
+ basename = os.path.basename(executable)
+ return f"/usr/bin/{basename}"
else:
assert "import sys; print(sys.prefix)" in python_cmd
- return str(Path("/prefix"))
+ return "/prefix"
return check_output
@@ -218,6 +221,7 @@ def test_activate_activates_non_existing_virtualenv_no_envs_file(
config.merge({"virtualenvs": {"path": str(tmp_dir)}})
+ mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
mocker.patch(
"subprocess.check_output",
side_effect=check_output_wrapper(),
@@ -252,6 +256,30 @@ def test_activate_activates_non_existing_virtualenv_no_envs_file(
assert env.base == Path("/prefix")
+def test_activate_fails_when_python_cannot_be_found(
+ tmp_dir: str,
+ manager: EnvManager,
+ poetry: Poetry,
+ config: Config,
+ mocker: MockerFixture,
+ venv_name: str,
+) -> None:
+ if "VIRTUAL_ENV" in os.environ:
+ del os.environ["VIRTUAL_ENV"]
+
+ os.mkdir(os.path.join(tmp_dir, f"{venv_name}-py3.7"))
+
+ config.merge({"virtualenvs": {"path": str(tmp_dir)}})
+
+ mocker.patch("shutil.which", return_value=None)
+
+ with pytest.raises(PythonVersionNotFound) as e:
+ manager.activate("python3.7")
+
+ expected_message = "Could not find the python executable python3.7"
+ assert str(e.value) == expected_message
+
+
def test_activate_activates_existing_virtualenv_no_envs_file(
tmp_dir: str,
manager: EnvManager,
@@ -267,6 +295,7 @@ def test_activate_activates_existing_virtualenv_no_envs_file(
config.merge({"virtualenvs": {"path": str(tmp_dir)}})
+ mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
mocker.patch(
"subprocess.check_output",
side_effect=check_output_wrapper(),
@@ -311,6 +340,7 @@ def test_activate_activates_same_virtualenv_with_envs_file(
config.merge({"virtualenvs": {"path": str(tmp_dir)}})
+ mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
mocker.patch(
"subprocess.check_output",
side_effect=check_output_wrapper(),
@@ -354,6 +384,7 @@ def test_activate_activates_different_virtualenv_with_envs_file(
config.merge({"virtualenvs": {"path": str(tmp_dir)}})
+ mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
mocker.patch(
"subprocess.check_output",
side_effect=check_output_wrapper(Version.parse("3.6.6")),
@@ -407,6 +438,7 @@ def test_activate_activates_recreates_for_different_patch(
config.merge({"virtualenvs": {"path": str(tmp_dir)}})
+ mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
mocker.patch(
"subprocess.check_output",
side_effect=check_output_wrapper(),
@@ -474,6 +506,7 @@ def test_activate_does_not_recreate_when_switching_minor(
config.merge({"virtualenvs": {"path": str(tmp_dir)}})
+ mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
mocker.patch(
"subprocess.check_output",
side_effect=check_output_wrapper(Version.parse("3.6.6")),
@@ -1070,6 +1103,7 @@ def test_create_venv_tries_to_find_a_compatible_python_executable_using_generic_
poetry.package.python_versions = "^3.6"
mocker.patch("sys.version_info", (2, 7, 16))
+ mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
mocker.patch(
"subprocess.check_output",
side_effect=check_output_wrapper(Version.parse("3.7.5")),
@@ -1093,6 +1127,34 @@ def test_create_venv_tries_to_find_a_compatible_python_executable_using_generic_
)
+def test_create_venv_finds_no_python_executable(
+ manager: EnvManager,
+ poetry: Poetry,
+ config: Config,
+ mocker: MockerFixture,
+ config_virtualenvs_path: Path,
+ venv_name: str,
+) -> None:
+ if "VIRTUAL_ENV" in os.environ:
+ del os.environ["VIRTUAL_ENV"]
+
+ poetry.package.python_versions = "^3.6"
+
+ mocker.patch("sys.version_info", (2, 7, 16))
+ mocker.patch("shutil.which", return_value=None)
+
+ with pytest.raises(NoCompatiblePythonVersionFound) as e:
+ manager.create_venv()
+
+ expected_message = (
+ "Poetry was unable to find a compatible version. "
+ "If you have one, you can explicitly use it "
+ 'via the "env use" command.'
+ )
+
+ assert str(e.value) == expected_message
+
+
def test_create_venv_tries_to_find_a_compatible_python_executable_using_specific_ones(
manager: EnvManager,
poetry: Poetry,
@@ -1107,8 +1169,10 @@ def test_create_venv_tries_to_find_a_compatible_python_executable_using_specific
poetry.package.python_versions = "^3.6"
mocker.patch("sys.version_info", (2, 7, 16))
+ mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
mocker.patch(
- "subprocess.check_output", side_effect=["3.5.3", "3.9.0", "/usr/bin/python3.9"]
+ "subprocess.check_output",
+ side_effect=["/usr/bin/python3", "3.5.3", "/usr/bin/python3.9", "3.9.0"],
)
m = mocker.patch(
"poetry.utils.env.EnvManager.build_venv", side_effect=lambda *args, **kwargs: ""
@@ -1309,6 +1373,7 @@ def test_activate_with_in_project_setting_does_not_fail_if_no_venvs_dir(
}
)
+ mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
mocker.patch(
"subprocess.check_output",
side_effect=check_output_wrapper(),
@@ -1546,13 +1611,15 @@ def test_create_venv_accepts_fallback_version_w_nonzero_patchlevel(
def mock_check_output(cmd: str, *args: Any, **kwargs: Any) -> str:
if GET_PYTHON_VERSION_ONELINER in cmd:
- if "python3.5" in cmd:
+ executable = cmd[0]
+ if "python3.5" in str(executable):
return "3.5.12"
else:
return "3.7.1"
else:
return "/usr/bin/python3.5"
+ mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
check_output = mocker.patch(
"subprocess.check_output",
side_effect=mock_check_output,
@@ -1662,6 +1729,7 @@ def test_create_venv_project_name_empty_sets_correct_prompt(
venv_name = manager.generate_env_name("", str(poetry.file.parent))
mocker.patch("sys.version_info", (2, 7, 16))
+ mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
mocker.patch(
"subprocess.check_output",
side_effect=check_output_wrapper(Version.parse("3.7.5")),
@@ -1697,3 +1765,17 @@ def test_fallback_on_detect_active_python(
assert active_python is None
assert m.call_count == 1
+
+
+@pytest.mark.skipif(sys.platform != "win32", reason="Windows only")
+def test_detect_active_python_with_bat(poetry: Poetry, tmp_path: Path) -> None:
+ """On Windows pyenv uses batch files for python management."""
+ python_wrapper = tmp_path / "python.bat"
+ wrapped_python = Path(r"C:\SpecialPython\python.exe")
+ with python_wrapper.open("w") as f:
+ f.write(f"@echo {wrapped_python}")
+ os.environ["PATH"] = str(python_wrapper.parent) + os.pathsep + os.environ["PATH"]
+
+ active_python = EnvManager(poetry)._detect_active_python()
+
+ assert active_python == wrapped_python