@@ -280,7 +280,7 @@ def arg_decorator(func: ArgparseCommandFunc[CmdOrSet]) -> RawCommandFuncOptional
280280 """
281281
282282 @functools .wraps (func )
283- def cmd_wrapper (* args : Any , ** kwargs : dict [ str , Any ] ) -> bool | None :
283+ def cmd_wrapper (* args : Any , ** kwargs : Any ) -> bool | None :
284284 """Command function wrapper which translates command line into argparse Namespace and call actual command function.
285285
286286 :param args: All positional arguments to this function. We're expecting there to be:
@@ -349,24 +349,73 @@ def cmd_wrapper(*args: Any, **kwargs: dict[str, Any]) -> bool | None:
349349def with_annotated (
350350 func : Callable [..., Any ] | None = None ,
351351 * ,
352+ ns_provider : Callable [..., argparse .Namespace ] | None = None ,
352353 preserve_quotes : bool = False ,
353354 with_unknown_args : bool = False ,
355+ base_command : bool = False ,
356+ subcommand_to : str | None = None ,
357+ help : str | None = None , # noqa: A002
358+ aliases : Sequence [str ] | None = None ,
359+ groups : tuple [tuple [str , ...], ...] | None = None ,
360+ mutually_exclusive_groups : tuple [tuple [str , ...], ...] | None = None ,
354361) -> Any :
355362 """Decorate a ``do_*`` method to build its argparse parser from type annotations.
356363
357- Can be used bare or with keyword arguments::
364+ .. warning:: Experimental -- behaviour may change in future releases.
358365
366+ :param func: the command function (when used without parentheses)
367+ :param ns_provider: optional callable returning a prepopulated argparse.Namespace.
368+ Not supported with ``subcommand_to``.
369+ :param preserve_quotes: if True, preserve quotes in arguments.
370+ Not supported with ``subcommand_to``.
371+ :param with_unknown_args: if True, capture unknown args (passed as extra kwarg ``_unknown``).
372+ Not supported with ``subcommand_to``.
373+ :param base_command: if True, this command has subcommands (adds ``add_subparsers()``).
374+ Requires a ``cmd2_handler`` parameter and no positional arguments.
375+ :param subcommand_to: parent command name (e.g. ``'team'`` or ``'team member'``).
376+ Function must be named ``{parent_underscored}_{subcommand}``.
377+ :param help: help text for the subcommand (only valid with ``subcommand_to``)
378+ :param aliases: alternative names for the subcommand (only valid with ``subcommand_to``)
379+ :param groups: tuples of parameter names to place in argument groups (for help display)
380+ :param mutually_exclusive_groups: tuples of parameter names that are mutually exclusive
381+
382+ Example:
383+ ```py
384+ class MyApp(cmd2.Cmd):
359385 @with_annotated
360386 def do_greet(self, name: str, count: int = 1): ...
361387
362- @with_annotated(preserve_quotes=True)
363- def do_raw(self, text: str): ...
388+ @with_annotated(base_command=True)
389+ def do_team(self, *, cmd2_handler): ...
390+
391+ @with_annotated(subcommand_to='team', help='create a team')
392+ def team_create(self, name: str): ...
393+ ```
364394
365- :param func: the command function (when used without parentheses)
366- :param preserve_quotes: if True, preserve quotes in arguments
367- :param with_unknown_args: if True, capture unknown args (passed as extra kwarg ``_unknown``)
368395 """
369- from .annotated import build_parser_from_function
396+ from .annotated import (
397+ _filtered_namespace_kwargs ,
398+ _validate_base_command_params ,
399+ build_parser_from_function ,
400+ build_subcommand_handler ,
401+ )
402+
403+ if (help is not None or aliases is not None ) and subcommand_to is None :
404+ raise TypeError ("'help' and 'aliases' are only valid with subcommand_to" )
405+ if subcommand_to is not None :
406+ unsupported : list [str ] = []
407+ if ns_provider is not None :
408+ unsupported .append ('ns_provider' )
409+ if preserve_quotes :
410+ unsupported .append ('preserve_quotes' )
411+ if with_unknown_args :
412+ unsupported .append ('with_unknown_args' )
413+ if unsupported :
414+ names = ', ' .join (unsupported )
415+ raise TypeError (
416+ f"{ names } { 'is' if len (unsupported ) == 1 else 'are' } not supported with subcommand_to. "
417+ "Configure these behaviors on the base command instead."
418+ )
370419
371420 def decorator (fn : Callable [..., Any ]) -> Callable [..., Any ]:
372421 if with_unknown_args :
@@ -376,45 +425,96 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
376425 if unknown_param .kind is inspect .Parameter .POSITIONAL_ONLY :
377426 raise TypeError ('Parameter _unknown must be keyword-compatible when with_unknown_args=True' )
378427
428+ if subcommand_to is not None :
429+ handler , subcmd_name , parser_builder = build_subcommand_handler (
430+ fn ,
431+ subcommand_to ,
432+ base_command = base_command ,
433+ groups = groups ,
434+ mutually_exclusive_groups = mutually_exclusive_groups ,
435+ )
436+ setattr (handler , constants .SUBCMD_ATTR_COMMAND , subcommand_to )
437+ setattr (handler , constants .SUBCMD_ATTR_NAME , subcmd_name )
438+ setattr (handler , constants .CMD_ATTR_ARGPARSER , parser_builder )
439+ add_parser_kwargs : dict [str , Any ] = {}
440+ if help is not None :
441+ add_parser_kwargs ['help' ] = help
442+ if aliases :
443+ add_parser_kwargs ['aliases' ] = aliases [:]
444+ setattr (handler , constants .SUBCMD_ATTR_ADD_PARSER_KWARGS , add_parser_kwargs )
445+ return handler
446+
447+ if base_command :
448+ _validate_base_command_params (fn , include_unknown = with_unknown_args )
449+
450+ # ---- Normal do_* command ----
379451 command_name = fn .__name__ [len (constants .COMMAND_FUNC_PREFIX ) :]
380452
453+ # Cache signature introspection at decoration time, not per-invocation
454+ _accepted_params = set (inspect .signature (fn ).parameters .keys ()) - {'self' }
455+
381456 @functools .wraps (fn )
382- def cmd_wrapper (* args : Any , ** _kwargs : Any ) -> bool | None :
457+ def cmd_wrapper (* args : Any , ** kwargs : Any ) -> bool | None :
383458 cmd2_app , statement_arg = _parse_positionals (args )
384459 owner = args [0 ] # Cmd or CommandSet instance
385- _statement , parsed_arglist = cmd2_app .statement_parser .get_command_arg_list (
460+ statement , parsed_arglist = cmd2_app .statement_parser .get_command_arg_list (
386461 command_name , statement_arg , preserve_quotes
387462 )
388463
389464 arg_parser = cmd2_app ._command_parsers .get (cmd_wrapper )
390465 if arg_parser is None :
391466 raise ValueError (f'No argument parser found for { command_name } ' )
392467
468+ # Resolve namespace provider (same logic as with_argparser)
469+ if ns_provider is None :
470+ namespace = None
471+ else :
472+ provider_self = cmd2_app ._resolve_func_self (ns_provider , args [0 ])
473+ namespace = ns_provider (provider_self if provider_self is not None else cmd2_app )
474+
393475 try :
394476 if with_unknown_args :
395- ns , unknown = arg_parser .parse_known_args (parsed_arglist )
477+ ns , unknown = arg_parser .parse_known_args (parsed_arglist , namespace )
396478 else :
397- ns = arg_parser .parse_args (parsed_arglist )
479+ ns = arg_parser .parse_args (parsed_arglist , namespace )
398480 unknown = None
399481 except SystemExit as exc :
400482 raise Cmd2ArgparseError from exc
401483
402- # Unpack Namespace into function kwargs
403- func_kwargs : dict [str , Any ] = {}
404- for key , value in vars (ns ).items ():
405- if key .startswith ('cmd2_' ) or key == constants .NS_ATTR_SUBCMD_HANDLER :
406- continue
407- func_kwargs [key ] = value
484+ # Extract user-defined kwargs from a parsed Namespace, excluding cmd2 internals.
485+ func_kwargs = _filtered_namespace_kwargs (ns , exclude_subcommand = True )
486+
487+ # Inject cmd2-specific kwargs that commands can opt into
488+ func_kwargs ['cmd2_statement' ] = Cmd2AttributeWrapper (statement )
489+ raw_handler = getattr (ns , constants .NS_ATTR_SUBCMD_HANDLER , None )
490+ bound_handler = (lambda _h = raw_handler , _ns = ns : _h (_ns )) if raw_handler is not None else None
491+ func_kwargs ['cmd2_handler' ] = Cmd2AttributeWrapper (bound_handler )
408492
409493 if with_unknown_args :
410494 func_kwargs ['_unknown' ] = unknown
411495
412- result : bool | None = fn (owner , ** func_kwargs )
496+ filtered_kwargs = {k : v for k , v in func_kwargs .items () if k in _accepted_params }
497+
498+ # Direct wrapper kwargs (e.g. do_greet("x", keyword_arg="y")) override parsed values
499+ for key in kwargs :
500+ filtered_kwargs .pop (key , None )
501+
502+ result : bool | None = fn (owner , ** filtered_kwargs , ** kwargs )
413503 return result
414504
415- # Store a parser-builder callable — _CommandParsers._build_parser()
416- # already handles callables by calling them with no arguments.
417- setattr (cmd_wrapper , constants .CMD_ATTR_ARGPARSER , lambda : build_parser_from_function (fn ))
505+ # Store a parser-builder callable
506+ def _build_cmd_parser () -> argparse .ArgumentParser :
507+ parser = build_parser_from_function (
508+ fn ,
509+ include_unknown = with_unknown_args ,
510+ groups = groups ,
511+ mutually_exclusive_groups = mutually_exclusive_groups ,
512+ )
513+ if base_command :
514+ parser .add_subparsers (dest = 'subcommand' , metavar = 'SUBCOMMAND' , required = True )
515+ return parser
516+
517+ setattr (cmd_wrapper , constants .CMD_ATTR_ARGPARSER , _build_cmd_parser )
418518 setattr (cmd_wrapper , constants .CMD_ATTR_PRESERVE_QUOTES , preserve_quotes )
419519
420520 return cmd_wrapper
0 commit comments