@@ -190,6 +190,29 @@ class is defined rather than on first command use. The one group rule that need
190190takes precedence over a *type-inferred* completer (the ``Path`` completer is dropped so the choices
191191drive both validation and completion). A ``choices_provider`` / ``completer`` you supply yourself
192192still 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
195218import argparse
@@ -211,7 +234,6 @@ class is defined rather than on first command use. The one group rule that need
211234)
212235from pathlib import Path
213236from 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
231253from rich .table import Column
232254
233255from . import constants
256+ from .argparse_completer import ArgparseCompleter
234257from .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
642673def _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+
9441022class _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