diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 16ac06dff..172551036 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -31,7 +31,7 @@ cmd2/argparse_*.py @kmvanbrunt cmd2/clipboard.py @tleonhardt cmd2/cmd2.py @tleonhardt @kmvanbrunt cmd2/colors.py @tleonhardt @kmvanbrunt -cmd2/command_definition.py @kmvanbrunt +cmd2/command_set.py @kmvanbrunt cmd2/completion.py @kmvanbrunt cmd2/constants.py @tleonhardt @kmvanbrunt cmd2/decorators.py @kmvanbrunt diff --git a/CHANGELOG.md b/CHANGELOG.md index 51e4c8d58..ebb12a047 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,11 @@ prompt is displayed. - Renamed `cmd2_handler` to `cmd2_subcmd_handler` 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`. + - 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 + the Enhancements section below for details). + - Removed `Cmd.undoc_header` since all commands are now considered categorized. - Enhancements - New `cmd2.Cmd` parameters - **auto_suggest**: (boolean) if `True`, provide fish shell style auto-suggestions. These @@ -97,6 +102,11 @@ prompt is displayed. - Add support for Python 3.15 by fixing various bugs related to internal `argparse` changes - Added `common_prefix` method to `cmd2.string_utils` module as a replacement for `os.path.commonprefix` since that is now deprecated in Python 3.15 + - Simplified command categorization: + - By default, all commands in a class are grouped under its `DEFAULT_CATEGORY`. + - Individual commands can still be manually moved using the `with_category()` decorator. + - For more details and examples, see the [Help](docs/features/help.md) documentation and the + `examples/default_categories.py` file. ## 3.4.0 (March 3, 2026) diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 001d031b1..9aa9bd769 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -19,10 +19,7 @@ ) from .cmd2 import Cmd from .colors import Color -from .command_definition import ( - CommandSet, - with_default_category, -) +from .command_set import CommandSet from .completion import ( Choices, CompletionItem, @@ -80,7 +77,6 @@ 'with_argument_list', 'with_argparser', 'with_category', - 'with_default_category', 'as_subcommand_to', # Exceptions 'Cmd2ArgparseError', diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 763d88538..5be38fc64 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -6,10 +6,7 @@ import argparse import dataclasses import inspect -from collections import ( - defaultdict, - deque, -) +from collections import deque from collections.abc import ( Mapping, MutableSequence, @@ -29,7 +26,7 @@ Cmd2ArgumentParser, build_range_error, ) -from .command_definition import CommandSet +from .command_set import CommandSet from .completion import ( CompletionItem, Completions, @@ -251,7 +248,7 @@ def complete( used_flags: set[str] = set() # Keeps track of arguments we've seen and any tokens they consumed - consumed_arg_values: dict[str, list[str]] = defaultdict(list) + consumed_arg_values: dict[str, list[str]] = {} # Completed mutually exclusive groups completed_mutex_groups: dict[argparse._MutuallyExclusiveGroup, argparse.Action] = {} @@ -259,7 +256,7 @@ def complete( def consume_argument(arg_state: _ArgumentState, arg_token: str) -> None: """Consume token as an argument.""" arg_state.count += 1 - consumed_arg_values[arg_state.action.dest].append(arg_token) + consumed_arg_values.setdefault(arg_state.action.dest, []).append(arg_token) ############################################################################################# # Parse all but the last token @@ -336,7 +333,7 @@ def consume_argument(arg_state: _ArgumentState, arg_token: str) -> None: # filter them from future completion results and clear any previously # recorded values for this destination. used_flags.update(action.option_strings) - consumed_arg_values[action.dest].clear() + consumed_arg_values[action.dest] = [] new_arg_state = _ArgumentState(action) @@ -362,7 +359,6 @@ def consume_argument(arg_state: _ArgumentState, arg_token: str) -> None: # Are we at a subcommand? If so, forward to the matching completer if self._subcommand_action is not None and action == self._subcommand_action: if token in self._subcommand_action.choices: - # Merge self._parent_tokens and consumed_arg_values parent_tokens = {**self._parent_tokens, **consumed_arg_values} # Include the subcommand name if its destination was set @@ -557,7 +553,7 @@ def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, used_f match_against.append(flag) # Build a dictionary linking actions with their matched flag names - matched_actions: dict[argparse.Action, list[str]] = defaultdict(list) + matched_actions: dict[argparse.Action, list[str]] = {} # Keep flags sorted in the order provided by argparse so our completion # suggestions display the same as argparse help text. @@ -565,7 +561,7 @@ def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, used_f for flag in matched_flags.to_strings(): action = self._flag_to_action[flag] - matched_actions[action].append(flag) + matched_actions.setdefault(action, []).append(flag) # For completion suggestions, group matched flags by action items: list[CompletionItem] = [] diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index e600d195c..462ce8fad 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -60,6 +60,7 @@ IO, TYPE_CHECKING, Any, + ClassVar, TextIO, TypeVar, Union, @@ -113,7 +114,7 @@ get_paste_buffer, write_to_paste_buffer, ) -from .command_definition import ( +from .command_set import ( CommandFunc, CommandSet, ) @@ -123,7 +124,6 @@ Completions, ) from .constants import ( - CMDSET_ATTR_DEFAULT_HELP_CATEGORY, COMMAND_FUNC_PREFIX, COMPLETER_FUNC_PREFIX, HELP_FUNC_PREFIX, @@ -328,13 +328,24 @@ class Cmd: Line-oriented command interpreters are often useful for test harnesses, internal tools, and rapid prototypes. """ - DEFAULT_COMPLETEKEY = 'tab' - DEFAULT_EDITOR = utils.find_editor() - DEFAULT_PROMPT = '(Cmd) ' + DEFAULT_COMPLETEKEY: ClassVar[str] = "tab" + DEFAULT_EDITOR: ClassVar[str | None] = utils.find_editor() + DEFAULT_PROMPT: ClassVar[str] = "(Cmd) " + + # Default category for commands defined in this class which have + # not been explicitly categorized with the @with_category decorator. + # This value is inherited by subclasses but they can set their own + # DEFAULT_CATEGORY to place their commands into a custom category. + # Subclasses can also reassign cmd2.Cmd.DEFAULT_CATEGORY to rename + # the category used for the framework's built-in commands. + DEFAULT_CATEGORY: ClassVar[str] = "Cmd2 Commands" + + # Header for table listing help topics not related to a command. + MISC_HEADER: ClassVar[str] = "Miscellaneous Help Topics" def __init__( self, - completekey: str = DEFAULT_COMPLETEKEY, + completekey: str | None = None, stdin: TextIO | None = None, stdout: TextIO | None = None, *, @@ -359,7 +370,7 @@ def __init__( ) -> None: """Easy but powerful framework for writing line-oriented command interpreters, extends Python's cmd package. - :param completekey: name of a completion key, default to Tab + :param completekey: name of a completion key, default to 'tab'. (If None or an empty string, 'tab' is used) :param stdin: alternate input file object, if not specified, sys.stdin is used :param stdout: alternate output file object, if not specified, sys.stdout is used :param allow_cli_args: if ``True``, then [cmd2.Cmd.__init__][] will process command @@ -416,9 +427,12 @@ def __init__( self._initialize_plugin_system() # Configure a few defaults - self.prompt: str = Cmd.DEFAULT_PROMPT + self.prompt: str = self.DEFAULT_PROMPT self.intro = intro + if not completekey: + completekey = self.DEFAULT_COMPLETEKEY + # What to use for standard input if stdin is not None: self.stdin = stdin @@ -446,7 +460,7 @@ def __init__( self.always_show_hint = False self.debug = False self.echo = False - self.editor = Cmd.DEFAULT_EDITOR + self.editor = self.DEFAULT_EDITOR self.feedback_to_output = False # Do not include nonessentials in >, | output by default (things like timing) self.quiet = False # Do not suppress nonessential output self.scripts_add_to_history = True # Scripts and pyscripts add commands to history @@ -537,19 +551,6 @@ def __init__( # Set text which prints right before all of the help tables are listed. self.doc_leader = "" - # Set header for table listing documented commands. - self.doc_header = "Documented Commands" - - # Set header for table listing help topics not related to a command. - self.misc_header = "Miscellaneous Help Topics" - - # Set header for table listing commands that have no help info. - self.undoc_header = "Undocumented Commands" - - # If any command has been categorized, then all other documented commands that - # haven't been categorized will display under this section in the help output. - self.default_category = "Uncategorized Commands" - # The error that prints when no help information can be found self.help_error = "No help on {}" @@ -840,8 +841,6 @@ def register_command_set(self, cmdset: CommandSet) -> None: ), ) - default_category = getattr(cmdset, CMDSET_ATTR_DEFAULT_HELP_CATEGORY, None) - installed_attributes = [] try: for cmd_func_name, command_method in methods: @@ -864,11 +863,8 @@ def register_command_set(self, cmdset: CommandSet) -> None: self._cmd_to_command_sets[command] = cmdset - if default_category and not hasattr(command_method, constants.CMD_ATTR_HELP_CATEGORY): - utils.categorize(command_method, default_category) - # If this command is in a disabled category, then disable it - command_category = getattr(command_method, constants.CMD_ATTR_HELP_CATEGORY, None) + command_category = self._get_command_category(command_method) if command_category in self.disabled_categories: message_to_print = self.disabled_categories[command_category] self.disable_command(command, message_to_print) @@ -3338,6 +3334,23 @@ def cmd_func(self, command: str) -> CommandFunc | None: func = getattr(self, func_name, None) return cast(CommandFunc, func) if callable(func) else None + def _get_command_category(self, func: CommandFunc) -> str: + """Determine the category for a command. + + :param func: the do_* function implementing the command + :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) + + # Otherwise get the category from its defining class. + else: + defining_cls = get_defining_class(func) + category = getattr(defining_cls, 'DEFAULT_CATEGORY', self.DEFAULT_CATEGORY) + + return category + def onecmd(self, statement: Statement | str, *, add_to_history: bool = True) -> bool: """Execute the actual do_* method for a command. @@ -4214,13 +4227,11 @@ def complete_help_subcommands( completer = argparse_completer.DEFAULT_AP_COMPLETER(argparser, self) return completer.complete_subcommand_help(text, line, begidx, endidx, arg_tokens['subcommands']) - def _build_command_info(self) -> tuple[dict[str, list[str]], list[str], list[str], list[str]]: + def _build_command_info(self) -> tuple[dict[str, list[str]], list[str]]: """Categorizes and sorts visible commands and help topics for display. :return: tuple containing: - dictionary mapping category names to lists of command names - - list of documented command names - - list of undocumented command names - list of help topic names that are not also commands """ # Get a sorted list of help topics @@ -4228,30 +4239,19 @@ def _build_command_info(self) -> tuple[dict[str, list[str]], list[str], list[str # Get a sorted list of visible command names visible_commands = sorted(self.get_visible_commands(), key=utils.DEFAULT_STR_SORT_KEY) - cmds_doc: list[str] = [] - cmds_undoc: list[str] = [] cmds_cats: dict[str, list[str]] = {} - for command in visible_commands: - func = cast(CommandFunc, self.cmd_func(command)) - has_help_func = False - has_parser = func in self._command_parsers + for command in visible_commands: + # Prevent the command from showing as both a command and help topic in the output if command in help_topics: - # Prevent the command from showing as both a command and help topic in the output help_topics.remove(command) - # Non-argparse commands can have help_functions for their documentation - has_help_func = not has_parser + # Store the command within its category + func = cast(CommandFunc, self.cmd_func(command)) + category = self._get_command_category(func) + cmds_cats.setdefault(category, []).append(command) - if hasattr(func, constants.CMD_ATTR_HELP_CATEGORY): - category: str = getattr(func, constants.CMD_ATTR_HELP_CATEGORY) - cmds_cats.setdefault(category, []) - cmds_cats[category].append(command) - elif func.__doc__ or has_help_func or has_parser: - cmds_doc.append(command) - else: - cmds_undoc.append(command) - return cmds_cats, cmds_doc, cmds_undoc, help_topics + return cmds_cats, help_topics @classmethod def _build_help_parser(cls) -> Cmd2ArgumentParser: @@ -4284,24 +4284,20 @@ def do_help(self, args: argparse.Namespace) -> None: self.last_result = True if not args.command or args.verbose: - cmds_cats, cmds_doc, cmds_undoc, help_topics = self._build_command_info() + cmds_cats, help_topics = self._build_command_info() if self.doc_leader: self.poutput() self.poutput(Text(self.doc_leader, style=Cmd2Style.HELP_LEADER)) self.poutput() - # Print any categories first and then the remaining documented commands. - sorted_categories = sorted(cmds_cats.keys(), key=utils.DEFAULT_STR_SORT_KEY) - all_cmds = {category: cmds_cats[category] for category in sorted_categories} - if all_cmds: - all_cmds[self.default_category] = cmds_doc - else: - all_cmds[self.doc_header] = cmds_doc - # Used to provide verbose table separation for better readability. previous_table_printed = False + # Print commands grouped by category + sorted_categories = sorted(cmds_cats.keys(), key=utils.DEFAULT_STR_SORT_KEY) + all_cmds = {category: cmds_cats[category] for category in sorted_categories} + for category, commands in all_cmds.items(): if previous_table_printed: self.poutput() @@ -4309,11 +4305,11 @@ def do_help(self, args: argparse.Namespace) -> None: self._print_documented_command_topics(category, commands, args.verbose) previous_table_printed = bool(commands) and args.verbose - if previous_table_printed and (help_topics or cmds_undoc): + if previous_table_printed and help_topics: self.poutput() - self.print_topics(self.misc_header, help_topics, 15, 80) - self.print_topics(self.undoc_header, cmds_undoc, 15, 80) + # Print help topics table + self.print_topics(self.MISC_HEADER, help_topics, 15, 80) else: # Getting help for a specific command @@ -5620,7 +5616,7 @@ def enable_category(self, category: str) -> None: for cmd_name in list(self.disabled_commands): func = self.disabled_commands[cmd_name].command_function - if getattr(func, constants.CMD_ATTR_HELP_CATEGORY, None) == category: + if self._get_command_category(func) == category: self.enable_command(cmd_name) del self.disabled_categories[category] @@ -5681,8 +5677,8 @@ def disable_category(self, category: str, message_to_print: str) -> None: all_commands = self.get_all_commands() for cmd_name in all_commands: - func = self.cmd_func(cmd_name) - if getattr(func, constants.CMD_ATTR_HELP_CATEGORY, None) == category: + func = cast(CommandFunc, self.cmd_func(cmd_name)) + if self._get_command_category(func) == category: self.disable_command(cmd_name, message_to_print) self.disabled_categories[category] = message_to_print diff --git a/cmd2/command_definition.py b/cmd2/command_set.py similarity index 61% rename from cmd2/command_definition.py rename to cmd2/command_set.py index b17a10906..277f4ebc9 100644 --- a/cmd2/command_definition.py +++ b/cmd2/command_set.py @@ -6,99 +6,50 @@ ) from typing import ( TYPE_CHECKING, + ClassVar, TypeAlias, - TypeVar, ) -from .constants import ( - CMDSET_ATTR_DEFAULT_HELP_CATEGORY, - COMMAND_FUNC_PREFIX, -) from .exceptions import CommandSetRegistrationError from .utils import Settable if TYPE_CHECKING: # pragma: no cover from .cmd2 import Cmd -#: Callable signature for a basic command function -#: Further refinements are needed to define the input parameters +# Callable signature for a basic command function +# Further refinements are needed to define the input parameters CommandFunc: TypeAlias = Callable[..., bool | None] -CommandSetType = TypeVar('CommandSetType', bound=type['CommandSet']) - - -def with_default_category(category: str, *, heritable: bool = True) -> Callable[[CommandSetType], CommandSetType]: - """Apply a category to all ``do_*`` command methods in a class that do not already have a category specified (Decorator). - - CommandSets that are decorated by this with `heritable` set to True (default) will set a class attribute that is - inherited by all subclasses unless overridden. All commands of this CommandSet and all subclasses of this CommandSet - that do not declare an explicit category will be placed in this category. Subclasses may use this decorator to - override the default category. - - If `heritable` is set to False, then only the commands declared locally to this CommandSet will be placed in the - specified category. Dynamically created commands and commands declared in sub-classes will not receive this - category. - - :param category: category to put all uncategorized commands in - :param heritable: Flag whether this default category should apply to sub-classes. Defaults to True - :return: decorator function - """ - - def decorate_class(cls: CommandSetType) -> CommandSetType: - if heritable: - setattr(cls, CMDSET_ATTR_DEFAULT_HELP_CATEGORY, category) - - import inspect - - from .constants import CMD_ATTR_HELP_CATEGORY - from .decorators import with_category - - # get members of the class that meet the following criteria: - # 1. Must be a function - # 2. Must start with COMMAND_FUNC_PREFIX (do_) - # 3. Must be a member of the class being decorated and not one inherited from a parent declaration - methods = inspect.getmembers( - cls, - predicate=lambda meth: ( - inspect.isfunction(meth) - and meth.__name__.startswith(COMMAND_FUNC_PREFIX) - and meth in inspect.getmro(cls)[0].__dict__.values() - ), - ) - category_decorator = with_category(category) - for method in methods: - if not hasattr(method[1], CMD_ATTR_HELP_CATEGORY): - setattr(cls, method[0], category_decorator(method[1])) - return cls - - return decorate_class - class CommandSet: """Base class for defining sets of commands to load in cmd2. - ``with_default_category`` can be used to apply a default category to all commands in the CommandSet. - ``do_``, ``help_``, and ``complete_`` functions differ only in that self is the CommandSet instead of the cmd2 app """ + # Default category for commands defined in this CommandSet which have + # not been explicitly categorized with the @with_category decorator. + # This value is inherited by subclasses but they can set their own + # DEFAULT_CATEGORY to place their commands into a custom category. + DEFAULT_CATEGORY: ClassVar[str] = "CommandSet Commands" + def __init__(self) -> None: """Private reference to the CLI instance in which this CommandSet running. This will be set when the CommandSet is registered and it should be accessed by child classes using the self._cmd property. """ - self.__cmd_internal: Cmd | None = None + self._cmd_internal: Cmd | None = None self._settables: dict[str, Settable] = {} self._settable_prefix = self.__class__.__name__ @property def _cmd(self) -> 'Cmd': - """Property for child classes to access self.__cmd_internal. + """Property for child classes to access self._cmd_internal. - Using this property ensures that self.__cmd_internal has been set - and it tells type checkers that it's no longer a None type. + Using this property ensures that the CommandSet has been registered + and tells type checkers that self._cmd_internal is not None. Override this property to specify a more specific return type for static type checking. The typing.cast function can be used to assert to the @@ -114,9 +65,9 @@ def _cmd(self) -> CustomCmdApp: :raises CommandSetRegistrationError: if CommandSet is not registered. """ - if self.__cmd_internal is None: + if self._cmd_internal is None: raise CommandSetRegistrationError('This CommandSet is not registered') - return self.__cmd_internal + return self._cmd_internal def on_register(self, cmd: 'Cmd') -> None: """First step to registering a CommandSet, called by cmd2.Cmd. @@ -128,8 +79,8 @@ def on_register(self, cmd: 'Cmd') -> None: :param cmd: The cmd2 main application :raises CommandSetRegistrationError: if CommandSet is already registered. """ - if self.__cmd_internal is None: - self.__cmd_internal = cmd + if self._cmd_internal is None: + self._cmd_internal = cmd else: raise CommandSetRegistrationError('This CommandSet has already been registered') @@ -151,7 +102,7 @@ def on_unregistered(self) -> None: Subclasses can override this to perform remaining cleanup steps. """ - self.__cmd_internal = None + self._cmd_internal = None @property def settable_prefix(self) -> str: @@ -168,7 +119,7 @@ def add_settable(self, settable: Settable) -> None: :param settable: Settable object being added """ - if self.__cmd_internal is not None: + if self._cmd_internal is not None: if not self._cmd.always_prefix_settables: if settable.name in self._cmd.settables and settable.name not in self._settables: raise KeyError(f'Duplicate settable: {settable.name}') diff --git a/cmd2/constants.py b/cmd2/constants.py index 3a0e4077c..b33be71f8 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -78,9 +78,6 @@ def cmd2_public_attr_name(name: str) -> str: # 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 a CommandSet class; defines a default help category for its member functions -CMDSET_ATTR_DEFAULT_HELP_CATEGORY = cmd2_private_attr_name('default_help_category') - # Attached to a subcommand function; defines the full command path to the parent (e.g., "foo" or "foo bar") SUBCMD_ATTR_COMMAND = cmd2_private_attr_name('parent_command') diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 7ebc6745b..39a3a959d 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -14,7 +14,7 @@ from . import constants from .argparse_custom import Cmd2ArgumentParser -from .command_definition import ( +from .command_set import ( CommandFunc, CommandSet, ) @@ -104,17 +104,17 @@ def _arg_swap(args: Sequence[Any], search_arg: Any, *replace_arg: Any) -> list[A return args_list -#: Function signature for a command function that accepts a pre-processed argument list from user input -#: and optionally returns a boolean +# Function signature for a command function that accepts a pre-processed argument list from user input +# and optionally returns a boolean ArgListCommandFuncOptionalBoolReturn: TypeAlias = Callable[[CmdOrSet, list[str]], bool | None] -#: Function signature for a command function that accepts a pre-processed argument list from user input -#: and returns a boolean +# Function signature for a command function that accepts a pre-processed argument list from user input +# and returns a boolean ArgListCommandFuncBoolReturn: TypeAlias = Callable[[CmdOrSet, list[str]], bool] -#: Function signature for a command function that accepts a pre-processed argument list from user input -#: and returns Nothing +# Function signature for a command function that accepts a pre-processed argument list from user input +# and returns Nothing ArgListCommandFuncNoneReturn: TypeAlias = Callable[[CmdOrSet, list[str]], None] -#: Aggregate of all accepted function signatures for command functions that accept a pre-processed argument list +# Aggregate of all accepted function signatures for command functions that accept a pre-processed argument list ArgListCommandFunc: TypeAlias = ( ArgListCommandFuncOptionalBoolReturn[CmdOrSet] | ArgListCommandFuncBoolReturn[CmdOrSet] @@ -184,24 +184,24 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: return arg_decorator -#: Function signatures for command functions that use a Cmd2ArgumentParser to process user input -#: and optionally return a boolean +# Function signatures for command functions that use a Cmd2ArgumentParser to process user input +# and optionally return a boolean ArgparseCommandFuncOptionalBoolReturn: TypeAlias = Callable[[CmdOrSet, argparse.Namespace], bool | None] ArgparseCommandFuncWithUnknownArgsOptionalBoolReturn: TypeAlias = Callable[ [CmdOrSet, argparse.Namespace, list[str]], bool | None ] -#: Function signatures for command functions that use a Cmd2ArgumentParser to process user input -#: and return a boolean +# Function signatures for command functions that use a Cmd2ArgumentParser to process user input +# and return a boolean ArgparseCommandFuncBoolReturn: TypeAlias = Callable[[CmdOrSet, argparse.Namespace], bool] ArgparseCommandFuncWithUnknownArgsBoolReturn: TypeAlias = Callable[[CmdOrSet, argparse.Namespace, list[str]], bool] -#: Function signatures for command functions that use a Cmd2ArgumentParser to process user input -#: and return nothing +# Function signatures for command functions that use a Cmd2ArgumentParser to process user input +# and return nothing ArgparseCommandFuncNoneReturn: TypeAlias = Callable[[CmdOrSet, argparse.Namespace], None] ArgparseCommandFuncWithUnknownArgsNoneReturn: TypeAlias = Callable[[CmdOrSet, argparse.Namespace, list[str]], None] -#: Aggregate of all accepted function signatures for an argparse command function +# Aggregate of all accepted function signatures for an argparse command function ArgparseCommandFunc: TypeAlias = ( ArgparseCommandFuncOptionalBoolReturn[CmdOrSet] | ArgparseCommandFuncWithUnknownArgsOptionalBoolReturn[CmdOrSet] diff --git a/cmd2/types.py b/cmd2/types.py index c1c2fada8..6c37b4b77 100644 --- a/cmd2/types.py +++ b/cmd2/types.py @@ -14,7 +14,7 @@ if TYPE_CHECKING: # pragma: no cover from .cmd2 import Cmd - from .command_definition import CommandSet + from .command_set import CommandSet from .completion import Choices, Completions # A Cmd or CommandSet diff --git a/docs/api/command_definition.md b/docs/api/command_definition.md deleted file mode 100644 index 36a1e026c..000000000 --- a/docs/api/command_definition.md +++ /dev/null @@ -1,3 +0,0 @@ -# cmd2.command_definition - -::: cmd2.command_definition diff --git a/docs/api/command_set.md b/docs/api/command_set.md new file mode 100644 index 000000000..300ccf95a --- /dev/null +++ b/docs/api/command_set.md @@ -0,0 +1,3 @@ +# cmd2.command_set + +::: cmd2.command_set diff --git a/docs/api/index.md b/docs/api/index.md index 990775d6b..e317a235f 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -16,8 +16,8 @@ incremented according to the [Semantic Version Specification](https://semver.org - [cmd2.argparse_custom](./argparse_custom.md) - classes and functions for extending `argparse` - [cmd2.clipboard](./clipboard.md) - functions to copy from and paste to the clipboard/pastebuffer - [cmd2.colors](./colors.md) - StrEnum of all color names supported by the Rich library -- [cmd2.command_definition](./command_definition.md) - supports the definition of commands in - separate classes to be composed into cmd2.Cmd +- [cmd2.command_set](./command_set.md) - supports the definition of commands in separate classes to + be composed into cmd2.Cmd - [cmd2.completion](./completion.md) - classes and functions related to command-line completion - [cmd2.constants](./constants.md) - constants used in `cmd2` - [cmd2.decorators](./decorators.md) - decorators for `cmd2` commands diff --git a/docs/features/help.md b/docs/features/help.md index 6def1f5b1..11150ee46 100644 --- a/docs/features/help.md +++ b/docs/features/help.md @@ -50,48 +50,74 @@ not use an `argparse` decorator because we didn't want different output for `hel ## Categorizing Commands -By default, the `help` command displays: +In `cmd2`, the `help` command organizes its output into categories. Every command belongs to a +category, and the display is driven by the `DEFAULT_CATEGORY` class variable. - Documented Commands - ─────────────────── - alias help ipy py run_pyscript set shortcuts - edit history macro quit run_script shell +There are 3 methods of specifying command categories: -If you have a large number of commands, you can optionally group your commands into categories. -Here's the output from the example -[help_categories.py](https://github.com/python-cmd2/cmd2/blob/main/examples/help_categories.py): +1. Using the `DEFAULT_CATEGORY` class variable (Automatic) +1. Using the [@with_category][cmd2.with_category] decorator (Manual) +1. Using the [categorize()][cmd2.categorize] function (Manual) - Application Management - ────────────────────── - deploy findleakers redeploy sessions stop - expire list restart start undeploy +### Automatic Categorization - Command Management - ────────────────── - disable_commands enable_commands +The most efficient way to categorize commands is by defining the `DEFAULT_CATEGORY` class variable +in your `Cmd` or `CommandSet` class. Any command defined in that class that does not have an +explicit category override will automatically be placed in this category. - Connecting - ────────── - connect which +By default, `cmd2.Cmd` defines its `DEFAULT_CATEGORY` as `"Cmd2 Commands"`. - Server Information - ────────────────── - resources serverinfo sslconnectorciphers status thread_dump vminfo +```py +class MyApp(cmd2.Cmd): + # All commands defined in this class will be grouped here + DEFAULT_CATEGORY = 'Application Commands' - Other - ───── - alias edit history quit run_script shell version - config help macro run_pyscript set shortcuts + def do_echo(self, arg): + """Echo command""" + self.poutput(arg) +``` + +This also works for [Command Sets](./modular_commands.md): + +```py +class Plugin(cmd2.CommandSet): + DEFAULT_CATEGORY = 'Plugin Commands' + + def do_plugin_cmd(self, _): + """Plugin command""" + self._cmd.poutput('Plugin') +``` + +When using inheritance, `cmd2` uses the `DEFAULT_CATEGORY` of the class where the command was +actually defined. This means built-in commands (like `help`, `history`, and `quit`) stay in the +`"Cmd2 Commands"` category, while your commands move to your custom category. + +If you want to rename the built-in category itself, you can do so by reassigning +`cmd2.Cmd.DEFAULT_CATEGORY` at the class level within your `Cmd` subclass: + +```py +class MyApp(cmd2.Cmd): + # Rename the framework's built-in category + cmd2.Cmd.DEFAULT_CATEGORY = 'Shell Commands' + + # Set the category for your own commands + DEFAULT_CATEGORY = 'Application Commands' +``` + +For a complete demonstration of this functionality, see the +[default_categories.py](https://github.com/python-cmd2/cmd2/blob/main/examples/default_categories.py) +example. + +### Manual Categorization -There are 2 methods of specifying command categories, using the [@with_category][cmd2.with_category] -decorator or with the [categorize()][cmd2.categorize] function. Once a single command category is -detected, the help output switches to a categorized mode of display. All commands without an -explicit category defined default to the category `Other`. +If you need to move an individual command to a different category than the class default, you can +use the `@with_category` decorator or the `categorize()` function. These manual settings always take +precedence over the `DEFAULT_CATEGORY`. Using the `@with_category` decorator: ```py -@with_category(CMD_CAT_CONNECTING) +@with_category('Connecting') def do_which(self, _): """Which command""" self.poutput('Which') diff --git a/docs/features/initialization.md b/docs/features/initialization.md index 0e7100fe2..dad4226ce 100644 --- a/docs/features/initialization.md +++ b/docs/features/initialization.md @@ -16,6 +16,15 @@ Certain things must be initialized within the `__init__()` method of your class ::: cmd2.Cmd.__init__ +## Cmd class variables + +The `cmd2.Cmd` class provides several class-level variables that can be overridden in subclasses to change default behavior across all instances of that class. + +- **DEFAULT_CATEGORY**: The default help category for commands defined in the class which haven't been explicitly categorized. (Default: `"Cmd2 Commands"`) +- **DEFAULT_EDITOR**: The default editor program used by the `edit` command. +- **DEFAULT_PROMPT**: The default prompt string. (Default: `"(Cmd) "`) +- **MISC_HEADER**: Header for the help section listing miscellaneous help topics. (Default: `"Miscellaneous Help Topics"`) + ## Cmd instance attributes The `cmd2.Cmd` class provides a large number of public instance attributes which allow developers to customize a `cmd2` application further beyond the options provided by the `__init__()` method. @@ -29,10 +38,8 @@ Here are instance attributes of `cmd2.Cmd` which developers might wish to overri - **broken_pipe_warning**: if non-empty, this string will be displayed if a broken pipe error occurs - **continuation_prompt**: used for multiline commands on 2nd+ line of input - **debug**: if `True`, show full stack trace on error (Default: `False`) -- **default_category**: if any command has been categorized, then all other commands that haven't been categorized will display under this section in the help output. - **default_error**: the error that prints when a non-existent command is run - **disabled_commands**: commands that have been disabled from use. This is to support commands that are only available during specific states of the application. This dictionary's keys are the command names and its values are DisabledCommand objects. -- **doc_header**: Set the header used for the help function's listing of documented functions - **echo**: if `True`, each command the user issues will be repeated to the screen before it is executed. This is particularly useful when running scripts. This behavior does not occur when running a command at the prompt. (Default: `False`) - **editor**: text editor program to use with _edit_ command (e.g. `vim`) - **exclude_from_history**: commands to exclude from the _history_ command diff --git a/docs/features/modular_commands.md b/docs/features/modular_commands.md index 851668836..767c69554 100644 --- a/docs/features/modular_commands.md +++ b/docs/features/modular_commands.md @@ -40,11 +40,6 @@ CommandSets group multiple commands together. The plugin will inspect functions `CommandSet` using the same rules as when they're defined in `cmd2.Cmd`. Commands must be prefixed with `do_`, help functions with `help_`, and completer functions with `complete_`. -The [@with_default_category][cmd2.with_default_category] decorator is provided to categorize all -commands within a CommandSet class in the same command category. Individual commands in a CommandSet -class may override the default category by using the [@with_category][cmd2.with_category] decorator -on that method. - CommandSet command methods will always expect the same parameters as when defined in a `cmd2.Cmd` sub-class, except that `self` will now refer to the `CommandSet` instead of the cmd2 instance. The cmd2 instance can be accessed through `self._cmd` that is populated when the `CommandSet` is @@ -55,17 +50,20 @@ initializer arguments, see [Manual CommandSet Construction](#manual-commandset-c ```py import cmd2 -from cmd2 import CommandSet, with_default_category +from cmd2 import CommandSet -@with_default_category('My Category') class AutoLoadCommandSet(CommandSet): + DEFAULT_CATEGORY = 'My Category' + def __init__(self): super().__init__() def do_hello(self, _: cmd2.Statement): + """Hello Command.""" self._cmd.poutput('Hello') def do_world(self, _: cmd2.Statement): + """World Command.""" self._cmd.poutput('World') class ExampleApp(cmd2.Cmd): @@ -76,6 +74,7 @@ class ExampleApp(cmd2.Cmd): super().__init__(*args, auto_load_commands=True, **kwargs) def do_something(self, arg): + """Something Command.""" self.poutput('this is the something command') ``` @@ -86,10 +85,11 @@ construct CommandSets and pass in the initializer to Cmd2. ```py import cmd2 -from cmd2 import CommandSet, with_default_category +from cmd2 import CommandSet -@with_default_category('My Category') class CustomInitCommandSet(CommandSet): + DEFAULT_CATEGORY = 'My Category' + def __init__(self, arg1, arg2): super().__init__() @@ -97,9 +97,11 @@ class CustomInitCommandSet(CommandSet): self._arg2 = arg2 def do_show_arg1(self, _: cmd2.Statement): + """Show Arg 1.""" self._cmd.poutput(f'Arg1: {self._arg1}') def do_show_arg2(self, _: cmd2.Statement): + """Show Arg 2.""" self._cmd.poutput(f'Arg2: {self._arg2}') class ExampleApp(cmd2.Cmd): @@ -111,6 +113,7 @@ class ExampleApp(cmd2.Cmd): super().__init__(*args, auto_load_commands=True, **kwargs) def do_something(self, arg): + """Something Command.""" self.last_result = 5 self.poutput('this is the something command') @@ -131,30 +134,36 @@ You may need to disable command auto-loading if you need to dynamically load com ```py import argparse import cmd2 -from cmd2 import CommandSet, with_argparser, with_category, with_default_category +from cmd2 import CommandSet, with_argparser, with_category -@with_default_category('Fruits') class LoadableFruits(CommandSet): + DEFAULT_CATEGORY = 'Fruits' + def __init__(self): super().__init__() def do_apple(self, _: cmd2.Statement): + """Apple Command.""" self._cmd.poutput('Apple') def do_banana(self, _: cmd2.Statement): + """Banana Command.""" self._cmd.poutput('Banana') -@with_default_category('Vegetables') class LoadableVegetables(CommandSet): + DEFAULT_CATEGORY = 'Vegetables' + def __init__(self): super().__init__() def do_arugula(self, _: cmd2.Statement): + """Arugula Command.""" self._cmd.poutput('Arugula') def do_bokchoy(self, _: cmd2.Statement): + """Bok Choy Command.""" self._cmd.poutput('Bok Choy') @@ -176,6 +185,7 @@ class ExampleApp(cmd2.Cmd): @with_argparser(load_parser) @with_category('Command Loading') def do_load(self, ns: argparse.Namespace): + """Load Command.""" if ns.cmds == 'fruits': try: self.register_command_set(self._fruits) @@ -192,6 +202,7 @@ class ExampleApp(cmd2.Cmd): @with_argparser(load_parser) def do_unload(self, ns: argparse.Namespace): + """Unload Command.""" if ns.cmds == 'fruits': self.unregister_command_set(self._fruits) self.poutput('Fruits unloaded') @@ -211,21 +222,21 @@ if __name__ == '__main__': The following functions are called at different points in the [CommandSet][cmd2.CommandSet] life cycle. -[on_register][cmd2.command_definition.CommandSet.on_register] - Called by `cmd2.Cmd` as the first -step to registering a `CommandSet`. The commands defined in this class have not be added to the CLI -object at this point. Subclasses can override this to perform any initialization requiring access to -the Cmd object (e.g. configure commands and their parsers based on CLI state data). +[on_register][cmd2.command_set.CommandSet.on_register] - Called by `cmd2.Cmd` as the first step to +registering a `CommandSet`. The commands defined in this class have not be added to the CLI object +at this point. Subclasses can override this to perform any initialization requiring access to the +Cmd object (e.g. configure commands and their parsers based on CLI state data). -[on_registered][cmd2.command_definition.CommandSet.on_registered] - Called by `cmd2.Cmd` after a +[on_registered][cmd2.command_set.CommandSet.on_registered] - Called by `cmd2.Cmd` after a `CommandSet` is registered and all its commands have been added to the CLI. Subclasses can override this to perform custom steps related to the newly added commands (e.g. setting them to a disabled state). -[on_unregister][cmd2.command_definition.CommandSet.on_unregister] - Called by `cmd2.Cmd` as the -first step to unregistering a `CommandSet`. Subclasses can override this to perform any cleanup -steps which require their commands being registered in the CLI. +[on_unregister][cmd2.command_set.CommandSet.on_unregister] - Called by `cmd2.Cmd` as the first step +to unregistering a `CommandSet`. Subclasses can override this to perform any cleanup steps which +require their commands being registered in the CLI. -[on_unregistered][cmd2.command_definition.CommandSet.on_unregistered] - Called by `cmd2.Cmd` after a +[on_unregistered][cmd2.command_set.CommandSet.on_unregistered] - Called by `cmd2.Cmd` after a `CommandSet` has been unregistered and all its commands removed from the CLI. Subclasses can override this to perform remaining cleanup steps. @@ -254,15 +265,17 @@ a base command and each CommandSet adds a subcommand to it. ```py import argparse import cmd2 -from cmd2 import CommandSet, with_argparser, with_category, with_default_category +from cmd2 import CommandSet, with_argparser, with_category -@with_default_category('Fruits') class LoadableFruits(CommandSet): + DEFAULT_CATEGORY = 'Fruits' + def __init__(self): super().__init__() def do_apple(self, _: cmd2.Statement): + """Apple Command.""" self._cmd.poutput('Apple') banana_parser = cmd2.Cmd2ArgumentParser() @@ -274,12 +287,14 @@ class LoadableFruits(CommandSet): self._cmd.poutput('cutting banana: ' + ns.direction) -@with_default_category('Vegetables') class LoadableVegetables(CommandSet): + DEFAULT_CATEGORY = 'Vegetables' + def __init__(self): super().__init__() def do_arugula(self, _: cmd2.Statement): + """Arugula Command.""" self._cmd.poutput('Arugula') bokchoy_parser = cmd2.Cmd2ArgumentParser() @@ -287,6 +302,7 @@ class LoadableVegetables(CommandSet): @cmd2.as_subcommand_to('cut', 'bokchoy', bokchoy_parser) def cut_bokchoy(self, _: argparse.Namespace): + """Cut bok choy.""" self._cmd.poutput('Bok Choy') @@ -308,6 +324,7 @@ class ExampleApp(cmd2.Cmd): @with_argparser(load_parser) @with_category('Command Loading') def do_load(self, ns: argparse.Namespace): + """Load Command.""" if ns.cmds == 'fruits': try: self.register_command_set(self._fruits) @@ -324,6 +341,7 @@ class ExampleApp(cmd2.Cmd): @with_argparser(load_parser) def do_unload(self, ns: argparse.Namespace): + """Unload Command.""" if ns.cmds == 'fruits': self.unregister_command_set(self._fruits) self.poutput('Fruits unloaded') @@ -337,6 +355,7 @@ class ExampleApp(cmd2.Cmd): @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 diff --git a/examples/README.md b/examples/README.md index 2727ac64a..32f2549ed 100644 --- a/examples/README.md +++ b/examples/README.md @@ -39,8 +39,7 @@ each: - [custom_types.py](https://github.com/python-cmd2/cmd2/blob/main/examples/custom_types.py) - Some useful custom argument types - [default_categories.py](https://github.com/python-cmd2/cmd2/blob/main/examples/default_categories.py) - - Demonstrates usage of `@with_default_category` decorator to group and categorize commands and - `CommandSet` use + - Demonstrates usage of the `DEFAULT_CATEGORY` class variable to group and categorize commands. - [dynamic_commands.py](https://github.com/python-cmd2/cmd2/blob/main/examples/dynamic_commands.py) - Shows how `do_*` commands can be dynamically created programmatically at runtime - [environment.py](https://github.com/python-cmd2/cmd2/blob/main/examples/environment.py) diff --git a/examples/command_sets.py b/examples/command_sets.py index fb0e3e024..3d4caa6ab 100755 --- a/examples/command_sets.py +++ b/examples/command_sets.py @@ -20,7 +20,6 @@ CommandSet, with_argparser, with_category, - with_default_category, ) COMMANDSET_BASIC = "Basic CommandSet" @@ -29,8 +28,9 @@ COMMANDSET_SUBCOMMAND = "Subcommands with CommandSet" -@with_default_category(COMMANDSET_BASIC) class AutoLoadCommandSet(CommandSet): + DEFAULT_CATEGORY = COMMANDSET_BASIC + def __init__(self) -> None: """CommandSet class for auto-loading commands at startup.""" super().__init__() @@ -44,8 +44,9 @@ def do_world(self, _: cmd2.Statement) -> None: self._cmd.poutput('World') -@with_default_category(COMMANDSET_DYNAMIC) class LoadableFruits(CommandSet): + DEFAULT_CATEGORY = COMMANDSET_DYNAMIC + def __init__(self) -> None: """CommandSet class for dynamically loading commands related to fruits.""" super().__init__() @@ -68,8 +69,9 @@ def cut_banana(self, ns: argparse.Namespace) -> None: self._cmd.poutput('cutting banana: ' + ns.direction) -@with_default_category(COMMANDSET_DYNAMIC) class LoadableVegetables(CommandSet): + DEFAULT_CATEGORY = COMMANDSET_DYNAMIC + def __init__(self) -> None: """CommandSet class for dynamically loading commands related to vegetables.""" super().__init__() diff --git a/examples/default_categories.py b/examples/default_categories.py index e0f26b991..109ceb188 100755 --- a/examples/default_categories.py +++ b/examples/default_categories.py @@ -1,76 +1,68 @@ #!/usr/bin/env python3 -"""Simple example demonstrating basic CommandSet usage.""" +"""Example demonstrating the DEFAULT_CATEGORY class variable for Cmd and CommandSet. + +In cmd2 4.0, command categorization is driven by the DEFAULT_CATEGORY class variable. +This example shows: +1. How a Cmd class defines its own default category. +2. How a CommandSet defines its own default category. +3. How overriding a framework command moves it to the child class's category. +4. How to use @with_category to manually override the automatic categorization. +""" + +import argparse import cmd2 from cmd2 import ( + Cmd2ArgumentParser, CommandSet, - with_default_category, + with_argparser, + with_category, ) -@with_default_category('Default Category') -class MyBaseCommandSet(CommandSet): - """Defines a default category for all sub-class CommandSets.""" - - -class ChildInheritsParentCategories(MyBaseCommandSet): - """This subclass doesn't declare any categories so all commands here are also categorized under 'Default Category'.""" - - def do_hello(self, _: cmd2.Statement) -> None: - self._cmd.poutput('Hello') - - def do_world(self, _: cmd2.Statement) -> None: - self._cmd.poutput('World') - - -@with_default_category('Non-Heritable Category', heritable=False) -class ChildOverridesParentCategoriesNonHeritable(MyBaseCommandSet): - """This subclass overrides the 'Default Category' from the parent, but in a non-heritable fashion. Sub-classes of this - CommandSet will not inherit this category and will, instead, inherit 'Default Category'. - """ - - def do_goodbye(self, _: cmd2.Statement) -> None: - self._cmd.poutput('Goodbye') +class MyPlugin(CommandSet): + """A CommandSet that defines its own category.""" + DEFAULT_CATEGORY = "Plugin Commands" -class GrandchildInheritsGrandparentCategory(ChildOverridesParentCategoriesNonHeritable): - """This subclass's parent class declared its default category non-heritable. Instead, it inherits the category defined - by the grandparent class. - """ + def do_plugin_action(self, _: cmd2.Statement) -> None: + """A command defined in a CommandSet.""" + self._cmd.poutput("Plugin action executed") - def do_aloha(self, _: cmd2.Statement) -> None: - self._cmd.poutput('Aloha') +class CategoryApp(cmd2.Cmd): + """An application demonstrating various categorization scenarios.""" -@with_default_category('Heritable Category') -class ChildOverridesParentCategories(MyBaseCommandSet): - """This subclass is decorated with a default category that is heritable. This overrides the parent class's default - category declaration. - """ + # This sets the default category for all commands defined in this class + DEFAULT_CATEGORY = "Application Commands" - def do_bonjour(self, _: cmd2.Statement) -> None: - self._cmd.poutput('Bonjour') - - -class GrandchildInheritsHeritable(ChildOverridesParentCategories): - """This subclass's parent declares a default category that overrides its parent. As a result, commands in this - CommandSet will be categorized under 'Heritable Category'. - """ - - def do_monde(self, _: cmd2.Statement) -> None: - self._cmd.poutput('Monde') - - -class ExampleApp(cmd2.Cmd): - """Example to demonstrate heritable default categories.""" + # This overrides the category for the cmd2 built-in commands + cmd2.Cmd.DEFAULT_CATEGORY = "Cmd2 Shell Commands" def __init__(self) -> None: super().__init__() + # Register a command set to show how its categories integrate + self.register_command_set(MyPlugin()) + + def do_app_command(self, _: cmd2.Statement) -> None: + """A standard command defined in the child class.""" + self.poutput("Application command executed") - def do_something(self, _arg) -> None: - self.poutput('this is the something command') + @with_argparser(Cmd2ArgumentParser(description="Overridden quit command")) + def do_quit(self, _: argparse.Namespace) -> bool | None: + """Overriding a built-in command without a decorator moves it to our category.""" + return super().do_quit("") + + @with_category(cmd2.Cmd.DEFAULT_CATEGORY) + @with_argparser(Cmd2ArgumentParser(description="Overridden shortcuts command")) + def do_shortcuts(self, _: argparse.Namespace) -> None: + """Overriding with @with_category(cmd2.Cmd.DEFAULT_CATEGORY) keeps it cmd2's category.""" + super().do_shortcuts("") if __name__ == '__main__': - app = ExampleApp() - app.cmdloop() + import sys + + app = CategoryApp() + app.poutput("Type 'help' to see how the commands are categorized.\n") + sys.exit(app.cmdloop()) diff --git a/examples/getting_started.py b/examples/getting_started.py index d46de434a..a5668f0fc 100755 --- a/examples/getting_started.py +++ b/examples/getting_started.py @@ -33,7 +33,7 @@ class BasicApp(cmd2.Cmd): """Cmd2 application to demonstrate many common features.""" - CUSTOM_CATEGORY = 'My Custom Commands' + DEFAULT_CATEGORY = 'My Custom Commands' def __init__(self) -> None: """Initialize the cmd2 application.""" @@ -78,9 +78,6 @@ def __init__(self) -> None: # Allow access to your application in py and ipy via self self.self_in_py = True - # Set the default category name - self.default_category = 'cmd2 Built-in Commands' - # Color to output text in with echo command self.foreground_color = Color.CYAN.value @@ -120,12 +117,10 @@ def _refresh_bottom_toolbar(self) -> None: app.invalidate() time.sleep(0.5) - @cmd2.with_category(CUSTOM_CATEGORY) def do_intro(self, _: cmd2.Statement) -> None: """Display the intro banner.""" self.poutput(self.intro) - @cmd2.with_category(CUSTOM_CATEGORY) def do_echo(self, arg: cmd2.Statement) -> None: """Multiline command.""" self.poutput( diff --git a/examples/help_categories.py b/examples/help_categories.py index 7a1872509..c49843fa6 100755 --- a/examples/help_categories.py +++ b/examples/help_categories.py @@ -29,12 +29,12 @@ class HelpCategories(cmd2.Cmd): CMD_CAT_APP_MGMT = 'Application Management' CMD_CAT_SERVER_INFO = 'Server Information' + # Show all other commands in "Other" category + cmd2.Cmd.DEFAULT_CATEGORY = 'Other' + def __init__(self) -> None: super().__init__() - # Set the default category for uncategorized commands - self.default_category = 'Other' - def do_connect(self, _) -> None: """Connect command.""" self.poutput('Connect') diff --git a/examples/modular_commands/commandset_basic.py b/examples/modular_commands/commandset_basic.py index b84e57ab3..517340ab6 100644 --- a/examples/modular_commands/commandset_basic.py +++ b/examples/modular_commands/commandset_basic.py @@ -3,14 +3,15 @@ from cmd2 import ( CommandSet, CompletionError, + Completions, Statement, with_category, - with_default_category, ) -@with_default_category('Basic Completion') class BasicCompletionCommandSet(CommandSet): + DEFAULT_CATEGORY = 'Basic Completion' + # This data is used to demonstrate delimiter_complete file_strs = ( '/home/user/file.db', @@ -24,14 +25,14 @@ def do_delimiter_complete(self, statement: Statement) -> None: """Tab completes files from a list using delimiter_complete.""" self._cmd.poutput(f"Args: {statement.args}") - def complete_delimiter_complete(self, text: str, line: str, begidx: int, endidx: int) -> list[str]: + def complete_delimiter_complete(self, text: str, line: str, begidx: int, endidx: int) -> Completions: return self._cmd.delimiter_complete(text, line, begidx, endidx, match_against=self.file_strs, delimiter='/') def do_raise_error(self, statement: Statement) -> None: """Demonstrates effect of raising CompletionError.""" self._cmd.poutput(f"Args: {statement.args}") - def complete_raise_error(self, _text: str, _line: str, _begidx: int, _endidx: int) -> list[str]: + def complete_raise_error(self, _text: str, _line: str, _begidx: int, _endidx: int) -> Completions: """CompletionErrors can be raised if an error occurs while tab completing. Example use cases diff --git a/examples/modular_commands/commandset_complex.py b/examples/modular_commands/commandset_complex.py index d1e157b98..8d78b97b1 100644 --- a/examples/modular_commands/commandset_complex.py +++ b/examples/modular_commands/commandset_complex.py @@ -5,9 +5,11 @@ import cmd2 -@cmd2.with_default_category('Fruits') class CommandSetA(cmd2.CommandSet): + DEFAULT_CATEGORY = 'Fruits' + def do_apple(self, _statement: cmd2.Statement) -> None: + """Apple Command.""" self._cmd.poutput('Apple!') def do_banana(self, _statement: cmd2.Statement) -> None: diff --git a/examples/modular_commands/commandset_custominit.py b/examples/modular_commands/commandset_custominit.py index fcd8bfa41..f136d690e 100644 --- a/examples/modular_commands/commandset_custominit.py +++ b/examples/modular_commands/commandset_custominit.py @@ -3,12 +3,12 @@ from cmd2 import ( CommandSet, Statement, - with_default_category, ) -@with_default_category('Custom Init') class CustomInitCommandSet(CommandSet): + DEFAULT_CATEGORY = 'Custom Init' + def __init__(self, arg1, arg2) -> None: super().__init__() @@ -16,7 +16,9 @@ def __init__(self, arg1, arg2) -> None: self._arg2 = arg2 def do_show_arg1(self, _: Statement) -> None: + """Show Arg 1.""" self._cmd.poutput('Arg1: ' + self._arg1) def do_show_arg2(self, _: Statement) -> None: + """Show Arg 2.""" self._cmd.poutput('Arg2: ' + self._arg2) diff --git a/examples/rich_tables.py b/examples/rich_tables.py index e2c891064..cc336d79b 100755 --- a/examples/rich_tables.py +++ b/examples/rich_tables.py @@ -64,7 +64,7 @@ class TableApp(cmd2.Cmd): """Cmd2 application to demonstrate displaying tabular data using rich.""" - TABLE_CATEGORY = 'Table Commands' + DEFAULT_CATEGORY = 'Table Commands' def __init__(self) -> None: """Initialize the cmd2 application.""" @@ -73,10 +73,6 @@ def __init__(self) -> None: # Prints an intro banner once upon application startup self.intro = 'Are you curious which countries and cities on Earth have the largest populations?' - # Set the default category name - self.default_category = 'cmd2 Built-in Commands' - - @cmd2.with_category(TABLE_CATEGORY) def do_cities(self, _: cmd2.Statement) -> None: """Display the cities with the largest population.""" table = Table(title=CITY_TITLE, caption=CITY_CAPTION) @@ -91,7 +87,6 @@ def do_cities(self, _: cmd2.Statement) -> None: self.poutput(table) - @cmd2.with_category(TABLE_CATEGORY) def do_countries(self, _: cmd2.Statement) -> None: """Display the countries with the largest population.""" table = Table(title=COUNTRY_TITLE, caption=COUNTRY_CAPTION) diff --git a/mkdocs.yml b/mkdocs.yml index b21b9ee8a..2511a6943 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -198,7 +198,7 @@ nav: - api/argparse_custom.md - api/clipboard.md - api/colors.md - - api/command_definition.md + - api/command_set.md - api/completion.md - api/constants.md - api/decorators.md diff --git a/tests/test_categories.py b/tests/test_categories.py index ee53bb134..37639825f 100644 --- a/tests/test_categories.py +++ b/tests/test_categories.py @@ -1,109 +1,130 @@ -"""Simple example demonstrating basic CommandSet usage.""" +"""Tests help categories for Cmd and CommandSet objects.""" -from typing import Any +import argparse import cmd2 from cmd2 import ( + Cmd2ArgumentParser, CommandSet, - with_default_category, + with_argparser, + with_category, ) -@with_default_category('Default Category') -class MyBaseCommandSet(CommandSet): - """Defines a default category for all sub-class CommandSets""" - - def __init__(self, _: Any) -> None: - super().__init__() +class NoCategoryCmd(cmd2.Cmd): + """Example to demonstrate a Cmd-based class which does not define its own DEFAULT_CATEGORY. + Its commands will inherit the parent class's DEFAULT_CATEGORY. + """ -class ChildInheritsParentCategories(MyBaseCommandSet): - """This subclass doesn't declare any categories so all commands here are also categorized under 'Default Category'""" + def do_inherit(self, _: cmd2.Statement) -> None: + """This function has a docstring. - def do_hello(self, _: cmd2.Statement) -> None: - self._cmd.poutput('Hello') + Since this class does NOT define its own DEFAULT_CATEGORY, + this command will show in cmd2.Cmd.DEFAULT_CATEGORY + """ - def do_world(self, _: cmd2.Statement) -> None: - self._cmd.poutput('World') +class CategoryCmd(cmd2.Cmd): + """Example to demonstrate custom DEFAULT_CATEGORY in a Cmd-based class. -@with_default_category('Non-Heritable Category', heritable=False) -class ChildOverridesParentCategoriesNonHeritable(MyBaseCommandSet): - """This subclass overrides the 'Default Category' from the parent, but in a non-heritable fashion. Sub-classes of this - CommandSet will not inherit this category and will, instead, inherit 'Default Category' + It also includes functions to fully exercise Cmd._build_command_info. """ - def do_goodbye(self, _: cmd2.Statement) -> None: - self._cmd.poutput('Goodbye') + DEFAULT_CATEGORY = "CategoryCmd Commands" + def do_cmd_command(self, _: cmd2.Statement) -> None: + """The cmd command. -class GrandchildInheritsGrandparentCategory(ChildOverridesParentCategoriesNonHeritable): - """This subclass's parent class declared its default category non-heritable. Instead, it inherits the category defined - by the grandparent class. - """ + Since this class DOES define its own DEFAULT_CATEGORY, + this command will show in CategoryCmd.DEFAULT_CATEGORY + """ - def do_aloha(self, _: cmd2.Statement) -> None: - self._cmd.poutput('Aloha') + @with_argparser(Cmd2ArgumentParser(description="Overridden quit command")) + def do_quit(self, _: argparse.Namespace) -> None: + """This function overrides the cmd2.Cmd quit command. + Since this override does not use the with_category decorator, + it will be in CategoryCmd.DEFAULT_CATEGORY and not cmd2.Cmd.DEFAULT_CATEGORY. + """ -@with_default_category('Heritable Category') -class ChildOverridesParentCategories(MyBaseCommandSet): - """This subclass is decorated with a default category that is heritable. This overrides the parent class's default - category declaration. - """ + @with_category(cmd2.Cmd.DEFAULT_CATEGORY) + @with_argparser(Cmd2ArgumentParser(description="Overridden shortcuts command")) + def do_shortcuts(self, _: argparse.Namespace) -> None: + """This function overrides the cmd2.Cmd shortcut command. - def do_bonjour(self, _: cmd2.Statement) -> None: - self._cmd.poutput('Bonjour') + It also uses the with_category decorator to keep shortcuts in + cmd2.Cmd.DEFAULT_CATEGORY for the parent class. + """ + def do_has_help_func(self, _: cmd2.Statement) -> None: + """This command has a help function.""" -class GrandchildInheritsHeritable(ChildOverridesParentCategories): - """This subclass's parent declares a default category that overrides its parent. As a result, commands in this - CommandSet will be categorized under 'Heritable Category' - """ + def help_has_help_func(self) -> None: + """Help function for the has_help_func command.""" + self.poutput("has_help_func help text.") + + def help_coding(self) -> None: + """This help function not tied to a command. + + It will be in help topics. + """ + self.poutput("Read a book.") - def do_monde(self, _: cmd2.Statement) -> None: - self._cmd.poutput('Monde') +def test_no_category_cmd() -> None: + app = NoCategoryCmd() + cmds_cats, _help_topics = app._build_command_info() + assert "inherit" in cmds_cats[cmd2.Cmd.DEFAULT_CATEGORY] + + +def test_category_cmd() -> None: + app = CategoryCmd() + cmds_cats, help_topics = app._build_command_info() + + assert "cmd_command" in cmds_cats[CategoryCmd.DEFAULT_CATEGORY] + assert "quit" in cmds_cats[CategoryCmd.DEFAULT_CATEGORY] + assert "shortcuts" in cmds_cats[cmd2.Cmd.DEFAULT_CATEGORY] + assert "has_help_func" in cmds_cats[CategoryCmd.DEFAULT_CATEGORY] + assert "coding" in help_topics + + +class NoCategoryCommandSet(CommandSet): + """Example to demonstrate a CommandSet which does not define its own DEFAULT_CATEGORY. + + Its commands will inherit the parent class's DEFAULT_CATEGORY. + """ -class ExampleApp(cmd2.Cmd): - """Example to demonstrate heritable default categories""" + def do_inherit(self, _: cmd2.Statement) -> None: + """This function has a docstring. - def __init__(self) -> None: - super().__init__(auto_load_commands=False) + Since this class does NOT define its own DEFAULT_CATEGORY, + this command will show in CommandSet.DEFAULT_CATEGORY + """ - def do_something(self, arg) -> None: - self.poutput('this is the something command') +class CategoryCommandSet(CommandSet): + """Example to demonstrate custom DEFAULT_CATEGORY in a CommandSet.""" -def test_heritable_categories() -> None: - app = ExampleApp() + DEFAULT_CATEGORY = "CategoryCommandSet Commands" - base_cs = MyBaseCommandSet(0) - assert getattr(base_cs, cmd2.constants.CMDSET_ATTR_DEFAULT_HELP_CATEGORY, None) == 'Default Category' + def do_cmdset_command(self, _: cmd2.Statement) -> None: + """The cmdset command. - child1 = ChildInheritsParentCategories(1) - assert getattr(child1, cmd2.constants.CMDSET_ATTR_DEFAULT_HELP_CATEGORY, None) == 'Default Category' - app.register_command_set(child1) - assert getattr(app.cmd_func('hello').__func__, cmd2.constants.CMD_ATTR_HELP_CATEGORY, None) == 'Default Category' - app.unregister_command_set(child1) + Since this class DOES define its own DEFAULT_CATEGORY, + this command will show in CategoryCommandSet.DEFAULT_CATEGORY + """ - child_nonheritable = ChildOverridesParentCategoriesNonHeritable(2) - assert getattr(child_nonheritable, cmd2.constants.CMDSET_ATTR_DEFAULT_HELP_CATEGORY, None) != 'Non-Heritable Category' - app.register_command_set(child_nonheritable) - assert getattr(app.cmd_func('goodbye').__func__, cmd2.constants.CMD_ATTR_HELP_CATEGORY, None) == 'Non-Heritable Category' - app.unregister_command_set(child_nonheritable) - grandchild1 = GrandchildInheritsGrandparentCategory(3) - assert getattr(grandchild1, cmd2.constants.CMDSET_ATTR_DEFAULT_HELP_CATEGORY, None) == 'Default Category' - app.register_command_set(grandchild1) - assert getattr(app.cmd_func('aloha').__func__, cmd2.constants.CMD_ATTR_HELP_CATEGORY, None) == 'Default Category' - app.unregister_command_set(grandchild1) +def test_no_category_command_set() -> None: + app = cmd2.Cmd() + app.register_command_set(NoCategoryCommandSet()) + cmds_cats, _help_topics = app._build_command_info() + assert "inherit" in cmds_cats[CommandSet.DEFAULT_CATEGORY] - child_overrides = ChildOverridesParentCategories(4) - assert getattr(child_overrides, cmd2.constants.CMDSET_ATTR_DEFAULT_HELP_CATEGORY, None) == 'Heritable Category' - app.register_command_set(child_overrides) - assert getattr(app.cmd_func('bonjour').__func__, cmd2.constants.CMD_ATTR_HELP_CATEGORY, None) == 'Heritable Category' - app.unregister_command_set(child_overrides) - grandchild2 = GrandchildInheritsHeritable(5) - assert getattr(grandchild2, cmd2.constants.CMDSET_ATTR_DEFAULT_HELP_CATEGORY, None) == 'Heritable Category' +def test_category_command_set() -> None: + app = cmd2.Cmd() + app.register_command_set(CategoryCommandSet()) + cmds_cats, _help_topics = app._build_command_info() + assert "cmdset_command" in cmds_cats[CategoryCommandSet.DEFAULT_CATEGORY] diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index e7293c30b..944870298 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1303,12 +1303,12 @@ def test_visible_prompt() -> None: class HelpApp(cmd2.Cmd): """Class for testing custom help_* methods which override docstring help.""" + DEFAULT_CATEGORY = "My Default Category." + MISC_HEADER = "Various topics found here." + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.doc_leader = "I now present you with a list of help topics." - self.doc_header = "My very custom doc header." - self.misc_header = "Various topics found here." - self.undoc_header = "Why did no one document these?" def do_squat(self, arg) -> None: """This docstring help will never be shown because the help_squat method overrides it.""" @@ -1319,8 +1319,8 @@ def help_squat(self) -> None: def do_edit(self, arg) -> None: """This overrides the edit command and does nothing.""" - # This command will be in the "undocumented" section of the help menu - def do_undoc(self, arg) -> None: + # This command has no help text + def do_no_help(self, arg) -> None: pass def do_multiline_docstr(self, arg) -> None: @@ -1352,9 +1352,8 @@ def test_help_headers(capsys) -> None: out, _err = capsys.readouterr() assert help_app.doc_leader in out - assert help_app.doc_header in out - assert help_app.misc_header in out - assert help_app.undoc_header in out + assert HelpApp.DEFAULT_CATEGORY in out + assert HelpApp.MISC_HEADER in out assert help_app.last_result is True @@ -1371,9 +1370,9 @@ def test_custom_help_menu(help_app) -> None: assert help_app.last_result is True -def test_help_undocumented(help_app) -> None: - _out, err = run_cmd(help_app, 'help undoc') - assert err[0].startswith("No help on undoc") +def test_help_no_help(help_app) -> None: + _out, err = run_cmd(help_app, 'help no_help') + assert err[0].startswith("No help on no_help") assert help_app.last_result is False @@ -1409,7 +1408,7 @@ def test_help_verbose_with_fake_command(capsys) -> None: help_app = HelpApp() cmds = ["alias", "fake_command"] - help_app._print_documented_command_topics(help_app.doc_header, cmds, verbose=True) + help_app._print_documented_command_topics(help_app.DEFAULT_CATEGORY, cmds, verbose=True) out, _err = capsys.readouterr() assert cmds[0] in out assert cmds[1] not in out @@ -1464,7 +1463,7 @@ def do_diddly(self, arg) -> None: def do_cat_nodoc(self, arg) -> None: pass - # This command will show in the category labeled with self.default_category + # This command will show in the category labeled with DEFAULT_CATEGORY def do_squat(self, arg) -> None: """This docstring help will never be shown because the help_squat method overrides it.""" @@ -1476,10 +1475,6 @@ def do_edit(self, arg) -> None: cmd2.categorize((do_squat, do_edit), CUSTOM_CATEGORY) - # This command will be in the "undocumented" section of the help menu - def do_undoc(self, arg) -> None: - pass - @pytest.fixture def helpcat_app(): @@ -1494,7 +1489,7 @@ def test_help_cat_base(helpcat_app) -> None: help_text = ''.join(out) assert helpcat_app.CUSTOM_CATEGORY in help_text assert helpcat_app.SOME_CATEGORY in help_text - assert helpcat_app.default_category in help_text + assert helpcat_app.DEFAULT_CATEGORY in help_text def test_help_cat_verbose(helpcat_app) -> None: @@ -1505,7 +1500,7 @@ def test_help_cat_verbose(helpcat_app) -> None: help_text = ''.join(out) assert helpcat_app.CUSTOM_CATEGORY in help_text assert helpcat_app.SOME_CATEGORY in help_text - assert helpcat_app.default_category in help_text + assert helpcat_app.DEFAULT_CATEGORY in help_text class SelectApp(cmd2.Cmd): @@ -3826,35 +3821,45 @@ def test_ansi_never_notty(mocker, capsys) -> None: class DisableCommandsApp(cmd2.Cmd): """Class for disabling commands""" + DEFAULT_CATEGORY = "DisabledApp Commands" category_name = "Test Category" def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) @cmd2.with_category(category_name) - def do_has_helper_funcs(self, arg) -> None: - self.poutput("The real has_helper_funcs") + def do_has_helper_func(self, arg) -> None: + self.poutput("The real has_helper_func") - def help_has_helper_funcs(self) -> None: - self.poutput('Help for has_helper_funcs') + def help_has_helper_func(self) -> None: + self.poutput('Help for has_helper_func') - def complete_has_helper_funcs(self, *args) -> Completions: + def complete_has_helper_func(self, *args) -> Completions: return Completions.from_values(['result']) @cmd2.with_category(category_name) - def do_has_no_helper_funcs(self, arg) -> None: - """Help for has_no_helper_funcs""" - self.poutput("The real has_no_helper_funcs") + def do_has_no_helper_func(self, arg) -> None: + """Help for has_no_helper_func""" + self.poutput("The real has_no_helper_func") + + def do_is_not_decorated(self, arg) -> None: + """This will be in the DEFAULT_CATEGORY.""" + self.poutput("The real is_not_decorated") class DisableCommandSet(CommandSet): """Test registering a command which is in a disabled category""" category_name = "CommandSet Test Category" + DEFAULT_CATEGORY = "DisableCommandSet Commands" @cmd2.with_category(category_name) def do_new_command(self, arg) -> None: - self._cmd.poutput("CommandSet function is enabled") + self._cmd.poutput("The real new_command") + + def do_cs_is_not_decorated(self, arg) -> None: + """This will be in the DEFAULT_CATEGORY.""" + self._cmd.poutput("The real cs_is_not_decorated") @pytest.fixture @@ -3867,24 +3872,35 @@ def test_disable_and_enable_category(disable_commands_app: DisableCommandsApp) - # Disable the category ########################################################################## message_to_print = 'These commands are currently disabled' + + # Disable commands which are decorated with a category disable_commands_app.disable_category(disable_commands_app.category_name, message_to_print) + # Disable commands in the default category + disable_commands_app.disable_category(disable_commands_app.DEFAULT_CATEGORY, message_to_print) + # Make sure all the commands and help on those commands displays the message - out, err = run_cmd(disable_commands_app, 'has_helper_funcs') + out, err = run_cmd(disable_commands_app, 'has_helper_func') + assert err[0].startswith(message_to_print) + + out, err = run_cmd(disable_commands_app, 'help has_helper_func') assert err[0].startswith(message_to_print) - out, err = run_cmd(disable_commands_app, 'help has_helper_funcs') + out, err = run_cmd(disable_commands_app, 'has_no_helper_func') assert err[0].startswith(message_to_print) - out, err = run_cmd(disable_commands_app, 'has_no_helper_funcs') + out, err = run_cmd(disable_commands_app, 'help has_no_helper_func') assert err[0].startswith(message_to_print) - out, err = run_cmd(disable_commands_app, 'help has_no_helper_funcs') + out, err = run_cmd(disable_commands_app, 'is_not_decorated') + assert err[0].startswith(message_to_print) + + out, err = run_cmd(disable_commands_app, 'help is_not_decorated') assert err[0].startswith(message_to_print) # Make sure neither function completes text = '' - line = f'has_helper_funcs {text}' + line = f'has_helper_func {text}' endidx = len(line) begidx = endidx - len(text) @@ -3892,7 +3908,7 @@ def test_disable_and_enable_category(disable_commands_app: DisableCommandsApp) - assert not completions text = '' - line = f'has_no_helper_funcs {text}' + line = f'has_no_helper_func {text}' endidx = len(line) begidx = endidx - len(text) @@ -3901,63 +3917,71 @@ def test_disable_and_enable_category(disable_commands_app: DisableCommandsApp) - # Make sure both commands are invisible visible_commands = disable_commands_app.get_visible_commands() - assert 'has_helper_funcs' not in visible_commands - assert 'has_no_helper_funcs' not in visible_commands + assert 'has_helper_func' not in visible_commands + assert 'has_no_helper_func' not in visible_commands # Make sure get_help_topics() filters out disabled commands help_topics = disable_commands_app.get_help_topics() - assert 'has_helper_funcs' not in help_topics + assert 'has_helper_func' not in help_topics ########################################################################## # Enable the category ########################################################################## + # Enable commands which are decorated with a category disable_commands_app.enable_category(disable_commands_app.category_name) + # Enable commands in the default category + disable_commands_app.enable_category(disable_commands_app.DEFAULT_CATEGORY) + # Make sure all the commands and help on those commands are restored - out, err = run_cmd(disable_commands_app, 'has_helper_funcs') - assert out[0] == "The real has_helper_funcs" + out, err = run_cmd(disable_commands_app, 'has_helper_func') + assert out[0] == "The real has_helper_func" - out, err = run_cmd(disable_commands_app, 'help has_helper_funcs') - assert out[0] == "Help for has_helper_funcs" + out, err = run_cmd(disable_commands_app, 'help has_helper_func') + assert out[0] == "Help for has_helper_func" - out, err = run_cmd(disable_commands_app, 'has_no_helper_funcs') - assert out[0] == "The real has_no_helper_funcs" + out, err = run_cmd(disable_commands_app, 'has_no_helper_func') + assert out[0] == "The real has_no_helper_func" - out, err = run_cmd(disable_commands_app, 'help has_no_helper_funcs') - assert out[0] == "Help for has_no_helper_funcs" + out, err = run_cmd(disable_commands_app, 'help has_no_helper_func') + assert out[0] == "Help for has_no_helper_func" - # has_helper_funcs should complete now + out, err = run_cmd(disable_commands_app, 'is_not_decorated') + assert out[0] == "The real is_not_decorated" + + # has_helper_func should complete now text = '' - line = f'has_helper_funcs {text}' + line = f'has_helper_func {text}' endidx = len(line) begidx = endidx - len(text) completions = disable_commands_app.complete(text, line, begidx, endidx) assert completions[0].text == "result" - # has_no_helper_funcs had no completer originally, so there should be no results + # has_no_helper_func had no completer originally, so there should be no results text = '' - line = f'has_no_helper_funcs {text}' + line = f'has_no_helper_func {text}' endidx = len(line) begidx = endidx - len(text) completions = disable_commands_app.complete(text, line, begidx, endidx) assert not completions - # Make sure both commands are visible + # Make sure all commands are visible visible_commands = disable_commands_app.get_visible_commands() - assert 'has_helper_funcs' in visible_commands - assert 'has_no_helper_funcs' in visible_commands + assert 'has_helper_func' in visible_commands + assert 'has_no_helper_func' in visible_commands + assert 'is_not_decorated' in visible_commands # Make sure get_help_topics() contains our help function help_topics = disable_commands_app.get_help_topics() - assert 'has_helper_funcs' in help_topics + assert 'has_helper_func' in help_topics def test_enable_enabled_command(disable_commands_app) -> None: # Test enabling a command that is not disabled saved_len = len(disable_commands_app.disabled_commands) - disable_commands_app.enable_command('has_helper_funcs') + disable_commands_app.enable_command('has_helper_func') # The number of disabled commands should not have changed assert saved_len == len(disable_commands_app.disabled_commands) @@ -3971,7 +3995,7 @@ def test_disable_fake_command(disable_commands_app) -> None: def test_disable_command_twice(disable_commands_app) -> None: saved_len = len(disable_commands_app.disabled_commands) message_to_print = 'These commands are currently disabled' - disable_commands_app.disable_command('has_helper_funcs', message_to_print) + disable_commands_app.disable_command('has_helper_func', message_to_print) # The number of disabled commands should have increased one new_len = len(disable_commands_app.disabled_commands) @@ -3979,46 +4003,63 @@ def test_disable_command_twice(disable_commands_app) -> None: saved_len = new_len # Disable again and the length should not change - disable_commands_app.disable_command('has_helper_funcs', message_to_print) + disable_commands_app.disable_command('has_helper_func', message_to_print) new_len = len(disable_commands_app.disabled_commands) assert saved_len == new_len def test_disabled_command_not_in_history(disable_commands_app) -> None: message_to_print = 'These commands are currently disabled' - disable_commands_app.disable_command('has_helper_funcs', message_to_print) + disable_commands_app.disable_command('has_helper_func', message_to_print) saved_len = len(disable_commands_app.history) - run_cmd(disable_commands_app, 'has_helper_funcs') + run_cmd(disable_commands_app, 'has_helper_func') assert saved_len == len(disable_commands_app.history) def test_disabled_message_command_name(disable_commands_app) -> None: message_to_print = f'{COMMAND_NAME} is currently disabled' - disable_commands_app.disable_command('has_helper_funcs', message_to_print) + disable_commands_app.disable_command('has_helper_func', message_to_print) - _out, err = run_cmd(disable_commands_app, 'has_helper_funcs') - assert err[0].startswith('has_helper_funcs is currently disabled') + _out, err = run_cmd(disable_commands_app, 'has_helper_func') + assert err[0].startswith('has_helper_func is currently disabled') def test_register_command_in_enabled_category(disable_commands_app) -> None: + # Enable commands which are decorated with a category disable_commands_app.enable_category(DisableCommandSet.category_name) + + # Enable commands in the default category + disable_commands_app.enable_category(DisableCommandSet.DEFAULT_CATEGORY) + cs = DisableCommandSet() disable_commands_app.register_command_set(cs) out, _err = run_cmd(disable_commands_app, 'new_command') - assert out[0] == "CommandSet function is enabled" + assert out[0] == "The real new_command" + + out, _err = run_cmd(disable_commands_app, 'cs_is_not_decorated') + assert out[0] == "The real cs_is_not_decorated" def test_register_command_in_disabled_category(disable_commands_app) -> None: message_to_print = "CommandSet function is disabled" + + # Disable commands which are decorated with a category disable_commands_app.disable_category(DisableCommandSet.category_name, message_to_print) + + # Disable commands in the default category + disable_commands_app.disable_category(DisableCommandSet.DEFAULT_CATEGORY, message_to_print) + cs = DisableCommandSet() disable_commands_app.register_command_set(cs) _out, err = run_cmd(disable_commands_app, 'new_command') assert err[0] == message_to_print + _out, err = run_cmd(disable_commands_app, 'cs_is_not_decorated') + assert err[0] == message_to_print + def test_enable_enabled_category(disable_commands_app) -> None: # Test enabling a category that is not disabled @@ -4123,6 +4164,17 @@ def test_custom_completekey_ctrl_k(): assert found, "Could not find binding for 'c-k' (Keys.ControlK) in session key bindings" +def test_completekey_empty_string() -> None: + # Test that an empty string for completekey defaults to DEFAULT_COMPLETEKEY + with mock.patch('cmd2.Cmd._create_main_session', autospec=True) as create_session_mock: + create_session_mock.return_value = mock.MagicMock(spec=PromptSession) + app = cmd2.Cmd(completekey='') + + # Verify it was called with DEFAULT_COMPLETEKEY + # auto_suggest is the second arg and it defaults to True + create_session_mock.assert_called_once_with(app, True, app.DEFAULT_COMPLETEKEY) + + def test_create_main_session_exception(monkeypatch): # Mock PromptSession to raise ValueError on first call, then succeed diff --git a/tests/test_commandset.py b/tests/test_commandset.py index 067a81215..07deeeb40 100644 --- a/tests/test_commandset.py +++ b/tests/test_commandset.py @@ -25,8 +25,9 @@ class CommandSetBase(cmd2.CommandSet): pass -@cmd2.with_default_category('Fruits') class CommandSetA(CommandSetBase): + DEFAULT_CATEGORY = 'Fruits' + def on_register(self, cmd) -> None: super().on_register(cmd) print("in on_register now") @@ -44,6 +45,7 @@ def on_unregistered(self) -> None: print("in on_unregistered now") def do_apple(self, statement: cmd2.Statement) -> None: + """Apple Command""" self._cmd.poutput('Apple!') def do_banana(self, statement: cmd2.Statement) -> None: @@ -55,6 +57,7 @@ def do_banana(self, statement: cmd2.Statement) -> None: @cmd2.with_argparser(cranberry_parser, with_unknown_args=True) def do_cranberry(self, ns: argparse.Namespace, unknown: list[str]) -> None: + """Cranberry Command""" self._cmd.poutput(f'Cranberry {ns.arg1}!!') if unknown and len(unknown): self._cmd.poutput('Unknown: ' + ', '.join(['{}'] * len(unknown)).format(*unknown)) @@ -80,6 +83,7 @@ def complete_durian(self, text: str, line: str, begidx: int, endidx: int) -> lis @cmd2.with_category('Alone') @cmd2.with_argparser(elderberry_parser) def do_elderberry(self, ns: argparse.Namespace) -> None: + """Elderberry Command""" self._cmd.poutput(f'Elderberry {ns.arg1}!!') self._cmd.last_result = {'arg1': ns.arg1} @@ -103,27 +107,30 @@ def subcmd_func(self, args: argparse.Namespace) -> None: self._cmd.poutput("Subcommand Ran") -@cmd2.with_default_category('Command Set B') class CommandSetB(CommandSetBase): + DEFAULT_CATEGORY = 'Command Set B' + def __init__(self, arg1) -> None: super().__init__() self._arg1 = arg1 def do_aardvark(self, statement: cmd2.Statement) -> None: + """Aardvark Command""" self._cmd.poutput('Aardvark!') def do_bat(self, statement: cmd2.Statement) -> None: - """Banana Command""" + """Bat Command""" self._cmd.poutput('Bat!!') def do_crocodile(self, statement: cmd2.Statement) -> None: + """Crocodile Command""" self._cmd.poutput('Crocodile!!') def test_autoload_commands(autoload_command_sets_app) -> None: # verifies that, when autoload is enabled, CommandSets and registered functions all show up - cmds_cats, _cmds_doc, _cmds_undoc, _help_topics = autoload_command_sets_app._build_command_info() + cmds_cats, _help_topics = autoload_command_sets_app._build_command_info() assert 'Alone' in cmds_cats assert 'elderberry' in cmds_cats['Alone'] @@ -152,7 +159,7 @@ def __init__(self, arg1) -> None: @cmd2.with_argparser(cmd2.Cmd2ArgumentParser(description="Native Command")) def do_builtin(self, _) -> None: - pass + """Builtin Command""" # Create a synonym to a command inside of this CommandSet do_builtin_synonym = do_builtin @@ -199,7 +206,7 @@ def test_custom_construct_commandsets() -> None: # Verifies that a custom initialized CommandSet loads correctly when passed into the constructor app = WithCommandSets(command_sets=[command_set_b]) - cmds_cats, _cmds_doc, _cmds_undoc, _help_topics = app._build_command_info() + cmds_cats, _help_topics = app._build_command_info() assert 'Command Set B' in cmds_cats # Verifies that the same CommandSet cannot be loaded twice @@ -250,7 +257,7 @@ def test_load_commands(manual_command_sets_app, capsys) -> None: assert "in on_register now" in out assert "in on_registered now" in out - cmds_cats, _cmds_doc, _cmds_undoc, _help_topics = manual_command_sets_app._build_command_info() + cmds_cats, _help_topics = manual_command_sets_app._build_command_info() assert 'Alone' in cmds_cats assert 'elderberry' in cmds_cats['Alone'] @@ -266,7 +273,7 @@ def test_load_commands(manual_command_sets_app, capsys) -> None: # uninstall the command set and verify it is now also no longer accessible manual_command_sets_app.unregister_command_set(cmd_set) - cmds_cats, _cmds_doc, _cmds_undoc, _help_topics = manual_command_sets_app._build_command_info() + cmds_cats, _help_topics = manual_command_sets_app._build_command_info() assert 'Alone' not in cmds_cats assert 'Fruits' not in cmds_cats @@ -282,7 +289,7 @@ def test_load_commands(manual_command_sets_app, capsys) -> None: # reinstall the command set and verify it is accessible manual_command_sets_app.register_command_set(cmd_set) - cmds_cats, _cmds_doc, _cmds_undoc, _help_topics = manual_command_sets_app._build_command_info() + cmds_cats, _help_topics = manual_command_sets_app._build_command_info() assert 'Alone' in cmds_cats assert 'elderberry' in cmds_cats['Alone'] @@ -335,7 +342,7 @@ def test_load_commandset_errors(manual_command_sets_app, capsys) -> None: manual_command_sets_app.register_command_set(cmd_set) # verify that the commands weren't installed - cmds_cats, _cmds_doc, _cmds_undoc, _help_topics = manual_command_sets_app._build_command_info() + cmds_cats, _help_topics = manual_command_sets_app._build_command_info() assert 'Alone' not in cmds_cats assert 'Fruits' not in cmds_cats @@ -457,13 +464,15 @@ def do_cut(self, ns: argparse.Namespace) -> None: self._cmd.do_help('cut') -@cmd2.with_default_category('Fruits') class LoadableFruits(cmd2.CommandSet): + DEFAULT_CATEGORY = 'Fruits' + def __init__(self, dummy) -> None: super().__init__() self._dummy = dummy # prevents autoload def do_apple(self, _: cmd2.Statement) -> None: + """Apple Command""" self._cmd.poutput('Apple') banana_parser = cmd2.Cmd2ArgumentParser() @@ -488,13 +497,15 @@ def stir_pasta_vigorously(self, ns: argparse.Namespace) -> None: self._cmd.poutput('stir the pasta vigorously') -@cmd2.with_default_category('Vegetables') class LoadableVegetables(cmd2.CommandSet): + DEFAULT_CATEGORY = 'Vegetables' + def __init__(self, dummy) -> None: super().__init__() self._dummy = dummy # prevents autoload def do_arugula(self, _: cmd2.Statement) -> None: + """Arugula Command""" self._cmd.poutput('Arugula') def complete_style_arg(self, text: str, line: str, begidx: int, endidx: int) -> Completions: @@ -523,10 +534,10 @@ def test_subcommands(manual_command_sets_app) -> None: with pytest.raises(CommandSetRegistrationError): manual_command_sets_app.register_command_set(fruit_cmds) - # verify that the commands weren't installed - cmds_cats, cmds_doc, _cmds_undoc, _help_topics = manual_command_sets_app._build_command_info() - assert 'cut' in cmds_doc + # verify that the Fruit commands weren't installed + cmds_cats, _help_topics = manual_command_sets_app._build_command_info() assert 'Fruits' not in cmds_cats + assert 'cut' in manual_command_sets_app.get_all_commands() # Now install the good base commands manual_command_sets_app.unregister_command_set(badbase_cmds) @@ -542,7 +553,7 @@ def test_subcommands(manual_command_sets_app) -> None: # verify that command set install without problems manual_command_sets_app.register_command_set(fruit_cmds) manual_command_sets_app.register_command_set(veg_cmds) - cmds_cats, cmds_doc, _cmds_undoc, _help_topics = manual_command_sets_app._build_command_info() + cmds_cats, _help_topics = manual_command_sets_app._build_command_info() assert 'Fruits' in cmds_cats text = '' @@ -568,7 +579,7 @@ def test_subcommands(manual_command_sets_app) -> None: # verify that command set uninstalls without problems manual_command_sets_app.unregister_command_set(fruit_cmds) - cmds_cats, cmds_doc, _cmds_undoc, _help_topics = manual_command_sets_app._build_command_info() + cmds_cats, _help_topics = manual_command_sets_app._build_command_info() assert 'Fruits' not in cmds_cats # verify a double-unregister raises exception @@ -585,7 +596,7 @@ def test_subcommands(manual_command_sets_app) -> None: manual_command_sets_app.enable_command('cut') - cmds_cats, cmds_doc, _cmds_undoc, _help_topics = manual_command_sets_app._build_command_info() + cmds_cats, _help_topics = manual_command_sets_app._build_command_info() assert 'Fruits' in cmds_cats text = '' @@ -611,7 +622,7 @@ def test_subcommands(manual_command_sets_app) -> None: # verify that command set uninstalls without problems manual_command_sets_app.unregister_command_set(fruit_cmds) - cmds_cats, cmds_doc, _cmds_undoc, _help_topics = manual_command_sets_app._build_command_info() + cmds_cats, _help_topics = manual_command_sets_app._build_command_info() assert 'Fruits' not in cmds_cats # verify a double-unregister raises exception @@ -630,6 +641,7 @@ def test_commandset_sigint(manual_command_sets_app) -> None: # returns True that we've handled interrupting the command. class SigintHandledCommandSet(cmd2.CommandSet): def do_foo(self, _) -> None: + """Foo Command""" self._cmd.poutput('in foo') self._cmd.sigint_handler(signal.SIGINT, None) self._cmd.poutput('end of foo') @@ -646,6 +658,7 @@ def sigint_handler(self) -> bool: # shows that the command is interrupted if we don't report we've handled the sigint class SigintUnhandledCommandSet(cmd2.CommandSet): def do_bar(self, _) -> None: + """Bar Command""" self._cmd.poutput('in do bar') self._cmd.sigint_handler(signal.SIGINT, None) self._cmd.poutput('end of do bar') @@ -748,7 +761,7 @@ def static_subcommands_app(): def test_static_subcommands(static_subcommands_app) -> None: - cmds_cats, _cmds_doc, _cmds_undoc, _help_topics = static_subcommands_app._build_command_info() + cmds_cats, _help_topics = static_subcommands_app._build_command_info() assert 'Fruits' in cmds_cats text = '' @@ -773,10 +786,10 @@ def test_static_subcommands(static_subcommands_app) -> None: complete_states_expected_self = None -@cmd2.with_default_category('With Completer') class SupportFuncProvider(cmd2.CommandSet): """CommandSet which provides a support function (complete_states) to other CommandSets""" + DEFAULT_CATEGORY = 'With Completer' states = ('alabama', 'alaska', 'arizona', 'arkansas', 'california', 'colorado', 'connecticut', 'delaware') def __init__(self, dummy) -> None: @@ -796,6 +809,7 @@ class SupportFuncUserSubclass1(SupportFuncProvider): @cmd2.with_argparser(parser) def do_user_sub1(self, ns: argparse.Namespace) -> None: + """User Sub1 Command""" self._cmd.poutput(f'something {ns.state}') @@ -807,6 +821,7 @@ class SupportFuncUserSubclass2(SupportFuncProvider): @cmd2.with_argparser(parser) def do_user_sub2(self, ns: argparse.Namespace) -> None: + """User sub2 Command""" self._cmd.poutput(f'something {ns.state}') @@ -822,6 +837,7 @@ def __init__(self, dummy) -> None: @cmd2.with_argparser(parser) def do_user_unrelated(self, ns: argparse.Namespace) -> None: + """User Unrelated Command""" self._cmd.poutput(f'something {ns.state}') @@ -857,10 +873,8 @@ def test_cross_commandset_completer(manual_command_sets_app) -> None: assert completions.to_strings() == Completions.from_values(SupportFuncProvider.states).to_strings() - assert ( - getattr(manual_command_sets_app.cmd_func('user_sub1').__func__, cmd2.constants.CMD_ATTR_HELP_CATEGORY) - == 'With Completer' - ) + cmds_cats, _help_topics = manual_command_sets_app._build_command_info() + assert 'user_sub1' in cmds_cats['With Completer'] manual_command_sets_app.unregister_command_set(user_sub2) manual_command_sets_app.unregister_command_set(user_sub1) @@ -961,6 +975,7 @@ def __init__(self, dummy) -> None: @cmd2.with_argparser(parser) def do_path(self, app: cmd2.Cmd, args) -> None: + """Path Command""" app.poutput(args.path)