From b5c96186b625f207d73541f44d6dfbc4463d6c79 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 27 May 2026 21:54:05 -0400 Subject: [PATCH 1/5] Standardized command/subcommand naming and metadata - Consistently use full words COMMAND and SUBCOMMAND over abbreviations - Rename cmd2_subcmd_handler to cmd2_subcommand_func for consistency - Consolidate command metadata into ApCommandSpec dataclass - Simplify subcommand examples by using required=True for subparsers --- CHANGELOG.md | 4 +- cmd2/argparse_utils.py | 32 +++++++++++-- cmd2/cmd2.py | 59 ++++++++++++++---------- cmd2/constants.py | 17 +++---- cmd2/decorators.py | 21 +++++---- cmd2/utils.py | 6 +-- docs/features/argument_processing.md | 2 +- docs/features/modular_commands.md | 13 ++---- examples/argparse_example.py | 2 +- examples/command_sets.py | 15 +++---- tests/test_argparse.py | 2 +- tests/test_argparse_completer.py | 2 +- tests/test_commandset.py | 67 ++++++++-------------------- 13 files changed, 121 insertions(+), 121 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7ad8dce8..0ec9118bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,9 +70,9 @@ prompt is displayed. - Removed `set_ap_completer_type()` and `get_ap_completer_type()` since `ap_completer_type` is now a public member of `Cmd2ArgumentParser`. - Moved `set_parser_prog()` to `Cmd2ArgumentParser.update_prog()`. - - Renamed `cmd2_handler` to `cmd2_subcmd_handler` in the `argparse.Namespace` for clarity. + - Renamed `cmd2_handler` to `cmd2_subcommand_func` in the `argparse.Namespace` for clarity. - Removed `Cmd2AttributeWrapper` class. `argparse.Namespace` objects passed to command functions - now contain direct attributes for `cmd2_statement` and `cmd2_subcmd_handler`. + now contain direct attributes for `cmd2_statement` and `cmd2_subcommand_func`. - Renamed `cmd2/command_definition.py` to `cmd2/command_set.py`. - Removed `Cmd.doc_header` and the `with_default_category` decorator. Help categorization is now driven by the `DEFAULT_CATEGORY` class variable (see **Simplified command categorization** in diff --git a/cmd2/argparse_utils.py b/cmd2/argparse_utils.py index 685a918b8..c2a4a0cb1 100644 --- a/cmd2/argparse_utils.py +++ b/cmd2/argparse_utils.py @@ -291,12 +291,32 @@ def get_choices(self) -> Choices: ] +@dataclass(kw_only=True) +class ApCommandSpec: + """Metadata for an argparse-based command function. + + :param parser_source: an existing Cmd2ArgumentParser instance or a factory + (callable, staticmethod, or classmethod) that returns one. + :param preserve_quotes: if True, then arguments passed to argparse maintain their quotes + """ + + parser_source: ParserSource[Any] + preserve_quotes: bool = False + + @dataclass(kw_only=True) class _SubcommandBase: - """Base metadata shared by all subcommand representations.""" + """Base metadata shared by all subcommand representations. + + :param name: the name of the subcommand + :param command: the full parent command path (e.g., 'foo bar') + :param help: optional help message for this subcommand + :param aliases: optional alternative names for this subcommand + :param deprecated: whether this subcommand is deprecated (requires Python 3.13+). + """ name: str - command: str # The full parent command path (e.g., 'foo bar') + command: str help: str | None = None aliases: tuple[str, ...] = () deprecated: bool = False @@ -304,7 +324,11 @@ class _SubcommandBase: @dataclass(kw_only=True) class SubcommandSpec(_SubcommandBase): - """Metadata used to build and register a subcommand.""" + """Metadata used to build and register a subcommand. + + :param parser_source: an existing Cmd2ArgumentParser instance or a factory + (callable, staticmethod, or classmethod) that returns one. + """ parser_source: ParserSource[Any] @@ -314,6 +338,8 @@ class SubcommandRecord(_SubcommandBase): """A record of a subcommand's configuration and parser. Used primarily for attaching and detaching subcommands. + + :param parser: the built Cmd2ArgumentParser instance for this subcommand """ parser: "Cmd2ArgumentParser" diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 53697bcbb..a6af3cd40 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -111,6 +111,7 @@ from . import rich_utils as ru from . import string_utils as su from .argparse_utils import ( + ApCommandSpec, Cmd2ArgumentParser, ParserSource, SubcommandRecord, @@ -280,12 +281,12 @@ def get(self, command_method: BoundCommandFunc) -> Cmd2ArgumentParser | None: return None command = command_method.__name__[len(COMMAND_FUNC_PREFIX) :] - parser_source = getattr(command_method, constants.CMD_ATTR_PARSER_SOURCE, None) - if parser_source is None: + spec: ApCommandSpec | None = getattr(command_method, constants.AP_COMMAND_ATTR_SPEC, None) + if spec is None: return None owner = self._cmd_app.find_commandset_for_command(command) or self._cmd_app - parser = self._cmd_app._build_parser(owner, parser_source) + parser = self._cmd_app._build_parser(owner, spec.parser_source) # To ensure accurate usage strings, recursively update 'prog' values # within the parser to match the command name. @@ -1063,9 +1064,21 @@ def unregister_command_set(self, cmdset: CommandSet[Any]) -> None: self._installed_command_sets.remove(cmdset) def _check_uninstallable(self, cmdset: CommandSet[Any]) -> None: - cmdset_id = id(cmdset) + """Verify if a CommandSet can be safely uninstalled from the application. + + This method acts as a safety guard before unregistration. It inspects all + command parsers provided by the CommandSet and recursively checks their + subcommand hierarchies to ensure no other registrant (another CommandSet + or the main application) has attached subcommands to them. + + :param cmdset: the CommandSet instance to check for uninstallation safety + :raises CommandSetRegistrationError: if any parser in the CommandSet is + required by another registrant + """ + registrant_id = id(cmdset) def check_parser_uninstallable(parser: Cmd2ArgumentParser) -> None: + # Recursively verify no subcommands belong to a different registrant try: subparsers_action = parser.get_subparsers_action() except ValueError: @@ -1080,10 +1093,10 @@ def check_parser_uninstallable(parser: Cmd2ArgumentParser) -> None: continue checked_parsers.add(subparser) - attached_cmdset_id = getattr(subparser, constants.PARSER_ATTR_OWNER_ID, None) - if attached_cmdset_id is not None and attached_cmdset_id != cmdset_id: + attached_registrant_id = getattr(subparser, constants.PARSER_ATTR_REGISTRANT_ID, None) + if attached_registrant_id is not None and attached_registrant_id != registrant_id: raise CommandSetRegistrationError( - f"Cannot uninstall CommandSet: '{subparser.prog}' is required by another CommandSet" + f"Cannot uninstall CommandSet: '{subparser.prog}' is required by another registrant" ) check_parser_uninstallable(subparser) @@ -1117,13 +1130,13 @@ def _register_subcommands(self, owner: CmdOrSet) -> None: owner, predicate=lambda meth: ( isinstance(meth, Callable) # type: ignore[arg-type] - and hasattr(meth, constants.SUBCMD_ATTR_SPEC) + and hasattr(meth, constants.SUBCOMMAND_ATTR_SPEC) ), ) # iterate through all matching methods for _method_name, method in methods: - spec: SubcommandSpec = getattr(method, constants.SUBCMD_ATTR_SPEC) + spec: SubcommandSpec = getattr(method, constants.SUBCOMMAND_ATTR_SPEC) subcommand_valid, errmsg = self.statement_parser.is_valid_command(spec.name, is_subcommand=True) if not subcommand_valid: @@ -1134,12 +1147,12 @@ def _register_subcommands(self, owner: CmdOrSet) -> None: if subcmd_parser.description is None and method.__doc__: subcmd_parser.description = strip_doc_annotations(method.__doc__) - # Set the subcommand handler - defaults = {constants.NS_ATTR_SUBCMD_HANDLER: method} + # Set the subcommand function + defaults = {constants.NS_ATTR_SUBCOMMAND_FUNC: method} subcmd_parser.set_defaults(**defaults) - # Set what instance the handler is bound to - setattr(subcmd_parser, constants.PARSER_ATTR_OWNER_ID, id(owner)) + # Record the ID of the instance that registered this subcommand parser + setattr(subcmd_parser, constants.PARSER_ATTR_REGISTRANT_ID, id(owner)) # Attach this subcommand record = SubcommandRecord( @@ -1169,13 +1182,13 @@ def _unregister_subcommands(self, owner: CmdOrSet) -> None: owner, predicate=lambda meth: ( isinstance(meth, Callable) # type: ignore[arg-type] - and hasattr(meth, constants.SUBCMD_ATTR_SPEC) + and hasattr(meth, constants.SUBCOMMAND_ATTR_SPEC) ), ) # iterate through all matching methods for _method_name, method in methods: - spec: SubcommandSpec = getattr(method, constants.SUBCMD_ATTR_SPEC) + spec: SubcommandSpec = getattr(method, constants.SUBCOMMAND_ATTR_SPEC) with contextlib.suppress(ValueError): self.detach_subcommand(spec.command, spec.name) @@ -2517,7 +2530,7 @@ def _perform_completion( if command_func is not None and argparser is not None: # Get arguments for complete() - preserve_quotes = getattr(command_func, constants.CMD_ATTR_PRESERVE_QUOTES) + spec: ApCommandSpec = getattr(command_func, constants.AP_COMMAND_ATTR_SPEC) cmd_set = self.find_commandset_for_command(command) # Create the argparse completer @@ -2525,7 +2538,7 @@ def _perform_completion( completer = completer_type(argparser, self) completer_func = functools.partial( - completer.complete, tokens=raw_tokens[1:] if preserve_quotes else tokens[1:], cmd_set=cmd_set + completer.complete, tokens=raw_tokens[1:] if spec.preserve_quotes else tokens[1:], cmd_set=cmd_set ) else: completer_func = self.completedefault # type: ignore[assignment] @@ -3380,8 +3393,8 @@ def _get_command_category(self, func: BoundCommandFunc) -> str: :return: category name """ # Check if the command function has a category. - if hasattr(func, constants.CMD_ATTR_HELP_CATEGORY): - category: str = getattr(func, constants.CMD_ATTR_HELP_CATEGORY) + if hasattr(func, constants.COMMAND_ATTR_HELP_CATEGORY): + category: str = getattr(func, constants.COMMAND_ATTR_HELP_CATEGORY) # Otherwise get the category from its defining class. else: @@ -3784,8 +3797,8 @@ def _build_alias_parser() -> Cmd2ArgumentParser: @with_argparser(_build_alias_parser, preserve_quotes=True) def do_alias(self, args: argparse.Namespace) -> None: """Manage aliases.""" - # Call handler for whatever subcommand was selected - args.cmd2_subcmd_handler(args) + # Call function for whatever subcommand was selected + args.cmd2_subcommand_func(args) # alias -> create @classmethod @@ -3998,8 +4011,8 @@ def _build_macro_parser() -> Cmd2ArgumentParser: @with_argparser(_build_macro_parser, preserve_quotes=True) def do_macro(self, args: argparse.Namespace) -> None: """Manage macros.""" - # Call handler for whatever subcommand was selected - args.cmd2_subcmd_handler(args) + # Call function for whatever subcommand was selected + args.cmd2_subcommand_func(args) # macro -> create @classmethod diff --git a/cmd2/constants.py b/cmd2/constants.py index 71a222144..335da9eaa 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -69,20 +69,17 @@ def cmd2_public_attr_name(name: str) -> str: # --- Private Internal Attributes --- -# Attached to a command function; defines the source from which its parser is built -CMD_ATTR_PARSER_SOURCE = cmd2_private_attr_name("parser_source") - # Attached to a command function; defines its help section category -CMD_ATTR_HELP_CATEGORY = cmd2_private_attr_name("help_category") +COMMAND_ATTR_HELP_CATEGORY = cmd2_private_attr_name("help_category") -# Attached to a command function; defines whether tokens are unquoted before reaching argparse -CMD_ATTR_PRESERVE_QUOTES = cmd2_private_attr_name("preserve_quotes") +# Attached to an argparse-based command function; defines its ApCommandSpec instance +AP_COMMAND_ATTR_SPEC = cmd2_private_attr_name("ap_command_spec") # Attached to a subcommand function; defines its SubcommandSpec instance -SUBCMD_ATTR_SPEC = cmd2_private_attr_name("subcommand_spec") +SUBCOMMAND_ATTR_SPEC = cmd2_private_attr_name("subcommand_spec") -# Attached to an argparse parser; identifies the Cmd or CommandSet instance it belongs to -PARSER_ATTR_OWNER_ID = cmd2_private_attr_name("owner_id") +# Attached to an argparse parser; stores the id() of the Cmd or CommandSet instance that registered it +PARSER_ATTR_REGISTRANT_ID = cmd2_private_attr_name("registrant_id") # --- Public Developer Attributes --- @@ -91,4 +88,4 @@ def cmd2_public_attr_name(name: str) -> str: NS_ATTR_STATEMENT = cmd2_public_attr_name("statement") # Attached to an argparse Namespace; the function to handle the subcommand (or None) -NS_ATTR_SUBCMD_HANDLER = cmd2_public_attr_name("subcmd_handler") +NS_ATTR_SUBCOMMAND_FUNC = cmd2_public_attr_name("subcommand_func") diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 153f9c116..b43c48b3e 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -16,6 +16,7 @@ from . import constants from .argparse_utils import ( + ApCommandSpec, ClassParamParserFactory, Cmd2ArgumentParser, NoParamParserFactory, @@ -354,18 +355,20 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: # Include the Statement object created from the command line setattr(parsed_namespace, constants.NS_ATTR_STATEMENT, statement) - # Ensure NS_ATTR_SUBCMD_HANDLER is always present. - if not hasattr(parsed_namespace, constants.NS_ATTR_SUBCMD_HANDLER): - setattr(parsed_namespace, constants.NS_ATTR_SUBCMD_HANDLER, None) + # Ensure subcommand function attribute is always present. + if not hasattr(parsed_namespace, constants.NS_ATTR_SUBCOMMAND_FUNC): + setattr(parsed_namespace, constants.NS_ATTR_SUBCOMMAND_FUNC, None) func_arg_list = _arg_swap(args, statement_arg, *parsing_results) return func(*func_arg_list, **kwargs) command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX) :] - # Set some custom attributes for this command - setattr(cmd_wrapper, constants.CMD_ATTR_PARSER_SOURCE, parser_source) - setattr(cmd_wrapper, constants.CMD_ATTR_PRESERVE_QUOTES, preserve_quotes) + spec = ApCommandSpec( + parser_source=parser_source, + preserve_quotes=preserve_quotes, + ) + setattr(cmd_wrapper, constants.AP_COMMAND_ATTR_SPEC, spec) return cmd_wrapper @@ -450,10 +453,10 @@ def as_subcommand_to( class MyApp(cmd2.Cmd): @cmd2.with_argparser(base_parser) def do_base(self, args: argparse.Namespace) -> None: - args.cmd2_subcmd_handler(args) + args.cmd2_subcommand_func(args) @cmd2.as_subcommand_to('base', 'sub', sub_parser, help="the subcommand") - def sub_handler(self, args: argparse.Namespace) -> None: + def sub_func(self, args: argparse.Namespace) -> None: self.poutput('Subcommand executed') ``` @@ -468,7 +471,7 @@ def arg_decorator(func: F) -> F: deprecated=deprecated, parser_source=parser_source, ) - setattr(func, constants.SUBCMD_ATTR_SPEC, spec) + setattr(func, constants.SUBCOMMAND_ATTR_SPEC, spec) return func return arg_decorator diff --git a/cmd2/utils.py b/cmd2/utils.py index c81aed158..cb8b2e7c4 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -712,11 +712,11 @@ def do_echo(self, arglist): """ if isinstance(func, Iterable): for item in func: - setattr(item, constants.CMD_ATTR_HELP_CATEGORY, category) + setattr(item, constants.COMMAND_ATTR_HELP_CATEGORY, category) elif inspect.ismethod(func): - setattr(func.__func__, constants.CMD_ATTR_HELP_CATEGORY, category) + setattr(func.__func__, constants.COMMAND_ATTR_HELP_CATEGORY, category) else: - setattr(func, constants.CMD_ATTR_HELP_CATEGORY, category) + setattr(func, constants.COMMAND_ATTR_HELP_CATEGORY, category) def get_defining_class(meth: Callable[..., Any]) -> type[Any] | None: diff --git a/docs/features/argument_processing.md b/docs/features/argument_processing.md index a0a577380..9b822e467 100644 --- a/docs/features/argument_processing.md +++ b/docs/features/argument_processing.md @@ -400,4 +400,4 @@ example demonstrates both above cases in a concrete fashion. naming collisions, do not use any of these names for your argparse arguments. - `cmd2_statement` - [cmd2.Statement][] object that was created when parsing the command line. -- `cmd2_subcmd_handler` - subcommand handler function or `None` if one was not set. +- `cmd2_subcommand_func` - subcommand handler function or `None` if one was not set. diff --git a/docs/features/modular_commands.md b/docs/features/modular_commands.md index 2380f4ec6..dbd9c5d86 100644 --- a/docs/features/modular_commands.md +++ b/docs/features/modular_commands.md @@ -378,19 +378,14 @@ class ExampleApp(cmd2.Cmd): self.poutput('Vegetables unloaded') cut_parser = cmd2.Cmd2ArgumentParser() - cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut') + cut_parser.add_subparsers(title="item", help="item to cut", metavar="ITEM", required=True) @with_argparser(cut_parser) def do_cut(self, ns: argparse.Namespace): """Cut Command.""" - handler = ns.cmd2_subcmd_handler - if handler is not None: - # Call whatever subcommand function was selected - handler(ns) - else: - # No subcommand was provided, so call help - self.poutput('This command does nothing without sub-parsers registered') - self.do_help('cut') + # Call whatever subcommand function was selected + ns.cmd2_subcommand_func(ns) + if __name__ == '__main__': diff --git a/examples/argparse_example.py b/examples/argparse_example.py index 40c9c66d9..1b12f321d 100755 --- a/examples/argparse_example.py +++ b/examples/argparse_example.py @@ -139,7 +139,7 @@ def subtract(self, args: argparse.Namespace) -> None: @cmd2.with_category(ARGPARSE_SUBCOMMANDS) def do_calculate(self, args: argparse.Namespace) -> None: """Calculate a simple mathematical operation on two integers.""" - args.cmd2_subcmd_handler(args) + args.cmd2_subcommand_func(args) if __name__ == "__main__": diff --git a/examples/command_sets.py b/examples/command_sets.py index f8dacf270..a77d361c1 100755 --- a/examples/command_sets.py +++ b/examples/command_sets.py @@ -18,6 +18,7 @@ import cmd2 from cmd2 import ( CommandSet, + CommandSetRegistrationError, with_argparser, with_category, ) @@ -121,14 +122,14 @@ def do_load(self, ns: argparse.Namespace) -> None: try: self.register_command_set(self._fruits) self.poutput("Fruits loaded") - except ValueError: + except CommandSetRegistrationError: self.poutput("Fruits already loaded") if ns.cmds == "vegetables": try: self.register_command_set(self._vegetables) self.poutput("Vegetables loaded") - except ValueError: + except CommandSetRegistrationError: self.poutput("Vegetables already loaded") @with_argparser(load_parser) @@ -144,19 +145,13 @@ def do_unload(self, ns: argparse.Namespace) -> None: self.poutput("Vegetables unloaded") cut_parser = cmd2.Cmd2ArgumentParser() - cut_subparsers = cut_parser.add_subparsers(title="item", help="item to cut") + cut_parser.add_subparsers(title="item", help="item to cut", metavar="ITEM", required=True) @with_argparser(cut_parser) @with_category(COMMANDSET_SUBCOMMAND) def do_cut(self, ns: argparse.Namespace) -> None: """Intended to be used with dynamically loaded subcommands specifically.""" - handler = ns.cmd2_subcmd_handler - if handler is not None: - handler(ns) - else: - # No subcommand was provided, so call help - self.poutput("This command does nothing without sub-parsers registered") - self.do_help("cut") + ns.cmd2_subcommand_func(ns) if __name__ == "__main__": diff --git a/tests/test_argparse.py b/tests/test_argparse.py index eb062af6d..4135fd015 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -358,7 +358,7 @@ def do_base(self, args) -> None: # Add subcommands using as_subcommand_to decorator @cmd2.with_argparser(_build_has_subcmd_parser) def do_test_subcmd_decorator(self, args: argparse.Namespace) -> None: - args.cmd2_subcmd_handler(args) + args.cmd2_subcommand_func(args) subcmd_parser = cmd2.Cmd2ArgumentParser(description="A subcommand") diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 6e42ad4c3..9577754a1 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -1314,7 +1314,7 @@ def do_custom_completer(self, args: argparse.Namespace) -> None: def do_top(self, args: argparse.Namespace) -> None: """Top level command""" # Call handler for whatever subcommand was selected - args.cmd2_subcmd_handler(args) + args.cmd2_subcommand_func(args) # Parser for a subcommand with no custom completer type no_custom_completer_parser = Cmd2ArgumentParser(description="No custom completer") diff --git a/tests/test_commandset.py b/tests/test_commandset.py index 66b1d1885..4d296400c 100644 --- a/tests/test_commandset.py +++ b/tests/test_commandset.py @@ -95,8 +95,8 @@ def do_elderberry(self, ns: argparse.Namespace) -> None: @cmd2.with_category("Alone") @cmd2.with_argparser(main_parser) def do_main(self, args: argparse.Namespace) -> None: - # Call handler for whatever subcommand was selected - args.cmd2_subcmd_handler(args) + # Call function for whatever subcommand was selected + args.cmd2_subcommand_func(args) # main -> sub subcmd_parser = cmd2.Cmd2ArgumentParser(description="Sub Command") @@ -394,7 +394,7 @@ def __init__(self, dummy) -> None: self._cut_called = False cut_parser = cmd2.Cmd2ArgumentParser() - cut_subparsers = cut_parser.add_subparsers(title="item", help="item to cut") + cut_parser.add_subparsers(title="item", help="item to cut", metavar="ITEM", required=True) def namespace_provider(self) -> argparse.Namespace: ns = argparse.Namespace() @@ -404,18 +404,12 @@ def namespace_provider(self) -> argparse.Namespace: @cmd2.with_argparser(cut_parser) def do_cut(self, ns: argparse.Namespace) -> None: """Cut something""" - handler = ns.cmd2_subcmd_handler - if handler is not None: - # Call whatever subcommand function was selected - handler(ns) - self._cut_called = True - else: - # No subcommand was provided, so call help - self._cmd.pwarning("This command does nothing without sub-parsers registered") - self._cmd.do_help("cut") + # Call whatever subcommand function was selected + ns.cmd2_subcommand_func(ns) + self._cut_called = True stir_parser = cmd2.Cmd2ArgumentParser() - stir_subparsers = stir_parser.add_subparsers(title="item", help="what to stir") + stir_subparsers = stir_parser.add_subparsers(title="item", help="what to stir", metavar="ITEM", required=True) @cmd2.with_argparser(stir_parser, ns_provider=namespace_provider) def do_stir(self, ns: argparse.Namespace) -> None: @@ -424,27 +418,17 @@ def do_stir(self, ns: argparse.Namespace) -> None: self._cmd.poutput("Need to cut before stirring") return - handler = ns.cmd2_subcmd_handler - if handler is not None: - # Call whatever subcommand function was selected - handler(ns) - else: - # No subcommand was provided, so call help - self._cmd.pwarning("This command does nothing without sub-parsers registered") - self._cmd.do_help("stir") + # Call whatever subcommand function was selected + ns.cmd2_subcommand_func(ns) stir_pasta_parser = cmd2.Cmd2ArgumentParser() stir_pasta_parser.add_argument("--option", "-o") - stir_pasta_parser.add_subparsers(title="style", help="Stir style") + stir_pasta_parser.add_subparsers(title="style", help="Stir style", required=True) @cmd2.as_subcommand_to("stir", "pasta", stir_pasta_parser) def stir_pasta(self, ns: argparse.Namespace) -> None: - handler = ns.cmd2_subcmd_handler - if handler is not None: - # Call whatever subcommand function was selected - handler(ns) - else: - self._cmd.poutput("Stir pasta haphazardly") + # Call whatever subcommand function was selected + ns.cmd2_subcommand_func(ns) class LoadableBadBase(cmd2.CommandSet): @@ -452,16 +436,9 @@ def __init__(self, dummy) -> None: super().__init__() self._dummy = dummy # prevents autoload - def do_cut(self, ns: argparse.Namespace) -> None: + # Create function which fails to decorate as an argparse base command. + def do_cut(self, _: cmd2.Statement) -> None: """Cut something""" - handler = ns.cmd2_subcmd_handler - if handler is not None: - # Call whatever subcommand function was selected - handler(ns) - else: - # No subcommand was provided, so call help - self._cmd.poutput("This command does nothing without sub-parsers registered") - self._cmd.do_help("cut") class LoadableFruits(cmd2.CommandSet): @@ -548,7 +525,7 @@ def test_subcommands(manual_command_sets_app) -> None: manual_command_sets_app._register_subcommands(fruit_cmds) cmd_result = manual_command_sets_app.app_cmd("cut") - assert "This command does nothing without sub-parsers registered" in cmd_result.stderr + assert "Error: the following arguments are required" in cmd_result.stderr # verify that command set install without problems manual_command_sets_app.register_command_set(fruit_cmds) @@ -722,19 +699,13 @@ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) cut_parser = cmd2.Cmd2ArgumentParser() - cut_subparsers = cut_parser.add_subparsers(title="item", help="item to cut") + cut_parser.add_subparsers(title="item", help="item to cut", metavar="ITEM", required=True) @cmd2.with_argparser(cut_parser) def do_cut(self, ns: argparse.Namespace) -> None: """Cut something""" - handler = ns.cmd2_subcmd_handler - if handler is not None: - # Call whatever subcommand function was selected - handler(ns) - else: - # No subcommand was provided, so call help - self.poutput("This command does nothing without sub-parsers registered") - self.do_help("cut") + # Call whatever subcommand function was selected + ns.cmd2_subcommand_func(ns) banana_parser = cmd2.Cmd2ArgumentParser() banana_parser.add_argument("direction", choices=["discs", "lengthwise"]) @@ -1001,7 +972,7 @@ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) cut_parser = cmd2.Cmd2ArgumentParser() - cut_subparsers = cut_parser.add_subparsers(title="item", help="item to cut") + cut_parser.add_subparsers(title="item", help="item to cut", metavar="ITEM", required=True) @cmd2.with_argparser(cut_parser) def do_cut(self, ns: argparse.Namespace) -> None: From 44fd9e86dea50d97e0914266bb0748d4a15e4d9b Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 27 May 2026 22:40:55 -0400 Subject: [PATCH 2/5] Added test coverage. --- tests/test_utils.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/test_utils.py b/tests/test_utils.py index 5083bf47f..c1f1e32eb 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -396,3 +396,37 @@ def bar(self, x: bool) -> None: param_name, param_value = next(iter(param_ann.items())) assert param_name == "x" assert param_value is bool + + +def test_categorize() -> None: + from cmd2 import constants + + category = "Test Category" + attr_name = constants.COMMAND_ATTR_HELP_CATEGORY + + # Test single function + def func1() -> None: + pass + + cu.categorize(func1, category) + assert getattr(func1, attr_name) == category + + # Test iterable of functions + def func2() -> None: + pass + + def func3() -> None: + pass + + cu.categorize([func2, func3], category) + assert getattr(func2, attr_name) == category + assert getattr(func3, attr_name) == category + + # Test bound method + class Foo: + def bar(self) -> None: + pass + + f = Foo() + cu.categorize(f.bar, category) + assert getattr(Foo.bar, attr_name) == category From 34d5f3d2a8f9cd7e79c03c11db85db0fb652c6b4 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 27 May 2026 23:08:58 -0400 Subject: [PATCH 3/5] Fixed error in categorize() which would not handle a bound method in an Iterable. --- cmd2/utils.py | 16 ++++++++-------- tests/test_utils.py | 30 ++++++++++++++++-------------- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/cmd2/utils.py b/cmd2/utils.py index cb8b2e7c4..250f353f9 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -694,7 +694,7 @@ def categorize(func: Callable[..., Any] | Iterable[Callable[..., Any]], category The help command output will group the passed function under the specified category heading - :param func: function or list of functions to categorize + :param func: function or Iterable of functions to categorize :param category: category to put it in Example: @@ -710,13 +710,13 @@ def do_echo(self, arglist): For an alternative approach to categorizing commands using a decorator, see [cmd2.decorators.with_category][] """ - if isinstance(func, Iterable): - for item in func: - setattr(item, constants.COMMAND_ATTR_HELP_CATEGORY, category) - elif inspect.ismethod(func): - setattr(func.__func__, constants.COMMAND_ATTR_HELP_CATEGORY, category) - else: - setattr(func, constants.COMMAND_ATTR_HELP_CATEGORY, category) + funcs = func if isinstance(func, Iterable) else (func,) + + for cur_func in funcs: + if inspect.ismethod(cur_func): + setattr(cur_func.__func__, constants.COMMAND_ATTR_HELP_CATEGORY, category) + else: + setattr(cur_func, constants.COMMAND_ATTR_HELP_CATEGORY, category) def get_defining_class(meth: Callable[..., Any]) -> type[Any] | None: diff --git a/tests/test_utils.py b/tests/test_utils.py index c1f1e32eb..4dc8ce99d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -411,22 +411,24 @@ def func1() -> None: cu.categorize(func1, category) assert getattr(func1, attr_name) == category - # Test iterable of functions - def func2() -> None: - pass + # Test single method + class Foo: + def foo_method(self) -> None: + pass - def func3() -> None: - pass + f = Foo() + cu.categorize(f.foo_method, category) + assert getattr(Foo.foo_method, attr_name) == category - cu.categorize([func2, func3], category) - assert getattr(func2, attr_name) == category - assert getattr(func3, attr_name) == category + # Test iterable + def func2() -> None: + pass - # Test bound method - class Foo: - def bar(self) -> None: + class Bar: + def bar_method(self) -> None: pass - f = Foo() - cu.categorize(f.bar, category) - assert getattr(Foo.bar, attr_name) == category + b = Bar() + cu.categorize([func2, b.bar_method], category) + assert getattr(func2, attr_name) == category + assert getattr(Bar.bar_method, attr_name) == category From 88f2e3951ee58ac72f16fe714885a7d303eb4edd Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 3 Jun 2026 08:58:16 -0400 Subject: [PATCH 4/5] Fixed tests. --- cmd2/annotated.py | 42 +++++++++++++++++++++++++++++------------ tests/test_annotated.py | 34 ++++++++++++++++----------------- 2 files changed, 46 insertions(+), 30 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index 268a71f09..82e1d1e93 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -181,8 +181,16 @@ def do_paint( import functools import inspect import types -from collections.abc import Callable, Container, Iterable, Sequence -from dataclasses import dataclass, field +from collections.abc import ( + Callable, + Container, + Iterable, + Sequence, +) +from dataclasses import ( + dataclass, + field, +) from pathlib import Path from typing import ( TYPE_CHECKING, @@ -203,12 +211,21 @@ def do_paint( from rich.table import Column from . import constants -from .argparse_utils import DEFAULT_ARGUMENT_PARSER, Cmd2ArgumentParser, SubcommandSpec +from .argparse_utils import ( + DEFAULT_ARGUMENT_PARSER, + ApCommandSpec, + Cmd2ArgumentParser, + SubcommandSpec, +) from .completion import CompletionItem from .decorators import _parse_positionals from .exceptions import Cmd2ArgparseError from .rich_utils import Cmd2HelpFormatter, HelpContent -from .types import CmdOrSetT, UnboundChoicesProvider, UnboundCompleter +from .types import ( + CmdOrSetT, + UnboundChoicesProvider, + UnboundCompleter, +) if TYPE_CHECKING: from .argparse_completer import ArgparseCompleter @@ -1828,13 +1845,11 @@ def _filtered_namespace_kwargs( exclude_subcommand: bool = False, ) -> dict[str, Any]: """Filter a parsed Namespace down to user-visible kwargs.""" - from .constants import NS_ATTR_SUBCMD_HANDLER - filtered: dict[str, Any] = {} for key, value in vars(ns).items(): if accepted is not None and key not in accepted: continue - if key == NS_ATTR_SUBCMD_HANDLER: + if key == constants.NS_ATTR_SUBCOMMAND_FUNC: continue if exclude_subcommand and key == "subcommand": continue @@ -2244,7 +2259,7 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: base_command=base_command, options=options, ) - spec = SubcommandSpec( + subcommand_spec = SubcommandSpec( name=subcmd_name, command=subcommand_to, help=help, @@ -2252,7 +2267,7 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: deprecated=deprecated, parser_source=subcmd_parser_builder, ) - setattr(handler, constants.SUBCMD_ATTR_SPEC, spec) + setattr(handler, constants.SUBCOMMAND_ATTR_SPEC, subcommand_spec) return handler command_name = fn.__name__[len(constants.COMMAND_FUNC_PREFIX) :] @@ -2296,7 +2311,7 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: raise Cmd2ArgparseError from exc setattr(ns, constants.NS_ATTR_STATEMENT, statement) - handler = getattr(ns, constants.NS_ATTR_SUBCMD_HANDLER, None) + handler = getattr(ns, constants.NS_ATTR_SUBCOMMAND_FUNC, None) if base_command and handler is not None: handler = functools.partial(handler, ns) ns.cmd2_handler = handler @@ -2312,8 +2327,11 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: ) return result - setattr(cmd_wrapper, constants.CMD_ATTR_PARSER_SOURCE, parser_builder) - setattr(cmd_wrapper, constants.CMD_ATTR_PRESERVE_QUOTES, preserve_quotes) + ap_command_spec = ApCommandSpec( + parser_source=parser_builder, + preserve_quotes=preserve_quotes, + ) + setattr(cmd_wrapper, constants.AP_COMMAND_ATTR_SPEC, ap_command_spec) return cmd_wrapper diff --git a/tests/test_annotated.py b/tests/test_annotated.py index 449d0e1ef..41b2c1ff4 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -23,9 +23,7 @@ import pytest import cmd2 -from cmd2 import ( - CompletionItem, -) +from cmd2 import CompletionItem from cmd2.annotated import ( Argument, Group, @@ -1121,7 +1119,7 @@ class MyCompleter(ArgparseCompleter): @with_annotated(ap_completer_type=MyCompleter) def do_run(self, name: str) -> None: ... - builder = getattr(do_run, constants.CMD_ATTR_PARSER_SOURCE) + builder = getattr(do_run, constants.AP_COMMAND_ATTR_SPEC).parser_source assert builder().ap_completer_type is MyCompleter def test_ap_completer_type_threads_to_subcommand(self) -> None: @@ -1134,7 +1132,7 @@ class MyCompleter(ArgparseCompleter): @with_annotated(subcommand_to="team", ap_completer_type=MyCompleter) def team_create(self, name: str) -> None: ... - spec = getattr(team_create, constants.SUBCMD_ATTR_SPEC) + spec = getattr(team_create, constants.SUBCOMMAND_ATTR_SPEC) assert spec.parser_source().ap_completer_type is MyCompleter def test_customization_via_decorator(self) -> None: @@ -1178,7 +1176,7 @@ def team_add(self, name: str) -> None: from cmd2 import constants - spec = getattr(App.team_add, constants.SUBCMD_ATTR_SPEC) + spec = getattr(App.team_add, constants.SUBCOMMAND_ATTR_SPEC) subparser = spec.parser_source() assert subparser.description == "add desc" assert subparser.epilog == "add epilog" @@ -1678,12 +1676,12 @@ def test_int_subclass_uses_int_converter(self) -> None: class TestFilteredNamespaceKwargs: def test_excludes_subcmd_handler_key(self) -> None: + from cmd2 import constants from cmd2.annotated import _filtered_namespace_kwargs - from cmd2.constants import NS_ATTR_SUBCMD_HANDLER - ns = argparse.Namespace(**{NS_ATTR_SUBCMD_HANDLER: lambda: None, "name": "Alice"}) + ns = argparse.Namespace(**{constants.NS_ATTR_SUBCOMMAND_FUNC: lambda: None, "name": "Alice"}) result = _filtered_namespace_kwargs(ns) - assert NS_ATTR_SUBCMD_HANDLER not in result + assert constants.NS_ATTR_SUBCOMMAND_FUNC not in result assert result == {"name": "Alice"} def test_excludes_subcommand_key(self) -> None: @@ -2376,7 +2374,7 @@ def test_subcommand_spec_attributes(self, decorator_kwargs, expected_help, expec @with_annotated(subcommand_to="team", **decorator_kwargs) def team_create(self, name: str = "") -> None: ... - spec = getattr(team_create, constants.SUBCMD_ATTR_SPEC) + spec = getattr(team_create, constants.SUBCOMMAND_ATTR_SPEC) assert spec.command == "team" assert spec.name == "create" assert spec.help == expected_help @@ -2390,7 +2388,7 @@ def test_subcommand_deprecated_flows_to_spec(self, deprecated) -> None: @with_annotated(subcommand_to="team", deprecated=deprecated) def team_create(self, name: str = "") -> None: ... - spec = getattr(team_create, constants.SUBCMD_ATTR_SPEC) + spec = getattr(team_create, constants.SUBCOMMAND_ATTR_SPEC) assert spec.deprecated is deprecated @@ -3026,7 +3024,7 @@ def _base_parser(**subcommand_kwargs): @with_annotated(base_command=True, **subcommand_kwargs) def do_root(self, cmd2_handler) -> None: ... - builder = getattr(do_root, constants.CMD_ATTR_PARSER_SOURCE) + builder = getattr(do_root, constants.AP_COMMAND_ATTR_SPEC).parser_source return builder() @staticmethod @@ -3139,7 +3137,7 @@ def do_run(self, name: str) -> None: Extra detail. """ - builder = getattr(do_run, constants.CMD_ATTR_PARSER_SOURCE) + builder = getattr(do_run, constants.AP_COMMAND_ATTR_SPEC).parser_source assert builder().description == "Run the thing." def test_subcommand_uses_docstring(self) -> None: @@ -3149,7 +3147,7 @@ def test_subcommand_uses_docstring(self) -> None: def team_add(self, name: str) -> None: """Add a member to the team.""" - spec = getattr(team_add, constants.SUBCMD_ATTR_SPEC) + spec = getattr(team_add, constants.SUBCOMMAND_ATTR_SPEC) assert spec.parser_source().description == "Add a member to the team." @@ -3240,7 +3238,7 @@ def test_decorator_passes_parser_kwargs(self) -> None: @with_annotated(prog="myprog", usage="usage line") def do_run(self, name: str) -> None: ... - builder = getattr(do_run, constants.CMD_ATTR_PARSER_SOURCE) + builder = getattr(do_run, constants.AP_COMMAND_ATTR_SPEC).parser_source parser = builder() assert parser.prog == "myprog" assert parser.usage == "usage line" @@ -3259,7 +3257,7 @@ def test_usage_allowed_on_subcommand(self) -> None: @with_annotated(subcommand_to="team", usage="team add NAME") def team_add(self, name: str) -> None: ... - spec = getattr(team_add, constants.SUBCMD_ATTR_SPEC) + spec = getattr(team_add, constants.SUBCOMMAND_ATTR_SPEC) assert spec.parser_source().usage == "team add NAME" def test_parents_allowed_on_subcommand(self) -> None: @@ -3271,7 +3269,7 @@ def test_parents_allowed_on_subcommand(self) -> None: @with_annotated(subcommand_to="team", parents=[parent]) def team_add(self, name: str) -> None: ... - spec = getattr(team_add, constants.SUBCMD_ATTR_SPEC) + spec = getattr(team_add, constants.SUBCOMMAND_ATTR_SPEC) dests = {a.dest for a in spec.parser_source()._actions} assert "shared" in dests @@ -3354,7 +3352,7 @@ def test_decorator_threads_all_low_level_kwargs(self) -> None: ) def do_run(self, name: str) -> None: ... - builder = getattr(do_run, constants.CMD_ATTR_PARSER_SOURCE) + builder = getattr(do_run, constants.AP_COMMAND_ATTR_SPEC).parser_source parser = builder() assert parser.prefix_chars == "+-" assert parser.fromfile_prefix_chars == "@" From 1b990789a9ae2a0eedcfeefcc29a464d959079b5 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 3 Jun 2026 10:55:37 -0400 Subject: [PATCH 5/5] Renamed cmd2_handler to cmd2_subcommand_handler in annotated functions. This is to match what it's called in the Namespace when using with_argparser. --- cmd2/annotated.py | 32 ++++++------ docs/features/annotated.md | 23 +++++---- examples/annotated_example.py | 12 ++--- tests/test_annotated.py | 93 ++++++++++++----------------------- tests/test_argparse.py | 4 +- 5 files changed, 68 insertions(+), 96 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index 82e1d1e93..61deacd8a 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -18,7 +18,7 @@ flag (``dry_run`` -> ``--dry-run``); pass an explicit ``Option("--my_flag")`` to opt out. Positional-only parameters (before ``/``) and ``**kwargs`` raise ``TypeError``. The parameter names ``dest`` and ``subcommand`` are reserved; ``cmd2_statement`` receives the parsed -``Statement`` and (with ``base_command=True``) ``cmd2_handler`` receives the subcommand handler: +``Statement`` and (with ``base_command=True``) ``cmd2_subcommand_func`` receives the subcommand handler: class MyApp(cmd2.Cmd): @cmd2.with_annotated @@ -1704,7 +1704,7 @@ def _const_mismatches_type(a: _ArgparseArgument) -> bool: # Parameters handled specially by the decorator and not added to the parser. The first positional # parameter (self/cls) is always skipped by position; these cover additional decorator-managed names. -_SKIP_PARAMS = frozenset({"cmd2_handler", "cmd2_statement"}) +_SKIP_PARAMS = frozenset({constants.NS_ATTR_SUBCOMMAND_FUNC, constants.NS_ATTR_STATEMENT}) def _link_group_membership( @@ -1735,14 +1735,17 @@ def _resolve_parameters( """Resolve a function signature into a list of argparse-argument builders. ``base_command`` marks each argument's context for the base-command :data:`_CONSTRAINTS` rows and - drives the function-level ``cmd2_handler`` check below. ``groups``/``mutually_exclusive_groups`` + drives the function-level ``cmd2_subcommand_func`` check below. ``groups``/``mutually_exclusive_groups`` are linked onto each argument as membership facts for the cross-config constraint rows. """ sig = inspect.signature(func) # Function-level check (not a per-argument _CONSTRAINTS row): a base command dispatches through - # cmd2_handler, so it must exist. Here so it also fires when the function has zero parameters. - if base_command and "cmd2_handler" not in sig.parameters: - raise TypeError(f"with_annotated(base_command=True) requires a 'cmd2_handler' parameter in {func.__qualname__}") + # cmd2_subcommand_func, so it must exist. Here so it also fires when the function has zero parameters. + if base_command and constants.NS_ATTR_SUBCOMMAND_FUNC not in sig.parameters: + raise TypeError( + f"with_annotated(base_command=True) requires a '{constants.NS_ATTR_SUBCOMMAND_FUNC}' " + f"parameter in {func.__qualname__}" + ) try: hints = get_type_hints(func, include_extras=True) except (NameError, AttributeError, TypeError) as exc: @@ -1849,8 +1852,6 @@ def _filtered_namespace_kwargs( for key, value in vars(ns).items(): if accepted is not None and key not in accepted: continue - if key == constants.NS_ATTR_SUBCOMMAND_FUNC: - continue if exclude_subcommand and key == "subcommand": continue filtered[key] = value @@ -2120,10 +2121,10 @@ def _build_subcommand_handler( def handler(self_arg: Any, ns: Any) -> Any: """Unpack Namespace into typed kwargs for the subcommand handler.""" filtered = _filtered_namespace_kwargs(ns, accepted=_accepted) - if "cmd2_handler" in filtered: - cmd2_h = filtered["cmd2_handler"] + if constants.NS_ATTR_SUBCOMMAND_FUNC in filtered: + cmd2_h = filtered[constants.NS_ATTR_SUBCOMMAND_FUNC] if isinstance(cmd2_h, functools.partial) and cmd2_h.func is handler: - filtered["cmd2_handler"] = None + filtered[constants.NS_ATTR_SUBCOMMAND_FUNC] = None return _invoke_command_func( func, self_arg, filtered, leading_names=_leading_names, var_positional_name=_var_positional_name ) @@ -2185,7 +2186,7 @@ def with_annotated( :param ns_provider: callable returning a prepopulated Namespace (not with ``subcommand_to``) :param preserve_quotes: preserve quotes in arguments (not with ``subcommand_to``) :param with_unknown_args: capture unknown args as the ``_unknown`` kwarg (not with ``subcommand_to``) - :param base_command: add ``add_subparsers()``; requires a ``cmd2_handler`` param and no positionals + :param base_command: add ``add_subparsers()``; requires a ``cmd2_subcommand_func`` param and no positionals :param subcommand_to: parent command name; function must be named ``{parent_underscored}_{subcommand}`` :param help: subcommand help text (only with ``subcommand_to``) :param aliases: alternative subcommand names (only with ``subcommand_to``) @@ -2247,9 +2248,10 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: if unknown_param.kind is inspect.Parameter.POSITIONAL_ONLY: raise TypeError("Parameter _unknown must be keyword-compatible when with_unknown_args=True") - if not base_command and "cmd2_handler" in inspect.signature(fn).parameters: + if not base_command and constants.NS_ATTR_SUBCOMMAND_FUNC in inspect.signature(fn).parameters: raise TypeError( - f"Parameter 'cmd2_handler' in {fn.__qualname__} is only valid when with_annotated(base_command=True) is used." + f"Parameter '{constants.NS_ATTR_SUBCOMMAND_FUNC}' in {fn.__qualname__} " + "is only valid when with_annotated(base_command=True) is used." ) if subcommand_to is not None: @@ -2314,7 +2316,7 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: handler = getattr(ns, constants.NS_ATTR_SUBCOMMAND_FUNC, None) if base_command and handler is not None: handler = functools.partial(handler, ns) - ns.cmd2_handler = handler + setattr(ns, constants.NS_ATTR_SUBCOMMAND_FUNC, handler) func_kwargs = _filtered_namespace_kwargs(ns, accepted=accepted, exclude_subcommand=base_command) diff --git a/docs/features/annotated.md b/docs/features/annotated.md index 1b04286d1..1526b2e3c 100644 --- a/docs/features/annotated.md +++ b/docs/features/annotated.md @@ -116,8 +116,9 @@ Unsupported patterns raise `TypeError`, including: by the tuple. The tuple type already pins `nargs`; user metadata cannot change it. The parameter names `dest` and `subcommand` are reserved and may not be used as annotated parameter -names. `cmd2_statement` receives the parsed [cmd2.Statement][] object, and `cmd2_handler` (only on a -command decorated with `@with_annotated(base_command=True)`) receives the subcommand handler. +names. `cmd2_statement` receives the parsed [cmd2.Statement][] object, and `cmd2_subcommand_func` +(only on a command decorated with `@with_annotated(base_command=True)`) receives the subcommand +handler. ## Annotated metadata @@ -269,8 +270,8 @@ list the values. - `with_unknown_args` -- if `True`, unrecognised arguments are passed as `_unknown` - `subcommand_to` -- register the function as an annotated subcommand under a parent command - `base_command` -- create a base command whose parser also adds subparsers and exposes - `cmd2_handler`. A `cmd2_handler` parameter is only valid on a command decorated with - `base_command=True`; declaring one elsewhere raises `TypeError`. + `cmd2_subcommand_func`. A `cmd2_subcommand_func` parameter is only valid on a command decorated + with `base_command=True`; declaring one elsewhere raises `TypeError`. - `subcommand_required` -- whether a subcommand must be supplied (only with `base_command=True`, default `True`) - `subcommand_metavar` -- metavar shown for the subcommands group (only with `base_command=True`, @@ -392,10 +393,9 @@ The remaining argparse kwargs cover less-common needs but are wired through unch ```py @with_annotated(base_command=True) -def do_manage(self, *, cmd2_handler): - handler = cmd2_handler - if handler: - handler() +def do_manage(self, *, cmd2_subcommand_func): + if cmd2_subcommand_func: + cmd2_subcommand_func() @with_annotated(subcommand_to="manage", help="list projects") def manage_list(self): @@ -408,10 +408,9 @@ creates its own subparsers: ```py @with_annotated(subcommand_to="manage", base_command=True, help="manage projects") -def manage_project(self, *, cmd2_handler): - handler = cmd2_handler - if handler: - handler() +def manage_project(self, *, cmd2_subcommand_func): + if cmd2_subcommand_func: + cmd2_subcommand_func() @with_annotated(subcommand_to="manage project", help="add a project") def manage_project_add(self, name: str): diff --git a/examples/annotated_example.py b/examples/annotated_example.py index e11be106e..94b65ea2e 100755 --- a/examples/annotated_example.py +++ b/examples/annotated_example.py @@ -440,7 +440,7 @@ def do_flex(self, name: str, _unknown: list[str] | None = None) -> None: @with_annotated(base_command=True) @cmd2.with_category(ANNOTATED_CATEGORY) - def do_manage(self, verbose: bool = False, *, cmd2_handler: Callable[[], Any] | None = None) -> None: + def do_manage(self, verbose: bool = False, *, cmd2_subcommand_func: Callable[[], Any] | None = None) -> None: """Base command for annotated subcommands. Try: @@ -449,13 +449,13 @@ def do_manage(self, verbose: bool = False, *, cmd2_handler: Callable[[], Any] | """ if verbose: self.poutput("verbose mode") - if cmd2_handler: - cmd2_handler() + if cmd2_subcommand_func: + cmd2_subcommand_func() @with_annotated(subcommand_to="manage", base_command=True, help="manage projects") - def manage_project(self, *, cmd2_handler: Callable[[], Any] | None = None) -> None: - if cmd2_handler: - cmd2_handler() + def manage_project(self, *, cmd2_subcommand_func: Callable[[], Any] | None = None) -> None: + if cmd2_subcommand_func: + cmd2_subcommand_func() @with_annotated(subcommand_to="manage project", help="add a project") def manage_project_add(self, name: str) -> None: diff --git a/tests/test_annotated.py b/tests/test_annotated.py index 41b2c1ff4..f0686cf8b 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -23,7 +23,10 @@ import pytest import cmd2 -from cmd2 import CompletionItem +from cmd2 import ( + CompletionItem, + constants, +) from cmd2.annotated import ( Argument, Group, @@ -633,7 +636,7 @@ def test_validate_base_command_type_hints_failure_raises(self) -> None: """Base-command validation should raise, not swallow, type hint failures.""" from cmd2.annotated import _resolve_parameters - def do_broken(self, cmd2_handler, name: "NonExistentType"): # noqa: F821 + def do_broken(self, cmd2_subcommand_func, name: "NonExistentType"): # noqa: F821 pass with pytest.raises(TypeError, match="Failed to resolve type hints"): @@ -1110,7 +1113,6 @@ def test_ap_completer_type_defaults_to_none(self) -> None: assert build_parser_from_function(_make_func(str)).ap_completer_type is None def test_ap_completer_type_via_decorator(self) -> None: - from cmd2 import constants from cmd2.argparse_completer import ArgparseCompleter class MyCompleter(ArgparseCompleter): @@ -1123,7 +1125,6 @@ def do_run(self, name: str) -> None: ... assert builder().ap_completer_type is MyCompleter def test_ap_completer_type_threads_to_subcommand(self) -> None: - from cmd2 import constants from cmd2.argparse_completer import ArgparseCompleter class MyCompleter(ArgparseCompleter): @@ -1162,9 +1163,9 @@ def test_customization_via_subcommand(self) -> None: class App(cmd2.Cmd): @with_annotated(base_command=True) - def do_team(self, *, cmd2_handler=None) -> None: - if cmd2_handler: - cmd2_handler() + def do_team(self, *, cmd2_subcommand_func=None) -> None: + if cmd2_subcommand_func: + cmd2_subcommand_func() @with_annotated(subcommand_to="team", help="add a member", description="add desc", epilog="add epilog") def team_add(self, name: str) -> None: @@ -1174,8 +1175,6 @@ def team_add(self, name: str) -> None: out, _err = run_cmd(app, "team add bob") assert out == ["added bob"] - from cmd2 import constants - spec = getattr(App.team_add, constants.SUBCOMMAND_ATTR_SPEC) subparser = spec.parser_source() assert subparser.description == "add desc" @@ -1675,15 +1674,6 @@ def test_int_subclass_uses_int_converter(self) -> None: class TestFilteredNamespaceKwargs: - def test_excludes_subcmd_handler_key(self) -> None: - from cmd2 import constants - from cmd2.annotated import _filtered_namespace_kwargs - - ns = argparse.Namespace(**{constants.NS_ATTR_SUBCOMMAND_FUNC: lambda: None, "name": "Alice"}) - result = _filtered_namespace_kwargs(ns) - assert constants.NS_ATTR_SUBCOMMAND_FUNC not in result - assert result == {"name": "Alice"} - def test_excludes_subcommand_key(self) -> None: from cmd2.annotated import _filtered_namespace_kwargs @@ -2158,13 +2148,12 @@ def test_grouped_command_help_lists_flags(self, grouped_app) -> None: class _SubcommandApp(cmd2.Cmd): # Level 1: base command @with_annotated(base_command=True) - def do_manage(self, cmd2_handler, verbose: bool = False) -> None: + def do_manage(self, cmd2_subcommand_func, verbose: bool = False) -> None: """Management command with subcommands.""" if verbose: self.poutput("verbose mode") - handler = cmd2_handler - if handler: - handler() + if cmd2_subcommand_func: + cmd2_subcommand_func() # Level 2: leaf subcommands @with_annotated(subcommand_to="manage", help="add something") @@ -2181,10 +2170,9 @@ def manage_list(self) -> None: # Level 2: intermediate subcommand (also a base for level 3) @with_annotated(subcommand_to="manage", base_command=True, help="manage members") - def manage_member(self, cmd2_handler) -> None: - handler = cmd2_handler - if handler: - handler() + def manage_member(self, cmd2_subcommand_func) -> None: + if cmd2_subcommand_func: + cmd2_subcommand_func() # Level 3: nested subcommand @with_annotated(subcommand_to="manage member", help="add a member") @@ -2244,7 +2232,7 @@ def test_base_command_positional_str_raises(self) -> None: with pytest.raises(TypeError, match="positional"): @with_annotated(base_command=True) - def do_bad(self, name: str, cmd2_handler) -> None: + def do_bad(self, name: str, cmd2_subcommand_func) -> None: pass def test_base_command_positional_annotated_raises(self) -> None: @@ -2252,36 +2240,36 @@ def test_base_command_positional_annotated_raises(self) -> None: with pytest.raises(TypeError, match="positional"): @with_annotated(base_command=True) - def do_bad(self, a: Annotated[str, Argument(help_text="x")], cmd2_handler) -> None: + def do_bad(self, a: Annotated[str, Argument(help_text="x")], cmd2_subcommand_func) -> None: pass - def test_base_command_missing_handler_raises(self) -> None: - with pytest.raises(TypeError, match="cmd2_handler"): + def test_base_command_missing_subcommand_func_raises(self) -> None: + with pytest.raises(TypeError, match=constants.NS_ATTR_SUBCOMMAND_FUNC): @with_annotated(base_command=True) def do_bad(self, verbose: bool = False) -> None: pass - def test_base_command_missing_handler_raises_with_no_parameters(self) -> None: - """A zero-parameter base command with no cmd2_handler must still raise. + def test_base_command_missing_subcommand_func_raises_with_no_parameters(self) -> None: + """A zero-parameter base command with no cmd2_subcommand_func must still raise. - Guards the function-level ``cmd2_handler`` check (a plain ``if`` in ``_resolve_parameters``, + Guards the function-level ``cmd2_subcommand_func`` check (a plain ``if`` in ``_resolve_parameters``, not a :data:`_CONSTRAINTS` row): the per-argument :data:`_CONSTRAINTS` loop never runs when no arguments exist, so this case is the sole reason the missing-handler check lives at function scope. """ - with pytest.raises(TypeError, match="cmd2_handler"): + with pytest.raises(TypeError, match=constants.NS_ATTR_SUBCOMMAND_FUNC): @with_annotated(base_command=True) def do_bad(self) -> None: pass - def test_cmd2_handler_without_base_command_raises(self) -> None: - """A 'cmd2_handler' parameter is only valid when base_command=True.""" + def test_cmd2_subcommand_func_without_base_command_raises(self) -> None: + """A 'cmd2_subcommand_func' parameter is only valid when base_command=True.""" with pytest.raises(TypeError, match="base_command=True"): @with_annotated - def do_bad(self, cmd2_handler, name: str = "") -> None: + def do_bad(self, cmd2_subcommand_func, name: str = "") -> None: pass @pytest.mark.parametrize( @@ -2319,10 +2307,9 @@ def test_subcommand_with_mutually_exclusive_groups(self) -> None: class App(cmd2.Cmd): @with_annotated(base_command=True) - def do_fmt(self, cmd2_handler) -> None: - handler = cmd2_handler - if handler: - handler() + def do_fmt(self, cmd2_subcommand_func) -> None: + if cmd2_subcommand_func: + cmd2_subcommand_func() @with_annotated(subcommand_to="fmt", help="output", mutually_exclusive_groups=(Group("json", "csv"),)) def fmt_out(self, msg: str, json: bool = False, csv: bool = False) -> None: @@ -2338,11 +2325,11 @@ def test_intermediate_base_command_positional_raises(self) -> None: with pytest.raises(TypeError, match="positional"): @with_annotated(subcommand_to="team", base_command=True) - def team_member(self, name: str, cmd2_handler) -> None: + def team_member(self, name: str, cmd2_subcommand_func) -> None: pass - def test_intermediate_base_command_missing_handler_raises(self) -> None: - with pytest.raises(TypeError, match="cmd2_handler"): + def test_intermediate_base_command_missing_subcommand_func_raises(self) -> None: + with pytest.raises(TypeError, match=constants.NS_ATTR_SUBCOMMAND_FUNC): @with_annotated(subcommand_to="team", base_command=True) def team_member(self) -> None: @@ -2369,8 +2356,6 @@ def test_subcommand_naming_enforced(self, subcommand_to, func_name) -> None: ], ) def test_subcommand_spec_attributes(self, decorator_kwargs, expected_help, expected_aliases) -> None: - from cmd2 import constants - @with_annotated(subcommand_to="team", **decorator_kwargs) def team_create(self, name: str = "") -> None: ... @@ -2383,8 +2368,6 @@ def team_create(self, name: str = "") -> None: ... @pytest.mark.parametrize("deprecated", [True, False]) def test_subcommand_deprecated_flows_to_spec(self, deprecated) -> None: - from cmd2 import constants - @with_annotated(subcommand_to="team", deprecated=deprecated) def team_create(self, name: str = "") -> None: ... @@ -3019,10 +3002,8 @@ def test_optional_scalar_positional_nargs_question_builds(self) -> None: class TestSubcommandGroupConfig: @staticmethod def _base_parser(**subcommand_kwargs): - from cmd2 import constants - @with_annotated(base_command=True, **subcommand_kwargs) - def do_root(self, cmd2_handler) -> None: ... + def do_root(self, cmd2_subcommand_func) -> None: ... builder = getattr(do_root, constants.AP_COMMAND_ATTR_SPEC).parser_source return builder() @@ -3128,8 +3109,6 @@ def func(self, name: str) -> None: assert parser.description == "Summary line." def test_decorator_uses_docstring(self) -> None: - from cmd2 import constants - @with_annotated def do_run(self, name: str) -> None: """Run the thing. @@ -3141,8 +3120,6 @@ def do_run(self, name: str) -> None: assert builder().description == "Run the thing." def test_subcommand_uses_docstring(self) -> None: - from cmd2 import constants - @with_annotated(subcommand_to="team") def team_add(self, name: str) -> None: """Add a member to the team.""" @@ -3233,8 +3210,6 @@ def func(self, name: str, count: int = 1) -> None: ... assert ns.count == 1 def test_decorator_passes_parser_kwargs(self) -> None: - from cmd2 import constants - @with_annotated(prog="myprog", usage="usage line") def do_run(self, name: str) -> None: ... @@ -3252,7 +3227,6 @@ def team_add(self, name: str) -> None: ... def test_usage_allowed_on_subcommand(self) -> None: """``usage`` doesn't conflict with subcommand prog rewriting.""" - from cmd2 import constants @with_annotated(subcommand_to="team", usage="team add NAME") def team_add(self, name: str) -> None: ... @@ -3261,8 +3235,6 @@ def team_add(self, name: str) -> None: ... assert spec.parser_source().usage == "team add NAME" def test_parents_allowed_on_subcommand(self) -> None: - from cmd2 import constants - parent = argparse.ArgumentParser(add_help=False) parent.add_argument("--shared") @@ -3340,7 +3312,6 @@ def func(self, count: int) -> None: ... def test_decorator_threads_all_low_level_kwargs(self) -> None: """End-to-end: each kwarg lands on the parser when set on the decorator.""" - from cmd2 import constants @with_annotated( prefix_chars="+-", diff --git a/tests/test_argparse.py b/tests/test_argparse.py index 4135fd015..d94a2fadb 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -364,7 +364,7 @@ def do_test_subcmd_decorator(self, args: argparse.Namespace) -> None: @cmd2.as_subcommand_to("test_subcmd_decorator", "subcmd", subcmd_parser, help=subcmd_parser.description.lower()) def subcmd_func(self, args: argparse.Namespace) -> None: - # Make sure printing the Namespace works. The way we originally added cmd2_handler to it resulted in a RecursionError. + # Make sure printing the Namespace works. self.poutput(args) helpless_subcmd_parser = cmd2.Cmd2ArgumentParser(add_help=False, description="A subcommand with no help") @@ -373,7 +373,7 @@ def subcmd_func(self, args: argparse.Namespace) -> None: "test_subcmd_decorator", "helpless_subcmd", helpless_subcmd_parser, help=helpless_subcmd_parser.description.lower() ) def helpless_subcmd_func(self, args: argparse.Namespace) -> None: - # Make sure vars(Namespace) works. The way we originally added cmd2_handler to it resulted in a RecursionError. + # Make sure vars(Namespace) works. self.poutput(vars(args))