@@ -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+
23122442class 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
34833598class 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
35073629class TestHelpKwargReserved :
0 commit comments