diff --git a/CHANGELOG.md b/CHANGELOG.md index eb449a5dc..8084508de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## 4.2.3 (TBD) + +- Enhancements + - Converted `BoundCommandFunc` and `UnboundCommandFunc` TypeAliases in `types.py` to Protocol + classes for stricter type checking on `cmd2` command method references +- Experimental features + - Defined private, unified type alias `_CommandFunc` in `annotated.py` based on + `BoundCommandFunc` and `UnboundCommandFunc` to get the benefit of stricter type checking here + as well + ## 4.2.2 (August 25, 2026) - Documentation Improvements diff --git a/cmd2/annotated.py b/cmd2/annotated.py index f11cce437..4d397c56d 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -284,6 +284,7 @@ def do_build(self, target: str, common: CommonArgs): NamedTuple, ParamSpec, Protocol, + TypeAlias, TypedDict, TypeGuard, TypeVar, @@ -309,8 +310,11 @@ def do_build(self, target: str, common: CommonArgs): from .exceptions import Cmd2ArgparseError from .rich_utils import Cmd2HelpFormatter, HelpContent from .types import ( + BoundCommandFunc, + CmdOrSet, CmdOrSetT, UnboundChoicesProvider, + UnboundCommandFunc, UnboundCompleter, ) @@ -318,6 +322,9 @@ def do_build(self, target: str, common: CommonArgs): _NargsValue = int | str | tuple[int] | tuple[int, int] | tuple[int, float] +_CommandFunc: TypeAlias = BoundCommandFunc | UnboundCommandFunc[CmdOrSet, Any] + + class Cmd2ParserKwargs(TypedDict, total=False): """Forwarded ctor kwargs for [`Cmd2ArgumentParser`][cmd2.argparse_utils.Cmd2ArgumentParser] (PEP 692 ``Unpack``). @@ -695,7 +702,7 @@ def _convert(value: str) -> enum.Enum: raise _invalid_choice(value, _value_map) _convert.__name__ = enum_class.__name__ - _convert._cmd2_enum_class = enum_class # type: ignore[attr-defined] + _convert._cmd2_enum_class = enum_class # type: ignore[attr-defined, ty:unresolved-attribute] return _convert @@ -1101,7 +1108,7 @@ def _convert(value: str) -> Any: _convert.__name__ = getattr(converter, "__name__", "preprocess") enum_class = getattr(converter, "_cmd2_enum_class", None) if enum_class is not None: - _convert._cmd2_enum_class = enum_class # type: ignore[attr-defined] + _convert._cmd2_enum_class = enum_class # type: ignore[attr-defined, ty:unresolved-attribute] return _convert @@ -2118,7 +2125,7 @@ def _link_mutex_group_membership( by_name[name].mutex_group_indices.append(index) -def _resolve_func_hints(func: Callable[..., Any], *, skip_params: frozenset[str] = _SKIP_PARAMS) -> dict[str, Any]: +def _resolve_func_hints(func: _CommandFunc, *, skip_params: frozenset[str] = _SKIP_PARAMS) -> dict[str, Any]: """Resolve the type hints for the parameters that become arguments. The bound first parameter (self/cls), the injected ``skip_params``, and the ``return`` annotation @@ -2296,7 +2303,7 @@ def _block_field_dest(spec: _BlockSpec, field_name: str) -> str: return _shared_field_dest(spec.dc_type, field_name) if spec.shared else field_name -def _dataclass_blocks(func: Callable[..., Any], *, skip_params: frozenset[str] = _SKIP_PARAMS) -> dict[str, _BlockSpec]: +def _dataclass_blocks(func: _CommandFunc, *, skip_params: frozenset[str] = _SKIP_PARAMS) -> dict[str, _BlockSpec]: """Map each dataclass-block parameter name to its :class:`_BlockSpec`. Used by the runtime handler to reconstruct the dataclass instance from the parsed namespace. A @@ -2320,7 +2327,7 @@ def _dataclass_blocks(func: Callable[..., Any], *, skip_params: frozenset[str] = def _lazy_block_resolver( - func: Callable[..., Any], + func: _CommandFunc, *, base_accepted: set[str], skip_params: frozenset[str], @@ -2377,7 +2384,7 @@ def _reconstruct_dataclass_blocks(func_kwargs: dict[str, Any], blocks: dict[str, def _resolve_parameters( - func: Callable[..., Any], + func: _CommandFunc, *, skip_params: frozenset[str] = _SKIP_PARAMS, base_command: bool = False, @@ -2721,7 +2728,7 @@ def _docstring_first_paragraph(doc: str | None) -> str | None: def build_parser_from_function( - func: Callable[..., Any], + func: _CommandFunc, *, skip_params: frozenset[str] = _SKIP_PARAMS, groups: tuple[Group, ...] | None = None, @@ -2796,7 +2803,7 @@ def build_parser_from_function( return parser -def _derive_subcommand_name(func: Callable[..., Any], subcommand_to: str) -> str: +def _derive_subcommand_name(func: _CommandFunc, subcommand_to: str) -> str: """Derive the subcommand name from the function name and validate the naming convention. ``subcommand_to='team member'`` + ``func.__name__='team_member_add'`` -> ``'add'``. @@ -2832,7 +2839,7 @@ class _ParserBuildOptions: def _make_parser_builder( - func: Callable[..., Any], + func: _CommandFunc, *, skip_params: frozenset[str], base_command: bool, @@ -2871,12 +2878,12 @@ def parser_builder() -> Cmd2ArgumentParser: def _build_subcommand_handler( - func: Callable[..., Any], + func: _CommandFunc, subcommand_to: str, *, base_command: bool = False, options: _ParserBuildOptions, -) -> tuple[Callable[..., Any], str, Callable[[], Cmd2ArgumentParser]]: +) -> tuple[_CommandFunc, str, Callable[[], Cmd2ArgumentParser]]: """Build a subcommand's parser and a handler that unpacks the Namespace into typed kwargs. :param func: the subcommand handler function @@ -2957,7 +2964,7 @@ def with_annotated( def with_annotated( - func: Callable[..., Any] | None = None, + func: _CommandFunc | None = None, *, ns_provider: Callable[..., argparse.Namespace] | None = None, preserve_quotes: bool = False, @@ -3036,7 +3043,7 @@ def with_annotated( subcommand_description=subcommand_description, ) - def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: + def decorator(fn: _CommandFunc) -> _CommandFunc: if with_unknown_args: unknown_param = inspect.signature(fn).parameters.get("_unknown") if unknown_param is None: diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 32466ad3e..dc2a7d4a8 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -52,7 +52,7 @@ def _build_hint(parser: Cmd2ArgumentParser, arg_action: argparse.Action) -> str: """Build completion hint for a given argument.""" # Check if hinting is disabled for this argument - suppress_hint = arg_action.get_suppress_tab_hint() # type: ignore[attr-defined] + suppress_hint = arg_action.get_suppress_tab_hint() # type: ignore[attr-defined, ty:unresolved-attribute] if suppress_hint or arg_action.help == argparse.SUPPRESS: return "" @@ -104,7 +104,7 @@ def __init__(self, arg_action: argparse.Action) -> None: self.is_remainder = self.action.nargs == argparse.REMAINDER # Check if nargs is a range - nargs_range: tuple[int, int | float] | None = self.action.get_nargs_range() # type: ignore[attr-defined] + nargs_range: tuple[int, int | float] | None = self.action.get_nargs_range() # type: ignore[attr-defined, ty:unresolved-attribute] if nargs_range is not None: self.min = nargs_range[0] self.max = nargs_range[1] @@ -575,7 +575,7 @@ def _validate_table_data(arg_state: _ArgumentState, completions: Completions) -> :raises ValueError: if there is an error with the data. """ - table_columns = arg_state.action.get_table_columns() # type: ignore[attr-defined] + table_columns = arg_state.action.get_table_columns() # type: ignore[attr-defined, ty:unresolved-attribute] has_table_data = any(item.table_data for item in completions) if table_columns is None: @@ -606,7 +606,7 @@ def _build_completion_table(self, arg_state: _ArgumentState, completions: Comple table_columns = cast( Sequence[str | Column] | None, - arg_state.action.get_table_columns(), # type: ignore[attr-defined] + arg_state.action.get_table_columns(), # type: ignore[attr-defined, ty:unresolved-attribute] ) # Skip table generation if results are outside thresholds or no columns are defined @@ -761,7 +761,7 @@ def _complete_arg( :raises CompletionError: if the completer or choices function this calls raises one """ # Check if the argument uses a completer - completer = arg_state.action.get_completer() # type: ignore[attr-defined] + completer = arg_state.action.get_completer() # type: ignore[attr-defined, ty:unresolved-attribute] if completer is not None: args, kwargs = self._prepare_callable_params( completer, @@ -775,7 +775,7 @@ def _complete_arg( # Otherwise it uses a choices provider or choices list else: - choices_provider = arg_state.action.get_choices_provider() # type: ignore[attr-defined] + choices_provider = arg_state.action.get_choices_provider() # type: ignore[attr-defined, ty:unresolved-attribute] if choices_provider is not None: args, kwargs = self._prepare_callable_params( choices_provider, diff --git a/cmd2/argparse_utils.py b/cmd2/argparse_utils.py index 0584103df..7913fa440 100644 --- a/cmd2/argparse_utils.py +++ b/cmd2/argparse_utils.py @@ -564,11 +564,11 @@ def _ActionsContainer_add_argument( # noqa: N802 new_arg = orig_actions_container_add_argument(self, *args, **kwargs) # Set the cmd2-specific attributes - new_arg.set_nargs_range(nargs_range) # type: ignore[attr-defined] - new_arg.set_choices_provider(choices_provider) # type: ignore[attr-defined] - new_arg.set_completer(completer) # type: ignore[attr-defined] - new_arg.set_suppress_tab_hint(suppress_tab_hint) # type: ignore[attr-defined] - new_arg.set_table_columns(table_columns) # type: ignore[attr-defined] + new_arg.set_nargs_range(nargs_range) # type: ignore[attr-defined, ty:unresolved-attribute] + new_arg.set_choices_provider(choices_provider) # type: ignore[attr-defined, ty:unresolved-attribute] + new_arg.set_completer(completer) # type: ignore[attr-defined, ty:unresolved-attribute] + new_arg.set_suppress_tab_hint(suppress_tab_hint) # type: ignore[attr-defined, ty:unresolved-attribute] + new_arg.set_table_columns(table_columns) # type: ignore[attr-defined, ty:unresolved-attribute] # Set other registered custom attributes for keyword, value in custom_attribs.items(): @@ -666,14 +666,14 @@ def _SubParsersAction_remove_all_parsers( # noqa: N802 # Get the next subcommand name. remove_parser() will remove # it and any associated aliases from _name_parser_map. name = next(iter(self._name_parser_map)) - record = self.remove_parser(name) # type: ignore[attr-defined] + record = self.remove_parser(name) # type: ignore[attr-defined, ty:unresolved-attribute] records.append(record) return records -argparse._SubParsersAction.remove_parser = _SubParsersAction_remove_parser # type: ignore[attr-defined] -argparse._SubParsersAction.remove_all_parsers = _SubParsersAction_remove_all_parsers # type: ignore[attr-defined] +argparse._SubParsersAction.remove_parser = _SubParsersAction_remove_parser # type: ignore[attr-defined, ty:unresolved-attribute] +argparse._SubParsersAction.remove_all_parsers = _SubParsersAction_remove_all_parsers # type: ignore[attr-defined, ty:unresolved-attribute] @dataclass @@ -984,7 +984,7 @@ def detach_subcommand(self, subcommand_path: Iterable[str], subcommand: str) -> try: record = cast( SubcommandRecord, - subparsers_action.remove_parser(subcommand), # type: ignore[attr-defined] + subparsers_action.remove_parser(subcommand), # type: ignore[attr-defined, ty:unresolved-attribute] ) except ValueError: raise ValueError(f"Subcommand '{subcommand}' does not exist for '{target_parser.prog}'") from None @@ -1006,7 +1006,7 @@ def detach_all_subcommands(self, subcommand_path: Iterable[str]) -> list[Subcomm records = cast( list[SubcommandRecord], - subparsers_action.remove_all_parsers(), # type: ignore[attr-defined] + subparsers_action.remove_all_parsers(), # type: ignore[attr-defined, ty:unresolved-attribute] ) # Update command for each detached subcommand for record in records: @@ -1046,7 +1046,7 @@ def format_help(self, *args: Any, **kwargs: Any) -> str: def _get_nargs_pattern(self, action: argparse.Action) -> str: """Override to support nargs ranges.""" - nargs_range = action.get_nargs_range() # type: ignore[attr-defined] + nargs_range = action.get_nargs_range() # type: ignore[attr-defined, ty:unresolved-attribute] if nargs_range: range_max = "" if nargs_range[1] == constants.INFINITY else nargs_range[1] nargs_pattern = f"(-*A{{{nargs_range[0]},{range_max}}}-*)" @@ -1066,7 +1066,7 @@ def _match_argument(self, action: argparse.Action, arg_strings_pattern: str) -> # raise an exception if we weren't able to find a match if match is None: - nargs_range = action.get_nargs_range() # type: ignore[attr-defined] + nargs_range = action.get_nargs_range() # type: ignore[attr-defined, ty:unresolved-attribute] if nargs_range is not None: raise ArgumentError(action, build_range_error(nargs_range[0], nargs_range[1])) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index f5becb330..4453411bd 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -919,7 +919,7 @@ def register_command_set(self, cmdset: CommandSet[Any]) -> None: cmdset.on_register(self) methods = cast( - list[tuple[str, Callable[..., Any]]], + list[tuple[str, BoundCommandFunc]], inspect.getmembers( cmdset, predicate=lambda meth: ( # type: ignore[arg-type] @@ -1086,12 +1086,15 @@ def unregister_command_set(self, cmdset: CommandSet[Any]) -> None: cmdset.on_unregister() self._unregister_subcommands(cmdset) - methods: list[tuple[str, Callable[..., Any]]] = inspect.getmembers( - cmdset, - predicate=lambda meth: ( # type: ignore[arg-type] - isinstance(meth, Callable) # type: ignore[arg-type] - and hasattr(meth, "__name__") - and meth.__name__.startswith(COMMAND_FUNC_PREFIX) + methods: list[tuple[str, BoundCommandFunc]] = cast( + list[tuple[str, BoundCommandFunc]], + inspect.getmembers( + cmdset, + predicate=lambda meth: ( # type: ignore[arg-type] + isinstance(meth, Callable) # type: ignore[arg-type] + and hasattr(meth, "__name__") + and meth.__name__.startswith(COMMAND_FUNC_PREFIX) + ), ), ) @@ -1158,12 +1161,15 @@ def check_parser_uninstallable(parser: Cmd2ArgumentParser) -> None: ) check_parser_uninstallable(subparser) - methods: list[tuple[str, Callable[..., Any]]] = inspect.getmembers( - cmdset, - predicate=lambda meth: ( # type: ignore[arg-type] - isinstance(meth, Callable) # type: ignore[arg-type] - and hasattr(meth, "__name__") - and meth.__name__.startswith(COMMAND_FUNC_PREFIX) + methods: list[tuple[str, BoundCommandFunc]] = cast( + list[tuple[str, BoundCommandFunc]], + inspect.getmembers( + cmdset, + predicate=lambda meth: ( # type: ignore[arg-type] + isinstance(meth, Callable) # type: ignore[arg-type] + and hasattr(meth, "__name__") + and meth.__name__.startswith(COMMAND_FUNC_PREFIX) + ), ), ) @@ -2827,9 +2833,10 @@ def _get_commands_aliases_and_macros_choices(self) -> Choices: # Add commands for command in self.get_visible_commands(): - command_func = cast(BoundCommandFunc, self.get_command_func(command)) - description = strip_doc_annotations(command_func.__doc__).splitlines()[0] if command_func.__doc__ else "" - items.append(CompletionItem(command, display_meta=description)) + command_func = self.get_command_func(command) + if command_func is not None: + description = strip_doc_annotations(command_func.__doc__).splitlines()[0] if command_func.__doc__ else "" + items.append(CompletionItem(command, display_meta=description)) # Add aliases for name, value in self.aliases.items(): @@ -4345,9 +4352,10 @@ def _build_command_info(self) -> tuple[dict[str, list[str]], list[str]]: help_topics.remove(command) # Store the command within its category - command_func = cast(BoundCommandFunc, self.get_command_func(command)) - category = self._get_command_category(command_func) - cmds_cats.setdefault(category, []).append(command) + command_func = self.get_command_func(command) + if command_func is not None: + category = self._get_command_category(command_func) + cmds_cats.setdefault(category, []).append(command) return cmds_cats, help_topics @@ -5814,8 +5822,8 @@ def disable_category(self, category: str, message_to_print: str) -> None: all_commands = self.get_all_commands() for command in all_commands: - command_func = cast(BoundCommandFunc, self.get_command_func(command)) - if self._get_command_category(command_func) == category: + command_func = self.get_command_func(command) + if command_func is not None and self._get_command_category(command_func) == category: self.disable_command(command, message_to_print) self.disabled_categories[category] = message_to_print @@ -5918,7 +5926,7 @@ def _validate_callable_param_count(cls, func: Callable[..., Any], count: int) -> nparam = len(signature.parameters) if nparam != count: plural = "" if nparam == 1 else "s" - raise TypeError(f"{func.__name__} has {nparam} positional argument{plural}, expected {count}") + raise TypeError(f"{getattr(func, '__name__', 'hook')} has {nparam} positional argument{plural}, expected {count}") @classmethod def _validate_prepostloop_callable(cls, func: Callable[[], None]) -> None: @@ -5927,7 +5935,7 @@ def _validate_prepostloop_callable(cls, func: Callable[[], None]) -> None: # make sure there is no return annotation or the return is specified as None _, ret_ann = get_types(func) if ret_ann is not None: - raise TypeError(f"{func.__name__} must have a return type of 'None', got: {ret_ann}") + raise TypeError(f"{getattr(func, '__name__', 'hook')} must have a return type of 'None', got: {ret_ann}") def register_preloop_hook(self, func: Callable[[], None]) -> None: """Register a function to be called at the beginning of the command loop.""" @@ -5944,13 +5952,14 @@ def _validate_postparsing_callable(cls, func: Callable[[plugin.PostparsingData], """Check parameter and return types for postparsing hooks.""" cls._validate_callable_param_count(cast(Callable[..., Any], func), 1) type_hints, ret_ann = get_types(func) + func_name = getattr(func, "__name__", "hook") if not type_hints: - raise TypeError(f"{func.__name__} parameter is missing a type hint, expected: 'cmd2.plugin.PostparsingData'") + raise TypeError(f"{func_name} parameter is missing a type hint, expected: 'cmd2.plugin.PostparsingData'") par_ann = next(iter(type_hints.values())) if par_ann != plugin.PostparsingData: - raise TypeError(f"{func.__name__} must have one parameter declared with type 'cmd2.plugin.PostparsingData'") + raise TypeError(f"{func_name} must have one parameter declared with type 'cmd2.plugin.PostparsingData'") if ret_ann != plugin.PostparsingData: - raise TypeError(f"{func.__name__} must declare return a return type of 'cmd2.plugin.PostparsingData'") + raise TypeError(f"{func_name} must declare return a return type of 'cmd2.plugin.PostparsingData'") def register_postparsing_hook(self, func: Callable[[plugin.PostparsingData], plugin.PostparsingData]) -> None: """Register a function to be called after parsing user input but before running the command.""" @@ -5968,17 +5977,18 @@ def _validate_prepostcmd_hook( cls._validate_callable_param_count(cast(Callable[..., Any], func), 1) type_hints, ret_ann = get_types(func) + func_name = getattr(func, "__name__", "hook") if not type_hints: - raise TypeError(f"{func.__name__} parameter is missing a type hint, expected: {data_type}") + raise TypeError(f"{func_name} parameter is missing a type hint, expected: {data_type}") _param_name, par_ann = next(iter(type_hints.items())) # validate the parameter has the right annotation if par_ann != data_type: - raise TypeError(f"argument 1 of {func.__name__} has incompatible type {par_ann}, expected {data_type}") + raise TypeError(f"argument 1 of {func_name} has incompatible type {par_ann}, expected {data_type}") # validate the return value has the right annotation if ret_ann is None: - raise TypeError(f"{func.__name__} does not have a declared return type, expected {data_type}") + raise TypeError(f"{func_name} does not have a declared return type, expected {data_type}") if ret_ann != data_type: - raise TypeError(f"{func.__name__} has incompatible return type {ret_ann}, expected {data_type}") + raise TypeError(f"{func_name} has incompatible return type {ret_ann}, expected {data_type}") def register_precmd_hook(self, func: Callable[[plugin.PrecommandData], plugin.PrecommandData]) -> None: """Register a hook to be called before the command function.""" @@ -5997,15 +6007,16 @@ def _validate_cmdfinalization_callable( """Check parameter and return types for command finalization hooks.""" cls._validate_callable_param_count(func, 1) type_hints, ret_ann = get_types(func) + func_name = getattr(func, "__name__", "hook") if not type_hints: - raise TypeError(f"{func.__name__} parameter is missing a type hint, expected: {plugin.CommandFinalizationData}") + raise TypeError(f"{func_name} parameter is missing a type hint, expected: {plugin.CommandFinalizationData}") _, par_ann = next(iter(type_hints.items())) if par_ann != plugin.CommandFinalizationData: raise TypeError( - f"{func.__name__} must have one parameter declared with type {plugin.CommandFinalizationData}, got: {par_ann}" + f"{func_name} must have one parameter declared with type {plugin.CommandFinalizationData}, got: {par_ann}" ) if ret_ann != plugin.CommandFinalizationData: - raise TypeError(f"{func.__name__} must declare return a return type of {plugin.CommandFinalizationData}") + raise TypeError(f"{func_name} must declare return a return type of {plugin.CommandFinalizationData}") def register_cmdfinalization_hook( self, func: Callable[[plugin.CommandFinalizationData], plugin.CommandFinalizationData] diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 8ad5bfd52..7bc56c90f 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -11,6 +11,7 @@ Any, TypeAlias, TypeVar, + cast, overload, ) @@ -196,7 +197,7 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX) :] cmd_wrapper.__doc__ = func.__doc__ - return cmd_wrapper + return cast(RawCommandFunc[CmdOrSetT], cmd_wrapper) if callable(cmd_func): return arg_decorator(cmd_func) @@ -372,7 +373,7 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: ) setattr(cmd_wrapper, constants.ARGPARSE_COMMAND_ATTR_SPEC, spec) - return cmd_wrapper + return cast(RawCommandFunc[CmdOrSetT], cmd_wrapper) return arg_decorator diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 9b4567175..011caf08e 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -202,7 +202,7 @@ def _format_args(self, action: argparse.Action, default_metavar: str) -> str: get_metavar = self._metavar_formatter(action, default_metavar) # Handle nargs specified as a range - nargs_range = action.get_nargs_range() # type: ignore[attr-defined] + nargs_range = action.get_nargs_range() # type: ignore[attr-defined, ty:unresolved-attribute] if nargs_range is not None: arg_str = "%s" % get_metavar(1) # noqa: UP031 range_str = self._build_nargs_range_str(nargs_range) @@ -229,7 +229,7 @@ def _rich_metavar_parts( get_metavar = self._metavar_formatter(action, default_metavar) # Handle nargs specified as a range - nargs_range = action.get_nargs_range() # type: ignore[attr-defined] + nargs_range = action.get_nargs_range() # type: ignore[attr-defined, ty:unresolved-attribute] if nargs_range is not None: yield "%s" % get_metavar(1), True # noqa: UP031 yield self._build_nargs_range_str(nargs_range), False diff --git a/cmd2/types.py b/cmd2/types.py index ff019ad9a..9931de0b6 100644 --- a/cmd2/types.py +++ b/cmd2/types.py @@ -8,11 +8,12 @@ from typing import ( TYPE_CHECKING, Any, - Concatenate, ParamSpec, + Protocol, TypeAlias, TypeVar, Union, + overload, ) if TYPE_CHECKING: # pragma: no cover @@ -65,13 +66,36 @@ # 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] +class BoundCommandFunc(Protocol): + """Protocol for a command function bound to a command instance.""" + + __name__: str + __qualname__: str + + def __call__(self, *args: Any, **kwargs: Any) -> bool | None: + """Invoke the bound command function.""" + # 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] +class UnboundCommandFunc(Protocol[CmdOrSetT, P]): + """Protocol for an unbound command function.""" + + __name__: str + __qualname__: str + + def __call__(self, __self: CmdOrSetT, /, *args: P.args, **kwargs: P.kwargs) -> bool | None: + """Invoke the unbound command function with its command instance.""" + ... + + @overload + def __get__(self, instance: None, owner: Any = ...) -> "UnboundCommandFunc[CmdOrSetT, P]": ... + + @overload + def __get__(self, instance: CmdOrSetT, owner: Any = ...) -> BoundCommandFunc: ... ################################################################################################## diff --git a/cmd2/utils.py b/cmd2/utils.py index a0c8d9067..f88a46f20 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -615,7 +615,7 @@ def _reader_thread_func(self, read_stdout: bool) -> None: # Run until process completes while self._proc.poll() is None: - available = read_stream.peek() # type: ignore[attr-defined] + available = read_stream.peek() # type: ignore[attr-defined, ty:unresolved-attribute] if available: read_stream.read(len(available)) self._write_bytes(write_stream, available) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 38985efa9..d2822bcd6 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -38,7 +38,6 @@ ) from cmd2 import rich_utils as ru from cmd2 import string_utils as su -from cmd2.types import BoundCommandFunc from .conftest import ( SHORTCUTS_TXT, @@ -4178,7 +4177,8 @@ def test_help_disabled_no_help_func(base_app: cmd2.Cmd) -> None: # Intentionally bypass disable_command() to test the fallback in do_help() command = "quit" - command_func = cast(BoundCommandFunc, base_app.get_command_func(command)) + command_func = base_app.get_command_func(command) + assert command_func is not None base_app.disabled_commands[command] = DisabledCommand(command_func=command_func, help_func=None, completer_func=None) _out, err = run_cmd(base_app, f"help {command}") diff --git a/ty.toml b/ty.toml index 3a27737dd..fcdf055d7 100644 --- a/ty.toml +++ b/ty.toml @@ -5,4 +5,3 @@ python-version = "3.11" include = ["cmd2"] [rules] -unresolved-attribute = "ignore" # 64 warnings