@@ -54,6 +54,9 @@ def do_paint(
5454- positional ``bool`` -- parsed from ``true/false``, ``yes/no``, ``on/off``, ``1/0``
5555- ``pathlib.Path`` -- sets ``type=Path``
5656- ``enum.Enum`` subclass -- ``type=converter``, ``choices`` from member values
57+ - a union of Enums (e.g. ``EnumA | EnumB``) -- each member keeps its own converter; a token resolves
58+ to the first member that accepts it, and the merged ``choices`` are the concatenation of each
59+ member's choices
5760- ``decimal.Decimal`` -- sets ``type=Decimal``
5861- ``Literal[...]`` -- ``type=converter`` and ``choices`` from the literal values
5962- ``list[T]`` / ``set[T]`` / ``frozenset[T]`` / ``tuple[T, ...]`` -- ``nargs='+'`` (or ``'*'`` with a default or ``| None``)
@@ -137,7 +140,8 @@ def do_paint(
137140 or any custom class), which would silently arrive as a plain string. Supported scalars
138141 are ``str``, ``int``, ``float``, ``bool``, ``decimal.Decimal``, ``pathlib.Path``,
139142 ``enum.Enum`` subclasses, and ``Literal[...]`` (``str``/``Any``/``object`` pass through raw)
140- - ``str | int`` -- a union of multiple non-None types is ambiguous
143+ - ``str | int`` -- a union of multiple non-None types is ambiguous (unless every member is an
144+ ``enum.Enum`` subclass, which resolves by trying each member's converter in turn)
141145- ``tuple[int, str, float]`` -- mixed element types (argparse applies one ``type=`` per argument)
142146- ``*args: tuple[T, ...]`` (or any collection element) -- the annotation is each value's type,
143147 so a collection element means a tuple-of-collections; annotate the element, e.g. ``*args: str``
@@ -180,6 +184,7 @@ def do_paint(
180184import enum
181185import functools
182186import inspect
187+ import operator
183188import types
184189from collections .abc import (
185190 Callable ,
@@ -311,15 +316,18 @@ def __init__(
311316 suppress_tab_hint : bool | None = None ,
312317 const : Any = _UNSET ,
313318 default : Any = _UNSET ,
319+ allow_unknown_entry : bool = False ,
314320 ** extra_kwargs : Any ,
315321 ) -> None :
316322 """Initialise shared metadata fields.
317323
318324 ``const`` is the value stored on a present flag with no argument (``Option`` only:
319325 ``store_const``/``append_const``); ``_UNSET`` distinguishes "no const" from ``const=None``.
320326 ``default`` mirrors the signature default (``Option(default=v)`` == ``... = v``); supplying
321- both, or ``argparse.SUPPRESS``, is rejected. ``extra_kwargs`` forwards any other
322- ``add_argument`` parameter (incl. those from
327+ both, or ``argparse.SUPPRESS``, is rejected. ``allow_unknown_entry`` only affects ``Enum``
328+ annotations: when set, a token matched by neither a member value nor name is routed through
329+ the enum's ``_missing_`` hook (for aliases / special keywords) instead of being rejected
330+ outright. ``extra_kwargs`` forwards any other ``add_argument`` parameter (incl. those from
323331 [`register_argparse_argument_parameter`][cmd2.argparse_utils.register_argparse_argument_parameter]) straight through.
324332 """
325333 reserved = self ._RESERVED_EXTRA_KWARGS & extra_kwargs .keys ()
@@ -347,6 +355,7 @@ def __init__(
347355 self .suppress_tab_hint = suppress_tab_hint
348356 self .const = const
349357 self .default = default
358+ self .allow_unknown_entry = allow_unknown_entry
350359 self .extra_kwargs = extra_kwargs
351360
352361 def to_kwargs (self ) -> dict [str , Any ]:
@@ -476,6 +485,25 @@ def _parse_bool(value: str) -> bool:
476485 raise argparse .ArgumentTypeError (f"invalid boolean value: { value !r} (choose from: 1, 0, true, false, yes, no, on, off)" )
477486
478487
488+ def _choice_text (choice : Any ) -> str :
489+ """Command-line spelling of a choice (the ``CompletionItem`` text, else ``str``)."""
490+ return choice .text if isinstance (choice , CompletionItem ) else str (choice )
491+
492+
493+ def _invalid_choice (value : str , choices : Iterable [Any ]) -> argparse .ArgumentTypeError :
494+ """Build the standard 'invalid choice' rejection, de-duplicating the listed choices."""
495+ valid = ", " .join (dict .fromkeys (_choice_text (c ) for c in choices ))
496+ return argparse .ArgumentTypeError (f"invalid choice: { value !r} (choose from { valid } )" )
497+
498+
499+ def _dedupe_choices (choices : Iterable [Any ]) -> list [Any ]:
500+ """Drop choices that share a command-line spelling, keeping the first occurrence."""
501+ by_text : dict [str , Any ] = {}
502+ for choice in choices :
503+ by_text .setdefault (_choice_text (choice ), choice )
504+ return list (by_text .values ())
505+
506+
479507def _make_literal_type (literal_values : list [Any ]) -> Callable [[str ], Any ]:
480508 """Create an argparse converter for a Literal's exact values."""
481509 value_map : dict [str , Any ] = {}
@@ -503,17 +531,20 @@ def _convert(value: str) -> Any:
503531 if type (v ) is bool and v == bool_value :
504532 return bool_value
505533
506- valid = ", " .join (str (v ) for v in literal_values )
507- raise argparse .ArgumentTypeError (f"invalid choice: { value !r} (choose from { valid } )" )
534+ raise _invalid_choice (value , literal_values )
508535
509536 _convert .__name__ = "literal"
510537 return _convert
511538
512539
513- def _make_enum_type (enum_class : type [enum .Enum ]) -> Callable [[str ], enum .Enum ]:
540+ def _make_enum_type (enum_class : type [enum .Enum ], * , allow_unknown_entry : bool = False ) -> Callable [[str ], enum .Enum ]:
514541 """Create an argparse *type* converter for an Enum class.
515542
516- Accepts both member *values* and member *names*.
543+ Accepts both member *values* and member *names*. When ``allow_unknown_entry`` is set, a token
544+ matched by neither is passed to the enum's own ``_missing_`` hook so it can resolve aliases,
545+ alternate spellings, or special keywords; a token ``_missing_`` declines to claim (returns
546+ ``None``) is still rejected. An enum that does not override ``_missing_`` inherits the default
547+ (which returns ``None``), so the flag is simply inert for it.
517548 """
518549 _value_map = {str (m .value ): m for m in enum_class }
519550
@@ -523,9 +554,15 @@ def _convert(value: str) -> enum.Enum:
523554 return member
524555 try :
525556 return enum_class [value ]
526- except KeyError as err :
527- valid = ", " .join (_value_map )
528- raise argparse .ArgumentTypeError (f"invalid choice: { value !r} (choose from { valid } )" ) from err
557+ except KeyError :
558+ pass
559+ if allow_unknown_entry :
560+ # Call _missing_ directly so its return is honored and any error it raises propagates
561+ # (rather than being masked as an "invalid choice"); a None return falls through below.
562+ resolved = enum_class ._missing_ (value )
563+ if isinstance (resolved , enum_class ):
564+ return resolved
565+ raise _invalid_choice (value , _value_map )
529566
530567 _convert .__name__ = enum_class .__name__
531568 _convert ._cmd2_enum_class = enum_class # type: ignore[attr-defined]
@@ -581,9 +618,9 @@ def _resolve_bool(_tp: Any, _args: tuple[Any, ...], *, is_positional: bool = Fal
581618 return _TypeResult (converter = _parse_bool , choices = list (_BOOL_CHOICES ))
582619
583620
584- def _resolve_element (tp : Any ) -> _TypeResult :
621+ def _resolve_element (tp : Any , * , allow_unknown_entry : bool = False ) -> _TypeResult :
585622 """Resolve a collection element type and reject nested collections."""
586- inner = _resolve_base_type (tp , is_positional = True )
623+ inner = _resolve_base_type (tp , is_positional = True , allow_unknown_entry = allow_unknown_entry )
587624 if inner .is_collection :
588625 raise TypeError ("Nested collections are not supported" )
589626 return inner
@@ -592,7 +629,7 @@ def _resolve_element(tp: Any) -> _TypeResult:
592629def _make_collection_resolver (collection_type : type ) -> Callable [..., _TypeResult ]:
593630 """Create a resolver for single-arg collections (list[T], set[T], frozenset[T])."""
594631
595- def _resolve (_tp : Any , args : tuple [Any , ...], ** _ctx : Any ) -> _TypeResult :
632+ def _resolve (_tp : Any , args : tuple [Any , ...], * , allow_unknown_entry : bool = False , * *_ctx : Any ) -> _TypeResult :
596633 if len (args ) == 0 :
597634 # Bare list/set/frozenset without type args -- treat as list[str]/set[str]/frozenset[str].
598635 return _TypeResult (is_collection = True , container_factory = collection_type )
@@ -601,7 +638,7 @@ def _resolve(_tp: Any, args: tuple[Any, ...], **_ctx: Any) -> _TypeResult:
601638 f"{ collection_type .__name__ } [...] with { len (args )} type arguments is not supported; "
602639 f"use { collection_type .__name__ } [T] with a single element type."
603640 )
604- element = _resolve_element (args [0 ])
641+ element = _resolve_element (args [0 ], allow_unknown_entry = allow_unknown_entry )
605642 return _TypeResult (
606643 converter = element .converter ,
607644 choices = element .choices ,
@@ -613,14 +650,14 @@ def _resolve(_tp: Any, args: tuple[Any, ...], **_ctx: Any) -> _TypeResult:
613650 return _resolve
614651
615652
616- def _resolve_tuple (_tp : Any , args : tuple [Any , ...], ** _ctx : Any ) -> _TypeResult :
653+ def _resolve_tuple (_tp : Any , args : tuple [Any , ...], * , allow_unknown_entry : bool = False , * *_ctx : Any ) -> _TypeResult :
617654 """Resolve tuple[T, ...] (variable) and tuple[T, T] (fixed arity)."""
618655 if not args :
619656 # Bare tuple without type args -- treat as tuple[str, ...].
620657 return _TypeResult (is_collection = True , container_factory = tuple )
621658
622659 if len (args ) == 2 and args [1 ] is Ellipsis :
623- element = _resolve_element (args [0 ])
660+ element = _resolve_element (args [0 ], allow_unknown_entry = allow_unknown_entry )
624661 return _TypeResult (
625662 converter = element .converter ,
626663 choices = element .choices ,
@@ -638,7 +675,7 @@ def _resolve_tuple(_tp: Any, args: tuple[Any, ...], **_ctx: Any) -> _TypeResult:
638675 f"can only apply a single type= converter per argument. "
639676 f"Use tuple[T, T] (same type) or tuple[T, ...] instead."
640677 )
641- element = _resolve_element (first )
678+ element = _resolve_element (first , allow_unknown_entry = allow_unknown_entry )
642679 return _TypeResult (
643680 converter = element .converter ,
644681 choices = element .choices ,
@@ -660,14 +697,54 @@ def _resolve_literal(_tp: Any, args: tuple[Any, ...], **_ctx: Any) -> _TypeResul
660697 return _TypeResult (converter = _make_literal_type (literal_values ), choices = literal_values )
661698
662699
663- def _resolve_enum (tp : Any , _args : tuple [Any , ...], ** _ctx : Any ) -> _TypeResult :
700+ def _resolve_enum (tp : Any , _args : tuple [Any , ...], * , allow_unknown_entry : bool = False , * *_ctx : Any ) -> _TypeResult :
664701 """Resolve Enum subclasses into converter + choices."""
665702 return _TypeResult (
666- converter = _make_enum_type (tp ),
703+ converter = _make_enum_type (tp , allow_unknown_entry = allow_unknown_entry ),
667704 choices = [CompletionItem (m , text = str (m .value ), display_meta = m .name ) for m in tp ],
668705 )
669706
670707
708+ def _is_enum (tp : Any ) -> bool :
709+ """Whether *tp* is an ``enum.Enum`` subclass."""
710+ return isinstance (tp , type ) and issubclass (tp , enum .Enum )
711+
712+
713+ def _resolve_union (_tp : Any , args : tuple [Any , ...], * , allow_unknown_entry : bool = False , ** _ctx : Any ) -> _TypeResult :
714+ """Resolve a union whose non-``None`` members are all Enums by trying each member's converter.
715+
716+ Each member keeps its own converter, so member values, member names, and any ``_missing_``
717+ behavior (via ``allow_unknown_entry``) are preserved. A token is resolved by the first member
718+ that accepts it, so when two members share a representation the earlier union member wins. A
719+ union with any non-Enum member (including a ``Literal``) is rejected as ambiguous.
720+
721+ A member converter signals "not mine, try the next member" by raising
722+ ``argparse.ArgumentTypeError``; any other exception (e.g. a custom ``_missing_`` that *raises*
723+ rather than returning ``None``) is a hard error and propagates, so order matters -- place a
724+ strict/raising Enum after the members that should get first refusal.
725+ """
726+ non_none = [a for a in args if a is not type (None )]
727+ if not all (_is_enum (a ) for a in non_none ):
728+ type_names = " | " .join (_type_name (a ) for a in non_none )
729+ raise TypeError (f"Union type { type_names } is ambiguous for auto-resolution." )
730+
731+ parts = [_resolve_base_type (member , allow_unknown_entry = allow_unknown_entry ) for member in non_none ]
732+ # Every part is an Enum (guarded above), so each has a converter; the None-filter keeps mypy happy.
733+ converters = [part .converter for part in parts if part .converter is not None ]
734+ choices = _dedupe_choices (choice for part in parts for choice in (part .choices or []))
735+
736+ def _convert (value : str ) -> Any :
737+ for converter in converters :
738+ try :
739+ return converter (value )
740+ except argparse .ArgumentTypeError :
741+ continue # this member rejected the token; try the next one
742+ raise _invalid_choice (value , choices )
743+
744+ _convert .__name__ = "union"
745+ return _TypeResult (converter = _convert , choices = choices )
746+
747+
671748# -- Registry -----------------------------------------------------------------
672749
673750_TYPE_TABLE : dict [Any , Callable [..., _TypeResult ]] = {
@@ -681,6 +758,8 @@ def _resolve_enum(tp: Any, _args: tuple[Any, ...], **_ctx: Any) -> _TypeResult:
681758 float : _make_simple_resolver (float ),
682759 int : _make_simple_resolver (int ),
683760 Literal : _resolve_literal ,
761+ Union : _resolve_union ,
762+ types .UnionType : _resolve_union ,
684763 frozenset : _make_collection_resolver (frozenset ),
685764 list : _make_collection_resolver (list ),
686765 set : _make_collection_resolver (set ),
@@ -700,7 +779,7 @@ def _type_name(tp: Any) -> str:
700779_PASSTHROUGH_TYPES = frozenset ({str , object , Any , inspect .Parameter .empty })
701780
702781
703- def _resolve_base_type (tp : Any , * , is_positional : bool = False ) -> _TypeResult :
782+ def _resolve_base_type (tp : Any , * , is_positional : bool = False , allow_unknown_entry : bool = False ) -> _TypeResult :
704783 """Resolve a declared type into a :class:`_TypeResult` via the registry.
705784
706785 Lookup order: ``get_origin(tp)`` -> ``tp`` -> ``issubclass`` fallback -> passthrough.
@@ -717,7 +796,7 @@ def _resolve_base_type(tp: Any, *, is_positional: bool = False) -> _TypeResult:
717796 break
718797
719798 if resolver is not None :
720- return resolver (tp , args , is_positional = is_positional )
799+ return resolver (tp , args , is_positional = is_positional , allow_unknown_entry = allow_unknown_entry )
721800 if tp in _PASSTHROUGH_TYPES :
722801 return _TypeResult ()
723802 raise TypeError (
@@ -731,7 +810,9 @@ def _resolve_base_type(tp: Any, *, is_positional: bool = False) -> _TypeResult:
731810def _unwrap_optional (tp : Any ) -> tuple [Any , bool ]:
732811 """If *tp* is ``T | None``, return ``(T, True)``. Otherwise ``(tp, False)``.
733812
734- Raises ``TypeError`` for ambiguous unions like ``str | int`` or ``str | int | None``.
813+ Only the ``None`` is stripped here. A multi-member union (with ``None`` removed) is handed back
814+ intact for :func:`_resolve_union` to accept (all-Enum) or reject (ambiguous); that decision lives
815+ there alone, so this helper never validates union members itself.
735816 """
736817 origin = get_origin (tp )
737818 if origin is Union or origin is types .UnionType : # type: ignore[comparison-overlap]
@@ -745,8 +826,8 @@ def _unwrap_optional(tp: Any) -> tuple[Any, bool]:
745826 f"Unexpected single-element Union without None: Union[{ non_none [0 ]} ]. "
746827 f"Use the type directly instead of wrapping in Union."
747828 )
748- type_names = " | " . join ( _type_name ( a ) for a in non_none )
749- raise TypeError ( f"Union type { type_names } is ambiguous for auto-resolution." )
829+ # Rebuild the union without its None member and let _resolve_union judge it.
830+ return functools . reduce ( operator . or_ , non_none ), has_none
750831 return tp , False
751832
752833
@@ -1115,8 +1196,11 @@ def _apply_type(self) -> None:
11151196 Rather than raise here -- which would let build order decide the message -- the error is captured
11161197 so :data:`_CONSTRAINTS` can rank it against more specific rules and raise the winner.
11171198 """
1199+ allow_unknown_entry = self .metadata .allow_unknown_entry if self .metadata is not None else False
11181200 try :
1119- result = _resolve_base_type (self .inner_type , is_positional = self .is_positional )
1201+ result = _resolve_base_type (
1202+ self .inner_type , is_positional = self .is_positional , allow_unknown_entry = allow_unknown_entry
1203+ )
11201204 except TypeError as exc :
11211205 self .build_error = exc
11221206 return
0 commit comments