Skip to content

feat(app): add better error for removed commands #10053

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jan 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion src/poetry/console/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <info>Poetry (<b>2.0.0</>)</>, the <c1>shell</> command is not installed by default. You can use,

- the new <c1>env activate</> command (<b>recommended</>); or
- the <c1>shell plugin</> to install the <c1>shell</> command

<b>Documentation:</> https://python-poetry.org/docs/managing-environments/#activating-the-environment

<warning>Note that the <c1>env activate</> command is not a direct replacement for <c1>shell</> command.
"""
}


class Application(BaseApplication):
def __init__(self) -> None:
Expand Down Expand Up @@ -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

Expand Down
47 changes: 46 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -49,14 +49,19 @@
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
from pytest import TempPathFactory
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
Expand Down Expand Up @@ -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
71 changes: 71 additions & 0 deletions tests/console/test_application_removed_commands.py
Original file line number Diff line number Diff line change
@@ -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 == ""
15 changes: 15 additions & 0 deletions tests/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@
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
from packaging.utils import NormalizedName

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
Expand Down Expand Up @@ -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: ...

Expand Down
Loading