From 7a12d84d21ad964836c0c50b11fdbf3b13443336 Mon Sep 17 00:00:00 2001 From: nityanand123gupta <156422250+nityanand123gupta@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:46:24 +0530 Subject: [PATCH] fix: bind the called command's own arguments in call()/call_silent() Command.call() and call_silent() built a StringInput from just the raw args string, then bound it against self.definition -- which, after merge_application_definition(), starts with the Application's own 'command' positional argument. The called command's first real argument was therefore silently consumed by that phantom 'command' slot instead of reaching its own argument definition, so e.g. self.call('foo', 'baz') failed with 'Not enough arguments (missing: baz)', and call_silent's NullIO path raised an AttributeError instead. Prepend the command name as the first token, exactly as a user would type it on the actual command line, so it fills the 'command' slot and the remaining tokens bind to the command's own arguments as intended. Fixes #130 --- news/130.bugfix.md | 1 + src/cleo/commands/command.py | 7 +++-- tests/commands/test_command.py | 52 ++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 news/130.bugfix.md diff --git a/news/130.bugfix.md b/news/130.bugfix.md new file mode 100644 index 00000000..2cb53a35 --- /dev/null +++ b/news/130.bugfix.md @@ -0,0 +1 @@ +Fixed `Command.call()` and `Command.call_silent()` misbinding arguments to the called command. diff --git a/src/cleo/commands/command.py b/src/cleo/commands/command.py index c617bc44..827d46e8 100644 --- a/src/cleo/commands/command.py +++ b/src/cleo/commands/command.py @@ -93,7 +93,8 @@ def call(self, name: str, args: str | None = None) -> int: command = self.application.get(name) return self.application._run_command( - command, self._io.with_input(StringInput(args or "")) + command, + self._io.with_input(StringInput(f"{name} {args}" if args else name)), ) def call_silent(self, name: str, args: str | None = None) -> int: @@ -103,7 +104,9 @@ def call_silent(self, name: str, args: str | None = None) -> int: assert self.application is not None command = self.application.get(name) - return self.application._run_command(command, NullIO(StringInput(args or ""))) + return self.application._run_command( + command, NullIO(StringInput(f"{name} {args}" if args else name)) + ) def argument(self, name: str) -> Any: """ diff --git a/tests/commands/test_command.py b/tests/commands/test_command.py index 4f4fd807..bec85a1f 100644 --- a/tests/commands/test_command.py +++ b/tests/commands/test_command.py @@ -6,6 +6,7 @@ from cleo.application import Application from cleo.commands.command import Command from cleo.helpers import argument +from cleo.testers.application_tester import ApplicationTester from cleo.testers.command_tester import CommandTester from tests.fixtures.inherited_command import ChildCommand from tests.fixtures.signature_command import SignatureCommand @@ -87,3 +88,54 @@ def test_explicit_multiple_argument() -> None: tester.execute("1 2 3") assert tester.io.fetch_output() == "1,2,3\n" + + +class CalleeCommand(Command): + name = "callee" + arguments: ClassVar[list[Argument]] = [argument("value", "The value to print.")] + + def handle(self) -> int: + self.line(self.argument("value")) + return 0 + + +class CallerCommand(Command): + name = "caller" + + def handle(self) -> int: + return self.call("callee", "hello") + + +class SilentCallerCommand(Command): + name = "silent-caller" + + def handle(self) -> int: + return self.call_silent("callee", "hello") + + +def _app_with_callee_and(caller: Command) -> Application: + application = Application() + application.add(CalleeCommand()) + application.add(caller) + + return application + + +def test_call_binds_arguments_to_the_called_command() -> None: + # Regression test for https://github.com/python-poetry/cleo/issues/130: the + # application's own top-level "command" argument used to be first in line for + # binding, so the called command's first real argument was silently swallowed + # by that phantom slot instead of reaching the called command's own argument. + tester = ApplicationTester(_app_with_callee_and(CallerCommand())) + + assert tester.execute("caller") == 0 + assert tester.io.fetch_output() == "hello\n" + + +def test_call_silent_binds_arguments_to_the_called_command() -> None: + tester = ApplicationTester(_app_with_callee_and(SilentCallerCommand())) + + assert tester.execute("silent-caller") == 0 + # call_silent runs the called command against a NullIO, so its own output + # isn't captured here -- the point of this test is that the call above didn't + # raise "Not enough arguments" and returned a successful status code.