Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
## 4.2.3 (TBD)

- Enhancements
- Converted `BoundCommandFunc` and `UnboundCommandFunc` TypeAliases in `types.py` to Protocol
classes for stricter type checking on `cmd2` command method references
- Experimental features
- Defined private, unified type alias `_CommandFunc` in `annotated.py` based on
`BoundCommandFunc` and `UnboundCommandFunc` to get the benefit of stricter type checking here
as well

## 4.2.2 (August 25, 2026)

- Documentation Improvements
Expand Down
33 changes: 20 additions & 13 deletions cmd2/annotated.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ def do_build(self, target: str, common: CommonArgs):
NamedTuple,
ParamSpec,
Protocol,
TypeAlias,
TypedDict,
TypeGuard,
TypeVar,
Expand All @@ -309,15 +310,21 @@ 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,
)

#: ``nargs`` values accepted by cmd2's patched ``add_argument`` (incl. ranged tuples).
_NargsValue = int | str | tuple[int] | tuple[int, int] | tuple[int, float]


_CommandFunc: TypeAlias = BoundCommandFunc | UnboundCommandFunc[CmdOrSet, Any]


class Cmd2ParserKwargs(TypedDict, total=False):
"""Forwarded ctor kwargs for [`Cmd2ArgumentParser`][cmd2.argparse_utils.Cmd2ArgumentParser] (PEP 692 ``Unpack``).

Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -2118,7 +2125,7 @@ def _link_mutex_group_membership(
by_name[name].mutex_group_indices.append(index)


def _resolve_func_hints(func: Callable[..., Any], *, skip_params: frozenset[str] = _SKIP_PARAMS) -> dict[str, Any]:
def _resolve_func_hints(func: _CommandFunc, *, skip_params: frozenset[str] = _SKIP_PARAMS) -> dict[str, Any]:
"""Resolve the type hints for the parameters that become arguments.

The bound first parameter (self/cls), the injected ``skip_params``, and the ``return`` annotation
Expand Down Expand Up @@ -2296,7 +2303,7 @@ def _block_field_dest(spec: _BlockSpec, field_name: str) -> str:
return _shared_field_dest(spec.dc_type, field_name) if spec.shared else field_name


def _dataclass_blocks(func: Callable[..., Any], *, skip_params: frozenset[str] = _SKIP_PARAMS) -> dict[str, _BlockSpec]:
def _dataclass_blocks(func: _CommandFunc, *, skip_params: frozenset[str] = _SKIP_PARAMS) -> dict[str, _BlockSpec]:
"""Map each dataclass-block parameter name to its :class:`_BlockSpec`.

Used by the runtime handler to reconstruct the dataclass instance from the parsed namespace. A
Expand All @@ -2320,7 +2327,7 @@ def _dataclass_blocks(func: Callable[..., Any], *, skip_params: frozenset[str] =


def _lazy_block_resolver(
func: Callable[..., Any],
func: _CommandFunc,
*,
base_accepted: set[str],
skip_params: frozenset[str],
Expand Down Expand Up @@ -2377,7 +2384,7 @@ def _reconstruct_dataclass_blocks(func_kwargs: dict[str, Any], blocks: dict[str,


def _resolve_parameters(
func: Callable[..., Any],
func: _CommandFunc,
*,
skip_params: frozenset[str] = _SKIP_PARAMS,
base_command: bool = False,
Expand Down Expand Up @@ -2721,7 +2728,7 @@ def _docstring_first_paragraph(doc: str | None) -> str | None:


def build_parser_from_function(
func: Callable[..., Any],
func: _CommandFunc,
*,
skip_params: frozenset[str] = _SKIP_PARAMS,
groups: tuple[Group, ...] | None = None,
Expand Down Expand Up @@ -2796,7 +2803,7 @@ def build_parser_from_function(
return parser


def _derive_subcommand_name(func: Callable[..., Any], subcommand_to: str) -> str:
def _derive_subcommand_name(func: _CommandFunc, subcommand_to: str) -> str:
"""Derive the subcommand name from the function name and validate the naming convention.

``subcommand_to='team member'`` + ``func.__name__='team_member_add'`` -> ``'add'``.
Expand Down Expand Up @@ -2832,7 +2839,7 @@ class _ParserBuildOptions:


def _make_parser_builder(
func: Callable[..., Any],
func: _CommandFunc,
*,
skip_params: frozenset[str],
base_command: bool,
Expand Down Expand Up @@ -2871,12 +2878,12 @@ def parser_builder() -> Cmd2ArgumentParser:


def _build_subcommand_handler(
func: Callable[..., Any],
func: _CommandFunc,
subcommand_to: str,
*,
base_command: bool = False,
options: _ParserBuildOptions,
) -> tuple[Callable[..., Any], str, Callable[[], Cmd2ArgumentParser]]:
) -> tuple[_CommandFunc, str, Callable[[], Cmd2ArgumentParser]]:
"""Build a subcommand's parser and a handler that unpacks the Namespace into typed kwargs.

:param func: the subcommand handler function
Expand Down Expand Up @@ -2957,7 +2964,7 @@ def with_annotated(


def with_annotated(
func: Callable[..., Any] | None = None,
func: _CommandFunc | None = None,
*,
ns_provider: Callable[..., argparse.Namespace] | None = None,
preserve_quotes: bool = False,
Expand Down Expand Up @@ -3036,7 +3043,7 @@ def with_annotated(
subcommand_description=subcommand_description,
)

def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
def decorator(fn: _CommandFunc) -> _CommandFunc:
if with_unknown_args:
unknown_param = inspect.signature(fn).parameters.get("_unknown")
if unknown_param is None:
Expand Down
12 changes: 6 additions & 6 deletions cmd2/argparse_completer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
24 changes: 12 additions & 12 deletions cmd2/argparse_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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}}}-*)"
Expand All @@ -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]))

Expand Down
Loading
Loading