Skip to content

Commit b5c9618

Browse files
committed
Standardized command/subcommand naming and metadata
- Consistently use full words COMMAND and SUBCOMMAND over abbreviations - Rename cmd2_subcmd_handler to cmd2_subcommand_func for consistency - Consolidate command metadata into ApCommandSpec dataclass - Simplify subcommand examples by using required=True for subparsers
1 parent e2c061c commit b5c9618

13 files changed

Lines changed: 121 additions & 121 deletions

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,9 @@ prompt is displayed.
7070
- Removed `set_ap_completer_type()` and `get_ap_completer_type()` since `ap_completer_type` is
7171
now a public member of `Cmd2ArgumentParser`.
7272
- Moved `set_parser_prog()` to `Cmd2ArgumentParser.update_prog()`.
73-
- Renamed `cmd2_handler` to `cmd2_subcmd_handler` in the `argparse.Namespace` for clarity.
73+
- Renamed `cmd2_handler` to `cmd2_subcommand_func` in the `argparse.Namespace` for clarity.
7474
- Removed `Cmd2AttributeWrapper` class. `argparse.Namespace` objects passed to command functions
75-
now contain direct attributes for `cmd2_statement` and `cmd2_subcmd_handler`.
75+
now contain direct attributes for `cmd2_statement` and `cmd2_subcommand_func`.
7676
- Renamed `cmd2/command_definition.py` to `cmd2/command_set.py`.
7777
- Removed `Cmd.doc_header` and the `with_default_category` decorator. Help categorization is now
7878
driven by the `DEFAULT_CATEGORY` class variable (see **Simplified command categorization** in

cmd2/argparse_utils.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -291,20 +291,44 @@ def get_choices(self) -> Choices:
291291
]
292292

293293

294+
@dataclass(kw_only=True)
295+
class ApCommandSpec:
296+
"""Metadata for an argparse-based command function.
297+
298+
:param parser_source: an existing Cmd2ArgumentParser instance or a factory
299+
(callable, staticmethod, or classmethod) that returns one.
300+
:param preserve_quotes: if True, then arguments passed to argparse maintain their quotes
301+
"""
302+
303+
parser_source: ParserSource[Any]
304+
preserve_quotes: bool = False
305+
306+
294307
@dataclass(kw_only=True)
295308
class _SubcommandBase:
296-
"""Base metadata shared by all subcommand representations."""
309+
"""Base metadata shared by all subcommand representations.
310+
311+
:param name: the name of the subcommand
312+
:param command: the full parent command path (e.g., 'foo bar')
313+
:param help: optional help message for this subcommand
314+
:param aliases: optional alternative names for this subcommand
315+
:param deprecated: whether this subcommand is deprecated (requires Python 3.13+).
316+
"""
297317

298318
name: str
299-
command: str # The full parent command path (e.g., 'foo bar')
319+
command: str
300320
help: str | None = None
301321
aliases: tuple[str, ...] = ()
302322
deprecated: bool = False
303323

304324

305325
@dataclass(kw_only=True)
306326
class SubcommandSpec(_SubcommandBase):
307-
"""Metadata used to build and register a subcommand."""
327+
"""Metadata used to build and register a subcommand.
328+
329+
:param parser_source: an existing Cmd2ArgumentParser instance or a factory
330+
(callable, staticmethod, or classmethod) that returns one.
331+
"""
308332

309333
parser_source: ParserSource[Any]
310334

@@ -314,6 +338,8 @@ class SubcommandRecord(_SubcommandBase):
314338
"""A record of a subcommand's configuration and parser.
315339
316340
Used primarily for attaching and detaching subcommands.
341+
342+
:param parser: the built Cmd2ArgumentParser instance for this subcommand
317343
"""
318344

319345
parser: "Cmd2ArgumentParser"

cmd2/cmd2.py

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@
111111
from . import rich_utils as ru
112112
from . import string_utils as su
113113
from .argparse_utils import (
114+
ApCommandSpec,
114115
Cmd2ArgumentParser,
115116
ParserSource,
116117
SubcommandRecord,
@@ -280,12 +281,12 @@ def get(self, command_method: BoundCommandFunc) -> Cmd2ArgumentParser | None:
280281
return None
281282
command = command_method.__name__[len(COMMAND_FUNC_PREFIX) :]
282283

283-
parser_source = getattr(command_method, constants.CMD_ATTR_PARSER_SOURCE, None)
284-
if parser_source is None:
284+
spec: ApCommandSpec | None = getattr(command_method, constants.AP_COMMAND_ATTR_SPEC, None)
285+
if spec is None:
285286
return None
286287

287288
owner = self._cmd_app.find_commandset_for_command(command) or self._cmd_app
288-
parser = self._cmd_app._build_parser(owner, parser_source)
289+
parser = self._cmd_app._build_parser(owner, spec.parser_source)
289290

290291
# To ensure accurate usage strings, recursively update 'prog' values
291292
# within the parser to match the command name.
@@ -1063,9 +1064,21 @@ def unregister_command_set(self, cmdset: CommandSet[Any]) -> None:
10631064
self._installed_command_sets.remove(cmdset)
10641065

10651066
def _check_uninstallable(self, cmdset: CommandSet[Any]) -> None:
1066-
cmdset_id = id(cmdset)
1067+
"""Verify if a CommandSet can be safely uninstalled from the application.
1068+
1069+
This method acts as a safety guard before unregistration. It inspects all
1070+
command parsers provided by the CommandSet and recursively checks their
1071+
subcommand hierarchies to ensure no other registrant (another CommandSet
1072+
or the main application) has attached subcommands to them.
1073+
1074+
:param cmdset: the CommandSet instance to check for uninstallation safety
1075+
:raises CommandSetRegistrationError: if any parser in the CommandSet is
1076+
required by another registrant
1077+
"""
1078+
registrant_id = id(cmdset)
10671079

10681080
def check_parser_uninstallable(parser: Cmd2ArgumentParser) -> None:
1081+
# Recursively verify no subcommands belong to a different registrant
10691082
try:
10701083
subparsers_action = parser.get_subparsers_action()
10711084
except ValueError:
@@ -1080,10 +1093,10 @@ def check_parser_uninstallable(parser: Cmd2ArgumentParser) -> None:
10801093
continue
10811094
checked_parsers.add(subparser)
10821095

1083-
attached_cmdset_id = getattr(subparser, constants.PARSER_ATTR_OWNER_ID, None)
1084-
if attached_cmdset_id is not None and attached_cmdset_id != cmdset_id:
1096+
attached_registrant_id = getattr(subparser, constants.PARSER_ATTR_REGISTRANT_ID, None)
1097+
if attached_registrant_id is not None and attached_registrant_id != registrant_id:
10851098
raise CommandSetRegistrationError(
1086-
f"Cannot uninstall CommandSet: '{subparser.prog}' is required by another CommandSet"
1099+
f"Cannot uninstall CommandSet: '{subparser.prog}' is required by another registrant"
10871100
)
10881101
check_parser_uninstallable(subparser)
10891102

@@ -1117,13 +1130,13 @@ def _register_subcommands(self, owner: CmdOrSet) -> None:
11171130
owner,
11181131
predicate=lambda meth: (
11191132
isinstance(meth, Callable) # type: ignore[arg-type]
1120-
and hasattr(meth, constants.SUBCMD_ATTR_SPEC)
1133+
and hasattr(meth, constants.SUBCOMMAND_ATTR_SPEC)
11211134
),
11221135
)
11231136

11241137
# iterate through all matching methods
11251138
for _method_name, method in methods:
1126-
spec: SubcommandSpec = getattr(method, constants.SUBCMD_ATTR_SPEC)
1139+
spec: SubcommandSpec = getattr(method, constants.SUBCOMMAND_ATTR_SPEC)
11271140

11281141
subcommand_valid, errmsg = self.statement_parser.is_valid_command(spec.name, is_subcommand=True)
11291142
if not subcommand_valid:
@@ -1134,12 +1147,12 @@ def _register_subcommands(self, owner: CmdOrSet) -> None:
11341147
if subcmd_parser.description is None and method.__doc__:
11351148
subcmd_parser.description = strip_doc_annotations(method.__doc__)
11361149

