From 41be6989f3127163b13a02ea9f70fc6f0bc11dc8 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 30 Mar 2026 16:53:42 -0400 Subject: [PATCH 01/13] Require devs to use Cmd2ArgumentParser-based parsers. --- CHANGELOG.md | 2 ++ cmd2/argparse_completer.py | 17 ++++++++-------- cmd2/argparse_custom.py | 13 +++---------- cmd2/cmd2.py | 29 ++++++++++++---------------- cmd2/decorators.py | 29 +++++++++++++++------------- cmd2/utils.py | 4 ++-- docs/features/argument_processing.md | 18 ++++++++--------- docs/features/completion.md | 2 +- docs/migrating/next_steps.md | 4 ++-- tests/conftest.py | 14 -------------- 10 files changed, 56 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50ab4d2cf..12c9dbae7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,8 @@ prompt is displayed. before calling it like the previous functions did. - Removed `Cmd.default_to_shell`. - Removed `Cmd.ruler` since `cmd2` no longer uses it. + - All parsers used with `cmd2` commands much be an instance of `Cmd2ArgumentParser` or a child + class of it. - Enhancements - New `cmd2.Cmd` parameters - **auto_suggest**: (boolean) if `True`, provide fish shell style auto-suggestions. These diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 0b2c3b3f9..a4db80f72 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -34,6 +34,7 @@ from .argparse_custom import ( ChoicesCallable, + Cmd2ArgumentParser, generate_range_error, ) from .command_definition import CommandSet @@ -49,7 +50,7 @@ ARG_TOKENS = 'arg_tokens' -def _build_hint(parser: argparse.ArgumentParser, arg_action: argparse.Action) -> str: +def _build_hint(parser: Cmd2ArgumentParser, arg_action: argparse.Action) -> str: """Build completion hint for a given argument.""" # Check if hinting is disabled for this argument suppress_hint = arg_action.get_suppress_tab_hint() # type: ignore[attr-defined] @@ -64,12 +65,12 @@ def _build_hint(parser: argparse.ArgumentParser, arg_action: argparse.Action) -> return formatter.format_help() -def _single_prefix_char(token: str, parser: argparse.ArgumentParser) -> bool: +def _single_prefix_char(token: str, parser: Cmd2ArgumentParser) -> bool: """Is a token just a single flag prefix character.""" return len(token) == 1 and token[0] in parser.prefix_chars -def _looks_like_flag(token: str, parser: argparse.ArgumentParser) -> bool: +def _looks_like_flag(token: str, parser: Cmd2ArgumentParser) -> bool: """Determine if a token looks like a flag. Unless an argument has nargs set to argparse.REMAINDER, then anything that looks like a flag @@ -140,12 +141,12 @@ def __init__(self, flag_arg_state: _ArgumentState) -> None: class _NoResultsError(CompletionError): - def __init__(self, parser: argparse.ArgumentParser, arg_action: argparse.Action) -> None: + def __init__(self, parser: Cmd2ArgumentParser, arg_action: argparse.Action) -> None: """CompletionError which occurs when there are no results. If hinting is allowed on this argument, then its hint text will display. - :param parser: ArgumentParser instance which owns the action being completed + :param parser: Cmd2ArgumentParser instance which owns the action being completed :param arg_action: action being completed. """ # Set apply_style to False because we don't want hints to look like errors @@ -157,14 +158,14 @@ class ArgparseCompleter: def __init__( self, - parser: argparse.ArgumentParser, + parser: Cmd2ArgumentParser, cmd2_app: 'Cmd', *, parent_tokens: Mapping[str, MutableSequence[str]] | None = None, ) -> None: """Create an ArgparseCompleter. - :param parser: ArgumentParser instance + :param parser: Cmd2ArgumentParser instance :param cmd2_app: reference to the Cmd2 application that owns this ArgparseCompleter :param parent_tokens: optional Mapping of parent parsers' arg names to their tokens This is only used by ArgparseCompleter when recursing on subcommand parsers @@ -187,7 +188,7 @@ def __init__( self._positional_actions: list[argparse.Action] = [] # This will be set if self._parser has subcommands - self._subcommand_action: argparse._SubParsersAction[argparse.ArgumentParser] | None = None + self._subcommand_action: argparse._SubParsersAction[Cmd2ArgumentParser] | None = None # Start digging through the argparse structures. # _actions is the top level container of parameter definitions diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 68f970cfa..8ba5ba9a7 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -2,16 +2,9 @@ It also defines a parser class called Cmd2ArgumentParser which improves error and help output over normal argparse. All cmd2 code uses this parser and it is -recommended that developers of cmd2-based apps either use it or write their own -parser that inherits from it. This will give a consistent look-and-feel between -the help/error output of built-in cmd2 commands and the app-specific commands. -If you wish to override the parser used by cmd2's built-in commands, see -custom_parser.py example. - -Since the new capabilities are added by patching at the argparse API level, -they are available whether or not Cmd2ArgumentParser is used. However, the help -and error output of Cmd2ArgumentParser is customized to notate nargs ranges -whereas any other parser class won't be as explicit in their output. +required that developers of cmd2-based apps either use it or write their own +parser that inherits from it. If you wish to override the parser used by cmd2's +built-in commands, see custom_parser.py example. **Added capabilities** diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 786417814..f36485434 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -206,8 +206,8 @@ def __init__(self, msg: str = '') -> None: ) if TYPE_CHECKING: # pragma: no cover - StaticArgParseBuilder = staticmethod[[], argparse.ArgumentParser] - ClassArgParseBuilder = classmethod['Cmd' | CommandSet, [], argparse.ArgumentParser] + StaticArgParseBuilder = staticmethod[[], Cmd2ArgumentParser] + ClassArgParseBuilder = classmethod['Cmd' | CommandSet, [], Cmd2ArgumentParser] from prompt_toolkit.buffer import Buffer else: StaticArgParseBuilder = staticmethod @@ -237,7 +237,7 @@ def __init__(self, cmd: 'Cmd') -> None: # Keyed by the fully qualified method names. This is more reliable than # the methods themselves, since wrapping a method will change its address. - self._parsers: dict[str, argparse.ArgumentParser] = {} + self._parsers: dict[str, Cmd2ArgumentParser] = {} @staticmethod def _fully_qualified_name(command_method: CommandFunc) -> str: @@ -256,7 +256,7 @@ def __contains__(self, command_method: CommandFunc) -> bool: parser = self.get(command_method) return bool(parser) - def get(self, command_method: CommandFunc) -> argparse.ArgumentParser | None: + def get(self, command_method: CommandFunc) -> Cmd2ArgumentParser | None: """Return a given method's parser or None if the method is not argparse-based. If the parser does not yet exist, it will be created. @@ -889,12 +889,9 @@ def register_command_set(self, cmdset: CommandSet) -> None: def _build_parser( self, parent: CmdOrSet, - parser_builder: argparse.ArgumentParser - | Callable[[], argparse.ArgumentParser] - | StaticArgParseBuilder - | ClassArgParseBuilder, + parser_builder: Cmd2ArgumentParser | Callable[[], Cmd2ArgumentParser] | StaticArgParseBuilder | ClassArgParseBuilder, prog: str, - ) -> argparse.ArgumentParser: + ) -> Cmd2ArgumentParser: """Build argument parser for a command/subcommand. :param parent: object which owns the command using the parser. @@ -911,7 +908,7 @@ def _build_parser( parser = parser_builder.__func__(parent.__class__) elif callable(parser_builder): parser = parser_builder() - elif isinstance(parser_builder, argparse.ArgumentParser): + elif isinstance(parser_builder, Cmd2ArgumentParser): parser = copy.deepcopy(parser_builder) else: raise TypeError(f"Invalid type for parser_builder: {type(parser_builder)}") @@ -1021,7 +1018,7 @@ def unregister_command_set(self, cmdset: CommandSet) -> None: self._installed_command_sets.remove(cmdset) def _check_uninstallable(self, cmdset: CommandSet) -> None: - def check_parser_uninstallable(parser: argparse.ArgumentParser) -> None: + def check_parser_uninstallable(parser: Cmd2ArgumentParser) -> None: cmdset_id = id(cmdset) for action in parser._actions: if isinstance(action, argparse._SubParsersAction): @@ -1098,9 +1095,7 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: f"Could not find argparser for command '{command_name}' needed by subcommand: {method}" ) - def find_subcommand( - action: argparse.ArgumentParser, subcmd_names: MutableSequence[str] - ) -> argparse.ArgumentParser: + def find_subcommand(action: Cmd2ArgumentParser, subcmd_names: MutableSequence[str]) -> Cmd2ArgumentParser: if not subcmd_names: return action cur_subcmd = subcmd_names.pop(0) @@ -2349,7 +2344,7 @@ def _redirect_complete(self, text: str, line: str, begidx: int, endidx: int, com return compfunc(text, line, begidx, endidx) @staticmethod - def _determine_ap_completer_type(parser: argparse.ArgumentParser) -> type[argparse_completer.ArgparseCompleter]: + def _determine_ap_completer_type(parser: Cmd2ArgumentParser) -> type[argparse_completer.ArgparseCompleter]: """Determine what type of ArgparseCompleter to use on a given parser. If the parser does not have one set, then use argparse_completer.DEFAULT_AP_COMPLETER. @@ -3455,7 +3450,7 @@ def _resolve_completer( choices: Iterable[Any] | None = None, choices_provider: ChoicesProviderUnbound[CmdOrSet] | None = None, completer: CompleterUnbound[CmdOrSet] | None = None, - parser: argparse.ArgumentParser | None = None, + parser: Cmd2ArgumentParser | None = None, ) -> Completer: """Determine the appropriate completer based on provided arguments.""" if not any((parser, choices, choices_provider, completer)): @@ -3487,7 +3482,7 @@ def read_input( choices: Iterable[Any] | None = None, choices_provider: ChoicesProviderUnbound[CmdOrSet] | None = None, completer: CompleterUnbound[CmdOrSet] | None = None, - parser: argparse.ArgumentParser | None = None, + parser: Cmd2ArgumentParser | None = None, ) -> str: """Read a line of input with optional completion and history. diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 5054d91f6..de3de1198 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -13,7 +13,10 @@ ) from . import constants -from .argparse_custom import Cmd2AttributeWrapper +from .argparse_custom import ( + Cmd2ArgumentParser, + Cmd2AttributeWrapper, +) from .command_definition import ( CommandFunc, CommandSet, @@ -184,19 +187,19 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: return arg_decorator -#: Function signatures for command functions that use an argparse.ArgumentParser to process user input +#: Function signatures for command functions that use a Cmd2ArgumentParser to process user input #: and optionally return a boolean ArgparseCommandFuncOptionalBoolReturn: TypeAlias = Callable[[CmdOrSet, argparse.Namespace], bool | None] ArgparseCommandFuncWithUnknownArgsOptionalBoolReturn: TypeAlias = Callable[ [CmdOrSet, argparse.Namespace, list[str]], bool | None ] -#: Function signatures for command functions that use an argparse.ArgumentParser to process user input +#: Function signatures for command functions that use a Cmd2ArgumentParser to process user input #: and return a boolean ArgparseCommandFuncBoolReturn: TypeAlias = Callable[[CmdOrSet, argparse.Namespace], bool] ArgparseCommandFuncWithUnknownArgsBoolReturn: TypeAlias = Callable[[CmdOrSet, argparse.Namespace, list[str]], bool] -#: Function signatures for command functions that use an argparse.ArgumentParser to process user input +#: Function signatures for command functions that use a Cmd2ArgumentParser to process user input #: and return nothing ArgparseCommandFuncNoneReturn: TypeAlias = Callable[[CmdOrSet, argparse.Namespace], None] ArgparseCommandFuncWithUnknownArgsNoneReturn: TypeAlias = Callable[[CmdOrSet, argparse.Namespace, list[str]], None] @@ -213,17 +216,17 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: def with_argparser( - parser: argparse.ArgumentParser # existing parser - | Callable[[], argparse.ArgumentParser] # function or staticmethod - | Callable[[CmdOrSetClass], argparse.ArgumentParser], # Cmd or CommandSet classmethod + parser: Cmd2ArgumentParser # existing parser + | Callable[[], Cmd2ArgumentParser] # function or staticmethod + | Callable[[CmdOrSetClass], Cmd2ArgumentParser], # Cmd or CommandSet classmethod *, ns_provider: Callable[..., argparse.Namespace] | None = None, preserve_quotes: bool = False, with_unknown_args: bool = False, ) -> Callable[[ArgparseCommandFunc[CmdOrSet]], RawCommandFuncOptionalBoolReturn[CmdOrSet]]: - """Decorate a ``do_*`` method to populate its ``args`` argument with the given instance of argparse.ArgumentParser. + """Decorate a ``do_*`` method to populate its ``args`` argument with the given instance of Cmd2ArgumentParser. - :param parser: instance of ArgumentParser or a callable that returns an ArgumentParser for this command + :param parser: instance of Cmd2ArgumentParser or a callable that returns an Cmd2ArgumentParser for this command :param ns_provider: An optional function that accepts a cmd2.Cmd or cmd2.CommandSet object as an argument and returns an argparse.Namespace. This is useful if the Namespace needs to be prepopulated with state data that affects parsing. @@ -347,9 +350,9 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: def as_subcommand_to( command: str, subcommand: str, - parser: argparse.ArgumentParser # existing parser - | Callable[[], argparse.ArgumentParser] # function or staticmethod - | Callable[[CmdOrSetClass], argparse.ArgumentParser], # Cmd or CommandSet classmethod + parser: Cmd2ArgumentParser # existing parser + | Callable[[], Cmd2ArgumentParser] # function or staticmethod + | Callable[[CmdOrSetClass], Cmd2ArgumentParser], # Cmd or CommandSet classmethod *, help: str | None = None, # noqa: A002 aliases: Sequence[str] | None = None, @@ -359,7 +362,7 @@ def as_subcommand_to( :param command: Command Name. Space-delimited subcommands may optionally be specified :param subcommand: Subcommand name - :param parser: instance of ArgumentParser or a callable that returns an ArgumentParser for this subcommand + :param parser: instance of Cmd2ArgumentParser or a callable that returns an Cmd2ArgumentParser for this subcommand :param help: Help message for this subcommand which displays in the list of subcommands of the command we are adding to. This is passed as the help argument to subparsers.add_parser(). :param aliases: Alternative names for this subcommand. This is passed as the alias argument to diff --git a/cmd2/utils.py b/cmd2/utils.py index 32459ae83..95e763e78 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -1,6 +1,5 @@ """Shared utility functions.""" -import argparse import contextlib import functools import glob @@ -36,6 +35,7 @@ if TYPE_CHECKING: # pragma: no cover PopenTextIO = subprocess.Popen[str] + from .argparse_custom import Cmd2ArgumentParser else: PopenTextIO = subprocess.Popen @@ -734,7 +734,7 @@ def get_defining_class(meth: Callable[..., Any]) -> type[Any] | None: class CustomCompletionSettings: """Used by cmd2.Cmd.complete() to complete strings other than command arguments.""" - def __init__(self, parser: argparse.ArgumentParser, *, preserve_quotes: bool = False) -> None: + def __init__(self, parser: 'Cmd2ArgumentParser', *, preserve_quotes: bool = False) -> None: """CustomCompletionSettings initializer. :param parser: arg parser defining format of string being completed diff --git a/docs/features/argument_processing.md b/docs/features/argument_processing.md index 00a9b94c6..8f9b3ccb4 100644 --- a/docs/features/argument_processing.md +++ b/docs/features/argument_processing.md @@ -6,7 +6,7 @@ following for you: 1. Parsing input and quoted strings in a manner similar to how POSIX shells do it 1. Parse the resulting argument list using an instance of - [argparse.ArgumentParser](https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser) + [Cmd2ArgumentParser](https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser) that you provide 1. Passes the resulting [argparse.Namespace](https://docs.python.org/3/library/argparse.html#argparse.Namespace) object @@ -39,9 +39,9 @@ command which might have its own argument parsing. The [@with_argparser][cmd2.with_argparser] decorator can accept the following for its first argument: -1. An existing instance of `argparse.ArgumentParser` -2. A function or static method which returns an instance of `argparse.ArgumentParser` -3. Cmd or CommandSet class method which returns an instance of `argparse.ArgumentParser` +1. An existing instance of `Cmd2ArgumentParser` +2. A function or static method which returns an instance of `Cmd2ArgumentParser` +3. Cmd or CommandSet class method which returns an instance of `Cmd2ArgumentParser` In all cases the `@with_argparser` decorator creates a deep copy of the parser instance which it stores internally. A consequence is that parsers don't need to be unique across commands. @@ -55,11 +55,11 @@ stores internally. A consequence is that parsers don't need to be unique across ## Argument Parsing For each command in the `cmd2.Cmd` subclass which requires argument parsing, create an instance of -`argparse.ArgumentParser()` which can parse the input appropriately for the command (or provide a +`Cmd2ArgumentParser` which can parse the input appropriately for the command (or provide a function/method that returns such a parser). Then decorate the command method with the `@with_argparser` decorator, passing the argument parser as the first parameter to the decorator. This changes the second argument of the command method, which will contain the results of -`ArgumentParser.parse_args()`. +`Cmd2ArgumentParser.parse_args()`. Here's what it looks like: @@ -97,7 +97,7 @@ def do_speak(self, opts): By default, `cmd2` uses the docstring of the command method when a user asks for help on the command. When you use the `@with_argparser` decorator, the docstring for the `do_*` method is used -to set the description for the `argparse.ArgumentParser`. +to set the description for the `Cmd2ArgumentParser`. !!! tip "description and epilog fields are rich objects" @@ -135,8 +135,8 @@ optional arguments: -h, --help show this help message and exit ``` -If you would prefer, you can set the `description` while instantiating the `argparse.ArgumentParser` -and leave the docstring on your method blank: +If you would prefer, you can set the `description` while instantiating the `Cmd2ArgumentParser` and +leave the docstring on your method blank: ```py from cmd2 import Cmd2ArgumentParser, with_argparser diff --git a/docs/features/completion.md b/docs/features/completion.md index d58d0cef5..868099025 100644 --- a/docs/features/completion.md +++ b/docs/features/completion.md @@ -77,7 +77,7 @@ When using `cmd2`'s [@with_argparser][cmd2.with_argparser] decorator, `cmd2` pro completion of flag names. Tab completion of argument values can be configured by using one of three parameters to -[argparse.ArgumentParser.add_argument](https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser.add_argument) +`Cmd2ArgumentParser.add_argument()`. - `choices` - `choices_provider` diff --git a/docs/migrating/next_steps.md b/docs/migrating/next_steps.md index cff4913c5..d1ab399ab 100644 --- a/docs/migrating/next_steps.md +++ b/docs/migrating/next_steps.md @@ -9,13 +9,13 @@ leveraging other `cmd2` features. The three ideas here will get you started. Bro For all but the simplest of commands, it's probably easier to use [argparse](https://docs.python.org/3/library/argparse.html) to parse user input than to do it manually yourself for each command. `cmd2` provides a `@with_argparser()` decorator which associates -an `ArgumentParser` object with one of your commands. Using this method will: +an `Cmd2ArgumentParser` object with one of your commands. Using this method will: 1. Pass your command a [Namespace](https://docs.python.org/3/library/argparse.html#argparse.Namespace) containing the arguments instead of a string of text 2. Properly handle quoted string input from your users -3. Create a help message for you based on the `ArgumentParser` +3. Create a help message for you based on the `Cmd2ArgumentParser` 4. Give you a big head start adding [Tab Completion](../features/completion.md) to your application 5. Make it much easier to implement subcommands (i.e. `git` has a bunch of subcommands such as `git pull`, `git diff`, etc) diff --git a/tests/conftest.py b/tests/conftest.py index d47c1b5de..3b68e36c6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,5 @@ """Cmd2 unit/functional testing""" -import argparse import sys from collections.abc import Callable from contextlib import redirect_stderr @@ -118,19 +117,6 @@ def cmd_wrapper(*args: P.args, **kwargs: P.kwargs) -> T: odd_file_names = ['nothingweird', 'has spaces', '"is_double_quoted"', "'is_single_quoted'"] -def find_subcommand(action: argparse.ArgumentParser, subcmd_names: list[str]) -> argparse.ArgumentParser: - 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 ValueError(f"Could not find subcommand '{subcmd_names}'") - - if TYPE_CHECKING: _Base = cmd2.Cmd else: From 52f6a3de9b196b28a32a7a2f86b06d6732f6a65f Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 30 Mar 2026 17:16:41 -0400 Subject: [PATCH 02/13] Moved _get_nargs_pattern and _match_argument overrides into Cmd2ArgumentParser. --- cmd2/argparse_custom.py | 87 ++++++++++++++--------------------------- 1 file changed, 29 insertions(+), 58 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 8ba5ba9a7..538510035 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -214,12 +214,6 @@ def get_choices(self) -> Choices: completion and enables nargs range parsing. See _add_argument_wrapper for more details on these arguments. -``argparse.ArgumentParser._get_nargs_pattern`` - adds support for nargs ranges. -See ``_get_nargs_pattern_wrapper`` for more details. - -``argparse.ArgumentParser._match_argument`` - adds support for nargs ranges. -See ``_match_argument_wrapper`` for more details. - **Added accessor methods** cmd2 has patched ``argparse.Action`` to include the following accessor methods @@ -694,7 +688,7 @@ def _add_argument_wrapper( table_columns: Sequence[str | Column] | None = None, **kwargs: Any, ) -> argparse.Action: - """Wrap ActionsContainer.add_argument() which supports more settings used by cmd2. + """Wrap ActionsContainer.add_argument() to support cmd2-specific settings. # Args from original function :param self: instance of the _ActionsContainer being added to @@ -815,57 +809,6 @@ def _add_argument_wrapper( # Overwrite _ActionsContainer.add_argument with our wrapper setattr(argparse._ActionsContainer, 'add_argument', _add_argument_wrapper) -############################################################################################################ -# Patch ArgumentParser._get_nargs_pattern with our wrapper to support nargs ranges -############################################################################################################ - -# Save original ArgumentParser._get_nargs_pattern so we can call it in our wrapper -orig_argument_parser_get_nargs_pattern = argparse.ArgumentParser._get_nargs_pattern - - -def _get_nargs_pattern_wrapper(self: argparse.ArgumentParser, action: argparse.Action) -> str: - # Wrapper around ArgumentParser._get_nargs_pattern behavior to support nargs ranges - nargs_range = action.get_nargs_range() # type: ignore[attr-defined] - if nargs_range: - range_max = '' if nargs_range[1] == constants.INFINITY else nargs_range[1] - nargs_pattern = f'(-*A{{{nargs_range[0]},{range_max}}}-*)' - - # if this is an optional action, -- is not allowed - if action.option_strings: - nargs_pattern = nargs_pattern.replace('-*', '') - nargs_pattern = nargs_pattern.replace('-', '') - return nargs_pattern - - return orig_argument_parser_get_nargs_pattern(self, action) - - -# Overwrite ArgumentParser._get_nargs_pattern with our wrapper -setattr(argparse.ArgumentParser, '_get_nargs_pattern', _get_nargs_pattern_wrapper) - - -############################################################################################################ -# Patch ArgumentParser._match_argument with our wrapper to support nargs ranges -############################################################################################################ -orig_argument_parser_match_argument = argparse.ArgumentParser._match_argument - - -def _match_argument_wrapper(self: argparse.ArgumentParser, action: argparse.Action, arg_strings_pattern: str) -> int: - # Wrapper around ArgumentParser._match_argument behavior to support nargs ranges - nargs_pattern = self._get_nargs_pattern(action) - match = re.match(nargs_pattern, arg_strings_pattern) - - # raise an exception if we weren't able to find a match - if match is None: - nargs_range = action.get_nargs_range() # type: ignore[attr-defined] - if nargs_range is not None: - raise ArgumentError(action, generate_range_error(nargs_range[0], nargs_range[1])) - - return orig_argument_parser_match_argument(self, action, arg_strings_pattern) - - -# Overwrite ArgumentParser._match_argument with our wrapper -setattr(argparse.ArgumentParser, '_match_argument', _match_argument_wrapper) - ############################################################################################################ # Patch argparse.ArgumentParser with accessors for ap_completer_type attribute @@ -1325,6 +1268,34 @@ def create_text_group(self, title: str, text: RenderableType) -> TextGroup: """Create a TextGroup using this parser's formatter creator.""" return TextGroup(title, text, self._get_formatter) + def _get_nargs_pattern(self, action: argparse.Action) -> str: + """Override to support nargs ranges.""" + nargs_range = action.get_nargs_range() # type: ignore[attr-defined] + if nargs_range: + range_max = '' if nargs_range[1] == constants.INFINITY else nargs_range[1] + nargs_pattern = f'(-*A{{{nargs_range[0]},{range_max}}}-*)' + + # if this is an optional action, -- is not allowed + if action.option_strings: + nargs_pattern = nargs_pattern.replace('-*', '') + nargs_pattern = nargs_pattern.replace('-', '') + return nargs_pattern + + return super()._get_nargs_pattern(action) + + def _match_argument(self, action: argparse.Action, arg_strings_pattern: str) -> int: + """Override to support nargs ranges.""" + nargs_pattern = self._get_nargs_pattern(action) + match = re.match(nargs_pattern, arg_strings_pattern) + + # raise an exception if we weren't able to find a match + if match is None: + nargs_range = action.get_nargs_range() # type: ignore[attr-defined] + if nargs_range is not None: + raise ArgumentError(action, generate_range_error(nargs_range[0], nargs_range[1])) + + return super()._match_argument(action, arg_strings_pattern) + class Cmd2AttributeWrapper: """Wraps a cmd2-specific attribute added to an argparse Namespace. From 403210a896637d042196e1603985e4ba0a5b11af Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 30 Mar 2026 22:31:03 -0400 Subject: [PATCH 03/13] Moved _check_value() override to Cmd2ArgumentParser. Removed set_ap_completer_type() and get_ap_completer_type() since ap_completer_type is now a public member of Cmd2ArgumentParser. --- CHANGELOG.md | 2 + cmd2/argparse_custom.py | 98 +++++++------------------------- cmd2/cmd2.py | 10 +--- tests/test_argparse_completer.py | 10 ++-- 4 files changed, 33 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12c9dbae7..6a8d55ae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,8 @@ prompt is displayed. - Removed `Cmd.ruler` since `cmd2` no longer uses it. - All parsers used with `cmd2` commands much be an instance of `Cmd2ArgumentParser` or a child class of it. + - Removed `set_ap_completer_type()` and `get_ap_completer_type()` since `ap_completer_type` is + now a public member of `Cmd2ArgumentParser`. - Enhancements - New `cmd2.Cmd` parameters - **auto_suggest**: (boolean) if `True`, provide fish shell style auto-suggestions. These diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 538510035..b02513d05 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -229,11 +229,6 @@ def get_choices(self) -> Choices: - ``argparse.Action.get_suppress_tab_hint()`` - See ``_action_get_suppress_tab_hint`` for more details. - ``argparse.Action.set_suppress_tab_hint()`` - See ``_action_set_suppress_tab_hint`` for more details. -cmd2 has patched ``argparse.ArgumentParser`` to include the following accessor methods - -- ``argparse.ArgumentParser.get_ap_completer_type()`` - See ``_ArgumentParser_get_ap_completer_type`` for more details. -- ``argparse.Action.set_ap_completer_type()`` - See ``_ArgumentParser_set_ap_completer_type`` for more details. - **Subcommand Manipulation** cmd2 has patched ``argparse._SubParsersAction`` with new functions to better facilitate the @@ -810,76 +805,6 @@ def _add_argument_wrapper( setattr(argparse._ActionsContainer, 'add_argument', _add_argument_wrapper) -############################################################################################################ -# Patch argparse.ArgumentParser with accessors for ap_completer_type attribute -############################################################################################################ - -# An ArgumentParser attribute which specifies a subclass of ArgparseCompleter for custom completion behavior on a -# given parser. If this is None or not present, then cmd2 will use argparse_completer.DEFAULT_AP_COMPLETER when tab -# completing a parser's arguments -ATTR_AP_COMPLETER_TYPE = 'ap_completer_type' - - -def _ArgumentParser_get_ap_completer_type(self: argparse.ArgumentParser) -> type['ArgparseCompleter'] | None: # noqa: N802 - """Get the ap_completer_type attribute of an argparse ArgumentParser. - - This function is added by cmd2 as a method called ``get_ap_completer_type()`` to ``argparse.ArgumentParser`` class. - - To call: ``parser.get_ap_completer_type()`` - - :param self: ArgumentParser being queried - :return: An ArgparseCompleter-based class or None if attribute does not exist - """ - return cast(type['ArgparseCompleter'] | None, getattr(self, ATTR_AP_COMPLETER_TYPE, None)) - - -setattr(argparse.ArgumentParser, 'get_ap_completer_type', _ArgumentParser_get_ap_completer_type) - - -def _ArgumentParser_set_ap_completer_type(self: argparse.ArgumentParser, ap_completer_type: type['ArgparseCompleter']) -> None: # noqa: N802 - """Set the ap_completer_type attribute of an argparse ArgumentParser. - - This function is added by cmd2 as a method called ``set_ap_completer_type()`` to ``argparse.ArgumentParser`` class. - - To call: ``parser.set_ap_completer_type(ap_completer_type)`` - - :param self: ArgumentParser being edited - :param ap_completer_type: the custom ArgparseCompleter-based class to use when completing arguments for this parser - """ - setattr(self, ATTR_AP_COMPLETER_TYPE, ap_completer_type) - - -setattr(argparse.ArgumentParser, 'set_ap_completer_type', _ArgumentParser_set_ap_completer_type) - - -############################################################################################################ -# Patch ArgumentParser._check_value to support CompletionItems as choices -############################################################################################################ -def _ArgumentParser_check_value(_self: argparse.ArgumentParser, action: argparse.Action, value: Any) -> None: # noqa: N802 - """Check_value that supports CompletionItems as choices (Custom override of ArgumentParser._check_value). - - 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 - """ - # Import gettext like argparse does - from gettext import ( - gettext as _, - ) - - if action.choices is not None and value not in action.choices: - # If any choice is a CompletionItem, then display its value property. - choices = [c.value if isinstance(c, CompletionItem) else c for c in action.choices] - args = {'value': value, 'choices': ', '.join(map(repr, choices))} - msg = _('invalid choice: %(value)r (choose from %(choices)s)') - raise ArgumentError(action, msg % args) - - -setattr(argparse.ArgumentParser, '_check_value', _ArgumentParser_check_value) - - ############################################################################################################ # Patch argparse._SubParsersAction to add attach_parser function ############################################################################################################ @@ -1221,7 +1146,7 @@ def __init__( self.description: RenderableType | None # type: ignore[assignment] self.epilog: RenderableType | None # type: ignore[assignment] - self.set_ap_completer_type(ap_completer_type) # type: ignore[attr-defined] + self.ap_completer_type = ap_completer_type def add_subparsers(self, **kwargs: Any) -> argparse._SubParsersAction: # type: ignore[type-arg] """Add a subcommand parser. @@ -1296,6 +1221,27 @@ def _match_argument(self, action: argparse.Action, arg_strings_pattern: str) -> return super()._match_argument(action, arg_strings_pattern) + def _check_value(self, action: argparse.Action, value: Any) -> None: + """Override that supports CompletionItems as choices. + + 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 + """ + # Import gettext like argparse does + from gettext import ( + gettext as _, + ) + + if action.choices is not None and value not in action.choices: + # If any choice is a CompletionItem, then display its value property. + choices = [c.value if isinstance(c, CompletionItem) else c for c in action.choices] + args = {'value': value, 'choices': ', '.join(map(repr, choices))} + msg = _('invalid choice: %(value)r (choose from %(choices)s)') + raise ArgumentError(action, msg % args) + class Cmd2AttributeWrapper: """Wraps a cmd2-specific attribute added to an argparse Namespace. diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index f36485434..aa6cd754b 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -62,7 +62,6 @@ TYPE_CHECKING, Any, TextIO, - TypeAlias, TypeVar, Union, cast, @@ -2351,12 +2350,9 @@ def _determine_ap_completer_type(parser: Cmd2ArgumentParser) -> type[argparse_co :param parser: the parser to examine :return: type of ArgparseCompleter """ - APCompleterType: TypeAlias = type[argparse_completer.ArgparseCompleter] | None - completer_type: APCompleterType = parser.get_ap_completer_type() # type: ignore[attr-defined] - - if completer_type is None: - completer_type = argparse_completer.DEFAULT_AP_COMPLETER - return completer_type + if parser.ap_completer_type is None: + return argparse_completer.DEFAULT_AP_COMPLETER + return parser.ap_completer_type def _perform_completion( self, text: str, line: str, begidx: int, endidx: int, custom_settings: utils.CustomCompletionSettings | None = None diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index a7e1b3a1b..ebc1d58b0 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -1421,8 +1421,10 @@ def test_add_parser_custom_completer() -> None: parser = Cmd2ArgumentParser() subparsers = parser.add_subparsers() - no_custom_completer_parser = subparsers.add_parser(name="no_custom_completer") - assert no_custom_completer_parser.get_ap_completer_type() is None # type: ignore[attr-defined] + no_custom_completer_parser: Cmd2ArgumentParser = subparsers.add_parser(name="no_custom_completer") + assert no_custom_completer_parser.ap_completer_type is None - custom_completer_parser = subparsers.add_parser(name="custom_completer", ap_completer_type=CustomCompleter) - assert custom_completer_parser.get_ap_completer_type() is CustomCompleter # type: ignore[attr-defined] + custom_completer_parser: Cmd2ArgumentParser = subparsers.add_parser( + name="custom_completer", ap_completer_type=CustomCompleter + ) + assert custom_completer_parser.ap_completer_type is CustomCompleter From b1e07dbbd29394b43788acda77038b3b4bf91840 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 30 Mar 2026 22:58:03 -0400 Subject: [PATCH 04/13] Renamed two patch functions. --- cmd2/argparse_custom.py | 20 ++++++++++---------- cmd2/cmd2.py | 4 ++-- cmd2/rich_utils.py | 6 +++--- tests/test_rich_utils.py | 6 +++--- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index b02513d05..44dd2b8fd 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -25,7 +25,7 @@ **Completion** cmd2 uses its ArgparseCompleter class to enable argparse-based completion -on all commands that use the @with_argparse wrappers. Out of the box you get +on all commands that use the @with_argparser decorator. Out of the box you get completion of commands, subcommands, and flag names, as well as instructive hints about the current argument that print when tab is pressed. In addition, you can add completion for each argument's values using parameters passed @@ -211,15 +211,15 @@ def get_choices(self) -> Choices: **Patched argparse functions** ``argparse._ActionsContainer.add_argument`` - adds arguments related to tab -completion and enables nargs range parsing. See _add_argument_wrapper for -more details on these arguments. +completion and enables nargs range parsing. See ``__ActionsContainer_add_argument`` +for more details on these arguments. **Added accessor methods** cmd2 has patched ``argparse.Action`` to include the following accessor methods for cases in which you need to manually access the cmd2-specific attributes. -- ``argparse.Action.get_choices_callable()`` - See ``action_get_choices_callable`` for more details. +- ``argparse.Action.get_choices_callable()`` - See ``_action_get_choices_callable`` for more details. - ``argparse.Action.set_choices_provider()`` - See ``_action_set_choices_provider`` for more details. - ``argparse.Action.set_completer()`` - See ``_action_set_completer`` for more details. - ``argparse.Action.get_table_columns()`` - See ``_action_get_table_columns`` for more details. @@ -665,15 +665,15 @@ def _action_set_custom_parameter(self: argparse.Action, value: Any) -> None: ############################################################################################################ -# Patch _ActionsContainer.add_argument with our wrapper to support more arguments +# Patch _ActionsContainer.add_argument to support more arguments ############################################################################################################ -# Save original _ActionsContainer.add_argument so we can call it in our wrapper +# Save original _ActionsContainer.add_argument so we can call it in our patch orig_actions_container_add_argument = argparse._ActionsContainer.add_argument -def _add_argument_wrapper( +def __ActionsContainer_add_argument( # noqa: N802 self: argparse._ActionsContainer, *args: Any, nargs: int | str | tuple[int] | tuple[int, int] | tuple[int, float] | None = None, @@ -683,7 +683,7 @@ def _add_argument_wrapper( table_columns: Sequence[str | Column] | None = None, **kwargs: Any, ) -> argparse.Action: - """Wrap ActionsContainer.add_argument() to support cmd2-specific settings. + """Patch ActionsContainer.add_argument() to support cmd2-specific settings. # Args from original function :param self: instance of the _ActionsContainer being added to @@ -801,8 +801,8 @@ def _add_argument_wrapper( return new_arg -# Overwrite _ActionsContainer.add_argument with our wrapper -setattr(argparse._ActionsContainer, 'add_argument', _add_argument_wrapper) +# Overwrite _ActionsContainer.add_argument with our patch +setattr(argparse._ActionsContainer, 'add_argument', __ActionsContainer_add_argument) ############################################################################################################ diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index aa6cd754b..ea566e326 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -2716,8 +2716,8 @@ def get_help_topics(self) -> list[str]: def sigint_handler( self, - signum: int, # noqa: ARG002, - frame: FrameType | None, # noqa: ARG002, + signum: int, # noqa: ARG002 + frame: FrameType | None, # noqa: ARG002 ) -> None: """Signal handler for SIGINTs which typically come from Ctrl-C events. diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 7b07185d2..58efba27e 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -494,12 +494,12 @@ def prepare_objects_for_rendering(*objects: Any) -> tuple[Any, ...]: # Text.from_ansi() monkey patch ################################################################################### -# Save original Text.from_ansi() so we can call it in our wrapper +# Save original Text.from_ansi() so we can call it in our patch _orig_text_from_ansi = Text.from_ansi @classmethod # type: ignore[misc] -def _from_ansi_wrapper(cls: type[Text], text: str, *args: Any, **kwargs: Any) -> Text: # noqa: ARG001 +def _Text_from_ansi(cls: type[Text], text: str, *args: Any, **kwargs: Any) -> Text: # noqa: N802, ARG001 r"""Wrap Text.from_ansi() to fix its trailing newline bug. This wrapper handles an issue where Text.from_ansi() removes the @@ -539,4 +539,4 @@ def _from_ansi_has_newline_bug() -> bool: # Only apply the monkey patch if the bug is present if _from_ansi_has_newline_bug(): - Text.from_ansi = _from_ansi_wrapper # type: ignore[assignment] + Text.from_ansi = _Text_from_ansi # type: ignore[assignment] diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py index a3e8f9d34..948ce5564 100644 --- a/tests/test_rich_utils.py +++ b/tests/test_rich_utils.py @@ -115,10 +115,10 @@ def test_set_theme() -> None: assert ru.APP_THEME.styles[rich_style_key] == theme[rich_style_key] -def test_from_ansi_wrapper() -> None: +def test_from_ansi_patch() -> None: # Check if we are still patching Text.from_ansi(). If this check fails, then Rich - # has fixed the bug. Therefore, we can remove this test function and ru._from_ansi_wrapper. - assert Text.from_ansi.__func__ is ru._from_ansi_wrapper.__func__ # type: ignore[attr-defined] + # has fixed the bug. Therefore, we can remove this test function and ru._Text_from_ansi. + assert Text.from_ansi.__func__ is ru._Text_from_ansi.__func__ # type: ignore[attr-defined] # Line breaks recognized by str.splitlines(). # Source: https://docs.python.org/3/library/stdtypes.html#str.splitlines From 28a4891d129515fe08dcd52136b8f6b8b6b35916 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 31 Mar 2026 21:54:49 -0400 Subject: [PATCH 05/13] Simplified creation of cmd2-specific argparse.Action attributes. --- cmd2/argparse_completer.py | 107 ++++----- cmd2/argparse_custom.py | 365 +++++-------------------------- tests/test_argparse_completer.py | 12 +- tests/test_argparse_custom.py | 28 +-- tests/test_cmd2.py | 4 +- 5 files changed, 132 insertions(+), 384 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index a4db80f72..1c1575271 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -22,18 +22,10 @@ cast, ) -from rich.text import Text - -from .constants import INFINITY -from .rich_utils import Cmd2SimpleTable - -if TYPE_CHECKING: # pragma: no cover - from .cmd2 import Cmd - from rich.table import Column +from rich.text import Text from .argparse_custom import ( - ChoicesCallable, Cmd2ArgumentParser, generate_range_error, ) @@ -43,7 +35,18 @@ Completions, all_display_numeric, ) +from .constants import INFINITY from .exceptions import CompletionError +from .rich_utils import Cmd2SimpleTable +from .types import ( + ChoicesProviderUnbound, + CmdOrSet, + CompleterUnbound, +) + +if TYPE_CHECKING: # pragma: no cover + from .cmd2 import Cmd + # Name of the choice/completer function argument that, if present, will be passed a dictionary of # command line tokens up through the token being completed mapped to their argparse destination name. @@ -708,33 +711,32 @@ def print_help(self, tokens: Sequence[str], file: IO[str] | None = None) -> None return self._parser.print_help(file=file) - def _get_raw_choices(self, arg_state: _ArgumentState) -> list[CompletionItem] | ChoicesCallable | None: - """Extract choices from action or return the choices_callable.""" - if arg_state.action.choices is not None: - # If choices are subcommands, then get their help text to populate display_meta. - if isinstance(arg_state.action, argparse._SubParsersAction): - parser_help = {} - for action in arg_state.action._choices_actions: - if action.dest in arg_state.action.choices: - subparser = arg_state.action.choices[action.dest] - parser_help[subparser] = action.help or '' - - return [ - CompletionItem(name, display_meta=parser_help.get(subparser, '')) - for name, subparser in arg_state.action.choices.items() - ] - - # Standard choices + def _choices_to_items(self, arg_state: _ArgumentState) -> list[CompletionItem]: + """Convert choices from action to list of CompletionItems.""" + if arg_state.action.choices is None: + return [] + + # If choices are subcommands, then get their help text to populate display_meta. + if isinstance(arg_state.action, argparse._SubParsersAction): + parser_help = {} + for action in arg_state.action._choices_actions: + if action.dest in arg_state.action.choices: + subparser = arg_state.action.choices[action.dest] + parser_help[subparser] = action.help or '' + return [ - choice if isinstance(choice, CompletionItem) else CompletionItem(choice) for choice in arg_state.action.choices + CompletionItem(name, display_meta=parser_help.get(subparser, '')) + for name, subparser in arg_state.action.choices.items() ] - choices_callable: ChoicesCallable | None = arg_state.action.get_choices_callable() # type: ignore[attr-defined] - return choices_callable + # Standard choices + return [ + choice if isinstance(choice, CompletionItem) else CompletionItem(choice) for choice in arg_state.action.choices + ] def _prepare_callable_params( self, - choices_callable: ChoicesCallable, + to_call: ChoicesProviderUnbound[CmdOrSet] | CompleterUnbound[CmdOrSet], arg_state: _ArgumentState, text: str, consumed_arg_values: dict[str, list[str]], @@ -745,14 +747,14 @@ def _prepare_callable_params( kwargs: dict[str, Any] = {} # Resolve the 'self' instance for the method - self_arg = self._cmd2_app._resolve_func_self(choices_callable.to_call, cmd_set) + self_arg = self._cmd2_app._resolve_func_self(to_call, cmd_set) if self_arg is None: - raise CompletionError("Could not find CommandSet instance matching defining type for completer") + raise CompletionError("Could not find CommandSet instance matching defining type") args.append(self_arg) # Check if the function expects 'arg_tokens' - to_call_params = inspect.signature(choices_callable.to_call).parameters + to_call_params = inspect.signature(to_call).parameters if ARG_TOKENS in to_call_params: arg_tokens = {**self._parent_tokens, **consumed_arg_values} arg_tokens.setdefault(arg_state.action.dest, []).append(text) @@ -776,26 +778,33 @@ def _complete_arg( :return: a Completions object :raises CompletionError: if the completer or choices function this calls raises one """ - raw_choices = self._get_raw_choices(arg_state) - if not raw_choices: - return Completions() - - # Check if the argument uses a completer function - if isinstance(raw_choices, ChoicesCallable) and raw_choices.is_completer: - args, kwargs = self._prepare_callable_params(raw_choices, arg_state, text, consumed_arg_values, cmd_set) + # Check if the argument uses a completer + completer = arg_state.action.get_completer() # type: ignore[attr-defined] + if completer is not None: + args, kwargs = self._prepare_callable_params( + completer, + arg_state, + text, + consumed_arg_values, + cmd_set, + ) args.extend([text, line, begidx, endidx]) - completions = raw_choices.completer(*args, **kwargs) + completions: Completions = completer(*args, **kwargs) - # Otherwise it uses a choices list or choices provider function + # Otherwise it uses a choices provider or choices list else: - all_choices: list[CompletionItem] = [] - - if isinstance(raw_choices, ChoicesCallable): - args, kwargs = self._prepare_callable_params(raw_choices, arg_state, text, consumed_arg_values, cmd_set) - choices_func = raw_choices.choices_provider - all_choices = list(choices_func(*args, **kwargs)) + choices_provider = arg_state.action.get_choices_provider() # type: ignore[attr-defined] + if choices_provider is not None: + args, kwargs = self._prepare_callable_params( + choices_provider, + arg_state, + text, + consumed_arg_values, + cmd_set, + ) + all_choices = list(choices_provider(*args, **kwargs)) else: - all_choices = raw_choices + all_choices = self._choices_to_items(arg_state) # Filter used values and run basic completion used_values = consumed_arg_values.get(arg_state.action.dest, []) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 44dd2b8fd..37af5421d 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -208,26 +208,20 @@ def get_choices(self) -> Choices: exceeds this number, then a completion table won't be displayed. -**Patched argparse functions** +**Custom Argument Parameters** -``argparse._ActionsContainer.add_argument`` - adds arguments related to tab -completion and enables nargs range parsing. See ``__ActionsContainer_add_argument`` -for more details on these arguments. +``argparse._ActionsContainer.add_argument`` has been patched to support several +custom parameters used for tab completion and nargs range parsing. These +parameters are registered using ``register_argparse_argument_parameter()``. +See ``__ActionsContainer_add_argument`` for more details on these parameters. -**Added accessor methods** +Registering a parameter whitelists it for use in ``add_argument()`` and +automatically adds getter and setter accessor methods to the ``argparse.Action`` +class. For any registered parameter named ````, the following methods are +available on the resulting ``Action`` object to access its underlying attribute: -cmd2 has patched ``argparse.Action`` to include the following accessor methods -for cases in which you need to manually access the cmd2-specific attributes. - -- ``argparse.Action.get_choices_callable()`` - See ``_action_get_choices_callable`` for more details. -- ``argparse.Action.set_choices_provider()`` - See ``_action_set_choices_provider`` for more details. -- ``argparse.Action.set_completer()`` - See ``_action_set_completer`` for more details. -- ``argparse.Action.get_table_columns()`` - See ``_action_get_table_columns`` for more details. -- ``argparse.Action.set_table_columns()`` - See ``_action_set_table_columns`` for more details. -- ``argparse.Action.get_nargs_range()`` - See ``_action_get_nargs_range`` for more details. -- ``argparse.Action.set_nargs_range()`` - See ``_action_set_nargs_range`` for more details. -- ``argparse.Action.get_suppress_tab_hint()`` - See ``_action_get_suppress_tab_hint`` for more details. -- ``argparse.Action.set_suppress_tab_hint()`` - See ``_action_set_suppress_tab_hint`` for more details. +- ``action.get_()`` +- ``action.set_(value)`` **Subcommand Manipulation** @@ -358,317 +352,79 @@ def set_parser_prog(parser: argparse.ArgumentParser, prog: str) -> None: req_args.append(action.dest) -class ChoicesCallable: - """Enables using a callable as the choices provider for an argparse argument. - - While argparse has the built-in choices attribute, it is limited to an iterable. - """ - - def __init__( - self, - is_completer: bool, - to_call: ChoicesProviderUnbound[CmdOrSet] | CompleterUnbound[CmdOrSet], - ) -> None: - """Initialize the ChoiceCallable instance. - - :param is_completer: True if to_call is a completion routine which expects - the args: text, line, begidx, endidx - :param to_call: the callable object that will be called to provide choices for the argument. - """ - self.is_completer = is_completer - self.to_call = to_call - - @property - def choices_provider(self) -> ChoicesProviderUnbound[CmdOrSet]: - """Retrieve the internal choices_provider function.""" - if self.is_completer: - raise AttributeError("This instance is configured as a completer, not a choices_provider") - return cast(ChoicesProviderUnbound[CmdOrSet], self.to_call) - - @property - def completer(self) -> CompleterUnbound[CmdOrSet]: - """Retrieve the internal completer function.""" - if not self.is_completer: - raise AttributeError("This instance is configured as a choices_provider, not a completer") - return cast(CompleterUnbound[CmdOrSet], self.to_call) - - ############################################################################################################ -# The following are names of custom argparse Action attributes added by cmd2 +# Allow developers to add custom action attributes ############################################################################################################ -# ChoicesCallable object that specifies the function to be called which provides choices to the argument -ATTR_CHOICES_CALLABLE = 'choices_callable' - -# Completion table columns -ATTR_TABLE_COLUMNS = 'table_columns' - -# A tuple specifying nargs as a range (min, max) -ATTR_NARGS_RANGE = 'nargs_range' +CUSTOM_ACTION_ATTRIBS: set[str] = set() +_CUSTOM_ATTRIB_PREFIX = '_cmd2_' -# Pressing tab normally displays the help text for the argument if no choices are available -# Setting this attribute to True will suppress these hints -ATTR_SUPPRESS_TAB_HINT = 'suppress_tab_hint' +def register_argparse_argument_parameter( + param_name: str, + *, + validator: Callable[[argparse.Action, Any], Any] | None = None, +) -> None: + """Register a custom parameter for argparse.Action and add accessors to the Action class. -############################################################################################################ -# Patch argparse.Action with accessors for choice_callable attribute -############################################################################################################ -def _action_get_choices_callable(self: argparse.Action) -> ChoicesCallable | None: - """Get the choices_callable attribute of an argparse Action. + :param param_name: Name of the parameter. This must be a valid Python identifier. + :param validator: Optional function to validate and/or transform the parameter value. + It accepts the Action instance and the value as arguments. + """ + if not param_name.isidentifier(): + raise KeyError(f'Invalid parameter name {param_name} - cannot be used as a python identifier') - This function is added by cmd2 as a method called ``get_choices_callable()`` to ``argparse.Action`` class. + attr_name = f'{_CUSTOM_ATTRIB_PREFIX}{param_name}' + if param_name in CUSTOM_ACTION_ATTRIBS or hasattr(argparse.Action, attr_name): + raise KeyError(f'Custom parameter {param_name} already exists') - To call: ``action.get_choices_callable()`` + def _action_get_custom_parameter(self: argparse.Action) -> Any: + """Get the custom attribute of an argparse Action.""" + return getattr(self, attr_name, None) - :param self: argparse Action being queried - :return: A ChoicesCallable instance or None if attribute does not exist - """ - return cast(ChoicesCallable | None, getattr(self, ATTR_CHOICES_CALLABLE, None)) + setattr(argparse.Action, f'get_{param_name}', _action_get_custom_parameter) + def _action_set_custom_parameter(self: argparse.Action, value: Any) -> None: + """Set the custom attribute of an argparse Action.""" + if validator is not None: + value = validator(self, value) -setattr(argparse.Action, 'get_choices_callable', _action_get_choices_callable) + setattr(self, attr_name, value) + setattr(argparse.Action, f'set_{param_name}', _action_set_custom_parameter) -def _action_set_choices_callable(self: argparse.Action, choices_callable: ChoicesCallable) -> None: - """Set the choices_callable attribute of an argparse Action. + CUSTOM_ACTION_ATTRIBS.add(param_name) - This function is added by cmd2 as a method called ``_set_choices_callable()`` to ``argparse.Action`` class. - Call this using the convenience wrappers ``set_choices_provider()`` and ``set_completer()`` instead. +def _choices_callable_validator(self: argparse.Action, value: Any) -> Any: + """Validate choices_provider and completer values for potential conflicts.""" + if value is None: + return None - :param self: action being edited - :param choices_callable: the ChoicesCallable instance to use - :raises TypeError: if used on incompatible action type - """ - # Verify consistent use of parameters if self.choices is not None: err_msg = "None of the following parameters can be used alongside a choices parameter:\nchoices_provider, completer" - raise (TypeError(err_msg)) + raise TypeError(err_msg) if self.nargs == 0: err_msg = ( "None of the following parameters can be used on an action that takes no arguments:\nchoices_provider, completer" ) - raise (TypeError(err_msg)) - - setattr(self, ATTR_CHOICES_CALLABLE, choices_callable) - - -setattr(argparse.Action, '_set_choices_callable', _action_set_choices_callable) - - -def _action_set_choices_provider( - self: argparse.Action, - choices_provider: ChoicesProviderUnbound[CmdOrSet], -) -> None: - """Set choices_provider of an argparse Action. - - This function is added by cmd2 as a method called ``set_choices_callable()`` to ``argparse.Action`` class. - - To call: ``action.set_choices_provider(choices_provider)`` - - :param self: action being edited - :param choices_provider: the choices_provider instance to use - :raises TypeError: if used on incompatible action type - """ - self._set_choices_callable(ChoicesCallable(is_completer=False, to_call=choices_provider)) # type: ignore[attr-defined] - - -setattr(argparse.Action, 'set_choices_provider', _action_set_choices_provider) - - -def _action_set_completer( - self: argparse.Action, - completer: CompleterUnbound[CmdOrSet], -) -> None: - """Set completer of an argparse Action. - - This function is added by cmd2 as a method called ``set_completer()`` to ``argparse.Action`` class. - - To call: ``action.set_completer(completer)`` - - :param self: action being edited - :param completer: the completer instance to use - :raises TypeError: if used on incompatible action type - """ - self._set_choices_callable(ChoicesCallable(is_completer=True, to_call=completer)) # type: ignore[attr-defined] - - -setattr(argparse.Action, 'set_completer', _action_set_completer) - - -############################################################################################################ -# Patch argparse.Action with accessors for table_columns attribute -############################################################################################################ -def _action_get_table_columns(self: argparse.Action) -> Sequence[str | Column] | None: - """Get the table_columns attribute of an argparse Action. - - This function is added by cmd2 as a method called ``get_table_columns()`` to ``argparse.Action`` class. - - To call: ``action.get_table_columns()`` - - :param self: argparse Action being queried - :return: The value of table_columns or None if attribute does not exist - """ - return cast(Sequence[str | Column] | None, getattr(self, ATTR_TABLE_COLUMNS, None)) - - -setattr(argparse.Action, 'get_table_columns', _action_get_table_columns) - + raise TypeError(err_msg) + return value -def _action_set_table_columns(self: argparse.Action, table_columns: Sequence[str | Column] | None) -> None: - """Set the table_columns attribute of an argparse Action. - This function is added by cmd2 as a method called ``set_table_columns()`` to ``argparse.Action`` class. - - To call: ``action.set_table_columns(table_columns)`` - - :param self: argparse Action being updated - :param table_columns: value being assigned - """ - setattr(self, ATTR_TABLE_COLUMNS, table_columns) - - -setattr(argparse.Action, 'set_table_columns', _action_set_table_columns) - - -############################################################################################################ -# Patch argparse.Action with accessors for nargs_range attribute -############################################################################################################ -def _action_get_nargs_range(self: argparse.Action) -> tuple[int, int | float] | None: - """Get the nargs_range attribute of an argparse Action. - - This function is added by cmd2 as a method called ``get_nargs_range()`` to ``argparse.Action`` class. - - To call: ``action.get_nargs_range()`` - - :param self: argparse Action being queried - :return: The value of nargs_range or None if attribute does not exist - """ - return cast(tuple[int, int | float] | None, getattr(self, ATTR_NARGS_RANGE, None)) - - -setattr(argparse.Action, 'get_nargs_range', _action_get_nargs_range) - - -def _action_set_nargs_range(self: argparse.Action, nargs_range: tuple[int, int | float] | None) -> None: - """Set the nargs_range attribute of an argparse Action. - - This function is added by cmd2 as a method called ``set_nargs_range()`` to ``argparse.Action`` class. - - To call: ``action.set_nargs_range(nargs_range)`` - - :param self: argparse Action being updated - :param nargs_range: value being assigned - """ - setattr(self, ATTR_NARGS_RANGE, nargs_range) - - -setattr(argparse.Action, 'set_nargs_range', _action_set_nargs_range) - - -############################################################################################################ -# Patch argparse.Action with accessors for suppress_tab_hint attribute -############################################################################################################ -def _action_get_suppress_tab_hint(self: argparse.Action) -> bool: - """Get the suppress_tab_hint attribute of an argparse Action. - - This function is added by cmd2 as a method called ``get_suppress_tab_hint()`` to ``argparse.Action`` class. - - To call: ``action.get_suppress_tab_hint()`` - - :param self: argparse Action being queried - :return: The value of suppress_tab_hint or False if attribute does not exist - """ - return cast(bool, getattr(self, ATTR_SUPPRESS_TAB_HINT, False)) - - -setattr(argparse.Action, 'get_suppress_tab_hint', _action_get_suppress_tab_hint) - - -def _action_set_suppress_tab_hint(self: argparse.Action, suppress_tab_hint: bool) -> None: - """Set the suppress_tab_hint attribute of an argparse Action. - - This function is added by cmd2 as a method called ``set_suppress_tab_hint()`` to ``argparse.Action`` class. - - To call: ``action.set_suppress_tab_hint(suppress_tab_hint)`` - - :param self: argparse Action being updated - :param suppress_tab_hint: value being assigned - """ - setattr(self, ATTR_SUPPRESS_TAB_HINT, suppress_tab_hint) - - -setattr(argparse.Action, 'set_suppress_tab_hint', _action_set_suppress_tab_hint) - - -############################################################################################################ -# Allow developers to add custom action attributes -############################################################################################################ - -CUSTOM_ACTION_ATTRIBS: set[str] = set() -_CUSTOM_ATTRIB_PFX = '_attr_' - - -def register_argparse_argument_parameter(param_name: str, param_type: type[Any] | None) -> None: - """Register a custom argparse argument parameter. - - The registered name will then be a recognized keyword parameter to the parser's `add_argument()` function. - - An accessor functions will be added to the parameter's Action object in the form of: ``get_{param_name}()`` - and ``set_{param_name}(value)``. - - :param param_name: Name of the parameter to add. - :param param_type: Type of the parameter to add. - """ - attr_name = f'{_CUSTOM_ATTRIB_PFX}{param_name}' - if param_name in CUSTOM_ACTION_ATTRIBS or hasattr(argparse.Action, attr_name): - raise KeyError(f'Custom parameter {param_name} already exists') - if not re.search('^[A-Za-z_][A-Za-z0-9_]*$', param_name): - raise KeyError(f'Invalid parameter name {param_name} - cannot be used as a python identifier') - - getter_name = f'get_{param_name}' - - def _action_get_custom_parameter(self: argparse.Action) -> Any: - """Get the custom attribute of an argparse Action. - - This function is added by cmd2 as a method called ``get_()`` to ``argparse.Action`` class. - - To call: ``action.get_()`` - - :param self: argparse Action being queried - :return: The value of the custom attribute or None if attribute does not exist - """ - return getattr(self, attr_name, None) - - setattr(argparse.Action, getter_name, _action_get_custom_parameter) - - setter_name = f'set_{param_name}' - - def _action_set_custom_parameter(self: argparse.Action, value: Any) -> None: - """Set the custom attribute of an argparse Action. - - This function is added by cmd2 as a method called ``set_()`` to ``argparse.Action`` class. - - To call: ``action.set_()`` - - :param self: argparse Action being updated - :param value: value being assigned - """ - if param_type and not isinstance(value, param_type): - raise TypeError(f'{param_name} must be of type {param_type}, got: {value} ({type(value)})') - setattr(self, attr_name, value) - - setattr(argparse.Action, setter_name, _action_set_custom_parameter) - - CUSTOM_ACTION_ATTRIBS.add(param_name) +# Add new attributes to argparse.Action. +# See __ActionsContainer_add_argument() for details on these attributes. +register_argparse_argument_parameter('choices_provider', validator=_choices_callable_validator) +register_argparse_argument_parameter('completer', validator=_choices_callable_validator) +register_argparse_argument_parameter('table_columns') +register_argparse_argument_parameter('nargs_range') +register_argparse_argument_parameter('suppress_tab_hint') ############################################################################################################ # Patch _ActionsContainer.add_argument to support more arguments ############################################################################################################ - # Save original _ActionsContainer.add_argument so we can call it in our patch orig_actions_container_add_argument = argparse._ActionsContainer.add_argument @@ -683,7 +439,7 @@ def __ActionsContainer_add_argument( # noqa: N802 table_columns: Sequence[str | Column] | None = None, **kwargs: Any, ) -> argparse.Action: - """Patch ActionsContainer.add_argument() to support cmd2-specific settings. + """Patch _ActionsContainer.add_argument() to support cmd2-specific settings. # Args from original function :param self: instance of the _ActionsContainer being added to @@ -782,17 +538,14 @@ def __ActionsContainer_add_argument( # noqa: N802 # Create the argument using the original add_argument function new_arg = orig_actions_container_add_argument(self, *args, **kwargs) - # Set the custom attributes + # Set the cmd2-specific attributes new_arg.set_nargs_range(nargs_range) # type: ignore[attr-defined] - - if choices_provider: - new_arg.set_choices_provider(choices_provider) # type: ignore[attr-defined] - elif completer: - new_arg.set_completer(completer) # type: ignore[attr-defined] - + new_arg.set_choices_provider(choices_provider) # type: ignore[attr-defined] + new_arg.set_completer(completer) # type: ignore[attr-defined] new_arg.set_suppress_tab_hint(suppress_tab_hint) # type: ignore[attr-defined] new_arg.set_table_columns(table_columns) # type: ignore[attr-defined] + # Set other registered custom attributes for keyword, value in custom_attribs.items(): attr_setter = getattr(new_arg, f'set_{keyword}', None) if attr_setter is not None: diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index ebc1d58b0..c94479f91 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -1266,20 +1266,20 @@ def test_validate_table_data_valid() -> None: # Custom ArgparseCompleter-based class class CustomCompleter(argparse_completer.ArgparseCompleter): - def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, matched_flags: list[str]) -> list[str]: + def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, used_flags: set[str]) -> Completions: """Override so flags with 'complete_when_ready' set to True will complete only when app is ready""" - # Find flags which should not be completed and place them in matched_flags + # Find flags which should not be completed and place them in used_flags for flag in self._flags: action = self._flag_to_action[flag] app: CustomCompleterApp = cast(CustomCompleterApp, self._cmd2_app) - if action.get_complete_when_ready() is True and not app.is_ready: - matched_flags.append(flag) + if action.get_complete_when_ready() and not app.is_ready: + used_flags.append(flag) - return super()._complete_flags(text, line, begidx, endidx, matched_flags) + return super()._complete_flags(text, line, begidx, endidx, used_flags) # Add a custom argparse action attribute -argparse_custom.register_argparse_argument_parameter('complete_when_ready', bool) +argparse_custom.register_argparse_argument_parameter('complete_when_ready') # App used to test custom ArgparseCompleter types and custom argparse attributes diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index f5967ee90..7a542ca2d 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -12,7 +12,6 @@ constants, ) from cmd2.argparse_custom import ( - ChoicesCallable, Cmd2HelpFormatter, Cmd2RichArgparseConsole, generate_range_error, @@ -81,19 +80,6 @@ def test_apcustom_no_choices_callables_when_nargs_is_0(kwargs) -> None: assert 'None of the following parameters can be used on an action that takes no arguments' in str(excinfo.value) -def test_apcustom_choices_callables_wrong_property() -> None: - """Test using the wrong property when retrieving the to_call value from a ChoicesCallable.""" - choices_callable = ChoicesCallable(is_completer=True, to_call=fake_func) - with pytest.raises(AttributeError) as excinfo: - _ = choices_callable.choices_provider - assert 'This instance is configured as a completer' in str(excinfo.value) - - choices_callable = ChoicesCallable(is_completer=False, to_call=fake_func) - with pytest.raises(AttributeError) as excinfo: - _ = choices_callable.completer - assert 'This instance is configured as a choices_provider' in str(excinfo.value) - - def test_apcustom_usage() -> None: usage = "A custom usage statement" parser = Cmd2ArgumentParser(usage=usage) @@ -206,19 +192,19 @@ def test_apcustom_narg_tuple_zero_base() -> None: parser = Cmd2ArgumentParser() arg = parser.add_argument('arg', nargs=(0,)) assert arg.nargs == argparse.ZERO_OR_MORE - assert arg.nargs_range is None + assert arg.get_nargs_range() is None assert "[arg ...]" in parser.format_help() parser = Cmd2ArgumentParser() arg = parser.add_argument('arg', nargs=(0, 1)) assert arg.nargs == argparse.OPTIONAL - assert arg.nargs_range is None + assert arg.get_nargs_range() is None assert "[arg]" in parser.format_help() parser = Cmd2ArgumentParser() arg = parser.add_argument('arg', nargs=(0, 3)) assert arg.nargs == argparse.ZERO_OR_MORE - assert arg.nargs_range == (0, 3) + assert arg.get_nargs_range() == (0, 3) assert "arg{0..3}" in parser.format_help() @@ -226,13 +212,13 @@ def test_apcustom_narg_tuple_one_base() -> None: parser = Cmd2ArgumentParser() arg = parser.add_argument('arg', nargs=(1,)) assert arg.nargs == argparse.ONE_OR_MORE - assert arg.nargs_range is None + assert arg.get_nargs_range() is None assert "arg [arg ...]" in parser.format_help() parser = Cmd2ArgumentParser() arg = parser.add_argument('arg', nargs=(1, 5)) assert arg.nargs == argparse.ONE_OR_MORE - assert arg.nargs_range == (1, 5) + assert arg.get_nargs_range() == (1, 5) assert "arg{1..5}" in parser.format_help() @@ -241,13 +227,13 @@ def test_apcustom_narg_tuple_other_ranges() -> None: parser = Cmd2ArgumentParser() arg = parser.add_argument('arg', nargs=(2,)) assert arg.nargs == argparse.ONE_OR_MORE - assert arg.nargs_range == (2, constants.INFINITY) + assert arg.get_nargs_range() == (2, constants.INFINITY) # Test finite range parser = Cmd2ArgumentParser() arg = parser.add_argument('arg', nargs=(2, 5)) assert arg.nargs == argparse.ONE_OR_MORE - assert arg.nargs_range == (2, 5) + assert arg.get_nargs_range() == (2, 5) def test_apcustom_print_message(capsys) -> None: diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 0f1e79566..d0998f30a 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -2151,7 +2151,7 @@ def test_resolve_completer_with_choices_provider(base_app: cmd2.Cmd) -> None: assert settings is not None action = settings.parser._actions[-1] - assert action.get_choices_callable().choices_provider == mock_provider + assert action.get_choices_provider() == mock_provider assert not settings.preserve_quotes @@ -2168,7 +2168,7 @@ def test_resolve_completer_with_completer(base_app: cmd2.Cmd) -> None: assert settings is not None action = settings.parser._actions[-1] - assert action.get_choices_callable().completer == mock_completer + assert action.get_completer() == mock_completer assert not settings.preserve_quotes From 2c8354af875a0cbbbfbc47630fb15baac0fa84d2 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 31 Mar 2026 22:39:16 -0400 Subject: [PATCH 06/13] Renamed patch function again. --- cmd2/argparse_custom.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 37af5421d..f846c5b1d 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -213,7 +213,7 @@ def get_choices(self) -> Choices: ``argparse._ActionsContainer.add_argument`` has been patched to support several custom parameters used for tab completion and nargs range parsing. These parameters are registered using ``register_argparse_argument_parameter()``. -See ``__ActionsContainer_add_argument`` for more details on these parameters. +See ``_ActionsContainer_add_argument`` for more details on these parameters. Registering a parameter whitelists it for use in ``add_argument()`` and automatically adds getter and setter accessor methods to the ``argparse.Action`` @@ -413,7 +413,7 @@ def _choices_callable_validator(self: argparse.Action, value: Any) -> Any: # Add new attributes to argparse.Action. -# See __ActionsContainer_add_argument() for details on these attributes. +# See _ActionsContainer_add_argument() for details on these attributes. register_argparse_argument_parameter('choices_provider', validator=_choices_callable_validator) register_argparse_argument_parameter('completer', validator=_choices_callable_validator) register_argparse_argument_parameter('table_columns') @@ -429,7 +429,7 @@ def _choices_callable_validator(self: argparse.Action, value: Any) -> Any: orig_actions_container_add_argument = argparse._ActionsContainer.add_argument -def __ActionsContainer_add_argument( # noqa: N802 +def _ActionsContainer_add_argument( # noqa: N802 self: argparse._ActionsContainer, *args: Any, nargs: int | str | tuple[int] | tuple[int, int] | tuple[int, float] | None = None, @@ -555,7 +555,7 @@ def __ActionsContainer_add_argument( # noqa: N802 # Overwrite _ActionsContainer.add_argument with our patch -setattr(argparse._ActionsContainer, 'add_argument', __ActionsContainer_add_argument) +setattr(argparse._ActionsContainer, 'add_argument', _ActionsContainer_add_argument) ############################################################################################################ From c516fa60b60e3a40ee08cb7e3e7689cc5d92be14 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 31 Mar 2026 23:32:47 -0400 Subject: [PATCH 07/13] Simplified some error checks. --- cmd2/argparse_custom.py | 18 +++++++----------- tests/test_argparse_custom.py | 10 +++++----- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index f846c5b1d..11b83832e 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -396,26 +396,26 @@ def _action_set_custom_parameter(self: argparse.Action, value: Any) -> None: CUSTOM_ACTION_ATTRIBS.add(param_name) -def _choices_callable_validator(self: argparse.Action, value: Any) -> Any: +def _validate_completion_callable(self: argparse.Action, value: Any) -> Any: """Validate choices_provider and completer values for potential conflicts.""" if value is None: return None if self.choices is not None: err_msg = "None of the following parameters can be used alongside a choices parameter:\nchoices_provider, completer" - raise TypeError(err_msg) + raise ValueError(err_msg) if self.nargs == 0: err_msg = ( "None of the following parameters can be used on an action that takes no arguments:\nchoices_provider, completer" ) - raise TypeError(err_msg) + raise ValueError(err_msg) return value # Add new attributes to argparse.Action. # See _ActionsContainer_add_argument() for details on these attributes. -register_argparse_argument_parameter('choices_provider', validator=_choices_callable_validator) -register_argparse_argument_parameter('completer', validator=_choices_callable_validator) +register_argparse_argument_parameter('choices_provider', validator=_validate_completion_callable) +register_argparse_argument_parameter('completer', validator=_validate_completion_callable) register_argparse_argument_parameter('table_columns') register_argparse_argument_parameter('nargs_range') register_argparse_argument_parameter('suppress_tab_hint') @@ -470,12 +470,8 @@ def _ActionsContainer_add_argument( # noqa: N802 :raises ValueError: on incorrect parameter usage """ # Verify consistent use of arguments - choices_callables = [choices_provider, completer] - num_params_set = len(choices_callables) - choices_callables.count(None) - - if num_params_set > 1: - err_msg = "Only one of the following parameters may be used at a time:\nchoices_provider, completer" - raise (ValueError(err_msg)) + if choices_provider is not None and completer is not None: + raise ValueError("Only one of the following parameters may be used at a time:\nchoices_provider, completer") # Pre-process special ranged nargs nargs_range = None diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index 7a542ca2d..6d8d0c2a3 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -54,7 +54,7 @@ def fake_func() -> None: ({'choices_provider': fake_func, 'completer': fake_func}, False), ], ) -def test_apcustom_choices_callable_count(kwargs, is_valid) -> None: +def test_apcustom_completion_callable_count(kwargs, is_valid) -> None: parser = Cmd2ArgumentParser() if is_valid: parser.add_argument('name', **kwargs) @@ -65,17 +65,17 @@ def test_apcustom_choices_callable_count(kwargs, is_valid) -> None: @pytest.mark.parametrize('kwargs', [({'choices_provider': fake_func}), ({'completer': fake_func})]) -def test_apcustom_no_choices_callables_alongside_choices(kwargs) -> None: +def test_apcustom_no_completion_callable_alongside_choices(kwargs) -> None: parser = Cmd2ArgumentParser() - with pytest.raises(TypeError) as excinfo: + with pytest.raises(ValueError) as excinfo: parser.add_argument('name', choices=['my', 'choices', 'list'], **kwargs) assert 'None of the following parameters can be used alongside a choices parameter' in str(excinfo.value) @pytest.mark.parametrize('kwargs', [({'choices_provider': fake_func}), ({'completer': fake_func})]) -def test_apcustom_no_choices_callables_when_nargs_is_0(kwargs) -> None: +def test_apcustom_no_completion_callable_when_nargs_is_0(kwargs) -> None: parser = Cmd2ArgumentParser() - with pytest.raises(TypeError) as excinfo: + with pytest.raises(ValueError) as excinfo: parser.add_argument('--name', action='store_true', **kwargs) assert 'None of the following parameters can be used on an action that takes no arguments' in str(excinfo.value) From 2d2319b9e05b76349c60a25cb7d5a7b8f51afae9 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 1 Apr 2026 01:07:06 -0400 Subject: [PATCH 08/13] Made common prefix for all custom cmd2 attributes. --- cmd2/argparse_custom.py | 3 +-- cmd2/constants.py | 33 +++++++++++++++++++++++---------- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 11b83832e..0fd6bb3a7 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -357,7 +357,6 @@ def set_parser_prog(parser: argparse.ArgumentParser, prog: str) -> None: ############################################################################################################ CUSTOM_ACTION_ATTRIBS: set[str] = set() -_CUSTOM_ATTRIB_PREFIX = '_cmd2_' def register_argparse_argument_parameter( @@ -374,7 +373,7 @@ def register_argparse_argument_parameter( if not param_name.isidentifier(): raise KeyError(f'Invalid parameter name {param_name} - cannot be used as a python identifier') - attr_name = f'{_CUSTOM_ATTRIB_PREFIX}{param_name}' + attr_name = constants.cmd2_attr_name(param_name) if param_name in CUSTOM_ACTION_ATTRIBS or hasattr(argparse.Action, attr_name): raise KeyError(f'Custom parameter {param_name} already exists') diff --git a/cmd2/constants.py b/cmd2/constants.py index 75c60662c..91497d86b 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -31,23 +31,36 @@ # All command completer functions start with this COMPLETER_FUNC_PREFIX = 'complete_' +# Prefix for private attributes injected by cmd2 +CMD2_ATTR_PREFIX = '_cmd2_' + + +def cmd2_attr_name(name: str) -> str: + """Build an attribute name with the cmd2 prefix. + + :param name: the name of the attribute + :return: the prefixed attribute name + """ + return f'{CMD2_ATTR_PREFIX}{name}' + + # The custom help category a command belongs to -CMD_ATTR_HELP_CATEGORY = 'help_category' -CLASS_ATTR_DEFAULT_HELP_CATEGORY = 'cmd2_default_help_category' +CMD_ATTR_HELP_CATEGORY = cmd2_attr_name('help_category') +CLASS_ATTR_DEFAULT_HELP_CATEGORY = cmd2_attr_name('default_help_category') # The argparse parser for the command -CMD_ATTR_ARGPARSER = 'argparser' +CMD_ATTR_ARGPARSER = cmd2_attr_name('argparser') # Whether or not tokens are unquoted before sending to argparse -CMD_ATTR_PRESERVE_QUOTES = 'preserve_quotes' +CMD_ATTR_PRESERVE_QUOTES = cmd2_attr_name('preserve_quotes') # subcommand attributes for the base command name and the subcommand name -SUBCMD_ATTR_COMMAND = 'parent_command' -SUBCMD_ATTR_NAME = 'subcommand_name' -SUBCMD_ATTR_ADD_PARSER_KWARGS = 'subcommand_add_parser_kwargs' +SUBCMD_ATTR_COMMAND = cmd2_attr_name('parent_command') +SUBCMD_ATTR_NAME = cmd2_attr_name('subcommand_name') +SUBCMD_ATTR_ADD_PARSER_KWARGS = cmd2_attr_name('subcommand_add_parser_kwargs') -# arpparse attribute uniquely identifying the command set instance -PARSER_ATTR_COMMANDSET_ID = 'command_set_id' +# argparse attribute uniquely identifying the command set instance +PARSER_ATTR_COMMANDSET_ID = cmd2_attr_name('command_set_id') # custom attributes added to argparse Namespaces -NS_ATTR_SUBCMD_HANDLER = '__subcmd_handler__' +NS_ATTR_SUBCMD_HANDLER = cmd2_attr_name('subcmd_handler') From d336c38f0fcb0e5cfe2821f57067518cefd47c11 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 1 Apr 2026 01:21:35 -0400 Subject: [PATCH 09/13] Fixing linting error. --- tests/test_argparse_custom.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index 6d8d0c2a3..4b82f1b95 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -67,17 +67,19 @@ def test_apcustom_completion_callable_count(kwargs, is_valid) -> None: @pytest.mark.parametrize('kwargs', [({'choices_provider': fake_func}), ({'completer': fake_func})]) def test_apcustom_no_completion_callable_alongside_choices(kwargs) -> None: parser = Cmd2ArgumentParser() - with pytest.raises(ValueError) as excinfo: + + expected_err = "None of the following parameters can be used alongside a choices parameter" + with pytest.raises(ValueError, match=expected_err): parser.add_argument('name', choices=['my', 'choices', 'list'], **kwargs) - assert 'None of the following parameters can be used alongside a choices parameter' in str(excinfo.value) @pytest.mark.parametrize('kwargs', [({'choices_provider': fake_func}), ({'completer': fake_func})]) def test_apcustom_no_completion_callable_when_nargs_is_0(kwargs) -> None: parser = Cmd2ArgumentParser() - with pytest.raises(ValueError) as excinfo: + + expected_err = "None of the following parameters can be used on an action that takes no arguments" + with pytest.raises(ValueError, match=expected_err): parser.add_argument('--name', action='store_true', **kwargs) - assert 'None of the following parameters can be used on an action that takes no arguments' in str(excinfo.value) def test_apcustom_usage() -> None: From 8049aefa5932615ec61d6b12a8f4aca9c1f187d4 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 1 Apr 2026 01:34:47 -0400 Subject: [PATCH 10/13] Added test coverage. --- cmd2/argparse_custom.py | 4 ++-- tests/test_argparse_custom.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 0fd6bb3a7..c1c678a91 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -371,11 +371,11 @@ def register_argparse_argument_parameter( It accepts the Action instance and the value as arguments. """ if not param_name.isidentifier(): - raise KeyError(f'Invalid parameter name {param_name} - cannot be used as a python identifier') + raise KeyError(f"Invalid parameter name '{param_name}' - cannot be used as a python identifier") attr_name = constants.cmd2_attr_name(param_name) if param_name in CUSTOM_ACTION_ATTRIBS or hasattr(argparse.Action, attr_name): - raise KeyError(f'Custom parameter {param_name} already exists') + raise KeyError(f"Custom parameter '{param_name}' already exists") def _action_get_custom_parameter(self: argparse.Action) -> Any: """Get the custom attribute of an argparse Action.""" diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index 4b82f1b95..61ff16b2a 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -9,12 +9,14 @@ from cmd2 import ( Choices, Cmd2ArgumentParser, + argparse_custom, constants, ) from cmd2.argparse_custom import ( Cmd2HelpFormatter, Cmd2RichArgparseConsole, generate_range_error, + register_argparse_argument_parameter, ) from .conftest import run_cmd @@ -296,6 +298,19 @@ def test_cmd2_attribute_wrapper() -> None: assert wrapper.get() == new_val +def test_register_argparse_argument_parameter() -> None: + register_argparse_argument_parameter("test") + assert "test" in argparse_custom.CUSTOM_ACTION_ATTRIBS + + expected_err = "already exists" + with pytest.raises(KeyError, match=expected_err): + register_argparse_argument_parameter("test") + + expected_err = "Invalid parameter name" + with pytest.raises(KeyError, match=expected_err): + register_argparse_argument_parameter("invalid name") + + def test_parser_attachment() -> None: # Attach a parser as a subcommand root_parser = Cmd2ArgumentParser(description="root command") From 187b8ec86154efc3510f20a921b0aabedb8704a7 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 1 Apr 2026 13:10:41 -0400 Subject: [PATCH 11/13] Correcting grammar. --- CHANGELOG.md | 2 +- cmd2/decorators.py | 4 ++-- docs/migrating/next_steps.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a8d55ae1..368a487e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,7 +64,7 @@ prompt is displayed. before calling it like the previous functions did. - Removed `Cmd.default_to_shell`. - Removed `Cmd.ruler` since `cmd2` no longer uses it. - - All parsers used with `cmd2` commands much be an instance of `Cmd2ArgumentParser` or a child + - All parsers used with `cmd2` commands must be an instance of `Cmd2ArgumentParser` or a child class of it. - Removed `set_ap_completer_type()` and `get_ap_completer_type()` since `ap_completer_type` is now a public member of `Cmd2ArgumentParser`. diff --git a/cmd2/decorators.py b/cmd2/decorators.py index de3de1198..c2c8b32c0 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -226,7 +226,7 @@ def with_argparser( ) -> Callable[[ArgparseCommandFunc[CmdOrSet]], RawCommandFuncOptionalBoolReturn[CmdOrSet]]: """Decorate a ``do_*`` method to populate its ``args`` argument with the given instance of Cmd2ArgumentParser. - :param parser: instance of Cmd2ArgumentParser or a callable that returns an Cmd2ArgumentParser for this command + :param parser: instance of Cmd2ArgumentParser or a callable that returns a Cmd2ArgumentParser for this command :param ns_provider: An optional function that accepts a cmd2.Cmd or cmd2.CommandSet object as an argument and returns an argparse.Namespace. This is useful if the Namespace needs to be prepopulated with state data that affects parsing. @@ -362,7 +362,7 @@ def as_subcommand_to( :param command: Command Name. Space-delimited subcommands may optionally be specified :param subcommand: Subcommand name - :param parser: instance of Cmd2ArgumentParser or a callable that returns an Cmd2ArgumentParser for this subcommand + :param parser: instance of Cmd2ArgumentParser or a callable that returns a Cmd2ArgumentParser for this subcommand :param help: Help message for this subcommand which displays in the list of subcommands of the command we are adding to. This is passed as the help argument to subparsers.add_parser(). :param aliases: Alternative names for this subcommand. This is passed as the alias argument to diff --git a/docs/migrating/next_steps.md b/docs/migrating/next_steps.md index d1ab399ab..886f06010 100644 --- a/docs/migrating/next_steps.md +++ b/docs/migrating/next_steps.md @@ -9,7 +9,7 @@ leveraging other `cmd2` features. The three ideas here will get you started. Bro For all but the simplest of commands, it's probably easier to use [argparse](https://docs.python.org/3/library/argparse.html) to parse user input than to do it manually yourself for each command. `cmd2` provides a `@with_argparser()` decorator which associates -an `Cmd2ArgumentParser` object with one of your commands. Using this method will: +a `Cmd2ArgumentParser` object with one of your commands. Using this method will: 1. Pass your command a [Namespace](https://docs.python.org/3/library/argparse.html#argparse.Namespace) containing the From 5ec21a5ed63e96d7aec65c39c71da3840d5c93b4 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 1 Apr 2026 14:19:21 -0400 Subject: [PATCH 12/13] Improved error handling in register_argparse_argument_parameter(). --- cmd2/argparse_custom.py | 34 ++++++++++++++++++++------- tests/test_argparse_custom.py | 43 ++++++++++++++++++++++++++++++----- 2 files changed, 63 insertions(+), 14 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index c1c678a91..5711ffb68 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -356,7 +356,9 @@ def set_parser_prog(parser: argparse.ArgumentParser, prog: str) -> None: # Allow developers to add custom action attributes ############################################################################################################ -CUSTOM_ACTION_ATTRIBS: set[str] = set() +# This set should only be edited by calling register_argparse_argument_parameter(). +# Do not manually add or remove items. +_CUSTOM_ACTION_ATTRIBS: set[str] = set() def register_argparse_argument_parameter( @@ -369,19 +371,35 @@ def register_argparse_argument_parameter( :param param_name: Name of the parameter. This must be a valid Python identifier. :param validator: Optional function to validate and/or transform the parameter value. It accepts the Action instance and the value as arguments. + :raises ValueError: if the parameter name is invalid + :raises KeyError: if the new parameter collides with any existing attributes """ if not param_name.isidentifier(): - raise KeyError(f"Invalid parameter name '{param_name}' - cannot be used as a python identifier") + raise ValueError(f"Invalid parameter name '{param_name}': must be a valid Python identifier") + if param_name in _CUSTOM_ACTION_ATTRIBS: + raise KeyError(f"Custom parameter '{param_name}' is already registered") + + # Ensure we don't hijack standard argparse.Action attributes or existing methods + if hasattr(argparse.Action, param_name): + raise KeyError(f"'{param_name}' conflicts with an existing attribute on argparse.Action") + + # Check if accessors already exist (e.g., from manual patching or previous registration) + getter_name = f'get_{param_name}' + setter_name = f'set_{param_name}' + if hasattr(argparse.Action, getter_name) or hasattr(argparse.Action, setter_name): + raise KeyError(f"Accessor methods for '{param_name}' already exist on argparse.Action") + + # Check for the prefixed internal attribute name collision (e.g., _cmd2_) attr_name = constants.cmd2_attr_name(param_name) - if param_name in CUSTOM_ACTION_ATTRIBS or hasattr(argparse.Action, attr_name): - raise KeyError(f"Custom parameter '{param_name}' already exists") + if hasattr(argparse.Action, attr_name): + raise KeyError(f"The internal attribute '{attr_name}' already exists on argparse.Action") def _action_get_custom_parameter(self: argparse.Action) -> Any: """Get the custom attribute of an argparse Action.""" return getattr(self, attr_name, None) - setattr(argparse.Action, f'get_{param_name}', _action_get_custom_parameter) + setattr(argparse.Action, getter_name, _action_get_custom_parameter) def _action_set_custom_parameter(self: argparse.Action, value: Any) -> None: """Set the custom attribute of an argparse Action.""" @@ -390,9 +408,9 @@ def _action_set_custom_parameter(self: argparse.Action, value: Any) -> None: setattr(self, attr_name, value) - setattr(argparse.Action, f'set_{param_name}', _action_set_custom_parameter) + setattr(argparse.Action, setter_name, _action_set_custom_parameter) - CUSTOM_ACTION_ATTRIBS.add(param_name) + _CUSTOM_ACTION_ATTRIBS.add(param_name) def _validate_completion_callable(self: argparse.Action, value: Any) -> Any: @@ -526,7 +544,7 @@ def _ActionsContainer_add_argument( # noqa: N802 kwargs['nargs'] = nargs_adjusted # Extract registered custom keyword arguments - custom_attribs = {keyword: value for keyword, value in kwargs.items() if keyword in CUSTOM_ACTION_ATTRIBS} + custom_attribs = {keyword: value for keyword, value in kwargs.items() if keyword in _CUSTOM_ACTION_ATTRIBS} for keyword in custom_attribs: del kwargs[keyword] diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index 61ff16b2a..95f5527c7 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -299,17 +299,48 @@ def test_cmd2_attribute_wrapper() -> None: def test_register_argparse_argument_parameter() -> None: - register_argparse_argument_parameter("test") - assert "test" in argparse_custom.CUSTOM_ACTION_ATTRIBS + # Test successful registration + param_name = "test_unique_param" + register_argparse_argument_parameter(param_name) - expected_err = "already exists" - with pytest.raises(KeyError, match=expected_err): - register_argparse_argument_parameter("test") + assert param_name in argparse_custom._CUSTOM_ACTION_ATTRIBS + assert hasattr(argparse.Action, f'get_{param_name}') + assert hasattr(argparse.Action, f'set_{param_name}') - expected_err = "Invalid parameter name" + # Test duplicate registration + expected_err = "already registered" with pytest.raises(KeyError, match=expected_err): + register_argparse_argument_parameter(param_name) + + # Test invalid identifier + expected_err = "must be a valid Python identifier" + with pytest.raises(ValueError, match=expected_err): register_argparse_argument_parameter("invalid name") + # Test collision with standard argparse.Action attribute + expected_err = "conflicts with an existing attribute on argparse.Action" + with pytest.raises(KeyError, match=expected_err): + register_argparse_argument_parameter("format_usage") + + # Test collision with existing accessor methods + try: + argparse.Action.get_colliding_param = lambda self: None + expected_err = "Accessor methods for 'colliding_param' already exist on argparse.Action" + with pytest.raises(KeyError, match=expected_err): + register_argparse_argument_parameter("colliding_param") + finally: + delattr(argparse.Action, 'get_colliding_param') + + # Test collision with internal attribute + try: + attr_name = constants.cmd2_attr_name("internal_collision") + setattr(argparse.Action, attr_name, None) + expected_err = f"The internal attribute '{attr_name}' already exists on argparse.Action" + with pytest.raises(KeyError, match=expected_err): + register_argparse_argument_parameter("internal_collision") + finally: + delattr(argparse.Action, attr_name) + def test_parser_attachment() -> None: # Attach a parser as a subcommand From 95be12bf5a82256f2d16886fd7758ebc3655298f Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 1 Apr 2026 15:29:25 -0400 Subject: [PATCH 13/13] Checking for Cmd2ArgumentParser instance in _build_parser(). --- cmd2/cmd2.py | 29 ++++++++++++++++--------- tests/test_argparse.py | 48 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 11 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index ea566e326..9181f01e1 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -896,21 +896,30 @@ def _build_parser( :param parent: object which owns the command using the parser. When parser_builder is a classmethod, this function passes parent's class to it. - :param parser_builder: means used to build the parser + :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 invalid type + :raises TypeError: if parser_builder is an invalid type or if the factory fails + to return a Cmd2ArgumentParser """ - if isinstance(parser_builder, staticmethod): - parser = parser_builder.__func__() - elif isinstance(parser_builder, classmethod): - parser = parser_builder.__func__(parent.__class__) - elif callable(parser_builder): - parser = parser_builder() - elif isinstance(parser_builder, Cmd2ArgumentParser): + if isinstance(parser_builder, Cmd2ArgumentParser): parser = copy.deepcopy(parser_builder) else: - raise TypeError(f"Invalid type for parser_builder: {type(parser_builder)}") + # Try to build the parser with a factory + if isinstance(parser_builder, staticmethod): + parser = parser_builder.__func__() + elif isinstance(parser_builder, classmethod): + parser = parser_builder.__func__(parent.__class__) + elif callable(parser_builder): + parser = parser_builder() + else: + raise TypeError(f"Invalid type for parser_builder: {type(parser_builder)}") + + # Verify the factory returned the required type + if not isinstance(parser, Cmd2ArgumentParser): + 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) diff --git a/tests/test_argparse.py b/tests/test_argparse.py index c2cfb7778..d1fed524d 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -247,10 +247,56 @@ def test_preservelist(argparse_app) -> None: def test_invalid_parser_builder(argparse_app): parser_builder = None - with pytest.raises(TypeError): + with pytest.raises(TypeError, match="Invalid type for parser_builder"): argparse_app._build_parser(argparse_app, parser_builder, "fake_prog") +def test_invalid_parser_return_type(argparse_app): + def bad_builder(): + return argparse.ArgumentParser() + + with pytest.raises(TypeError, match="must be a Cmd2ArgumentParser or a subclass of it"): + argparse_app._build_parser(argparse_app, bad_builder, "fake_prog") + + +def test_invalid_parser_return_type_staticmethod(argparse_app): + def bad_builder(): + return argparse.ArgumentParser() + + sm = staticmethod(bad_builder) + + with pytest.raises(TypeError, match="must be a Cmd2ArgumentParser or a subclass of it"): + argparse_app._build_parser(argparse_app, sm, "fake_prog") + + +def test_invalid_parser_return_type_classmethod(argparse_app): + def bad_builder(cls): + return argparse.ArgumentParser() + + cm = classmethod(bad_builder) + + with pytest.raises(TypeError, match="must be a Cmd2ArgumentParser or a subclass of it"): + argparse_app._build_parser(argparse_app, cm, "fake_prog") + + +def test_invalid_parser_return_type_nameless_object(argparse_app): + # A class that is callable but has no __name__ attribute + class NamelessBuilder: + def __call__(self): + return argparse.ArgumentParser() + + builder = NamelessBuilder() + + # Verify __name__ is actually missing + assert not hasattr(builder, '__name__') + + # The error message should now contain the string representation of the object + expected_msg = f"The parser returned by '{builder}' must be a Cmd2ArgumentParser" + + with pytest.raises(TypeError, match=expected_msg): + argparse_app._build_parser(argparse_app, builder, "fake_prog") + + def _build_has_subcmd_parser() -> cmd2.Cmd2ArgumentParser: has_subcmds_parser = cmd2.Cmd2ArgumentParser(description="Tests as_subcmd_to decorator") has_subcmds_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND', required=True)