diff --git a/src/poetry/console/application.py b/src/poetry/console/application.py index 89f78bf0819..1ff189bd819 100644 --- a/src/poetry/console/application.py +++ b/src/poetry/console/application.py @@ -13,6 +13,7 @@ from cleo.events.console_command_event import ConsoleCommandEvent from cleo.events.console_events import COMMAND from cleo.events.event_dispatcher import EventDispatcher +from cleo.exceptions import CleoCommandNotFoundError from cleo.exceptions import CleoError from cleo.formatters.style import Style @@ -94,6 +95,25 @@ def _load() -> Command: "source show", ] +# these are special messages to override the default message when a command is not found +# in cases where a previously existing command has been moved to a plugin or outright +# removed for various reasons +COMMAND_NOT_FOUND_PREFIX_MESSAGE = ( + "Looks like you're trying to use a Poetry command that is not available." +) +COMMAND_NOT_FOUND_MESSAGES = { + "shell": """ +Since Poetry (2.0.0), the shell command is not installed by default. You can use, + + - the new env activate command (recommended); or + - the shell plugin to install the shell command + +Documentation: https://python-poetry.org/docs/managing-environments/#activating-the-environment + +Note that the env activate command is not a direct replacement for shell command. +""" +} + class Application(BaseApplication): def __init__(self) -> None: @@ -228,7 +248,20 @@ def _run(self, io: IO) -> int: self._load_plugins(io) with directory(self._working_directory): - exit_code: int = super()._run(io) + try: + exit_code: int = super()._run(io) + except CleoCommandNotFoundError as e: + command = self._get_command_name(io) + + if command is not None and ( + message := COMMAND_NOT_FOUND_MESSAGES.get(command) + ): + io.write_error_line("") + io.write_error_line(COMMAND_NOT_FOUND_PREFIX_MESSAGE) + io.write_error_line(message) + return 1 + + raise e return exit_code diff --git a/tests/conftest.py b/tests/conftest.py index 39d71e172fc..d69dc6bd12d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,6 @@ from collections.abc import Iterator from pathlib import Path from typing import TYPE_CHECKING -from typing import Any import httpretty import keyring @@ -26,6 +25,7 @@ from poetry.config.config import Config as BaseConfig from poetry.config.dict_config_source import DictConfigSource +from poetry.console.commands.command import Command from poetry.factory import Factory from poetry.layouts import layout from poetry.packages.direct_origin import _get_package_from_git @@ -49,7 +49,11 @@ if TYPE_CHECKING: from collections.abc import Iterator from collections.abc import Mapping + from typing import Any + from typing import Callable + from cleo.io.inputs.argument import Argument + from cleo.io.inputs.option import Option from keyring.credentials import Credential from pytest import Config as PyTestConfig from pytest import Parser @@ -57,6 +61,7 @@ from pytest_mock import MockerFixture from poetry.poetry import Poetry + from tests.types import CommandFactory from tests.types import FixtureCopier from tests.types import FixtureDirGetter from tests.types import ProjectFactory @@ -582,3 +587,43 @@ def project_context(project: str | Path, in_place: bool = False) -> Iterator[Pat yield path return project_context + + +@pytest.fixture +def command_factory() -> CommandFactory: + """ + Provides a pytest fixture for creating mock commands using a factory function. + + This fixture allows for customization of command attributes like name, + arguments, options, description, help text, and handler. + """ + + def _command_factory( + command_name: str, + command_arguments: list[Argument] | None = None, + command_options: list[Option] | None = None, + command_description: str = "", + command_help: str = "", + command_handler: Callable[[Command], int] | str | None = None, + ) -> Command: + class MockCommand(Command): + name = command_name + arguments = command_arguments or [] + options = command_options or [] + description = command_description + help = command_help + + def handle(self) -> int: + if command_handler is not None and not isinstance(command_handler, str): + return command_handler(self) + + self._io.write_line( + command_handler + or f"The mock command '{command_name}' has been called" + ) + + return 0 + + return MockCommand() + + return _command_factory diff --git a/tests/console/test_application_removed_commands.py b/tests/console/test_application_removed_commands.py new file mode 100644 index 00000000000..5f328dbc73a --- /dev/null +++ b/tests/console/test_application_removed_commands.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from cleo.testers.application_tester import ApplicationTester + +from poetry.console.application import COMMAND_NOT_FOUND_PREFIX_MESSAGE +from poetry.console.application import Application + + +if TYPE_CHECKING: + from tests.types import CommandFactory + + +@pytest.fixture +def tester() -> ApplicationTester: + return ApplicationTester(Application()) + + +def test_application_removed_command_default_message( + tester: ApplicationTester, +) -> None: + tester.execute("nonexistent") + assert tester.status_code != 0 + + stderr = tester.io.fetch_error() + assert COMMAND_NOT_FOUND_PREFIX_MESSAGE not in stderr + assert 'The command "nonexistent" does not exist.' in stderr + + +@pytest.mark.parametrize( + ("command", "message"), + [ + ("shell", "shell command is not installed by default"), + ], +) +def test_application_removed_command_messages( + command: str, + message: str, + tester: ApplicationTester, + command_factory: CommandFactory, +) -> None: + # ensure precondition is met + assert not tester.application.has(command) + + # verify that the custom message is returned and command fails + tester.execute(command) + assert tester.status_code != 0 + + stderr = tester.io.fetch_error() + assert COMMAND_NOT_FOUND_PREFIX_MESSAGE in stderr + assert message in stderr + + # flush any output/error messages to ensure consistency + tester.io.clear() + + # add a mock command and verify the command succeeds and no error message is provided + message = "The shell command was called" + tester.application.add(command_factory(command, command_handler=message)) + assert tester.application.has(command) + + tester.execute(command) + assert tester.status_code == 0 + + stdout = tester.io.fetch_output() + stderr = tester.io.fetch_error() + assert message in stdout + assert COMMAND_NOT_FOUND_PREFIX_MESSAGE not in stderr + assert stderr == "" diff --git a/tests/types.py b/tests/types.py index a9b2e8079d9..7027be6af79 100644 --- a/tests/types.py +++ b/tests/types.py @@ -10,6 +10,8 @@ from contextlib import AbstractContextManager from pathlib import Path + from cleo.io.inputs.argument import Argument + from cleo.io.inputs.option import Option from cleo.io.io import IO from cleo.testers.command_tester import CommandTester from httpretty.core import HTTPrettyRequest @@ -17,6 +19,7 @@ from poetry.config.config import Config from poetry.config.source import Source + from poetry.console.commands.command import Command from poetry.installation import Installer from poetry.installation.executor import Executor from poetry.poetry import Poetry @@ -65,6 +68,18 @@ def __call__( ) -> Poetry: ... +class CommandFactory(Protocol): + def __call__( + self, + command_name: str, + command_arguments: list[Argument] | None = None, + command_options: list[Option] | None = None, + command_description: str = "", + command_help: str = "", + command_handler: Callable[[Command], int] | str | None = None, + ) -> Command: ... + + class FixtureDirGetter(Protocol): def __call__(self, name: str) -> Path: ...