1137-
# Set the subcommand handler
1138-
defaults = {constants.NS_ATTR_SUBCMD_HANDLER: method}
1150+
# Set the subcommand function
1151+
defaults = {constants.NS_ATTR_SUBCOMMAND_FUNC: method}
11391152
subcmd_parser.set_defaults(**defaults)
11401153

1141-
# Set what instance the handler is bound to
1142-
setattr(subcmd_parser, constants.PARSER_ATTR_OWNER_ID, id(owner))
1154+
# Record the ID of the instance that registered this subcommand parser
1155+
setattr(subcmd_parser, constants.PARSER_ATTR_REGISTRANT_ID, id(owner))
11431156

11441157
# Attach this subcommand
11451158
record = SubcommandRecord(
@@ -1169,13 +1182,13 @@ def _unregister_subcommands(self, owner: CmdOrSet) -> None:
11691182
owner,
11701183
predicate=lambda meth: (
11711184
isinstance(meth, Callable) # type: ignore[arg-type]
1172-
and hasattr(meth, constants.SUBCMD_ATTR_SPEC)
1185+
and hasattr(meth, constants.SUBCOMMAND_ATTR_SPEC)
11731186
),
11741187
)
11751188

11761189
# iterate through all matching methods
11771190
for _method_name, method in methods:
1178-
spec: SubcommandSpec = getattr(method, constants.SUBCMD_ATTR_SPEC)
1191+
spec: SubcommandSpec = getattr(method, constants.SUBCOMMAND_ATTR_SPEC)
11791192

