From d5a0a1afe10d29c7256ce9a8e08a2fbed32241b7 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Tue, 25 Aug 2026 21:36:52 -0400 Subject: [PATCH 1/6] Fix ty unresolved-attribute warnings and stop globally ignoring them Major changes: - Convert BoundCommandFunc and UnboundCommandFunc TypeAliases in types.py to Protocol classes for stricter type checking - Added `_NamedCallable` Protocol class in annotated.py for stricter type checking of function references - Used `getattr` and/or `cast()` to help resolve some type errors in cmd2.py Minor changes: - Added type ignore for `ty:unresolved-attribute` to a number of places we were already ignoring `attr-defined` for mypy (problem of different name for same type of check) --- CHANGELOG.md | 9 +++++++++ cmd2/annotated.py | 33 ++++++++++++++++++++------------- cmd2/argparse_completer.py | 12 ++++++------ cmd2/argparse_utils.py | 24 ++++++++++++------------ cmd2/cmd2.py | 37 +++++++++++++++++++++---------------- cmd2/decorators.py | 5 +++-- cmd2/rich_utils.py | 4 ++-- cmd2/types.py | 30 +++++++++++++++++++++++++++--- cmd2/utils.py | 2 +- ty.toml | 1 - 10 files changed, 101 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb449a5dc..c5e58fce3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## 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 `_NamedCallable` protocol class in `annotated.py` to implement some stricter type + checking on function references + ## 4.2.2 (August 25, 2026) - Documentation Improvements diff --git a/cmd2/annotated.py b/cmd2/annotated.py index f11cce437..48767bfda 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -318,6 +318,13 @@ def do_build(self, target: str, common: CommonArgs): _NargsValue = int | str | tuple[int] | tuple[int, int] | tuple[int, float] +class _NamedCallable(Protocol): + __name__: str + __qualname__: str + + def __call__(self, *args: Any, **kwargs: Any) -> 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: _NamedCallable, *, 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: _NamedCallable, *, 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: _NamedCallable, *, 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: _NamedCallable, *, 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: _NamedCallable, *, 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: _NamedCallable, 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: _NamedCallable, *, skip_params: frozenset[str], base_command: bool, @@ -2871,12 +2878,12 @@ def parser_builder() -> Cmd2ArgumentParser: def _build_subcommand_handler( - func: Callable[..., Any], + func: _NamedCallable, subcommand_to: str, *, base_command: bool = False, options: _ParserBuildOptions, -) -> tuple[Callable[..., Any], str, Callable[[], Cmd2ArgumentParser]]: +) -> tuple[_NamedCallable, 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: _NamedCallable | 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: _NamedCallable) -> _NamedCallable: 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..4fe259e71 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -935,7 +935,7 @@ def register_command_set(self, cmdset: CommandSet[Any]) -> None: for cmd_func_name, command_method in methods: command = cmd_func_name[len(COMMAND_FUNC_PREFIX) :] - self._install_command_function(cmd_func_name, command_method, type(cmdset).__name__) + self._install_command_function(cmd_func_name, cast(BoundCommandFunc, command_method), type(cmdset).__name__) installed_attributes.append(cmd_func_name) completer_func_name = COMPLETER_FUNC_PREFIX + command @@ -953,7 +953,7 @@ def register_command_set(self, cmdset: CommandSet[Any]) -> None: self._cmd_to_command_sets[command] = cmdset # If this command is in a disabled category, then disable it - command_category = self._get_command_category(command_method) + command_category = self._get_command_category(cast(BoundCommandFunc, command_method)) if command_category in self.disabled_categories: message_to_print = self.disabled_categories[command_category] self.disable_command(command, message_to_print) @@ -1095,7 +1095,8 @@ def unregister_command_set(self, cmdset: CommandSet[Any]) -> None: ), ) - for cmd_func_name, command_method in methods: + for cmd_func_name, command_method_raw in methods: + command_method = cast(BoundCommandFunc, command_method_raw) command = cmd_func_name[len(COMMAND_FUNC_PREFIX) :] # Enable the command before uninstalling it to make sure we remove both @@ -1167,7 +1168,8 @@ def check_parser_uninstallable(parser: Cmd2ArgumentParser) -> None: ), ) - for cmd_func_name, command_method in methods: + for cmd_func_name, command_method_raw in methods: + command_method = cast(BoundCommandFunc, command_method_raw) # We only need to check if it's safe to remove the parser if this # is the actual command since command synonyms don't own it. if cmd_func_name == command_method.__name__: @@ -5918,7 +5920,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 +5929,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 +5946,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 +5971,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 +6001,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..1b0844687 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/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 From 2c94add0f694f05cbefabe4a4770602e711ae885 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Tue, 25 Aug 2026 22:48:09 -0400 Subject: [PATCH 2/6] Removed redundant _NamedCallable protocol class from cmd2/annotated.py Changes include: 1. Removed _NamedCallable: Deleted the redundant protocol class definition of _NamedCallable from cmd2/annotated.py. 2. Imported type protocols: Imported BoundCommandFunc and UnboundCommandFunc from cmd2/types.py, and TypeAlias from typing. 3. Defined unified _CommandFunc alias: Formed a private, unified type alias _CommandFunc = BoundCommandFunc | UnboundCommandFunc[CmdOrSetT, [argparse.Namespace]]. 4. Updated function signatures: Replaced all annotations that previously used _NamedCallable in cmd2/annotated.py with _CommandFunc. --- CHANGELOG.md | 5 +++-- cmd2/annotated.py | 31 +++++++++++++++---------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5e58fce3..406193cf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,9 @@ - Converted `BoundCommandFunc` and `UnboundCommandFunc` TypeAliases in `types.py` to Protocol classes for stricter type checking on `cmd2` command method references - Experimental features - - Defined `_NamedCallable` protocol class in `annotated.py` to implement some stricter type - checking on function references + - Defined private, unified type alias `_CommandFunc` in `annotated.py` basead on + `BoundCommandFunc` and `UnboundCommandFunc` to get the benefit of stricter type checking here + as well ## 4.2.2 (August 25, 2026) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index 48767bfda..b81489ac8 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,10 @@ def do_build(self, target: str, common: CommonArgs): from .exceptions import Cmd2ArgparseError from .rich_utils import Cmd2HelpFormatter, HelpContent from .types import ( + BoundCommandFunc, CmdOrSetT, UnboundChoicesProvider, + UnboundCommandFunc, UnboundCompleter, ) @@ -318,11 +321,7 @@ def do_build(self, target: str, common: CommonArgs): _NargsValue = int | str | tuple[int] | tuple[int, int] | tuple[int, float] -class _NamedCallable(Protocol): - __name__: str - __qualname__: str - - def __call__(self, *args: Any, **kwargs: Any) -> Any: ... +_CommandFunc: TypeAlias = BoundCommandFunc | UnboundCommandFunc[CmdOrSetT, [argparse.Namespace]] class Cmd2ParserKwargs(TypedDict, total=False): @@ -2125,7 +2124,7 @@ def _link_mutex_group_membership( by_name[name].mutex_group_indices.append(index) -def _resolve_func_hints(func: _NamedCallable, *, 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 @@ -2303,7 +2302,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: _NamedCallable, *, 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 @@ -2327,7 +2326,7 @@ def _dataclass_blocks(func: _NamedCallable, *, skip_params: frozenset[str] = _SK def _lazy_block_resolver( - func: _NamedCallable, + func: _CommandFunc, *, base_accepted: set[str], skip_params: frozenset[str], @@ -2384,7 +2383,7 @@ def _reconstruct_dataclass_blocks(func_kwargs: dict[str, Any], blocks: dict[str, def _resolve_parameters( - func: _NamedCallable, + func: _CommandFunc, *, skip_params: frozenset[str] = _SKIP_PARAMS, base_command: bool = False, @@ -2728,7 +2727,7 @@ def _docstring_first_paragraph(doc: str | None) -> str | None: def build_parser_from_function( - func: _NamedCallable, + func: _CommandFunc, *, skip_params: frozenset[str] = _SKIP_PARAMS, groups: tuple[Group, ...] | None = None, @@ -2803,7 +2802,7 @@ def build_parser_from_function( return parser -def _derive_subcommand_name(func: _NamedCallable, 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'``. @@ -2839,7 +2838,7 @@ class _ParserBuildOptions: def _make_parser_builder( - func: _NamedCallable, + func: _CommandFunc, *, skip_params: frozenset[str], base_command: bool, @@ -2878,12 +2877,12 @@ def parser_builder() -> Cmd2ArgumentParser: def _build_subcommand_handler( - func: _NamedCallable, + func: _CommandFunc, subcommand_to: str, *, base_command: bool = False, options: _ParserBuildOptions, -) -> tuple[_NamedCallable, 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 @@ -2964,7 +2963,7 @@ def with_annotated( def with_annotated( - func: _NamedCallable | None = None, + func: _CommandFunc | None = None, *, ns_provider: Callable[..., argparse.Namespace] | None = None, preserve_quotes: bool = False, @@ -3043,7 +3042,7 @@ def with_annotated( subcommand_description=subcommand_description, ) - def decorator(fn: _NamedCallable) -> _NamedCallable: + def decorator(fn: _CommandFunc) -> _CommandFunc: if with_unknown_args: unknown_param = inspect.signature(fn).parameters.get("_unknown") if unknown_param is None: From ec7745e4bd998f4c34cfc96582d9477244f4c284 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Tue, 25 Aug 2026 23:00:31 -0400 Subject: [PATCH 3/6] Fixed typo --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 406193cf1..8084508de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - 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` basead on + - 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 From 59455b60166ce1ba59c280106355f90c4acac627 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Tue, 25 Aug 2026 23:10:30 -0400 Subject: [PATCH 4/6] Fix type alias --- cmd2/annotated.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index b81489ac8..87737fa0a 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -321,7 +321,7 @@ def do_build(self, target: str, common: CommonArgs): _NargsValue = int | str | tuple[int] | tuple[int, int] | tuple[int, float] -_CommandFunc: TypeAlias = BoundCommandFunc | UnboundCommandFunc[CmdOrSetT, [argparse.Namespace]] +_CommandFunc: TypeAlias = BoundCommandFunc | UnboundCommandFunc[CmdOrSetT, Any] class Cmd2ParserKwargs(TypedDict, total=False): From c19c018fd66efde67f8022d900d33f0ff0171344 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Wed, 26 Aug 2026 18:40:09 -0400 Subject: [PATCH 5/6] Made `__name__` a read-only propery for BoundCommandFunc and UnboundCommandFunc Protocol classes Also: - Switched some types in cmd2.py from `Callable[..., Any]` to `BoundCommandFunc` - Removed a number of `cast` calls in cmd2.py which were no longer needed --- cmd2/cmd2.py | 60 +++++++++++++++++++++++++--------------------- cmd2/types.py | 12 ++++++++-- tests/test_cmd2.py | 4 ++-- 3 files changed, 45 insertions(+), 31 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 4fe259e71..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] @@ -935,7 +935,7 @@ def register_command_set(self, cmdset: CommandSet[Any]) -> None: for cmd_func_name, command_method in methods: command = cmd_func_name[len(COMMAND_FUNC_PREFIX) :] - self._install_command_function(cmd_func_name, cast(BoundCommandFunc, command_method), type(cmdset).__name__) + self._install_command_function(cmd_func_name, command_method, type(cmdset).__name__) installed_attributes.append(cmd_func_name) completer_func_name = COMPLETER_FUNC_PREFIX + command @@ -953,7 +953,7 @@ def register_command_set(self, cmdset: CommandSet[Any]) -> None: self._cmd_to_command_sets[command] = cmdset # If this command is in a disabled category, then disable it - command_category = self._get_command_category(cast(BoundCommandFunc, command_method)) + command_category = self._get_command_category(command_method) if command_category in self.disabled_categories: message_to_print = self.disabled_categories[command_category] self.disable_command(command, message_to_print) @@ -1086,17 +1086,19 @@ 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) + ), ), ) - for cmd_func_name, command_method_raw in methods: - command_method = cast(BoundCommandFunc, command_method_raw) + for cmd_func_name, command_method in methods: command = cmd_func_name[len(COMMAND_FUNC_PREFIX) :] # Enable the command before uninstalling it to make sure we remove both @@ -1159,17 +1161,19 @@ 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) + ), ), ) - for cmd_func_name, command_method_raw in methods: - command_method = cast(BoundCommandFunc, command_method_raw) + for cmd_func_name, command_method in methods: # We only need to check if it's safe to remove the parser if this # is the actual command since command synonyms don't own it. if cmd_func_name == command_method.__name__: @@ -2829,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(): @@ -4347,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 @@ -5816,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 diff --git a/cmd2/types.py b/cmd2/types.py index 1b0844687..47a38fb3c 100644 --- a/cmd2/types.py +++ b/cmd2/types.py @@ -72,9 +72,13 @@ class BoundCommandFunc(Protocol): """Protocol for a command function bound to a command instance.""" - __name__: str __qualname__: str + @property + def __name__(self) -> str: + """The name of the bound command function.""" + ... + def __call__(self, *args: Any, **kwargs: Any) -> bool | None: """Invoke the bound command function.""" @@ -84,9 +88,13 @@ def __call__(self, *args: Any, **kwargs: Any) -> bool | None: class UnboundCommandFunc(Protocol[CmdOrSetT, P]): """Protocol for an unbound command function.""" - __name__: str __qualname__: str + @property + def __name__(self) -> str: + """The name of the unbound command function.""" + ... + def __call__(self, __self: CmdOrSetT, /, *args: P.args, **kwargs: P.kwargs) -> bool | None: """Invoke the unbound command function with its command instance.""" ... 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}") From 06e806aa8c069a1e7cc579cd42663cfba05336cc Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Wed, 26 Aug 2026 19:12:26 -0400 Subject: [PATCH 6/6] Made the owner parameter for `__get__` optional in both BoundCommandFunc and UnboundCommand func Also: - Restored the simple attribute definition for `__name__` attribute in BoundCommandFunc and UnboundCommandFund --- cmd2/annotated.py | 3 ++- cmd2/types.py | 16 ++++------------ 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index 87737fa0a..4d397c56d 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -311,6 +311,7 @@ def do_build(self, target: str, common: CommonArgs): from .rich_utils import Cmd2HelpFormatter, HelpContent from .types import ( BoundCommandFunc, + CmdOrSet, CmdOrSetT, UnboundChoicesProvider, UnboundCommandFunc, @@ -321,7 +322,7 @@ def do_build(self, target: str, common: CommonArgs): _NargsValue = int | str | tuple[int] | tuple[int, int] | tuple[int, float] -_CommandFunc: TypeAlias = BoundCommandFunc | UnboundCommandFunc[CmdOrSetT, Any] +_CommandFunc: TypeAlias = BoundCommandFunc | UnboundCommandFunc[CmdOrSet, Any] class Cmd2ParserKwargs(TypedDict, total=False): diff --git a/cmd2/types.py b/cmd2/types.py index 47a38fb3c..9931de0b6 100644 --- a/cmd2/types.py +++ b/cmd2/types.py @@ -72,13 +72,9 @@ class BoundCommandFunc(Protocol): """Protocol for a command function bound to a command instance.""" + __name__: str __qualname__: str - @property - def __name__(self) -> str: - """The name of the bound command function.""" - ... - def __call__(self, *args: Any, **kwargs: Any) -> bool | None: """Invoke the bound command function.""" @@ -88,22 +84,18 @@ def __call__(self, *args: Any, **kwargs: Any) -> bool | None: class UnboundCommandFunc(Protocol[CmdOrSetT, P]): """Protocol for an unbound command function.""" + __name__: str __qualname__: str - @property - def __name__(self) -> str: - """The name of the unbound command function.""" - ... - 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]": ... + def __get__(self, instance: None, owner: Any = ...) -> "UnboundCommandFunc[CmdOrSetT, P]": ... @overload - def __get__(self, instance: CmdOrSetT, owner: Any) -> BoundCommandFunc: ... + def __get__(self, instance: CmdOrSetT, owner: Any = ...) -> BoundCommandFunc: ... ##################################################################################################