Skip to content
Merged
2 changes: 1 addition & 1 deletion .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ prompt is displayed.
- Renamed `cmd2_handler` to `cmd2_subcmd_handler` in the `argparse.Namespace` for clarity.
- Removed `Cmd2AttributeWrapper` class. `argparse.Namespace` objects passed to command functions
now contain direct attributes for `cmd2_statement` and `cmd2_subcmd_handler`.
- Renamed `cmd2/command_definition.py` to `cmd2/command_set.py`.
- Removed `Cmd.doc_header` and the `with_default_category` decorator. Help categorization is now
driven by the `DEFAULT_CATEGORY` class variable (see **Simplified command categorization** in
the Enhancements section below for details).
- Removed `Cmd.undoc_header` since all commands are now considered categorized.
- Enhancements
- New `cmd2.Cmd` parameters
- **auto_suggest**: (boolean) if `True`, provide fish shell style auto-suggestions. These
Expand All @@ -97,6 +102,11 @@ prompt is displayed.
- Add support for Python 3.15 by fixing various bugs related to internal `argparse` changes
- Added `common_prefix` method to `cmd2.string_utils` module as a replacement for
`os.path.commonprefix` since that is now deprecated in Python 3.15
- Simplified command categorization:
- By default, all commands in a class are grouped under its `DEFAULT_CATEGORY`.
- Individual commands can still be manually moved using the `with_category()` decorator.
- For more details and examples, see the [Help](docs/features/help.md) documentation and the
`examples/default_categories.py` file.

## 3.4.0 (March 3, 2026)

Expand Down
6 changes: 1 addition & 5 deletions cmd2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,7 @@
)
from .cmd2 import Cmd
from .colors import Color
from .command_definition import (
CommandSet,
with_default_category,
)
from .command_set import CommandSet
from .completion import (
Choices,
CompletionItem,
Expand Down Expand Up @@ -80,7 +77,6 @@
'with_argument_list',
'with_argparser',
'with_category',
'with_default_category',
'as_subcommand_to',
# Exceptions
'Cmd2ArgparseError',
Expand Down
18 changes: 7 additions & 11 deletions cmd2/argparse_completer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -29,7 +26,7 @@
Cmd2ArgumentParser,
build_range_error,
)
from .command_definition import CommandSet
from .command_set import CommandSet
from .completion import (
CompletionItem,
Completions,
Expand Down Expand Up @@ -251,15 +248,15 @@ 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] = {}

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
Expand Down Expand Up @@ -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)

