Commit dfb49048 by David Hotham Committed by GitHub

use shutil.which() to detect the active python (#7771)

Co-authored-by: Randy Döring <30527984+radoering@users.noreply.github.com>
parent f6e1f936
...@@ -9,6 +9,7 @@ import os ...@@ -9,6 +9,7 @@ import os
import platform import platform
import plistlib import plistlib
import re import re
import shutil
import subprocess import subprocess
import sys import sys
import sysconfig import sysconfig
...@@ -472,6 +473,11 @@ class EnvCommandError(EnvError): ...@@ -472,6 +473,11 @@ class EnvCommandError(EnvError):
super().__init__("\n\n".join(message_parts)) 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): class NoCompatiblePythonVersionFound(EnvError):
def __init__(self, expected: str, given: str | None = None) -> None: def __init__(self, expected: str, given: str | None = None) -> None:
if given: if given:
...@@ -517,24 +523,26 @@ class EnvManager: ...@@ -517,24 +523,26 @@ class EnvManager:
self._io = io or NullIO() self._io = io or NullIO()
@staticmethod @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: try:
executable = decode( executable = decode(
subprocess.check_output( subprocess.check_output(
[python, "-c", "import sys; print(sys.executable)"], [path_python, "-c", "import sys; print(sys.executable)"],
).strip() ).strip()
) )
except CalledProcessError as e:
raise EnvCommandError(e)
return Path(executable) return Path(executable)
except CalledProcessError:
return None
@staticmethod @staticmethod
def _detect_active_python(io: None | IO = None) -> Path | None: def _detect_active_python(io: None | IO = None) -> Path | None:
io = io or NullIO() io = io or NullIO()
executable = None
try:
io.write_error_line( io.write_error_line(
( (
"Trying to detect current active python executable as specified in" "Trying to detect current active python executable as specified in"
...@@ -542,9 +550,12 @@ class EnvManager: ...@@ -542,9 +550,12 @@ class EnvManager:
), ),
verbosity=Verbosity.VERBOSE, 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) io.write_error_line(f"Found: {executable}", verbosity=Verbosity.VERBOSE)
except EnvCommandError: else:
io.write_error_line( io.write_error_line(
( (
"Unable to detect the current active python executable. Falling" "Unable to detect the current active python executable. Falling"
...@@ -552,6 +563,7 @@ class EnvManager: ...@@ -552,6 +563,7 @@ class EnvManager:
), ),
verbosity=Verbosity.VERBOSE, verbosity=Verbosity.VERBOSE,
) )
return executable return executable
@staticmethod @staticmethod
...@@ -592,6 +604,8 @@ class EnvManager: ...@@ -592,6 +604,8 @@ class EnvManager:
pass pass
python_path = self._full_python_path(python) python_path = self._full_python_path(python)
if python_path is None:
raise PythonVersionNotFound(python)
try: try:
python_version_string = decode( python_version_string = decode(
...@@ -949,25 +963,26 @@ class EnvManager: ...@@ -949,25 +963,26 @@ class EnvManager:
"Trying to find and use a compatible version.</warning> " "Trying to find and use a compatible version.</warning> "
) )
for python_to_try in sorted( for suffix in sorted(
self._poetry.package.AVAILABLE_PYTHONS, self._poetry.package.AVAILABLE_PYTHONS,
key=lambda v: (v.startswith("3"), -len(v), v), key=lambda v: (v.startswith("3"), -len(v), v),
reverse=True, reverse=True,
): ):
if len(python_to_try) == 1: if len(suffix) == 1:
if not parse_constraint(f"^{python_to_try}.0").allows_any( if not parse_constraint(f"^{suffix}.0").allows_any(
supported_python supported_python
): ):
continue continue
elif not supported_python.allows_any( elif not supported_python.allows_any(parse_constraint(suffix + ".*")):
parse_constraint(python_to_try + ".*")
):
continue continue
python = "python" + python_to_try python_name = f"python{suffix}"
if self._io.is_debug(): if self._io.is_debug():
self._io.write_error_line(f"<debug>Trying {python}</debug>") self._io.write_error_line(f"<debug>Trying {python_name}</debug>")
python = self._full_python_path(python_name)
if python is None:
continue
try: try:
python_patch = decode( python_patch = decode(
...@@ -979,14 +994,11 @@ class EnvManager: ...@@ -979,14 +994,11 @@ class EnvManager:
except CalledProcessError: except CalledProcessError:
continue continue
if not python_patch:
continue
if supported_python.allows(Version.parse(python_patch)): if supported_python.allows(Version.parse(python_patch)):
self._io.write_error_line( self._io.write_error_line(
f"Using <c1>{python}</c1> ({python_patch})" f"Using <c1>{python_name}</c1> ({python_patch})"
) )
executable = self._full_python_path(python) executable = python
python_minor = ".".join(python_patch.split(".")[:2]) python_minor = ".".join(python_patch.split(".")[:2])
break break
......
from __future__ import annotations from __future__ import annotations
import os
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Any from typing import Any
...@@ -28,9 +30,11 @@ def check_output_wrapper( ...@@ -28,9 +30,11 @@ def check_output_wrapper(
elif "sys.version_info[:2]" in python_cmd: elif "sys.version_info[:2]" in python_cmd:
return f"{version.major}.{version.minor}" return f"{version.major}.{version.minor}"
elif "import sys; print(sys.executable)" in python_cmd: 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: else:
assert "import sys; print(sys.prefix)" in python_cmd assert "import sys; print(sys.prefix)" in python_cmd
return str(Path("/prefix")) return "/prefix"
return check_output return check_output
...@@ -56,6 +56,7 @@ def test_activate_activates_non_existing_virtualenv_no_envs_file( ...@@ -56,6 +56,7 @@ def test_activate_activates_non_existing_virtualenv_no_envs_file(
venv_name: str, venv_name: str,
venvs_in_cache_config: None, venvs_in_cache_config: None,
) -> None: ) -> None:
mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
mocker.patch( mocker.patch(
"subprocess.check_output", "subprocess.check_output",
side_effect=check_output_wrapper(), side_effect=check_output_wrapper(),
...@@ -94,6 +95,7 @@ def test_activate_activates_non_existing_virtualenv_no_envs_file( ...@@ -94,6 +95,7 @@ def test_activate_activates_non_existing_virtualenv_no_envs_file(
def test_get_prefers_explicitly_activated_virtualenvs_over_env_var( def test_get_prefers_explicitly_activated_virtualenvs_over_env_var(
mocker: MockerFixture,
tester: CommandTester, tester: CommandTester,
current_python: tuple[int, int, int], current_python: tuple[int, int, int],
venv_cache: Path, venv_cache: Path,
...@@ -112,6 +114,8 @@ def test_get_prefers_explicitly_activated_virtualenvs_over_env_var( ...@@ -112,6 +114,8 @@ def test_get_prefers_explicitly_activated_virtualenvs_over_env_var(
doc[venv_name] = {"minor": python_minor, "patch": python_patch} doc[venv_name] = {"minor": python_minor, "patch": python_patch}
envs_file.write(doc) envs_file.write(doc)
mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
tester.execute(python_minor) tester.execute(python_minor)
expected = f"""\ expected = f"""\
...@@ -134,6 +138,7 @@ def test_get_prefers_explicitly_activated_non_existing_virtualenvs_over_env_var( ...@@ -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]) python_minor = ".".join(str(v) for v in current_python[:2])
venv_dir = venv_cache / f"{venv_name}-py{python_minor}" venv_dir = venv_cache / f"{venv_name}-py{python_minor}"
mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
mocker.patch( mocker.patch(
"poetry.utils.env.EnvManager._env", "poetry.utils.env.EnvManager._env",
new_callable=mocker.PropertyMock, new_callable=mocker.PropertyMock,
......
...@@ -27,6 +27,7 @@ from poetry.utils.env import IncorrectEnvError ...@@ -27,6 +27,7 @@ from poetry.utils.env import IncorrectEnvError
from poetry.utils.env import InvalidCurrentPythonVersionError from poetry.utils.env import InvalidCurrentPythonVersionError
from poetry.utils.env import MockEnv from poetry.utils.env import MockEnv
from poetry.utils.env import NoCompatiblePythonVersionFound from poetry.utils.env import NoCompatiblePythonVersionFound
from poetry.utils.env import PythonVersionNotFound
from poetry.utils.env import SystemEnv from poetry.utils.env import SystemEnv
from poetry.utils.env import VirtualEnv from poetry.utils.env import VirtualEnv
from poetry.utils.env import build_environment from poetry.utils.env import build_environment
...@@ -197,10 +198,12 @@ def check_output_wrapper( ...@@ -197,10 +198,12 @@ def check_output_wrapper(
elif "sys.version_info[:2]" in python_cmd: elif "sys.version_info[:2]" in python_cmd:
return f"{version.major}.{version.minor}" return f"{version.major}.{version.minor}"
elif "import sys; print(sys.executable)" in python_cmd: 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: else:
assert "import sys; print(sys.prefix)" in python_cmd assert "import sys; print(sys.prefix)" in python_cmd
return str(Path("/prefix")) return "/prefix"
return check_output return check_output
...@@ -218,6 +221,7 @@ def test_activate_activates_non_existing_virtualenv_no_envs_file( ...@@ -218,6 +221,7 @@ def test_activate_activates_non_existing_virtualenv_no_envs_file(
config.merge({"virtualenvs": {"path": str(tmp_dir)}}) config.merge({"virtualenvs": {"path": str(tmp_dir)}})
mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
mocker.patch( mocker.patch(
"subprocess.check_output", "subprocess.check_output",
side_effect=check_output_wrapper(), side_effect=check_output_wrapper(),
...@@ -252,6 +256,30 @@ def test_activate_activates_non_existing_virtualenv_no_envs_file( ...@@ -252,6 +256,30 @@ def test_activate_activates_non_existing_virtualenv_no_envs_file(
assert env.base == Path("/prefix") 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( def test_activate_activates_existing_virtualenv_no_envs_file(
tmp_dir: str, tmp_dir: str,
manager: EnvManager, manager: EnvManager,
...@@ -267,6 +295,7 @@ def test_activate_activates_existing_virtualenv_no_envs_file( ...@@ -267,6 +295,7 @@ def test_activate_activates_existing_virtualenv_no_envs_file(
config.merge({"virtualenvs": {"path": str(tmp_dir)}}) config.merge({"virtualenvs": {"path": str(tmp_dir)}})
mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
mocker.patch( mocker.patch(
"subprocess.check_output", "subprocess.check_output",
side_effect=check_output_wrapper(), side_effect=check_output_wrapper(),
...@@ -311,6 +340,7 @@ def test_activate_activates_same_virtualenv_with_envs_file( ...@@ -311,6 +340,7 @@ def test_activate_activates_same_virtualenv_with_envs_file(
config.merge({"virtualenvs": {"path": str(tmp_dir)}}) config.merge({"virtualenvs": {"path": str(tmp_dir)}})
mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
mocker.patch( mocker.patch(
"subprocess.check_output", "subprocess.check_output",
side_effect=check_output_wrapper(), side_effect=check_output_wrapper(),
...@@ -354,6 +384,7 @@ def test_activate_activates_different_virtualenv_with_envs_file( ...@@ -354,6 +384,7 @@ def test_activate_activates_different_virtualenv_with_envs_file(
config.merge({"virtualenvs": {"path": str(tmp_dir)}}) config.merge({"virtualenvs": {"path": str(tmp_dir)}})
mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
mocker.patch( mocker.patch(
"subprocess.check_output", "subprocess.check_output",
side_effect=check_output_wrapper(Version.parse("3.6.6")), side_effect=check_output_wrapper(Version.parse("3.6.6")),
...@@ -407,6 +438,7 @@ def test_activate_activates_recreates_for_different_patch( ...@@ -407,6 +438,7 @@ def test_activate_activates_recreates_for_different_patch(
config.merge({"virtualenvs": {"path": str(tmp_dir)}}) config.merge({"virtualenvs": {"path": str(tmp_dir)}})
mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
mocker.patch( mocker.patch(
"subprocess.check_output", "subprocess.check_output",
side_effect=check_output_wrapper(), side_effect=check_output_wrapper(),
...@@ -474,6 +506,7 @@ def test_activate_does_not_recreate_when_switching_minor( ...@@ -474,6 +506,7 @@ def test_activate_does_not_recreate_when_switching_minor(
config.merge({"virtualenvs": {"path": str(tmp_dir)}}) config.merge({"virtualenvs": {"path": str(tmp_dir)}})
mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
mocker.patch( mocker.patch(
"subprocess.check_output", "subprocess.check_output",
side_effect=check_output_wrapper(Version.parse("3.6.6")), 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_ ...@@ -1070,6 +1103,7 @@ def test_create_venv_tries_to_find_a_compatible_python_executable_using_generic_
poetry.package.python_versions = "^3.6" poetry.package.python_versions = "^3.6"
mocker.patch("sys.version_info", (2, 7, 16)) mocker.patch("sys.version_info", (2, 7, 16))
mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
mocker.patch( mocker.patch(
"subprocess.check_output", "subprocess.check_output",
side_effect=check_output_wrapper(Version.parse("3.7.5")), 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_ ...@@ -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( def test_create_venv_tries_to_find_a_compatible_python_executable_using_specific_ones(
manager: EnvManager, manager: EnvManager,
poetry: Poetry, poetry: Poetry,
...@@ -1107,8 +1169,10 @@ def test_create_venv_tries_to_find_a_compatible_python_executable_using_specific ...@@ -1107,8 +1169,10 @@ def test_create_venv_tries_to_find_a_compatible_python_executable_using_specific
poetry.package.python_versions = "^3.6" poetry.package.python_versions = "^3.6"
mocker.patch("sys.version_info", (2, 7, 16)) mocker.patch("sys.version_info", (2, 7, 16))
mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
mocker.patch( 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( m = mocker.patch(
"poetry.utils.env.EnvManager.build_venv", side_effect=lambda *args, **kwargs: "" "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( ...@@ -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( mocker.patch(
"subprocess.check_output", "subprocess.check_output",
side_effect=check_output_wrapper(), side_effect=check_output_wrapper(),
...@@ -1546,13 +1611,15 @@ def test_create_venv_accepts_fallback_version_w_nonzero_patchlevel( ...@@ -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: def mock_check_output(cmd: str, *args: Any, **kwargs: Any) -> str:
if GET_PYTHON_VERSION_ONELINER in cmd: 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" return "3.5.12"
else: else:
return "3.7.1" return "3.7.1"
else: else:
return "/usr/bin/python3.5" return "/usr/bin/python3.5"
mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
check_output = mocker.patch( check_output = mocker.patch(
"subprocess.check_output", "subprocess.check_output",
side_effect=mock_check_output, side_effect=mock_check_output,
...@@ -1662,6 +1729,7 @@ def test_create_venv_project_name_empty_sets_correct_prompt( ...@@ -1662,6 +1729,7 @@ def test_create_venv_project_name_empty_sets_correct_prompt(
venv_name = manager.generate_env_name("", str(poetry.file.parent)) venv_name = manager.generate_env_name("", str(poetry.file.parent))
mocker.patch("sys.version_info", (2, 7, 16)) mocker.patch("sys.version_info", (2, 7, 16))
mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
mocker.patch( mocker.patch(
"subprocess.check_output", "subprocess.check_output",
side_effect=check_output_wrapper(Version.parse("3.7.5")), side_effect=check_output_wrapper(Version.parse("3.7.5")),
...@@ -1697,3 +1765,17 @@ def test_fallback_on_detect_active_python( ...@@ -1697,3 +1765,17 @@ def test_fallback_on_detect_active_python(
assert active_python is None assert active_python is None
assert m.call_count == 1 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
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment