Skip to content

Commit 875dbb1

Browse files
feat(annotated): add converter and preprocess hooks for custom conversion
1 parent 49a176a commit 875dbb1

5 files changed

Lines changed: 501 additions & 26 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@
3535
aborting the union, and a merged "choose from ..." error is raised only when every member
3636
declines. Unions containing a `Literal` or any non-`Enum` member are still rejected as
3737
ambiguous.
38+
- `Argument`/`Option` accept new `converter` and `preprocess` hooks for custom string
39+
conversion, giving `@with_annotated` parity with a hand-built `add_argument(type=...)` (a raw
40+
`type=` in the metadata is still rejected). `converter` is a `Callable[[str], Any]` that
41+
replaces the inferred `type=` converter; because it owns the conversion, the annotation may be
42+
any type (an otherwise unsupported type like `datetime`, or an otherwise-ambiguous
43+
multi-member union like `int | str`, becomes legal) and the inferred `choices`/completer are
44+
dropped. `preprocess` is a `Callable[[str], str]` that runs before the inferred converter,
45+
transforming the raw token while keeping the inferred `type=`/`choices`/completer (e.g.
46+
`preprocess=str.lower` on an `Enum`). The two are mutually exclusive on one parameter and
47+
neither may be combined with a value-less action.
3848

3949
## 4.0.0 (June 5, 2026)
4050

‎cmd2/annotated.py‎

