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
import platform
import plistlib
import re
import shutil
import subprocess
import sys
import sysconfig
......@@ -472,6 +473,11 @@ class EnvCommandError(EnvError):
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 @@ class EnvManager:
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 @@ class EnvManager:
),
verbosity=Verbosity.VERBOSE,
)
return executable
@staticmethod
......@@ -592,6 +604,8 @@ class EnvManager:
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 @@ class EnvManager:
"Trying to find and use a compatible version.</warning> "
)
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"<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:
python_patch = decode(
......@@ -979,14 +994,11 @@ class EnvManager:
except CalledProcessError:
continue
if not python_patch:
continue
if supported_python.allows(Version.parse(python_patch)):
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])
break
......
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_wrapper(
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
......@@ -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,
......
......@@ -27,6 +27,7 @@ from poetry.utils.env import IncorrectEnvError
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_wrapper(
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
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