Skip to content
Merged
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
44 changes: 23 additions & 21 deletions cmd2/annotated.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ def do_paint(
UnboundCompleter,
)

if TYPE_CHECKING:
if TYPE_CHECKING: # pragma: no cover
from .argparse_completer import ArgparseCompleter

#: ``nargs`` values accepted by cmd2's patched ``add_argument`` (incl. ranged tuples).
Expand Down Expand Up @@ -1745,8 +1745,19 @@ def _resolve_parameters(
f"with_annotated(base_command=True) requires a '{constants.NS_ATTR_SUBCOMMAND_FUNC}' "
f"parameter in {func.__qualname__}"
)
# Resolve hints only for the parameters that become arguments: the bound first parameter
# (self/cls), the injected skip_params, and the "return" annotation never become arguments
ignored = {next(iter(sig.parameters), None), "return", *skip_params}
ignored.discard(None)
relevant_annotations = {name: ann for name, ann in getattr(func, "__annotations__", {}).items() if name not in ignored}
# Forward references resolve against the *original* function's module during functools.wraps wrapper.
unwrapped = inspect.unwrap(func)
try:
hints = get_type_hints(func, include_extras=True)
hints = get_type_hints(
types.SimpleNamespace(__annotations__=relevant_annotations),
globalns=getattr(unwrapped, "__globals__", {}),
include_extras=True,
)
except (NameError, AttributeError, TypeError) as exc:
raise TypeError(
f"Failed to resolve type hints for {func.__qualname__}. Ensure all annotations use valid, importable types."
Expand Down Expand Up @@ -1986,25 +1997,16 @@ def build_parser_from_function(
mutually_exclusive_groups=mutually_exclusive_groups,
)

# ``argument_default=argparse.SUPPRESS`` removes an absent argument from the parsed namespace.
# That is safe only for arguments that are always supplied (required) or carry their own default;
# an *omittable* argument with no default (e.g. a ``T | None`` positional -> nargs='?') would be
# dropped when absent, leaving the function without a keyword argument it expects. ``*args`` is
# exempt: the invocation path substitutes an empty tuple for it. Reject the combination here,
# mirroring the per-argument ``default=argparse.SUPPRESS`` rejection.
# ``argument_default=argparse.SUPPRESS`` drops an absent argument from the parsed namespace.
# @with_annotated builds the call from the function signature, so every declared parameter is
# expected at invocation -- an argument vanishing from the namespace can never be valid here.
# Reject it outright, mirroring the per-argument ``default=argparse.SUPPRESS`` rejection.
if parser_kwargs.get("argument_default") is argparse.SUPPRESS:
dropped = [
arg.name
for arg in resolved
if arg.default is _UNSET and arg.omittable and not arg.required and not arg.is_variadic
]
if dropped:
raise TypeError(
f"argument_default=argparse.SUPPRESS is not supported by @with_annotated for {func.__qualname__}: "
f"it would drop {dropped!r} from the parsed namespace when absent, but the function expects "
f"{'them' if len(dropped) > 1 else 'it'} as a keyword argument. Give each an explicit default or "
f"make it required, or drop argument_default=argparse.SUPPRESS."
)
raise TypeError(
f"argument_default=argparse.SUPPRESS is not supported by @with_annotated for {func.__qualname__}: "
f"it drops absent arguments from the parsed namespace, but every parameter built from the "
f"signature is expected at invocation. Drop argument_default=argparse.SUPPRESS."
)

# Build the group lookup (member references already validated by _resolve_parameters).
target_for, argument_group_for = _build_argument_group_targets(parser, groups=groups)
Expand Down Expand Up @@ -2124,7 +2126,7 @@ def handler(self_arg: Any, ns: Any) -> Any:
filtered = _filtered_namespace_kwargs(ns, accepted=_accepted)
if constants.NS_ATTR_SUBCOMMAND_FUNC in filtered:
cmd2_h = filtered[constants.NS_ATTR_SUBCOMMAND_FUNC]
if isinstance(cmd2_h, functools.partial) and cmd2_h.func is handler:
if isinstance(cmd2_h, functools.partial) and getattr(cmd2_h.func, "__func__", cmd2_h.func) is handler:
filtered[constants.NS_ATTR_SUBCOMMAND_FUNC] = None
return _invoke_command_func(
func, self_arg, filtered, leading_names=_leading_names, var_positional_name=_var_positional_name
Expand Down
13 changes: 5 additions & 8 deletions docs/features/annotated.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,14 +363,11 @@ def do_run(self, verbose: bool = False, quiet: bool = False): ...
```

`parents=` mirrors argparse's standard parents mechanism for sharing argument definitions across
parsers. `argument_default=argparse.SUPPRESS` is accepted only when no argument could be stranded by
it: it removes an absent argument from the parsed namespace, which is safe for an argument that is
always supplied (a required option, a mandatory positional) or that carries its own default, but not
for an _omittable_ argument with no default (for example a `T | None` positional, which becomes
`nargs='?'`). If any such argument is present, `@with_annotated` raises `TypeError` rather than let
the function be called missing a keyword argument it expects (mirroring the per-argument
`default=argparse.SUPPRESS` rejection). `*args` is exempt, since the invocation path substitutes an
empty tuple for it.
parsers. `argument_default=argparse.SUPPRESS` is not supported and raises `TypeError`. It removes an
absent argument from the parsed namespace, but `@with_annotated` builds the call from the function
signature, so every declared parameter is expected at invocation; an argument vanishing from the
namespace can never be valid here (mirroring the per-argument `default=argparse.SUPPRESS`
rejection). Any other `argument_default` value is forwarded to the parser unchanged.

The remaining argparse kwargs cover less-common needs but are wired through unchanged:

Expand Down
Loading
Loading