diff --git a/CHANGELOG.md b/CHANGELOG.md index de4309786..d5bd378bd 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 @@ -107,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) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 5be38fc64..9553e9359 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -35,9 +35,9 @@ from .exceptions import CompletionError from .rich_utils import Cmd2SimpleTable from .types import ( - ChoicesProviderUnbound, - CmdOrSet, - CompleterUnbound, + CmdOrSetT, + UnboundChoicesProvider, + UnboundCompleter, ) if TYPE_CHECKING: # pragma: no cover @@ -214,7 +214,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 +469,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 +734,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[CmdOrSetT] | UnboundCompleter[CmdOrSetT], 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 +769,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..1db0f858c 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -262,9 +262,9 @@ def get_choices(self) -> Choices: from .rich_utils import Cmd2RichArgparseConsole from .styles import Cmd2Style from .types import ( - ChoicesProviderUnbound, - CmdOrSet, - CompleterUnbound, + CmdOrSetT, + UnboundChoicesProvider, + UnboundCompleter, ) if TYPE_CHECKING: # pragma: no cover @@ -388,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: ChoicesProviderUnbound[CmdOrSet] | None = None, - completer: CompleterUnbound[CmdOrSet] | 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 462ce8fad..cbfefdfa1 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,12 @@ ) from .styles import Cmd2Style from .types import ( - ChoicesProviderUnbound, + BoundCommandFunc, + BoundCompleter, CmdOrSet, - CompleterBound, - CompleterUnbound, + CmdOrSetT, + UnboundChoicesProvider, + UnboundCompleter, ) with contextlib.suppress(ImportError): @@ -205,7 +203,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 +236,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 +252,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 +285,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 +353,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 +480,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 +756,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 +773,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 +787,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 +805,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 +920,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 +961,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 +975,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 +1020,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 +1062,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 +1112,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 @@ -1164,7 +1164,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") @@ -2286,7 +2286,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 +2428,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: @@ -2443,7 +2443,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: @@ -3317,24 +3317,17 @@ 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: - """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) - 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 @@ -3365,7 +3358,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 ( @@ -3486,8 +3479,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[CmdOrSetT] | None = None, + completer: UnboundCompleter[CmdOrSetT] | None = None, parser: Cmd2ArgumentParser | None = None, ) -> Completer: """Determine the appropriate completer based on provided arguments.""" @@ -3518,8 +3511,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[CmdOrSetT] | None = None, + completer: UnboundCompleter[CmdOrSetT] | None = None, parser: Cmd2ArgumentParser | None = None, ) -> str: """Read a line of input with optional completion and history. @@ -4221,7 +4214,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) @@ -4247,7 +4240,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.get_command_func(command)) category = self._get_command_category(func) cmds_cats.setdefault(category, []).append(command) @@ -4313,7 +4306,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) @@ -4388,7 +4381,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 @@ -5636,7 +5629,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") @@ -5677,7 +5670,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.get_command_func(cmd_name)) if self._get_command_category(func) == category: self.disable_command(cmd_name, message_to_print) @@ -5871,7 +5864,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 +5888,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 +5897,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..773b676a6 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,29 +36,22 @@ 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. """ @@ -69,7 +59,7 @@ def _cmd(self) -> CustomCmdApp: raise CommandSetRegistrationError('This CommandSet is not registered') return self._cmd_internal - 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. @@ -79,10 +69,9 @@ def on_register(self, cmd: 'Cmd') -> 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. @@ -119,14 +108,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: - 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: - raise KeyError(f'Duplicate settable: {settable.name}') + if (cmd := self._cmd_internal) is not None: + # 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: diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 39a3a959d..e66b1a729 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, @@ -10,33 +11,44 @@ Any, TypeAlias, TypeVar, + overload, ) 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, + UnboundCommandFunc, +) if TYPE_CHECKING: # pragma: no cover from .cmd2 import Cmd +F = TypeVar("F", bound=Callable[..., Any]) + -def with_category(category: str) -> Callable[[CommandFunc], CommandFunc]: - """Decorate a ``do_*`` command method to apply a category. +def with_category( + category: str, +) -> Callable[[F], F]: + """Decorate a ``do_*`` command function to apply a category. + + 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. + :return: a decorator that assigns the specified category to the command function Example: ```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) ``` @@ -45,10 +57,8 @@ def do_echo(self, args) """ - def cat_decorator(func: CommandFunc) -> CommandFunc: - from .utils import ( - categorize, - ) + def cat_decorator(func: F) -> F: + from .utils import categorize categorize(func, category) return func @@ -56,10 +66,6 @@ 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] - - ########################## # 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 @@ -74,9 +80,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): @@ -104,54 +108,63 @@ 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 -# and optionally returns a boolean -ArgListCommandFuncOptionalBoolReturn: TypeAlias = Callable[[CmdOrSet, 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] -# 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] - -# Aggregate of all accepted function signatures for command functions that accept a pre-processed argument list -ArgListCommandFunc: TypeAlias = ( - ArgListCommandFuncOptionalBoolReturn[CmdOrSet] - | ArgListCommandFuncBoolReturn[CmdOrSet] - | ArgListCommandFuncNoneReturn[CmdOrSet] -) +ArgListCommandFunc: TypeAlias = UnboundCommandFunc[CmdOrSetT, [list[str]]] +# Overload for: @with_argument_list +@overload def with_argument_list( - func_arg: ArgListCommandFunc[CmdOrSet] | None = None, + cmd_func: ArgListCommandFunc[CmdOrSetT], *, preserve_quotes: bool = False, -) -> ( - RawCommandFuncOptionalBoolReturn[CmdOrSet] - | Callable[[ArgListCommandFunc[CmdOrSet]], RawCommandFuncOptionalBoolReturn[CmdOrSet]] -): - """Decorate a ``do_*`` method to alter the arguments passed to it so it is passed a list[str]. +) -> RawCommandFunc[CmdOrSetT]: ... + - 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. +# 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]]: ... - :param func_arg: Single-element positional argument list containing ``doi_*`` 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 + +def with_argument_list( + cmd_func: ArgListCommandFunc[CmdOrSetT] | None = None, + *, + preserve_quotes: bool = False, +) -> RawCommandFunc[CmdOrSetT] | Callable[[ArgListCommandFunc[CmdOrSetT]], RawCommandFunc[CmdOrSetT]]: + """Decorate a ``do_*`` command function to receive a list of parsed arguments. + + This decorator can be used either directly (``@with_argument_list``) or as a + factory with arguments (``@with_argument_list(preserve_quotes=True)``). + + :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]) -> 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]) -> None: + self.poutput(' '.join(arglist)) ``` """ - import functools - def arg_decorator(func: ArgListCommandFunc[CmdOrSet]) -> RawCommandFuncOptionalBoolReturn[CmdOrSet]: + 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. @@ -179,49 +192,30 @@ 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 # 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] -ArgparseCommandFuncWithUnknownArgsOptionalBoolReturn: TypeAlias = Callable[ - [CmdOrSet, 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] - -# 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] - -# 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] + # (self, args: argparse.Namespace) + UnboundCommandFunc[CmdOrSetT, [argparse.Namespace]] + # (self, args: argparse.Namespace, unknown_args: list[str]) + | UnboundCommandFunc[CmdOrSetT, [argparse.Namespace, list[str]]] ) 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]]: - """Decorate a ``do_*`` method to populate its ``args`` argument with the given instance of Cmd2ArgumentParser. +) -> Callable[[ArgparseCommandFunc[CmdOrSetT]], RawCommandFunc[CmdOrSetT]]: + """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 @@ -244,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}') ``` @@ -259,16 +253,15 @@ 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}') ``` """ - import functools - def arg_decorator(func: ArgparseCommandFunc[CmdOrSet]) -> RawCommandFuncOptionalBoolReturn[CmdOrSet]: + 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. @@ -346,13 +339,22 @@ 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]]: - """Tag this method as a subcommand to an existing argparse decorated command. +) -> Callable[[F], F]: + """Tag a function as a subcommand to an existing argparse decorated command. + + 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 + `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 @@ -363,10 +365,27 @@ 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") + def sub_handler(self, args: argparse.Namespace) -> None: + self.poutput('Subcommand executed') + ``` + """ - def arg_decorator(func: ArgparseCommandFunc[CmdOrSet]) -> ArgparseCommandFunc[CmdOrSet]: + 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) diff --git a/cmd2/types.py b/cmd2/types.py index 6c37b4b77..ff019ad9a 100644 --- a/cmd2/types.py +++ b/cmd2/types.py @@ -7,6 +7,9 @@ ) from typing import ( TYPE_CHECKING, + Any, + Concatenate, + ParamSpec, TypeAlias, TypeVar, Union, @@ -17,12 +20,63 @@ from .command_set import CommandSet from .completion import Choices, Completions -# A Cmd or CommandSet -CmdOrSet = TypeVar("CmdOrSet", bound=Union["Cmd", "CommandSet"]) +P = ParamSpec("P") -################################################## + +################################################################################################## +# 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, P], bool | None] + + +################################################################################################## # 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..5a984fafe 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -28,9 +28,10 @@ from . import constants from . import string_utils as su from .types import ( - ChoicesProviderUnbound, CmdOrSet, - CompleterUnbound, + CmdOrSetT, + UnboundChoicesProvider, + UnboundCompleter, ) if TYPE_CHECKING: # pragma: no cover @@ -39,7 +40,7 @@ else: PopenTextIO = subprocess.Popen -_T = TypeVar('_T') +T = TypeVar('T') def to_bool(val: Any) -> bool: @@ -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: ChoicesProviderUnbound[CmdOrSet] | None = None, - completer: CompleterUnbound[CmdOrSet] | None = None, + choices_provider: UnboundChoicesProvider[CmdOrSetT] | None = None, + completer: UnboundCompleter[CmdOrSetT] | None = None, ) -> None: """Settable Initializer. @@ -185,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 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/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/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/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 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"