Skip to content

Commit 0087489

Browse files
authored
Merge branch 'main' into feat/annotated-argparse
2 parents 0432ba2 + 1d6910a commit 0087489

28 files changed

+546
-472
lines changed

.github/CODEOWNERS

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ cmd2/argparse_*.py @kmvanbrunt
3131
cmd2/clipboard.py @tleonhardt
3232
cmd2/cmd2.py @tleonhardt @kmvanbrunt
3333
cmd2/colors.py @tleonhardt @kmvanbrunt
34-
cmd2/command_definition.py @kmvanbrunt
34+
cmd2/command_set.py @kmvanbrunt
3535
cmd2/completion.py @kmvanbrunt
3636
cmd2/constants.py @tleonhardt @kmvanbrunt
3737
cmd2/decorators.py @kmvanbrunt

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ prompt is displayed.
7272
- Renamed `cmd2_handler` to `cmd2_subcmd_handler` in the `argparse.Namespace` for clarity.
7373
- Removed `Cmd2AttributeWrapper` class. `argparse.Namespace` objects passed to command functions
7474
now contain direct attributes for `cmd2_statement` and `cmd2_subcmd_handler`.
75+
- Renamed `cmd2/command_definition.py` to `cmd2/command_set.py`.
76+
- Removed `Cmd.doc_header` and the `with_default_category` decorator. Help categorization is now
77+
driven by the `DEFAULT_CATEGORY` class variable (see **Simplified command categorization** in
78+
the Enhancements section below for details).
79+
- Removed `Cmd.undoc_header` since all commands are now considered categorized.
7580
- Enhancements
7681
- New `cmd2.Cmd` parameters
7782
- **auto_suggest**: (boolean) if `True`, provide fish shell style auto-suggestions. These
@@ -97,6 +102,11 @@ prompt is displayed.
97102
- Add support for Python 3.15 by fixing various bugs related to internal `argparse` changes
98103
- Added `common_prefix` method to `cmd2.string_utils` module as a replacement for
99104
`os.path.commonprefix` since that is now deprecated in Python 3.15
105+
- Simplified command categorization:
106+
- By default, all commands in a class are grouped under its `DEFAULT_CATEGORY`.
107+
- Individual commands can still be manually moved using the `with_category()` decorator.
108+
- For more details and examples, see the [Help](docs/features/help.md) documentation and the
109+
`examples/default_categories.py` file.
100110

101111
## 3.4.0 (March 3, 2026)
102112