11801193
with contextlib.suppress(ValueError):
11811194
self.detach_subcommand(spec.command, spec.name)
@@ -2517,15 +2530,15 @@ def _perform_completion(
25172530

25182531
if command_func is not None and argparser is not None:
25192532
# Get arguments for complete()
2520-
preserve_quotes = getattr(command_func, constants.CMD_ATTR_PRESERVE_QUOTES)
2533+
spec: ApCommandSpec = getattr(command_func, constants.AP_COMMAND_ATTR_SPEC)
25212534
cmd_set = self.find_commandset_for_command(command)
25222535

25232536
# Create the argparse completer
25242537
completer_type = self._determine_ap_completer_type(argparser)
25252538
completer = completer_type(argparser, self)
25262539

25272540
completer_func = functools.partial(
2528-
completer.complete, tokens=raw_tokens[1:] if preserve_quotes else tokens[1:], cmd_set=cmd_set
2541+
completer.complete, tokens=raw_tokens[1:] if spec.preserve_quotes else tokens[1:], cmd_set=cmd_set
25292542
)
25302543
else:
25312544
completer_func = self.completedefault # type: ignore[assignment]
@@ -3380,8 +3393,8 @@ def _get_command_category(self, func: BoundCommandFunc) -> str:
33803393
:return: category name
33813394
"""
33823395
# Check if the command function has a category.
3383-
if hasattr(func, constants.CMD_ATTR_HELP_CATEGORY):
3384-
category: str = getattr(func, constants.CMD_ATTR_HELP_CATEGORY)
3396+
if hasattr(func, constants.COMMAND_ATTR_HELP_CATEGORY):
3397+
category: str = getattr(func, constants.COMMAND_ATTR_HELP_CATEGORY)
33853398

33863399
# Otherwise get the category from its defining class.
33873400
else:
@@ -3784,8 +3797,8 @@ def _build_alias_parser() -> Cmd2ArgumentParser:
37843797
@with_argparser(_build_alias_parser, preserve_quotes=True)
37853798
def do_alias(self, args: argparse.Namespace) -> None:
37863799
"""Manage aliases."""
3787-
# Call handler for whatever subcommand was selected
3788-
args.cmd2_subcmd_handler(args)
3800+
# Call function for whatever subcommand was selected
3801+
args.cmd2_subcommand_func(args)
37893802

37903803
# alias -> create
37913804
@classmethod
@@ -3998,8 +4011,8 @@ def _build_macro_parser() -> Cmd2ArgumentParser:
39984011
@with_argparser(_build_macro_parser, preserve_quotes=True)
39994012
def do_macro(self, args: argparse.Namespace) -> None:
40004013
"""Manage macros."""
4001-
# Call handler for whatever subcommand was selected
4002-
args.cmd2_subcmd_handler(args)
4014+
# Call function for whatever subcommand was selected
4015+
args.cmd2_subcommand_func(args)
40034016

40044017
# macro -> create
40054018
@classmethod

cmd2/constants.py

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -69,20 +69,17 @@ def cmd2_public_attr_name(name: str) -> str:
6969

7070
# --- Private Internal Attributes ---
7171

72-
# Attached to a command function; defines the source from which its parser is built
73-
CMD_ATTR_PARSER_SOURCE = cmd2_private_attr_name("parser_source")
74-
7572
# Attached to a command function; defines its help section category
76-
CMD_ATTR_HELP_CATEGORY = cmd2_private_attr_name("help_category")
73+
COMMAND_ATTR_HELP_CATEGORY = cmd2_private_attr_name("help_category")
7774

78-
# Attached to a command function; defines whether tokens are unquoted before reaching argparse
79-
CMD_ATTR_PRESERVE_QUOTES = cmd2_private_attr_name("preserve_quotes")
75+
# Attached to an argparse-based command function; defines its ApCommandSpec instance
76+
AP_COMMAND_ATTR_SPEC = cmd2_private_attr_name("ap_command_spec")
8077

8178
# Attached to a subcommand function; defines its SubcommandSpec instance
82-
SUBCMD_ATTR_SPEC = cmd2_private_attr_name("subcommand_spec")
79+
SUBCOMMAND_ATTR_SPEC = cmd2_private_attr_name("subcommand_spec")
8380

84-
# Attached to an argparse parser; identifies the Cmd or CommandSet instance it belongs to
85-
PARSER_ATTR_OWNER_ID = cmd2_private_attr_name("owner_id")
81+
# Attached to an argparse parser; stores the id() of the Cmd or CommandSet instance that registered it
82+
PARSER_ATTR_REGISTRANT_ID = cmd2_private_attr_name("registrant_id")
8683

8784

8885
# --- Public Developer Attributes ---
@@ -91,4 +88,4 @@ def cmd2_public_attr_name(name: str) -> str:
9188
NS_ATTR_STATEMENT = cmd2_public_attr_name("statement")
9289

9390
# Attached to an argparse Namespace; the function to handle the subcommand (or None)
94-
NS_ATTR_SUBCMD_HANDLER = cmd2_public_attr_name("subcmd_handler")
91+
NS_ATTR_SUBCOMMAND_FUNC = cmd2_public_attr_name("subcommand_func")

cmd2/decorators.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from . import constants
1818
from .argparse_utils import (
19+
ApCommandSpec,
1920
ClassParamParserFactory,
2021
Cmd2ArgumentParser,
2122
NoParamParserFactory,
@@ -354,18 +355,20 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
354355
# Include the Statement object created from the command line
355356
setattr(parsed_namespace, constants.NS_ATTR_STATEMENT, statement)
356357

357-
# Ensure NS_ATTR_SUBCMD_HANDLER is always present.
358-
if not hasattr(parsed_namespace, constants.NS_ATTR_SUBCMD_HANDLER):
359-
setattr(parsed_namespace, constants.NS_ATTR_SUBCMD_HANDLER, None)
358+
# Ensure subcommand function attribute is always present.
359+
if not hasattr(parsed_namespace, constants.NS_ATTR_SUBCOMMAND_FUNC):
360+
setattr(parsed_namespace, constants.NS_ATTR_SUBCOMMAND_FUNC, None)
360361

361362
func_arg_list = _arg_swap(args, statement_arg, *parsing_results)
362363
return func(*func_arg_list, **kwargs)
363364

364365
command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX) :]
365366

366-
# Set some custom attributes for this command
367-
setattr(cmd_wrapper, constants.CMD_ATTR_PARSER_SOURCE, parser_source)
368-
setattr(cmd_wrapper, constants.CMD_ATTR_PRESERVE_QUOTES, preserve_quotes)
367+
spec = ApCommandSpec(
368+
parser_source=parser_source,
369+
preserve_quotes=preserve_quotes,
370+
)
371+
setattr(cmd_wrapper, constants.AP_COMMAND_ATTR_SPEC, spec)
369372

370373
return cmd_wrapper
371374

@@ -450,10 +453,10 @@ def as_subcommand_to(
450453
class MyApp(cmd2.Cmd):
451454
@cmd2.with_argparser(base_parser)
452455
def do_base(self, args: argparse.Namespace) -> None:
453-
args.cmd2_subcmd_handler(args)
456+
args.cmd2_subcommand_func(args)
454457
455458
@cmd2.as_subcommand_to('base', 'sub', sub_parser, help="the subcommand")
456-
def sub_handler(self, args: argparse.Namespace) -> None:
459+
def sub_func(self, args: argparse.Namespace) -> None:
457460
self.poutput('Subcommand executed')
458461
```
459462
@@ -468,7 +471,7 @@ def arg_decorator(func: F) -> F:
468471
deprecated=deprecated,
469472
parser_source=parser_source,
470473
)
471-
setattr(func, constants.SUBCMD_ATTR_SPEC, spec)
474+
setattr(func, constants.SUBCOMMAND_ATTR_SPEC, spec)
472475
return func
473476

474477
return arg_decorator

cmd2/utils.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -712,11 +712,11 @@ def do_echo(self, arglist):
712712
"""
713713
if isinstance(func, Iterable):
714714
for item in func:
715-
setattr(item, constants.CMD_ATTR_HELP_CATEGORY, category)
715+
setattr(item, constants.COMMAND_ATTR_HELP_CATEGORY, category)
716716
elif inspect.ismethod(func):
717-
setattr(func.__func__, constants.CMD_ATTR_HELP_CATEGORY, category)
717+
setattr(func.__func__, constants.COMMAND_ATTR_HELP_CATEGORY, category)
718718
else:
719-
setattr(func, constants.CMD_ATTR_HELP_CATEGORY, category)
719+
setattr(func, constants.COMMAND_ATTR_HELP_CATEGORY, category)
720720

721721

722722
def get_defining_class(meth: Callable[..., Any]) -> type[Any] | None:

docs/features/argument_processing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,4 +400,4 @@ example demonstrates both above cases in a concrete fashion.
400400
naming collisions, do not use any of these names for your argparse arguments.
401401

402402
- `cmd2_statement` - [cmd2.Statement][] object that was created when parsing the command line.
403-
- `cmd2_subcmd_handler` - subcommand handler function or `None` if one was not set.
403+
- `cmd2_subcommand_func` - subcommand handler function or `None` if one was not set.

docs/features/modular_commands.md

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -378,19 +378,14 @@ class ExampleApp(cmd2.Cmd):
378378
self.poutput('Vegetables unloaded')
379379

380380
cut_parser = cmd2.Cmd2ArgumentParser()
381-
cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut')
381+
cut_parser.add_subparsers(title="item", help="item to cut", metavar="ITEM", required=True)
382382

383383
@with_argparser(cut_parser)
384384
def do_cut(self, ns: argparse.Namespace):
385385
"""Cut Command."""
386-
handler = ns.cmd2_subcmd_handler
387-
if handler is not None:
388-
# Call whatever subcommand function was selected
389-
handler(ns)
390-
else:
391-
# No subcommand was provided, so call help
392-
self.poutput('This command does nothing without sub-parsers registered')
393-
self.do_help('cut')
386+
# Call whatever subcommand function was selected
387+
ns.cmd2_subcommand_func(ns)
388+
394389

395390

396391
if __name__ == '__main__':

examples/argparse_example.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ def subtract(self, args: argparse.Namespace) -> None:
139139
@cmd2.with_category(ARGPARSE_SUBCOMMANDS)
140140
def do_calculate(self, args: argparse.Namespace) -> None:
141141
"""Calculate a simple mathematical operation on two integers."""
142-
args.cmd2_subcmd_handler(args)
142+
args.cmd2_subcommand_func(args)
143143

144144

145145
if __name__ == "__main__":

0 commit comments

Comments
 (0)