Lines changed: 156 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,29 @@ class is defined rather than on first command use. The one group rule that need
190190
takes precedence over a *type-inferred* completer (the ``Path`` completer is dropped so the choices
191191
drive both validation and completion). A ``choices_provider`` / ``completer`` you supply yourself
192192
still wins over ``choices=``.
193+
194+
Two hooks customize the string -> value conversion, parity with a hand-built ``add_argument(type=...)``
195+
(a raw ``type=`` in the metadata is rejected; use these instead):
196+
197+
- ``converter`` -- a ``Callable[[str], Any]`` that *replaces* the inferred ``type=`` converter. Because
198+
the converter owns the conversion, the annotation is no longer required to be a supported scalar -- any
199+
type is legal (``Annotated[datetime, Argument(converter=parse_iso)]``), and the "unsupported type" error
200+
is suppressed. The inferred ``choices`` and completer (which described the *inferred* value-space) are
201+
dropped; supply ``choices=`` / ``completer`` / ``choices_provider`` to re-add them (an explicit
202+
``choices=`` is still run through your converter). argparse applies it per token, so on a ``list[T]`` it
203+
converts each value; a non-collection annotation such as ``Any`` keeps a single token, so the converter
204+
may itself return a collection (``Annotated[Any, Option('--idx', converter=parse_intset)]``).
205+
- ``preprocess`` -- a ``Callable[[str], str]`` that runs *before* the inferred converter, transforming the
206+
raw token while *keeping* the inferred ``type=``, ``choices``, completer, and coercion. Use it to
207+
normalize input for a type that already has rich inference, e.g. ``Annotated[Color, Argument(preprocess=
208+
str.lower)]`` accepts ``RED`` while still showing the ``Color`` choices, or ``Annotated[Path,
209+
Argument(preprocess=os.path.expanduser)]`` keeps the path completer. With a plain ``str`` (no inferred
210+
converter) it becomes the ``type=`` directly.
211+
212+
``converter`` and ``preprocess`` are mutually exclusive on one parameter (fold the preprocessing into the
213+
converter, which already receives the raw token), and neither may be combined with a value-less action
214+
(``store_true`` / ``store_false`` / ``count`` / ``store_const`` / ``append_const``), which consumes no
215+
token to convert.
193216
"""
194217

195218
import argparse
@@ -211,7 +234,6 @@ class is defined rather than on first command use. The one group rule that need
211234
)
212235
from pathlib import Path
213236
from typing import (
214-
TYPE_CHECKING,
215237
Annotated,
216238
Any,
217239
ClassVar,
@@ -231,6 +253,7 @@ class is defined rather than on first command use. The one group rule that need
231253
from rich.table import Column
232254

233255
from . import constants
256+
from .argparse_completer import ArgparseCompleter
234257
from .argparse_utils import (
235258
ArgparseCommandSpec,
236259
Cmd2ArgumentParser,
@@ -246,9 +269,6 @@ class is defined rather than on first command use. The one group rule that need
246269
UnboundCompleter,
247270
)
248271

249-
if TYPE_CHECKING: # pragma: no cover
250-
from .argparse_completer import ArgparseCompleter
251-
252272
#: ``nargs`` values accepted by cmd2's patched ``add_argument`` (incl. ranged tuples).
253273
_NargsValue = int | str | tuple[int] | tuple[int, int] | tuple[int, float]
254274

@@ -276,7 +296,7 @@ class Cmd2ParserKwargs(TypedDict, total=False):
276296
exit_on_error: bool
277297
suggest_on_error: bool
278298
color: bool
279-
completer_class: "type[ArgparseCompleter] | None"
299+
completer_class: type[ArgparseCompleter] | None
280300

281301

282302
# ---------------------------------------------------------------------------
@@ -330,6 +350,8 @@ def __init__(
330350
const: Any = _UNSET,
331351
default: Any = _UNSET,
332352
allow_unknown_entry: bool = False,
353+
converter: Callable[[str], Any] | None = None,
354+
preprocess: Callable[[str], str] | None = None,
333355
**extra_kwargs: Any,
334356
) -> None:
335357
"""Initialise shared metadata fields.
@@ -340,15 +362,22 @@ def __init__(
340362
both, or ``argparse.SUPPRESS``, is rejected. ``allow_unknown_entry`` only affects ``Enum``
341363
annotations: when set, a token matched by neither a member value nor name is routed through
342364
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
365+
outright. ``converter`` replaces the inferred ``type=`` converter (and makes any annotation
366+
type legal); ``preprocess`` runs before the inferred converter to transform the raw token while
367+
keeping the inferred choices/completer. The two are mutually exclusive and neither combines with
368+
a value-less action (see the module docstring). ``extra_kwargs`` forwards any other
369+
``add_argument`` parameter (incl. those from
344370
[`register_argparse_argument_parameter`][cmd2.argparse_utils.register_argparse_argument_parameter]) straight through.
345371
"""
346372
reserved = self._RESERVED_EXTRA_KWARGS & extra_kwargs.keys()
347373
if reserved:
348374
name = sorted(reserved)[0]
349375
# Per-key remediation hint for the reserved kwarg.
350376
hint = {
351-
"type": "The converter is derived from the parameter annotation; change the annotation instead.",
377+
"type": (
378+
"The converter is derived from the parameter annotation; change the annotation, or pass "
379+
"converter= for a custom string -> value callable (preprocess= to transform the token first)."
380+
),
352381
"dest": "The dest is the parameter name; rename the parameter instead.",
353382
"action": "Use Option(action=...) (only Option supports an action; Argument is always positional).",
354383
"required": (
@@ -369,6 +398,8 @@ def __init__(
369398
self.const = const
370399
self.default = default
371400
self.allow_unknown_entry = allow_unknown_entry
401+
self.converter = converter
402+
self.preprocess = preprocess
372403
self.extra_kwargs = extra_kwargs
373404

374405
def to_kwargs(self) -> dict[str, Any]:
@@ -631,9 +662,9 @@ def _resolve_bool(_tp: Any, _args: tuple[Any, ...], *, is_positional: bool = Fal
631662
return _TypeResult(converter=_parse_bool, choices=list(_BOOL_CHOICES))
632663

633664

634-
def _resolve_element(tp: Any, *, allow_unknown_entry: bool = False) -> _TypeResult:
665+
def _resolve_element(tp: Any, *, allow_unknown_entry: bool = False, has_converter: bool = False) -> _TypeResult:
635666
"""Resolve a collection element type and reject nested collections."""
636-
inner = _resolve_base_type(tp, is_positional=True, allow_unknown_entry=allow_unknown_entry)
667+
inner = _resolve_base_type(tp, is_positional=True, allow_unknown_entry=allow_unknown_entry, has_converter=has_converter)
637668
if inner.is_collection:
638669
raise TypeError("Nested collections are not supported")
639670
return inner
@@ -642,7 +673,14 @@ def _resolve_element(tp: Any, *, allow_unknown_entry: bool = False) -> _TypeResu
642673
def _make_collection_resolver(collection_type: type) -> Callable[..., _TypeResult]:
643674
"""Create a resolver for single-arg collections (list[T], set[T], frozenset[T])."""
644675

645-
def _resolve(_tp: Any, args: tuple[Any, ...], *, allow_unknown_entry: bool = False, **_ctx: Any) -> _TypeResult:
676+
def _resolve(
677+
_tp: Any,
678+
args: tuple[Any, ...],
679+
*,
680+
allow_unknown_entry: bool = False,
681+
has_converter: bool = False,
682+
**_ctx: Any,
683+
) -> _TypeResult:
646684
if len(args) == 0:
647685
# Bare list/set/frozenset without type args -- treat as list[str]/set[str]/frozenset[str].
648686
return _TypeResult(is_collection=True, container_factory=collection_type)
@@ -651,7 +689,7 @@ def _resolve(_tp: Any, args: tuple[Any, ...], *, allow_unknown_entry: bool = Fal
651689
f"{collection_type.__name__}[...] with {len(args)} type arguments is not supported; "
652690
f"use {collection_type.__name__}[T] with a single element type."
653691
)
654-
element = _resolve_element(args[0], allow_unknown_entry=allow_unknown_entry)
692+
element = _resolve_element(args[0], allow_unknown_entry=allow_unknown_entry, has_converter=has_converter)
655693
return _TypeResult(
656694
converter=element.converter,
657695
choices=element.choices,
@@ -663,14 +701,21 @@ def _resolve(_tp: Any, args: tuple[Any, ...], *, allow_unknown_entry: bool = Fal
663701
return _resolve
664702

665703

666-
def _resolve_tuple(_tp: Any, args: tuple[Any, ...], *, allow_unknown_entry: bool = False, **_ctx: Any) -> _TypeResult:
704+
def _resolve_tuple(
705+
_tp: Any,
706+
args: tuple[Any, ...],
707+
*,
708+
allow_unknown_entry: bool = False,
709+
has_converter: bool = False,
710+
**_ctx: Any,
711+
) -> _TypeResult:
667712
"""Resolve tuple[T, ...] (variable) and tuple[T, T] (fixed arity)."""
668713
if not args:
669714
# Bare tuple without type args -- treat as tuple[str, ...].
670715
return _TypeResult(is_collection=True, container_factory=tuple)
671716

672717
if len(args) == 2 and args[1] is Ellipsis:
673-
element = _resolve_element(args[0], allow_unknown_entry=allow_unknown_entry)
718+
element = _resolve_element(args[0], allow_unknown_entry=allow_unknown_entry, has_converter=has_converter)
674719
return _TypeResult(
675720
converter=element.converter,
676721
choices=element.choices,
@@ -688,7 +733,7 @@ def _resolve_tuple(_tp: Any, args: tuple[Any, ...], *, allow_unknown_entry: bool
688733
f"can only apply a single type= converter per argument. "
689734
f"Use tuple[T, T] (same type) or tuple[T, ...] instead."
690735
)
691-
element = _resolve_element(first, allow_unknown_entry=allow_unknown_entry)
736+
element = _resolve_element(first, allow_unknown_entry=allow_unknown_entry, has_converter=has_converter)
692737
return _TypeResult(
693738
converter=element.converter,
694739
choices=element.choices,
@@ -723,7 +768,9 @@ def _is_enum(tp: Any) -> bool:
723768
return isinstance(tp, type) and issubclass(tp, enum.Enum)
724769

725770

726-
def _resolve_union(_tp: Any, args: tuple[Any, ...], *, allow_unknown_entry: bool = False, **_ctx: Any) -> _TypeResult:
771+
def _resolve_union(
772+
_tp: Any, args: tuple[Any, ...], *, allow_unknown_entry: bool = False, has_converter: bool = False, **_ctx: Any
773+
) -> _TypeResult:
727774
"""Resolve a union whose non-``None`` members are all Enums by trying each member's converter.
728775
729776
Each member keeps its own converter, so member values, member names, and any ``_missing_``
@@ -734,7 +781,12 @@ def _resolve_union(_tp: Any, args: tuple[Any, ...], *, allow_unknown_entry: bool
734781
A member declines a token by raising -- a clean ``ArgumentTypeError`` or anything a strict
735782
``_missing_`` raises -- and the next member is tried, so a raising member never pre-empts those
736783
after it. Only when every member declines is the merged-choices rejection raised.
784+
785+
With ``has_converter`` the user's ``converter=`` owns the conversion, so the ambiguity rejection
786+
is suppressed and an empty result is returned (any union, Enum or not, is legal).
737787
"""
788+
if has_converter:
789+
return _TypeResult()
738790
non_none = [a for a in args if a is not type(None)]
739791
if not all(_is_enum(a) for a in non_none):
740792
type_names = " | ".join(_type_name(a) for a in non_none)
@@ -791,11 +843,15 @@ def _type_name(tp: Any) -> str:
791843
_PASSTHROUGH_TYPES = frozenset({str, object, Any, inspect.Parameter.empty})
792844

793845

794-
def _resolve_base_type(tp: Any, *, is_positional: bool = False, allow_unknown_entry: bool = False) -> _TypeResult:
846+
def _resolve_base_type(
847+
tp: Any, *, is_positional: bool = False, allow_unknown_entry: bool = False, has_converter: bool = False
848+
) -> _TypeResult:
795849
"""Resolve a declared type into a :class:`_TypeResult` via the registry.
796850
797851
Lookup order: ``get_origin(tp)`` -> ``tp`` -> ``issubclass`` fallback -> passthrough.
798-
Raises ``TypeError`` for a scalar with no converter.
852+
Raises ``TypeError`` for a scalar with no converter, unless ``has_converter`` is set -- then an
853+
unresolvable type yields an empty result, because the user's ``converter=`` owns the conversion
854+
and only the collection *shape* (if any) is read from the resolved entry.
799855
"""
800856
args = get_args(tp)
801857
resolver = _TYPE_TABLE.get(get_origin(tp)) or _TYPE_TABLE.get(tp)
@@ -808,8 +864,10 @@ def _resolve_base_type(tp: Any, *, is_positional: bool = False, allow_unknown_en
808864
break
809865

810866
if resolver is not None:
811-
return resolver(tp, args, is_positional=is_positional, allow_unknown_entry=allow_unknown_entry)
812-
if tp in _PASSTHROUGH_TYPES:
867+
return resolver(
868+
tp, args, is_positional=is_positional, allow_unknown_entry=allow_unknown_entry, has_converter=has_converter
869+
)
870+
if tp in _PASSTHROUGH_TYPES or has_converter:
813871
return _TypeResult()
814872
raise TypeError(
815873
f"Unsupported parameter type {_type_name(tp)!r} for @with_annotated: there is no converter "
@@ -941,6 +999,26 @@ def _first_match(rules: list[_Rule[_S, _R]], subject: _S) -> _R:
941999
return next(produce(subject) for predicate, produce in rules if predicate(subject))
9421000

9431001

1002+
def _compose_preprocess(preprocess: Callable[[str], str], converter: Callable[[str], Any] | None) -> Callable[[str], Any]:
1003+
"""Return a ``type=`` callable that runs *preprocess* on the raw token before *converter*.
1004+
1005+
With no inferred converter (a ``str`` passthrough) the preprocess callable becomes the converter
1006+
itself. The wrapper copies the inner converter's ``__name__`` and ``_cmd2_enum_class`` so argparse
1007+
error messages and enum introspection keep working through the wrap.
1008+
"""
1009+
if converter is None:
1010+
return preprocess
1011+
1012+
def _convert(value: str) -> Any:
1013+
return converter(preprocess(value))
1014+
1015+
_convert.__name__ = getattr(converter, "__name__", "preprocess")
1016+
enum_class = getattr(converter, "_cmd2_enum_class", None)
1017+
if enum_class is not None:
1018+
_convert._cmd2_enum_class = enum_class # type: ignore[attr-defined]
1019+
return _convert
1020+
1021+
9441022
class _ArgparseArgument:
9451023
"""Builder whose output fields mirror ``parser.add_argument(...)``'s schema."""
9461024

@@ -1067,6 +1145,24 @@ def _has_user_completion(self) -> bool:
10671145
return False
10681146
return self.metadata.choices_provider is not None or self.metadata.completer is not None
10691147

1148+
@property
1149+
def _meta_converter(self) -> Callable[[str], Any] | None:
1150+
"""An explicit ``Argument/Option(converter=)`` callable, else ``None``.
1151+
1152+
When present it replaces the inferred ``type=`` converter and the annotation is no longer
1153+
required to be a built-in scalar (the converter owns string -> value).
1154+
"""
1155+
return self.metadata.converter if self.metadata is not None else None
1156+
1157+
@property
1158+
def _meta_preprocess(self) -> Callable[[str], str] | None:
1159+
"""An explicit ``Argument/Option(preprocess=)`` callable, else ``None``.
1160+
1161+
When present it runs before the inferred converter (``str -> str``), so the inferred
1162+
``type=``/``choices``/completer are all kept and only the raw token is transformed.
1163+
"""
1164+
return self.metadata.preprocess if self.metadata is not None else None
1165+
10701166
@property
10711167
def _meta_action(self) -> str | type[argparse.Action] | None:
10721168
"""An explicit ``Option(action=)`` value, else ``None`` (only ``Option`` carries one)."""
@@ -1211,19 +1307,31 @@ def _apply_type(self) -> None:
12111307
allow_unknown_entry = self.metadata.allow_unknown_entry if self.metadata is not None else False
12121308
try:
12131309
result = _resolve_base_type(
1214-
self.inner_type, is_positional=self.is_positional, allow_unknown_entry=allow_unknown_entry
1310+
self.inner_type,
1311+
is_positional=self.is_positional,
1312+
allow_unknown_entry=allow_unknown_entry,
1313+
has_converter=self._meta_converter is not None,
12151314
)
12161315
except TypeError as exc:
12171316
self.build_error = exc
12181317
return
1219-
self.type = result.converter
1220-
self.choices = result.choices
1318+
if self._meta_converter is not None:
1319+
# An explicit converter replaces the inferred type= and owns the value-space, so the
1320+
# inferred choices/completer (derived from the inferred converter) no longer apply.
1321+
self.type = self._meta_converter
1322+
self.choices = None
1323+
else:
1324+
self.type = result.converter
1325+
self.choices = result.choices
1326+
if result.completer is not None:
1327+
self.extras["completer"] = result.completer
1328+
if self._meta_preprocess is not None:
1329+
# Transform the raw token before the (inferred) converter, keeping its choices/completer.
1330+
self.type = _compose_preprocess(self._meta_preprocess, self.type)
12211331
# A collection coerces its parsed list into the declared container type; option bool
12221332
# gets ``--flag/--no-flag``. Either may be overridden by an explicit ``Option(action=)``.
12231333
self.action = _CollectionCastingAction if result.is_collection else result.action
12241334
self.container_factory = result.container_factory
1225-
if result.completer is not None:
1226-
self.extras["completer"] = result.completer
12271335
self.is_collection = result.is_collection
12281336
self.fixed_arity = result.fixed_arity
12291337

@@ -1631,6 +1739,30 @@ def _const_mismatches_type(a: _ArgparseArgument) -> bool:
16311739
f"completer/choices_provider, or use a value-consuming action."
16321740
),
16331741
),
1742+
(
1743+
# converter= replaces the conversion; preprocess= composes with the inferred one. They are two
1744+
# ways to set the same thing, so supplying both is ambiguous -- fold the preprocessing into the
1745+
# converter, which already receives the raw token.
1746+
lambda a: a._meta_converter is not None and a._meta_preprocess is not None,
1747+
lambda a: TypeError(
1748+
f"converter= and preprocess= on '{a.name}' cannot be combined; a converter receives the raw "
1749+
f"token, so fold the preprocessing into it."
1750+
),
1751+
),
1752+
(
1753+
# A converter/preprocess on a value-less action (store_true/false, count, store_const,
1754+
# append_const) has no command-line value to convert.
1755+
lambda a: (
1756+
a._policy is not None
1757+
and a._policy.drop_converter
1758+
and (a._meta_converter is not None or a._meta_preprocess is not None)
1759+
),
1760+
lambda a: TypeError(
1761+
f"converter=/preprocess= on '{a.name}' cannot be used with action={a._effective_action!r}, "
1762+
f"which takes no value from the command line, so there is nothing to convert. Remove the "
1763+
f"converter/preprocess, or use a value-consuming action."
1764+
),
1765+
),
16341766
(
16351767
lambda a: a._policy is not None and a._policy.requires is not None and not a._policy.requires(a),
16361768
lambda a: TypeError(

0 commit comments

Comments
 (0)