Skip to content

Commit 854f7a7

Browse files
committed
use shutil.which() to detect python executables
1 parent 860c838 commit 854f7a7

File tree

4 files changed

+121
-36
lines changed

4 files changed

+121
-36
lines changed

src/poetry/utils/env.py

Lines changed: 42 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import platform
1010
import plistlib
1111
import re
12+
import shutil
1213
import subprocess
1314
import sys
1415
import sysconfig
@@ -472,6 +473,11 @@ def __init__(self, e: CalledProcessError, input: str | None = None) -> None:
472473
super().__init__("\n\n".join(message_parts))
473474

474475

476+
class PythonVersionNotFound(EnvError):
477+
def __init__(self, expected: str) -> None:
478+
super().__init__(f"Could not find the python executable {expected}")
479+
480+
475481
class NoCompatiblePythonVersionFound(EnvError):
476482
def __init__(self, expected: str, given: str | None = None) -> None:
477483
if given:
@@ -517,41 +523,47 @@ def __init__(self, poetry: Poetry, io: None | IO = None) -> None:
517523
self._io = io or NullIO()
518524

519525
@staticmethod
520-
def _full_python_path(python: str) -> Path:
526+
def _full_python_path(python: str) -> Path | None:
527+
# eg first find pythonXY.bat on windows.
528+
path_python = shutil.which(python)
529+
if path_python is None:
530+
return None
531+
521532
try:
522533
executable = decode(
523534
subprocess.check_output(
524-
[python, "-c", "import sys; print(sys.executable)"],
535+
[path_python, "-c", "import sys; print(sys.executable)"],
525536
).strip()
526537
)
527-
except CalledProcessError as e:
528-
raise EnvCommandError(e)
538+
return Path(executable)
529539

530-
return Path(executable)
540+
except CalledProcessError:
541+
return None
531542

532543
@staticmethod
533544
def _detect_active_python(io: None | IO = None) -> Path | None:
534545
io = io or NullIO()
535-
executable = None
546+
io.write_error_line(
547+
(
548+
"Trying to detect current active python executable as specified in"
549+
" the config."
550+
),
551+
verbosity=Verbosity.VERBOSE,
552+
)
536553

537-
try:
538-
io.write_error_line(
539-
(
540-
"Trying to detect current active python executable as specified in"
541-
" the config."
542-
),
543-
verbosity=Verbosity.VERBOSE,
544-
)
545-
executable = EnvManager._full_python_path("python")
554+
executable = EnvManager._full_python_path("python")
555+
556+
if executable is not None:
546557
io.write_error_line(f"Found: {executable}", verbosity=Verbosity.VERBOSE)
547-
except EnvCommandError:
558+
else:
548559
io.write_error_line(
549560
(
550561
"Unable to detect the current active python executable. Falling"
551562
" back to default."
552563
),
553564
verbosity=Verbosity.VERBOSE,
554565
)
566+
555567
return executable
556568

557569
@staticmethod
@@ -592,6 +604,8 @@ def activate(self, python: str) -> Env:
592604
pass
593605

594606
python_path = self._full_python_path(python)
607+
if python_path is None:
608+
raise PythonVersionNotFound(python)
595609

