Skip to content

Commit ed15d09

Browse files
committed
feat(app): add better error for removed commands
In cases like the `shell` command, where it has been moved to a plugin, allow Poetry to provide a better UX when these commands are called.
1 parent 81f2935 commit ed15d09

File tree

4 files changed

+166
-2
lines changed

4 files changed

+166
-2
lines changed

src/poetry/console/application.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from cleo.events.console_command_event import ConsoleCommandEvent
1414
from cleo.events.console_events import COMMAND
1515
from cleo.events.event_dispatcher import EventDispatcher
16+
from cleo.exceptions import CleoCommandNotFoundError
1617
from cleo.exceptions import CleoError
1718
from cleo.formatters.style import Style
1819

@@ -94,6 +95,25 @@ def _load() -> Command:
9495
"source show",
9596
]
9697

98+
# these are special messages to override the default message when a command is not found
99+
# in cases where a previously existing command has been moved to a plugin or outright
100+
# removed for various reasons
101+
COMMAND_NOT_FOUND_PREFIX_MESSAGE = (
102+
"Looks like you're trying to use a Poetry command that is not available."
103+
)
104+
COMMAND_NOT_FOUND_MESSAGES = {
105+
"shell": """
106+
Since <info>Poetry (<b>2.0.0</>)</>, the <c1>shell</> command is not installed by default. You can use,
107+
108+
- the new <c1>env activate</> command (<b>recommended</>); or
109+
- the <c1>shell plugin</> to install the <c1>shell</> command
110+
111+
<b>Documentation:</> https://python-poetry.org/docs/managing-environments/#activating-the-environment
112+
113+
<warning>Note that the <c1>env activate</> command is not a direct replacement for <c1>shell</> command.
114+
"""
115+
}
116+
97117

98118
class Application(BaseApplication):
99119
def __init__(self) -> None:
@@ -228,7 +248,20 @@ def _run(self, io: IO) -> int:
228248
self._load_plugins(io)
229249

230250
with directory(self._working_directory):
231-
exit_code: int = super()._run(io)
251+
try:
252+
exit_code: int = super()._run(io)
253+
except CleoCommandNotFoundError as e:
254+
command = self._get_command_name(io)
255+
256+
if command is not None and (
257+
message := COMMAND_NOT_FOUND_MESSAGES.get(command)
258+
):
259+
io.write_error_line("")
260+
io.write_error_line(COMMAND_NOT_FOUND_PREFIX_MESSAGE)
261+
io.write_error_line(message)
262+
return 1
263+
264+
raise e
232265

233266
return exit_code
234267

tests/conftest.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from collections.abc import Iterator
1111
from pathlib import Path
1212
from typing import TYPE_CHECKING
13-
from typing import Any
1413

1514
import httpretty
1615
import keyring
@@ -26,6 +25,7 @@
2625

2726
from poetry.config.config import Config as BaseConfig
2827
from poetry.config.dict_config_source import DictConfigSource
28+
from poetry.console.commands.command import Command
2929
from poetry.factory import Factory
3030
from poetry.layouts import layout
3131
from poetry.packages.direct_origin import _get_package_from_git
@@ -49,14 +49,19 @@
4949
if TYPE_CHECKING:
5050
from collections.abc import Iterator
5151
from collections.abc import Mapping
52+
from typing import Any
53+
from typing import Callable
5254

55+
from cleo.io.inputs.argument import Argument
56+
from cleo.io.inputs.option import Option
5357
from keyring.credentials import Credential
5458
from pytest import Config as PyTestConfig
5559
from pytest import Parser
5660
from pytest import TempPathFactory
5761
from pytest_mock import MockerFixture
5862

