Skip to content

Commit fb0115f

Browse files
feat(annotated): allow honoring Enum _missing_ and support Enum | Enum
1 parent 2a9eee9 commit fb0115f

4 files changed

Lines changed: 370 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@
1313
- Experimental features
1414
- `@with_annotated` now supports `frozenset[T]` collection parameters, alongside the existing
1515
`list[T]`, `set[T]`, and `tuple[T, ...]` collection types.
16+
- `Argument`/`Option` accept a new `allow_unknown_entry` flag for `Enum` parameters. When set,
17+
a command-line token matched by neither a member value nor name is routed through the enum's
18+
own [`_missing_`](https://docs.python.org/3/library/enum.html#enum.Enum._missing_) hook, so an
19+
enum can resolve aliases, alternate spellings, or special keywords. A token that `_missing_`
20+
declines (returns `None`) is still rejected.
21+
- `@with_annotated` now supports a union of `Enum` subclasses (e.g. `EnumA | EnumB`). Each
22+
member keeps its own converter and a token resolves to the first member that accepts it, so
23+
when two members share a representation the earlier one in the union wins. Unions containing a
24+
`Literal` or any non-`Enum` member are still rejected as ambiguous.
1625

1726
## 4.0.0 (June 5, 2026)
1827

cmd2/annotated.py

Lines changed: 109 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -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(
180184
import enum
181185
import functools
182186
import inspect
187+
import operator
183188
import types
184189
from 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+
479507
def _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:
592629
def _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:
731810
def _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

Comments
 (0)