Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions cmd2/argparse_completer.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

from .argparse_custom import (
Cmd2ArgumentParser,
generate_range_error,
build_range_error,
)
from .command_definition import CommandSet
from .completion import (
Expand Down Expand Up @@ -137,7 +137,7 @@ def __init__(self, flag_arg_state: _ArgumentState) -> None:
:param flag_arg_state: information about the unfinished flag action.
"""
arg = f'{argparse._get_action_name(flag_arg_state.action)}'
err = f'{generate_range_error(flag_arg_state.min, flag_arg_state.max)}'
err = f'{build_range_error(flag_arg_state.min, flag_arg_state.max)}'
error = f"Error: argument {arg}: {err} ({flag_arg_state.count} entered)"
super().__init__(error)

Expand Down
356 changes: 197 additions & 159 deletions cmd2/argparse_custom.py

Large diffs are not rendered by default.

190 changes: 105 additions & 85 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@
Callable,
Iterable,
Mapping,
MutableSequence,
Sequence,
)
from dataclasses import (
Expand Down Expand Up @@ -274,7 +273,11 @@ def get(self, command_method: CommandFunc) -> Cmd2ArgumentParser | None:
return None

parent = self._cmd.find_commandset_for_command(command) or self._cmd
parser = self._cmd._build_parser(parent, parser_builder, command)
parser = self._cmd._build_parser(parent, parser_builder)

# To ensure accurate usage strings, recursively update 'prog' values
# within the parser to match the command name.
parser.update_prog(command)

# If the description has not been set, then use the method docstring if one exists
if parser.description is None and command_method.__doc__:
Expand Down Expand Up @@ -889,7 +892,6 @@ def _build_parser(
self,
parent: CmdOrSet,
parser_builder: Cmd2ArgumentParser | Callable[[], Cmd2ArgumentParser] | StaticArgParseBuilder | ClassArgParseBuilder,
prog: str,
) -> Cmd2ArgumentParser:
"""Build argument parser for a command/subcommand.

Expand All @@ -898,7 +900,6 @@ def _build_parser(
parent's class to it.
:param parser_builder: an existing Cmd2ArgumentParser instance or a factory
(callable, staticmethod, or classmethod) that returns one.
:param prog: prog value to set in new parser
:return: new parser
:raises TypeError: if parser_builder is an invalid type or if the factory fails
to return a Cmd2ArgumentParser
Expand All @@ -921,8 +922,6 @@ def _build_parser(
builder_name = getattr(parser_builder, "__name__", str(parser_builder)) # type: ignore[unreachable]
raise TypeError(f"The parser returned by '{builder_name}' must be a Cmd2ArgumentParser or a subclass of it")

argparse_custom.set_parser_prog(parser, prog)

return parser

def _install_command_function(self, command_func_name: str, command_method: CommandFunc, context: str = '') -> None:
Expand Down Expand Up @@ -1026,18 +1025,29 @@ def unregister_command_set(self, cmdset: CommandSet) -> None:
self._installed_command_sets.remove(cmdset)

def _check_uninstallable(self, cmdset: CommandSet) -> None:
cmdset_id = id(cmdset)

def check_parser_uninstallable(parser: Cmd2ArgumentParser) -> None:
cmdset_id = id(cmdset)
for action in parser._actions:
if isinstance(action, argparse._SubParsersAction):
for subparser in action.choices.values():
attached_cmdset_id = getattr(subparser, constants.PARSER_ATTR_COMMANDSET_ID, None)
if attached_cmdset_id is not None and attached_cmdset_id != cmdset_id:
raise CommandSetRegistrationError(
'Cannot uninstall CommandSet when another CommandSet depends on it'
)
check_parser_uninstallable(subparser)
break
try:
subparsers_action = parser._get_subparsers_action()
except ValueError:
# No subcommands to check
return

# Prevent redundant traversal of parser aliases
checked_parsers: set[Cmd2ArgumentParser] = set()

for subparser in subparsers_action.choices.values():
if subparser in checked_parsers:
continue
checked_parsers.add(subparser)

attached_cmdset_id = getattr(subparser, constants.PARSER_ATTR_COMMANDSET_ID, None)
if attached_cmdset_id is not None and attached_cmdset_id != cmdset_id:
raise CommandSetRegistrationError(
f"Cannot uninstall CommandSet: '{subparser.prog}' is required by another CommandSet"
)
check_parser_uninstallable(subparser)

methods: list[tuple[str, Callable[..., Any]]] = inspect.getmembers(
cmdset,
Expand Down Expand Up @@ -1085,40 +1095,8 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:
if not subcommand_valid:
raise CommandSetRegistrationError(f'Subcommand {subcommand_name} is not valid: {errmsg}')

command_tokens = full_command_name.split()
command_name = command_tokens[0]
subcommand_names = command_tokens[1:]

# Search for the base command function and verify it has an argparser defined
if command_name in self.disabled_commands:
command_func = self.disabled_commands[command_name].command_function
else:
command_func = self.cmd_func(command_name)

if command_func is None:
raise CommandSetRegistrationError(f"Could not find command '{command_name}' needed by subcommand: {method}")
command_parser = self._command_parsers.get(command_func)
if command_parser is None:
raise CommandSetRegistrationError(
f"Could not find argparser for command '{command_name}' needed by subcommand: {method}"
)

def find_subcommand(action: Cmd2ArgumentParser, subcmd_names: MutableSequence[str]) -> Cmd2ArgumentParser:
if not subcmd_names:
return action
cur_subcmd = subcmd_names.pop(0)
for sub_action in action._actions:
if isinstance(sub_action, argparse._SubParsersAction):
for choice_name, choice in sub_action.choices.items():
if choice_name == cur_subcmd:
return find_subcommand(choice, subcmd_names)
break
raise CommandSetRegistrationError(f"Could not find subcommand '{action}'")

target_parser = find_subcommand(command_parser, subcommand_names)

# Create the subcommand parser and configure it
subcmd_parser = self._build_parser(cmdset, subcmd_parser_builder, f'{command_name} {subcommand_name}')
subcmd_parser = self._build_parser(cmdset, subcmd_parser_builder)
if subcmd_parser.description is None and method.__doc__:
subcmd_parser.description = strip_doc_annotations(method.__doc__)

Expand All @@ -1129,19 +1107,14 @@ def find_subcommand(action: Cmd2ArgumentParser, subcmd_names: MutableSequence[st
# Set what instance the handler is bound to
setattr(subcmd_parser, constants.PARSER_ATTR_COMMANDSET_ID, id(cmdset))

# Find the argparse action that handles subcommands
for action in target_parser._actions:
if isinstance(action, argparse._SubParsersAction):
# Get add_parser() kwargs (aliases, help, etc.) defined by the decorator
add_parser_kwargs = getattr(method, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS, {})

# Attach existing parser as a subcommand
action.attach_parser( # type: ignore[attr-defined]
subcommand_name,
subcmd_parser,
**add_parser_kwargs,
)
break
# Get add_parser() kwargs (aliases, help, etc.) defined by the decorator
add_parser_kwargs = getattr(method, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS, {})

# Attach existing parser as a subcommand
try:
self.attach_subcommand(full_command_name, subcommand_name, subcmd_parser, **add_parser_kwargs)
except ValueError as ex:
raise CommandSetRegistrationError(str(ex)) from ex

def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:
"""Unregister subcommands from their base command.
Expand All @@ -1165,30 +1138,77 @@ def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:
# iterate through all matching methods
for _method_name, method in methods:
subcommand_name = getattr(method, constants.SUBCMD_ATTR_NAME)
command_name = getattr(method, constants.SUBCMD_ATTR_COMMAND)
full_command_name = getattr(method, constants.SUBCMD_ATTR_COMMAND)

# Search for the base command function and verify it has an argparser defined
if command_name in self.disabled_commands:
command_func = self.disabled_commands[command_name].command_function
else:
command_func = self.cmd_func(command_name)

if command_func is None: # pragma: no cover
# This really shouldn't be possible since _register_subcommands would prevent this from happening
# but keeping in case it does for some strange reason
raise CommandSetRegistrationError(f"Could not find command '{command_name}' needed by subcommand: {method}")
command_parser = self._command_parsers.get(command_func)
if command_parser is None: # pragma: no cover
# This really shouldn't be possible since _register_subcommands would prevent this from happening
# but keeping in case it does for some strange reason
raise CommandSetRegistrationError(
f"Could not find argparser for command '{command_name}' needed by subcommand: {method}"
)
with contextlib.suppress(ValueError):
self.detach_subcommand(full_command_name, subcommand_name)

for action in command_parser._actions:
if isinstance(action, argparse._SubParsersAction):
action.detach_parser(subcommand_name) # type: ignore[attr-defined]
break
def _get_root_parser_and_subcmd_path(self, command: str) -> tuple[Cmd2ArgumentParser, list[str]]:
"""Tokenize a command string and resolve the associated root parser and relative subcommand path.

This helper handles the initial resolution of a command string (e.g., 'foo bar baz') by
identifying 'foo' as the root command (even if disabled), retrieving its associated
parser, and returning any remaining tokens (['bar', 'baz']) as a path relative
to that parser for further traversal.

:param command: full space-delimited command path leading to a parser (e.g. 'foo' or 'foo bar')
:return: a tuple containing the Cmd2ArgumentParser for the root command and a list of
strings representing the relative path to the desired hosting parser.
:raises ValueError: if the command is empty, the root command is not found, or
the root command does not use an argparse parser.
"""
tokens = command.split()
if not tokens:
raise ValueError("Command path cannot be empty")

root_command = tokens[0]
subcommand_path = tokens[1:]

# Search for the base command function and verify it has an argparser defined
if root_command in self.disabled_commands:
command_func = self.disabled_commands[root_command].command_function
else:
command_func = self.cmd_func(root_command)

if command_func is None:
raise ValueError(f"Root command '{root_command}' not found")

root_parser = self._command_parsers.get(command_func)
if root_parser is None:
raise ValueError(f"Command '{root_command}' does not use argparse")

return root_parser, subcommand_path

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

:param command: full command path (space-delimited) leading to the parser that will
host the new subcommand (e.g. 'foo bar')
: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
"""
root_parser, subcommand_path = self._get_root_parser_and_subcmd_path(command)
root_parser.attach_subcommand(subcommand_path, subcommand, parser, **add_parser_kwargs)

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

:param command: full command path (space-delimited) leading to the parser hosting the
subcommand to be detached (e.g. 'foo bar')
: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
"""
root_parser, subcommand_path = self._get_root_parser_and_subcmd_path(command)
return root_parser.detach_subcommand(subcommand_path, subcommand)

@property
def always_prefix_settables(self) -> bool:
Expand Down
53 changes: 33 additions & 20 deletions examples/scripts/save_help_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,36 @@
This is meant to be run within a cmd2 session using run_pyscript.
"""

import argparse
import os
import sys
from typing import TextIO

from cmd2 import Cmd2ArgumentParser

ASTERISKS = "********************************************************"


def get_sub_commands(parser: argparse.ArgumentParser) -> list[str]:
"""Get a list of subcommands for an ArgumentParser."""
def get_sub_commands(parser: Cmd2ArgumentParser) -> list[str]:
"""Get a list of subcommands for a Cmd2ArgumentParser."""
try:
subparsers_action = parser._get_subparsers_action()
except ValueError:
# No subcommands
return []

# Prevent redundant traversal of parser aliases
checked_parsers: set[Cmd2ArgumentParser] = set()

sub_cmds = []
for subcmd, subcmd_parser in subparsers_action.choices.items():
if subcmd_parser in checked_parsers:
continue
checked_parsers.add(subcmd_parser)

# Check if this is parser has subcommands
if parser is not None and parser._subparsers is not None:
# Find the _SubParsersAction for the subcommands of this parser
for action in parser._subparsers._actions:
if isinstance(action, argparse._SubParsersAction):
for sub_cmd, sub_cmd_parser in action.choices.items():
sub_cmds.append(sub_cmd)
sub_cmds.append(subcmd)

# Look for nested subcommands
sub_cmds.extend(f'{sub_cmd} {nested_sub_cmd}' for nested_sub_cmd in get_sub_commands(sub_cmd_parser))
break
# Look for nested subcommands
sub_cmds.extend(f'{subcmd} {nested_subcmd}' for nested_subcmd in get_sub_commands(subcmd_parser))

sub_cmds.sort()
return sub_cmds
Expand Down Expand Up @@ -60,8 +67,7 @@ def main() -> None:
# Open the output file
outfile_path = os.path.expanduser(sys.argv[1])
try:
with open(outfile_path, 'w') as outfile:
pass
outfile = open(outfile_path, 'w') # noqa: SIM115
except OSError as e:
print(f"Error opening {outfile_path} because: {e}")
return
Expand All @@ -83,11 +89,18 @@ def main() -> None:
is_command = item in all_commands
add_help_to_file(item, outfile, is_command)

if is_command:
# Add any subcommands
for subcmd in get_sub_commands(getattr(self.cmd_func(item), 'argparser', None)):
full_cmd = f'{item} {subcmd}'
add_help_to_file(full_cmd, outfile, is_command)
if not is_command:
continue

cmd_func = self.cmd_func(item)
parser = self._command_parsers.get(cmd_func)
if parser is None:
continue

# Add any subcommands
for subcmd in get_sub_commands(parser):
full_cmd = f'{item} {subcmd}'
add_help_to_file(full_cmd, outfile, is_command)

outfile.close()
print(f"Output written to {outfile_path}")
Expand Down
Loading
Loading