Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ prompt is displayed.
class of it.
- Removed `set_ap_completer_type()` and `get_ap_completer_type()` since `ap_completer_type` is
now a public member of `Cmd2ArgumentParser`.
- Moved `set_parser_prog()` to `Cmd2ArgumentParser.update_prog()`.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we update anything in the documentation in relation to this PR? Perhaps in the migration guide for 4.x?

- Enhancements
- New `cmd2.Cmd` parameters
- **auto_suggest**: (boolean) if `True`, provide fish shell style auto-suggestions. These
Expand Down
170 changes: 116 additions & 54 deletions cmd2/argparse_custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ def get_choices(self) -> Choices:
from argparse import ArgumentError
from collections.abc import (
Callable,
Iterable,
Iterator,
Sequence,
)
Expand Down Expand Up @@ -302,56 +303,6 @@ def generate_range_error(range_min: int, range_max: float) -> str:
return err_msg


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)


############################################################################################################
# Allow developers to add custom action attributes
############################################################################################################
Expand Down Expand Up @@ -582,7 +533,7 @@ def _SubParsersAction_attach_parser( # noqa: N802
subcmd_parser: argparse.ArgumentParser,
**add_parser_kwargs: Any,
) -> None:
"""Attach an existing ArgumentParser to a subparsers action.
"""Attach an existing parser to a subparsers action.

This is useful when a parser is pre-configured (e.g. by cmd2's subcommand decorator)
and needs to be attached to a parent parser.
Expand Down Expand Up @@ -877,7 +828,7 @@ def __init__(
*,
ap_completer_type: type['ArgparseCompleter'] | None = None,
) -> None:
"""Initialize the Cmd2ArgumentParser instance, a custom ArgumentParser added by cmd2.
"""Initialize the Cmd2ArgumentParser instance.

:param ap_completer_type: optional parameter which specifies a subclass of ArgparseCompleter for custom completion
behavior on this parser. If this is None or not present, then cmd2 will use
Expand Down Expand Up @@ -927,6 +878,118 @@ def add_subparsers(self, **kwargs: Any) -> argparse._SubParsersAction: # type:

return super().add_subparsers(**kwargs)

def _get_subparsers_action(self) -> argparse._SubParsersAction: # type: ignore[type-arg]
"""Get the _SubParsersAction for this parser if it exists.

:return: the _SubParsersAction for this parser
:raises ValueError: if this parser does not support subcommands
"""
if self._subparsers is not None:
for action in self._subparsers._group_actions:
if isinstance(action, argparse._SubParsersAction):
return action
raise ValueError(f"Command '{self.prog}' does not support subcommands")

def update_prog(self, prog: str) -> None:
"""Recursively update the prog attribute of this parser and all of its subparsers.

:param prog: new value for this parser's prog attribute
"""
# Set the prog value for this parser
self.prog = prog

if self._subparsers is None:
# This parser has no subcommands
return

# argparse includes positional arguments that appear before the subcommand in its
# subparser prog strings. Track these while iterating through actions.
positionals: list[argparse.Action] = []

# Set the prog value for the parser's subcommands
for action in self._actions:
if isinstance(action, argparse._SubParsersAction):
# Use a formatter to generate _prog_prefix exactly as argparse does in
# add_subparsers(). This ensures that any subcommands added later via
# add_parser() will have the correct prog value.
formatter = self._get_formatter()
formatter.add_usage(self.usage, positionals, self._mutually_exclusive_groups, '')
action._prog_prefix = formatter.format_help().strip()

# Note: action.choices contains both subcommand names and aliases.
# To ensure subcommands (and not aliases) are used in 'prog':
# 1. We can't use action._choices_actions because it excludes subcommands without help text.
# 2. Since dictionaries are ordered and argparse inserts the primary name before aliases,
# we assume the first time we encounter a parser, the key is the true subcommand name.
updated_parsers: set[Cmd2ArgumentParser] = set()

# Set the prog value for each subcommand's parser
for subcmd_name, subcmd_parser in action.choices.items():
if subcmd_parser in updated_parsers:
continue

subcmd_prog = f"{action._prog_prefix} {subcmd_name}"
subcmd_parser.update_prog(subcmd_prog) # type: ignore[attr-defined]
updated_parsers.add(subcmd_parser)

# We can break since argparse only allows 1 group of subcommands per level
break

# Save positional argument
if not action.option_strings:
positionals.append(action)

def _find_parser(self, subcommand_path: Iterable[str]) -> 'Cmd2ArgumentParser':
"""Find a parser in the hierarchy based on a sequence of subcommand names.

:param subcommand_path: sequence of subcommand names leading to the target parser
:return: the discovered Cmd2ArgumentParser
:raises ValueError: if any subcommand in the path is not found or a level doesn't support subcommands
"""
parser = self
for name in subcommand_path:
subparsers_action = parser._get_subparsers_action()
if name not in subparsers_action.choices:
raise ValueError(f"Subcommand '{name}' not found in '{parser.prog}'")
parser = cast(Cmd2ArgumentParser, subparsers_action.choices[name])
return parser

def attach_subcommand(
self,
subcommand_path: Iterable[str],
subcommand: str,
parser: 'Cmd2ArgumentParser',
**add_parser_kwargs: Any,
) -> None:
"""Attach a parser as a subcommand to a command at the specified path.

:param subcommand_path: sequence of subcommand names leading to the parser that will
host the new subcommand. An empty sequence indicates this parser.
:param subcommand: name of the new subcommand
:param parser: the parser to attach
:param add_parser_kwargs: additional arguments for the subparser registration (e.g. help, aliases)
:raises ValueError: if the command path is invalid or doesn't support subcommands
"""
target_parser = self._find_parser(subcommand_path)
subparsers_action = target_parser._get_subparsers_action()
subparsers_action.attach_parser(subcommand, parser, **add_parser_kwargs) # type: ignore[attr-defined]

def detach_subcommand(self, subcommand_path: Iterable[str], subcommand: str) -> 'Cmd2ArgumentParser':
"""Detach a subcommand from a command at the specified path.

:param subcommand_path: sequence of subcommand names leading to the parser hosting the
subcommand to be detached. An empty sequence indicates this parser.
:param subcommand: name of the subcommand to detach
:return: the detached parser
:raises ValueError: if the command path is invalid or the subcommand doesn't exist
"""
target_parser = self._find_parser(subcommand_path)
subparsers_action = target_parser._get_subparsers_action()
detached = subparsers_action.detach_parser(subcommand) # type: ignore[attr-defined]
if detached is None:
raise ValueError(f"Subcommand '{subcommand}' not found in '{target_parser.prog}'")
return cast(Cmd2ArgumentParser, detached)

def error(self, message: str) -> NoReturn:
"""Override that applies custom formatting to the error message."""
lines = message.split('\n')
Expand Down Expand Up @@ -992,7 +1055,6 @@ def _check_value(self, action: argparse.Action, value: Any) -> None:

When displaying choices, use CompletionItem.value instead of the CompletionItem instance.

:param self: ArgumentParser instance
:param action: the action being populated
:param value: value from command line already run through conversion function by argparse
"""
Expand Down Expand Up @@ -1035,7 +1097,7 @@ def set(self, new_val: Any) -> None:


def set_default_argument_parser_type(parser_type: type[Cmd2ArgumentParser]) -> None:
"""Set the default ArgumentParser class for cmd2's built-in commands.
"""Set the default Cmd2ArgumentParser class for cmd2's built-in commands.

Since built-in commands rely on customizations made in Cmd2ArgumentParser,
your custom parser class should inherit from Cmd2ArgumentParser.
Expand Down
Loading
Loading