From cecb59ecc003df48d1eb2aa51bc4be516ba61451 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 7 Apr 2026 03:44:35 -0400 Subject: [PATCH 01/11] Simplified categorizing commands. --- cmd2/__init__.py | 6 +- cmd2/cmd2.py | 65 +++---- cmd2/command_definition.py | 68 +------ cmd2/constants.py | 3 - cmd2/decorators.py | 28 +-- docs/features/help.md | 74 +++++--- docs/features/initialization.md | 2 - docs/features/modular_commands.md | 49 +++-- examples/README.md | 3 +- examples/command_sets.py | 10 +- examples/default_categories.py | 103 +++++------ examples/getting_started.py | 7 +- examples/help_categories.py | 6 +- examples/modular_commands/commandset_basic.py | 9 +- .../modular_commands/commandset_complex.py | 4 +- .../modular_commands/commandset_custominit.py | 6 +- examples/rich_tables.py | 7 +- tests/test_categories.py | 173 ++++++++++-------- tests/test_cmd2.py | 21 ++- tests/test_commandset.py | 65 ++++--- 20 files changed, 356 insertions(+), 353 deletions(-) diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 001d031b1..881c1d765 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_definition 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/cmd2.py b/cmd2/cmd2.py index e600d195c..f927fd24d 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -42,6 +42,7 @@ import time from code import InteractiveConsole from collections import ( + defaultdict, deque, namedtuple, ) @@ -60,6 +61,7 @@ IO, TYPE_CHECKING, Any, + ClassVar, TextIO, TypeVar, Union, @@ -123,7 +125,6 @@ Completions, ) from .constants import ( - CMDSET_ATTR_DEFAULT_HELP_CATEGORY, COMMAND_FUNC_PREFIX, COMPLETER_FUNC_PREFIX, HELP_FUNC_PREFIX, @@ -328,9 +329,22 @@ 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 used for documented commands (those with a docstring, + # help function, or argparse decorator) defined in this class that 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] = "Cmd2 Commands" + + # Header for table listing help topics not related to a command. + MISC_HEADER: ClassVar[str] = "Miscellaneous Help Topics" + + # Header for table listing commands that have no help info. + UNDOC_HEADER: ClassVar[str] = "Undocumented Commands" def __init__( self, @@ -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,9 +863,6 @@ 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) if command_category in self.disabled_categories: @@ -4214,12 +4210,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], 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 """ @@ -4228,9 +4223,9 @@ 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_cats: dict[str, list[str]] = defaultdict(list) 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 @@ -4245,13 +4240,15 @@ def _build_command_info(self) -> tuple[dict[str, list[str]], list[str], list[str 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) + # Determine the category based on the defining class + defining_cls = get_defining_class(func) + category = getattr(defining_cls, 'DEFAULT_CATEGORY', self.DEFAULT_CATEGORY) + cmds_cats[category].append(command) else: cmds_undoc.append(command) - return cmds_cats, cmds_doc, cmds_undoc, help_topics + return cmds_cats, cmds_undoc, help_topics @classmethod def _build_help_parser(cls) -> Cmd2ArgumentParser: @@ -4284,7 +4281,7 @@ 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, cmds_undoc, help_topics = self._build_command_info() if self.doc_leader: self.poutput() @@ -4294,10 +4291,6 @@ def do_help(self, args: argparse.Namespace) -> None: # 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 @@ -4312,8 +4305,8 @@ def do_help(self, args: argparse.Namespace) -> None: if previous_table_printed and (help_topics or cmds_undoc): self.poutput() - self.print_topics(self.misc_header, help_topics, 15, 80) - self.print_topics(self.undoc_header, cmds_undoc, 15, 80) + self.print_topics(self.MISC_HEADER, help_topics, 15, 80) + self.print_topics(self.UNDOC_HEADER, cmds_undoc, 15, 80) else: # Getting help for a specific command diff --git a/cmd2/command_definition.py b/cmd2/command_definition.py index b17a10906..59009e806 100644 --- a/cmd2/command_definition.py +++ b/cmd2/command_definition.py @@ -6,82 +6,34 @@ ) 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 used for documented commands (those with a docstring, + # help function, or argparse decorator) defined in this CommandSet that 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. 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..61b74a41f 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -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/docs/features/help.md b/docs/features/help.md index 6def1f5b1..3c1c17fed 100644 --- a/docs/features/help.md +++ b/docs/features/help.md @@ -50,48 +50,62 @@ 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 (Surgical) +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. + +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..8f5403ef9 100644 --- a/docs/features/initialization.md +++ b/docs/features/initialization.md @@ -29,10 +29,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..e09041755 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') @@ -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..322cef5ce 100755 --- a/examples/default_categories.py +++ b/examples/default_categories.py @@ -1,76 +1,69 @@ #!/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') + 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") -class ExampleApp(cmd2.Cmd): - """Example to demonstrate heritable default categories.""" + @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("") - def __init__(self) -> None: - super().__init__() + @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("") - def do_something(self, _arg) -> None: - self.poutput('this is the something command') + def do_undocumented(self, _: cmd2.Statement) -> None: + # This has no docstring and no help function, so it stays in "Undocumented Commands" + pass 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/tests/test_categories.py b/tests/test_categories.py index ee53bb134..f985988bf 100644 --- a/tests/test_categories.py +++ b/tests/test_categories.py @@ -1,109 +1,138 @@ -"""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_documented(self, _: cmd2.Statement) -> None: + """This function has a docstring. -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') + # This function has no docstring and does not use argparse. + # Therefore it will be uncategorized. + def do_undocumented(self, _: cmd2.Statement) -> None: + pass + @with_argparser(Cmd2ArgumentParser(description="Overridden quit command")) + def do_quit(self, _: argparse.Namespace) -> None: + """This function overrides the cmd2.Cmd quit command. -@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. - """ + Since this override does not use the with_category decorator, + it will be in CategoryCmd.DEFAULT_CATEGORY and not cmd2.Cmd.DEFAULT_CATEGORY. + """ - def do_bonjour(self, _: cmd2.Statement) -> None: - self._cmd.poutput('Bonjour') + @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. + It also uses the with_category decorator to keep shortcuts in + cmd2.Cmd.DEFAULT_CATEGORY for the parent class. + """ -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' - """ + # This function has no docstring but it has a help function + # so it will be in CategoryCmd.DEFAULT_CATEGORY + def do_helpless(self, _: cmd2.Statement) -> None: + pass + + def help_helpless(self) -> None: + """Help function for the helpless command.""" + self.poutput("The function has help.") + + def help_coding(self) -> None: + """This is 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, _cmds_undoc, _help_topics = app._build_command_info() + assert "inherit" in cmds_cats[cmd2.Cmd.DEFAULT_CATEGORY] + + +def test_category_cmd() -> None: + app = CategoryCmd() + cmds_cats, cmds_undoc, help_topics = app._build_command_info() + + assert "documented" in cmds_cats[CategoryCmd.DEFAULT_CATEGORY] + assert "undocumented" in cmds_undoc + assert "quit" in cmds_cats[CategoryCmd.DEFAULT_CATEGORY] + assert "shortcuts" in cmds_cats[cmd2.Cmd.DEFAULT_CATEGORY] + assert "helpless" 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_documented(self, _: cmd2.Statement) -> None: + """This function has a docstring. - 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, _cmds_undoc, _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, _cmds_undoc, _help_topics = app._build_command_info() + assert "documented" in cmds_cats[CategoryCommandSet.DEFAULT_CATEGORY] diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index e7293c30b..348f5767a 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1303,12 +1303,13 @@ 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." + UNDOC_HEADER = "Why did no one document these?" + 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.""" @@ -1352,9 +1353,9 @@ 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 HelpApp.UNDOC_HEADER in out assert help_app.last_result is True @@ -1409,7 +1410,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 +1465,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.""" @@ -1494,7 +1495,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 +1506,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): diff --git a/tests/test_commandset.py b/tests/test_commandset.py index 067a81215..a980f0dbe 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, _cmds_undoc, _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, _cmds_undoc, _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, _cmds_undoc, _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, _cmds_undoc, _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, _cmds_undoc, _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, _cmds_undoc, _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, _cmds_undoc, _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, _cmds_undoc, _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, _cmds_undoc, _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, _cmds_undoc, _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, _cmds_undoc, _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, _cmds_undoc, _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, _cmds_undoc, _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) From 697da11940aa33f7332e94625a11438581a2d5dd Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 7 Apr 2026 03:51:20 -0400 Subject: [PATCH 02/11] Renamed command_definition.py to command_set.py. --- .github/CODEOWNERS | 2 +- cmd2/__init__.py | 2 +- cmd2/argparse_completer.py | 2 +- cmd2/cmd2.py | 2 +- cmd2/{command_definition.py => command_set.py} | 0 cmd2/decorators.py | 2 +- cmd2/types.py | 2 +- docs/api/command_definition.md | 3 --- docs/api/command_set.md | 3 +++ docs/api/index.md | 4 ++-- docs/features/modular_commands.md | 18 +++++++++--------- mkdocs.yml | 2 +- 12 files changed, 21 insertions(+), 21 deletions(-) rename cmd2/{command_definition.py => command_set.py} (100%) delete mode 100644 docs/api/command_definition.md create mode 100644 docs/api/command_set.md 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/cmd2/__init__.py b/cmd2/__init__.py index 881c1d765..9aa9bd769 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -19,7 +19,7 @@ ) from .cmd2 import Cmd from .colors import Color -from .command_definition import CommandSet +from .command_set import CommandSet from .completion import ( Choices, CompletionItem, diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 763d88538..9e715ad8d 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -29,7 +29,7 @@ Cmd2ArgumentParser, build_range_error, ) -from .command_definition import CommandSet +from .command_set import CommandSet from .completion import ( CompletionItem, Completions, diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index f927fd24d..b9d0fc2a0 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -115,7 +115,7 @@ get_paste_buffer, write_to_paste_buffer, ) -from .command_definition import ( +from .command_set import ( CommandFunc, CommandSet, ) diff --git a/cmd2/command_definition.py b/cmd2/command_set.py similarity index 100% rename from cmd2/command_definition.py rename to cmd2/command_set.py diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 61b74a41f..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, ) 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/modular_commands.md b/docs/features/modular_commands.md index e09041755..767c69554 100644 --- a/docs/features/modular_commands.md +++ b/docs/features/modular_commands.md @@ -222,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. 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 From d9c2c34762e8a899724909c5c069234965e8a138 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 7 Apr 2026 14:54:44 -0400 Subject: [PATCH 03/11] Removed defaultdicts to simplify some code. --- cmd2/argparse_completer.py | 16 ++++++---------- cmd2/cmd2.py | 16 ++++++++++------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 9e715ad8d..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, @@ -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 b9d0fc2a0..a0b5216d4 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -42,7 +42,6 @@ import time from code import InteractiveConsole from collections import ( - defaultdict, deque, namedtuple, ) @@ -4223,7 +4222,7 @@ 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_cats: dict[str, list[str]] = defaultdict(list) + cmds_cats: dict[str, list[str]] = {} cmds_undoc: list[str] = [] for command in visible_commands: @@ -4238,16 +4237,21 @@ def _build_command_info(self) -> tuple[dict[str, list[str]], list[str], list[str # Non-argparse commands can have help_functions for their documentation has_help_func = not has_parser + # Determine the category + category: str | None = None + if hasattr(func, constants.CMD_ATTR_HELP_CATEGORY): - category: str = getattr(func, constants.CMD_ATTR_HELP_CATEGORY) - cmds_cats[category].append(command) + category = getattr(func, constants.CMD_ATTR_HELP_CATEGORY) elif func.__doc__ or has_help_func or has_parser: - # Determine the category based on the defining class defining_cls = get_defining_class(func) category = getattr(defining_cls, 'DEFAULT_CATEGORY', self.DEFAULT_CATEGORY) - cmds_cats[category].append(command) + + # Store the command + if category is not None: + cmds_cats.setdefault(category, []).append(command) else: cmds_undoc.append(command) + return cmds_cats, cmds_undoc, help_topics @classmethod From 0281e5cdd0b3c379a80d244bdb2f5968bbc71a74 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 7 Apr 2026 14:57:10 -0400 Subject: [PATCH 04/11] Corrected usages of some class variables. --- cmd2/cmd2.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index a0b5216d4..c92c4b323 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -347,7 +347,7 @@ class Cmd: def __init__( self, - completekey: str = DEFAULT_COMPLETEKEY, + completekey: str | None = None, stdin: TextIO | None = None, stdout: TextIO | None = None, *, @@ -429,9 +429,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 completekey is None: + completekey = self.DEFAULT_COMPLETEKEY + # What to use for standard input if stdin is not None: self.stdin = stdin @@ -459,7 +462,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 From 9914407da847c9f61b76e92ba66bc8b3e447e61e Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 7 Apr 2026 15:05:12 -0400 Subject: [PATCH 05/11] Renamed variable and updated comment. --- cmd2/command_set.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/cmd2/command_set.py b/cmd2/command_set.py index 59009e806..59988089c 100644 --- a/cmd2/command_set.py +++ b/cmd2/command_set.py @@ -40,17 +40,17 @@ def __init__(self) -> None: 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 @@ -66,9 +66,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. @@ -80,8 +80,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') @@ -103,7 +103,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: @@ -120,7 +120,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}') From 02327aaf493694131b9aa737b39d72c51b7b0df5 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 7 Apr 2026 15:18:41 -0400 Subject: [PATCH 06/11] Documented how to override the build-in command category. --- cmd2/cmd2.py | 2 ++ docs/features/help.md | 14 +++++++++++++- examples/default_categories.py | 3 +++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index c92c4b323..ffbddf6ac 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -337,6 +337,8 @@ class Cmd: # 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. diff --git a/docs/features/help.md b/docs/features/help.md index 3c1c17fed..b9c2b2078 100644 --- a/docs/features/help.md +++ b/docs/features/help.md @@ -90,7 +90,19 @@ class Plugin(cmd2.CommandSet): 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. +`"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) diff --git a/examples/default_categories.py b/examples/default_categories.py index 322cef5ce..355054a7d 100755 --- a/examples/default_categories.py +++ b/examples/default_categories.py @@ -36,6 +36,9 @@ class CategoryApp(cmd2.Cmd): # This sets the default category for all commands defined in this class DEFAULT_CATEGORY = "Application Commands" + # 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 From 970ff2d205bb4fb858460c3ac4c3cafcb015d931 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 7 Apr 2026 15:51:16 -0400 Subject: [PATCH 07/11] Updated documentation. --- CHANGELOG.md | 8 ++++++++ docs/features/help.md | 2 +- docs/features/initialization.md | 10 ++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51e4c8d58..904e0f32c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,9 @@ 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`. + - 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). - Enhancements - New `cmd2.Cmd` parameters - **auto_suggest**: (boolean) if `True`, provide fish shell style auto-suggestions. These @@ -97,6 +100,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/docs/features/help.md b/docs/features/help.md index b9c2b2078..11150ee46 100644 --- a/docs/features/help.md +++ b/docs/features/help.md @@ -56,7 +56,7 @@ category, and the display is driven by the `DEFAULT_CATEGORY` class variable. There are 3 methods of specifying command categories: 1. Using the `DEFAULT_CATEGORY` class variable (Automatic) -1. Using the [@with_category][cmd2.with_category] decorator (Surgical) +1. Using the [@with_category][cmd2.with_category] decorator (Manual) 1. Using the [categorize()][cmd2.categorize] function (Manual) ### Automatic Categorization diff --git a/docs/features/initialization.md b/docs/features/initialization.md index 8f5403ef9..cfa189ef0 100644 --- a/docs/features/initialization.md +++ b/docs/features/initialization.md @@ -16,6 +16,16 @@ 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 documented commands defined in the class that 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"`) +- **UNDOC_HEADER**: Header for the help section listing undocumented commands. (Default: `"Undocumented Commands"`) + ## 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. From 4c7688b77285e4d059e5add34ed18f5e1adf8dfb Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 7 Apr 2026 16:12:47 -0400 Subject: [PATCH 08/11] Updated CHANGELOG. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 904e0f32c..b7393fa93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,7 @@ 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). From 9bda5657a1cbc4ca9cc2d9bc4604d633f55944bf Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 7 Apr 2026 19:34:32 -0400 Subject: [PATCH 09/11] Fixed enable_category() and disable_category() to support DEFAULT_CATEGORY. Removed concept of undocumented commands. --- CHANGELOG.md | 1 + cmd2/cmd2.py | 66 +++++++------- docs/features/initialization.md | 1 - examples/default_categories.py | 4 - tests/test_categories.py | 42 ++++----- tests/test_cmd2.py | 148 ++++++++++++++++++++------------ tests/test_commandset.py | 26 +++--- 7 files changed, 155 insertions(+), 133 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7393fa93..ebb12a047 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ prompt is displayed. - 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 diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index ffbddf6ac..ab74f113d 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -344,9 +344,6 @@ class Cmd: # Header for table listing help topics not related to a command. MISC_HEADER: ClassVar[str] = "Miscellaneous Help Topics" - # Header for table listing commands that have no help info. - UNDOC_HEADER: ClassVar[str] = "Undocumented Commands" - def __init__( self, completekey: str | None = None, @@ -868,7 +865,7 @@ def register_command_set(self, cmdset: CommandSet) -> None: self._cmd_to_command_sets[command] = cmdset # 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 +3335,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,12 +4228,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]]: + 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 undocumented command names - list of help topic names that are not also commands """ # Get a sorted list of help topics @@ -4228,36 +4241,18 @@ 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_cats: dict[str, list[str]] = {} - cmds_undoc: 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 - + # 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 - - # Determine the category - category: str | None = None - - if hasattr(func, constants.CMD_ATTR_HELP_CATEGORY): - category = getattr(func, constants.CMD_ATTR_HELP_CATEGORY) - elif func.__doc__ or has_help_func or has_parser: - defining_cls = get_defining_class(func) - category = getattr(defining_cls, 'DEFAULT_CATEGORY', self.DEFAULT_CATEGORY) - - # Store the command - if category is not None: - cmds_cats.setdefault(category, []).append(command) - else: - cmds_undoc.append(command) + # 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) - return cmds_cats, cmds_undoc, help_topics + return cmds_cats, help_topics @classmethod def _build_help_parser(cls) -> Cmd2ArgumentParser: @@ -4290,7 +4285,7 @@ def do_help(self, args: argparse.Namespace) -> None: self.last_result = True if not args.command or args.verbose: - cmds_cats, cmds_undoc, help_topics = self._build_command_info() + cmds_cats, help_topics = self._build_command_info() if self.doc_leader: self.poutput() @@ -4311,11 +4306,10 @@ 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) else: # Getting help for a specific command @@ -5622,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] @@ -5683,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/docs/features/initialization.md b/docs/features/initialization.md index cfa189ef0..3eb43e90d 100644 --- a/docs/features/initialization.md +++ b/docs/features/initialization.md @@ -24,7 +24,6 @@ The `cmd2.Cmd` class provides several class-level variables that can be overridd - **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"`) -- **UNDOC_HEADER**: Header for the help section listing undocumented commands. (Default: `"Undocumented Commands"`) ## Cmd instance attributes diff --git a/examples/default_categories.py b/examples/default_categories.py index 355054a7d..109ceb188 100755 --- a/examples/default_categories.py +++ b/examples/default_categories.py @@ -59,10 +59,6 @@ def do_shortcuts(self, _: argparse.Namespace) -> None: """Overriding with @with_category(cmd2.Cmd.DEFAULT_CATEGORY) keeps it cmd2's category.""" super().do_shortcuts("") - def do_undocumented(self, _: cmd2.Statement) -> None: - # This has no docstring and no help function, so it stays in "Undocumented Commands" - pass - if __name__ == '__main__': import sys diff --git a/tests/test_categories.py b/tests/test_categories.py index f985988bf..37639825f 100644 --- a/tests/test_categories.py +++ b/tests/test_categories.py @@ -33,18 +33,13 @@ class CategoryCmd(cmd2.Cmd): DEFAULT_CATEGORY = "CategoryCmd Commands" - def do_documented(self, _: cmd2.Statement) -> None: - """This function has a docstring. + def do_cmd_command(self, _: cmd2.Statement) -> None: + """The cmd command. Since this class DOES define its own DEFAULT_CATEGORY, this command will show in CategoryCmd.DEFAULT_CATEGORY """ - # This function has no docstring and does not use argparse. - # Therefore it will be uncategorized. - def do_undocumented(self, _: cmd2.Statement) -> None: - pass - @with_argparser(Cmd2ArgumentParser(description="Overridden quit command")) def do_quit(self, _: argparse.Namespace) -> None: """This function overrides the cmd2.Cmd quit command. @@ -62,17 +57,15 @@ def do_shortcuts(self, _: argparse.Namespace) -> None: cmd2.Cmd.DEFAULT_CATEGORY for the parent class. """ - # This function has no docstring but it has a help function - # so it will be in CategoryCmd.DEFAULT_CATEGORY - def do_helpless(self, _: cmd2.Statement) -> None: - pass + def do_has_help_func(self, _: cmd2.Statement) -> None: + """This command has a help function.""" - def help_helpless(self) -> None: - """Help function for the helpless command.""" - self.poutput("The function has help.") + 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 is help function not tied to a command. + """This help function not tied to a command. It will be in help topics. """ @@ -81,19 +74,18 @@ def help_coding(self) -> None: def test_no_category_cmd() -> None: app = NoCategoryCmd() - cmds_cats, _cmds_undoc, _help_topics = app._build_command_info() + 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, cmds_undoc, help_topics = app._build_command_info() + cmds_cats, help_topics = app._build_command_info() - assert "documented" in cmds_cats[CategoryCmd.DEFAULT_CATEGORY] - assert "undocumented" in cmds_undoc + 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 "helpless" in cmds_cats[CategoryCmd.DEFAULT_CATEGORY] + assert "has_help_func" in cmds_cats[CategoryCmd.DEFAULT_CATEGORY] assert "coding" in help_topics @@ -116,8 +108,8 @@ class CategoryCommandSet(CommandSet): DEFAULT_CATEGORY = "CategoryCommandSet Commands" - def do_documented(self, _: cmd2.Statement) -> None: - """This function has a docstring. + def do_cmdset_command(self, _: cmd2.Statement) -> None: + """The cmdset command. Since this class DOES define its own DEFAULT_CATEGORY, this command will show in CategoryCommandSet.DEFAULT_CATEGORY @@ -127,12 +119,12 @@ def do_documented(self, _: cmd2.Statement) -> None: def test_no_category_command_set() -> None: app = cmd2.Cmd() app.register_command_set(NoCategoryCommandSet()) - cmds_cats, _cmds_undoc, _help_topics = app._build_command_info() + cmds_cats, _help_topics = app._build_command_info() assert "inherit" in cmds_cats[CommandSet.DEFAULT_CATEGORY] def test_category_command_set() -> None: app = cmd2.Cmd() app.register_command_set(CategoryCommandSet()) - cmds_cats, _cmds_undoc, _help_topics = app._build_command_info() - assert "documented" in cmds_cats[CategoryCommandSet.DEFAULT_CATEGORY] + 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 348f5767a..73be046a8 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1305,7 +1305,6 @@ class HelpApp(cmd2.Cmd): DEFAULT_CATEGORY = "My Default Category." MISC_HEADER = "Various topics found here." - UNDOC_HEADER = "Why did no one document these?" def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) @@ -1320,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: @@ -1355,7 +1354,6 @@ def test_help_headers(capsys) -> None: assert help_app.doc_leader in out assert HelpApp.DEFAULT_CATEGORY in out assert HelpApp.MISC_HEADER in out - assert HelpApp.UNDOC_HEADER in out assert help_app.last_result is True @@ -1372,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 @@ -1477,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(): @@ -3827,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 @@ -3868,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, 'has_no_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, 'help 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, 'is_not_decorated') 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, '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) @@ -3893,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) @@ -3902,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_func') + assert out[0] == "Help for 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, 'has_no_helper_func') + assert out[0] == "The real has_no_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, 'help has_no_helper_func') + assert out[0] == "Help for 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, 'is_not_decorated') + assert out[0] == "The real is_not_decorated" - # has_helper_funcs should complete now + # 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) @@ -3972,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) @@ -3980,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 diff --git a/tests/test_commandset.py b/tests/test_commandset.py index a980f0dbe..07deeeb40 100644 --- a/tests/test_commandset.py +++ b/tests/test_commandset.py @@ -130,7 +130,7 @@ def do_crocodile(self, statement: cmd2.Statement) -> None: 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_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'] @@ -206,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_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 @@ -257,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_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'] @@ -273,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_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 @@ -289,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_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'] @@ -342,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_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 @@ -535,7 +535,7 @@ def test_subcommands(manual_command_sets_app) -> None: manual_command_sets_app.register_command_set(fruit_cmds) # verify that the Fruit commands weren't installed - cmds_cats, _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 assert 'cut' in manual_command_sets_app.get_all_commands() @@ -553,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_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 = '' @@ -579,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_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 @@ -596,7 +596,7 @@ def test_subcommands(manual_command_sets_app) -> None: manual_command_sets_app.enable_command('cut') - cmds_cats, _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 = '' @@ -622,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_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 @@ -761,7 +761,7 @@ def static_subcommands_app(): def test_static_subcommands(static_subcommands_app) -> None: - cmds_cats, _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 = '' @@ -873,7 +873,7 @@ def test_cross_commandset_completer(manual_command_sets_app) -> None: assert completions.to_strings() == Completions.from_values(SupportFuncProvider.states).to_strings() - cmds_cats, _cmds_undoc, _help_topics = manual_command_sets_app._build_command_info() + 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) From 0a26a510834f1a17df032cec66f31b319e51b8ec Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 7 Apr 2026 20:52:29 -0400 Subject: [PATCH 10/11] Updated comments and documentation. --- cmd2/cmd2.py | 12 ++++++------ cmd2/command_set.py | 3 +-- docs/features/initialization.md | 2 +- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index ab74f113d..e51be79e8 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -332,8 +332,7 @@ class Cmd: DEFAULT_EDITOR: ClassVar[str | None] = utils.find_editor() DEFAULT_PROMPT: ClassVar[str] = "(Cmd) " - # Default category used for documented commands (those with a docstring, - # help function, or argparse decorator) defined in this class that have + # 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. @@ -4292,13 +4291,13 @@ def do_help(self, args: argparse.Namespace) -> None: 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} - # 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,6 +4308,7 @@ def do_help(self, args: argparse.Namespace) -> None: if previous_table_printed and help_topics: self.poutput() + # Print help topics table self.print_topics(self.MISC_HEADER, help_topics, 15, 80) else: diff --git a/cmd2/command_set.py b/cmd2/command_set.py index 59988089c..277f4ebc9 100644 --- a/cmd2/command_set.py +++ b/cmd2/command_set.py @@ -27,8 +27,7 @@ class CommandSet: ``do_``, ``help_``, and ``complete_`` functions differ only in that self is the CommandSet instead of the cmd2 app """ - # Default category used for documented commands (those with a docstring, - # help function, or argparse decorator) defined in this CommandSet that have + # 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. diff --git a/docs/features/initialization.md b/docs/features/initialization.md index 3eb43e90d..dad4226ce 100644 --- a/docs/features/initialization.md +++ b/docs/features/initialization.md @@ -20,7 +20,7 @@ Certain things must be initialized within the `__init__()` method of your class 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 documented commands defined in the class that haven't been explicitly categorized. (Default: `"Cmd2 Commands"`) +- **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"`) From 27acf59449dc17b656f69abbf54f8a76f2ad64f2 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 7 Apr 2026 21:30:47 -0400 Subject: [PATCH 11/11] Handling case when completekey is empty string. --- cmd2/cmd2.py | 4 ++-- tests/test_cmd2.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index e51be79e8..462ce8fad 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -370,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 @@ -430,7 +430,7 @@ def __init__( self.prompt: str = self.DEFAULT_PROMPT self.intro = intro - if completekey is None: + if not completekey: completekey = self.DEFAULT_COMPLETEKEY # What to use for standard input diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 73be046a8..944870298 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -4164,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