Skip to content

Commit f1d67ec

Browse files
chore: more edge case fix
1 parent 9402a91 commit f1d67ec

3 files changed

Lines changed: 165 additions & 55 deletions

File tree

cmd2/annotated.py

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2000,25 +2000,16 @@ def build_parser_from_function(
20002000
mutually_exclusive_groups=mutually_exclusive_groups,
20012001
)
20022002

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

20232014
# Build the group lookup (member references already validated by _resolve_parameters).
20242015
target_for, argument_group_for = _build_argument_group_targets(parser, groups=groups)
@@ -2138,7 +2129,7 @@ def handler(self_arg: Any, ns: Any) -> Any:
21382129
filtered = _filtered_namespace_kwargs(ns, accepted=_accepted)
21392130
if constants.NS_ATTR_SUBCOMMAND_FUNC in filtered:
21402131
cmd2_h = filtered[constants.NS_ATTR_SUBCOMMAND_FUNC]
2141-
if isinstance(cmd2_h, functools.partial) and cmd2_h.func is handler:
2132+
if isinstance(cmd2_h, functools.partial) and getattr(cmd2_h.func, "__func__", cmd2_h.func) is handler:
21422133
filtered[constants.NS_ATTR_SUBCOMMAND_FUNC] = None
21432134
return _invoke_command_func(
21442135
func, self_arg, filtered, leading_names=_leading_names, var_positional_name=_var_positional_name

docs/features/annotated.md

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -363,14 +363,11 @@ def do_run(self, verbose: bool = False, quiet: bool = False): ...
363363
```
364364

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

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

tests/test_annotated.py

Lines changed: 150 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1186,6 +1186,28 @@ class MyParser(cmd2.Cmd2ArgumentParser):
11861186
parser = build_parser_from_function(_make_func(str), parser_class=MyParser)
11871187
assert isinstance(parser, MyParser)
11881188

1189+
def test_default_parser_class(self) -> None:
1190+
"""With no parser_class, the parser is an instance of the configured default."""
1191+
from cmd2 import argparse_utils
1192+
1193+
parser = build_parser_from_function(_make_func(str))
1194+
assert type(parser) is argparse_utils.DEFAULT_ARGUMENT_PARSER
1195+
1196+
def test_default_parser_class_follows_current_default(self, monkeypatch) -> None:
1197+
"""The default is resolved at call time, never a copy captured at import.
1198+
1199+
``set_default_argument_parser`` rebinds ``argparse_utils.DEFAULT_ARGUMENT_PARSER`` at runtime;
1200+
a build issued afterwards must honor the new value.
1201+
"""
1202+
from cmd2 import argparse_utils
1203+
1204+
class MyDefaultParser(cmd2.Cmd2ArgumentParser):
1205+
pass
1206+
1207+
monkeypatch.setattr(argparse_utils, "DEFAULT_ARGUMENT_PARSER", MyDefaultParser)
1208+
parser = build_parser_from_function(_make_func(str))
1209+
assert type(parser) is MyDefaultParser
1210+
11891211
def test_completer_class(self) -> None:
11901212
from cmd2.argparse_completer import ArgparseCompleter
11911213

@@ -2309,6 +2331,114 @@ def test_subcommand_help(self, subcmd_app) -> None:
23092331
assert "member" in help_text
23102332

23112333

2334+
class _OptionalIntermediateApp(cmd2.Cmd):
2335+
"""An intermediate subcommand that is itself a base command with an *optional* subcommand."""
2336+
2337+
@with_annotated(base_command=True)
2338+
def do_opt(self, cmd2_subcommand_func) -> None:
2339+
if cmd2_subcommand_func:
2340+
cmd2_subcommand_func()
2341+
2342+
@with_annotated(subcommand_to="opt", base_command=True, subcommand_required=False, help="optional middle")
2343+
def opt_mid(self, cmd2_subcommand_func) -> None:
2344+
# The guard must blank out the self-referential handler so this runs exactly once.
2345+
self.poutput("mid:none" if cmd2_subcommand_func is None else "mid:recurse")
2346+
if cmd2_subcommand_func:
2347+
cmd2_subcommand_func()
2348+
2349+
@with_annotated(subcommand_to="opt mid", help="leaf")
2350+
def opt_mid_leaf(self, name: str) -> None:
2351+
self.poutput(f"leaf:{name}")
2352+
2353+
2354+
@pytest.fixture
2355+
def opt_app() -> _OptionalIntermediateApp:
2356+
return _OptionalIntermediateApp()
2357+
2358+
2359+
class TestOptionalIntermediateSubcommand:
2360+
def test_intermediate_without_deeper_subcommand_runs_once(self, opt_app) -> None:
2361+
"""The recursion guard blanks the self-referential handler: the body runs once with None."""
2362+
out, _err = run_cmd(opt_app, "opt mid")
2363+
assert out == ["mid:none"]
2364+
2365+
def test_deeper_subcommand_still_dispatches(self, opt_app) -> None:
2366+
"""Blanking the self-reference must not break dispatch to a genuine deeper subcommand."""
2367+
out, _err = run_cmd(opt_app, "opt mid leaf Bob")
2368+
assert out == ["leaf:Bob"]
2369+
2370+
def test_guard_blanks_only_subcommand_func(self) -> None:
2371+
"""The guard must null *only* cmd2_subcommand_func, leaving the intermediate's own args intact."""
2372+
2373+
class App(cmd2.Cmd):
2374+
@with_annotated(base_command=True)
2375+
def do_opt(self, cmd2_subcommand_func) -> None:
2376+
if cmd2_subcommand_func:
2377+
cmd2_subcommand_func()
2378+
2379+
@with_annotated(subcommand_to="opt", base_command=True, subcommand_required=False)
2380+
def opt_mid(self, cmd2_subcommand_func, verbose: bool = False) -> None:
2381+
self.poutput(f"none={cmd2_subcommand_func is None}:verbose={verbose}")
2382+
if cmd2_subcommand_func:
2383+
cmd2_subcommand_func()
2384+
2385+
out, _err = run_cmd(App(), "opt mid --verbose")
2386+
assert out == ["none=True:verbose=True"]
2387+
2388+
def test_guard_fires_at_deeper_nesting_level(self) -> None:
2389+
"""The guard must work past two levels: the deepest *selected* optional base runs once."""
2390+
2391+
class App(cmd2.Cmd):
2392+
@with_annotated(base_command=True)
2393+
def do_a(self, cmd2_subcommand_func) -> None:
2394+
if cmd2_subcommand_func:
2395+
cmd2_subcommand_func()
2396+
2397+
@with_annotated(subcommand_to="a", base_command=True, subcommand_required=False)
2398+
def a_b(self, cmd2_subcommand_func) -> None:
2399+
if cmd2_subcommand_func:
2400+
cmd2_subcommand_func()
2401+
2402+
@with_annotated(subcommand_to="a b", base_command=True, subcommand_required=False)
2403+
def a_b_c(self, cmd2_subcommand_func) -> None:
2404+
self.poutput(f"c:none={cmd2_subcommand_func is None}")
2405+
if cmd2_subcommand_func:
2406+
cmd2_subcommand_func()
2407+
2408+
@with_annotated(subcommand_to="a b c", help="leaf")
2409+
def a_b_c_leaf(self, name: str) -> None:
2410+
self.poutput(f"leaf:{name}")
2411+
2412+
app = App()
2413+
assert run_cmd(app, "a b c")[0] == ["c:none=True"]
2414+
assert run_cmd(app, "a b c leaf Z")[0] == ["leaf:Z"]
2415+
2416+
def test_guard_works_for_commandset_subcommand(self) -> None:
2417+
"""The handler is a CommandSet-bound method here; unwrapping to __func__ must still match."""
2418+
2419+
class _Grp(cmd2.CommandSet):
2420+
@cmd2.with_category("grp")
2421+
@with_annotated(base_command=True)
2422+
def do_grp(self, cmd2_subcommand_func) -> None:
2423+
if cmd2_subcommand_func:
2424+
cmd2_subcommand_func()
2425+
2426+
@with_annotated(subcommand_to="grp", base_command=True, subcommand_required=False)
2427+
def grp_mid(self, cmd2_subcommand_func) -> None:
2428+
self._cmd.poutput(f"mid:none={cmd2_subcommand_func is None}")
2429+
if cmd2_subcommand_func:
2430+
cmd2_subcommand_func()
2431+
2432+
@with_annotated(subcommand_to="grp mid", help="leaf")
2433+
def grp_mid_leaf(self, name: str) -> None:
2434+
self._cmd.poutput(f"leaf:{name}")
2435+
2436+
app = cmd2.Cmd(auto_load_commands=False)
2437+
app.register_command_set(_Grp())
2438+
assert run_cmd(app, "grp mid")[0] == ["mid:none=True"]
2439+
assert run_cmd(app, "grp mid leaf Q")[0] == ["leaf:Q"]
2440+
2441+
23122442
class TestSubcommandValidation:
23132443
def test_subcommand_aliases_none_raises(self) -> None:
23142444
"""aliases=None is off-spec (it must be a Sequence[str]); reject it with a clear message."""
@@ -3282,21 +3412,6 @@ def test_argument_default_passthrough(self) -> None:
32823412
parser = build_parser_from_function(_make_func(str), argument_default=sentinel)
32833413
assert parser.argument_default == sentinel
32843414

3285-
def test_argument_default_suppress_works_with_explicit_defaults(self) -> None:
3286-
"""``argument_default=SUPPRESS`` is safe when every argument sets its own default.
3287-
3288-
Every ``@with_annotated`` argument either is positional (always supplied) or
3289-
has an explicit default, so SUPPRESS at the parser level can't drop a kwarg
3290-
the function expects.
3291-
"""
3292-
3293-
def func(self, name: str, count: int = 1) -> None: ...
3294-
3295-
parser = build_parser_from_function(func, argument_default=argparse.SUPPRESS)
3296-
ns = parser.parse_args(["alice"])
3297-
assert ns.name == "alice"
3298-
assert ns.count == 1
3299-
33003415
def test_decorator_passes_parser_kwargs(self) -> None:
33013416
@with_annotated(prog="myprog", usage="usage line")
33023417
def do_run(self, name: str) -> None: ...
@@ -3481,27 +3596,34 @@ def test_const_on_untyped_param_not_validated(self) -> None:
34813596

34823597

34833598
class TestArgumentDefaultSuppressGuard:
3484-
"""``argument_default=argparse.SUPPRESS`` is rejected when it would drop an omittable argument."""
3599+
"""``argument_default=argparse.SUPPRESS`` is rejected outright by @with_annotated.
34853600
3486-
def test_suppress_with_optional_positional_rejected(self) -> None:
3487-
def do_t(self, x: int | None): ...
3601+
SUPPRESS drops an absent argument from the parsed namespace, but @with_annotated builds the call
3602+
from the signature, so every declared parameter is expected at invocation -- a vanished argument
3603+
can never be valid. The rejection is unconditional (it never inspects the signature), so one
3604+
direct-build case and one subcommand-registration case cover it.
3605+
"""
3606+
3607+
def test_suppress_rejected(self) -> None:
3608+
def do_t(self, a: int, b: str = "x"): ...
34883609

34893610
with pytest.raises(TypeError, match="SUPPRESS"):
34903611
build_parser_from_function(do_t, argument_default=argparse.SUPPRESS)
34913612

3492-
def test_suppress_safe_when_all_args_required_or_defaulted(self) -> None:
3493-
def do_t(self, a: int, b: str = "x"): ...
3613+
def test_suppress_rejected_in_subcommand(self) -> None:
3614+
"""The subcommand path shares the same builder, so the rejection fires at registration too."""
34943615

3495-
# ``a`` is always supplied (required positional); ``b`` carries its own default -> safe.
3496-
parser = build_parser_from_function(do_t, argument_default=argparse.SUPPRESS)
3497-
assert parser is not None
3616+
class App(cmd2.Cmd):
3617+
@with_annotated(base_command=True)
3618+
def do_calc(self, cmd2_subcommand_func) -> None:
3619+
if cmd2_subcommand_func:
3620+
cmd2_subcommand_func()
34983621

3499-
def test_suppress_safe_with_var_positional(self) -> None:
3500-
def do_t(self, *vals: int): ...
3622+
@with_annotated(subcommand_to="calc", argument_default=argparse.SUPPRESS, help="sum values")
3623+
def calc_sum(self, value: str = "x") -> None: ...
35013624

3502-
# *args is substituted with () by the invocation path, so SUPPRESS cannot strand it.
3503-
parser = build_parser_from_function(do_t, argument_default=argparse.SUPPRESS)
3504-
assert parser is not None
3625+
with pytest.raises(TypeError, match="SUPPRESS"):
3626+
App()
35053627

35063628

35073629
class TestHelpKwargReserved:

0 commit comments

Comments
 (0)