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..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.CMD_ATTR_HELP_CATEGORY, category) - elif inspect.ismethod(func): - setattr(func.__func__, constants.CMD_ATTR_HELP_CATEGORY, category) - else: - setattr(func, constants.CMD_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/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: diff --git a/tests/test_utils.py b/tests/test_utils.py index 5083bf47f..4dc8ce99d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -396,3 +396,39 @@ 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 single method + class Foo: + def foo_method(self) -> None: + pass + + f = Foo() + cu.categorize(f.foo_method, category) + assert getattr(Foo.foo_method, attr_name) == category + + # Test iterable + def func2() -> None: + pass + + class Bar: + def bar_method(self) -> None: + pass + + b = Bar() + cu.categorize([func2, b.bar_method], category) + assert getattr(func2, attr_name) == category + assert getattr(Bar.bar_method, attr_name) == category