Expand All @@ -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
Expand Down Expand Up @@ -557,15 +553,15 @@ 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.
matched_flags = self._cmd2_app.basic_complete(text, line, begidx, endidx, match_against, sort=False)

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] = []
Expand Down
124 changes: 60 additions & 64 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
IO,
TYPE_CHECKING,
Any,
ClassVar,
TextIO,
TypeVar,
Union,
Expand Down Expand Up @@ -113,7 +114,7 @@
get_paste_buffer,
write_to_paste_buffer,
)
from .command_definition import (
from .command_set import (
CommandFunc,
CommandSet,
)
Expand All @@ -123,7 +124,6 @@
Completions,
)
from .constants import (
CMDSET_ATTR_DEFAULT_HELP_CATEGORY,
COMMAND_FUNC_PREFIX,
COMPLETER_FUNC_PREFIX,
HELP_FUNC_PREFIX,
Expand Down Expand Up @@ -328,13 +328,24 @@ class Cmd:
Line-oriented command interpreters are often useful for test harnesses, internal tools, and rapid prototypes.
"""

DEFAULT_COMPLETEKEY = 'tab'
DEFAULT_EDITOR = utils.find_editor()
DEFAULT_PROMPT = '(Cmd) '
DEFAULT_COMPLETEKEY: ClassVar[str] = "tab"
DEFAULT_EDITOR: ClassVar[str | None] = utils.find_editor()
DEFAULT_PROMPT: ClassVar[str] = "(Cmd) "

# Default category for commands defined in this class which have
# not been explicitly categorized with the @with_category decorator.
# This value is inherited by subclasses but they can set their own
# DEFAULT_CATEGORY to place their commands into a custom category.
# Subclasses can also reassign cmd2.Cmd.DEFAULT_CATEGORY to rename
# the category used for the framework's built-in commands.
DEFAULT_CATEGORY: ClassVar[str] = "Cmd2 Commands"

# Header for table listing help topics not related to a command.
MISC_HEADER: ClassVar[str] = "Miscellaneous Help Topics"

def __init__(
self,
completekey: str = DEFAULT_COMPLETEKEY,
completekey: str | None = None,
stdin: TextIO | None = None,
stdout: TextIO | None = None,
*,
Expand All @@ -359,7 +370,7 @@ def __init__(
) -> None:
"""Easy but powerful framework for writing line-oriented command interpreters, extends Python's cmd package.

:param completekey: name of a completion key, default to Tab
:param completekey: name of a completion key, default to 'tab'. (If None or an empty string, 'tab' is used)
:param stdin: alternate input file object, if not specified, sys.stdin is used
:param stdout: alternate output file object, if not specified, sys.stdout is used
:param allow_cli_args: if ``True``, then [cmd2.Cmd.__init__][] will process command
Expand Down Expand Up @@ -416,9 +427,12 @@ def __init__(
self._initialize_plugin_system()

# Configure a few defaults
self.prompt: str = Cmd.DEFAULT_PROMPT
self.prompt: str = self.DEFAULT_PROMPT
self.intro = intro

if not completekey:
completekey = self.DEFAULT_COMPLETEKEY

# What to use for standard input
if stdin is not None:
self.stdin = stdin
Expand Down Expand Up @@ -446,7 +460,7 @@ def __init__(
self.always_show_hint = False
self.debug = False
self.echo = False
self.editor = Cmd.DEFAULT_EDITOR
self.editor = self.DEFAULT_EDITOR
self.feedback_to_output = False # Do not include nonessentials in >, | output by default (things like timing)
self.quiet = False # Do not suppress nonessential output
self.scripts_add_to_history = True # Scripts and pyscripts add commands to history
Expand Down Expand Up @@ -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 {}"

Expand Down Expand Up @@ -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:
Expand All @@ -864,11 +863,8 @@ def register_command_set(self, cmdset: CommandSet) -> None:

self._cmd_to_command_sets[command] = cmdset

if default_category and not hasattr(command_method, constants.CMD_ATTR_HELP_CATEGORY):
utils.categorize(command_method, default_category)

# If this command is in a disabled category, then disable it
command_category = getattr(command_method, constants.CMD_ATTR_HELP_CATEGORY, None)
command_category = self._get_command_category(command_method)
if command_category in self.disabled_categories:
message_to_print = self.disabled_categories[command_category]
self.disable_command(command, message_to_print)
Expand Down Expand Up @@ -3338,6 +3334,23 @@ def cmd_func(self, command: str) -> CommandFunc | None:
func = getattr(self, func_name, None)
return cast(CommandFunc, func) if callable(func) else None

def _get_command_category(self, func: CommandFunc) -> str:
"""Determine the category for a command.

:param func: the do_* function implementing the command
:return: category name
"""
# Check if the command function has a category.
if hasattr(func, constants.CMD_ATTR_HELP_CATEGORY):
category: str = getattr(func, constants.CMD_ATTR_HELP_CATEGORY)

# Otherwise get the category from its defining class.
else:
defining_cls = get_defining_class(func)
category = getattr(defining_cls, 'DEFAULT_CATEGORY', self.DEFAULT_CATEGORY)

return category

def onecmd(self, statement: Statement | str, *, add_to_history: bool = True) -> bool:
"""Execute the actual do_* method for a command.

Expand Down Expand Up @@ -4214,44 +4227,31 @@ def complete_help_subcommands(
completer = argparse_completer.DEFAULT_AP_COMPLETER(argparser, self)
return completer.complete_subcommand_help(text, line, begidx, endidx, arg_tokens['subcommands'])

def _build_command_info(self) -> tuple[dict[str, list[str]], list[str], list[str], list[str]]:
def _build_command_info(self) -> tuple[dict[str, list[str]], list[str]]:
"""Categorizes and sorts visible commands and help topics for display.

:return: tuple containing:
- dictionary mapping category names to lists of command names
- list of documented command names
- list of undocumented command names
- list of help topic names that are not also commands
"""
# Get a sorted list of help topics
help_topics = sorted(self.get_help_topics(), key=utils.DEFAULT_STR_SORT_KEY)

# Get a sorted list of visible command names
visible_commands = sorted(self.get_visible_commands(), key=utils.DEFAULT_STR_SORT_KEY)
cmds_doc: list[str] = []
cmds_undoc: list[str] = []
cmds_cats: dict[str, list[str]] = {}
for command in visible_commands:
func = cast(CommandFunc, self.cmd_func(command))
has_help_func = False
has_parser = func in self._command_parsers

for command in visible_commands:
# Prevent the command from showing as both a command and help topic in the output
if command in help_topics:
# Prevent the command from showing as both a command and help topic in the output
help_topics.remove(command)

# Non-argparse commands can have help_functions for their documentation
has_help_func = not has_parser
# Store the command within its category
func = cast(CommandFunc, self.cmd_func(command))
category = self._get_command_category(func)
cmds_cats.setdefault(category, []).append(command)

if hasattr(func, constants.CMD_ATTR_HELP_CATEGORY):
category: str = getattr(func, constants.CMD_ATTR_HELP_CATEGORY)
cmds_cats.setdefault(category, [])
cmds_cats[category].append(command)
elif func.__doc__ or has_help_func or has_parser:
cmds_doc.append(command)
else:
cmds_undoc.append(command)
return cmds_cats, cmds_doc, cmds_undoc, help_topics
return cmds_cats, help_topics

@classmethod
def _build_help_parser(cls) -> Cmd2ArgumentParser:
Expand Down Expand Up @@ -4284,36 +4284,32 @@ def do_help(self, args: argparse.Namespace) -> None:
self.last_result = True

if not args.command or args.verbose:
cmds_cats, cmds_doc, cmds_undoc, help_topics = self._build_command_info()
cmds_cats, help_topics = self._build_command_info()

if self.doc_leader:
self.poutput()
self.poutput(Text(self.doc_leader, style=Cmd2Style.HELP_LEADER))
self.poutput()

# Print any categories first and then the remaining documented commands.
sorted_categories = sorted(cmds_cats.keys(), key=utils.DEFAULT_STR_SORT_KEY)
all_cmds = {category: cmds_cats[category] for category in sorted_categories}
if all_cmds:
all_cmds[self.default_category] = cmds_doc
else:
all_cmds[self.doc_header] = cmds_doc

# Used to provide verbose table separation for better readability.
previous_table_printed = False

# Print commands grouped by category
sorted_categories = sorted(cmds_cats.keys(), key=utils.DEFAULT_STR_SORT_KEY)
all_cmds = {category: cmds_cats[category] for category in sorted_categories}

for category, commands in all_cmds.items():
if previous_table_printed:
self.poutput()

self._print_documented_command_topics(category, commands, args.verbose)
previous_table_printed = bool(commands) and args.verbose

if previous_table_printed and (help_topics or cmds_undoc):
if previous_table_printed and help_topics:
self.poutput()

self.print_topics(self.misc_header, help_topics, 15, 80)
self.print_topics(self.undoc_header, cmds_undoc, 15, 80)
# Print help topics table
self.print_topics(self.MISC_HEADER, help_topics, 15, 80)

else:
# Getting help for a specific command
Expand Down Expand Up @@ -5620,7 +5616,7 @@ def enable_category(self, category: str) -> None:

for cmd_name in list(self.disabled_commands):
func = self.disabled_commands[cmd_name].command_function
if getattr(func, constants.CMD_ATTR_HELP_CATEGORY, None) == category:
if self._get_command_category(func) == category:
self.enable_command(cmd_name)

del self.disabled_categories[category]
Expand Down Expand Up @@ -5681,8 +5677,8 @@ def disable_category(self, category: str, message_to_print: str) -> None:
all_commands = self.get_all_commands()

for cmd_name in all_commands:
func = self.cmd_func(cmd_name)
if getattr(func, constants.CMD_ATTR_HELP_CATEGORY, None) == category:
func = cast(CommandFunc, self.cmd_func(cmd_name))
if self._get_command_category(func) == category:
self.disable_command(cmd_name, message_to_print)

self.disabled_categories[category] = message_to_print
Expand Down
Loading
Loading