From 7dbd3a39fda20a839a721e5d03ee65a35932483c Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 8 Jan 2025 13:59:21 -0500 Subject: [PATCH 1/3] Fixed 'index out of range' error when passing no arguments to an argparse-based command function. --- CHANGELOG.md | 4 ++++ cmd2/decorators.py | 4 ++-- tests/test_argparse.py | 7 +++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91eae494..6a6edb9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.5.9 (TBD) +* Bug Fixes + * Fixed 'index out of range' error when passing no arguments to an argparse-based command function. + ## 2.5.8 (December 17, 2024) * Bug Fixes * Rolled back undocumented changes to printing functions introduced in 2.5.0. diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 098ea71c..25e7d18a 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -91,7 +91,7 @@ def _parse_positionals(args: Tuple[Any, ...]) -> Tuple['cmd2.Cmd', Union[Stateme Cmd, ) - if (isinstance(arg, Cmd) or isinstance(arg, CommandSet)) and len(args) > pos: + if isinstance(arg, (Cmd, CommandSet)) and len(args) > pos + 1: if isinstance(arg, CommandSet): arg = arg._cmd next_arg = args[pos + 1] @@ -100,7 +100,7 @@ def _parse_positionals(args: Tuple[Any, ...]) -> Tuple['cmd2.Cmd', Union[Stateme # This shouldn't happen unless we forget to pass statement in `Cmd.onecmd` or # somehow call the unbound class method. - raise TypeError('Expected arguments: cmd: cmd2.Cmd, statement: Union[Statement, str] Not found') # pragma: no cover + raise TypeError('Expected arguments: cmd: cmd2.Cmd, statement: Union[Statement, str] Not found') def _arg_swap(args: Union[Sequence[Any]], search_arg: Any, *replace_arg: Any) -> List[Any]: diff --git a/tests/test_argparse.py b/tests/test_argparse.py index c2731d37..f800c84a 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -148,6 +148,13 @@ def test_argparse_remove_quotes(argparse_app): assert out == ['hello there'] +def test_argparse_with_no_args(argparse_app): + """Make sure we receive TypeError when calling argparse-based function with no args""" + with pytest.raises(TypeError) as excinfo: + argparse_app.do_say() + assert 'Expected arguments' in str(excinfo.value) + + def test_argparser_kwargs(argparse_app, capsys): """Test with_argparser wrapper passes through kwargs to command function""" argparse_app.do_say('word', keyword_arg="foo") From 7a3da261d8f331844c543fc5c538c008d93680cd Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 8 Jan 2025 14:22:58 -0500 Subject: [PATCH 2/3] Added Callable types for argparse-based commands which use with_unknown_args. --- cmd2/decorators.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 25e7d18a..cb254952 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -118,17 +118,17 @@ def _arg_swap(args: Union[Sequence[Any]], search_arg: Any, *replace_arg: Any) -> return args_list -#: Function signature for an Command Function that accepts a pre-processed argument list from user input +#: Function signature for a command function that accepts a pre-processed argument list from user input #: and optionally returns a boolean ArgListCommandFuncOptionalBoolReturn = Callable[[CommandParent, List[str]], Optional[bool]] -#: Function signature for an Command Function that accepts a pre-processed argument list from user input +#: Function signature for a command function that accepts a pre-processed argument list from user input #: and returns a boolean ArgListCommandFuncBoolReturn = Callable[[CommandParent, List[str]], bool] -#: Function signature for an Command Function that accepts a pre-processed argument list from user input +#: Function signature for a command function that accepts a pre-processed argument list from user input #: and returns Nothing ArgListCommandFuncNoneReturn = Callable[[CommandParent, List[str]], None] -#: Aggregate of all accepted function signatures for Command Functions that accept a pre-processed argument list +#: Aggregate of all accepted function signatures for command functions that accept a pre-processed argument list ArgListCommandFunc = Union[ ArgListCommandFuncOptionalBoolReturn[CommandParent], ArgListCommandFuncBoolReturn[CommandParent], @@ -249,21 +249,29 @@ def _set_parser_prog(parser: argparse.ArgumentParser, prog: str) -> None: req_args.append(action.dest) -#: Function signature for a Command Function that uses an argparse.ArgumentParser to process user input -#: and optionally returns a boolean +#: Function signatures for command functions that use an argparse.ArgumentParser to process user input +#: and optionally return a boolean ArgparseCommandFuncOptionalBoolReturn = Callable[[CommandParent, argparse.Namespace], Optional[bool]] -#: Function signature for a Command Function that uses an argparse.ArgumentParser to process user input -#: and returns a boolean +ArgparseCommandFuncWithUnknownArgsOptionalBoolReturn = Callable[[CommandParent, argparse.Namespace, List[str]], Optional[bool]] + +#: Function signatures for command functions that use an argparse.ArgumentParser to process user input +#: and return a boolean ArgparseCommandFuncBoolReturn = Callable[[CommandParent, argparse.Namespace], bool] -#: Function signature for an Command Function that uses an argparse.ArgumentParser to process user input -#: and returns nothing +ArgparseCommandFuncWithUnknownArgsBoolReturn = Callable[[CommandParent, argparse.Namespace, List[str]], bool] + +#: Function signatures for command functions that use an argparse.ArgumentParser to process user input +#: and return nothing ArgparseCommandFuncNoneReturn = Callable[[CommandParent, argparse.Namespace], None] +ArgparseCommandFuncWithUnknownArgsNoneReturn = Callable[[CommandParent, argparse.Namespace, List[str]], None] -#: Aggregate of all accepted function signatures for an argparse Command Function +#: Aggregate of all accepted function signatures for an argparse command function ArgparseCommandFunc = Union[ ArgparseCommandFuncOptionalBoolReturn[CommandParent], + ArgparseCommandFuncWithUnknownArgsOptionalBoolReturn[CommandParent], ArgparseCommandFuncBoolReturn[CommandParent], + ArgparseCommandFuncWithUnknownArgsBoolReturn[CommandParent], ArgparseCommandFuncNoneReturn[CommandParent], + ArgparseCommandFuncWithUnknownArgsNoneReturn[CommandParent], ] From 6330daf428bf67bded807797373b5795d45f7440 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 8 Jan 2025 14:30:19 -0500 Subject: [PATCH 3/3] Fixed broken example. --- examples/modular_commands/commandset_basic.py | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/examples/modular_commands/commandset_basic.py b/examples/modular_commands/commandset_basic.py index a4b7582f..8587b98d 100644 --- a/examples/modular_commands/commandset_basic.py +++ b/examples/modular_commands/commandset_basic.py @@ -8,7 +8,6 @@ ) from cmd2 import ( - Cmd, CommandSet, CompletionError, Statement, @@ -32,7 +31,7 @@ class BasicCompletionCommandSet(CommandSet): '/home/other user/tests.db', ] - def do_flag_based(self, cmd: Cmd, statement: Statement): + def do_flag_based(self, statement: Statement) -> None: """Tab completes arguments based on a preceding flag using flag_based_complete -f, --food [completes food items] -s, --sport [completes sports] @@ -40,7 +39,7 @@ def do_flag_based(self, cmd: Cmd, statement: Statement): """ self._cmd.poutput("Args: {}".format(statement.args)) - def complete_flag_based(self, cmd: Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]: + def complete_flag_based(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: """Completion function for do_flag_based""" flag_dict = { # Tab complete food items after -f and --food flags in command line @@ -50,38 +49,38 @@ def complete_flag_based(self, cmd: Cmd, text: str, line: str, begidx: int, endid '-s': self.sport_item_strs, '--sport': self.sport_item_strs, # Tab complete using path_complete function after -p and --path flags in command line - '-p': cmd.path_complete, - '--path': cmd.path_complete, + '-p': self._cmd.path_complete, + '--path': self._cmd.path_complete, } - return cmd.flag_based_complete(text, line, begidx, endidx, flag_dict=flag_dict) + return self._cmd.flag_based_complete(text, line, begidx, endidx, flag_dict=flag_dict) - def do_index_based(self, cmd: Cmd, statement: Statement): + def do_index_based(self, statement: Statement) -> None: """Tab completes first 3 arguments using index_based_complete""" self._cmd.poutput("Args: {}".format(statement.args)) - def complete_index_based(self, cmd: Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]: + def complete_index_based(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: """Completion function for do_index_based""" index_dict = { 1: self.food_item_strs, # Tab complete food items at index 1 in command line 2: self.sport_item_strs, # Tab complete sport items at index 2 in command line - 3: cmd.path_complete, # Tab complete using path_complete function at index 3 in command line + 3: self._cmd.path_complete, # Tab complete using path_complete function at index 3 in command line } - return cmd.index_based_complete(text, line, begidx, endidx, index_dict=index_dict) + return self._cmd.index_based_complete(text, line, begidx, endidx, index_dict=index_dict) - def do_delimiter_complete(self, cmd: Cmd, statement: Statement): + def do_delimiter_complete(self, statement: Statement) -> None: """Tab completes files from a list using delimiter_complete""" self._cmd.poutput("Args: {}".format(statement.args)) - def complete_delimiter_complete(self, cmd: Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]: - return cmd.delimiter_complete(text, line, begidx, endidx, match_against=self.file_strs, delimiter='/') + def complete_delimiter_complete(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + return self._cmd.delimiter_complete(text, line, begidx, endidx, match_against=self.file_strs, delimiter='/') - def do_raise_error(self, cmd: Cmd, statement: Statement): + def do_raise_error(self, statement: Statement) -> None: """Demonstrates effect of raising CompletionError""" self._cmd.poutput("Args: {}".format(statement.args)) - def complete_raise_error(self, cmd: Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]: + def complete_raise_error(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: """ CompletionErrors can be raised if an error occurs while tab completing. @@ -92,5 +91,5 @@ def complete_raise_error(self, cmd: Cmd, text: str, line: str, begidx: int, endi raise CompletionError("This is how a CompletionError behaves") @with_category('Not Basic Completion') - def do_custom_category(self, cmd: Cmd, statement: Statement): + def do_custom_category(self, statement: Statement) -> None: self._cmd.poutput('Demonstrates a command that bypasses the default category')