From 48d4ace01be668ed80a75ed6520ab6ded238034d 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 | 35 +--- pyproject.toml | 1 - src/poetry/console/application.py | 2 +- src/poetry/console/commands/env/activate.py | 67 ++++++++ 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 | 47 ++++++ tests/console/commands/test_shell.py | 89 ---------- 9 files changed, 124 insertions(+), 350 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 38cf588c08e..9ad9f70d0b0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -20,7 +20,7 @@ name = "build" version = "1.2.2" description = "A simple, correct Python build frontend" optional = false -python-versions = ">= 3.8" +python-versions = ">=3.8" files = [ {file = "build-1.2.2-py3-none-any.whl", hash = "sha256:277ccc71619d98afdd841a0e96ac9fe1593b823af481d3b0cea748e8894e0613"}, {file = "build-1.2.2.tar.gz", hash = "sha256:119b2fb462adef986483438377a13b2f42064a2a3a4161f24a0cca698a07ac8c"}, @@ -884,7 +884,7 @@ name = "nodeenv" version = "1.9.1" description = "Node.js virtual environment builder" optional = false -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, @@ -915,20 +915,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" @@ -994,7 +980,7 @@ name = "poetry-plugin-export" version = "1.8.0" description = "Poetry plugin to export the dependencies to various formats" optional = false -python-versions = ">=3.8,<4.0" +python-versions = "<4.0,>=3.8" files = [ {file = "poetry_plugin_export-1.8.0-py3-none-any.whl", hash = "sha256:adbe232cfa0cc04991ea3680c865cf748bff27593b9abcb1f35fb50ed7ba2c22"}, {file = "poetry_plugin_export-1.8.0.tar.gz", hash = "sha256:1fa6168a85d59395d835ca564bc19862a7c76061e60c3e7dfaec70d50937fc61"}, @@ -1027,7 +1013,7 @@ name = "psutil" version = "6.0.0" description = "Cross-platform lib for process and system monitoring in Python." optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" files = [ {file = "psutil-6.0.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a021da3e881cd935e64a3d0a20983bda0bb4cf80e4f74fa9bfcb1bc5785360c6"}, {file = "psutil-6.0.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:1287c2b95f1c0a364d23bc6f2ea2365a8d4d9b726a3be7294296ff7ba97c17f0"}, @@ -1051,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" @@ -1630,4 +1605,4 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "816bcb3532fd7484005946146373ddb848e7405a72f10a8dc345b9e4596427f7" +content-hash = "a6382a86b2a384f8f75d8f948c2951876bd29718c0121ab774d671fbb54d7f87" diff --git a/pyproject.toml b/pyproject.toml index f9fe2d313e3..86c6e46a2e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,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 d144c427d4f..f2a51d3c698 100644 --- a/src/poetry/console/application.py +++ b/src/poetry/console/application.py @@ -60,7 +60,6 @@ def _load() -> Command: "remove", "run", "search", - "shell", "show", "update", "version", @@ -71,6 +70,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..3e096911f43 --- /dev/null +++ b/src/poetry/console/commands/env/activate.py @@ -0,0 +1,67 @@ +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): + print(command) + 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" + + print(env.bin_dir, command, filename) + + if (activate_script := env.bin_dir / filename).exists(): + if WINDOWS: + return f"{self.quote(str(activate_script), shell)}" + return f"{command} {self.quote(str(activate_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 5a8f2fc6146..e46762bc53f 100644 --- a/src/poetry/utils/env/base_env.py +++ b/src/poetry/utils/env/base_env.py @@ -66,6 +66,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..38dad552d51 --- /dev/null +++ b/tests/console/commands/env/test_activate.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + + +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"), + ("pwsh", ".", "Activate.ps1"), + ("powershell", ".", "Activate.ps1"), + ), +) +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() + + assert tester.io.fetch_output().startswith(command) + assert tester.io.fetch_output().strip("\n").endswith(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