@@ -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``)
@@ -150,7 +153,8 @@ class is defined rather than on first command use. The one group rule that need
150153 or any custom class), which would silently arrive as a plain string. Supported scalars
151154 are ``str``, ``int``, ``float``, ``bool``, ``decimal.Decimal``, ``pathlib.Path``,
152155 ``enum.Enum`` subclasses, and ``Literal[...]`` (``str``/``Any``/``object`` pass through raw)
153- - ``str | int`` -- a union of multiple non-None types is ambiguous
156+ - ``str | int`` -- a union of multiple non-None types is ambiguous (unless every member is an
157+ ``enum.Enum`` subclass, which resolves by trying each member's converter in turn)
154158- ``tuple[int, str, float]`` -- mixed element types (argparse applies one ``type=`` per argument)
155159- ``*args: tuple[T, ...]`` (or any collection element) -- the annotation is each value's type,
156160 so a collection element means a tuple-of-collections; annotate the element, e.g. ``*args: str``
@@ -193,6 +197,7 @@ class is defined rather than on first command use. The one group rule that need
193197import enum
194198import functools
195199import inspect
200+ import operator
196201import types
197202from collections .abc import (
198203 Callable ,
@@ -324,15 +329,18 @@ def __init__(
324329 suppress_tab_hint : bool | None = None ,
325330 const : Any = _UNSET ,
326331 default : Any = _UNSET ,
332+ allow_unknown_entry : bool = False ,
327333 ** extra_kwargs : Any ,
328334 ) -> None :
329335 """Initialise shared metadata fields.
330336
331337 ``const`` is the value stored on a present flag with no argument (``Option`` only:
332338 ``store_const``/``append_const``); ``_UNSET`` distinguishes "no const" from ``const=None``.
333339 ``default`` mirrors the signature default (``Option(default=v)`` == ``... = v``); supplying
334- both, or ``argparse.SUPPRESS``, is rejected. ``extra_kwargs`` forwards any other
335- ``add_argument`` parameter (incl. those from
340+ both, or ``argparse.SUPPRESS``, is rejected. ``allow_unknown_entry`` only affects ``Enum``
341+ annotations: when set, a token matched by neither a member value nor name is routed through
342+ the enum's ``_missing_`` hook (for aliases / special keywords) instead of being rejected
343+ outright. ``extra_kwargs`` forwards any other ``add_argument`` parameter (incl. those from
336344 [`register_argparse_argument_parameter`][cmd2.argparse_utils.register_argparse_argument_parameter]) straight through.
337345 """
338346 reserved = self ._RESERVED_EXTRA_KWARGS & extra_kwargs .keys ()
@@ -360,6 +368,7 @@ def __init__(
360368 self .suppress_tab_hint = suppress_tab_hint
361369 self .const = const
362370 self .default = default
371+ self .allow_unknown_entry = allow_unknown_entry
363372 self .extra_kwargs = extra_kwargs
364373
365374 def to_kwargs (self ) -> dict [str , Any ]:
@@ -489,6 +498,25 @@ def _parse_bool(value: str) -> bool:
489498 raise argparse .ArgumentTypeError (f"invalid boolean value: { value !r} (choose from: 1, 0, true, false, yes, no, on, off)" )
490499
491500
501+ def _choice_text (choice : Any ) -> str :
502+ """Command-line spelling of a choice (the ``CompletionItem`` text, else ``str``)."""
503+ return choice .text if isinstance (choice , CompletionItem ) else str (choice )
504+
505+
506+ def _invalid_choice (value : str , choices : Iterable [Any ]) -> argparse .ArgumentTypeError :
507+ """Build the standard 'invalid choice' rejection, de-duplicating the listed choices."""
508+ valid = ", " .join (dict .fromkeys (_choice_text (c ) for c in choices ))
509+ return argparse .ArgumentTypeError (f"invalid choice: { value !r} (choose from { valid } )" )
510+
511+
512+ def _dedupe_choices (choices : Iterable [Any ]) -> list [Any ]:
513+ """Drop choices that share a command-line spelling, keeping the first occurrence."""
514+ by_text : dict [str , Any ] = {}
515+ for choice in choices :
516+ by_text .setdefault (_choice_text (choice ), choice )
517+ return list (by_text .values ())
518+
519+
492520def _make_literal_type (literal_values : list [Any ]) -> Callable [[str ], Any ]:
493521 """Create an argparse converter for a Literal's exact values."""
494522 value_map : dict [str , Any ] = {}
@@ -516,17 +544,20 @@ def _convert(value: str) -> Any:
516544 if type (v ) is bool and v == bool_value :
517545 return bool_value
518546
519- valid = ", " .join (str (v ) for v in literal_values )
520- raise argparse .ArgumentTypeError (f"invalid choice: { value !r} (choose from { valid } )" )
547+ raise _invalid_choice (value , literal_values )
521548
522549 _convert .__name__ = "literal"
523550 return _convert
524551
525552
526- def _make_enum_type (enum_class : type [enum .Enum ]) -> Callable [[str ], enum .Enum ]:
553+ def _make_enum_type (enum_class : type [enum .Enum ], * , allow_unknown_entry : bool = False ) -> Callable [[str ], enum .Enum ]:
527554 """Create an argparse *type* converter for an Enum class.
528555
529- Accepts both member *values* and member *names*.
556+ Accepts both member *values* and member *names*. When ``allow_unknown_entry`` is set, a token
557+ matched by neither is passed to the enum's own ``_missing_`` hook so it can resolve aliases,
558+ alternate spellings, or special keywords; a token ``_missing_`` declines to claim (returns
559+ ``None``) is still rejected. An enum that does not override ``_missing_`` inherits the default
560+ (which returns ``None``), so the flag is simply inert for it.
530561 """
531562 _value_map = {str (m .value ): m for m in enum_class }
532563
@@ -536,9 +567,15 @@ def _convert(value: str) -> enum.Enum:
536567 return member
537568 try :
538569 return enum_class [value ]
539- except KeyError as err :
540- valid = ", " .join (_value_map )
541- raise argparse .ArgumentTypeError (f"invalid choice: { value !r} (choose from { valid } )" ) from err
570+ except KeyError :
571+ pass
572+ if allow_unknown_entry :
573+ # Call _missing_ directly so its return is honored and any error it raises propagates
574+ # (rather than being masked as an "invalid choice"); a None return falls through below.
575+ resolved = enum_class ._missing_ (value )
576+ if isinstance (resolved , enum_class ):
577+ return resolved
578+ raise _invalid_choice (value , _value_map )
542579
543580 _convert .__name__ = enum_class .__name__
544581 _convert ._cmd2_enum_class = enum_class # type: ignore[attr-defined]
@@ -594,9 +631,9 @@ def _resolve_bool(_tp: Any, _args: tuple[Any, ...], *, is_positional: bool = Fal
594631 return _TypeResult (converter = _parse_bool , choices = list (_BOOL_CHOICES ))
595632
596633
597- def _resolve_element (tp : Any ) -> _TypeResult :
634+ def _resolve_element (tp : Any , * , allow_unknown_entry : bool = False ) -> _TypeResult :
598635 """Resolve a collection element type and reject nested collections."""
599- inner = _resolve_base_type (tp , is_positional = True )
636+ inner = _resolve_base_type (tp , is_positional = True , allow_unknown_entry = allow_unknown_entry )
600637 if inner .is_collection :
601638 raise TypeError ("Nested collections are not supported" )
602639 return inner
@@ -605,7 +642,7 @@ def _resolve_element(tp: Any) -> _TypeResult:
605642def _make_collection_resolver (collection_type : type ) -> Callable [..., _TypeResult ]:
606643 """Create a resolver for single-arg collections (list[T], set[T], frozenset[T])."""
607644
608- def _resolve (_tp : Any , args : tuple [Any , ...], ** _ctx : Any ) -> _TypeResult :
645+ def _resolve (_tp : Any , args : tuple [Any , ...], * , allow_unknown_entry : bool = False , * *_ctx : Any ) -> _TypeResult :
609646 if len (args ) == 0 :
610647 # Bare list/set/frozenset without type args -- treat as list[str]/set[str]/frozenset[str].
611648 return _TypeResult (is_collection = True , container_factory = collection_type )
@@ -614,7 +651,7 @@ def _resolve(_tp: Any, args: tuple[Any, ...], **_ctx: Any) -> _TypeResult:
614651 f"{ collection_type .__name__ } [...] with { len (args )} type arguments is not supported; "
615652 f"use { collection_type .__name__ } [T] with a single element type."
616653 )
617- element = _resolve_element (args [0 ])
654+ element = _resolve_element (args [0 ], allow_unknown_entry = allow_unknown_entry )
618655 return _TypeResult (
619656 converter = element .converter ,
620657 choices = element .choices ,
@@ -626,14 +663,14 @@ def _resolve(_tp: Any, args: tuple[Any, ...], **_ctx: Any) -> _TypeResult:
626663 return _resolve
627664
628665
629- def _resolve_tuple (_tp : Any , args : tuple [Any , ...], ** _ctx : Any ) -> _TypeResult :
666+ def _resolve_tuple (_tp : Any , args : tuple [Any , ...], * , allow_unknown_entry : bool = False , * *_ctx : Any ) -> _TypeResult :
630667 """Resolve tuple[T, ...] (variable) and tuple[T, T] (fixed arity)."""
631668 if not args :
632669 # Bare tuple without type args -- treat as tuple[str, ...].
633670 return _TypeResult (is_collection = True , container_factory = tuple )
634671
635672 if len (args ) == 2 and args [1 ] is Ellipsis :
636- element = _resolve_element (args [0 ])
673+ element = _resolve_element (args [0 ], allow_unknown_entry = allow_unknown_entry )
637674 return _TypeResult (
638675 converter = element .converter ,
639676 choices = element .choices ,
@@ -651,7 +688,7 @@ def _resolve_tuple(_tp: Any, args: tuple[Any, ...], **_ctx: Any) -> _TypeResult:
651688 f"can only apply a single type= converter per argument. "
652689 f"Use tuple[T, T] (same type) or tuple[T, ...] instead."
653690 )
654- element = _resolve_element (first )
691+ element = _resolve_element (first , allow_unknown_entry = allow_unknown_entry )
655692 return _TypeResult (
656693 converter = element .converter ,
657694 choices = element .choices ,
@@ -673,14 +710,54 @@ def _resolve_literal(_tp: Any, args: tuple[Any, ...], **_ctx: Any) -> _TypeResul
673710 return _TypeResult (converter = _make_literal_type (literal_values ), choices = literal_values )
674711
675712
676- def _resolve_enum (tp : Any , _args : tuple [Any , ...], ** _ctx : Any ) -> _TypeResult :
713+ def _resolve_enum (tp : Any , _args : tuple [Any , ...], * , allow_unknown_entry : bool = False , * *_ctx : Any ) -> _TypeResult :
677714 """Resolve Enum subclasses into converter + choices."""
678715 return _TypeResult (
679- converter = _make_enum_type (tp ),
716+ converter = _make_enum_type (tp , allow_unknown_entry = allow_unknown_entry ),
680717 choices = [CompletionItem (m , text = str (m .value ), display_meta = m .name ) for m in tp ],
681718 )
682719
683720
721+ def _is_enum (tp : Any ) -> bool :
722+ """Whether *tp* is an ``enum.Enum`` subclass."""
723+ return isinstance (tp , type ) and issubclass (tp , enum .Enum )
724+
725+
726+ def _resolve_union (_tp : Any , args : tuple [Any , ...], * , allow_unknown_entry : bool = False , ** _ctx : Any ) -> _TypeResult :
727+ """Resolve a union whose non-``None`` members are all Enums by trying each member's converter.
728+
729+ Each member keeps its own converter, so member values, member names, and any ``_missing_``
730+ behavior (via ``allow_unknown_entry``) are preserved. A token is resolved by the first member
731+ that accepts it, so when two members share a representation the earlier union member wins. A
732+ union with any non-Enum member (including a ``Literal``) is rejected as ambiguous.
733+
734+ A member converter signals "not mine, try the next member" by raising
735+ ``argparse.ArgumentTypeError``; any other exception (e.g. a custom ``_missing_`` that *raises*
736+ rather than returning ``None``) is a hard error and propagates, so order matters -- place a
737+ strict/raising Enum after the members that should get first refusal.
738+ """
739+ non_none = [a for a in args if a is not type (None )]
740+ if not all (_is_enum (a ) for a in non_none ):
741+ type_names = " | " .join (_type_name (a ) for a in non_none )
742+ raise TypeError (f"Union type { type_names } is ambiguous for auto-resolution." )
743+
744+ parts = [_resolve_base_type (member , allow_unknown_entry = allow_unknown_entry ) for member in non_none ]
745+ # Every part is an Enum (guarded above), so each has a converter; the None-filter keeps mypy happy.
746+ converters = [part .converter for part in parts if part .converter is not None ]
747+ choices = _dedupe_choices (choice for part in parts for choice in (part .choices or []))
748+
749+ def _convert (value : str ) -> Any :
750+ for converter in converters :
751+ try :
752+ return converter (value )
753+ except argparse .ArgumentTypeError :
754+ continue # this member rejected the token; try the next one
755+ raise _invalid_choice (value , choices )
756+
757+ _convert .__name__ = "union"
758+ return _TypeResult (converter = _convert , choices = choices )
759+
760+
684761# -- Registry -----------------------------------------------------------------
685762
686763_TYPE_TABLE : dict [Any , Callable [..., _TypeResult ]] = {
@@ -694,6 +771,8 @@ def _resolve_enum(tp: Any, _args: tuple[Any, ...], **_ctx: Any) -> _TypeResult:
694771 float : _make_simple_resolver (float ),
695772 int : _make_simple_resolver (int ),
696773 Literal : _resolve_literal ,
774+ Union : _resolve_union ,
775+ types .UnionType : _resolve_union ,
697776 frozenset : _make_collection_resolver (frozenset ),
698777 list : _make_collection_resolver (list ),
699778 set : _make_collection_resolver (set ),
@@ -713,7 +792,7 @@ def _type_name(tp: Any) -> str:
713792_PASSTHROUGH_TYPES = frozenset ({str , object , Any , inspect .Parameter .empty })
714793
715794
716- def _resolve_base_type (tp : Any , * , is_positional : bool = False ) -> _TypeResult :
795+ def _resolve_base_type (tp : Any , * , is_positional : bool = False , allow_unknown_entry : bool = False ) -> _TypeResult :
717796 """Resolve a declared type into a :class:`_TypeResult` via the registry.
718797
719798 Lookup order: ``get_origin(tp)`` -> ``tp`` -> ``issubclass`` fallback -> passthrough.
@@ -730,7 +809,7 @@ def _resolve_base_type(tp: Any, *, is_positional: bool = False) -> _TypeResult:
730809 break
731810
732811 if resolver is not None :
733- return resolver (tp , args , is_positional = is_positional )
812+ return resolver (tp , args , is_positional = is_positional , allow_unknown_entry = allow_unknown_entry )
734813 if tp in _PASSTHROUGH_TYPES :
735814 return _TypeResult ()
736815 raise TypeError (
@@ -744,7 +823,9 @@ def _resolve_base_type(tp: Any, *, is_positional: bool = False) -> _TypeResult:
744823def _unwrap_optional (tp : Any ) -> tuple [Any , bool ]:
745824 """If *tp* is ``T | None``, return ``(T, True)``. Otherwise ``(tp, False)``.
746825
747- Raises ``TypeError`` for ambiguous unions like ``str | int`` or ``str | int | None``.
826+ Only the ``None`` is stripped here. A multi-member union (with ``None`` removed) is handed back
827+ intact for :func:`_resolve_union` to accept (all-Enum) or reject (ambiguous); that decision lives
828+ there alone, so this helper never validates union members itself.
748829 """
749830 origin = get_origin (tp )
750831 if origin is Union or origin is types .UnionType : # type: ignore[comparison-overlap]
@@ -758,8 +839,8 @@ def _unwrap_optional(tp: Any) -> tuple[Any, bool]:
758839 f"Unexpected single-element Union without None: Union[{ non_none [0 ]} ]. "
759840 f"Use the type directly instead of wrapping in Union."
760841 )
761- type_names = " | " . join ( _type_name ( a ) for a in non_none )
762- raise TypeError ( f"Union type { type_names } is ambiguous for auto-resolution." )
842+ # Rebuild the union without its None member and let _resolve_union judge it.
843+ return functools . reduce ( operator . or_ , non_none ), has_none
763844 return tp , False
764845
765846
@@ -1128,8 +1209,11 @@ def _apply_type(self) -> None:
11281209 Rather than raise here -- which would let build order decide the message -- the error is captured
11291210 so :data:`_CONSTRAINTS` can rank it against more specific rules and raise the winner.
11301211 """
1212+ allow_unknown_entry = self .metadata .allow_unknown_entry if self .metadata is not None else False
11311213 try :
1132- result = _resolve_base_type (self .inner_type , is_positional = self .is_positional )
1214+ result = _resolve_base_type (
1215+ self .inner_type , is_positional = self .is_positional , allow_unknown_entry = allow_unknown_entry
1216+ )
11331217 except TypeError as exc :
11341218 self .build_error = exc
11351219 return
0 commit comments