From d5a0a1afe10d29c7256ce9a8e08a2fbed32241b7 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Tue, 25 Aug 2026 21:36:52 -0400 Subject: [PATCH 01/24] 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 02/24] 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 03/24] 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 04/24] 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 05/24] 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 06/24] 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: ... ################################################################################################## From 87bb1f2e6b3f365117327d88dc0c1c7bce8b9896 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 27 Aug 2026 19:03:06 -0400 Subject: [PATCH 07/24] Remove unecessary ... and slight bump of ruff version --- .pre-commit-config.yaml | 4 ++-- cmd2/types.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4326fd9f1..0510bb303 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.16.3" + rev: "v0.16.4" hooks: - id: ruff-format args: [--config=ruff.toml] @@ -30,7 +30,7 @@ repos: - prettier-plugin-toml@2.0.6 - repo: https://github.com/crate-ci/typos - rev: v1.49.0 + rev: v1.49.1 hooks: - id: typos exclude: | diff --git a/cmd2/types.py b/cmd2/types.py index 9931de0b6..c9a836949 100644 --- a/cmd2/types.py +++ b/cmd2/types.py @@ -89,7 +89,6 @@ class UnboundCommandFunc(Protocol[CmdOrSetT, P]): 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]": ... From 5ed78e9837f4a118f7761734399527403f53c534 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 27 Aug 2026 19:13:23 -0400 Subject: [PATCH 08/24] Make types in annotated.py fully consistent --- cmd2/annotated.py | 43 +++++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index 4d397c56d..da45c9ffd 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -290,6 +290,7 @@ def do_build(self, target: str, common: CommonArgs): TypeVar, Union, Unpack, + cast, get_args, get_origin, get_type_hints, @@ -2964,7 +2965,7 @@ def with_annotated( def with_annotated( - func: _CommandFunc | None = None, + func: Callable[_CommandParams, _CommandReturn] | None = None, *, ns_provider: Callable[..., argparse.Namespace] | None = None, preserve_quotes: bool = False, @@ -2982,7 +2983,7 @@ def with_annotated( subcommand_title: str | None = None, subcommand_description: str | None = None, **parser_kwargs: Unpack[Cmd2ParserKwargs], -) -> Callable[..., Any] | Callable[[Callable[..., Any]], Callable[..., Any]]: +) -> Callable[_CommandParams, _CommandReturn] | _WithAnnotatedDecorator: """Decorate a ``do_*`` method to build its argparse parser from type annotations. :param func: the command function (when used without parentheses) @@ -3043,23 +3044,27 @@ def with_annotated( subcommand_description=subcommand_description, ) - def decorator(fn: _CommandFunc) -> _CommandFunc: + def decorator(fn: Callable[_CommandParams, _CommandReturn]) -> Callable[_CommandParams, _CommandReturn]: + # ``with_annotated`` is publicly typed as signature-preserving. Its runtime contract is + # narrower: decorated functions must be cmd2 command methods. Keep that narrowing local + # to the parser-building machinery while retaining the callable's public signature. + command_func = cast(_CommandFunc, fn) if with_unknown_args: - unknown_param = inspect.signature(fn).parameters.get("_unknown") + unknown_param = inspect.signature(command_func).parameters.get("_unknown") if unknown_param is None: raise TypeError("with_annotated(with_unknown_args=True) requires a parameter named _unknown") if unknown_param.kind is inspect.Parameter.POSITIONAL_ONLY: raise TypeError("Parameter _unknown must be keyword-compatible when with_unknown_args=True") - if not base_command and constants.NS_ATTR_SUBCOMMAND_FUNC in inspect.signature(fn).parameters: + if not base_command and constants.NS_ATTR_SUBCOMMAND_FUNC in inspect.signature(command_func).parameters: raise TypeError( - f"Parameter '{constants.NS_ATTR_SUBCOMMAND_FUNC}' in {fn.__qualname__} " + f"Parameter '{constants.NS_ATTR_SUBCOMMAND_FUNC}' in {command_func.__qualname__} " "is only valid when with_annotated(base_command=True) is used." ) if subcommand_to is not None: handler, subcmd_name, subcmd_parser_builder = _build_subcommand_handler( - fn, + command_func, subcommand_to, base_command=base_command, options=options, @@ -3073,9 +3078,9 @@ def decorator(fn: _CommandFunc) -> _CommandFunc: parser_source=subcmd_parser_builder, ) setattr(handler, constants.SUBCOMMAND_ATTR_SPEC, subcommand_spec) - return handler + return cast(Callable[_CommandParams, _CommandReturn], handler) - command_name = fn.__name__[len(constants.COMMAND_FUNC_PREFIX) :] + command_name = command_func.__name__[len(constants.COMMAND_FUNC_PREFIX) :] skip_params = _SKIP_PARAMS | ({"_unknown"} if with_unknown_args else frozenset()) # Validate the group specs eagerly (decoration time) so a misconfigured group hard-fails when @@ -3084,16 +3089,18 @@ def decorator(fn: _CommandFunc) -> _CommandFunc: _validate_group_specs(options.groups, options.mutually_exclusive_groups) if base_command: # Validate eagerly (decoration time); the base-command rows in _CONSTRAINTS fire here. - _resolve_parameters(fn, skip_params=skip_params, base_command=True) + _resolve_parameters(command_func, skip_params=skip_params, base_command=True) # Cache signature introspection at decoration time, not per-invocation - accepted = set(list(inspect.signature(fn).parameters.keys())[1:]) - resolve_blocks = _lazy_block_resolver(fn, base_accepted=accepted, skip_params=skip_params) - leading_names, var_positional_name = _var_positional_call_plan(fn) + accepted = set(list(inspect.signature(command_func).parameters.keys())[1:]) + resolve_blocks = _lazy_block_resolver(command_func, base_accepted=accepted, skip_params=skip_params) + leading_names, var_positional_name = _var_positional_call_plan(command_func) - parser_builder = _make_parser_builder(fn, skip_params=skip_params, base_command=base_command, options=options) + parser_builder = _make_parser_builder( + command_func, skip_params=skip_params, base_command=base_command, options=options + ) - @functools.wraps(fn) + @functools.wraps(command_func) def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: cmd2_app, statement_arg = _parse_positionals(args) owner = args[0] # Cmd or CommandSet instance @@ -3138,7 +3145,7 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: func_kwargs.update(kwargs) _reconstruct_dataclass_blocks(func_kwargs, blocks, ns) result: bool | None = _invoke_command_func( - fn, owner, func_kwargs, leading_names=leading_names, var_positional_name=var_positional_name + command_func, owner, func_kwargs, leading_names=leading_names, var_positional_name=var_positional_name ) return result @@ -3148,9 +3155,9 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: ) setattr(cmd_wrapper, constants.ARGPARSE_COMMAND_ATTR_SPEC, argparse_command_spec) - return cmd_wrapper + return cast(Callable[_CommandParams, _CommandReturn], cmd_wrapper) # Support both @with_annotated and @with_annotated(...) if func is not None: return decorator(func) - return decorator + return cast(_WithAnnotatedDecorator, decorator) From 557a11e72d74dbb09569b5ad2da8e1f3041fe963 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 27 Aug 2026 19:21:28 -0400 Subject: [PATCH 09/24] Implemented protcol class in annotated.py to match needs and remove casts --- cmd2/annotated.py | 47 ++++++++++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index da45c9ffd..ad3e94ed0 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -284,13 +284,11 @@ def do_build(self, target: str, common: CommonArgs): NamedTuple, ParamSpec, Protocol, - TypeAlias, TypedDict, TypeGuard, TypeVar, Union, Unpack, - cast, get_args, get_origin, get_type_hints, @@ -311,11 +309,8 @@ 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, ) @@ -323,7 +318,18 @@ 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 _CommandFunc(Protocol): + """The command-function interface required by the annotation machinery. + + The parser builders only depend on a command's name metadata and its + boolean command result; they never depend on whether the function is + bound or unbound, nor on the types of its parsed arguments. + """ + + __name__: str + __qualname__: str + + def __call__(self, *args: Any, **kwargs: Any) -> bool | None: ... class Cmd2ParserKwargs(TypedDict, total=False): @@ -2928,17 +2934,22 @@ def handler(self_arg: Any, ns: Any) -> Any: _CommandParams = ParamSpec("_CommandParams") -_CommandReturn = TypeVar("_CommandReturn") + + +class _AnnotatedCommand(_CommandFunc, Protocol[_CommandParams]): + """A callable command method with the metadata used by ``with_annotated``.""" + + def __call__(self, *args: _CommandParams.args, **kwargs: _CommandParams.kwargs) -> bool | None: ... class _WithAnnotatedDecorator(Protocol): """The signature-preserving decorator ``with_annotated(...)`` returns (generic per call).""" - def __call__(self, fn: Callable[_CommandParams, _CommandReturn], /) -> Callable[_CommandParams, _CommandReturn]: ... + def __call__(self, fn: _AnnotatedCommand[_CommandParams], /) -> _AnnotatedCommand[_CommandParams]: ... @overload -def with_annotated(func: Callable[_CommandParams, _CommandReturn]) -> Callable[_CommandParams, _CommandReturn]: ... +def with_annotated(func: _AnnotatedCommand[_CommandParams], /) -> _AnnotatedCommand[_CommandParams]: ... @overload @@ -2965,7 +2976,7 @@ def with_annotated( def with_annotated( - func: Callable[_CommandParams, _CommandReturn] | None = None, + func: _AnnotatedCommand[_CommandParams] | None = None, *, ns_provider: Callable[..., argparse.Namespace] | None = None, preserve_quotes: bool = False, @@ -2983,7 +2994,7 @@ def with_annotated( subcommand_title: str | None = None, subcommand_description: str | None = None, **parser_kwargs: Unpack[Cmd2ParserKwargs], -) -> Callable[_CommandParams, _CommandReturn] | _WithAnnotatedDecorator: +) -> _AnnotatedCommand[_CommandParams] | _WithAnnotatedDecorator: """Decorate a ``do_*`` method to build its argparse parser from type annotations. :param func: the command function (when used without parentheses) @@ -3044,11 +3055,9 @@ def with_annotated( subcommand_description=subcommand_description, ) - def decorator(fn: Callable[_CommandParams, _CommandReturn]) -> Callable[_CommandParams, _CommandReturn]: - # ``with_annotated`` is publicly typed as signature-preserving. Its runtime contract is - # narrower: decorated functions must be cmd2 command methods. Keep that narrowing local - # to the parser-building machinery while retaining the callable's public signature. - command_func = cast(_CommandFunc, fn) + def decorator( + command_func: _AnnotatedCommand[_CommandParams], + ) -> _AnnotatedCommand[_CommandParams]: if with_unknown_args: unknown_param = inspect.signature(command_func).parameters.get("_unknown") if unknown_param is None: @@ -3078,7 +3087,7 @@ def decorator(fn: Callable[_CommandParams, _CommandReturn]) -> Callable[_Command parser_source=subcmd_parser_builder, ) setattr(handler, constants.SUBCOMMAND_ATTR_SPEC, subcommand_spec) - return cast(Callable[_CommandParams, _CommandReturn], handler) + return handler command_name = command_func.__name__[len(constants.COMMAND_FUNC_PREFIX) :] @@ -3155,9 +3164,9 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: ) setattr(cmd_wrapper, constants.ARGPARSE_COMMAND_ATTR_SPEC, argparse_command_spec) - return cast(Callable[_CommandParams, _CommandReturn], cmd_wrapper) + return cmd_wrapper # Support both @with_annotated and @with_annotated(...) if func is not None: return decorator(func) - return cast(_WithAnnotatedDecorator, decorator) + return decorator From 242e7688a1b5a106e55d462f80b15650cfed0899 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 27 Aug 2026 19:26:33 -0400 Subject: [PATCH 10/24] Realized that _CommandFunc and BoundCommandFunc had identical implementation Deleted _CommandFunc from annotated.py and replaced with BoundCommandFunc from types.py --- cmd2/annotated.py | 35 +++++++++++------------------------ 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index ad3e94ed0..c95e216a7 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -309,6 +309,7 @@ def do_build(self, target: str, common: CommonArgs): from .exceptions import Cmd2ArgparseError from .rich_utils import Cmd2HelpFormatter, HelpContent from .types import ( + BoundCommandFunc, CmdOrSetT, UnboundChoicesProvider, UnboundCompleter, @@ -318,20 +319,6 @@ def do_build(self, target: str, common: CommonArgs): _NargsValue = int | str | tuple[int] | tuple[int, int] | tuple[int, float] -class _CommandFunc(Protocol): - """The command-function interface required by the annotation machinery. - - The parser builders only depend on a command's name metadata and its - boolean command result; they never depend on whether the function is - bound or unbound, nor on the types of its parsed arguments. - """ - - __name__: str - __qualname__: str - - def __call__(self, *args: Any, **kwargs: Any) -> bool | None: ... - - class Cmd2ParserKwargs(TypedDict, total=False): """Forwarded ctor kwargs for [`Cmd2ArgumentParser`][cmd2.argparse_utils.Cmd2ArgumentParser] (PEP 692 ``Unpack``). @@ -2132,7 +2119,7 @@ def _link_mutex_group_membership( by_name[name].mutex_group_indices.append(index) -def _resolve_func_hints(func: _CommandFunc, *, skip_params: frozenset[str] = _SKIP_PARAMS) -> dict[str, Any]: +def _resolve_func_hints(func: BoundCommandFunc, *, 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 @@ -2310,7 +2297,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: _CommandFunc, *, skip_params: frozenset[str] = _SKIP_PARAMS) -> dict[str, _BlockSpec]: +def _dataclass_blocks(func: BoundCommandFunc, *, 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 @@ -2334,7 +2321,7 @@ def _dataclass_blocks(func: _CommandFunc, *, skip_params: frozenset[str] = _SKIP def _lazy_block_resolver( - func: _CommandFunc, + func: BoundCommandFunc, *, base_accepted: set[str], skip_params: frozenset[str], @@ -2391,7 +2378,7 @@ def _reconstruct_dataclass_blocks(func_kwargs: dict[str, Any], blocks: dict[str, def _resolve_parameters( - func: _CommandFunc, + func: BoundCommandFunc, *, skip_params: frozenset[str] = _SKIP_PARAMS, base_command: bool = False, @@ -2735,7 +2722,7 @@ def _docstring_first_paragraph(doc: str | None) -> str | None: def build_parser_from_function( - func: _CommandFunc, + func: BoundCommandFunc, *, skip_params: frozenset[str] = _SKIP_PARAMS, groups: tuple[Group, ...] | None = None, @@ -2810,7 +2797,7 @@ def build_parser_from_function( return parser -def _derive_subcommand_name(func: _CommandFunc, subcommand_to: str) -> str: +def _derive_subcommand_name(func: BoundCommandFunc, 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'``. @@ -2846,7 +2833,7 @@ class _ParserBuildOptions: def _make_parser_builder( - func: _CommandFunc, + func: BoundCommandFunc, *, skip_params: frozenset[str], base_command: bool, @@ -2885,12 +2872,12 @@ def parser_builder() -> Cmd2ArgumentParser: def _build_subcommand_handler( - func: _CommandFunc, + func: BoundCommandFunc, subcommand_to: str, *, base_command: bool = False, options: _ParserBuildOptions, -) -> tuple[_CommandFunc, str, Callable[[], Cmd2ArgumentParser]]: +) -> tuple[BoundCommandFunc, str, Callable[[], Cmd2ArgumentParser]]: """Build a subcommand's parser and a handler that unpacks the Namespace into typed kwargs. :param func: the subcommand handler function @@ -2936,7 +2923,7 @@ def handler(self_arg: Any, ns: Any) -> Any: _CommandParams = ParamSpec("_CommandParams") -class _AnnotatedCommand(_CommandFunc, Protocol[_CommandParams]): +class _AnnotatedCommand(BoundCommandFunc, Protocol[_CommandParams]): """A callable command method with the metadata used by ``with_annotated``.""" def __call__(self, *args: _CommandParams.args, **kwargs: _CommandParams.kwargs) -> bool | None: ... From 5163c2d8c5237bd171c58c215be6f54d064dd6ee Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 27 Aug 2026 20:16:03 -0400 Subject: [PATCH 11/24] Update CHANGELOG --- CHANGELOG.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8084508de..1a49295d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,6 @@ - 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) From c35de2bc04c8b5f9274ed69a1e8dce204cf422dd Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 27 Aug 2026 20:24:09 -0400 Subject: [PATCH 12/24] Define _CommandFunc to represent either a Bound or Ubound command function to make some types less restrictive --- cmd2/annotated.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index c95e216a7..8eff1dc76 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, @@ -312,12 +313,16 @@ def do_build(self, target: str, common: CommonArgs): BoundCommandFunc, CmdOrSetT, UnboundChoicesProvider, + UnboundCommandFunc, UnboundCompleter, ) #: ``nargs`` values accepted by cmd2's patched ``add_argument`` (incl. ranged tuples). _NargsValue = int | str | tuple[int] | tuple[int, int] | tuple[int, float] +# Parser construction works with a command method before or after descriptor binding. +_CommandFunc: TypeAlias = BoundCommandFunc | UnboundCommandFunc[Any, ...] + class Cmd2ParserKwargs(TypedDict, total=False): """Forwarded ctor kwargs for [`Cmd2ArgumentParser`][cmd2.argparse_utils.Cmd2ArgumentParser] (PEP 692 ``Unpack``). @@ -2119,7 +2124,7 @@ def _link_mutex_group_membership( by_name[name].mutex_group_indices.append(index) -def _resolve_func_hints(func: BoundCommandFunc, *, 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 @@ -2297,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: BoundCommandFunc, *, 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 @@ -2321,7 +2326,7 @@ def _dataclass_blocks(func: BoundCommandFunc, *, skip_params: frozenset[str] = _ def _lazy_block_resolver( - func: BoundCommandFunc, + func: _CommandFunc, *, base_accepted: set[str], skip_params: frozenset[str], @@ -2378,7 +2383,7 @@ def _reconstruct_dataclass_blocks(func_kwargs: dict[str, Any], blocks: dict[str, def _resolve_parameters( - func: BoundCommandFunc, + func: _CommandFunc, *, skip_params: frozenset[str] = _SKIP_PARAMS, base_command: bool = False, @@ -2722,7 +2727,7 @@ def _docstring_first_paragraph(doc: str | None) -> str | None: def build_parser_from_function( - func: BoundCommandFunc, + func: _CommandFunc, *, skip_params: frozenset[str] = _SKIP_PARAMS, groups: tuple[Group, ...] | None = None, @@ -2797,7 +2802,7 @@ def build_parser_from_function( return parser -def _derive_subcommand_name(func: BoundCommandFunc, 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'``. @@ -2833,7 +2838,7 @@ class _ParserBuildOptions: def _make_parser_builder( - func: BoundCommandFunc, + func: _CommandFunc, *, skip_params: frozenset[str], base_command: bool, @@ -2872,12 +2877,12 @@ def parser_builder() -> Cmd2ArgumentParser: def _build_subcommand_handler( - func: BoundCommandFunc, + func: _CommandFunc, subcommand_to: str, *, base_command: bool = False, options: _ParserBuildOptions, -) -> tuple[BoundCommandFunc, 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 @@ -2923,9 +2928,12 @@ def handler(self_arg: Any, ns: Any) -> Any: _CommandParams = ParamSpec("_CommandParams") -class _AnnotatedCommand(BoundCommandFunc, Protocol[_CommandParams]): +class _AnnotatedCommand(Protocol[_CommandParams]): """A callable command method with the metadata used by ``with_annotated``.""" + __name__: str + __qualname__: str + def __call__(self, *args: _CommandParams.args, **kwargs: _CommandParams.kwargs) -> bool | None: ... From c0b58916c98c8952619ef31c2cf7b5544bd994de Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 28 Aug 2026 18:11:15 -0400 Subject: [PATCH 13/24] Add mypy back in addition to ty for type checking and fix warnings It turns out that ty is better at some things and mypy is better at others when it comes to type checking. So ty is at least not yet a purely superior replacement for mypy. --- .github/workflows/typecheck.yml | 5 ++++- Makefile | 13 +++++++++++- cmd2/annotated.py | 2 +- cmd2/argparse_utils.py | 4 ++-- pyproject.toml | 37 ++++++++++++++++++++++++++++----- ruff.toml | 1 + 6 files changed, 52 insertions(+), 10 deletions(-) diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index f5e875fe8..2c2d393a5 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -31,5 +31,8 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Check typing + - name: Check typing using ty run: uv run ty check + + - name: Check typing using mypy + run: uv run mypy . diff --git a/Makefile b/Makefile index 01f28658d..546fd2c5c 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,8 @@ check: ## Run code quality tools. @uv run prek run -a @echo "🚀 Static type checking: Running ty" @uv run ty check + @echo "🚀 Static type checking: Running mypy" + @uv run mypy . .PHONY: format format: ## Perform ruff formatting @@ -29,8 +31,17 @@ format: ## Perform ruff formatting lint: ## Perform ruff linting @uv run ruff check --fix +.PHONY: mypy +mypy: ## Perform type checking using mypy + @uv run mypy . + +.PHONY: ty +ty: ## Perform type checking using ty + @uv run ty check + .PHONY: typecheck -typecheck: ## Perform type checking +typecheck: ## Perform type checking using both mypy and ty + @uv run mypy . @uv run ty check .PHONY: test diff --git a/cmd2/annotated.py b/cmd2/annotated.py index 8eff1dc76..abbdefb91 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -3052,7 +3052,7 @@ def with_annotated( def decorator( command_func: _AnnotatedCommand[_CommandParams], - ) -> _AnnotatedCommand[_CommandParams]: + ) -> _CommandFunc: if with_unknown_args: unknown_param = inspect.signature(command_func).parameters.get("_unknown") if unknown_param is None: diff --git a/cmd2/argparse_utils.py b/cmd2/argparse_utils.py index 7913fa440..3bad19c09 100644 --- a/cmd2/argparse_utils.py +++ b/cmd2/argparse_utils.py @@ -831,7 +831,7 @@ def _build_subparsers_prog_prefix(self, positionals: list[argparse.Action]) -> s temp_parser = Cmd2ArgumentParser( prog=self.prog, usage=None, - formatter_class=cast(type[Cmd2HelpFormatter], self.formatter_class), + formatter_class=cast(type[Cmd2HelpFormatter], self.formatter_class), # type: ignore[redundant-cast] add_help=False, ) @@ -1037,7 +1037,7 @@ def error(self, message: str) -> NoReturn: def _get_formatter(self, *_args: Any, **_kwargs: Any) -> Cmd2HelpFormatter: """Override with customizations for Cmd2HelpFormatter.""" - formatter_class = cast(type[Cmd2HelpFormatter], self.formatter_class) + formatter_class = cast(type[Cmd2HelpFormatter], self.formatter_class) # type: ignore[redundant-cast] return formatter_class(prog=self.prog, file=self._thread_locals.current_output_file) def format_help(self, *args: Any, **kwargs: Any) -> str: diff --git a/pyproject.toml b/pyproject.toml index 840670685..e125f0c9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,20 +40,21 @@ dev = [ "codecov>=2.1", "ipython>=8.23", "mkdocstrings[python]>=1", + "mypy>=2.3.1", "prek>=0.3.5", "pytest>=8.1.1", "pytest-cov>=5", "pytest-mock>=3.14.1", - "ruff>=0.14.10", - "ty>=0.0.73", + "ruff>=0.16.0", + "ty>=0.0.75", "uv-publish>=1.3", - "zensical>=0.0.17", + "zensical>=0.0.57", ] docs = [ "mkdocstrings[python]>=1", "setuptools>=80.8.0", "setuptools_scm>=8", - "zensical>=0.0.44", + "zensical>=0.0.57", ] quality = ["prek>=0.3.5"] test = [ @@ -63,7 +64,33 @@ test = [ "pytest-cov>=5", "pytest-mock>=3.14.1", ] -validate = ["ruff>=0.14.10", "ty>=0.0.73", "types-setuptools>=80.8.0"] +validate = ["mypy>=2.3.1", "ruff>=0.16.0", "ty>=0.0.75", "types-setuptools>=80.8.0"] + +[tool.mypy] +disallow_incomplete_defs = true +disallow_untyped_calls = true +disallow_untyped_defs = true +exclude = [ + "^.git/", + "^.venv/", + "^build/", # .build directory + "^docs/", # docs directory + "^dist/", + "^examples/", # examples directory + "^noxfile\\.py$", # nox config file + "setup\\.py$", # any files named setup.py + "^site/", + "^tests/", # tests directory +] +files = ['.'] +show_column_numbers = true +show_error_codes = true +show_error_context = true +strict = true +warn_redundant_casts = true +warn_return_any = true +warn_unreachable = true +warn_unused_ignores = false [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/ruff.toml b/ruff.toml index ca97d1ce7..fbbf32d54 100644 --- a/ruff.toml +++ b/ruff.toml @@ -7,6 +7,7 @@ exclude = [ ".git-rewrite", ".hg", ".ipynb_checkpoints", + ".mypy_cache", ".nox", ".pants.d", ".pyenv", From 976f47da270fadf0cb9f5ef2696a6912aa9bb801 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 28 Aug 2026 18:16:16 -0400 Subject: [PATCH 14/24] Copy GEMINI.md to AGENTS.md Most modern agent harness CLIs like codex, agy, opencode, hemes, pi, oh-my-pi, and tau all read AGENTS.md by default. It has emerged as the single file used by most agentic AI tools. Leaving GEMINI.md in place for now since Gemini CLI doesn't read AGENTS.md by default. --- AGENTS.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..e93be474d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,40 @@ +# Instructions for Gemini CLI in a `uv` Python project + +This `GEMINI.md` file provides context and instructions for the Gemini CLI when working with this +Python project, which utilizes `uv` for environment and package management. + +## General Instructions + +- **Environment Management:** Prefer using `uv` for all Python environment management tasks. +- **Package Installation:** Always use `uv` to install packages and ensure they are installed within + the project's virtual environment. +- **Running Scripts/Commands:** + - To run Python scripts within the project's virtual environment, use `uv run ...`. + - To run programs directly from a PyPI package (installing it on the fly if necessary), use + `uvx ...` (shortcut for `uv tool run`). +- **New Dependencies:** If a new dependency is required, please state the reason for its inclusion. +- Do not commit spec, plan, or markdown documents to git without asking first. +- Save plans to `~/.superpowers/plans/` instead of the project directory. + +## Python Code Standards + +To ensure Python code adheres to required standards, the following commands **must** be run before +creating or modifying any `.py` files: + +```bash +make check +``` + +To run unit tests use the following command: + +```bash +make test +``` + +To make sure the documentation builds properly, use the following command: + +```bash +make docs-test +``` + +All 3 of the above commands should be run prior to committing code. From 383c04e51a2b6a38be50ed024953ceaeb5b266fa Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 28 Aug 2026 18:21:01 -0400 Subject: [PATCH 15/24] Fix types in annotated.py so both mypy and ty pass --- cmd2/annotated.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index abbdefb91..60555d0ed 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -290,6 +290,7 @@ def do_build(self, target: str, common: CommonArgs): TypeVar, Union, Unpack, + cast, get_args, get_origin, get_type_hints, @@ -3052,7 +3053,7 @@ def with_annotated( def decorator( command_func: _AnnotatedCommand[_CommandParams], - ) -> _CommandFunc: + ) -> _AnnotatedCommand[_CommandParams]: if with_unknown_args: unknown_param = inspect.signature(command_func).parameters.get("_unknown") if unknown_param is None: @@ -3082,7 +3083,7 @@ def decorator( parser_source=subcmd_parser_builder, ) setattr(handler, constants.SUBCOMMAND_ATTR_SPEC, subcommand_spec) - return handler + return cast(_AnnotatedCommand[_CommandParams], handler) command_name = command_func.__name__[len(constants.COMMAND_FUNC_PREFIX) :] @@ -3164,4 +3165,4 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: # Support both @with_annotated and @with_annotated(...) if func is not None: return decorator(func) - return decorator + return cast(_WithAnnotatedDecorator, decorator) From 11bc20c00d7be91c308d7c918e4312ac8730d595 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 28 Aug 2026 18:33:59 -0400 Subject: [PATCH 16/24] Updated AGENTS.ai so it doesn't refer to Gemini --- AGENTS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e93be474d..56d6998bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ -# Instructions for Gemini CLI in a `uv` Python project +# Instructions for AI Agents -This `GEMINI.md` file provides context and instructions for the Gemini CLI when working with this -Python project, which utilizes `uv` for environment and package management. +This file provides context and instructions for the agentic AI tools when working with this Python +project, which utilizes `uv` for environment and package management. ## General Instructions From 89b98048acace459f1d0d1d74bd5ab17fd853a8f Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 28 Aug 2026 19:26:53 -0400 Subject: [PATCH 17/24] Fix some type annotations so that cast is no longer needed in annotated.py --- cmd2/annotated.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index 60555d0ed..0ec1db27c 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -290,7 +290,6 @@ def do_build(self, target: str, common: CommonArgs): TypeVar, Union, Unpack, - cast, get_args, get_origin, get_type_hints, @@ -2878,12 +2877,12 @@ def parser_builder() -> Cmd2ArgumentParser: def _build_subcommand_handler( - func: _CommandFunc, + func: "_AnnotatedCommand[_CommandParams]", subcommand_to: str, *, base_command: bool = False, options: _ParserBuildOptions, -) -> tuple[_CommandFunc, str, Callable[[], Cmd2ArgumentParser]]: +) -> tuple["_AnnotatedCommand[_CommandParams]", str, Callable[[], Cmd2ArgumentParser]]: """Build a subcommand's parser and a handler that unpacks the Namespace into typed kwargs. :param func: the subcommand handler function @@ -2909,8 +2908,13 @@ def _build_subcommand_handler( _leading_names, _var_positional_name = _var_positional_call_plan(func) @functools.wraps(func) - def handler(self_arg: Any, ns: Any) -> Any: + def handler(*args: _CommandParams.args, **kwargs: _CommandParams.kwargs) -> bool | None: """Unpack Namespace into typed kwargs for the subcommand handler.""" + if kwargs: + raise TypeError("subcommand handlers do not accept keyword arguments") + self_arg, ns = args + if not isinstance(ns, argparse.Namespace): + raise TypeError("subcommand handlers require a parsed argparse.Namespace") _blocks, _eff_accepted = _resolve_blocks() filtered = _filtered_namespace_kwargs(ns, accepted=_eff_accepted) if constants.NS_ATTR_SUBCOMMAND_FUNC in filtered: @@ -2918,9 +2922,12 @@ def handler(self_arg: Any, ns: Any) -> Any: if isinstance(cmd2_h, functools.partial) and getattr(cmd2_h.func, "__func__", cmd2_h.func) is handler: filtered[constants.NS_ATTR_SUBCOMMAND_FUNC] = None _reconstruct_dataclass_blocks(filtered, _blocks, ns) - return _invoke_command_func( + result = _invoke_command_func( func, self_arg, filtered, leading_names=_leading_names, var_positional_name=_var_positional_name ) + if result is None or isinstance(result, bool): + return result + raise TypeError("annotated command functions must return bool or None") parser_builder = _make_parser_builder(func, skip_params=_SKIP_PARAMS, base_command=base_command, options=options) return handler, subcmd_name, parser_builder @@ -2972,7 +2979,7 @@ def with_annotated( def with_annotated( - func: _AnnotatedCommand[_CommandParams] | None = None, + func: _CommandFunc | None = None, *, ns_provider: Callable[..., argparse.Namespace] | None = None, preserve_quotes: bool = False, @@ -2990,7 +2997,7 @@ def with_annotated( subcommand_title: str | None = None, subcommand_description: str | None = None, **parser_kwargs: Unpack[Cmd2ParserKwargs], -) -> _AnnotatedCommand[_CommandParams] | _WithAnnotatedDecorator: +) -> Any: """Decorate a ``do_*`` method to build its argparse parser from type annotations. :param func: the command function (when used without parentheses) @@ -3083,7 +3090,7 @@ def decorator( parser_source=subcmd_parser_builder, ) setattr(handler, constants.SUBCOMMAND_ATTR_SPEC, subcommand_spec) - return cast(_AnnotatedCommand[_CommandParams], handler) + return handler command_name = command_func.__name__[len(constants.COMMAND_FUNC_PREFIX) :] @@ -3165,4 +3172,4 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: # Support both @with_annotated and @with_annotated(...) if func is not None: return decorator(func) - return cast(_WithAnnotatedDecorator, decorator) + return decorator From 53ff2edb72cba09920104a97c6d8f015d726e94c Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 28 Aug 2026 19:36:31 -0400 Subject: [PATCH 18/24] Fixed some types so an Any return type wasn't required --- cmd2/annotated.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index 0ec1db27c..a79655d13 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -2934,6 +2934,7 @@ def handler(*args: _CommandParams.args, **kwargs: _CommandParams.kwargs) -> bool _CommandParams = ParamSpec("_CommandParams") +_DecoratorParams = ParamSpec("_DecoratorParams") class _AnnotatedCommand(Protocol[_CommandParams]): @@ -2979,7 +2980,7 @@ def with_annotated( def with_annotated( - func: _CommandFunc | None = None, + func: _AnnotatedCommand[_CommandParams] | None = None, *, ns_provider: Callable[..., argparse.Namespace] | None = None, preserve_quotes: bool = False, @@ -2997,7 +2998,7 @@ def with_annotated( subcommand_title: str | None = None, subcommand_description: str | None = None, **parser_kwargs: Unpack[Cmd2ParserKwargs], -) -> Any: +) -> _AnnotatedCommand[_CommandParams] | _WithAnnotatedDecorator: """Decorate a ``do_*`` method to build its argparse parser from type annotations. :param func: the command function (when used without parentheses) @@ -3059,8 +3060,8 @@ def with_annotated( ) def decorator( - command_func: _AnnotatedCommand[_CommandParams], - ) -> _AnnotatedCommand[_CommandParams]: + command_func: _AnnotatedCommand[_DecoratorParams], + ) -> _AnnotatedCommand[_DecoratorParams]: if with_unknown_args: unknown_param = inspect.signature(command_func).parameters.get("_unknown") if unknown_param is None: From 9699b2bd52f1e605dbeb744a5176a81429527ef2 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 28 Aug 2026 20:51:42 -0400 Subject: [PATCH 19/24] Added tests to cover the missing lines in annotated.py --- tests/test_annotated.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/test_annotated.py b/tests/test_annotated.py index 1829147f8..f7db3571c 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -2307,6 +2307,41 @@ def test_command_set_completion(self, cmdset_app) -> None: # --------------------------------------------------------------------------- +def test_subcommand_handler_rejects_keyword_arguments() -> None: + """The internal subcommand adapter only accepts the owner and parsed namespace positionally.""" + + @with_annotated(subcommand_to="root") + def root_child(self) -> None: + pass + + with pytest.raises(TypeError, match="do not accept keyword arguments"): + root_child(object(), argparse.Namespace(), unexpected=True) + + +def test_subcommand_handler_requires_namespace() -> None: + """The internal subcommand adapter rejects calls without a parsed namespace.""" + + @with_annotated(subcommand_to="root") + def root_child(self) -> None: + pass + + with pytest.raises(TypeError, match=r"parsed argparse.Namespace"): + root_child(object(), object()) + + +def test_subcommand_handler_rejects_invalid_return_value() -> None: + """The internal subcommand adapter enforces the command return contract.""" + + invalid_result: Any = object() + + @with_annotated(subcommand_to="root") + def root_child(self) -> bool | None: + return invalid_result + + with pytest.raises(TypeError, match="must return bool or None"): + root_child(object(), argparse.Namespace()) + + class _IntegrationApp(cmd2.Cmd): def __init__(self) -> None: super().__init__() From 77cd8ee999e70a6bb1bcce141c2935d48f94b9d2 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sat, 29 Aug 2026 13:00:12 -0400 Subject: [PATCH 20/24] Removed a couple cast() calls which were no longer needed --- cmd2/cmd2.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 4453411bd..f0c7cd211 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -5950,7 +5950,7 @@ def register_postloop_hook(self, func: Callable[[], None]) -> None: @classmethod def _validate_postparsing_callable(cls, func: Callable[[plugin.PostparsingData], plugin.PostparsingData]) -> None: """Check parameter and return types for postparsing hooks.""" - cls._validate_callable_param_count(cast(Callable[..., Any], func), 1) + cls._validate_callable_param_count(func, 1) type_hints, ret_ann = get_types(func) func_name = getattr(func, "__name__", "hook") if not type_hints: @@ -5974,7 +5974,7 @@ def _validate_prepostcmd_hook( ) -> None: """Check parameter and return types for pre and post command hooks.""" # validate that the callable has the right number of parameters - cls._validate_callable_param_count(cast(Callable[..., Any], func), 1) + cls._validate_callable_param_count(func, 1) type_hints, ret_ann = get_types(func) func_name = getattr(func, "__name__", "hook") From 705f2ba1c6aaca29da2db4855d704619319fa7fc Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sat, 29 Aug 2026 13:55:25 -0400 Subject: [PATCH 21/24] Re-ordered some code so that quotes are not needed around type annotations --- cmd2/annotated.py | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index a79655d13..561f3f105 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -2876,13 +2876,32 @@ def parser_builder() -> Cmd2ArgumentParser: return parser_builder +_CommandParams = ParamSpec("_CommandParams") +_DecoratorParams = ParamSpec("_DecoratorParams") + + +class _AnnotatedCommand(Protocol[_CommandParams]): + """A callable command method with the metadata used by ``with_annotated``.""" + + __name__: str + __qualname__: str + + def __call__(self, *args: _CommandParams.args, **kwargs: _CommandParams.kwargs) -> bool | None: ... + + +class _WithAnnotatedDecorator(Protocol): + """The signature-preserving decorator ``with_annotated(...)`` returns (generic per call).""" + + def __call__(self, fn: _AnnotatedCommand[_CommandParams], /) -> _AnnotatedCommand[_CommandParams]: ... + + def _build_subcommand_handler( - func: "_AnnotatedCommand[_CommandParams]", + func: _AnnotatedCommand[_CommandParams], subcommand_to: str, *, base_command: bool = False, options: _ParserBuildOptions, -) -> tuple["_AnnotatedCommand[_CommandParams]", str, Callable[[], Cmd2ArgumentParser]]: +) -> tuple[_AnnotatedCommand[_CommandParams], str, Callable[[], Cmd2ArgumentParser]]: """Build a subcommand's parser and a handler that unpacks the Namespace into typed kwargs. :param func: the subcommand handler function @@ -2933,25 +2952,6 @@ def handler(*args: _CommandParams.args, **kwargs: _CommandParams.kwargs) -> bool return handler, subcmd_name, parser_builder -_CommandParams = ParamSpec("_CommandParams") -_DecoratorParams = ParamSpec("_DecoratorParams") - - -class _AnnotatedCommand(Protocol[_CommandParams]): - """A callable command method with the metadata used by ``with_annotated``.""" - - __name__: str - __qualname__: str - - def __call__(self, *args: _CommandParams.args, **kwargs: _CommandParams.kwargs) -> bool | None: ... - - -class _WithAnnotatedDecorator(Protocol): - """The signature-preserving decorator ``with_annotated(...)`` returns (generic per call).""" - - def __call__(self, fn: _AnnotatedCommand[_CommandParams], /) -> _AnnotatedCommand[_CommandParams]: ... - - @overload def with_annotated(func: _AnnotatedCommand[_CommandParams], /) -> _AnnotatedCommand[_CommandParams]: ... From de4ba20017f99719dde3eb9c3242eeae3b7beaec Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sat, 29 Aug 2026 14:07:39 -0400 Subject: [PATCH 22/24] Add a missing check and associated unit test Also: - Reword a couple TypeError messages which read awkwardly --- cmd2/annotated.py | 2 ++ cmd2/cmd2.py | 4 ++-- tests/test_annotated.py | 14 ++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index 561f3f105..6bd3198d2 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -2931,6 +2931,8 @@ def handler(*args: _CommandParams.args, **kwargs: _CommandParams.kwargs) -> bool """Unpack Namespace into typed kwargs for the subcommand handler.""" if kwargs: raise TypeError("subcommand handlers do not accept keyword arguments") + if len(args) != 2: + raise TypeError(f"subcommand handlers require 2 positional arguments (self, namespace), got {len(args)}") self_arg, ns = args if not isinstance(ns, argparse.Namespace): raise TypeError("subcommand handlers require a parsed argparse.Namespace") diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index f0c7cd211..f8da50595 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -5959,7 +5959,7 @@ def _validate_postparsing_callable(cls, func: Callable[[plugin.PostparsingData], if par_ann != 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 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.""" @@ -6016,7 +6016,7 @@ def _validate_cmdfinalization_callable( 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 a return type of {plugin.CommandFinalizationData}") def register_cmdfinalization_hook( self, func: Callable[[plugin.CommandFinalizationData], plugin.CommandFinalizationData] diff --git a/tests/test_annotated.py b/tests/test_annotated.py index f7db3571c..daf206b2c 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -2318,6 +2318,20 @@ def root_child(self) -> None: root_child(object(), argparse.Namespace(), unexpected=True) +def test_subcommand_handler_rejects_wrong_positional_argument_count() -> None: + """The internal subcommand adapter requires exactly two positional arguments (self, namespace).""" + + @with_annotated(subcommand_to="root") + def root_child(self) -> None: + pass + + with pytest.raises(TypeError, match=r"require 2 positional arguments"): + root_child(object()) + + with pytest.raises(TypeError, match=r"require 2 positional arguments"): + root_child(object(), argparse.Namespace(), "extra") + + def test_subcommand_handler_requires_namespace() -> None: """The internal subcommand adapter rejects calls without a parsed namespace.""" From e7dac066d5e41d02fbeaacf3364d0d464ec15c5d Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sat, 29 Aug 2026 17:35:19 -0400 Subject: [PATCH 23/24] Ruggedize a few types --- cmd2/annotated.py | 41 +++++++++++++++++++++++++---------------- cmd2/cmd2.py | 30 ++++++++++++++++-------------- cmd2/types.py | 6 +++--- 3 files changed, 44 insertions(+), 33 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index 6bd3198d2..f869beef9 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -290,6 +290,7 @@ def do_build(self, target: str, common: CommonArgs): TypeVar, Union, Unpack, + cast, get_args, get_origin, get_type_hints, @@ -321,7 +322,7 @@ def do_build(self, target: str, common: CommonArgs): _NargsValue = int | str | tuple[int] | tuple[int, int] | tuple[int, float] # Parser construction works with a command method before or after descriptor binding. -_CommandFunc: TypeAlias = BoundCommandFunc | UnboundCommandFunc[Any, ...] +_CommandFunc: TypeAlias = BoundCommandFunc[...] | UnboundCommandFunc[Any, ...] class Cmd2ParserKwargs(TypedDict, total=False): @@ -2880,28 +2881,36 @@ def parser_builder() -> Cmd2ArgumentParser: _DecoratorParams = ParamSpec("_DecoratorParams") -class _AnnotatedCommand(Protocol[_CommandParams]): +class _AnnotatedCommand(Protocol[CmdOrSetT, _CommandParams]): """A callable command method with the metadata used by ``with_annotated``.""" __name__: str __qualname__: str - def __call__(self, *args: _CommandParams.args, **kwargs: _CommandParams.kwargs) -> bool | None: ... + def __call__(self, __self: CmdOrSetT, /, *args: _CommandParams.args, **kwargs: _CommandParams.kwargs) -> bool | None: ... + + @overload + def __get__(self, instance: None, owner: Any = ...) -> "_AnnotatedCommand[CmdOrSetT, _CommandParams]": ... + + @overload + def __get__(self, instance: CmdOrSetT, owner: Any = ...) -> BoundCommandFunc[_CommandParams]: ... class _WithAnnotatedDecorator(Protocol): """The signature-preserving decorator ``with_annotated(...)`` returns (generic per call).""" - def __call__(self, fn: _AnnotatedCommand[_CommandParams], /) -> _AnnotatedCommand[_CommandParams]: ... + def __call__( + self, fn: _AnnotatedCommand[CmdOrSetT, _CommandParams], / + ) -> _AnnotatedCommand[CmdOrSetT, _CommandParams]: ... def _build_subcommand_handler( - func: _AnnotatedCommand[_CommandParams], + func: _AnnotatedCommand[Any, ...], subcommand_to: str, *, base_command: bool = False, options: _ParserBuildOptions, -) -> tuple[_AnnotatedCommand[_CommandParams], str, Callable[[], Cmd2ArgumentParser]]: +) -> tuple[_AnnotatedCommand[Any, ...], str, Callable[[], Cmd2ArgumentParser]]: """Build a subcommand's parser and a handler that unpacks the Namespace into typed kwargs. :param func: the subcommand handler function @@ -2927,7 +2936,7 @@ def _build_subcommand_handler( _leading_names, _var_positional_name = _var_positional_call_plan(func) @functools.wraps(func) - def handler(*args: _CommandParams.args, **kwargs: _CommandParams.kwargs) -> bool | None: + def handler(*args: Any, **kwargs: Any) -> bool | None: """Unpack Namespace into typed kwargs for the subcommand handler.""" if kwargs: raise TypeError("subcommand handlers do not accept keyword arguments") @@ -2951,11 +2960,11 @@ def handler(*args: _CommandParams.args, **kwargs: _CommandParams.kwargs) -> bool raise TypeError("annotated command functions must return bool or None") parser_builder = _make_parser_builder(func, skip_params=_SKIP_PARAMS, base_command=base_command, options=options) - return handler, subcmd_name, parser_builder + return cast(_AnnotatedCommand[Any, ...], handler), subcmd_name, parser_builder @overload -def with_annotated(func: _AnnotatedCommand[_CommandParams], /) -> _AnnotatedCommand[_CommandParams]: ... +def with_annotated(func: _AnnotatedCommand[CmdOrSetT, _CommandParams], /) -> _AnnotatedCommand[CmdOrSetT, _CommandParams]: ... @overload @@ -2982,7 +2991,7 @@ def with_annotated( def with_annotated( - func: _AnnotatedCommand[_CommandParams] | None = None, + func: _AnnotatedCommand[CmdOrSetT, _CommandParams] | None = None, *, ns_provider: Callable[..., argparse.Namespace] | None = None, preserve_quotes: bool = False, @@ -3000,7 +3009,7 @@ def with_annotated( subcommand_title: str | None = None, subcommand_description: str | None = None, **parser_kwargs: Unpack[Cmd2ParserKwargs], -) -> _AnnotatedCommand[_CommandParams] | _WithAnnotatedDecorator: +) -> _AnnotatedCommand[CmdOrSetT, _CommandParams] | _WithAnnotatedDecorator: """Decorate a ``do_*`` method to build its argparse parser from type annotations. :param func: the command function (when used without parentheses) @@ -3062,8 +3071,8 @@ def with_annotated( ) def decorator( - command_func: _AnnotatedCommand[_DecoratorParams], - ) -> _AnnotatedCommand[_DecoratorParams]: + command_func: _AnnotatedCommand[CmdOrSetT, _DecoratorParams], + ) -> _AnnotatedCommand[CmdOrSetT, _DecoratorParams]: if with_unknown_args: unknown_param = inspect.signature(command_func).parameters.get("_unknown") if unknown_param is None: @@ -3093,7 +3102,7 @@ def decorator( parser_source=subcmd_parser_builder, ) setattr(handler, constants.SUBCOMMAND_ATTR_SPEC, subcommand_spec) - return handler + return cast(_AnnotatedCommand[CmdOrSetT, _DecoratorParams], handler) command_name = command_func.__name__[len(constants.COMMAND_FUNC_PREFIX) :] @@ -3170,9 +3179,9 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: ) setattr(cmd_wrapper, constants.ARGPARSE_COMMAND_ATTR_SPEC, argparse_command_spec) - return cmd_wrapper + return cast(_AnnotatedCommand[CmdOrSetT, _DecoratorParams], cmd_wrapper) # Support both @with_annotated and @with_annotated(...) if func is not None: return decorator(func) - return decorator + return cast(_WithAnnotatedDecorator, decorator) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index f8da50595..5ceffb085 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -228,7 +228,7 @@ class DisabledCommand(NamedTuple): This data is used to restore its functions when the command is enabled. """ - command_func: BoundCommandFunc + command_func: BoundCommandFunc[...] help_func: Callable[[], Any] | None completer_func: BoundCompleter | None @@ -251,14 +251,14 @@ def __init__(self, cmd_app: "Cmd") -> None: self._parsers: dict[str, Cmd2ArgumentParser] = {} @staticmethod - def _fully_qualified_name(command_method: BoundCommandFunc) -> 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: BoundCommandFunc) -> 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. @@ -267,7 +267,7 @@ def __contains__(self, command_method: BoundCommandFunc) -> bool: parser = self.get(command_method) return bool(parser) - def get(self, command_method: BoundCommandFunc) -> 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. @@ -300,7 +300,7 @@ def get(self, command_method: BoundCommandFunc) -> Cmd2ArgumentParser | None: return self._parsers.get(full_method_name) - def remove(self, command_method: BoundCommandFunc) -> 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: @@ -919,7 +919,7 @@ def register_command_set(self, cmdset: CommandSet[Any]) -> None: cmdset.on_register(self) methods = cast( - list[tuple[str, BoundCommandFunc]], + list[tuple[str, BoundCommandFunc[...]]], inspect.getmembers( cmdset, predicate=lambda meth: ( # type: ignore[arg-type] @@ -1021,7 +1021,9 @@ def _build_parser( return parser - def _install_command_function(self, command_func_name: str, command_method: BoundCommandFunc, 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 @@ -1086,8 +1088,8 @@ def unregister_command_set(self, cmdset: CommandSet[Any]) -> None: cmdset.on_unregister() self._unregister_subcommands(cmdset) - methods: list[tuple[str, BoundCommandFunc]] = cast( - list[tuple[str, BoundCommandFunc]], + methods: list[tuple[str, BoundCommandFunc[...]]] = cast( + list[tuple[str, BoundCommandFunc[...]]], inspect.getmembers( cmdset, predicate=lambda meth: ( # type: ignore[arg-type] @@ -1161,8 +1163,8 @@ def check_parser_uninstallable(parser: Cmd2ArgumentParser) -> None: ) check_parser_uninstallable(subparser) - methods: list[tuple[str, BoundCommandFunc]] = cast( - list[tuple[str, BoundCommandFunc]], + methods: list[tuple[str, BoundCommandFunc[...]]] = cast( + list[tuple[str, BoundCommandFunc[...]]], inspect.getmembers( cmdset, predicate=lambda meth: ( # type: ignore[arg-type] @@ -3413,7 +3415,7 @@ def _restore_output(self, statement: Statement, saved_redir_state: utils.Redirec self._cur_pipe_proc_reader = saved_redir_state.saved_pipe_proc_reader self._redirecting = saved_redir_state.saved_redirecting - def get_command_func(self, command: str) -> BoundCommandFunc | None: + def get_command_func(self, command: str) -> BoundCommandFunc[...] | None: """Get the bound command function for a command. :param command: the name of the command @@ -3421,9 +3423,9 @@ def get_command_func(self, command: str) -> BoundCommandFunc | None: """ command_func_name = constants.COMMAND_FUNC_PREFIX + command command_func = getattr(self, command_func_name, None) - return cast(BoundCommandFunc, command_func) if callable(command_func) else None + return cast(BoundCommandFunc[...], command_func) if callable(command_func) else None - def _get_command_category(self, func: BoundCommandFunc) -> str: + def _get_command_category(self, func: BoundCommandFunc[...]) -> str: """Determine the category for a command. :param func: the do_* function implementing the command diff --git a/cmd2/types.py b/cmd2/types.py index c9a836949..4a06f2e1f 100644 --- a/cmd2/types.py +++ b/cmd2/types.py @@ -69,13 +69,13 @@ # A bound cmd2 command function (e.g. do_command). # The 'self' argument is already tied to an instance and is omitted. -class BoundCommandFunc(Protocol): +class BoundCommandFunc(Protocol[P]): """Protocol for a command function bound to a command instance.""" __name__: str __qualname__: str - def __call__(self, *args: Any, **kwargs: Any) -> bool | None: + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> bool | None: """Invoke the bound command function.""" @@ -94,7 +94,7 @@ def __call__(self, __self: CmdOrSetT, /, *args: P.args, **kwargs: P.kwargs) -> b 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[P]: ... ################################################################################################## From a9fa36a119bc16d523b53856b765279ec9eee1e9 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sat, 29 Aug 2026 18:05:13 -0400 Subject: [PATCH 24/24] Make __name__ and __qualname__ read-only properties in protocol classes --- cmd2/annotated.py | 87 +++++++++++++++++++++++++++-------------- cmd2/types.py | 11 +++++- tests/test_annotated.py | 28 +++++++++---- 3 files changed, 87 insertions(+), 39 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index f869beef9..d4b9901f2 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -277,6 +277,7 @@ def do_build(self, target: str, common: CommonArgs): ) from pathlib import Path from typing import ( + TYPE_CHECKING, Annotated, Any, ClassVar, @@ -284,7 +285,6 @@ def do_build(self, target: str, common: CommonArgs): NamedTuple, ParamSpec, Protocol, - TypeAlias, TypedDict, TypeGuard, TypeVar, @@ -314,15 +314,25 @@ def do_build(self, target: str, common: CommonArgs): BoundCommandFunc, CmdOrSetT, UnboundChoicesProvider, - UnboundCommandFunc, UnboundCompleter, ) #: ``nargs`` values accepted by cmd2's patched ``add_argument`` (incl. ranged tuples). _NargsValue = int | str | tuple[int] | tuple[int, int] | tuple[int, float] -# Parser construction works with a command method before or after descriptor binding. -_CommandFunc: TypeAlias = BoundCommandFunc[...] | UnboundCommandFunc[Any, ...] + +class _CommandFunc(Protocol): + """Callable metadata needed to inspect a command before or after descriptor binding.""" + + if TYPE_CHECKING: + + @property + def __name__(self) -> str: ... + + @property + def __qualname__(self) -> str: ... + + def __call__(self, *args: Any, **kwargs: Any) -> Any: ... class Cmd2ParserKwargs(TypedDict, total=False): @@ -2879,38 +2889,58 @@ def parser_builder() -> Cmd2ArgumentParser: _CommandParams = ParamSpec("_CommandParams") _DecoratorParams = ParamSpec("_DecoratorParams") +_CommandReturn = TypeVar("_CommandReturn") +_CommandReturn_co = TypeVar("_CommandReturn_co", covariant=True) + + +class _BoundAnnotatedCommand(Protocol[_CommandParams, _CommandReturn_co]): + """A bound annotated command whose complete call signature is preserved.""" + if TYPE_CHECKING: -class _AnnotatedCommand(Protocol[CmdOrSetT, _CommandParams]): + @property + def __name__(self) -> str: ... + + @property + def __qualname__(self) -> str: ... + + def __call__(self, *args: _CommandParams.args, **kwargs: _CommandParams.kwargs) -> _CommandReturn_co: ... + + +class _AnnotatedCommand(Protocol[CmdOrSetT, _CommandParams, _CommandReturn_co]): """A callable command method with the metadata used by ``with_annotated``.""" __name__: str __qualname__: str - def __call__(self, __self: CmdOrSetT, /, *args: _CommandParams.args, **kwargs: _CommandParams.kwargs) -> bool | None: ... + def __call__( + self, __self: CmdOrSetT, /, *args: _CommandParams.args, **kwargs: _CommandParams.kwargs + ) -> _CommandReturn_co: ... @overload - def __get__(self, instance: None, owner: Any = ...) -> "_AnnotatedCommand[CmdOrSetT, _CommandParams]": ... + def __get__( + self, instance: None, owner: Any = ... + ) -> "_AnnotatedCommand[CmdOrSetT, _CommandParams, _CommandReturn_co]": ... @overload - def __get__(self, instance: CmdOrSetT, owner: Any = ...) -> BoundCommandFunc[_CommandParams]: ... + def __get__(self, instance: CmdOrSetT, owner: Any = ...) -> _BoundAnnotatedCommand[_CommandParams, _CommandReturn_co]: ... class _WithAnnotatedDecorator(Protocol): """The signature-preserving decorator ``with_annotated(...)`` returns (generic per call).""" def __call__( - self, fn: _AnnotatedCommand[CmdOrSetT, _CommandParams], / - ) -> _AnnotatedCommand[CmdOrSetT, _CommandParams]: ... + self, fn: _AnnotatedCommand[CmdOrSetT, _CommandParams, _CommandReturn], / + ) -> _AnnotatedCommand[CmdOrSetT, _CommandParams, _CommandReturn]: ... def _build_subcommand_handler( - func: _AnnotatedCommand[Any, ...], + func: _AnnotatedCommand[Any, ..., Any], subcommand_to: str, *, base_command: bool = False, options: _ParserBuildOptions, -) -> tuple[_AnnotatedCommand[Any, ...], str, Callable[[], Cmd2ArgumentParser]]: +) -> tuple[_AnnotatedCommand[Any, ..., Any], str, Callable[[], Cmd2ArgumentParser]]: """Build a subcommand's parser and a handler that unpacks the Namespace into typed kwargs. :param func: the subcommand handler function @@ -2936,7 +2966,7 @@ def _build_subcommand_handler( _leading_names, _var_positional_name = _var_positional_call_plan(func) @functools.wraps(func) - def handler(*args: Any, **kwargs: Any) -> bool | None: + def handler(*args: Any, **kwargs: Any) -> Any: """Unpack Namespace into typed kwargs for the subcommand handler.""" if kwargs: raise TypeError("subcommand handlers do not accept keyword arguments") @@ -2952,19 +2982,18 @@ def handler(*args: Any, **kwargs: Any) -> bool | None: if isinstance(cmd2_h, functools.partial) and getattr(cmd2_h.func, "__func__", cmd2_h.func) is handler: filtered[constants.NS_ATTR_SUBCOMMAND_FUNC] = None _reconstruct_dataclass_blocks(filtered, _blocks, ns) - result = _invoke_command_func( + return _invoke_command_func( func, self_arg, filtered, leading_names=_leading_names, var_positional_name=_var_positional_name ) - if result is None or isinstance(result, bool): - return result - raise TypeError("annotated command functions must return bool or None") parser_builder = _make_parser_builder(func, skip_params=_SKIP_PARAMS, base_command=base_command, options=options) - return cast(_AnnotatedCommand[Any, ...], handler), subcmd_name, parser_builder + return cast(_AnnotatedCommand[Any, ..., Any], handler), subcmd_name, parser_builder @overload -def with_annotated(func: _AnnotatedCommand[CmdOrSetT, _CommandParams], /) -> _AnnotatedCommand[CmdOrSetT, _CommandParams]: ... +def with_annotated( + func: _AnnotatedCommand[CmdOrSetT, _CommandParams, _CommandReturn], / +) -> _AnnotatedCommand[CmdOrSetT, _CommandParams, _CommandReturn]: ... @overload @@ -2991,7 +3020,7 @@ def with_annotated( def with_annotated( - func: _AnnotatedCommand[CmdOrSetT, _CommandParams] | None = None, + func: _AnnotatedCommand[CmdOrSetT, _CommandParams, _CommandReturn] | None = None, *, ns_provider: Callable[..., argparse.Namespace] | None = None, preserve_quotes: bool = False, @@ -3009,7 +3038,7 @@ def with_annotated( subcommand_title: str | None = None, subcommand_description: str | None = None, **parser_kwargs: Unpack[Cmd2ParserKwargs], -) -> _AnnotatedCommand[CmdOrSetT, _CommandParams] | _WithAnnotatedDecorator: +) -> _AnnotatedCommand[CmdOrSetT, _CommandParams, _CommandReturn] | _WithAnnotatedDecorator: """Decorate a ``do_*`` method to build its argparse parser from type annotations. :param func: the command function (when used without parentheses) @@ -3071,8 +3100,8 @@ def with_annotated( ) def decorator( - command_func: _AnnotatedCommand[CmdOrSetT, _DecoratorParams], - ) -> _AnnotatedCommand[CmdOrSetT, _DecoratorParams]: + command_func: _AnnotatedCommand[CmdOrSetT, _DecoratorParams, _CommandReturn], + ) -> _AnnotatedCommand[CmdOrSetT, _DecoratorParams, _CommandReturn]: if with_unknown_args: unknown_param = inspect.signature(command_func).parameters.get("_unknown") if unknown_param is None: @@ -3102,7 +3131,7 @@ def decorator( parser_source=subcmd_parser_builder, ) setattr(handler, constants.SUBCOMMAND_ATTR_SPEC, subcommand_spec) - return cast(_AnnotatedCommand[CmdOrSetT, _DecoratorParams], handler) + return cast(_AnnotatedCommand[CmdOrSetT, _DecoratorParams, _CommandReturn], handler) command_name = command_func.__name__[len(constants.COMMAND_FUNC_PREFIX) :] @@ -3125,14 +3154,14 @@ def decorator( ) @functools.wraps(command_func) - def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: + def cmd_wrapper(*args: Any, **kwargs: Any) -> _CommandReturn: cmd2_app, statement_arg = _parse_positionals(args) owner = args[0] # Cmd or CommandSet instance statement, parsed_arglist = cmd2_app.statement_parser.get_command_arg_list( command_name, statement_arg, preserve_quotes ) - arg_parser = cmd2_app.command_parsers.get(cmd_wrapper) + arg_parser = cmd2_app.command_parsers.get(cast(BoundCommandFunc[...], cmd_wrapper)) if arg_parser is None: raise ValueError(f"No argument parser found for {command_name}") @@ -3168,10 +3197,10 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: func_kwargs.update(kwargs) _reconstruct_dataclass_blocks(func_kwargs, blocks, ns) - result: bool | None = _invoke_command_func( + result = _invoke_command_func( command_func, owner, func_kwargs, leading_names=leading_names, var_positional_name=var_positional_name ) - return result + return cast(_CommandReturn, result) argparse_command_spec = ArgparseCommandSpec( parser_source=parser_builder, @@ -3179,7 +3208,7 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: ) setattr(cmd_wrapper, constants.ARGPARSE_COMMAND_ATTR_SPEC, argparse_command_spec) - return cast(_AnnotatedCommand[CmdOrSetT, _DecoratorParams], cmd_wrapper) + return cast(_AnnotatedCommand[CmdOrSetT, _DecoratorParams, _CommandReturn], cmd_wrapper) # Support both @with_annotated and @with_annotated(...) if func is not None: diff --git a/cmd2/types.py b/cmd2/types.py index 4a06f2e1f..1353b56cb 100644 --- a/cmd2/types.py +++ b/cmd2/types.py @@ -72,8 +72,15 @@ class BoundCommandFunc(Protocol[P]): """Protocol for a command function bound to a command instance.""" - __name__: str - __qualname__: str + if TYPE_CHECKING: + + @property + def __name__(self) -> str: + """Name of the command function.""" + + @property + def __qualname__(self) -> str: + """Qualified name of the command function.""" def __call__(self, *args: P.args, **kwargs: P.kwargs) -> bool | None: """Invoke the bound command function.""" diff --git a/tests/test_annotated.py b/tests/test_annotated.py index daf206b2c..8f53c960f 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -2343,17 +2343,29 @@ def root_child(self) -> None: root_child(object(), object()) -def test_subcommand_handler_rejects_invalid_return_value() -> None: - """The internal subcommand adapter enforces the command return contract.""" - - invalid_result: Any = object() +def test_subcommand_handler_preserves_truthy_return_value() -> None: + """The internal subcommand adapter preserves any truthy command result.""" @with_annotated(subcommand_to="root") - def root_child(self) -> bool | None: - return invalid_result + def root_child(self) -> int: + return 1 + + assert root_child(object(), argparse.Namespace()) == 1 + + +def test_truthy_subcommand_return_stops_command_loop() -> None: + """A documented non-boolean truthy result propagates through the command loop.""" + + class TruthySubcommandApp(cmd2.Cmd): + @with_annotated(base_command=True) + def do_root(self, cmd2_subcommand_func) -> int | None: + return cmd2_subcommand_func() if cmd2_subcommand_func is not None else None + + @with_annotated(subcommand_to="root") + def root_stop(self) -> int: + return 1 - with pytest.raises(TypeError, match="must return bool or None"): - root_child(object(), argparse.Namespace()) + assert TruthySubcommandApp().onecmd_plus_hooks("root stop") class _IntegrationApp(cmd2.Cmd):