5963
from poetry.poetry import Poetry
64+
from tests.types import CommandFactory
6065
from tests.types import FixtureCopier
6166
from tests.types import FixtureDirGetter
6267
from tests.types import ProjectFactory
@@ -582,3 +587,43 @@ def project_context(project: str | Path, in_place: bool = False) -> Iterator[Pat
582587
yield path
583588

584589
return project_context
590+
591+
592+
@pytest.fixture
593+
def command_factory() -> CommandFactory:
594+
"""
595+
Provides a pytest fixture for creating mock commands using a factory function.
596+
597+
This fixture allows for customization of command attributes like name,
598+
arguments, options, description, help text, and handler.
599+
"""
600+
601+
def _command_factory(
602+
command_name: str,
603+
command_arguments: list[Argument] | None = None,
604+
command_options: list[Option] | None = None,
605+
command_description: str = "",
606+
command_help: str = "",
607+
command_handler: Callable[[Command], int] | str | None = None,
608+
) -> Command:
609+
class MockCommand(Command):
610+
name = command_name
611+
arguments = command_arguments or []
612+
options = command_options or []
613+
description = command_description
614+
help = command_help
615+
616+
def handle(self) -> int:
617+
if command_handler is not None and not isinstance(command_handler, str):
618+
return command_handler(self)
619+
620+
self._io.write_line(
621+
command_handler
622+
or f"The mock command '{command_name}' has been called"
623+
)
624+
625+
return 0
626+
627+
return MockCommand()
628+
629+
return _command_factory
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
import pytest
6+
7+
from cleo.testers.application_tester import ApplicationTester
8+
9+
from poetry.console.application import COMMAND_NOT_FOUND_PREFIX_MESSAGE
10+
from poetry.console.application import Application
11+
12+
13+
if TYPE_CHECKING:
14+
from tests.types import CommandFactory
15+
16+
17+
@pytest.fixture
18+
def tester() -> ApplicationTester:
19+
return ApplicationTester(Application())
20+
21+
22+
def test_application_removed_command_default_message(
23+
tester: ApplicationTester,
24+
) -> None:
25+
tester.execute("nonexistent")
26+
assert tester.status_code != 0
27+
28+
stderr = tester.io.fetch_error()
29+
assert COMMAND_NOT_FOUND_PREFIX_MESSAGE not in stderr
30+
assert 'The command "nonexistent" does not exist.' in stderr
31+
32+
33+
@pytest.mark.parametrize(
34+
("command", "message"),
35+
[
36+
("shell", "shell command is not installed by default"),
37+
],
38+
)
39+
def test_application_removed_command_messages(
40+
command: str,
41+
message: str,
42+
tester: ApplicationTester,
43+
command_factory: CommandFactory,
44+
) -> None:
45+
# ensure precondition is met
46+
assert not tester.application.has(command)
47+
48+
# verify that the custom message is returned and command fails
49+
tester.execute(command)
50+
assert tester.status_code != 0
51+
52+
stderr = tester.io.fetch_error()
53+
assert COMMAND_NOT_FOUND_PREFIX_MESSAGE in stderr
54+
assert message in stderr
55+
56+
# flush any output/error messages to ensure consistency
57+
tester.io.clear()
58+
59+
# add a mock command and verify the command succeeds and no error message is provided
60+
message = "The shell command was called"
61+
tester.application.add(command_factory(command, command_handler=message))
62+
assert tester.application.has(command)
63+
64+
tester.execute(command)
65+
assert tester.status_code == 0
66+
67+
stdout = tester.io.fetch_output()
68+
stderr = tester.io.fetch_error()
69+
assert message in stdout
70+
assert COMMAND_NOT_FOUND_PREFIX_MESSAGE not in stderr
71+
assert stderr == ""

tests/types.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@
1010
from contextlib import AbstractContextManager
1111
from pathlib import Path
1212

13+
from cleo.io.inputs.argument import Argument
14+
from cleo.io.inputs.option import Option
1315
from cleo.io.io import IO
1416
from cleo.testers.command_tester import CommandTester
1517
from httpretty.core import HTTPrettyRequest
1618
from packaging.utils import NormalizedName
1719

1820
from poetry.config.config import Config
1921
from poetry.config.source import Source
22+
from poetry.console.commands.command import Command
2023
from poetry.installation import Installer
2124
from poetry.installation.executor import Executor
2225
from poetry.poetry import Poetry
@@ -65,6 +68,18 @@ def __call__(
6568
) -> Poetry: ...
6669

6770

71+
class CommandFactory(Protocol):
72+
def __call__(
73+
self,
74+
command_name: str,
75+
command_arguments: list[Argument] | None = None,
76+
command_options: list[Option] | None = None,
77+
command_description: str = "",
78+
command_help: str = "",
79+
command_handler: Callable[[Command], int] | str | None = None,
80+
) -> Command: ...
81+
82+
6883
class FixtureDirGetter(Protocol):
6984
def __call__(self, name: str) -> Path: ...
7085

0 commit comments

Comments
 (0)