Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ prompt is displayed.
- Removed `set_ap_completer_type()` and `get_ap_completer_type()` since `ap_completer_type` is
now a public member of `Cmd2ArgumentParser`.
- Moved `set_parser_prog()` to `Cmd2ArgumentParser.update_prog()`.
- Renamed `cmd2_handler` to `cmd2_subcmd_handler` in the `argparse.Namespace` for clarity.
- Renamed `cmd2_handler` to `cmd2_subcommand_func` 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`.
now contain direct attributes for `cmd2_statement` and `cmd2_subcommand_func`.
- 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
Expand Down
32 changes: 29 additions & 3 deletions cmd2/argparse_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,20 +291,44 @@ def get_choices(self) -> Choices:
]


@dataclass(kw_only=True)
class ApCommandSpec:
"""Metadata for an argparse-based command function.

:param parser_source: an existing Cmd2ArgumentParser instance or a factory
(callable, staticmethod, or classmethod) that returns one.
:param preserve_quotes: if True, then arguments passed to argparse maintain their quotes
"""

parser_source: ParserSource[Any]
preserve_quotes: bool = False


@dataclass(kw_only=True)
class _SubcommandBase:
"""Base metadata shared by all subcommand representations."""
"""Base metadata shared by all subcommand representations.

:param name: the name of the subcommand
:param command: the full parent command path (e.g., 'foo bar')
:param help: optional help message for this subcommand
:param aliases: optional alternative names for this subcommand
:param deprecated: whether this subcommand is deprecated (requires Python 3.13+).
"""

name: str
command: str # The full parent command path (e.g., 'foo bar')
command: str
help: str | None = None
aliases: tuple[str, ...] = ()
deprecated: bool = False


@dataclass(kw_only=True)
class SubcommandSpec(_SubcommandBase):
"""Metadata used to build and register a subcommand."""
"""Metadata used to build and register a subcommand.

:param parser_source: an existing Cmd2ArgumentParser instance or a factory
(callable, staticmethod, or classmethod) that returns one.
"""

parser_source: ParserSource[Any]

Expand All @@ -314,6 +338,8 @@ class SubcommandRecord(_SubcommandBase):
"""A record of a subcommand's configuration and parser.

Used primarily for attaching and detaching subcommands.

:param parser: the built Cmd2ArgumentParser instance for this subcommand
"""

parser: "Cmd2ArgumentParser"
Expand Down
59 changes: 36 additions & 23 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
from . import rich_utils as ru
from . import string_utils as su
from .argparse_utils import (
ApCommandSpec,
Cmd2ArgumentParser,
ParserSource,
SubcommandRecord,
Expand Down Expand Up @@ -280,12 +281,12 @@ def get(self, command_method: BoundCommandFunc) -> Cmd2ArgumentParser | None:
return None
command = command_method.__name__[len(COMMAND_FUNC_PREFIX) :]

parser_source = getattr(command_method, constants.CMD_ATTR_PARSER_SOURCE, None)
if parser_source is None:
spec: ApCommandSpec | None = getattr(command_method, constants.AP_COMMAND_ATTR_SPEC, None)
if spec is None:
return None

owner = self._cmd_app.find_commandset_for_command(command) or self._cmd_app
parser = self._cmd_app._build_parser(owner, parser_source)
parser = self._cmd_app._build_parser(owner, spec.parser_source)

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

def _check_uninstallable(self, cmdset: CommandSet[Any]) -> None:
cmdset_id = id(cmdset)
"""Verify if a CommandSet can be safely uninstalled from the application.

This method acts as a safety guard before unregistration. It inspects all
command parsers provided by the CommandSet and recursively checks their
subcommand hierarchies to ensure no other registrant (another CommandSet
or the main application) has attached subcommands to them.

:param cmdset: the CommandSet instance to check for uninstallation safety
:raises CommandSetRegistrationError: if any parser in the CommandSet is
required by another registrant
"""
registrant_id = id(cmdset)

def check_parser_uninstallable(parser: Cmd2ArgumentParser) -> None:
# Recursively verify no subcommands belong to a different registrant
try:
subparsers_action = parser.get_subparsers_action()
except ValueError:
Expand All @@ -1080,10 +1093,10 @@ def check_parser_uninstallable(parser: Cmd2ArgumentParser) -> None:
continue
checked_parsers.add(subparser)

attached_cmdset_id = getattr(subparser, constants.PARSER_ATTR_OWNER_ID, None)
if attached_cmdset_id is not None and attached_cmdset_id != cmdset_id:
attached_registrant_id = getattr(subparser, constants.PARSER_ATTR_REGISTRANT_ID, None)
if attached_registrant_id is not None and attached_registrant_id != registrant_id:
raise CommandSetRegistrationError(
f"Cannot uninstall CommandSet: '{subparser.prog}' is required by another CommandSet"
f"Cannot uninstall CommandSet: '{subparser.prog}' is required by another registrant"
)
check_parser_uninstallable(subparser)

Expand Down Expand Up @@ -1117,13 +1130,13 @@ def _register_subcommands(self, owner: CmdOrSet) -> None:
owner,
predicate=lambda meth: (
isinstance(meth, Callable) # type: ignore[arg-type]
and hasattr(meth, constants.SUBCMD_ATTR_SPEC)
and hasattr(meth, constants.SUBCOMMAND_ATTR_SPEC)
),
)

# iterate through all matching methods
for _method_name, method in methods:
spec: SubcommandSpec = getattr(method, constants.SUBCMD_ATTR_SPEC)
spec: SubcommandSpec = getattr(method, constants.SUBCOMMAND_ATTR_SPEC)

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

# Set the subcommand handler
defaults = {constants.NS_ATTR_SUBCMD_HANDLER: method}
# Set the subcommand function
defaults = {constants.NS_ATTR_SUBCOMMAND_FUNC: method}
subcmd_parser.set_defaults(**defaults)

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

# Attach this subcommand
record = SubcommandRecord(
Expand Down Expand Up @@ -1169,13 +1182,13 @@ def _unregister_subcommands(self, owner: CmdOrSet) -> None:
owner,
predicate=lambda meth: (
isinstance(meth, Callable) # type: ignore[arg-type]
and hasattr(meth, constants.SUBCMD_ATTR_SPEC)
and hasattr(meth, constants.SUBCOMMAND_ATTR_SPEC)
),
)

# iterate through all matching methods
for _method_name, method in methods:
spec: SubcommandSpec = getattr(method, constants.SUBCMD_ATTR_SPEC)
spec: SubcommandSpec = getattr(method, constants.SUBCOMMAND_ATTR_SPEC)

with contextlib.suppress(ValueError):
self.detach_subcommand(spec.command, spec.name)
Expand Down Expand Up @@ -2517,15 +2530,15 @@ def _perform_completion(

if command_func is not None and argparser is not None:
# Get arguments for complete()
preserve_quotes = getattr(command_func, constants.CMD_ATTR_PRESERVE_QUOTES)
spec: ApCommandSpec = getattr(command_func, constants.AP_COMMAND_ATTR_SPEC)
cmd_set = self.find_commandset_for_command(command)

# Create the argparse completer
completer_type = self._determine_ap_completer_type(argparser)
completer = completer_type(argparser, self)

completer_func = functools.partial(
completer.complete, tokens=raw_tokens[1:] if preserve_quotes else tokens[1:], cmd_set=cmd_set
completer.complete, tokens=raw_tokens[1:] if spec.preserve_quotes else tokens[1:], cmd_set=cmd_set
)
else:
completer_func = self.completedefault # type: ignore[assignment]
Expand Down Expand Up @@ -3380,8 +3393,8 @@ def _get_command_category(self, func: BoundCommandFunc) -> str:
: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)
if hasattr(func, constants.COMMAND_ATTR_HELP_CATEGORY):
category: str = getattr(func, constants.COMMAND_ATTR_HELP_CATEGORY)

# Otherwise get the category from its defining class.
else:
Expand Down Expand Up @@ -3784,8 +3797,8 @@ def _build_alias_parser() -> Cmd2ArgumentParser:
@with_argparser(_build_alias_parser, preserve_quotes=True)
def do_alias(self, args: argparse.Namespace) -> None:
"""Manage aliases."""
# Call handler for whatever subcommand was selected
args.cmd2_subcmd_handler(args)
# Call function for whatever subcommand was selected
args.cmd2_subcommand_func(args)

# alias -> create
@classmethod
Expand Down Expand Up @@ -3998,8 +4011,8 @@ def _build_macro_parser() -> Cmd2ArgumentParser:
@with_argparser(_build_macro_parser, preserve_quotes=True)
def do_macro(self, args: argparse.Namespace) -> None:
"""Manage macros."""
# Call handler for whatever subcommand was selected
args.cmd2_subcmd_handler(args)
# Call function for whatever subcommand was selected
args.cmd2_subcommand_func(args)

# macro -> create
@classmethod
Expand Down
17 changes: 7 additions & 10 deletions cmd2/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,20 +69,17 @@ def cmd2_public_attr_name(name: str) -> str:

# --- Private Internal Attributes ---

# Attached to a command function; defines the source from which its parser is built
CMD_ATTR_PARSER_SOURCE = cmd2_private_attr_name("parser_source")

# Attached to a command function; defines its help section category
CMD_ATTR_HELP_CATEGORY = cmd2_private_attr_name("help_category")
COMMAND_ATTR_HELP_CATEGORY = cmd2_private_attr_name("help_category")

# 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 an argparse-based command function; defines its ApCommandSpec instance
AP_COMMAND_ATTR_SPEC = cmd2_private_attr_name("ap_command_spec")

# Attached to a subcommand function; defines its SubcommandSpec instance
SUBCMD_ATTR_SPEC = cmd2_private_attr_name("subcommand_spec")
SUBCOMMAND_ATTR_SPEC = cmd2_private_attr_name("subcommand_spec")

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


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

# Attached to an argparse Namespace; the function to handle the subcommand (or None)
NS_ATTR_SUBCMD_HANDLER = cmd2_public_attr_name("subcmd_handler")
NS_ATTR_SUBCOMMAND_FUNC = cmd2_public_attr_name("subcommand_func")
21 changes: 12 additions & 9 deletions cmd2/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from . import constants
from .argparse_utils import (
ApCommandSpec,
ClassParamParserFactory,
Cmd2ArgumentParser,
NoParamParserFactory,
Expand Down Expand Up @@ -354,18 +355,20 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
# Include the Statement object created from the command line
setattr(parsed_namespace, constants.NS_ATTR_STATEMENT, statement)

# Ensure NS_ATTR_SUBCMD_HANDLER is always present.
if not hasattr(parsed_namespace, constants.NS_ATTR_SUBCMD_HANDLER):
setattr(parsed_namespace, constants.NS_ATTR_SUBCMD_HANDLER, None)
# Ensure subcommand function attribute is always present.
if not hasattr(parsed_namespace, constants.NS_ATTR_SUBCOMMAND_FUNC):
setattr(parsed_namespace, constants.NS_ATTR_SUBCOMMAND_FUNC, None)

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

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

# Set some custom attributes for this command
setattr(cmd_wrapper, constants.CMD_ATTR_PARSER_SOURCE, parser_source)
setattr(cmd_wrapper, constants.CMD_ATTR_PRESERVE_QUOTES, preserve_quotes)
spec = ApCommandSpec(
parser_source=parser_source,
preserve_quotes=preserve_quotes,
)
setattr(cmd_wrapper, constants.AP_COMMAND_ATTR_SPEC, spec)

return cmd_wrapper

Expand Down Expand Up @@ -450,10 +453,10 @@ def as_subcommand_to(
class MyApp(cmd2.Cmd):
@cmd2.with_argparser(base_parser)
def do_base(self, args: argparse.Namespace) -> None:
args.cmd2_subcmd_handler(args)
args.cmd2_subcommand_func(args)

@cmd2.as_subcommand_to('base', 'sub', sub_parser, help="the subcommand")
def sub_handler(self, args: argparse.Namespace) -> None:
def sub_func(self, args: argparse.Namespace) -> None:
self.poutput('Subcommand executed')
```

