diff --git a/CHANGELOG.md b/CHANGELOG.md index de6d2a11a..177fee970 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/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/cmd2/argparse_utils.py b/cmd2/argparse_utils.py index 7bdec4403..deaf8b579 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 855a0c670..e4b946403 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -16,6 +16,7 @@ from . import constants from .argparse_utils import ( + ApCommandSpec, ClassParamParserFactory, Cmd2ArgumentParser, NoParamParserFactory, @@ -356,18 +357,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 @@ -453,10 +456,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") ``` @@ -471,7 +474,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 d920de550..d97378fdf 100644 --- a/docs/features/argument_processing.md +++ b/docs/features/argument_processing.md @@ -414,4 +414,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 0d31308ef..7db8a3568 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_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 == "@" 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 a38195056..0f6f1747e 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 18da47573..0e630a109 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -397,3 +397,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