77finer control is needed.
88
99Basic usage -- parameters without defaults become positional arguments,
10- parameters with defaults become ``--option`` flags::
10+ parameters with defaults become ``--option`` flags. Keyword-only
11+ parameters (after ``*``) always become options; without a default they
12+ are required. The parameter name ``dest`` is reserved and cannot be
13+ used::
1114
1215 class MyApp(cmd2.Cmd):
1316 @cmd2.with_annotated
@@ -55,6 +58,10 @@ def do_paint(
5558- ``tuple[int, str, float]`` -- mixed element types are not currently supported
5659 because argparse can only apply a single ``type=`` converter per argument
5760
61+ When combining ``Annotated`` with ``Optional``, the union must go
62+ *inside*: ``Annotated[T | None, meta]``. Writing
63+ ``Annotated[T, meta] | None`` is ambiguous and raises ``TypeError``.
64+
5865Note: ``Path`` and ``Enum`` types also get automatic tab completion via
5966``ArgparseCompleter`` type inference. This works for both ``@with_annotated``
6067and ``@with_argparser`` -- see the ``argparse_completer`` module.
@@ -301,9 +308,9 @@ def _make_collection_resolver(collection_type: type) -> Callable[..., dict[str,
301308 """Create a resolver for single-arg collections (list[T], set[T])."""
302309
303310 def _resolve (_tp : Any , args : tuple [Any , ...], * , has_default : bool = False , ** _ctx : Any ) -> dict [str , Any ]:
311+ nargs = '*' if has_default else '+'
304312 if len (args ) == 0 :
305313 # Bare list/tuple without type args -- treat as list[str]/set[str]
306- nargs = '*' if has_default else '+'
307314 return {
308315 'is_collection' : True ,
309316 'nargs' : nargs ,
@@ -314,7 +321,6 @@ def _resolve(_tp: Any, args: tuple[Any, ...], *, has_default: bool = False, **_c
314321 if len (args ) != 1 :
315322 return {} # pragma: no cover
316323 element_type , inner = _resolve_element (args [0 ])
317- nargs = '*' if has_default else '+'
318324 return {
319325 ** inner ,
320326 'is_collection' : True ,
@@ -331,14 +337,13 @@ def _resolve_tuple(_tp: Any, args: tuple[Any, ...], *, has_default: bool = False
331337 """Resolve tuple[T, ...] and tuple[T1, T2, ...]."""
332338 cast_kwargs = {'action' : _CollectionCastingAction , 'container_factory' : tuple }
333339
340+ nargs = '*' if has_default else '+'
334341 if not args :
335342 # Bare tuple without type args -- treat as tuple[str, ...]
336- nargs = '*' if has_default else '+'
337343 return {'is_collection' : True , 'nargs' : nargs , 'base_type' : str , ** cast_kwargs }
338344
339345 if len (args ) == 2 and args [1 ] is Ellipsis :
340346 element_type , inner = _resolve_element (args [0 ])
341- nargs = '*' if has_default else '+'
342347 return {** inner , 'is_collection' : True , 'nargs' : nargs , 'base_type' : element_type , ** cast_kwargs }
343348
344349 if Ellipsis not in args :
@@ -424,6 +429,26 @@ def _resolve_type(
424429 return tp , {}
425430
426431
432+ def _unwrap_optional (tp : type ) -> tuple [type , bool ]:
433+ """If *tp* is ``T | None``, return ``(T, True)``. Otherwise ``(tp, False)``.
434+
435+ Raises ``TypeError`` for ambiguous unions like ``str | int`` or ``str | int | None``.
436+ """
437+ origin = get_origin (tp )
438+ if origin is Union or origin is types .UnionType : # type: ignore[comparison-overlap]
439+ all_args = get_args (tp )
440+ non_none = [a for a in all_args if a is not type (None )]
441+ has_none = len (non_none ) < len (all_args )
442+ if len (non_none ) == 1 :
443+ if has_none :
444+ return non_none [0 ], True
445+ # Single-element union without None shouldn't happen, pass through
446+ return non_none [0 ], False # pragma: no cover
447+ type_names = ' | ' .join (a .__name__ if hasattr (a , '__name__' ) else str (a ) for a in non_none )
448+ raise TypeError (f"Union type { type_names } is ambiguous for auto-resolution. " )
449+ return tp , False
450+
451+
427452# ---------------------------------------------------------------------------
428453# Annotation resolution
429454# ---------------------------------------------------------------------------
@@ -437,13 +462,27 @@ def _resolve_annotation(
437462) -> tuple [dict [str , Any ], ArgMetadata , bool , bool ]:
438463 """Decompose a type annotation into ``(type_kwargs, metadata, is_positional, is_bool_flag)``.
439464
440- Peels in order: Annotated → Optional → type resolution.
465+ Peels ``Annotated`` then ``Optional``. The only supported way to combine
466+ ``Annotated`` with ``Optional`` is ``Annotated[T | None, meta]``.
467+ Writing ``Annotated[T, meta] | None`` is ambiguous and raises ``TypeError``.
441468 """
442469 tp = annotation
443470 metadata : ArgMetadata = None
444471 is_optional = False
445472
446- # 1. Annotated
473+ # 1. If top level is Optional wrapping Annotated, only allow
474+ # Annotated[T | None, meta] form (inner type must contain None).
475+ tp , unwrapped = _unwrap_optional (tp )
476+ if unwrapped :
477+ is_optional = True
478+ if get_origin (tp ) is Annotated : # type: ignore[comparison-overlap]
479+ inner_tp = get_args (tp )[0 ]
480+ inner_origin = get_origin (inner_tp )
481+ inner_is_union = inner_origin is Union or inner_origin is types .UnionType # type: ignore[comparison-overlap]
482+ if not (inner_is_union and type (None ) in get_args (inner_tp )):
483+ raise TypeError ("Annotated[T, meta] | None is ambiguous. Use Annotated[T | None, meta] instead." )
484+
485+ # 2. Peel Annotated to extract metadata
447486 if get_origin (tp ) is Annotated : # type: ignore[comparison-overlap]
448487 args = get_args (tp )
449488 tp = args [0 ]
@@ -452,18 +491,10 @@ def _resolve_annotation(
452491 metadata = meta
453492 break
454493
455- # 2. Optional / T | None
456- origin = get_origin (tp )
457- if origin is Union or origin is types .UnionType : # type: ignore[comparison-overlap]
458- args = [a for a in get_args (tp ) if a is not type (None )] # type: ignore[assignment]
459- if len (args ) == 1 :
460- tp = args [0 ]
461- is_optional = True
462- else :
463- # len > 1: ambiguous union (e.g. str | int)
464- # len == 0: all-None union -- unreachable via normal typing but handle defensively
465- type_names = ' | ' .join (a .__name__ if hasattr (a , '__name__' ) else str (a ) for a in args )
466- raise TypeError (f"Union type { type_names } is ambiguous for auto-resolution. " )
494+ # 3. Peel inner Optional (from Annotated[T | None, meta])
495+ tp , inner_unwrapped = _unwrap_optional (tp )
496+ if inner_unwrapped :
497+ is_optional = True
467498
468499 is_positional = isinstance (metadata , Argument ) or (
469500 not isinstance (metadata , Option ) and not has_default and not is_optional
@@ -548,12 +579,20 @@ def build_parser_from_function(func: Callable[..., Any]) -> argparse.ArgumentPar
548579 has_default = param .default is not inspect .Parameter .empty
549580 default = param .default if has_default else None
550581
582+ # Keyword-only params (after *) are always options, never positional
583+ is_kw_only = param .kind == inspect .Parameter .KEYWORD_ONLY
584+
551585 kwargs , metadata , positional , _is_bool_flag = _resolve_annotation (
552586 annotation ,
553- has_default = has_default ,
587+ has_default = has_default or is_kw_only ,
554588 default = default ,
555589 )
556590
591+ # Keyword-only without default becomes a required option
592+ if is_kw_only and not has_default :
593+ positional = False
594+ kwargs ['required' ] = True
595+
557596 if positional :
558597 parser .add_argument (name , ** kwargs )
559598 else :
0 commit comments