@@ -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,74 @@ 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+ _SKIP_PARAMS ,
398+ _filtered_namespace_kwargs ,
399+ _validate_base_command_params ,
400+ build_parser_from_function ,
401+ build_subcommand_handler ,
402+ )
403+
404+ if (help is not None or aliases is not None ) and subcommand_to is None :
405+ raise TypeError ("'help' and 'aliases' are only valid with subcommand_to" )
406+ if subcommand_to is not None :
407+ unsupported : list [str ] = []
408+ if ns_provider is not None :
409+ unsupported .append ('ns_provider' )
410+ if preserve_quotes :
411+ unsupported .append ('preserve_quotes' )
412+ if with_unknown_args :
413+ unsupported .append ('with_unknown_args' )
414+ if unsupported :
415+ names = ', ' .join (unsupported )
416+ raise TypeError (
417+ f"{ names } { 'is' if len (unsupported ) == 1 else 'are' } not supported with subcommand_to. "
418+ "Configure these behaviors on the base command instead."
419+ )
370420
371421 def decorator (fn : Callable [..., Any ]) -> Callable [..., Any ]:
372422 if with_unknown_args :
@@ -376,45 +426,98 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
376426 if unknown_param .kind is inspect .Parameter .POSITIONAL_ONLY :
377427 raise TypeError ('Parameter _unknown must be keyword-compatible when with_unknown_args=True' )
378428
429+ if subcommand_to is not None :
430+ handler , subcmd_name , parser_builder = build_subcommand_handler (
431+ fn ,
432+ subcommand_to ,
433+ base_command = base_command ,
434+ groups = groups ,
435+ mutually_exclusive_groups = mutually_exclusive_groups ,
436+ )
437+ setattr (handler , constants .SUBCMD_ATTR_COMMAND , subcommand_to )
438+ setattr (handler , constants .SUBCMD_ATTR_NAME , subcmd_name )
439+ setattr (handler , constants .CMD_ATTR_ARGPARSER , parser_builder )
440+ add_parser_kwargs : dict [str , Any ] = {}
441+ if help is not None :
442+ add_parser_kwargs ['help' ] = help
443+ if aliases :
444+ add_parser_kwargs ['aliases' ] = aliases [:]
445+ setattr (handler , constants .SUBCMD_ATTR_ADD_PARSER_KWARGS , add_parser_kwargs )
446+ return handler
447+
448+ skip_params = _SKIP_PARAMS | ({'_unknown' } if with_unknown_args else frozenset ())
449+
450+ if base_command :
451+ _validate_base_command_params (fn , skip_params = skip_params )
452+
453+ # ---- Normal do_* command ----
379454 command_name = fn .__name__ [len (constants .COMMAND_FUNC_PREFIX ) :]
380455
456+ # Cache signature introspection at decoration time, not per-invocation
457+ _accepted_params = set (inspect .signature (fn ).parameters .keys ()) - {'self' }
458+
381459 @functools .wraps (fn )
382- def cmd_wrapper (* args : Any , ** _kwargs : Any ) -> bool | None :
460+ def cmd_wrapper (* args : Any , ** kwargs : Any ) -> bool | None :
383461 cmd2_app , statement_arg = _parse_positionals (args )
384462 owner = args [0 ] # Cmd or CommandSet instance
385- _statement , parsed_arglist = cmd2_app .statement_parser .get_command_arg_list (
463+ statement , parsed_arglist = cmd2_app .statement_parser .get_command_arg_list (
386464 command_name , statement_arg , preserve_quotes
387465 )
388466
389467 arg_parser = cmd2_app ._command_parsers .get (cmd_wrapper )
390468 if arg_parser is None :
391469 raise ValueError (f'No argument parser found for { command_name } ' )
392470
471+ # Resolve namespace provider (same logic as with_argparser)
472+ if ns_provider is None :
473+ namespace = None
474+ else :
475+ provider_self = cmd2_app ._resolve_func_self (ns_provider , args [0 ])
476+ namespace = ns_provider (provider_self if provider_self is not None else cmd2_app )
477+
393478 try :
394479 if with_unknown_args :
395- ns , unknown = arg_parser .parse_known_args (parsed_arglist )
480+ ns , unknown = arg_parser .parse_known_args (parsed_arglist , namespace )
396481 else :
397- ns = arg_parser .parse_args (parsed_arglist )
482+ ns = arg_parser .parse_args (parsed_arglist , namespace )
398483 unknown = None
399484 except SystemExit as exc :
400485 raise Cmd2ArgparseError from exc
401486
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
487+ # Extract user-defined kwargs from a parsed Namespace, excluding cmd2 internals.
488+ func_kwargs = _filtered_namespace_kwargs (ns , exclude_subcommand = True )
489+
490+ # Inject cmd2-specific kwargs that commands can opt into
491+ func_kwargs ['cmd2_statement' ] = Cmd2AttributeWrapper (statement )
492+ raw_handler = getattr (ns , constants .NS_ATTR_SUBCMD_HANDLER , None )
493+ bound_handler = (lambda _h = raw_handler , _ns = ns : _h (_ns )) if raw_handler is not None else None
494+ func_kwargs ['cmd2_handler' ] = Cmd2AttributeWrapper (bound_handler )
408495
409496 if with_unknown_args :
410497 func_kwargs ['_unknown' ] = unknown
411498
412- result : bool | None = fn (owner , ** func_kwargs )
499+ filtered_kwargs = {k : v for k , v in func_kwargs .items () if k in _accepted_params }
500+
501+ # Direct wrapper kwargs (e.g. do_greet("x", keyword_arg="y")) override parsed values
502+ for key in kwargs :
503+ filtered_kwargs .pop (key , None )
504+
505+ result : bool | None = fn (owner , ** filtered_kwargs , ** kwargs )
413506 return result
414507
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 ))
508+ # Store a parser-builder callable
509+ def _build_cmd_parser () -> argparse .ArgumentParser :
510+ parser = build_parser_from_function (
511+ fn ,
512+ skip_params = skip_params ,
513+ groups = groups ,
514+ mutually_exclusive_groups = mutually_exclusive_groups ,
515+ )
516+ if base_command :
517+ parser .add_subparsers (dest = 'subcommand' , metavar = 'SUBCOMMAND' , required = True )
518+ return parser
519+
520+ setattr (cmd_wrapper , constants .CMD_ATTR_ARGPARSER , _build_cmd_parser )
418521 setattr (cmd_wrapper , constants .CMD_ATTR_PRESERVE_QUOTES , preserve_quotes )
419522
420523 return cmd_wrapper
0 commit comments