diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 41f063549..95a3644b2 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -291,6 +291,56 @@ def generate_range_error(range_min: int, range_max: float) -> str: return err_str +def set_parser_prog(parser: argparse.ArgumentParser, prog: str) -> None: + """Recursively set prog attribute of a parser and all of its subparsers. + + Does so that the root command is a command name and not sys.argv[0]. + + :param parser: the parser being edited + :param prog: new value for the parser's prog attribute + """ + # Set the prog value for this parser + parser.prog = prog + req_args: list[str] = [] + + # Set the prog value for the parser's subcommands + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + # Set the _SubParsersAction's _prog_prefix value. That way if its add_parser() method is called later, + # the correct prog value will be set on the parser being added. + action._prog_prefix = parser.prog + + # The keys of action.choices are subcommand names as well as subcommand aliases. The aliases point to the + # same parser as the actual subcommand. We want to avoid placing an alias into a parser's prog value. + # Unfortunately there is nothing about an action.choices entry which tells us it's an alias. In most cases + # we can filter out the aliases by checking the contents of action._choices_actions. This list only contains + # help information and names for the subcommands and not aliases. However, subcommands without help text + # won't show up in that list. Since dictionaries are ordered in Python 3.6 and above and argparse inserts the + # subcommand name into choices dictionary before aliases, we should be OK assuming the first time we see a + # parser, the dictionary key is a subcommand and not alias. + processed_parsers = [] + + # Set the prog value for each subcommand's parser + for subcmd_name, subcmd_parser in action.choices.items(): + # Check if we've already edited this parser + if subcmd_parser in processed_parsers: + continue + + subcmd_prog = parser.prog + if req_args: + subcmd_prog += " " + " ".join(req_args) + subcmd_prog += " " + subcmd_name + set_parser_prog(subcmd_parser, subcmd_prog) + processed_parsers.append(subcmd_parser) + + # We can break since argparse only allows 1 group of subcommands per level + break + + # Need to save required args so they can be prepended to the subcommand usage + if action.required: + req_args.append(action.dest) + + class CompletionItem(str): # noqa: SLOT000 """Completion item with descriptive text attached. diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 31aa9b1c0..387b0df35 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -784,9 +784,7 @@ def _build_parser( else: raise TypeError(f"Invalid type for parser_builder: {type(parser_builder)}") - from .decorators import _set_parser_prog - - _set_parser_prog(parser, prog) + argparse_custom.set_parser_prog(parser, prog) return parser diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 61742ad34..246055fa6 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -192,56 +192,6 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> Optional[bool]: return arg_decorator -def _set_parser_prog(parser: argparse.ArgumentParser, prog: str) -> None: - """Recursively set prog attribute of a parser and all of its subparsers. - - Does so that the root command is a command name and not sys.argv[0]. - - :param parser: the parser being edited - :param prog: new value for the parser's prog attribute - """ - # Set the prog value for this parser - parser.prog = prog - req_args: list[str] = [] - - # Set the prog value for the parser's subcommands - for action in parser._actions: - if isinstance(action, argparse._SubParsersAction): - # Set the _SubParsersAction's _prog_prefix value. That way if its add_parser() method is called later, - # the correct prog value will be set on the parser being added. - action._prog_prefix = parser.prog - - # The keys of action.choices are subcommand names as well as subcommand aliases. The aliases point to the - # same parser as the actual subcommand. We want to avoid placing an alias into a parser's prog value. - # Unfortunately there is nothing about an action.choices entry which tells us it's an alias. In most cases - # we can filter out the aliases by checking the contents of action._choices_actions. This list only contains - # help information and names for the subcommands and not aliases. However, subcommands without help text - # won't show up in that list. Since dictionaries are ordered in Python 3.6 and above and argparse inserts the - # subcommand name into choices dictionary before aliases, we should be OK assuming the first time we see a - # parser, the dictionary key is a subcommand and not alias. - processed_parsers = [] - - # Set the prog value for each subcommand's parser - for subcmd_name, subcmd_parser in action.choices.items(): - # Check if we've already edited this parser - if subcmd_parser in processed_parsers: - continue - - subcmd_prog = parser.prog - if req_args: - subcmd_prog += " " + " ".join(req_args) - subcmd_prog += " " + subcmd_name - _set_parser_prog(subcmd_parser, subcmd_prog) - processed_parsers.append(subcmd_parser) - - # We can break since argparse only allows 1 group of subcommands per level - break - - # Need to save required args so they can be prepended to the subcommand usage - if action.required: - req_args.append(action.dest) - - #: 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]] diff --git a/tests/test_argparse.py b/tests/test_argparse.py index 0ae9e7245..7eb320765 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -290,7 +290,7 @@ def base_helpless(self, args) -> None: parser_bar.set_defaults(func=base_bar) # create the parser for the "helpless" subcommand - # This subcommand has aliases and no help text. It exists to prevent changes to _set_parser_prog() which + # This subcommand has aliases and no help text. It exists to prevent changes to set_parser_prog() which # use an approach which relies on action._choices_actions list. See comment in that function for more # details. parser_helpless = base_subparsers.add_parser('helpless', aliases=['helpless_1', 'helpless_2']) @@ -401,7 +401,7 @@ def test_subcommand_invalid_help(subcommand_app) -> None: def test_add_another_subcommand(subcommand_app) -> None: - """This tests makes sure _set_parser_prog() sets _prog_prefix on every _SubParsersAction so that all future calls + """This tests makes sure set_parser_prog() sets _prog_prefix on every _SubParsersAction so that all future calls to add_parser() write the correct prog value to the parser being added. """ base_parser = subcommand_app._command_parsers.get(subcommand_app.do_base) diff --git a/tests_isolated/test_commandset/test_argparse_subcommands.py b/tests_isolated/test_commandset/test_argparse_subcommands.py index ee0b08e70..a95c57777 100644 --- a/tests_isolated/test_commandset/test_argparse_subcommands.py +++ b/tests_isolated/test_commandset/test_argparse_subcommands.py @@ -45,7 +45,7 @@ def base_helpless(self, args) -> None: parser_bar.set_defaults(func=base_bar) # create the parser for the "helpless" subcommand - # This subcommand has aliases and no help text. It exists to prevent changes to _set_parser_prog() which + # This subcommand has aliases and no help text. It exists to prevent changes to set_parser_prog() which # use an approach which relies on action._choices_actions list. See comment in that function for more # details. parser_helpless = base_subparsers.add_parser('helpless', aliases=['helpless_1', 'helpless_2'])