From 96d7d577270aa8b831723a5e43a4a448379b36a7 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 15 Apr 2026 14:55:30 -0400 Subject: [PATCH 01/15] Improved type hints. --- cmd2/argparse_completer.py | 15 ++-- cmd2/argparse_custom.py | 9 +- cmd2/cmd2.py | 79 +++++++++-------- cmd2/command_set.py | 60 ++++++------- cmd2/decorators.py | 79 ++++++++--------- cmd2/types.py | 78 ++++++++++++++--- cmd2/utils.py | 8 +- docs/features/modular_commands.md | 87 ++++++++++++------- examples/command_sets.py | 6 +- examples/default_categories.py | 2 +- examples/modular_commands/commandset_basic.py | 3 +- .../modular_commands/commandset_custominit.py | 3 +- tests/test_categories.py | 4 +- tests/test_cmd2.py | 2 +- 14 files changed, 250 insertions(+), 185 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 5be38fc64..512068bbf 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -35,9 +35,8 @@ from .exceptions import CompletionError from .rich_utils import Cmd2SimpleTable from .types import ( - ChoicesProviderUnbound, - CmdOrSet, - CompleterUnbound, + UnboundChoicesProvider, + UnboundCompleter, ) if TYPE_CHECKING: # pragma: no cover @@ -214,7 +213,7 @@ def complete( endidx: int, tokens: Sequence[str], *, - cmd_set: CommandSet | None = None, + cmd_set: CommandSet[Any] | None = None, ) -> Completions: """Complete text using argparse metadata. @@ -469,7 +468,7 @@ def _handle_last_token( consumed_arg_values: dict[str, list[str]], used_flags: set[str], skip_remaining_flags: bool, - cmd_set: CommandSet | None, + cmd_set: CommandSet[Any] | None, ) -> Completions: """Perform final completion step handling positionals and flags.""" # Check if we are completing a flag name. This check ignores strings with a length of one, like '-'. @@ -734,11 +733,11 @@ def _choices_to_items(self, arg_state: _ArgumentState) -> list[CompletionItem]: def _prepare_callable_params( self, - to_call: ChoicesProviderUnbound[CmdOrSet] | CompleterUnbound[CmdOrSet], + to_call: UnboundChoicesProvider[Any] | UnboundCompleter[Any], arg_state: _ArgumentState, text: str, consumed_arg_values: dict[str, list[str]], - cmd_set: CommandSet | None, + cmd_set: CommandSet[Any] | None, ) -> tuple[list[Any], dict[str, Any]]: """Resolve the instance and arguments required to call a choices/completer function.""" args: list[Any] = [] @@ -769,7 +768,7 @@ def _complete_arg( arg_state: _ArgumentState, consumed_arg_values: dict[str, list[str]], *, - cmd_set: CommandSet | None = None, + cmd_set: CommandSet[Any] | None = None, ) -> Completions: """Completion routine for an argparse argument. diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 88ef9202f..85b8024fa 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -262,9 +262,8 @@ def get_choices(self) -> Choices: from .rich_utils import Cmd2RichArgparseConsole from .styles import Cmd2Style from .types import ( - ChoicesProviderUnbound, - CmdOrSet, - CompleterUnbound, + UnboundChoicesProvider, + UnboundCompleter, ) if TYPE_CHECKING: # pragma: no cover @@ -388,8 +387,8 @@ def _ActionsContainer_add_argument( # noqa: N802 self: argparse._ActionsContainer, *args: Any, nargs: int | str | tuple[int] | tuple[int, int] | tuple[int, float] | None = None, - choices_provider: ChoicesProviderUnbound[CmdOrSet] | None = None, - completer: CompleterUnbound[CmdOrSet] | None = None, + choices_provider: UnboundChoicesProvider[Any] | None = None, + completer: UnboundCompleter[Any] | None = None, suppress_tab_hint: bool = False, table_columns: Sequence[str | Column] | None = None, **kwargs: Any, diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 462ce8fad..7c525cb88 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -63,7 +63,6 @@ ClassVar, TextIO, TypeVar, - Union, cast, ) @@ -114,10 +113,7 @@ get_paste_buffer, write_to_paste_buffer, ) -from .command_set import ( - CommandFunc, - CommandSet, -) +from .command_set import CommandSet from .completion import ( Choices, CompletionItem, @@ -164,10 +160,11 @@ ) from .styles import Cmd2Style from .types import ( - ChoicesProviderUnbound, + BoundCommandFunc, + BoundCompleter, CmdOrSet, - CompleterBound, - CompleterUnbound, + UnboundChoicesProvider, + UnboundCompleter, ) with contextlib.suppress(ImportError): @@ -205,7 +202,7 @@ def __init__(self, msg: str = '') -> None: if TYPE_CHECKING: # pragma: no cover StaticArgParseBuilder = staticmethod[[], Cmd2ArgumentParser] - ClassArgParseBuilder = classmethod['Cmd' | CommandSet, [], Cmd2ArgumentParser] + ClassArgParseBuilder = classmethod[CmdOrSet, [], Cmd2ArgumentParser] from prompt_toolkit.buffer import Buffer else: StaticArgParseBuilder = staticmethod @@ -238,14 +235,14 @@ def __init__(self, cmd: 'Cmd') -> None: self._parsers: dict[str, Cmd2ArgumentParser] = {} @staticmethod - def _fully_qualified_name(command_method: CommandFunc) -> str: + def _fully_qualified_name(command_method: BoundCommandFunc) -> str: """Return the fully qualified name of a method or None if a method wasn't passed in.""" try: return f"{command_method.__module__}.{command_method.__qualname__}" except AttributeError: return "" - def __contains__(self, command_method: CommandFunc) -> bool: + def __contains__(self, command_method: BoundCommandFunc) -> bool: """Return whether a given method's parser is in self. If the parser does not yet exist, it will be created if applicable. @@ -254,7 +251,7 @@ def __contains__(self, command_method: CommandFunc) -> bool: parser = self.get(command_method) return bool(parser) - def get(self, command_method: CommandFunc) -> Cmd2ArgumentParser | None: + def get(self, command_method: BoundCommandFunc) -> Cmd2ArgumentParser | None: """Return a given method's parser or None if the method is not argparse-based. If the parser does not yet exist, it will be created. @@ -287,7 +284,7 @@ def get(self, command_method: CommandFunc) -> Cmd2ArgumentParser | None: return self._parsers.get(full_method_name) - def remove(self, command_method: CommandFunc) -> None: + def remove(self, command_method: BoundCommandFunc) -> None: """Remove a given method's parser if it exists.""" full_method_name = self._fully_qualified_name(command_method) if full_method_name in self._parsers: @@ -355,7 +352,7 @@ def __init__( auto_load_commands: bool = False, auto_suggest: bool = True, bottom_toolbar: bool = False, - command_sets: Iterable[CommandSet] | None = None, + command_sets: Iterable[CommandSet[Any]] | None = None, include_ipy: bool = False, include_py: bool = False, intro: RenderableType = '', @@ -482,8 +479,8 @@ def __init__( self._always_prefix_settables: bool = False # CommandSet containers - self._installed_command_sets: set[CommandSet] = set() - self._cmd_to_command_sets: dict[str, CommandSet] = {} + self._installed_command_sets: set[CommandSet[Any]] = set() + self._cmd_to_command_sets: dict[str, CommandSet[Any]] = {} self.build_settables() @@ -758,7 +755,9 @@ def _(event: Any) -> None: # pragma: no cover ) return PromptSession(**kwargs) - def find_commandsets(self, commandset_type: type[CommandSet], *, subclass_match: bool = False) -> list[CommandSet]: + def find_commandsets( + self, commandset_type: type[CommandSet[Any]], *, subclass_match: bool = False + ) -> list[CommandSet[Any]]: """Find all CommandSets that match the provided CommandSet type. By default, locates a CommandSet that is an exact type match but may optionally return all CommandSets that @@ -773,7 +772,7 @@ def find_commandsets(self, commandset_type: type[CommandSet], *, subclass_match: if type(cmdset) == commandset_type or (subclass_match and isinstance(cmdset, commandset_type)) # noqa: E721 ] - def find_commandset_for_command(self, command_name: str) -> CommandSet | None: + def find_commandset_for_command(self, command_name: str) -> CommandSet[Any] | None: """Find the CommandSet that registered the command name. :param command_name: command name to search @@ -787,7 +786,7 @@ def _autoload_commands(self) -> None: all_commandset_defs = CommandSet.__subclasses__() existing_commandset_types = [type(command_set) for command_set in self._installed_command_sets] - def load_commandset_by_type(commandset_types: list[type[CommandSet]]) -> None: + def load_commandset_by_type(commandset_types: list[type[CommandSet[Any]]]) -> None: for cmdset_type in commandset_types: # check if the type has sub-classes. We will only auto-load leaf class types. subclasses = cmdset_type.__subclasses__() @@ -805,7 +804,7 @@ def load_commandset_by_type(commandset_types: list[type[CommandSet]]) -> None: load_commandset_by_type(all_commandset_defs) - def register_command_set(self, cmdset: CommandSet) -> None: + def register_command_set(self, cmdset: CommandSet[Any]) -> None: """Installs a CommandSet, loading all commands defined in the CommandSet. :param cmdset: CommandSet to load @@ -920,7 +919,7 @@ def _build_parser( return parser - def _install_command_function(self, command_func_name: str, command_method: CommandFunc, context: str = '') -> None: + def _install_command_function(self, command_func_name: str, command_method: BoundCommandFunc, context: str = '') -> None: """Install a new command function into the CLI. :param command_func_name: name of command function to add @@ -961,7 +960,7 @@ def _install_command_function(self, command_func_name: str, command_method: Comm setattr(self, command_func_name, command_method) - def _install_completer_function(self, cmd_name: str, cmd_completer: CompleterBound) -> None: + def _install_completer_function(self, cmd_name: str, cmd_completer: BoundCompleter) -> None: completer_func_name = COMPLETER_FUNC_PREFIX + cmd_name if hasattr(self, completer_func_name): @@ -975,7 +974,7 @@ def _install_help_function(self, cmd_name: str, cmd_help: Callable[..., None]) - raise CommandSetRegistrationError(f'Attribute already exists: {help_func_name}') setattr(self, help_func_name, cmd_help) - def unregister_command_set(self, cmdset: CommandSet) -> None: + def unregister_command_set(self, cmdset: CommandSet[Any]) -> None: """Uninstalls a CommandSet and unloads all associated commands. :param cmdset: CommandSet to uninstall @@ -1020,7 +1019,7 @@ def unregister_command_set(self, cmdset: CommandSet) -> None: cmdset.on_unregistered() self._installed_command_sets.remove(cmdset) - def _check_uninstallable(self, cmdset: CommandSet) -> None: + def _check_uninstallable(self, cmdset: CommandSet[Any]) -> None: cmdset_id = id(cmdset) def check_parser_uninstallable(parser: Cmd2ArgumentParser) -> None: @@ -1062,7 +1061,7 @@ def check_parser_uninstallable(parser: Cmd2ArgumentParser) -> None: if command_parser is not None: check_parser_uninstallable(command_parser) - def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: + def _register_subcommands(self, cmdset: CmdOrSet) -> None: """Register subcommands with their base command. :param cmdset: CommandSet or cmd2.Cmd subclass containing subcommands @@ -1112,7 +1111,7 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: except ValueError as ex: raise CommandSetRegistrationError(str(ex)) from ex - def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: + def _unregister_subcommands(self, cmdset: CmdOrSet) -> None: """Unregister subcommands from their base command. :param cmdset: CommandSet containing subcommands @@ -2286,7 +2285,7 @@ def shell_cmd_complete( text, line, begidx, endidx, path_filter=lambda path: os.path.isdir(path) or os.access(path, os.X_OK) ) - def _redirect_complete(self, text: str, line: str, begidx: int, endidx: int, compfunc: CompleterBound) -> Completions: + def _redirect_complete(self, text: str, line: str, begidx: int, endidx: int, compfunc: BoundCompleter) -> Completions: """First completion function for all commands, called by complete(). It determines if it should complete for redirection (|, >, >>) or use the @@ -2428,7 +2427,7 @@ def _perform_completion( return Completions() # Determine the completer function to use for the command's argument - completer_func: CompleterBound + completer_func: BoundCompleter if custom_settings is None: # Check if a macro was entered if command in self.macros: @@ -3317,7 +3316,7 @@ def _restore_output(self, statement: Statement, saved_redir_state: utils.Redirec self._cur_pipe_proc_reader = saved_redir_state.saved_pipe_proc_reader self._redirecting = saved_redir_state.saved_redirecting - def cmd_func(self, command: str) -> CommandFunc | None: + def cmd_func(self, command: str) -> BoundCommandFunc | None: """Get the function for a command. :param command: the name of the command @@ -3332,9 +3331,9 @@ def cmd_func(self, command: str) -> CommandFunc | None: """ func_name = constants.COMMAND_FUNC_PREFIX + command func = getattr(self, func_name, None) - return cast(CommandFunc, func) if callable(func) else None + return cast(BoundCommandFunc, func) if callable(func) else None - def _get_command_category(self, func: CommandFunc) -> str: + def _get_command_category(self, func: BoundCommandFunc) -> str: """Determine the category for a command. :param func: the do_* function implementing the command @@ -3486,8 +3485,8 @@ def _resolve_completer( self, preserve_quotes: bool = False, choices: Iterable[Any] | None = None, - choices_provider: ChoicesProviderUnbound[CmdOrSet] | None = None, - completer: CompleterUnbound[CmdOrSet] | None = None, + choices_provider: UnboundChoicesProvider[Any] | None = None, + completer: UnboundCompleter[Any] | None = None, parser: Cmd2ArgumentParser | None = None, ) -> Completer: """Determine the appropriate completer based on provided arguments.""" @@ -3518,8 +3517,8 @@ def read_input( history: Sequence[str] | None = None, preserve_quotes: bool = False, choices: Iterable[Any] | None = None, - choices_provider: ChoicesProviderUnbound[CmdOrSet] | None = None, - completer: CompleterUnbound[CmdOrSet] | None = None, + choices_provider: UnboundChoicesProvider[Any] | None = None, + completer: UnboundCompleter[Any] | None = None, parser: Cmd2ArgumentParser | None = None, ) -> str: """Read a line of input with optional completion and history. @@ -4247,7 +4246,7 @@ def _build_command_info(self) -> tuple[dict[str, list[str]], list[str]]: help_topics.remove(command) # Store the command within its category - func = cast(CommandFunc, self.cmd_func(command)) + func = cast(BoundCommandFunc, self.cmd_func(command)) category = self._get_command_category(func) cmds_cats.setdefault(category, []).append(command) @@ -5677,7 +5676,7 @@ def disable_category(self, category: str, message_to_print: str) -> None: all_commands = self.get_all_commands() for cmd_name in all_commands: - func = cast(CommandFunc, self.cmd_func(cmd_name)) + func = cast(BoundCommandFunc, self.cmd_func(cmd_name)) if self._get_command_category(func) == category: self.disable_command(cmd_name, message_to_print) @@ -5871,7 +5870,7 @@ def register_cmdfinalization_hook( def _resolve_func_self( self, cmd_support_func: Callable[..., Any], - cmd_self: Union[CommandSet, 'Cmd', None], + cmd_self: CmdOrSet | None, ) -> object | None: """Attempt to resolve a candidate instance to pass as 'self'. @@ -5895,7 +5894,7 @@ def _resolve_func_self( # 2. Do any of the registered CommandSets in the Cmd2 application exactly match the type? # 3. Is there a registered CommandSet that is is the only matching subclass? - func_self: CommandSet | Cmd | None + func_self: CmdOrSet | None # check if the command's CommandSet is a sub-class of the support function's defining class if isinstance(cmd_self, func_class): @@ -5904,7 +5903,7 @@ def _resolve_func_self( else: # Search all registered CommandSets func_self = None - candidate_sets: list[CommandSet] = [] + candidate_sets: list[CommandSet[Any]] = [] for installed_cmd_set in self._installed_command_sets: if type(installed_cmd_set) == func_class: # noqa: E721 # Case 2: CommandSet is an exact type match for the function's CommandSet diff --git a/cmd2/command_set.py b/cmd2/command_set.py index 277f4ebc9..2bdacecdb 100644 --- a/cmd2/command_set.py +++ b/cmd2/command_set.py @@ -1,30 +1,27 @@ """Supports the definition of commands in separate classes to be composed into cmd2.Cmd.""" -from collections.abc import ( - Callable, - Mapping, -) +from collections.abc import Mapping from typing import ( - TYPE_CHECKING, ClassVar, - TypeAlias, + Generic, ) from .exceptions import CommandSetRegistrationError +from .types import CmdT from .utils import Settable -if TYPE_CHECKING: # pragma: no cover - from .cmd2 import Cmd - -# Callable signature for a basic command function -# Further refinements are needed to define the input parameters -CommandFunc: TypeAlias = Callable[..., bool | None] - -class CommandSet: +class CommandSet(Generic[CmdT]): """Base class for defining sets of commands to load in cmd2. - ``do_``, ``help_``, and ``complete_`` functions differ only in that self is the CommandSet instead of the cmd2 app + ``do_``, ``help_``, and ``complete_`` functions differ only in that self is the + CommandSet instead of the cmd2 app. + + This class is generic over the `Cmd` type it is expected to be loaded into. + By providing the specific `Cmd` subclass as a type argument + (e.g., `class MyCommandSet(CommandSet[MyApp]):`), type checkers will know the exact + type of `self._cmd`, allowing for autocompletion and type validation when accessing + custom attributes and methods on the main application instance. """ # Default category for commands defined in this CommandSet which have @@ -39,37 +36,30 @@ def __init__(self) -> None: This will be set when the CommandSet is registered and it should be accessed by child classes using the self._cmd property. """ - self._cmd_internal: Cmd | None = None + self._cmd_internal: CmdT | None = None self._settables: dict[str, Settable] = {} self._settable_prefix = self.__class__.__name__ @property - def _cmd(self) -> 'Cmd': + def _cmd(self) -> CmdT: """Property for child classes to access self._cmd_internal. Using this property ensures that the CommandSet has been registered and tells type checkers that self._cmd_internal is not None. - Override this property to specify a more specific return type for static - type checking. The typing.cast function can be used to assert to the - type checker that the parent cmd2.Cmd instance is of a more specific - subclass, enabling better autocompletion and type safety in the child class. - - For example: - - @property - def _cmd(self) -> CustomCmdApp: - return cast(CustomCmdApp, super()._cmd) + Subclasses can specify their specific Cmd type during inheritance: + class MyCommandSet(CommandSet[MyCustomApp]): + ... :raises CommandSetRegistrationError: if CommandSet is not registered. """ - if self._cmd_internal is None: - raise CommandSetRegistrationError('This CommandSet is not registered') - return self._cmd_internal + if (cmd := self._cmd_internal) is not None: + return cmd + raise CommandSetRegistrationError('This CommandSet is not registered') - def on_register(self, cmd: 'Cmd') -> None: + def on_register(self, cmd: CmdT) -> None: """First step to registering a CommandSet, called by cmd2.Cmd. The commands defined in this class have not been added to the CLI object at this point. @@ -119,13 +109,13 @@ def add_settable(self, settable: Settable) -> None: :param settable: Settable object being added """ - if self._cmd_internal is not None: - if not self._cmd.always_prefix_settables: - if settable.name in self._cmd.settables and settable.name not in self._settables: + if (cmd := self._cmd_internal) is not None: + if not cmd.always_prefix_settables: + if settable.name in cmd.settables and settable.name not in self._settables: raise KeyError(f'Duplicate settable: {settable.name}') else: prefixed_name = f'{self._settable_prefix}.{settable.name}' - if prefixed_name in self._cmd.settables and settable.name not in self._settables: + if prefixed_name in cmd.settables and settable.name not in self._settables: raise KeyError(f'Duplicate settable: {settable.name}') self._settables[settable.name] = settable diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 39a3a959d..a92b5e742 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -9,24 +9,24 @@ TYPE_CHECKING, Any, TypeAlias, - TypeVar, ) from . import constants from .argparse_custom import Cmd2ArgumentParser -from .command_set import ( - CommandFunc, - CommandSet, -) +from .command_set import CommandSet from .exceptions import Cmd2ArgparseError from .parsing import Statement -from .types import CmdOrSet +from .types import ( + CmdOrSetClassT, + CmdOrSetT, + UnboundCommandFuncT, +) if TYPE_CHECKING: # pragma: no cover from .cmd2 import Cmd -def with_category(category: str) -> Callable[[CommandFunc], CommandFunc]: +def with_category(category: str) -> Callable[[UnboundCommandFuncT], UnboundCommandFuncT]: """Decorate a ``do_*`` command method to apply a category. :param category: the name of the category in which this command should @@ -45,10 +45,8 @@ def do_echo(self, args) """ - def cat_decorator(func: CommandFunc) -> CommandFunc: - from .utils import ( - categorize, - ) + def cat_decorator(func: UnboundCommandFuncT) -> UnboundCommandFuncT: + from .utils import categorize categorize(func, category) return func @@ -56,8 +54,7 @@ def cat_decorator(func: CommandFunc) -> CommandFunc: return cat_decorator -CmdOrSetClass = TypeVar('CmdOrSetClass', bound=type['Cmd'] | type[CommandSet]) -RawCommandFuncOptionalBoolReturn: TypeAlias = Callable[[CmdOrSet, Statement | str], bool | None] +RawCommandFuncOptionalBoolReturn: TypeAlias = Callable[[CmdOrSetT, Statement | str], bool | None] ########################## @@ -106,29 +103,29 @@ def _arg_swap(args: Sequence[Any], search_arg: Any, *replace_arg: Any) -> list[A # Function signature for a command function that accepts a pre-processed argument list from user input # and optionally returns a boolean -ArgListCommandFuncOptionalBoolReturn: TypeAlias = Callable[[CmdOrSet, list[str]], bool | None] +ArgListCommandFuncOptionalBoolReturn: TypeAlias = Callable[[CmdOrSetT, list[str]], bool | None] # Function signature for a command function that accepts a pre-processed argument list from user input # and returns a boolean -ArgListCommandFuncBoolReturn: TypeAlias = Callable[[CmdOrSet, list[str]], bool] +ArgListCommandFuncBoolReturn: TypeAlias = Callable[[CmdOrSetT, list[str]], bool] # Function signature for a command function that accepts a pre-processed argument list from user input # and returns Nothing -ArgListCommandFuncNoneReturn: TypeAlias = Callable[[CmdOrSet, list[str]], None] +ArgListCommandFuncNoneReturn: TypeAlias = Callable[[CmdOrSetT, list[str]], None] # Aggregate of all accepted function signatures for command functions that accept a pre-processed argument list ArgListCommandFunc: TypeAlias = ( - ArgListCommandFuncOptionalBoolReturn[CmdOrSet] - | ArgListCommandFuncBoolReturn[CmdOrSet] - | ArgListCommandFuncNoneReturn[CmdOrSet] + ArgListCommandFuncOptionalBoolReturn[CmdOrSetT] + | ArgListCommandFuncBoolReturn[CmdOrSetT] + | ArgListCommandFuncNoneReturn[CmdOrSetT] ) def with_argument_list( - func_arg: ArgListCommandFunc[CmdOrSet] | None = None, + func_arg: ArgListCommandFunc[CmdOrSetT] | None = None, *, preserve_quotes: bool = False, ) -> ( - RawCommandFuncOptionalBoolReturn[CmdOrSet] - | Callable[[ArgListCommandFunc[CmdOrSet]], RawCommandFuncOptionalBoolReturn[CmdOrSet]] + RawCommandFuncOptionalBoolReturn[CmdOrSetT] + | Callable[[ArgListCommandFunc[CmdOrSetT]], RawCommandFuncOptionalBoolReturn[CmdOrSetT]] ): """Decorate a ``do_*`` method to alter the arguments passed to it so it is passed a list[str]. @@ -151,7 +148,7 @@ def do_echo(self, arglist): """ import functools - def arg_decorator(func: ArgListCommandFunc[CmdOrSet]) -> RawCommandFuncOptionalBoolReturn[CmdOrSet]: + def arg_decorator(func: ArgListCommandFunc[CmdOrSetT]) -> RawCommandFuncOptionalBoolReturn[CmdOrSetT]: """Decorate function that ingests an Argument List function and returns a raw command function. The returned function will process the raw input into an argument list to be passed to the wrapped function. @@ -186,41 +183,41 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: # Function signatures for command functions that use a Cmd2ArgumentParser to process user input # and optionally return a boolean -ArgparseCommandFuncOptionalBoolReturn: TypeAlias = Callable[[CmdOrSet, argparse.Namespace], bool | None] +ArgparseCommandFuncOptionalBoolReturn: TypeAlias = Callable[[CmdOrSetT, argparse.Namespace], bool | None] ArgparseCommandFuncWithUnknownArgsOptionalBoolReturn: TypeAlias = Callable[ - [CmdOrSet, argparse.Namespace, list[str]], bool | None + [CmdOrSetT, argparse.Namespace, list[str]], bool | None ] # Function signatures for command functions that use a Cmd2ArgumentParser to process user input # and return a boolean -ArgparseCommandFuncBoolReturn: TypeAlias = Callable[[CmdOrSet, argparse.Namespace], bool] -ArgparseCommandFuncWithUnknownArgsBoolReturn: TypeAlias = Callable[[CmdOrSet, argparse.Namespace, list[str]], bool] +ArgparseCommandFuncBoolReturn: TypeAlias = Callable[[CmdOrSetT, argparse.Namespace], bool] +ArgparseCommandFuncWithUnknownArgsBoolReturn: TypeAlias = Callable[[CmdOrSetT, argparse.Namespace, list[str]], bool] # Function signatures for command functions that use a Cmd2ArgumentParser to process user input # and return nothing -ArgparseCommandFuncNoneReturn: TypeAlias = Callable[[CmdOrSet, argparse.Namespace], None] -ArgparseCommandFuncWithUnknownArgsNoneReturn: TypeAlias = Callable[[CmdOrSet, argparse.Namespace, list[str]], None] +ArgparseCommandFuncNoneReturn: TypeAlias = Callable[[CmdOrSetT, argparse.Namespace], None] +ArgparseCommandFuncWithUnknownArgsNoneReturn: TypeAlias = Callable[[CmdOrSetT, argparse.Namespace, list[str]], None] # Aggregate of all accepted function signatures for an argparse command function ArgparseCommandFunc: TypeAlias = ( - ArgparseCommandFuncOptionalBoolReturn[CmdOrSet] - | ArgparseCommandFuncWithUnknownArgsOptionalBoolReturn[CmdOrSet] - | ArgparseCommandFuncBoolReturn[CmdOrSet] - | ArgparseCommandFuncWithUnknownArgsBoolReturn[CmdOrSet] - | ArgparseCommandFuncNoneReturn[CmdOrSet] - | ArgparseCommandFuncWithUnknownArgsNoneReturn[CmdOrSet] + ArgparseCommandFuncOptionalBoolReturn[CmdOrSetT] + | ArgparseCommandFuncWithUnknownArgsOptionalBoolReturn[CmdOrSetT] + | ArgparseCommandFuncBoolReturn[CmdOrSetT] + | ArgparseCommandFuncWithUnknownArgsBoolReturn[CmdOrSetT] + | ArgparseCommandFuncNoneReturn[CmdOrSetT] + | ArgparseCommandFuncWithUnknownArgsNoneReturn[CmdOrSetT] ) def with_argparser( parser: Cmd2ArgumentParser # existing parser | Callable[[], Cmd2ArgumentParser] # function or staticmethod - | Callable[[CmdOrSetClass], Cmd2ArgumentParser], # Cmd or CommandSet classmethod + | Callable[[CmdOrSetClassT], Cmd2ArgumentParser], # Cmd or CommandSet classmethod *, ns_provider: Callable[..., argparse.Namespace] | None = None, preserve_quotes: bool = False, with_unknown_args: bool = False, -) -> Callable[[ArgparseCommandFunc[CmdOrSet]], RawCommandFuncOptionalBoolReturn[CmdOrSet]]: +) -> Callable[[ArgparseCommandFunc[CmdOrSetT]], RawCommandFuncOptionalBoolReturn[CmdOrSetT]]: """Decorate a ``do_*`` method to populate its ``args`` argument with the given instance of Cmd2ArgumentParser. :param parser: instance of Cmd2ArgumentParser or a callable that returns a Cmd2ArgumentParser for this command @@ -268,7 +265,7 @@ def do_argprint(self, args, unknown): """ import functools - def arg_decorator(func: ArgparseCommandFunc[CmdOrSet]) -> RawCommandFuncOptionalBoolReturn[CmdOrSet]: + def arg_decorator(func: ArgparseCommandFunc[CmdOrSetT]) -> RawCommandFuncOptionalBoolReturn[CmdOrSetT]: """Decorate function that ingests an Argparse Command Function and returns a raw command function. The returned function will process the raw input into an argparse Namespace to be passed to the wrapped function. @@ -346,12 +343,12 @@ def as_subcommand_to( subcommand: str, parser: Cmd2ArgumentParser # existing parser | Callable[[], Cmd2ArgumentParser] # function or staticmethod - | Callable[[CmdOrSetClass], Cmd2ArgumentParser], # Cmd or CommandSet classmethod + | Callable[[CmdOrSetClassT], Cmd2ArgumentParser], # Cmd or CommandSet classmethod *, help: str | None = None, # noqa: A002 aliases: Sequence[str] | None = None, **add_parser_kwargs: Any, -) -> Callable[[ArgparseCommandFunc[CmdOrSet]], ArgparseCommandFunc[CmdOrSet]]: +) -> Callable[[ArgparseCommandFunc[CmdOrSetT]], ArgparseCommandFunc[CmdOrSetT]]: """Tag this method as a subcommand to an existing argparse decorated command. :param command: Command Name. Space-delimited subcommands may optionally be specified @@ -366,7 +363,7 @@ def as_subcommand_to( :return: Wrapper function that can receive an argparse.Namespace """ - def arg_decorator(func: ArgparseCommandFunc[CmdOrSet]) -> ArgparseCommandFunc[CmdOrSet]: + def arg_decorator(func: ArgparseCommandFunc[CmdOrSetT]) -> ArgparseCommandFunc[CmdOrSetT]: # Set some custom attributes for this command setattr(func, constants.SUBCMD_ATTR_COMMAND, command) setattr(func, constants.CMD_ATTR_ARGPARSER, parser) diff --git a/cmd2/types.py b/cmd2/types.py index 6c37b4b77..645226263 100644 --- a/cmd2/types.py +++ b/cmd2/types.py @@ -7,6 +7,8 @@ ) from typing import ( TYPE_CHECKING, + Any, + Concatenate, TypeAlias, TypeVar, Union, @@ -17,12 +19,64 @@ from .command_set import CommandSet from .completion import Choices, Completions -# A Cmd or CommandSet -CmdOrSet = TypeVar("CmdOrSet", bound=Union["Cmd", "CommandSet"]) +################################################################################################## +# Cmd and CommandSet Aliases (For basic inputs) +# +# Use these for arguments where the function can handle either a Cmd or a CommandSet. +# Note: The function logic must be able to handle both types. +# +# If the function returns the object it was passed, using these aliases will cause +# the IDE to "lose track" of the specific subclass. Use the Generics below instead. +################################################################################################## -################################################## +# A Cmd or CommandSet instance +CmdOrSet: TypeAlias = Union["Cmd", "CommandSet[Any]"] + +# A Cmd or CommandSet class +CmdOrSetClass: TypeAlias = type["Cmd"] | type["CommandSet[Any]"] + + +################################################################################################## +# Cmd and CommandSet Generics (Subclass Tracking) +# +# Use these when you need to track a specific subclass through a function. +# This ensures that if you pass in 'CustomCmd', the type checker knows it's +# still a 'CustomCmd' (not just a generic 'Cmd') when it comes out. +################################################################################################## + +# Tracks a specific subclass instance of Cmd +CmdT = TypeVar("CmdT", bound="Cmd") + +# Tracks a specific subclass instance of CommandSet +CommandSetT = TypeVar("CommandSetT", bound="CommandSet[Any]") + +# Tracks the specific subclass instance (either a Cmd or CommandSet) +CmdOrSetT = TypeVar("CmdOrSetT", bound=CmdOrSet) + +# Tracks the specific class itself (either a Cmd or CommandSet class) +CmdOrSetClassT = TypeVar("CmdOrSetClassT", bound=CmdOrSetClass) + + +################################################################################################## +# Command Function Types +################################################################################################## + +# A bound cmd2 command function (e.g. do_command). +# The 'self' argument is already tied to an instance and is omitted. +BoundCommandFunc: TypeAlias = Callable[..., bool | None] + +# An unbound cmd2 command function (e.g. the class method do_command). +# The 'self' argument can be either a Cmd or CommandSet instance. +UnboundCommandFunc: TypeAlias = Callable[Concatenate[CmdOrSetT, ...], bool | None] + +# TypeVar for unbound command methods that preserves the specific signature. +# This allows decorators to return a function with the same argument types as the original. +UnboundCommandFuncT = TypeVar("UnboundCommandFuncT", bound=UnboundCommandFunc[Any]) + + +################################################################################################## # Types used in choices_providers and completers -################################################## +################################################################################################## # Represents the parsed tokens from argparse during completion ArgTokens: TypeAlias = Mapping[str, Sequence[str]] @@ -33,12 +87,11 @@ # Unbound choices_provider function types used by argparse-based completion. # These expect a Cmd or CommandSet instance as the first argument. -ChoicesProviderUnbound: TypeAlias = ( +UnboundChoicesProvider: TypeAlias = ( # Basic: (self) -> Choices - Callable[[CmdOrSet], "Choices"] - | + Callable[[CmdOrSetT], "Choices"] # Context-aware: (self, arg_tokens) -> Choices - Callable[[CmdOrSet, "ArgTokens"], "Choices"] + | Callable[[CmdOrSetT, ArgTokens], "Choices"] ) ################################################## @@ -47,15 +100,14 @@ # Unbound completer function types used by argparse-based completion. # These expect a Cmd or CommandSet instance as the first argument. -CompleterUnbound: TypeAlias = ( +UnboundCompleter: TypeAlias = ( # Basic: (self, text, line, begidx, endidx) -> Completions - Callable[[CmdOrSet, str, str, int, int], "Completions"] - | + Callable[[CmdOrSetT, str, str, int, int], "Completions"] # Context-aware: (self, text, line, begidx, endidx, arg_tokens) -> Completions - Callable[[CmdOrSet, str, str, int, int, ArgTokens], "Completions"] + | Callable[[CmdOrSetT, str, str, int, int, ArgTokens], "Completions"] ) # A bound completer used internally by cmd2 for basic completion logic. # The 'self' argument is already tied to an instance and is omitted. # Format: (text, line, begidx, endidx) -> Completions -CompleterBound: TypeAlias = Callable[[str, str, int, int], "Completions"] +BoundCompleter: TypeAlias = Callable[[str, str, int, int], "Completions"] diff --git a/cmd2/utils.py b/cmd2/utils.py index 5c1f871d3..c3b9066ad 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -28,9 +28,9 @@ from . import constants from . import string_utils as su from .types import ( - ChoicesProviderUnbound, CmdOrSet, - CompleterUnbound, + UnboundChoicesProvider, + UnboundCompleter, ) if TYPE_CHECKING: # pragma: no cover @@ -76,8 +76,8 @@ def __init__( settable_attrib_name: str | None = None, onchange_cb: Callable[[str, Any, Any], Any] | None = None, choices: Iterable[Any] | None = None, - choices_provider: ChoicesProviderUnbound[CmdOrSet] | None = None, - completer: CompleterUnbound[CmdOrSet] | None = None, + choices_provider: UnboundChoicesProvider[Any] | None = None, + completer: UnboundCompleter[Any] | None = None, ) -> None: """Settable Initializer. diff --git a/docs/features/modular_commands.md b/docs/features/modular_commands.md index 767c69554..2380f4ec6 100644 --- a/docs/features/modular_commands.md +++ b/docs/features/modular_commands.md @@ -52,7 +52,18 @@ initializer arguments, see [Manual CommandSet Construction](#manual-commandset-c import cmd2 from cmd2 import CommandSet -class AutoLoadCommandSet(CommandSet): +class ExampleApp(cmd2.Cmd): + """ + CommandSets are automatically loaded. Nothing needs to be done. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, auto_load_commands=True, **kwargs) + + def do_something(self, arg): + """Something Command.""" + self.poutput('this is the something command') + +class AutoLoadCommandSet(CommandSet[ExampleApp]): DEFAULT_CATEGORY = 'My Category' def __init__(self): @@ -65,17 +76,6 @@ class AutoLoadCommandSet(CommandSet): def do_world(self, _: cmd2.Statement): """World Command.""" self._cmd.poutput('World') - -class ExampleApp(cmd2.Cmd): - """ - CommandSets are automatically loaded. Nothing needs to be done. - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, auto_load_commands=True, **kwargs) - - def do_something(self, arg): - """Something Command.""" - self.poutput('this is the something command') ``` ### Manual CommandSet Construction @@ -87,7 +87,20 @@ construct CommandSets and pass in the initializer to Cmd2. import cmd2 from cmd2 import CommandSet -class CustomInitCommandSet(CommandSet): +class ExampleApp(cmd2.Cmd): + """ + CommandSets with initializer parameters are provided in the initializer + """ + def __init__(self, *args, **kwargs): + # gotta have this or neither the plugin or cmd2 will initialize + super().__init__(*args, auto_load_commands=True, **kwargs) + + def do_something(self, arg): + """Something Command.""" + self.last_result = 5 + self.poutput('this is the something command') + +class CustomInitCommandSet(CommandSet[ExampleApp]): DEFAULT_CATEGORY = 'My Category' def __init__(self, arg1, arg2): @@ -104,19 +117,6 @@ class CustomInitCommandSet(CommandSet): """Show Arg 2.""" self._cmd.poutput(f'Arg2: {self._arg2}') -class ExampleApp(cmd2.Cmd): - """ - CommandSets with initializer parameters are provided in the initializer - """ - def __init__(self, *args, **kwargs): - # gotta have this or neither the plugin or cmd2 will initialize - super().__init__(*args, auto_load_commands=True, **kwargs) - - def do_something(self, arg): - """Something Command.""" - self.last_result = 5 - self.poutput('this is the something command') - def main(): my_commands = CustomInitCommandSet(1, 2) @@ -124,6 +124,33 @@ def main(): app.cmdloop() ``` +### Type Hinting and self.\_cmd + +When a `CommandSet` is registered, its `_cmd` property is populated with a reference to the +`cmd2.Cmd` instance. `CommandSet` is a +[generic](https://docs.python.org/3/library/typing.html#typing.Generic) class, allowing you to +specify the specific `cmd2.Cmd` subclass it expects to be loaded into. + +By parameterizing the inheritance with your application class, your IDE and static analysis tools +(like Mypy) will know the exact type of `self._cmd`. This provides full autocompletion and type +validation when accessing custom attributes or methods on your main application instance. + +```py +import cmd2 +from cmd2 import CommandSet + +class MyApp(cmd2.Cmd): + def __init__(self): + super().__init__() + self.custom_state = "Some important data" + +class MyCommands(CommandSet[MyApp]): + def do_check_state(self, _: cmd2.Statement): + # Type checkers know self._cmd is an instance of MyApp + # and can validate the 'custom_state' attribute exists. + self._cmd.poutput(f"State: {self._cmd.custom_state}") +``` + ### Dynamic Commands You can also dynamically load and unload commands by installing and removing CommandSets at runtime. @@ -137,7 +164,7 @@ import cmd2 from cmd2 import CommandSet, with_argparser, with_category -class LoadableFruits(CommandSet): +class LoadableFruits(CommandSet["ExampleApp"]): DEFAULT_CATEGORY = 'Fruits' def __init__(self): @@ -152,7 +179,7 @@ class LoadableFruits(CommandSet): self._cmd.poutput('Banana') -class LoadableVegetables(CommandSet): +class LoadableVegetables(CommandSet["ExampleApp"]): DEFAULT_CATEGORY = 'Vegetables' def __init__(self): @@ -268,7 +295,7 @@ import cmd2 from cmd2 import CommandSet, with_argparser, with_category -class LoadableFruits(CommandSet): +class LoadableFruits(CommandSet["ExampleApp"]): DEFAULT_CATEGORY = 'Fruits' def __init__(self): @@ -287,7 +314,7 @@ class LoadableFruits(CommandSet): self._cmd.poutput('cutting banana: ' + ns.direction) -class LoadableVegetables(CommandSet): +class LoadableVegetables(CommandSet["ExampleApp"]): DEFAULT_CATEGORY = 'Vegetables' def __init__(self): diff --git a/examples/command_sets.py b/examples/command_sets.py index 3d4caa6ab..c27204fd8 100755 --- a/examples/command_sets.py +++ b/examples/command_sets.py @@ -28,7 +28,7 @@ COMMANDSET_SUBCOMMAND = "Subcommands with CommandSet" -class AutoLoadCommandSet(CommandSet): +class AutoLoadCommandSet(CommandSet[cmd2.Cmd]): DEFAULT_CATEGORY = COMMANDSET_BASIC def __init__(self) -> None: @@ -44,7 +44,7 @@ def do_world(self, _: cmd2.Statement) -> None: self._cmd.poutput('World') -class LoadableFruits(CommandSet): +class LoadableFruits(CommandSet[cmd2.Cmd]): DEFAULT_CATEGORY = COMMANDSET_DYNAMIC def __init__(self) -> None: @@ -69,7 +69,7 @@ def cut_banana(self, ns: argparse.Namespace) -> None: self._cmd.poutput('cutting banana: ' + ns.direction) -class LoadableVegetables(CommandSet): +class LoadableVegetables(CommandSet[cmd2.Cmd]): DEFAULT_CATEGORY = COMMANDSET_DYNAMIC def __init__(self) -> None: diff --git a/examples/default_categories.py b/examples/default_categories.py index 109ceb188..df7ff724c 100755 --- a/examples/default_categories.py +++ b/examples/default_categories.py @@ -20,7 +20,7 @@ ) -class MyPlugin(CommandSet): +class MyPlugin(CommandSet[cmd2.Cmd]): """A CommandSet that defines its own category.""" DEFAULT_CATEGORY = "Plugin Commands" diff --git a/examples/modular_commands/commandset_basic.py b/examples/modular_commands/commandset_basic.py index 517340ab6..01d121caa 100644 --- a/examples/modular_commands/commandset_basic.py +++ b/examples/modular_commands/commandset_basic.py @@ -1,6 +1,7 @@ """A simple example demonstrating a loadable command set.""" from cmd2 import ( + Cmd, CommandSet, CompletionError, Completions, @@ -9,7 +10,7 @@ ) -class BasicCompletionCommandSet(CommandSet): +class BasicCompletionCommandSet(CommandSet[Cmd]): DEFAULT_CATEGORY = 'Basic Completion' # This data is used to demonstrate delimiter_complete diff --git a/examples/modular_commands/commandset_custominit.py b/examples/modular_commands/commandset_custominit.py index f136d690e..989f19f70 100644 --- a/examples/modular_commands/commandset_custominit.py +++ b/examples/modular_commands/commandset_custominit.py @@ -1,12 +1,13 @@ """A simple example demonstrating a loadable command set.""" from cmd2 import ( + Cmd, CommandSet, Statement, ) -class CustomInitCommandSet(CommandSet): +class CustomInitCommandSet(CommandSet[Cmd]): DEFAULT_CATEGORY = 'Custom Init' def __init__(self, arg1, arg2) -> None: diff --git a/tests/test_categories.py b/tests/test_categories.py index 37639825f..eaf05641b 100644 --- a/tests/test_categories.py +++ b/tests/test_categories.py @@ -89,7 +89,7 @@ def test_category_cmd() -> None: assert "coding" in help_topics -class NoCategoryCommandSet(CommandSet): +class NoCategoryCommandSet(CommandSet[cmd2.Cmd]): """Example to demonstrate a CommandSet which does not define its own DEFAULT_CATEGORY. Its commands will inherit the parent class's DEFAULT_CATEGORY. @@ -103,7 +103,7 @@ def do_inherit(self, _: cmd2.Statement) -> None: """ -class CategoryCommandSet(CommandSet): +class CategoryCommandSet(CommandSet[cmd2.Cmd]): """Example to demonstrate custom DEFAULT_CATEGORY in a CommandSet.""" DEFAULT_CATEGORY = "CategoryCommandSet Commands" diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 414439f10..d17427f46 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -3838,7 +3838,7 @@ def do_is_not_decorated(self, arg) -> None: self.poutput("The real is_not_decorated") -class DisableCommandSet(CommandSet): +class DisableCommandSet(CommandSet[cmd2.Cmd]): """Test registering a command which is in a disabled category""" category_name = "CommandSet Test Category" From 1407e18248882acf28aa12df043822a158f846ed Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 15 Apr 2026 15:10:21 -0400 Subject: [PATCH 02/15] Renamed Cmd.cmd_func() to Cmd.get_command_func(). --- CHANGELOG.md | 1 + cmd2/cmd2.py | 31 ++++++++++++------------------ examples/hooks.py | 2 +- examples/scripts/save_help_text.py | 2 +- 4 files changed, 15 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de4309786..1278c1704 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,7 @@ prompt is displayed. 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. + - Renamed `Cmd.cmd_func()` to `Cmd.get_command_func()`. - Enhancements - New `cmd2.Cmd` parameters - **auto_suggest**: (boolean) if `True`, provide fish shell style auto-suggestions. These diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 7c525cb88..6bd44d5e0 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1163,7 +1163,7 @@ def _get_root_parser_and_subcmd_path(self, command: str) -> tuple[Cmd2ArgumentPa if root_command in self.disabled_commands: command_func = self.disabled_commands[root_command].command_function else: - command_func = self.cmd_func(root_command) + command_func = self.get_command_func(root_command) if command_func is None: raise ValueError(f"Root command '{root_command}' not found") @@ -2442,7 +2442,7 @@ def _perform_completion( completer_func = func_attr else: # There's no completer function, next see if the command uses argparse - func = self.cmd_func(command) + func = self.get_command_func(command) argparser = None if func is None else self._command_parsers.get(func) if func is not None and argparser is not None: @@ -3316,18 +3316,11 @@ def _restore_output(self, statement: Statement, saved_redir_state: utils.Redirec self._cur_pipe_proc_reader = saved_redir_state.saved_pipe_proc_reader self._redirecting = saved_redir_state.saved_redirecting - def cmd_func(self, command: str) -> BoundCommandFunc | None: - """Get the function for a command. + def get_command_func(self, command: str) -> BoundCommandFunc | None: + """Get the bound command function for a command. :param command: the name of the command - - Example: - ```py - helpfunc = self.cmd_func('help') - ``` - - helpfunc now contains a reference to the ``do_help`` method - + :return: the bound function implementing the command, or None if not found """ func_name = constants.COMMAND_FUNC_PREFIX + command func = getattr(self, func_name, None) @@ -3364,7 +3357,7 @@ def onecmd(self, statement: Statement | str, *, add_to_history: bool = True) -> if not isinstance(statement, Statement): statement = self._input_line_to_statement(statement) - func = self.cmd_func(statement.command) + func = self.get_command_func(statement.command) if func: # Check to see if this command should be stored in history if ( @@ -4220,7 +4213,7 @@ def complete_help_subcommands( return Completions() # Check if this command uses argparse - if (func := self.cmd_func(command)) is None or (argparser := self._command_parsers.get(func)) is None: + if (func := self.get_command_func(command)) is None or (argparser := self._command_parsers.get(func)) is None: return Completions() completer = argparse_completer.DEFAULT_AP_COMPLETER(argparser, self) @@ -4246,7 +4239,7 @@ def _build_command_info(self) -> tuple[dict[str, list[str]], list[str]]: help_topics.remove(command) # Store the command within its category - func = cast(BoundCommandFunc, self.cmd_func(command)) + func = cast(BoundCommandFunc, self.get_command_func(command)) category = self._get_command_category(func) cmds_cats.setdefault(category, []).append(command) @@ -4312,7 +4305,7 @@ def do_help(self, args: argparse.Namespace) -> None: else: # Getting help for a specific command - func = self.cmd_func(args.command) + func = self.get_command_func(args.command) help_func = getattr(self, constants.HELP_FUNC_PREFIX + args.command, None) argparser = None if func is None else self._command_parsers.get(func) @@ -4387,7 +4380,7 @@ def _print_documented_command_topics(self, header: str, cmds: Sequence[str], ver # Try to get the documentation string for each command topics = self.get_help_topics() for command in cmds: - if (cmd_func := self.cmd_func(command)) is None: + if (cmd_func := self.get_command_func(command)) is None: continue doc: str | None @@ -5635,7 +5628,7 @@ def disable_command(self, command: str, message_to_print: str) -> None: return # Make sure this is an actual command - command_function = self.cmd_func(command) + command_function = self.get_command_func(command) if command_function is None: raise AttributeError(f"'{command}' does not refer to a command") @@ -5676,7 +5669,7 @@ def disable_category(self, category: str, message_to_print: str) -> None: all_commands = self.get_all_commands() for cmd_name in all_commands: - func = cast(BoundCommandFunc, self.cmd_func(cmd_name)) + func = cast(BoundCommandFunc, self.get_command_func(cmd_name)) if self._get_command_category(func) == category: self.disable_command(cmd_name, message_to_print) diff --git a/examples/hooks.py b/examples/hooks.py index a1ed27f38..73487bcd7 100755 --- a/examples/hooks.py +++ b/examples/hooks.py @@ -79,7 +79,7 @@ def downcase_hook(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.Postpa def abbrev_hook(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.PostparsingData: """Accept unique abbreviated commands.""" - func = self.cmd_func(data.statement.command) + func = self.get_command_func(data.statement.command) if func is None: # check if the entered command might be an abbreviation possible_cmds = [cmd for cmd in self.get_all_commands() if cmd.startswith(data.statement.command)] diff --git a/examples/scripts/save_help_text.py b/examples/scripts/save_help_text.py index 71a1f5fa6..cbc425592 100644 --- a/examples/scripts/save_help_text.py +++ b/examples/scripts/save_help_text.py @@ -87,7 +87,7 @@ def main() -> None: if not is_command: continue - cmd_func = self.cmd_func(item) + cmd_func = self.get_command_func(item) parser = self._command_parsers.get(cmd_func) if parser is None: continue From b032bf061255b85cfb682bcafe41749f9ba55bab Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 15 Apr 2026 17:10:58 -0400 Subject: [PATCH 03/15] Updated change log. --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1278c1704..005abea81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,10 @@ prompt is displayed. - 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. + - `CommandSet` is now a generic class, which allows developers to parameterize it with their + specific`cmd2.Cmd`subclass (e.g.,`class MyCommandSet(CommandSet[MyApp]):`). This provides full + type hints and IDE autocompletion for `self._cmd` without needing to override and cast the + property. ## 3.5.0 (April 13, 2026) From fc668f0f0fa1348c1375e00fd5fee8bb0f2bd2a8 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 15 Apr 2026 20:05:06 -0400 Subject: [PATCH 04/15] Made some type hints more strict. --- cmd2/argparse_completer.py | 3 ++- cmd2/argparse_custom.py | 5 +++-- cmd2/cmd2.py | 9 +++++---- cmd2/utils.py | 5 +++-- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 512068bbf..9553e9359 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -35,6 +35,7 @@ from .exceptions import CompletionError from .rich_utils import Cmd2SimpleTable from .types import ( + CmdOrSetT, UnboundChoicesProvider, UnboundCompleter, ) @@ -733,7 +734,7 @@ def _choices_to_items(self, arg_state: _ArgumentState) -> list[CompletionItem]: def _prepare_callable_params( self, - to_call: UnboundChoicesProvider[Any] | UnboundCompleter[Any], + to_call: UnboundChoicesProvider[CmdOrSetT] | UnboundCompleter[CmdOrSetT], arg_state: _ArgumentState, text: str, consumed_arg_values: dict[str, list[str]], diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 85b8024fa..1db0f858c 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -262,6 +262,7 @@ def get_choices(self) -> Choices: from .rich_utils import Cmd2RichArgparseConsole from .styles import Cmd2Style from .types import ( + CmdOrSetT, UnboundChoicesProvider, UnboundCompleter, ) @@ -387,8 +388,8 @@ def _ActionsContainer_add_argument( # noqa: N802 self: argparse._ActionsContainer, *args: Any, nargs: int | str | tuple[int] | tuple[int, int] | tuple[int, float] | None = None, - choices_provider: UnboundChoicesProvider[Any] | None = None, - completer: UnboundCompleter[Any] | None = None, + choices_provider: UnboundChoicesProvider[CmdOrSetT] | None = None, + completer: UnboundCompleter[CmdOrSetT] | None = None, suppress_tab_hint: bool = False, table_columns: Sequence[str | Column] | None = None, **kwargs: Any, diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 6bd44d5e0..cbfefdfa1 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -163,6 +163,7 @@ BoundCommandFunc, BoundCompleter, CmdOrSet, + CmdOrSetT, UnboundChoicesProvider, UnboundCompleter, ) @@ -3478,8 +3479,8 @@ def _resolve_completer( self, preserve_quotes: bool = False, choices: Iterable[Any] | None = None, - choices_provider: UnboundChoicesProvider[Any] | None = None, - completer: UnboundCompleter[Any] | None = None, + choices_provider: UnboundChoicesProvider[CmdOrSetT] | None = None, + completer: UnboundCompleter[CmdOrSetT] | None = None, parser: Cmd2ArgumentParser | None = None, ) -> Completer: """Determine the appropriate completer based on provided arguments.""" @@ -3510,8 +3511,8 @@ def read_input( history: Sequence[str] | None = None, preserve_quotes: bool = False, choices: Iterable[Any] | None = None, - choices_provider: UnboundChoicesProvider[Any] | None = None, - completer: UnboundCompleter[Any] | None = None, + choices_provider: UnboundChoicesProvider[CmdOrSetT] | None = None, + completer: UnboundCompleter[CmdOrSetT] | None = None, parser: Cmd2ArgumentParser | None = None, ) -> str: """Read a line of input with optional completion and history. diff --git a/cmd2/utils.py b/cmd2/utils.py index c3b9066ad..43bc5a1fb 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -29,6 +29,7 @@ from . import string_utils as su from .types import ( CmdOrSet, + CmdOrSetT, UnboundChoicesProvider, UnboundCompleter, ) @@ -76,8 +77,8 @@ def __init__( settable_attrib_name: str | None = None, onchange_cb: Callable[[str, Any, Any], Any] | None = None, choices: Iterable[Any] | None = None, - choices_provider: UnboundChoicesProvider[Any] | None = None, - completer: UnboundCompleter[Any] | None = None, + choices_provider: UnboundChoicesProvider[CmdOrSetT] | None = None, + completer: UnboundCompleter[CmdOrSetT] | None = None, ) -> None: """Settable Initializer. From f4e9f31c0c68066d19baad87f0f224a44f31f510 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 16 Apr 2026 00:50:13 -0400 Subject: [PATCH 05/15] Simplified type aliases in decorators.py. --- CHANGELOG.md | 6 ++-- cmd2/decorators.py | 70 +++++++++++++--------------------------------- cmd2/types.py | 11 ++++---- 3 files changed, 29 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 005abea81..f79403a6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,9 +109,9 @@ prompt is displayed. - For more details and examples, see the [Help](docs/features/help.md) documentation and the `examples/default_categories.py` file. - `CommandSet` is now a generic class, which allows developers to parameterize it with their - specific`cmd2.Cmd`subclass (e.g.,`class MyCommandSet(CommandSet[MyApp]):`). This provides full - type hints and IDE autocompletion for `self._cmd` without needing to override and cast the - property. + specific `cmd2.Cmd`subclass (e.g.,`class MyCommandSet(CommandSet[MyApp]):`). This provides + full type hints and IDE autocompletion for `self._cmd` without needing to override and cast + the property. ## 3.5.0 (April 13, 2026) diff --git a/cmd2/decorators.py b/cmd2/decorators.py index a92b5e742..fb04e4901 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -8,6 +8,7 @@ from typing import ( TYPE_CHECKING, Any, + ParamSpec, TypeAlias, ) @@ -19,14 +20,18 @@ from .types import ( CmdOrSetClassT, CmdOrSetT, - UnboundCommandFuncT, + UnboundCommandFunc, ) if TYPE_CHECKING: # pragma: no cover from .cmd2 import Cmd +P = ParamSpec("P") -def with_category(category: str) -> Callable[[UnboundCommandFuncT], UnboundCommandFuncT]: + +def with_category( + category: str, +) -> Callable[[UnboundCommandFunc[CmdOrSetT, P]], UnboundCommandFunc[CmdOrSetT, P]]: """Decorate a ``do_*`` command method to apply a category. :param category: the name of the category in which this command should @@ -45,7 +50,7 @@ def do_echo(self, args) """ - def cat_decorator(func: UnboundCommandFuncT) -> UnboundCommandFuncT: + def cat_decorator(func: UnboundCommandFunc[CmdOrSetT, P]) -> UnboundCommandFunc[CmdOrSetT, P]: from .utils import categorize categorize(func, category) @@ -54,7 +59,8 @@ def cat_decorator(func: UnboundCommandFuncT) -> UnboundCommandFuncT: return cat_decorator -RawCommandFuncOptionalBoolReturn: TypeAlias = Callable[[CmdOrSetT, Statement | str], bool | None] +# The standard cmd2 command function signature (e.g. do_command(self, statement)) +RawCommandFunc: TypeAlias = UnboundCommandFunc[CmdOrSetT, [Statement | str]] ########################## @@ -102,37 +108,20 @@ def _arg_swap(args: Sequence[Any], search_arg: Any, *replace_arg: Any) -> list[A # Function signature for a command function that accepts a pre-processed argument list from user input -# and optionally returns a boolean -ArgListCommandFuncOptionalBoolReturn: TypeAlias = Callable[[CmdOrSetT, list[str]], bool | None] -# Function signature for a command function that accepts a pre-processed argument list from user input -# and returns a boolean -ArgListCommandFuncBoolReturn: TypeAlias = Callable[[CmdOrSetT, list[str]], bool] -# Function signature for a command function that accepts a pre-processed argument list from user input -# and returns Nothing -ArgListCommandFuncNoneReturn: TypeAlias = Callable[[CmdOrSetT, list[str]], None] - -# Aggregate of all accepted function signatures for command functions that accept a pre-processed argument list -ArgListCommandFunc: TypeAlias = ( - ArgListCommandFuncOptionalBoolReturn[CmdOrSetT] - | ArgListCommandFuncBoolReturn[CmdOrSetT] - | ArgListCommandFuncNoneReturn[CmdOrSetT] -) +ArgListCommandFunc: TypeAlias = UnboundCommandFunc[CmdOrSetT, [list[str]]] def with_argument_list( func_arg: ArgListCommandFunc[CmdOrSetT] | None = None, *, preserve_quotes: bool = False, -) -> ( - RawCommandFuncOptionalBoolReturn[CmdOrSetT] - | Callable[[ArgListCommandFunc[CmdOrSetT]], RawCommandFuncOptionalBoolReturn[CmdOrSetT]] -): +) -> RawCommandFunc[CmdOrSetT] | Callable[[ArgListCommandFunc[CmdOrSetT]], RawCommandFunc[CmdOrSetT]]: """Decorate a ``do_*`` method to alter the arguments passed to it so it is passed a list[str]. Default passes a string of whatever the user typed. With this decorator, the decorated method will receive a list of arguments parsed from user input. - :param func_arg: Single-element positional argument list containing ``doi_*`` method + :param func_arg: Single-element positional argument list containing ``do_*`` method this decorator is wrapping :param preserve_quotes: if ``True``, then argument quotes will not be stripped :return: function that gets passed a list of argument strings @@ -148,7 +137,7 @@ def do_echo(self, arglist): """ import functools - def arg_decorator(func: ArgListCommandFunc[CmdOrSetT]) -> RawCommandFuncOptionalBoolReturn[CmdOrSetT]: + def arg_decorator(func: ArgListCommandFunc[CmdOrSetT]) -> RawCommandFunc[CmdOrSetT]: """Decorate function that ingests an Argument List function and returns a raw command function. The returned function will process the raw input into an argument list to be passed to the wrapped function. @@ -182,30 +171,11 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: # Function signatures for command functions that use a Cmd2ArgumentParser to process user input -# and optionally return a boolean -ArgparseCommandFuncOptionalBoolReturn: TypeAlias = Callable[[CmdOrSetT, argparse.Namespace], bool | None] -ArgparseCommandFuncWithUnknownArgsOptionalBoolReturn: TypeAlias = Callable[ - [CmdOrSetT, argparse.Namespace, list[str]], bool | None -] - -# Function signatures for command functions that use a Cmd2ArgumentParser to process user input -# and return a boolean -ArgparseCommandFuncBoolReturn: TypeAlias = Callable[[CmdOrSetT, argparse.Namespace], bool] -ArgparseCommandFuncWithUnknownArgsBoolReturn: TypeAlias = Callable[[CmdOrSetT, argparse.Namespace, list[str]], bool] - -# Function signatures for command functions that use a Cmd2ArgumentParser to process user input -# and return nothing -ArgparseCommandFuncNoneReturn: TypeAlias = Callable[[CmdOrSetT, argparse.Namespace], None] -ArgparseCommandFuncWithUnknownArgsNoneReturn: TypeAlias = Callable[[CmdOrSetT, argparse.Namespace, list[str]], None] - -# Aggregate of all accepted function signatures for an argparse command function ArgparseCommandFunc: TypeAlias = ( - ArgparseCommandFuncOptionalBoolReturn[CmdOrSetT] - | ArgparseCommandFuncWithUnknownArgsOptionalBoolReturn[CmdOrSetT] - | ArgparseCommandFuncBoolReturn[CmdOrSetT] - | ArgparseCommandFuncWithUnknownArgsBoolReturn[CmdOrSetT] - | ArgparseCommandFuncNoneReturn[CmdOrSetT] - | ArgparseCommandFuncWithUnknownArgsNoneReturn[CmdOrSetT] + # (self, args: argparse.Namespace) + UnboundCommandFunc[CmdOrSetT, [argparse.Namespace]] + # (self, args: argparse.Namespace, unknown_args: list[str]) + | UnboundCommandFunc[CmdOrSetT, [argparse.Namespace, list[str]]] ) @@ -217,7 +187,7 @@ def with_argparser( ns_provider: Callable[..., argparse.Namespace] | None = None, preserve_quotes: bool = False, with_unknown_args: bool = False, -) -> Callable[[ArgparseCommandFunc[CmdOrSetT]], RawCommandFuncOptionalBoolReturn[CmdOrSetT]]: +) -> Callable[[ArgparseCommandFunc[CmdOrSetT]], RawCommandFunc[CmdOrSetT]]: """Decorate a ``do_*`` method to populate its ``args`` argument with the given instance of Cmd2ArgumentParser. :param parser: instance of Cmd2ArgumentParser or a callable that returns a Cmd2ArgumentParser for this command @@ -265,7 +235,7 @@ def do_argprint(self, args, unknown): """ import functools - def arg_decorator(func: ArgparseCommandFunc[CmdOrSetT]) -> RawCommandFuncOptionalBoolReturn[CmdOrSetT]: + def arg_decorator(func: ArgparseCommandFunc[CmdOrSetT]) -> RawCommandFunc[CmdOrSetT]: """Decorate function that ingests an Argparse Command Function and returns a raw command function. The returned function will process the raw input into an argparse Namespace to be passed to the wrapped function. diff --git a/cmd2/types.py b/cmd2/types.py index 645226263..e12a6fbea 100644 --- a/cmd2/types.py +++ b/cmd2/types.py @@ -9,6 +9,7 @@ TYPE_CHECKING, Any, Concatenate, + ParamSpec, TypeAlias, TypeVar, Union, @@ -19,6 +20,10 @@ from .command_set import CommandSet from .completion import Choices, Completions +# TypeVar for function parameters +P = ParamSpec("P") + + ################################################################################################## # Cmd and CommandSet Aliases (For basic inputs) # @@ -67,11 +72,7 @@ # An unbound cmd2 command function (e.g. the class method do_command). # The 'self' argument can be either a Cmd or CommandSet instance. -UnboundCommandFunc: TypeAlias = Callable[Concatenate[CmdOrSetT, ...], bool | None] - -# TypeVar for unbound command methods that preserves the specific signature. -# This allows decorators to return a function with the same argument types as the original. -UnboundCommandFuncT = TypeVar("UnboundCommandFuncT", bound=UnboundCommandFunc[Any]) +UnboundCommandFunc: TypeAlias = Callable[Concatenate[CmdOrSetT, P], bool | None] ################################################################################################## From 584fbdfd428edd3179073a35a60867d703f792c2 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 16 Apr 2026 01:44:36 -0400 Subject: [PATCH 06/15] Fixed type checking for with_argument_list when passing preserve_quotes. --- cmd2/decorators.py | 48 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/cmd2/decorators.py b/cmd2/decorators.py index fb04e4901..526ce41c3 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -10,6 +10,7 @@ Any, ParamSpec, TypeAlias, + overload, ) from . import constants @@ -111,27 +112,50 @@ def _arg_swap(args: Sequence[Any], search_arg: Any, *replace_arg: Any) -> list[A ArgListCommandFunc: TypeAlias = UnboundCommandFunc[CmdOrSetT, [list[str]]] +# Overload for: @with_argument_list +@overload def with_argument_list( - func_arg: ArgListCommandFunc[CmdOrSetT] | None = None, + cmd_func: ArgListCommandFunc[CmdOrSetT], + *, + preserve_quotes: bool = False, +) -> RawCommandFunc[CmdOrSetT]: ... + + +# Overload for: @with_argument_list(preserve_quotes=True) +@overload +def with_argument_list( + cmd_func: None = None, + *, + preserve_quotes: bool = False, +) -> Callable[[ArgListCommandFunc[CmdOrSetT]], RawCommandFunc[CmdOrSetT]]: ... + + +def with_argument_list( + cmd_func: ArgListCommandFunc[CmdOrSetT] | None = None, *, preserve_quotes: bool = False, ) -> RawCommandFunc[CmdOrSetT] | Callable[[ArgListCommandFunc[CmdOrSetT]], RawCommandFunc[CmdOrSetT]]: - """Decorate a ``do_*`` method to alter the arguments passed to it so it is passed a list[str]. + """Decorate a ``do_*`` command function to receive a list of parsed arguments. - Default passes a string of whatever the user typed. With this decorator, the - decorated method will receive a list of arguments parsed from user input. + This decorator can be used either directly (``@with_argument_list``) or as a + factory with arguments (``@with_argument_list(preserve_quotes=True)``). - :param func_arg: Single-element positional argument list containing ``do_*`` method - this decorator is wrapping - :param preserve_quotes: if ``True``, then argument quotes will not be stripped - :return: function that gets passed a list of argument strings + :param cmd_func: The command function being decorated. + :param preserve_quotes: If ``True``, argument quotes will not be stripped from the input. + :return: A command function that accepts a list of strings instead of a raw string. Example: ```py class MyApp(cmd2.Cmd): + # Basic usage: receives a list of words with quotes stripped @cmd2.with_argument_list - def do_echo(self, arglist): - self.poutput(' '.join(arglist) + def do_echo(self, arglist: list[str]): + self.poutput(' '.join(arglist)) + + # Factory usage: preserves quotes in the argument list + @cmd2.with_argument_list(preserve_quotes=True) + def do_print_raw(self, arglist: list[str]): + self.poutput(' '.join(arglist)) ``` """ @@ -165,8 +189,8 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: cmd_wrapper.__doc__ = func.__doc__ return cmd_wrapper - if callable(func_arg): - return arg_decorator(func_arg) + if callable(cmd_func): + return arg_decorator(cmd_func) return arg_decorator From 094223dd6c95a26cb6149c0261072c0a255816dc Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 16 Apr 2026 02:39:57 -0400 Subject: [PATCH 07/15] Updated comments. --- cmd2/decorators.py | 9 ++++----- cmd2/types.py | 1 - cmd2/utils.py | 4 ++-- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 526ce41c3..24d28ad93 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -1,6 +1,7 @@ """Decorators for ``cmd2`` commands.""" import argparse +import functools from collections.abc import ( Callable, Sequence, @@ -33,7 +34,7 @@ def with_category( category: str, ) -> Callable[[UnboundCommandFunc[CmdOrSetT, P]], UnboundCommandFunc[CmdOrSetT, P]]: - """Decorate a ``do_*`` command method to apply a category. + """Decorate a ``do_*`` command function to apply a category. :param category: the name of the category in which this command should be grouped when displaying the list of commands. @@ -159,7 +160,6 @@ def do_print_raw(self, arglist: list[str]): ``` """ - import functools def arg_decorator(func: ArgListCommandFunc[CmdOrSetT]) -> RawCommandFunc[CmdOrSetT]: """Decorate function that ingests an Argument List function and returns a raw command function. @@ -212,7 +212,7 @@ def with_argparser( preserve_quotes: bool = False, with_unknown_args: bool = False, ) -> Callable[[ArgparseCommandFunc[CmdOrSetT]], RawCommandFunc[CmdOrSetT]]: - """Decorate a ``do_*`` method to populate its ``args`` argument with the given instance of Cmd2ArgumentParser. + """Decorate a ``do_*`` command function to populate its ``args`` argument with a Cmd2ArgumentParser. :param parser: instance of Cmd2ArgumentParser or a callable that returns a Cmd2ArgumentParser for this command :param ns_provider: An optional function that accepts a cmd2.Cmd or cmd2.CommandSet object as an argument and returns an @@ -257,7 +257,6 @@ def do_argprint(self, args, unknown): ``` """ - import functools def arg_decorator(func: ArgparseCommandFunc[CmdOrSetT]) -> RawCommandFunc[CmdOrSetT]: """Decorate function that ingests an Argparse Command Function and returns a raw command function. @@ -343,7 +342,7 @@ def as_subcommand_to( aliases: Sequence[str] | None = None, **add_parser_kwargs: Any, ) -> Callable[[ArgparseCommandFunc[CmdOrSetT]], ArgparseCommandFunc[CmdOrSetT]]: - """Tag this method as a subcommand to an existing argparse decorated command. + """Tag this function as a subcommand to an existing argparse decorated command. :param command: Command Name. Space-delimited subcommands may optionally be specified :param subcommand: Subcommand name diff --git a/cmd2/types.py b/cmd2/types.py index e12a6fbea..ff019ad9a 100644 --- a/cmd2/types.py +++ b/cmd2/types.py @@ -20,7 +20,6 @@ from .command_set import CommandSet from .completion import Choices, Completions -# TypeVar for function parameters P = ParamSpec("P") diff --git a/cmd2/utils.py b/cmd2/utils.py index 43bc5a1fb..5a984fafe 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -40,7 +40,7 @@ else: PopenTextIO = subprocess.Popen -_T = TypeVar('_T') +T = TypeVar('T') def to_bool(val: Any) -> bool: @@ -186,7 +186,7 @@ def is_text_file(file_path: str) -> bool: return valid_text_file -def remove_duplicates(items: Iterable[_T]) -> list[_T]: +def remove_duplicates(items: Iterable[T]) -> list[T]: """Remove duplicates from an iterable while preserving order of the items. :param items: the items being pruned of duplicates From 643b06649ba7cd883c52633a4ccc1efeea47b642 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 16 Apr 2026 02:54:04 -0400 Subject: [PATCH 08/15] Updated import. --- cmd2/decorators.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 24d28ad93..36a8eabc6 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -79,9 +79,7 @@ def _parse_positionals(args: tuple[Any, ...]) -> tuple['Cmd', Statement | str]: :return: The cmd2.Cmd reference and the command line statement. """ for pos, arg in enumerate(args): - from cmd2 import ( - Cmd, - ) + from .cmd2 import Cmd if isinstance(arg, (Cmd, CommandSet)) and len(args) > pos + 1: if isinstance(arg, CommandSet): From c6f5a2c20a0d8b32d60a10445570cdec07355481 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 16 Apr 2026 03:04:20 -0400 Subject: [PATCH 09/15] Updated comment. --- cmd2/decorators.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 36a8eabc6..0233cc106 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -38,6 +38,7 @@ def with_category( :param category: the name of the category in which this command should be grouped when displaying the list of commands. + :return: a decorator that assigns the specified category to the command function Example: ```py From 24592bcc8e1cb1adeda675c77e3ad8629d0f93cd Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 16 Apr 2026 09:57:24 -0400 Subject: [PATCH 10/15] Made type hints of with_category() and as_subcommand_to() more permissive to better support order-independent stacking with other decorators. --- cmd2/decorators.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 0233cc106..6d16cab43 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -9,8 +9,8 @@ from typing import ( TYPE_CHECKING, Any, - ParamSpec, TypeAlias, + TypeVar, overload, ) @@ -28,14 +28,18 @@ if TYPE_CHECKING: # pragma: no cover from .cmd2 import Cmd -P = ParamSpec("P") +F = TypeVar("F", bound=Callable[..., Any]) def with_category( category: str, -) -> Callable[[UnboundCommandFunc[CmdOrSetT, P]], UnboundCommandFunc[CmdOrSetT, P]]: +) -> Callable[[F], F]: """Decorate a ``do_*`` command function to apply a category. + This decorator has permissive type hints to allow for order-independent stacking + with other decorators that may modify the function signature or return type of the + command function. + :param category: the name of the category in which this command should be grouped when displaying the list of commands. :return: a decorator that assigns the specified category to the command function @@ -53,7 +57,7 @@ def do_echo(self, args) """ - def cat_decorator(func: UnboundCommandFunc[CmdOrSetT, P]) -> UnboundCommandFunc[CmdOrSetT, P]: + def cat_decorator(func: F) -> F: from .utils import categorize categorize(func, category) @@ -62,10 +66,6 @@ def cat_decorator(func: UnboundCommandFunc[CmdOrSetT, P]) -> UnboundCommandFunc[ return cat_decorator -# The standard cmd2 command function signature (e.g. do_command(self, statement)) -RawCommandFunc: TypeAlias = UnboundCommandFunc[CmdOrSetT, [Statement | str]] - - ########################## # The _parse_positionals and _arg_swap functions allow for additional positional args to be preserved # in cmd2 command functions/callables. As long as the 2-ple of arguments we expect to be there can be @@ -108,6 +108,10 @@ def _arg_swap(args: Sequence[Any], search_arg: Any, *replace_arg: Any) -> list[A return args_list +# The standard cmd2 command function signature (e.g. do_command(self, statement)) +RawCommandFunc: TypeAlias = UnboundCommandFunc[CmdOrSetT, [Statement | str]] + + # Function signature for a command function that accepts a pre-processed argument list from user input ArgListCommandFunc: TypeAlias = UnboundCommandFunc[CmdOrSetT, [list[str]]] @@ -340,8 +344,12 @@ def as_subcommand_to( help: str | None = None, # noqa: A002 aliases: Sequence[str] | None = None, **add_parser_kwargs: Any, -) -> Callable[[ArgparseCommandFunc[CmdOrSetT]], ArgparseCommandFunc[CmdOrSetT]]: - """Tag this function as a subcommand to an existing argparse decorated command. +) -> Callable[[F], F]: + """Tag a function as a subcommand to an existing argparse decorated command. + + This decorator has permissive type hints to allow for order-independent stacking + with other decorators that may modify the function signature or return type of the + subcommand function. :param command: Command Name. Space-delimited subcommands may optionally be specified :param subcommand: Subcommand name @@ -355,7 +363,7 @@ def as_subcommand_to( :return: Wrapper function that can receive an argparse.Namespace """ - def arg_decorator(func: ArgparseCommandFunc[CmdOrSetT]) -> ArgparseCommandFunc[CmdOrSetT]: + def arg_decorator(func: F) -> F: # Set some custom attributes for this command setattr(func, constants.SUBCMD_ATTR_COMMAND, command) setattr(func, constants.CMD_ATTR_ARGPARSER, parser) From 1801d643a1080cf85a8d8bf6f40b681ae027230f Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 16 Apr 2026 10:05:24 -0400 Subject: [PATCH 11/15] Fixed CHANGELOG. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f79403a6d..d5bd378bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,7 +109,7 @@ prompt is displayed. - For more details and examples, see the [Help](docs/features/help.md) documentation and the `examples/default_categories.py` file. - `CommandSet` is now a generic class, which allows developers to parameterize it with their - specific `cmd2.Cmd`subclass (e.g.,`class MyCommandSet(CommandSet[MyApp]):`). This provides + specific `cmd2.Cmd` subclass (e.g.,`class MyCommandSet(CommandSet[MyApp]):`). This provides full type hints and IDE autocompletion for `self._cmd` without needing to override and cast the property. From 5d74fb255aebd2206c29b10f88a1d1511a2e675a Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 16 Apr 2026 11:02:06 -0400 Subject: [PATCH 12/15] Updated comments. --- cmd2/decorators.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 6d16cab43..e818b0e5b 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -253,10 +253,10 @@ def do_argprint(self, args): class MyApp(cmd2.Cmd): @cmd2.with_argparser(parser, with_unknown_args=True) - def do_argprint(self, args, unknown): + def do_argprint(self, args: argparse.Namespace, unknown_args: list[str]): "Print the options and argument list this options command was called with." self.poutput(f'args: {args!r}') - self.poutput(f'unknowns: {unknown}') + self.poutput(f'unknown_args: {unknown_args}') ``` """ @@ -351,6 +351,11 @@ def as_subcommand_to( with other decorators that may modify the function signature or return type of the subcommand function. + While this decorator has permissive type hints, the subcommand function's signature + must match the root command's signature. For example, if the root command uses + `with_unknown_args=True`, then the subcommand function must also accept the + unknown arguments list. + :param command: Command Name. Space-delimited subcommands may optionally be specified :param subcommand: Subcommand name :param parser: instance of Cmd2ArgumentParser or a callable that returns a Cmd2ArgumentParser for this subcommand @@ -360,7 +365,24 @@ def as_subcommand_to( subparsers.add_parser(). :param add_parser_kwargs: other registration-specific kwargs for add_parser() (e.g. deprecated [Python 3.13+]) - :return: Wrapper function that can receive an argparse.Namespace + :return: a decorator which configures the target function to be a subcommand handler + + Example: + ```py + base_parser = cmd2.Cmd2ArgumentParser() + base_parser.add_subparsers(metavar='SUBCOMMAND', required=True) + sub_parser = cmd2.Cmd2ArgumentParser() + + class MyApp(cmd2.Cmd): + @cmd2.with_argparser(base_parser) + def do_base(self, args: argparse.Namespace) -> None: + args.cmd2_subcmd_handler(args) + + @cmd2.as_subcommand_to('base', 'sub', sub_parser, help="the subcommand") -> None: + def sub_handler(self, args: argparse.Namespace): + self.poutput('Subcommand executed') + ``` + """ def arg_decorator(func: F) -> F: From 1dffe3f5c1fa6ada7f3c2c38732e011fd1a7cbcc Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 16 Apr 2026 11:26:26 -0400 Subject: [PATCH 13/15] Updated comments. --- cmd2/decorators.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cmd2/decorators.py b/cmd2/decorators.py index e818b0e5b..37d2158d5 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -48,7 +48,7 @@ def with_category( ```py class MyApp(cmd2.Cmd): @cmd2.with_category('Text Functions') - def do_echo(self, args) + def do_echo(self, args: cmd2.Statement) -> None: self.poutput(args) ``` @@ -153,12 +153,12 @@ def with_argument_list( class MyApp(cmd2.Cmd): # Basic usage: receives a list of words with quotes stripped @cmd2.with_argument_list - def do_echo(self, arglist: list[str]): + def do_echo(self, arglist: list[str]) -> None: self.poutput(' '.join(arglist)) # Factory usage: preserves quotes in the argument list @cmd2.with_argument_list(preserve_quotes=True) - def do_print_raw(self, arglist: list[str]): + def do_print_raw(self, arglist: list[str]) -> None: self.poutput(' '.join(arglist)) ``` @@ -238,7 +238,7 @@ def with_argparser( class MyApp(cmd2.Cmd): @cmd2.with_argparser(parser, preserve_quotes=True) - def do_argprint(self, args): + def do_argprint(self, args: argparse.Namespace) -> None: "Print the options and argument list this options command was called with." self.poutput(f'args: {args!r}') ``` @@ -378,8 +378,8 @@ class MyApp(cmd2.Cmd): def do_base(self, args: argparse.Namespace) -> None: args.cmd2_subcmd_handler(args) - @cmd2.as_subcommand_to('base', 'sub', sub_parser, help="the subcommand") -> None: - def sub_handler(self, args: argparse.Namespace): + @cmd2.as_subcommand_to('base', 'sub', sub_parser, help="the subcommand") + def sub_handler(self, args: argparse.Namespace) -> None: self.poutput('Subcommand executed') ``` From 26ef9c9ae5e9b3c5990f43c395f07423eaf0acb3 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 16 Apr 2026 12:01:51 -0400 Subject: [PATCH 14/15] Updated comments. --- cmd2/decorators.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 37d2158d5..e66b1a729 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -36,9 +36,9 @@ def with_category( ) -> Callable[[F], F]: """Decorate a ``do_*`` command function to apply a category. - This decorator has permissive type hints to allow for order-independent stacking - with other decorators that may modify the function signature or return type of the - command function. + Permissive type hints allow this decorator to be stacked in any order, even + when other decorators in the chain transform the signature or return type of + the command function. :param category: the name of the category in which this command should be grouped when displaying the list of commands. @@ -347,9 +347,9 @@ def as_subcommand_to( ) -> Callable[[F], F]: """Tag a function as a subcommand to an existing argparse decorated command. - This decorator has permissive type hints to allow for order-independent stacking - with other decorators that may modify the function signature or return type of the - subcommand function. + Permissive type hints allow this decorator to be stacked in any order, even + when other decorators in the chain transform the signature or return type of + the subcommand function. While this decorator has permissive type hints, the subcommand function's signature must match the root command's signature. For example, if the root command uses From fc2b390901cf870f85c106474aa852f44a771f33 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 16 Apr 2026 12:42:07 -0400 Subject: [PATCH 15/15] Simplified some code. --- cmd2/command_set.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/cmd2/command_set.py b/cmd2/command_set.py index 2bdacecdb..773b676a6 100644 --- a/cmd2/command_set.py +++ b/cmd2/command_set.py @@ -55,9 +55,9 @@ class MyCommandSet(CommandSet[MyCustomApp]): :raises CommandSetRegistrationError: if CommandSet is not registered. """ - if (cmd := self._cmd_internal) is not None: - return cmd - raise CommandSetRegistrationError('This CommandSet is not registered') + if self._cmd_internal is None: + raise CommandSetRegistrationError('This CommandSet is not registered') + return self._cmd_internal def on_register(self, cmd: CmdT) -> None: """First step to registering a CommandSet, called by cmd2.Cmd. @@ -69,10 +69,9 @@ def on_register(self, cmd: CmdT) -> None: :param cmd: The cmd2 main application :raises CommandSetRegistrationError: if CommandSet is already registered. """ - if self._cmd_internal is None: - self._cmd_internal = cmd - else: + if self._cmd_internal is not None: raise CommandSetRegistrationError('This CommandSet has already been registered') + self._cmd_internal = cmd def on_registered(self) -> None: """2nd step to registering, called by cmd2.Cmd after a CommandSet is registered and all its commands have been added. @@ -110,13 +109,12 @@ def add_settable(self, settable: Settable) -> None: :param settable: Settable object being added """ if (cmd := self._cmd_internal) is not None: - if not cmd.always_prefix_settables: - if settable.name in cmd.settables and settable.name not in self._settables: - raise KeyError(f'Duplicate settable: {settable.name}') - else: - prefixed_name = f'{self._settable_prefix}.{settable.name}' - if prefixed_name in cmd.settables and settable.name not in self._settables: - raise KeyError(f'Duplicate settable: {settable.name}') + # Determine the name to check for collisions in the main app + check_name = settable.name if not cmd.always_prefix_settables else f'{self._settable_prefix}.{settable.name}' + + if check_name in cmd.settables and settable.name not in self._settables: + raise KeyError(f'Duplicate settable: {settable.name}') + self._settables[settable.name] = settable def remove_settable(self, name: str) -> None: