From bfaa2b33c544461d5bee20cde3716d451d72e0fe Mon Sep 17 00:00:00 2001 From: Bartosz Sokorski Date: Mon, 23 Sep 2024 23:37:20 +0200 Subject: [PATCH] Replace shell command with activator --- poetry.lock | 28 +--- pyproject.toml | 1 - src/poetry/console/application.py | 2 +- src/poetry/console/commands/env/activate.py | 64 ++++++++ src/poetry/console/commands/shell.py | 57 ------- src/poetry/utils/env/base_env.py | 4 + src/poetry/utils/shell.py | 172 -------------------- tests/console/commands/env/test_activate.py | 74 +++++++++ tests/console/commands/test_shell.py | 89 ---------- 9 files changed, 144 insertions(+), 347 deletions(-) create mode 100644 src/poetry/console/commands/env/activate.py delete mode 100644 src/poetry/console/commands/shell.py delete mode 100644 src/poetry/utils/shell.py create mode 100644 tests/console/commands/env/test_activate.py delete mode 100644 tests/console/commands/test_shell.py diff --git a/poetry.lock b/poetry.lock index 8bef0cfed76..279b21db948 100644 --- a/poetry.lock +++ b/poetry.lock @@ -833,7 +833,6 @@ files = [ {file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fbb160554e319f7b22ecf530a80a3ff496d38e8e07ae763b9e82fadfe96f273"}, {file = "msgpack-1.0.8-cp39-cp39-win32.whl", hash = "sha256:f9af38a89b6a5c04b7d18c492c8ccf2aee7048aff1ce8437c4683bb5a1df893d"}, {file = "msgpack-1.0.8-cp39-cp39-win_amd64.whl", hash = "sha256:ed59dd52075f8fc91da6053b12e8c89e37aa043f8986efd89e61fae69dc1b011"}, - {file = "msgpack-1.0.8-py3-none-any.whl", hash = "sha256:24f727df1e20b9876fa6e95f840a2a2651e34c0ad147676356f4bf5fbb0206ca"}, {file = "msgpack-1.0.8.tar.gz", hash = "sha256:95c02b0e27e706e48d0e5426d1710ca78e0f0628d6e89d5b5a5b91a5f12274f3"}, ] @@ -931,20 +930,6 @@ files = [ {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] -[[package]] -name = "pexpect" -version = "4.9.0" -description = "Pexpect allows easy control of interactive console applications." -optional = false -python-versions = "*" -files = [ - {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, - {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"}, -] - -[package.dependencies] -ptyprocess = ">=0.5" - [[package]] name = "pkginfo" version = "1.11.1" @@ -1052,17 +1037,6 @@ files = [ [package.extras] test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"] -[[package]] -name = "ptyprocess" -version = "0.7.0" -description = "Run a subprocess in a pseudo terminal" -optional = false -python-versions = "*" -files = [ - {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, - {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, -] - [[package]] name = "pycparser" version = "2.22" @@ -1631,4 +1605,4 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "74b6f4dc98249bd138d4a45120c185f5df794354b880c1695aab8e197df8e87e" +content-hash = "d0cc4752b6ea79fd9f2b7879cf0fce3d8bcb8781c00d4af39dde670829b6e5a7" diff --git a/pyproject.toml b/pyproject.toml index 6b8ba209ff2..897a6e89f6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,6 @@ installer = "^0.7.0" keyring = "^25.1.0" # packaging uses calver, so version is unclamped packaging = ">=24.0" -pexpect = "^4.7.0" pkginfo = "^1.10" platformdirs = ">=3.0.0,<5" pyproject-hooks = "^1.0.0" diff --git a/src/poetry/console/application.py b/src/poetry/console/application.py index a10b41a309e..c5c1348d885 100644 --- a/src/poetry/console/application.py +++ b/src/poetry/console/application.py @@ -62,7 +62,6 @@ def _load() -> Command: "remove", "run", "search", - "shell", "show", "update", "version", @@ -73,6 +72,7 @@ def _load() -> Command: "debug info", "debug resolve", # Env commands + "env activate", "env info", "env list", "env remove", diff --git a/src/poetry/console/commands/env/activate.py b/src/poetry/console/commands/env/activate.py new file mode 100644 index 00000000000..bdc25297159 --- /dev/null +++ b/src/poetry/console/commands/env/activate.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import shlex + +from typing import TYPE_CHECKING + +import shellingham + +from poetry.console.commands.command import Command +from poetry.utils._compat import WINDOWS + + +if TYPE_CHECKING: + from poetry.utils.env import Env + + +class ShellNotSupportedError(Exception): + """Raised when a shell doesn't have an activator in virtual environment""" + + +class EnvActivateCommand(Command): + name = "env activate" + description = "Print the command to activate a virtual environment" + + def handle(self) -> int: + from poetry.utils.env import EnvManager + + env = EnvManager(self.poetry).get() + + if command := self.get_activate_command(env): + self.line(command) + return 0 + else: + raise ShellNotSupportedError( + "Discovered shell doesn't have an activator in virtual environment" + ) + + def get_activate_command(self, env: Env) -> str: + try: + shell, _ = shellingham.detect_shell() + except shellingham.ShellDetectionFailure: + shell = "" + if shell == "fish": + command, filename = "source", "activate.fish" + elif shell == "nu": + command, filename = "overlay use", "activate.nu" + elif shell == "csh": + command, filename = "source", "activate.csh" + elif shell in ["powershell", "pwsh"]: + command, filename = ".", "Activate.ps1" + else: + command, filename = "source", "activate" + + if (activation_script := env.bin_dir / filename).exists(): + if WINDOWS: + return f"{self.quote(str(activation_script), shell)}" + return f"{command} {self.quote(str(activation_script), shell)}" + return "" + + @staticmethod + def quote(command: str, shell: str) -> str: + if shell in ["powershell", "pwsh"] or WINDOWS: + return "{}".format(command.replace("'", "''")) + return shlex.quote(command) diff --git a/src/poetry/console/commands/shell.py b/src/poetry/console/commands/shell.py deleted file mode 100644 index 62e9e39ce2d..00000000000 --- a/src/poetry/console/commands/shell.py +++ /dev/null @@ -1,57 +0,0 @@ -from __future__ import annotations - -import os -import sys - -from typing import TYPE_CHECKING -from typing import cast - -from poetry.console.commands.env_command import EnvCommand - - -if TYPE_CHECKING: - from poetry.utils.env import VirtualEnv - - -class ShellCommand(EnvCommand): - name = "shell" - description = "Spawns a shell within the virtual environment." - - help = f"""The shell command spawns a shell within the project's virtual environment. - -By default, the current active shell is detected and used. Failing that, -the shell defined via the environment variable {'COMSPEC' if os.name == 'nt' else 'SHELL'} is used. - -If a virtual environment does not exist, it will be created. -""" - - def handle(self) -> int: - from poetry.utils.shell import Shell - - # Check if it's already activated or doesn't exist and won't be created - if self._is_venv_activated(): - self.line( - f"Virtual environment already activated: {self.env.path}" - ) - - return 0 - - self.line(f"Spawning shell within {self.env.path}") - - # Be sure that we have the right type of environment. - env = self.env - assert env.is_venv() - env = cast("VirtualEnv", env) - - # Setting this to avoid spawning unnecessary nested shells - os.environ["POETRY_ACTIVE"] = "1" - shell = Shell.get() - shell.activate(env) - os.environ.pop("POETRY_ACTIVE") - - return 0 - - def _is_venv_activated(self) -> bool: - return bool(os.environ.get("POETRY_ACTIVE")) or getattr( - sys, "real_prefix", sys.prefix - ) == str(self.env.path) diff --git a/src/poetry/utils/env/base_env.py b/src/poetry/utils/env/base_env.py index 7c5574c19e3..f3b778f75b3 100644 --- a/src/poetry/utils/env/base_env.py +++ b/src/poetry/utils/env/base_env.py @@ -67,6 +67,10 @@ def __init__(self, path: Path, base: Path | None = None) -> None: self._embedded_pip_path: Path | None = None + @property + def bin_dir(self) -> Path: + return self._bin_dir + @property def path(self) -> Path: return self._path diff --git a/src/poetry/utils/shell.py b/src/poetry/utils/shell.py deleted file mode 100644 index 8f720962cdb..00000000000 --- a/src/poetry/utils/shell.py +++ /dev/null @@ -1,172 +0,0 @@ -from __future__ import annotations - -import os -import shutil -import signal -import subprocess -import sys - -from pathlib import Path -from typing import TYPE_CHECKING -from typing import Any - -import pexpect - -from shellingham import ShellDetectionFailure -from shellingham import detect_shell - -from poetry.utils._compat import WINDOWS - - -if TYPE_CHECKING: - from poetry.utils.env import VirtualEnv - - -class Shell: - """ - Represents the current shell. - """ - - _shell = None - - def __init__(self, name: str, path: str) -> None: - self._name = name - self._path = path - - @property - def name(self) -> str: - return self._name - - @property - def path(self) -> str: - return self._path - - @classmethod - def get(cls) -> Shell: - """ - Retrieve the current shell. - """ - if cls._shell is not None: - return cls._shell - - try: - name, path = detect_shell(os.getpid()) - except (RuntimeError, ShellDetectionFailure): - shell = None - - if os.name == "posix": - shell = os.environ.get("SHELL") - elif os.name == "nt": - shell = os.environ.get("COMSPEC") - - if not shell: - raise RuntimeError("Unable to detect the current shell.") - - name, path = Path(shell).stem, shell - - cls._shell = cls(name, path) - - return cls._shell - - def activate(self, env: VirtualEnv) -> int | None: - activate_script = self._get_activate_script() - if WINDOWS: - bin_path = env.path / "Scripts" - # Python innstalled via msys2 on Windows might produce a POSIX-like venv - # See https://github.com/python-poetry/poetry/issues/8638 - bin_dir = "Scripts" if bin_path.exists() else "bin" - else: - bin_dir = "bin" - activate_path = env.path / bin_dir / activate_script - - # mypy requires using sys.platform instead of WINDOWS constant - # in if statements to properly type check on Windows - if sys.platform == "win32": - args = None - if self._name in ("powershell", "pwsh"): - args = ["-NoExit", "-File", str(activate_path)] - elif self._name == "cmd": - # /K will execute the bat file and - # keep the cmd process from terminating - args = ["/K", str(activate_path)] - - if args: - completed_proc = subprocess.run([self.path, *args]) - return completed_proc.returncode - else: - # If no args are set, execute the shell within the venv - # This activates it, but there could be some features missing: - # deactivate command might not work - # shell prompt will not be modified. - return env.execute(self._path) - - import shlex - - terminal = shutil.get_terminal_size() - cmd = f"{self._get_source_command()} {shlex.quote(str(activate_path))}" - - with env.temp_environ(): - if self._name == "nu": - args = ["-e", cmd] - elif self._name == "fish": - args = ["-i", "--init-command", cmd] - else: - args = ["-i"] - - c = pexpect.spawn( - self._path, args, dimensions=(terminal.lines, terminal.columns) - ) - - if self._name in ["zsh"]: - c.setecho(False) - - if self._name == "zsh": - # Under ZSH the source command should be invoked in zsh's bash emulator - quoted_activate_path = shlex.quote(str(activate_path)) - c.sendline(f"emulate bash -c {shlex.quote(f'. {quoted_activate_path}')}") - elif self._name == "xonsh": - c.sendline(f"vox activate {shlex.quote(str(env.path))}") - elif self._name in ["nu", "fish"]: - # If this is nu or fish, we don't want to send the activation command to the - # command line since we already ran it via the shell's invocation. - pass - else: - c.sendline(cmd) - - def resize(sig: Any, data: Any) -> None: - terminal = shutil.get_terminal_size() - c.setwinsize(terminal.lines, terminal.columns) - - signal.signal(signal.SIGWINCH, resize) - - # Interact with the new shell. - c.interact(escape_character=None) - c.close() - - sys.exit(c.exitstatus) - - def _get_activate_script(self) -> str: - if self._name == "fish": - suffix = ".fish" - elif self._name in ("csh", "tcsh"): - suffix = ".csh" - elif self._name in ("powershell", "pwsh"): - suffix = ".ps1" - elif self._name == "cmd": - suffix = ".bat" - elif self._name == "nu": - suffix = ".nu" - else: - suffix = "" - - return "activate" + suffix - - def _get_source_command(self) -> str: - if self._name in ("fish", "csh", "tcsh"): - return "source" - elif self._name == "nu": - return "overlay use" - return "." - - def __repr__(self) -> str: - return f'{self.__class__.__name__}("{self._name}", "{self._path}")' diff --git a/tests/console/commands/env/test_activate.py b/tests/console/commands/env/test_activate.py new file mode 100644 index 00000000000..15c29d18713 --- /dev/null +++ b/tests/console/commands/env/test_activate.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from poetry.utils._compat import WINDOWS + + +if TYPE_CHECKING: + from cleo.testers.command_tester import CommandTester + from pytest_mock import MockerFixture + + from poetry.utils.env import VirtualEnv + from tests.types import CommandTesterFactory + + +@pytest.fixture +def tester(command_tester_factory: CommandTesterFactory) -> CommandTester: + return command_tester_factory("env activate") + + +@pytest.mark.parametrize( + "shell, command, ext", + ( + ("bash", "source", ""), + ("zsh", "source", ""), + ("fish", "source", ".fish"), + ("nu", "overlay use", ".nu"), + ("csh", "source", ".csh"), + ), +) +@pytest.mark.skipif(WINDOWS, reason="Only Unix shells") +def test_env_activate_prints_correct_script_windows( + tmp_venv: VirtualEnv, + mocker: MockerFixture, + tester: CommandTester, + shell: str, + command: str, + ext: str, +) -> None: + mocker.patch("shellingham.detect_shell", return_value=(shell, None)) + mocker.patch("poetry.utils.env.EnvManager.get", return_value=tmp_venv) + + tester.execute() + + line = tester.io.fetch_output().split("\n")[0] + assert line.startswith(command) + assert line.endswith(f"activate{ext}") + + +@pytest.mark.parametrize( + "shell, command, ext", + ( + ("pwsh", ".", "Activate.ps1"), + ("powershell", ".", "Activate.ps1"), + ), +) +@pytest.mark.skipif(not WINDOWS, reason="Only Windows shells") +def test_env_activate_prints_correct_script( + tmp_venv: VirtualEnv, + mocker: MockerFixture, + tester: CommandTester, + shell: str, + command: str, + ext: str, +) -> None: + mocker.patch("shellingham.detect_shell", return_value=(shell, None)) + mocker.patch("poetry.utils.env.EnvManager.get", return_value=tmp_venv) + + tester.execute() + + line = tester.io.fetch_output().split("\n")[0] + assert line == str(tmp_venv.bin_dir / ext) diff --git a/tests/console/commands/test_shell.py b/tests/console/commands/test_shell.py deleted file mode 100644 index 4ef48f2dc44..00000000000 --- a/tests/console/commands/test_shell.py +++ /dev/null @@ -1,89 +0,0 @@ -from __future__ import annotations - -import os - -from pathlib import Path -from typing import TYPE_CHECKING - -import pytest - -from poetry.console.commands.shell import ShellCommand - - -if TYPE_CHECKING: - from cleo.testers.command_tester import CommandTester - from pytest_mock import MockerFixture - - from tests.types import CommandTesterFactory - - -@pytest.fixture -def tester(command_tester_factory: CommandTesterFactory) -> CommandTester: - return command_tester_factory("shell") - - -def test_shell(tester: CommandTester, mocker: MockerFixture) -> None: - shell_activate = mocker.patch("poetry.utils.shell.Shell.activate") - - tester.execute() - - assert isinstance(tester.command, ShellCommand) - expected_output = f"Spawning shell within {tester.command.env.path}\n" - - shell_activate.assert_called_once_with(tester.command.env) - assert tester.io.fetch_output() == expected_output - assert tester.status_code == 0 - - -def test_shell_already_active(tester: CommandTester, mocker: MockerFixture) -> None: - os.environ["POETRY_ACTIVE"] = "1" - shell_activate = mocker.patch("poetry.utils.shell.Shell.activate") - - tester.execute() - - assert isinstance(tester.command, ShellCommand) - expected_output = ( - f"Virtual environment already activated: {tester.command.env.path}\n" - ) - - shell_activate.assert_not_called() - assert tester.io.fetch_output() == expected_output - assert tester.status_code == 0 - - -@pytest.mark.parametrize( - ("poetry_active", "real_prefix", "prefix", "expected"), - [ - (None, None, "", False), - ("", None, "", False), - (" ", None, "", True), - ("0", None, "", True), - ("1", None, "", True), - ("foobar", None, "", True), - ("1", "foobar", "foobar", True), - (None, "foobar", "foobar", True), - (None, "foobar", "foo", True), - (None, None, "foobar", True), - (None, "foo", "foobar", False), - (None, "foo", "foo", False), - ], -) -def test__is_venv_activated( - tester: CommandTester, - mocker: MockerFixture, - poetry_active: str | None, - real_prefix: str | None, - prefix: str, - expected: bool, -) -> None: - assert isinstance(tester.command, ShellCommand) - mocker.patch.object(tester.command.env, "_path", Path("foobar")) - mocker.patch("sys.prefix", prefix) - - if real_prefix is not None: - mocker.patch("sys.real_prefix", real_prefix, create=True) - - if poetry_active is not None: - os.environ["POETRY_ACTIVE"] = poetry_active - - assert tester.command._is_venv_activated() is expected