Expand All @@ -468,7 +471,7 @@ def arg_decorator(func: F) -> F:
deprecated=deprecated,
parser_source=parser_source,
)
setattr(func, constants.SUBCMD_ATTR_SPEC, spec)
setattr(func, constants.SUBCOMMAND_ATTR_SPEC, spec)
return func

return arg_decorator
16 changes: 8 additions & 8 deletions cmd2/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -694,7 +694,7 @@ def categorize(func: Callable[..., Any] | Iterable[Callable[..., Any]], category
The help command output will group the passed function under the
specified category heading

:param func: function or list of functions to categorize
:param func: function or Iterable of functions to categorize
:param category: category to put it in

Example:
Expand All @@ -710,13 +710,13 @@ def do_echo(self, arglist):
For an alternative approach to categorizing commands using a decorator, see [cmd2.decorators.with_category][]

"""
if isinstance(func, Iterable):
for item in func:
setattr(item, constants.CMD_ATTR_HELP_CATEGORY, category)
elif inspect.ismethod(func):
setattr(func.__func__, constants.CMD_ATTR_HELP_CATEGORY, category)
else:
setattr(func, constants.CMD_ATTR_HELP_CATEGORY, category)
funcs = func if isinstance(func, Iterable) else (func,)

for cur_func in funcs:
if inspect.ismethod(cur_func):
setattr(cur_func.__func__, constants.COMMAND_ATTR_HELP_CATEGORY, category)
else:
setattr(cur_func, constants.COMMAND_ATTR_HELP_CATEGORY, category)


def get_defining_class(meth: Callable[..., Any]) -> type[Any] | None:
Expand Down
2 changes: 1 addition & 1 deletion docs/features/argument_processing.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,4 +400,4 @@ example demonstrates both above cases in a concrete fashion.
naming collisions, do not use any of these names for your argparse arguments.

- `cmd2_statement` - [cmd2.Statement][] object that was created when parsing the command line.
- `cmd2_subcmd_handler` - subcommand handler function or `None` if one was not set.
- `cmd2_subcommand_func` - subcommand handler function or `None` if one was not set.
13 changes: 4 additions & 9 deletions docs/features/modular_commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,19 +378,14 @@ class ExampleApp(cmd2.Cmd):
self.poutput('Vegetables unloaded')

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

@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
handler(ns)
else:
# No subcommand was provided, so call help
self.poutput('This command does nothing without sub-parsers registered')
self.do_help('cut')
# Call whatever subcommand function was selected
ns.cmd2_subcommand_func(ns)



if __name__ == '__main__':
Expand Down
2 changes: 1 addition & 1 deletion examples/argparse_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ def subtract(self, args: argparse.Namespace) -> None:
@cmd2.with_category(ARGPARSE_SUBCOMMANDS)
def do_calculate(self, args: argparse.Namespace) -> None:
"""Calculate a simple mathematical operation on two integers."""
args.cmd2_subcmd_handler(args)
args.cmd2_subcommand_func(args)


if __name__ == "__main__":
Expand Down
Loading
Loading