596610
try:
597611
python_version_string = decode(
@@ -949,25 +963,26 @@ def create_venv(
949963
"Trying to find and use a compatible version.</warning> "
950964
)
951965

952-
for python_to_try in sorted(
966+
for suffix in sorted(
953967
self._poetry.package.AVAILABLE_PYTHONS,
954968
key=lambda v: (v.startswith("3"), -len(v), v),
955969
reverse=True,
956970
):
957-
if len(python_to_try) == 1:
958-
if not parse_constraint(f"^{python_to_try}.0").allows_any(
971+
if len(suffix) == 1:
972+
if not parse_constraint(f"^{suffix}.0").allows_any(
959973
supported_python
960974
):
961975
continue
962-
elif not supported_python.allows_any(
963-
parse_constraint(python_to_try + ".*")
964-
):
976+
elif not supported_python.allows_any(parse_constraint(suffix + ".*")):
965977
continue
966978

967-
python = "python" + python_to_try
968-
979+
python_name = f"python{suffix}"
969980
if self._io.is_debug():
970-
self._io.write_error_line(f"<debug>Trying {python}</debug>")
981+
self._io.write_error_line(f"<debug>Trying {python_name}</debug>")
982+
983+
python = self._full_python_path(python_name)
984+
if python is None:
985+
continue
971986

972987
try:
973988
python_patch = decode(
@@ -979,14 +994,11 @@ def create_venv(
979994
except CalledProcessError:
980995
continue
981996

982-
if not python_patch:
983-
continue
984-
985997
if supported_python.allows(Version.parse(python_patch)):
986998
self._io.write_error_line(
987-
f"Using <c1>{python}</c1> ({python_patch})"
999+
f"Using <c1>{python_name}</c1> ({python_patch})"
9881000
)
989-
executable = self._full_python_path(python)
1001+
executable = python
9901002
python_minor = ".".join(python_patch.split(".")[:2])
9911003
break
9921004

tests/console/commands/env/helpers.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
import os
4+
35
from pathlib import Path
46
from typing import TYPE_CHECKING
57
from typing import Any
@@ -28,9 +30,11 @@ def check_output(cmd: list[str], *args: Any, **kwargs: Any) -> str:
2830
elif "sys.version_info[:2]" in python_cmd:
2931
return f"{version.major}.{version.minor}"
3032
elif "import sys; print(sys.executable)" in python_cmd:
31-
return f"/usr/bin/{cmd[0]}"
33+
executable = cmd[0]
34+
basename = os.path.basename(executable)
35+
return f"/usr/bin/{basename}"
3236
else:
3337
assert "import sys; print(sys.prefix)" in python_cmd
34-
return str(Path("/prefix"))
38+
return "/prefix"
3539

3640
return check_output

tests/console/commands/env/test_use.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ def test_activate_activates_non_existing_virtualenv_no_envs_file(
5656
venv_name: str,
5757
venvs_in_cache_config: None,
5858
) -> None:
59+
mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
5960
mocker.patch(
6061
"subprocess.check_output",
6162
side_effect=check_output_wrapper(),

tests/utils/test_env.py

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from poetry.utils.env import InvalidCurrentPythonVersionError
2828
from poetry.utils.env import MockEnv
2929
from poetry.utils.env import NoCompatiblePythonVersionFound
30+
from poetry.utils.env import PythonVersionNotFound
3031
from poetry.utils.env import SystemEnv
3132
from poetry.utils.env import VirtualEnv
3233
from poetry.utils.env import build_environment
@@ -197,10 +198,12 @@ def check_output(cmd: list[str], *args: Any, **kwargs: Any) -> str:
197198
elif "sys.version_info[:2]" in python_cmd:
198199
return f"{version.major}.{version.minor}"
199200
elif "import sys; print(sys.executable)" in python_cmd:
200-
return f"/usr/bin/{cmd[0]}"
201+
executable = cmd[0]
202+
basename = os.path.basename(executable)
203+
return f"/usr/bin/{basename}"
201204
else:
202205
assert "import sys; print(sys.prefix)" in python_cmd
203-
return str(Path("/prefix"))
206+
return "/prefix"
204207

205208
return check_output
206209

@@ -218,6 +221,7 @@ def test_activate_activates_non_existing_virtualenv_no_envs_file(
218221

219222
config.merge({"virtualenvs": {"path": str(tmp_dir)}})
220223

224+
mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
221225
mocker.patch(
222226
"subprocess.check_output",
223227
side_effect=check_output_wrapper(),
@@ -252,6 +256,30 @@ def test_activate_activates_non_existing_virtualenv_no_envs_file(
252256
assert env.base == Path("/prefix")
253257

254258

259+
def test_activate_fails_when_python_cannot_be_found(
260+
tmp_dir: str,
261+
manager: EnvManager,
262+
poetry: Poetry,
263+
config: Config,
264+
mocker: MockerFixture,
265+
venv_name: str,
266+
) -> None:
267+
if "VIRTUAL_ENV" in os.environ:
268+
del os.environ["VIRTUAL_ENV"]
269+
270+
os.mkdir(os.path.join(tmp_dir, f"{venv_name}-py3.7"))
271+
272+
config.merge({"virtualenvs": {"path": str(tmp_dir)}})
273+
274+
mocker.patch("shutil.which", return_value=None)
275+
276+
with pytest.raises(PythonVersionNotFound) as e:
277+
manager.activate("python3.7")
278+
279+
expected_message = "Could not find the python executable python3.7"
280+
assert str(e.value) == expected_message
281+
282+
255283
def test_activate_activates_existing_virtualenv_no_envs_file(
256284
tmp_dir: str,
257285
manager: EnvManager,
@@ -267,6 +295,7 @@ def test_activate_activates_existing_virtualenv_no_envs_file(
267295

268296
config.merge({"virtualenvs": {"path": str(tmp_dir)}})
269297

298+
mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
270299
mocker.patch(
271300
"subprocess.check_output",
272301
side_effect=check_output_wrapper(),
@@ -311,6 +340,7 @@ def test_activate_activates_same_virtualenv_with_envs_file(
311340

312341
config.merge({"virtualenvs": {"path": str(tmp_dir)}})
313342

343+
mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
314344
mocker.patch(
315345
"subprocess.check_output",
316346
side_effect=check_output_wrapper(),
@@ -354,6 +384,7 @@ def test_activate_activates_different_virtualenv_with_envs_file(
354384

355385
config.merge({"virtualenvs": {"path": str(tmp_dir)}})
356386

387+
mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
357388
mocker.patch(
358389
"subprocess.check_output",
359390
side_effect=check_output_wrapper(Version.parse("3.6.6")),
@@ -407,6 +438,7 @@ def test_activate_activates_recreates_for_different_patch(
407438

408439
config.merge({"virtualenvs": {"path": str(tmp_dir)}})
409440

441+
mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
410442
mocker.patch(
411443
"subprocess.check_output",
412444
side_effect=check_output_wrapper(),
@@ -474,6 +506,7 @@ def test_activate_does_not_recreate_when_switching_minor(
474506

475507
config.merge({"virtualenvs": {"path": str(tmp_dir)}})
476508

509+
mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
477510
mocker.patch(
478511
"subprocess.check_output",
479512
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_
10701103
poetry.package.python_versions = "^3.6"
10711104

10721105
mocker.patch("sys.version_info", (2, 7, 16))
1106+
mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
10731107
mocker.patch(
10741108
"subprocess.check_output",
10751109
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_
10931127
)
10941128

10951129

1130+
def test_create_venv_finds_no_python_executable(
1131+
manager: EnvManager,
1132+
poetry: Poetry,
1133+
config: Config,
1134+
mocker: MockerFixture,
1135+
config_virtualenvs_path: Path,
1136+
venv_name: str,
1137+
) -> None:
1138+
if "VIRTUAL_ENV" in os.environ:
1139+
del os.environ["VIRTUAL_ENV"]
1140+
1141+
poetry.package.python_versions = "^3.6"
1142+
1143+
mocker.patch("sys.version_info", (2, 7, 16))
1144+
mocker.patch("shutil.which", return_value=None)
1145+
1146+
with pytest.raises(NoCompatiblePythonVersionFound) as e:
1147+
manager.create_venv()
1148+
1149+
expected_message = (
1150+
"Poetry was unable to find a compatible version. "
1151+
"If you have one, you can explicitly use it "
1152+
'via the "env use" command.'
1153+
)
1154+
1155+
assert str(e.value) == expected_message
1156+
1157+
10961158
def test_create_venv_tries_to_find_a_compatible_python_executable_using_specific_ones(
10971159
manager: EnvManager,
10981160
poetry: Poetry,
@@ -1107,8 +1169,10 @@ def test_create_venv_tries_to_find_a_compatible_python_executable_using_specific
11071169
poetry.package.python_versions = "^3.6"
11081170

11091171
mocker.patch("sys.version_info", (2, 7, 16))
1172+
mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
11101173
mocker.patch(
1111-
"subprocess.check_output", side_effect=["3.5.3", "3.9.0", "/usr/bin/python3.9"]
1174+
"subprocess.check_output",
1175+
side_effect=["/usr/bin/python3", "3.5.3", "/usr/bin/python3.9", "3.9.0"],
11121176
)
11131177
m = mocker.patch(
11141178
"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(
13091373
}
13101374
)
13111375

1376+
mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
13121377
mocker.patch(
13131378
"subprocess.check_output",
13141379
side_effect=check_output_wrapper(),
@@ -1546,13 +1611,15 @@ def test_create_venv_accepts_fallback_version_w_nonzero_patchlevel(
15461611

15471612
def mock_check_output(cmd: str, *args: Any, **kwargs: Any) -> str:
15481613
if GET_PYTHON_VERSION_ONELINER in cmd:
1549-
if "python3.5" in cmd:
1614+
executable = cmd[0]
1615+
if "python3.5" in str(executable):
15501616
return "3.5.12"
15511617
else:
15521618
return "3.7.1"
15531619
else:
15541620
return "/usr/bin/python3.5"
15551621

1622+
mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
15561623
check_output = mocker.patch(
15571624
"subprocess.check_output",
15581625
side_effect=mock_check_output,
@@ -1662,6 +1729,7 @@ def test_create_venv_project_name_empty_sets_correct_prompt(
16621729
venv_name = manager.generate_env_name("", str(poetry.file.parent))
16631730

16641731
mocker.patch("sys.version_info", (2, 7, 16))
1732+
mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}")
16651733
mocker.patch(
16661734
"subprocess.check_output",
16671735
side_effect=check_output_wrapper(Version.parse("3.7.5")),

0 commit comments

Comments
 (0)