cmd2/__init__.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,7 @@
2323
)
2424
from .cmd2 import Cmd
2525
from .colors import Color
26-
from .command_definition import (
27-
CommandSet,
28-
with_default_category,
29-
)
26+
from .command_set import CommandSet
3027
from .completion import (
3128
Choices,
3229
CompletionItem,
@@ -89,7 +86,6 @@
8986
'with_argument_list',
9087
'with_argparser',
9188
'with_category',
92-
'with_default_category',
9389
'as_subcommand_to',
9490
# Exceptions
9591
'Cmd2ArgparseError',

cmd2/argparse_completer.py

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,7 @@
66
import argparse
77
import dataclasses
88
import inspect
9-
from collections import (
10-
defaultdict,
11-
deque,
12-
)
9+
from collections import deque
1310
from collections.abc import (
1411
Mapping,
1512
MutableSequence,
@@ -29,7 +26,7 @@
2926
Cmd2ArgumentParser,
3027
build_range_error,
3128
)
32-
from .command_definition import CommandSet
29+
from .command_set import CommandSet
3330
from .completion import (
3431
CompletionItem,
3532
Completions,
@@ -251,15 +248,15 @@ def complete(
251248
used_flags: set[str] = set()
252249

253250
# Keeps track of arguments we've seen and any tokens they consumed
254-
consumed_arg_values: dict[str, list[str]] = defaultdict(list)
251+
consumed_arg_values: dict[str, list[str]] = {}
255252

256253
# Completed mutually exclusive groups
257254
completed_mutex_groups: dict[argparse._MutuallyExclusiveGroup, argparse.Action] = {}
258255

259256
def consume_argument(arg_state: _ArgumentState, arg_token: str) -> None:
260257
"""Consume token as an argument."""
261258
arg_state.count += 1
262-
consumed_arg_values[arg_state.action.dest].append(arg_token)
259+
consumed_arg_values.setdefault(arg_state.action.dest, []).append(arg_token)
263260

264261
#############################################################################################
265262
# Parse all but the last token
@@ -336,7 +333,7 @@ def consume_argument(arg_state: _ArgumentState, arg_token: str) -> None:
336333
# filter them from future completion results and clear any previously
337334
# recorded values for this destination.
338335
used_flags.update(action.option_strings)
339-
consumed_arg_values[action.dest].clear()
336+
consumed_arg_values[action.dest] = []
340337

341338
new_arg_state = _ArgumentState(action)
342339

@@ -362,7 +359,6 @@ def consume_argument(arg_state: _ArgumentState, arg_token: str) -> None:
362359
# Are we at a subcommand? If so, forward to the matching completer
363360
if self._subcommand_action is not None and action == self._subcommand_action:
364361
if token in self._subcommand_action.choices:
365-
# Merge self._parent_tokens and consumed_arg_values
366362
parent_tokens = {**self._parent_tokens, **consumed_arg_values}
367363

368364
# Include the subcommand name if its destination was set
@@ -557,15 +553,15 @@ def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, used_f
557553
match_against.append(flag)
558554

559555
# Build a dictionary linking actions with their matched flag names
560-
matched_actions: dict[argparse.Action, list[str]] = defaultdict(list)
556+
matched_actions: dict[argparse.Action, list[str]] = {}
561557

562558
# Keep flags sorted in the order provided by argparse so our completion
563559
# suggestions display the same as argparse help text.
564560
matched_flags = self._cmd2_app.basic_complete(text, line, begidx, endidx, match_against, sort=False)
565561

566562
for flag in matched_flags.to_strings():
567563
action = self._flag_to_action[flag]
568-
matched_actions[action].append(flag)
564+
matched_actions.setdefault(action, []).append(flag)
569565

570566
# For completion suggestions, group matched flags by action
571567
items: list[CompletionItem] = []

cmd2/cmd2.py

Lines changed: 60 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
IO,
6161
TYPE_CHECKING,
6262
Any,
63+
ClassVar,
6364
TextIO,
6465
TypeVar,
6566
Union,
@@ -113,7 +114,7 @@
113114
get_paste_buffer,
114115
write_to_paste_buffer,
115116
)
116-
from .command_definition import (
117+
from .command_set import (
117118
CommandFunc,
118119
CommandSet,
119120
)
@@ -123,7 +124,6 @@
123124
Completions,
124125
)
125126
from .constants import (
126-
CMDSET_ATTR_DEFAULT_HELP_CATEGORY,
127127
COMMAND_FUNC_PREFIX,
128128
COMPLETER_FUNC_PREFIX,
129129
HELP_FUNC_PREFIX,
@@ -328,13 +328,24 @@ class Cmd:
328328
Line-oriented command interpreters are often useful for test harnesses, internal tools, and rapid prototypes.
329329
"""
330330

331-
DEFAULT_COMPLETEKEY = 'tab'
332-
DEFAULT_EDITOR = utils.find_editor()
333-
DEFAULT_PROMPT = '(Cmd) '
331+
DEFAULT_COMPLETEKEY: ClassVar[str] = "tab"
332+
DEFAULT_EDITOR: ClassVar[str | None] = utils.find_editor()
333+
DEFAULT_PROMPT: ClassVar[str] = "(Cmd) "
334+
335+
# Default category for commands defined in this class which have
336+
# not been explicitly categorized with the @with_category decorator.
337+
# This value is inherited by subclasses but they can set their own
338+
# DEFAULT_CATEGORY to place their commands into a custom category.
339+
# Subclasses can also reassign cmd2.Cmd.DEFAULT_CATEGORY to rename
340+
# the category used for the framework's built-in commands.
341+
DEFAULT_CATEGORY: ClassVar[str] = "Cmd2 Commands"
342+
343+
# Header for table listing help topics not related to a command.
344+
MISC_HEADER: ClassVar[str] = "Miscellaneous Help Topics"
334345

335346
def __init__(
336347
self,
337-
completekey: str = DEFAULT_COMPLETEKEY,
348+
completekey: str | None = None,
338349
stdin: TextIO | None = None,
339350
stdout: TextIO | None = None,
340351
*,
@@ -359,7 +370,7 @@ def __init__(
359370
) -> None:
360371
"""Easy but powerful framework for writing line-oriented command interpreters, extends Python's cmd package.
361372
362-
:param completekey: name of a completion key, default to Tab
373+
:param completekey: name of a completion key, default to 'tab'. (If None or an empty string, 'tab' is used)
363374
:param stdin: alternate input file object, if not specified, sys.stdin is used
364375
:param stdout: alternate output file object, if not specified, sys.stdout is used
365376
:param allow_cli_args: if ``True``, then [cmd2.Cmd.__init__][] will process command
@@ -416,9 +427,12 @@ def __init__(
416427
self._initialize_plugin_system()
417428

418429
# Configure a few defaults
419-
self.prompt: str = Cmd.DEFAULT_PROMPT
430+
self.prompt: str = self.DEFAULT_PROMPT
420431
self.intro = intro
421432

433+
if not completekey:
434+
completekey = self.DEFAULT_COMPLETEKEY
435+
422436
# What to use for standard input
423437
if stdin is not None:
424438
self.stdin = stdin
@@ -446,7 +460,7 @@ def __init__(
446460
self.always_show_hint = False
447461
self.debug = False
448462
self.echo = False
449-
self.editor = Cmd.DEFAULT_EDITOR
463+
self.editor = self.DEFAULT_EDITOR
450464
self.feedback_to_output = False # Do not include nonessentials in >, | output by default (things like timing)
451465
self.quiet = False # Do not suppress nonessential output
452466
self.scripts_add_to_history = True # Scripts and pyscripts add commands to history
@@ -537,19 +551,6 @@ def __init__(
537551
# Set text which prints right before all of the help tables are listed.
538552
self.doc_leader = ""
539553

540-
# Set header for table listing documented commands.
541-
self.doc_header = "Documented Commands"
542-
543-
# Set header for table listing help topics not related to a command.
544-
self.misc_header = "Miscellaneous Help Topics"
545-
546-
# Set header for table listing commands that have no help info.
547-
self.undoc_header = "Undocumented Commands"
548-
549-
# If any command has been categorized, then all other documented commands that
550-
# haven't been categorized will display under this section in the help output.
551-
self.default_category = "Uncategorized Commands"
552-
553554
# The error that prints when no help information can be found
554555
self.help_error = "No help on {}"
555556

@@ -840,8 +841,6 @@ def register_command_set(self, cmdset: CommandSet) -> None:
840841
),
841842
)
842843

843-
default_category = getattr(cmdset, CMDSET_ATTR_DEFAULT_HELP_CATEGORY, None)
844-
845844
installed_attributes = []
846845
try:
847846
for cmd_func_name, command_method in methods:
@@ -864,11 +863,8 @@ def register_command_set(self, cmdset: CommandSet) -> None:
864863

865864
self._cmd_to_command_sets[command] = cmdset
866865

867-
if default_category and not hasattr(command_method, constants.CMD_ATTR_HELP_CATEGORY):
868-
utils.categorize(command_method, default_category)
869-
870866
# If this command is in a disabled category, then disable it
871-
command_category = getattr(command_method, constants.CMD_ATTR_HELP_CATEGORY, None)
867+
command_category = self._get_command_category(command_method)
872868
if command_category in self.disabled_categories:
873869
message_to_print = self.disabled_categories[command_category]
874870
self.disable_command(command, message_to_print)
@@ -3338,6 +3334,23 @@ def cmd_func(self, command: str) -> CommandFunc | None:
33383334
func = getattr(self, func_name, None)
33393335
return cast(CommandFunc, func) if callable(func) else None
33403336

3337+
def _get_command_category(self, func: CommandFunc) -> str:
3338+
"""Determine the category for a command.
3339+
3340+
:param func: the do_* function implementing the command
3341+
:return: category name
3342+
"""
3343+
# Check if the command function has a category.
3344+
if hasattr(func, constants.CMD_ATTR_HELP_CATEGORY):
3345+
category: str = getattr(func, constants.CMD_ATTR_HELP_CATEGORY)
3346+
3347+
# Otherwise get the category from its defining class.
3348+
else:
3349+
defining_cls = get_defining_class(func)
3350+
category = getattr(defining_cls, 'DEFAULT_CATEGORY', self.DEFAULT_CATEGORY)
3351+
3352+
return category
3353+
33413354
def onecmd(self, statement: Statement | str, *, add_to_history: bool = True) -> bool:
33423355
"""Execute the actual do_* method for a command.
33433356
@@ -4214,44 +4227,31 @@ def complete_help_subcommands(
42144227
completer = argparse_completer.DEFAULT_AP_COMPLETER(argparser, self)
42154228
return completer.complete_subcommand_help(text, line, begidx, endidx, arg_tokens['subcommands'])
42164229

4217-
def _build_command_info(self) -> tuple[dict[str, list[str]], list[str], list[str], list[str]]:
4230+
def _build_command_info(self) -> tuple[dict[str, list[str]], list[str]]:
42184231
"""Categorizes and sorts visible commands and help topics for display.
42194232
42204233
:return: tuple containing:
42214234
- dictionary mapping category names to lists of command names
4222-
- list of documented command names
4223-
- list of undocumented command names
42244235
- list of help topic names that are not also commands
42254236
"""
42264237
# Get a sorted list of help topics
42274238
help_topics = sorted(self.get_help_topics(), key=utils.DEFAULT_STR_SORT_KEY)
42284239

42294240
# Get a sorted list of visible command names
42304241
visible_commands = sorted(self.get_visible_commands(), key=utils.DEFAULT_STR_SORT_KEY)
4231-
cmds_doc: list[str] = []
4232-
cmds_undoc: list[str] = []
42334242
cmds_cats: dict[str, list[str]] = {}
4234-
for command in visible_commands:
4235-
func = cast(CommandFunc, self.cmd_func(command))
4236-
has_help_func = False
4237-
has_parser = func in self._command_parsers
42384243

4244+
for command in visible_commands:
4245+
# Prevent the command from showing as both a command and help topic in the output
42394246
if command in help_topics:
4240-
# Prevent the command from showing as both a command and help topic in the output
42414247
help_topics.remove(command)
42424248

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

4246-
if hasattr(func, constants.CMD_ATTR_HELP_CATEGORY):
4247-
category: str = getattr(func, constants.CMD_ATTR_HELP_CATEGORY)
4248-
cmds_cats.setdefault(category, [])
4249-
cmds_cats[category].append(command)
4250-
elif func.__doc__ or has_help_func or has_parser:
4251-
cmds_doc.append(command)
4252-
else:
4253-
cmds_undoc.append(command)
4254-
return cmds_cats, cmds_doc, cmds_undoc, help_topics
4254+
return cmds_cats, help_topics
42554255

42564256
@classmethod
42574257
def _build_help_parser(cls) -> Cmd2ArgumentParser:
@@ -4284,36 +4284,32 @@ def do_help(self, args: argparse.Namespace) -> None:
42844284
self.last_result = True
42854285

42864286
if not args.command or args.verbose:
4287-
cmds_cats, cmds_doc, cmds_undoc, help_topics = self._build_command_info()
4287+
cmds_cats, help_topics = self._build_command_info()
42884288

42894289
if self.doc_leader:
42904290
self.poutput()
42914291
self.poutput(Text(self.doc_leader, style=Cmd2Style.HELP_LEADER))
42924292
self.poutput()
42934293

4294-
# Print any categories first and then the remaining documented commands.
4295-
sorted_categories = sorted(cmds_cats.keys(), key=utils.DEFAULT_STR_SORT_KEY)
4296-
all_cmds = {category: cmds_cats[category] for category in sorted_categories}
4297-
if all_cmds:
4298-
all_cmds[self.default_category] = cmds_doc
4299-
else:
4300-
all_cmds[self.doc_header] = cmds_doc
4301-
43024294
# Used to provide verbose table separation for better readability.
43034295
previous_table_printed = False
43044296

4297+
# Print commands grouped by category
4298+
sorted_categories = sorted(cmds_cats.keys(), key=utils.DEFAULT_STR_SORT_KEY)
4299+
all_cmds = {category: cmds_cats[category] for category in sorted_categories}
4300+
43054301
for category, commands in all_cmds.items():
43064302
if previous_table_printed:
43074303
self.poutput()
43084304

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

4312-
if previous_table_printed and (help_topics or cmds_undoc):
4308+
if previous_table_printed and help_topics:
43134309
self.poutput()
43144310

4315-
self.print_topics(self.misc_header, help_topics, 15, 80)
4316-
self.print_topics(self.undoc_header, cmds_undoc, 15, 80)
4311+
# Print help topics table
4312+
self.print_topics(self.MISC_HEADER, help_topics, 15, 80)
43174313

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

56215617
for cmd_name in list(self.disabled_commands):
56225618
func = self.disabled_commands[cmd_name].command_function
5623-
if getattr(func, constants.CMD_ATTR_HELP_CATEGORY, None) == category:
5619+
if self._get_command_category(func) == category:
56245620
self.enable_command(cmd_name)
56255621

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

56835679
for cmd_name in all_commands:
5684-
func = self.cmd_func(cmd_name)
5685-
if getattr(func, constants.CMD_ATTR_HELP_CATEGORY, None) == category:
5680+
func = cast(CommandFunc, self.cmd_func(cmd_name))
5681+
if self._get_command_category(func) == category:
56865682
self.disable_command(cmd_name, message_to_print)
56875683

56885684
self.disabled_categories[category] = message_to_print

0 commit comments

Comments
 (0)