Skip to content

Commit 8cf8d62

Browse files
feat(annotated): allow honoring Enum _missing_ and support Enum | Enum
1 parent ec22b82 commit 8cf8d62

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
@@ -23,6 +23,15 @@
2323
when the class is defined instead of being deferred to first command use where the error was
2424
swallowed. The checks read parameter names only, so forward-referenced annotations still
2525
decorate cleanly.
26+
- `Argument`/`Option` accept a new `allow_unknown_entry` flag for `Enum` parameters. When set,
27+
a command-line token matched by neither a member value nor name is routed through the enum's
28+
own [`_missing_`](https://docs.python.org/3/library/enum.html#enum.Enum._missing_) hook, so an
29+
enum can resolve aliases, alternate spellings, or special keywords. A token that `_missing_`
30+
declines (returns `None`) is still rejected.
31+
- `@with_annotated` now supports a union of `Enum` subclasses (e.g. `EnumA | EnumB`). Each
32+
member keeps its own converter and a token resolves to the first member that accepts it, so
33+
when two members share a representation the earlier one in the union wins. Unions containing a
34+
`Literal` or any non-`Enum` member are still rejected as ambiguous.
2635

2736
## 4.0.0 (June 5, 2026)
2837

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``)
@@ -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
193197
import enum
194198
import functools
195199
import inspect
200+
import operator
196201
import types
197202
from 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+
492520
def _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:
605642
def _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:
744823
def _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

Comments
 (0)