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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ 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).
- Enhancements
- New `cmd2.Cmd` parameters
- **auto_suggest**: (boolean) if `True`, provide fish shell style auto-suggestions. These
Expand All @@ -97,6 +101,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
86 changes: 44 additions & 42 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,28 @@ 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,
Comment thread
kmvanbrunt marked this conversation as resolved.
Outdated
# 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.
# 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"
Comment thread
tleonhardt marked this conversation as resolved.

# 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 = DEFAULT_COMPLETEKEY,
completekey: str | None = None,
stdin: TextIO | None = None,
stdout: TextIO | None = None,
*,
Expand Down Expand Up @@ -416,9 +431,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:
Comment thread
kmvanbrunt marked this conversation as resolved.
Outdated
completekey = self.DEFAULT_COMPLETEKEY

# What to use for standard input
if stdin is not None:
self.stdin = stdin
Expand Down Expand Up @@ -446,7 +464,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 +555,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 +845,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,9 +867,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:
Expand Down Expand Up @@ -4214,12 +4214,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
"""
Expand All @@ -4228,9 +4227,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_undoc: list[str] = []
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
Expand All @@ -4243,15 +4242,22 @@ 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.setdefault(category, [])
cmds_cats[category].append(command)
category = getattr(func, constants.CMD_ATTR_HELP_CATEGORY)
elif func.__doc__ or has_help_func or has_parser:
cmds_doc.append(command)
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)
return cmds_cats, cmds_doc, cmds_undoc, help_topics

return cmds_cats, cmds_undoc, help_topics

@classmethod
def _build_help_parser(cls) -> Cmd2ArgumentParser:
Expand Down Expand Up @@ -4284,7 +4290,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()
Expand All @@ -4294,10 +4300,6 @@ def do_help(self, args: argparse.Namespace) -> None:
# Print any categories first and then the remaining documented commands.
Comment thread
kmvanbrunt marked this conversation as resolved.
Outdated
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
Expand All @@ -4312,8 +4314,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
Expand Down
Loading
Loading