From 875dbb1d2436c6534c7663bb2adec6960b736500 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Tue, 16 Jun 2026 15:48:41 +0100 Subject: [PATCH] feat(annotated): add converter and preprocess hooks for custom conversion --- CHANGELOG.md | 10 ++ cmd2/annotated.py | 180 +++++++++++++++++++++++++++++----- docs/features/annotated.md | 96 +++++++++++++++++- examples/annotated_example.py | 63 ++++++++++++ tests/test_annotated.py | 178 +++++++++++++++++++++++++++++++++ 5 files changed, 501 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bd13a6ea..1331b3dcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,16 @@ aborting the union, and a merged "choose from ..." error is raised only when every member declines. Unions containing a `Literal` or any non-`Enum` member are still rejected as ambiguous. + - `Argument`/`Option` accept new `converter` and `preprocess` hooks for custom string + conversion, giving `@with_annotated` parity with a hand-built `add_argument(type=...)` (a raw + `type=` in the metadata is still rejected). `converter` is a `Callable[[str], Any]` that + replaces the inferred `type=` converter; because it owns the conversion, the annotation may be + any type (an otherwise unsupported type like `datetime`, or an otherwise-ambiguous + multi-member union like `int | str`, becomes legal) and the inferred `choices`/completer are + dropped. `preprocess` is a `Callable[[str], str]` that runs before the inferred converter, + transforming the raw token while keeping the inferred `type=`/`choices`/completer (e.g. + `preprocess=str.lower` on an `Enum`). The two are mutually exclusive on one parameter and + neither may be combined with a value-less action. ## 4.0.0 (June 5, 2026) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index d5697c1fb..5c97b21c9 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -190,6 +190,29 @@ class is defined rather than on first command use. The one group rule that need takes precedence over a *type-inferred* completer (the ``Path`` completer is dropped so the choices drive both validation and completion). A ``choices_provider`` / ``completer`` you supply yourself still wins over ``choices=``. + +Two hooks customize the string -> value conversion, parity with a hand-built ``add_argument(type=...)`` +(a raw ``type=`` in the metadata is rejected; use these instead): + +- ``converter`` -- a ``Callable[[str], Any]`` that *replaces* the inferred ``type=`` converter. Because + the converter owns the conversion, the annotation is no longer required to be a supported scalar -- any + type is legal (``Annotated[datetime, Argument(converter=parse_iso)]``), and the "unsupported type" error + is suppressed. The inferred ``choices`` and completer (which described the *inferred* value-space) are + dropped; supply ``choices=`` / ``completer`` / ``choices_provider`` to re-add them (an explicit + ``choices=`` is still run through your converter). argparse applies it per token, so on a ``list[T]`` it + converts each value; a non-collection annotation such as ``Any`` keeps a single token, so the converter + may itself return a collection (``Annotated[Any, Option('--idx', converter=parse_intset)]``). +- ``preprocess`` -- a ``Callable[[str], str]`` that runs *before* the inferred converter, transforming the + raw token while *keeping* the inferred ``type=``, ``choices``, completer, and coercion. Use it to + normalize input for a type that already has rich inference, e.g. ``Annotated[Color, Argument(preprocess= + str.lower)]`` accepts ``RED`` while still showing the ``Color`` choices, or ``Annotated[Path, + Argument(preprocess=os.path.expanduser)]`` keeps the path completer. With a plain ``str`` (no inferred + converter) it becomes the ``type=`` directly. + +``converter`` and ``preprocess`` are mutually exclusive on one parameter (fold the preprocessing into the +converter, which already receives the raw token), and neither may be combined with a value-less action +(``store_true`` / ``store_false`` / ``count`` / ``store_const`` / ``append_const``), which consumes no +token to convert. """ import argparse @@ -211,7 +234,6 @@ class is defined rather than on first command use. The one group rule that need ) from pathlib import Path from typing import ( - TYPE_CHECKING, Annotated, Any, ClassVar, @@ -231,6 +253,7 @@ class is defined rather than on first command use. The one group rule that need from rich.table import Column from . import constants +from .argparse_completer import ArgparseCompleter from .argparse_utils import ( ArgparseCommandSpec, Cmd2ArgumentParser, @@ -246,9 +269,6 @@ class is defined rather than on first command use. The one group rule that need UnboundCompleter, ) -if TYPE_CHECKING: # pragma: no cover - from .argparse_completer import ArgparseCompleter - #: ``nargs`` values accepted by cmd2's patched ``add_argument`` (incl. ranged tuples). _NargsValue = int | str | tuple[int] | tuple[int, int] | tuple[int, float] @@ -276,7 +296,7 @@ class Cmd2ParserKwargs(TypedDict, total=False): exit_on_error: bool suggest_on_error: bool color: bool - completer_class: "type[ArgparseCompleter] | None" + completer_class: type[ArgparseCompleter] | None # --------------------------------------------------------------------------- @@ -330,6 +350,8 @@ def __init__( const: Any = _UNSET, default: Any = _UNSET, allow_unknown_entry: bool = False, + converter: Callable[[str], Any] | None = None, + preprocess: Callable[[str], str] | None = None, **extra_kwargs: Any, ) -> None: """Initialise shared metadata fields. @@ -340,7 +362,11 @@ def __init__( both, or ``argparse.SUPPRESS``, is rejected. ``allow_unknown_entry`` only affects ``Enum`` annotations: when set, a token matched by neither a member value nor name is routed through the enum's ``_missing_`` hook (for aliases / special keywords) instead of being rejected - outright. ``extra_kwargs`` forwards any other ``add_argument`` parameter (incl. those from + outright. ``converter`` replaces the inferred ``type=`` converter (and makes any annotation + type legal); ``preprocess`` runs before the inferred converter to transform the raw token while + keeping the inferred choices/completer. The two are mutually exclusive and neither combines with + a value-less action (see the module docstring). ``extra_kwargs`` forwards any other + ``add_argument`` parameter (incl. those from [`register_argparse_argument_parameter`][cmd2.argparse_utils.register_argparse_argument_parameter]) straight through. """ reserved = self._RESERVED_EXTRA_KWARGS & extra_kwargs.keys() @@ -348,7 +374,10 @@ def __init__( name = sorted(reserved)[0] # Per-key remediation hint for the reserved kwarg. hint = { - "type": "The converter is derived from the parameter annotation; change the annotation instead.", + "type": ( + "The converter is derived from the parameter annotation; change the annotation, or pass " + "converter= for a custom string -> value callable (preprocess= to transform the token first)." + ), "dest": "The dest is the parameter name; rename the parameter instead.", "action": "Use Option(action=...) (only Option supports an action; Argument is always positional).", "required": ( @@ -369,6 +398,8 @@ def __init__( self.const = const self.default = default self.allow_unknown_entry = allow_unknown_entry + self.converter = converter + self.preprocess = preprocess self.extra_kwargs = extra_kwargs def to_kwargs(self) -> dict[str, Any]: @@ -631,9 +662,9 @@ def _resolve_bool(_tp: Any, _args: tuple[Any, ...], *, is_positional: bool = Fal return _TypeResult(converter=_parse_bool, choices=list(_BOOL_CHOICES)) -def _resolve_element(tp: Any, *, allow_unknown_entry: bool = False) -> _TypeResult: +def _resolve_element(tp: Any, *, allow_unknown_entry: bool = False, has_converter: bool = False) -> _TypeResult: """Resolve a collection element type and reject nested collections.""" - inner = _resolve_base_type(tp, is_positional=True, allow_unknown_entry=allow_unknown_entry) + inner = _resolve_base_type(tp, is_positional=True, allow_unknown_entry=allow_unknown_entry, has_converter=has_converter) if inner.is_collection: raise TypeError("Nested collections are not supported") return inner @@ -642,7 +673,14 @@ def _resolve_element(tp: Any, *, allow_unknown_entry: bool = False) -> _TypeResu def _make_collection_resolver(collection_type: type) -> Callable[..., _TypeResult]: """Create a resolver for single-arg collections (list[T], set[T], frozenset[T]).""" - def _resolve(_tp: Any, args: tuple[Any, ...], *, allow_unknown_entry: bool = False, **_ctx: Any) -> _TypeResult: + def _resolve( + _tp: Any, + args: tuple[Any, ...], + *, + allow_unknown_entry: bool = False, + has_converter: bool = False, + **_ctx: Any, + ) -> _TypeResult: if len(args) == 0: # Bare list/set/frozenset without type args -- treat as list[str]/set[str]/frozenset[str]. 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 f"{collection_type.__name__}[...] with {len(args)} type arguments is not supported; " f"use {collection_type.__name__}[T] with a single element type." ) - element = _resolve_element(args[0], allow_unknown_entry=allow_unknown_entry) + element = _resolve_element(args[0], allow_unknown_entry=allow_unknown_entry, has_converter=has_converter) return _TypeResult( converter=element.converter, choices=element.choices, @@ -663,14 +701,21 @@ def _resolve(_tp: Any, args: tuple[Any, ...], *, allow_unknown_entry: bool = Fal return _resolve -def _resolve_tuple(_tp: Any, args: tuple[Any, ...], *, allow_unknown_entry: bool = False, **_ctx: Any) -> _TypeResult: +def _resolve_tuple( + _tp: Any, + args: tuple[Any, ...], + *, + allow_unknown_entry: bool = False, + has_converter: bool = False, + **_ctx: Any, +) -> _TypeResult: """Resolve tuple[T, ...] (variable) and tuple[T, T] (fixed arity).""" if not args: # Bare tuple without type args -- treat as tuple[str, ...]. return _TypeResult(is_collection=True, container_factory=tuple) if len(args) == 2 and args[1] is Ellipsis: - element = _resolve_element(args[0], allow_unknown_entry=allow_unknown_entry) + element = _resolve_element(args[0], allow_unknown_entry=allow_unknown_entry, has_converter=has_converter) return _TypeResult( converter=element.converter, choices=element.choices, @@ -688,7 +733,7 @@ def _resolve_tuple(_tp: Any, args: tuple[Any, ...], *, allow_unknown_entry: bool f"can only apply a single type= converter per argument. " f"Use tuple[T, T] (same type) or tuple[T, ...] instead." ) - element = _resolve_element(first, allow_unknown_entry=allow_unknown_entry) + element = _resolve_element(first, allow_unknown_entry=allow_unknown_entry, has_converter=has_converter) return _TypeResult( converter=element.converter, choices=element.choices, @@ -723,7 +768,9 @@ def _is_enum(tp: Any) -> bool: return isinstance(tp, type) and issubclass(tp, enum.Enum) -def _resolve_union(_tp: Any, args: tuple[Any, ...], *, allow_unknown_entry: bool = False, **_ctx: Any) -> _TypeResult: +def _resolve_union( + _tp: Any, args: tuple[Any, ...], *, allow_unknown_entry: bool = False, has_converter: bool = False, **_ctx: Any +) -> _TypeResult: """Resolve a union whose non-``None`` members are all Enums by trying each member's converter. 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 A member declines a token by raising -- a clean ``ArgumentTypeError`` or anything a strict ``_missing_`` raises -- and the next member is tried, so a raising member never pre-empts those after it. Only when every member declines is the merged-choices rejection raised. + + With ``has_converter`` the user's ``converter=`` owns the conversion, so the ambiguity rejection + is suppressed and an empty result is returned (any union, Enum or not, is legal). """ + if has_converter: + return _TypeResult() non_none = [a for a in args if a is not type(None)] if not all(_is_enum(a) for a in non_none): type_names = " | ".join(_type_name(a) for a in non_none) @@ -791,11 +843,15 @@ def _type_name(tp: Any) -> str: _PASSTHROUGH_TYPES = frozenset({str, object, Any, inspect.Parameter.empty}) -def _resolve_base_type(tp: Any, *, is_positional: bool = False, allow_unknown_entry: bool = False) -> _TypeResult: +def _resolve_base_type( + tp: Any, *, is_positional: bool = False, allow_unknown_entry: bool = False, has_converter: bool = False +) -> _TypeResult: """Resolve a declared type into a :class:`_TypeResult` via the registry. Lookup order: ``get_origin(tp)`` -> ``tp`` -> ``issubclass`` fallback -> passthrough. - Raises ``TypeError`` for a scalar with no converter. + Raises ``TypeError`` for a scalar with no converter, unless ``has_converter`` is set -- then an + unresolvable type yields an empty result, because the user's ``converter=`` owns the conversion + and only the collection *shape* (if any) is read from the resolved entry. """ args = get_args(tp) 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 break if resolver is not None: - return resolver(tp, args, is_positional=is_positional, allow_unknown_entry=allow_unknown_entry) - if tp in _PASSTHROUGH_TYPES: + return resolver( + tp, args, is_positional=is_positional, allow_unknown_entry=allow_unknown_entry, has_converter=has_converter + ) + if tp in _PASSTHROUGH_TYPES or has_converter: return _TypeResult() raise TypeError( 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: return next(produce(subject) for predicate, produce in rules if predicate(subject)) +def _compose_preprocess(preprocess: Callable[[str], str], converter: Callable[[str], Any] | None) -> Callable[[str], Any]: + """Return a ``type=`` callable that runs *preprocess* on the raw token before *converter*. + + With no inferred converter (a ``str`` passthrough) the preprocess callable becomes the converter + itself. The wrapper copies the inner converter's ``__name__`` and ``_cmd2_enum_class`` so argparse + error messages and enum introspection keep working through the wrap. + """ + if converter is None: + return preprocess + + def _convert(value: str) -> Any: + return converter(preprocess(value)) + + _convert.__name__ = getattr(converter, "__name__", "preprocess") + enum_class = getattr(converter, "_cmd2_enum_class", None) + if enum_class is not None: + _convert._cmd2_enum_class = enum_class # type: ignore[attr-defined] + return _convert + + class _ArgparseArgument: """Builder whose output fields mirror ``parser.add_argument(...)``'s schema.""" @@ -1067,6 +1145,24 @@ def _has_user_completion(self) -> bool: return False return self.metadata.choices_provider is not None or self.metadata.completer is not None + @property + def _meta_converter(self) -> Callable[[str], Any] | None: + """An explicit ``Argument/Option(converter=)`` callable, else ``None``. + + When present it replaces the inferred ``type=`` converter and the annotation is no longer + required to be a built-in scalar (the converter owns string -> value). + """ + return self.metadata.converter if self.metadata is not None else None + + @property + def _meta_preprocess(self) -> Callable[[str], str] | None: + """An explicit ``Argument/Option(preprocess=)`` callable, else ``None``. + + When present it runs before the inferred converter (``str -> str``), so the inferred + ``type=``/``choices``/completer are all kept and only the raw token is transformed. + """ + return self.metadata.preprocess if self.metadata is not None else None + @property def _meta_action(self) -> str | type[argparse.Action] | None: """An explicit ``Option(action=)`` value, else ``None`` (only ``Option`` carries one).""" @@ -1211,19 +1307,31 @@ def _apply_type(self) -> None: allow_unknown_entry = self.metadata.allow_unknown_entry if self.metadata is not None else False try: result = _resolve_base_type( - self.inner_type, is_positional=self.is_positional, allow_unknown_entry=allow_unknown_entry + self.inner_type, + is_positional=self.is_positional, + allow_unknown_entry=allow_unknown_entry, + has_converter=self._meta_converter is not None, ) except TypeError as exc: self.build_error = exc return - self.type = result.converter - self.choices = result.choices + if self._meta_converter is not None: + # An explicit converter replaces the inferred type= and owns the value-space, so the + # inferred choices/completer (derived from the inferred converter) no longer apply. + self.type = self._meta_converter + self.choices = None + else: + self.type = result.converter + self.choices = result.choices + if result.completer is not None: + self.extras["completer"] = result.completer + if self._meta_preprocess is not None: + # Transform the raw token before the (inferred) converter, keeping its choices/completer. + self.type = _compose_preprocess(self._meta_preprocess, self.type) # A collection coerces its parsed list into the declared container type; option bool # gets ``--flag/--no-flag``. Either may be overridden by an explicit ``Option(action=)``. self.action = _CollectionCastingAction if result.is_collection else result.action self.container_factory = result.container_factory - if result.completer is not None: - self.extras["completer"] = result.completer self.is_collection = result.is_collection self.fixed_arity = result.fixed_arity @@ -1631,6 +1739,30 @@ def _const_mismatches_type(a: _ArgparseArgument) -> bool: f"completer/choices_provider, or use a value-consuming action." ), ), + ( + # converter= replaces the conversion; preprocess= composes with the inferred one. They are two + # ways to set the same thing, so supplying both is ambiguous -- fold the preprocessing into the + # converter, which already receives the raw token. + lambda a: a._meta_converter is not None and a._meta_preprocess is not None, + lambda a: TypeError( + f"converter= and preprocess= on '{a.name}' cannot be combined; a converter receives the raw " + f"token, so fold the preprocessing into it." + ), + ), + ( + # A converter/preprocess on a value-less action (store_true/false, count, store_const, + # append_const) has no command-line value to convert. + lambda a: ( + a._policy is not None + and a._policy.drop_converter + and (a._meta_converter is not None or a._meta_preprocess is not None) + ), + lambda a: TypeError( + f"converter=/preprocess= on '{a.name}' cannot be used with action={a._effective_action!r}, " + f"which takes no value from the command line, so there is nothing to convert. Remove the " + f"converter/preprocess, or use a value-consuming action." + ), + ), ( lambda a: a._policy is not None and a._policy.requires is not None and not a._policy.requires(a), lambda a: TypeError( diff --git a/docs/features/annotated.md b/docs/features/annotated.md index c0355da0b..b8e8fde93 100644 --- a/docs/features/annotated.md +++ b/docs/features/annotated.md @@ -154,7 +154,9 @@ class MyApp(cmd2.Cmd): Both `Argument` and `Option` accept the same cmd2-specific fields as `add_argument()`: `choices`, `choices_provider`, `completer`, `table_columns`, `suppress_tab_hint`, `metavar`, `nargs`, and -`help_text`. +`help_text`. They also accept `converter` / `preprocess` for +[custom conversion](#custom-conversion-converter-and-preprocess) and `allow_unknown_entry` for +[enum aliases](#enum-aliases-and-special-keywords-allow_unknown_entry). `Option` additionally accepts `action`, `required`, and positional `*names` for custom flag strings (e.g. `Option("--color", "-c")`). @@ -277,7 +279,9 @@ if you need them. from the function signature itself, so misusing them surfaces as a clear `TypeError` instead of a silent override. The refused kwargs are: -- `type` -- comes from the parameter annotation +- `type` -- comes from the parameter annotation; for a custom string-to-value callable use + [`converter`](#custom-conversion-converter-and-preprocess) (or `preprocess` to transform the token + first) - `dest` -- comes from the parameter name - `action` and `required` on `Argument` -- only `Option` accepts them; positional arguments have no action and are required unless they carry a default or `| None` @@ -329,6 +333,94 @@ An `Enum` parameter accepts both member **values** and member **names** on the c (`Color.RED` with value `"red"` is selected by either `red` or `RED`); tab-completion and `--help` list the values. +### Custom conversion (`converter` and `preprocess`) + +With `@with_argparser` you can pass any callable as `add_argument(type=...)` to parse a token into a +custom value. `@with_annotated` derives `type=` from the annotation instead, and rejects a raw +`type=` in the metadata (it would silently shadow the inferred converter). Two hooks give the same +power without that footgun: + +- `converter` -- a `Callable[[str], Any]` that **replaces** the inferred `type=` converter. +- `preprocess` -- a `Callable[[str], str]` that runs **before** the inferred converter. + +They differ in what survives. A `converter` owns the whole conversion, so the inferred `choices` and +completer (which described the inferred value-space) are dropped. A `preprocess` only transforms the +raw token, so the inferred `type=`, `choices`, completer, and coercion are all kept. + +#### `converter`: replace the conversion + +Use `converter` when the annotation's built-in conversion is wrong for your input, or when the type +has no built-in conversion at all. Because the converter owns the conversion, the annotation no +longer has to be one of the supported scalar types -- any type is legal, and the usual "unsupported +type" error is suppressed: + +```py +import datetime +from typing import Annotated +from cmd2.annotated import Argument, Option, with_annotated + +def parse_size(value: str) -> int: + """Parse an integer with an optional K/M/G suffix.""" + multiplier = {"K": 1_000, "M": 1_000_000, "G": 1_000_000_000}.get(value[-1:].upper(), 1) + return int(value[:-1] if multiplier != 1 else value) * multiplier + +class MyApp(cmd2.Cmd): + @with_annotated + def do_alloc(self, size: Annotated[int, Argument(converter=parse_size)]) -> None: + self.poutput(f"Allocating {size} bytes") # `alloc 64K` -> 64000 + + @with_annotated + def do_at(self, when: Annotated[datetime.datetime, Option("--when", converter=datetime.datetime.fromisoformat)]): + self.poutput(when.isoformat()) # `datetime` has no inferred converter, so converter= makes it legal +``` + +argparse applies `type=` per token, so on a `list[T]` the converter runs on each value. To take a +**single** token and have the converter return a whole collection (the one-token-to-many idiom), +annotate with a non-collection type such as `Any` -- a collection annotation like `set[int]` would +instead infer `nargs` and split the input across several tokens: + +```py +from typing import Annotated, Any + +def parse_intset(value: str) -> set[int]: + return {int(piece) for piece in value.split(",")} + +@with_annotated +def do_select(self, idx: Annotated[Any, Option("--idx", converter=parse_intset)]) -> None: + self.poutput(sorted(idx)) # `select --idx 1,3,5` -> [1, 3, 5] +``` + +An explicit `choices=` is still reconciled against the converter (its values are run through the +converter), so you can re-add a restricted choice set after replacing the conversion. + +#### `preprocess`: normalize before the inferred conversion + +Use `preprocess` when the inferred conversion is correct but the raw token needs massaging first. +The inferred `type=`, `choices`, and completer are kept, so an `Enum` keeps its choices and +completion while accepting normalized input, and a `Path` keeps its completer: + +```py +import os +from typing import Annotated +from cmd2.annotated import Argument, with_annotated + +class MyApp(cmd2.Cmd): + @with_annotated + def do_tag(self, color: Annotated[Color, Argument(preprocess=str.lower)]) -> None: + self.poutput(color.value) # `tag RED` works, and `tag ` still lists the colors + + @with_annotated + def do_open(self, path: Annotated[Path, Argument(preprocess=os.path.expanduser)]) -> None: + self.poutput(path) # `open ~/file` expands, path completion still works +``` + +With a plain `str` (no inferred converter) the `preprocess` callable simply becomes the `type=`. + +`converter` and `preprocess` are **mutually exclusive** on one parameter -- a converter already +receives the raw token, so fold the preprocessing into it. Neither may be combined with a value-less +action (`store_true`, `store_false`, `count`, `store_const`, `append_const`), which consumes no +token to convert. Both raise `TypeError` at decoration time. + ## Decorator options `@with_annotated` currently supports: diff --git a/examples/annotated_example.py b/examples/annotated_example.py index 899fdcf9a..8b10933a7 100755 --- a/examples/annotated_example.py +++ b/examples/annotated_example.py @@ -17,6 +17,8 @@ python examples/annotated_example.py """ +import datetime +import os import sys from argparse import Namespace from collections.abc import Callable @@ -71,6 +73,20 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: ANNOTATED_CATEGORY = "Annotated Commands" +_SIZE_SUFFIXES = {"K": 1_000, "M": 1_000_000, "G": 1_000_000_000} + + +def parse_size(value: str) -> int: + """Parse an integer with an optional K/M/G suffix (a custom ``converter=``).""" + multiplier = _SIZE_SUFFIXES.get(value[-1:].upper(), 1) + digits = value[:-1] if multiplier != 1 else value + return int(digits) * multiplier + + +def parse_iso(value: str) -> datetime.datetime: + """Parse an ISO-8601 timestamp (a ``converter=`` for an otherwise-unsupported type).""" + return datetime.datetime.fromisoformat(value) + class AnnotatedExample(Cmd): """Demonstrates @with_annotated strengths over @with_argparser.""" @@ -404,6 +420,53 @@ def do_install( """ self.poutput(f"Installing {package}") + # -- Advanced: converter (custom string -> value) ------------------------ + # ``converter=`` replaces the inferred type= converter, giving parity with a + # hand-built ``add_argument(type=...)``: ``size`` parses a K/M/G suffix into an + # int, and ``--until`` makes the otherwise-unsupported ``datetime`` type legal + # (the converter owns the conversion, so any annotation type is allowed). + + @with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_alloc( + self, + size: Annotated[int, Argument(converter=parse_size, help_text="size with optional K/M/G suffix")], + until: Annotated[ + datetime.datetime | None, + Option("--until", converter=parse_iso, help_text="ISO-8601 expiry (an unsupported type)"), + ] = None, + ) -> None: + """Allocate memory. ``converter=`` parses ``64K`` / ``2M`` into an int and ``--until`` into a datetime. + + Try: + alloc 64K + alloc 2M --until 2025-06-16T09:30 + """ + msg = f"Allocating {size} bytes" + self.poutput(f"{msg} until {until:%Y-%m-%d %H:%M}" if until else msg) + + # -- Advanced: preprocess (normalize the raw token) ---------------------- + # ``preprocess=`` only transforms the raw token before the inferred converter, + # so the inferred type, choices, and completion all survive: ``str.lower`` lets + # the ``Color`` Enum accept ``RED`` (keeping its choices), and + # ``os.path.expanduser`` expands ``~`` while ``Path`` keeps its completer. + + @with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_tag( + self, + color: Annotated[Color, Argument(preprocess=str.lower, help_text="color (case-insensitive)")], + path: Annotated[Path, Argument(preprocess=os.path.expanduser, help_text="file to tag (~ is expanded)")], + ) -> None: + """Tag a file with a color. ``preprocess=`` normalizes input while keeping Enum/Path inference. + + Try: + tag RED ~/notes.txt + tag # Color choices + tag red # path completion + """ + self.poutput(f"Tagged {path} {color.value}") + # -- Namespace provider -------------------------------------------------- # This mirrors one of @with_argparser's advanced features. diff --git a/tests/test_annotated.py b/tests/test_annotated.py index 011ee3a08..c4df85e12 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -4061,3 +4061,181 @@ def test_union_choices_preserve_display_meta(self) -> None: action = _get_param_action(_make_func(_Color | _IntColor, name="c")) meta = {c.text: c.display_meta for c in action.choices if isinstance(c, CompletionItem)} assert meta == {"red": "red", "green": "green", "blue": "blue", "1": "red", "2": "green", "3": "blue"} + + +# --------------------------------------------------------------------------- +# converter / preprocess metadata +# --------------------------------------------------------------------------- + + +def _hex(value: str) -> int: + """Parse a hexadecimal integer (test converter).""" + return int(value, 16) + + +def _csv_ints(value: str) -> set[int]: + """Parse a comma-separated list of ints into a set (single token -> collection).""" + return {int(piece) for piece in value.split(",")} + + +def _parse_iso(value: str) -> datetime.datetime: + """Parse an ISO-8601 timestamp (test converter for an otherwise-unsupported type).""" + return datetime.datetime.fromisoformat(value) + + +class TestConverter: + """`Argument`/`Option` ``converter=`` replaces the inferred ``type=`` converter.""" + + def test_converter_becomes_type(self) -> None: + """A supplied converter is emitted as argparse ``type=``, replacing the inferred one.""" + action = _action_for(Annotated[int, Argument(converter=_hex)]) + assert action.type is _hex + + def test_converter_allows_unsupported_annotation_type(self) -> None: + """A converter suppresses the 'unsupported scalar type' error: the annotation may be anything.""" + action = _action_for(Annotated[datetime.datetime, Argument(converter=_parse_iso)]) + assert action.type is _parse_iso + + def test_converter_parses_end_to_end(self) -> None: + """The converter runs at parse time, producing its own value-space.""" + parser = build_parser_from_function(_make_func(Annotated[int, Argument(converter=_hex)], name="addr")) + assert parser.parse_args(["ff"]).addr == 255 + + def test_converter_drops_inferred_enum_choices(self) -> None: + """Replacing the converter on an Enum drops the inferred choices (user owns the value-space).""" + action = _action_for(Annotated[_Color, Argument(converter=str)]) + assert action.choices is None + + def test_converter_drops_inferred_path_completer(self) -> None: + """Replacing the converter on a Path drops the inferred path completer.""" + action = _action_for(Annotated[Path, Argument(converter=str)]) + assert action.get_completer() is None # type: ignore[attr-defined] + + def test_converter_applies_per_element_on_collection(self) -> None: + """On a ``list[T]`` the converter runs per token (argparse applies ``type=`` per value).""" + parser = build_parser_from_function(_make_func(Annotated[list[int], Option("--n", converter=_hex)], name="n")) + assert parser.parse_args(["--n", "ff", "10"]).n == [255, 16] + + def test_converter_single_token_to_collection(self) -> None: + """A non-collection annotation (Any) keeps a single token, so the converter may return a collection.""" + parser = build_parser_from_function(_make_func(Annotated[Any, Option("--idx", converter=_csv_ints)], name="idx")) + assert parser.parse_args(["--idx", "1,3,5"]).idx == {1, 3, 5} + + def test_converter_runs_explicit_choices_through_itself(self) -> None: + """Explicit ``choices`` are run through the user converter for argparse's post-conversion match.""" + action = _action_for(Annotated[int, Argument(converter=_hex, choices=["ff", "10"])]) + assert action.choices == [255, 16] + + def test_converter_allows_unsupported_collection_element(self) -> None: + """A converter on ``list[Unsupported]`` keeps the collection shape and suppresses the element error.""" + parser = build_parser_from_function( + _make_func(Annotated[list[datetime.datetime], Option("--ts", converter=_parse_iso)], name="ts") + ) + parsed = parser.parse_args(["--ts", "2020-01-01", "2021-06-15"]).ts + assert parsed == [_parse_iso("2020-01-01"), _parse_iso("2021-06-15")] + + def test_converter_allows_ambiguous_union(self) -> None: + """A converter suppresses the 'ambiguous union' error: a multi-member union is legal.""" + parser = build_parser_from_function(_make_func(Annotated[int | str, Argument(converter=_hex)], name="addr")) + assert parser.parse_args(["ff"]).addr == 255 + + def test_converter_on_optional_single_member(self) -> None: + """A converter legalizes ``T | None``: the optional collapses to ``T`` and the converter owns conversion.""" + parser = build_parser_from_function( + _make_func( + Annotated[datetime.datetime | None, Option("--until", converter=_parse_iso)], + name="until", + kind="kw", + default=None, + ) + ) + assert parser.parse_args(["--until", "2020-01-01"]).until == _parse_iso("2020-01-01") + assert parser.parse_args([]).until is None + + def test_converter_keeps_user_completer(self) -> None: + """``converter=`` drops the *inferred* completer, but a user-supplied ``completer=`` survives.""" + action = _action_for(Annotated[Path, Argument(converter=str, completer=cmd2.Cmd.path_complete)]) + assert action.get_completer() is cmd2.Cmd.path_complete # type: ignore[attr-defined] + + def test_converter_rejects_invalid_explicit_choice(self) -> None: + """A choice the user converter rejects is a build-time error (run through the converter, not the inferred type).""" + _assert_build_error( + Annotated[int, Argument(converter=_hex, choices=["ff", "zz"])], + match="not a valid", + ) + + +class TestPreprocess: + """`Argument`/`Option` ``preprocess=`` runs before the inferred converter, keeping inference.""" + + def test_preprocess_runs_before_inferred_converter(self) -> None: + """The token is transformed before the inferred Enum converter sees it.""" + parser = build_parser_from_function(_make_func(Annotated[_Color, Argument(preprocess=str.lower)], name="c")) + assert parser.parse_args(["RED"]).c is _Color.red + + def test_preprocess_keeps_inferred_choices(self) -> None: + """The inferred Enum choices survive (preprocess composes with, not replaces, the converter).""" + action = _action_for(Annotated[_Color, Argument(preprocess=str.lower)]) + assert action.choices == _COLOR_CHOICE_ITEMS + + def test_preprocess_keeps_inferred_path_completer(self) -> None: + """The inferred path completer survives a preprocess hook.""" + action = _action_for(Annotated[Path, Argument(preprocess=str.strip)]) + assert action.get_completer() is cmd2.Cmd.path_complete # type: ignore[attr-defined] + + def test_preprocess_on_str_passthrough_becomes_type(self) -> None: + """With no inferred converter (plain ``str``), preprocess becomes the ``type=`` directly.""" + parser = build_parser_from_function(_make_func(Annotated[str, Argument(preprocess=str.upper)], name="s")) + assert parser.parse_args(["abc"]).s == "ABC" + + def test_preprocess_applies_per_element_on_collection(self) -> None: + """On a ``list[T]`` preprocess runs per token, before the per-token inferred converter.""" + parser = build_parser_from_function(_make_func(Annotated[list[_Color], Option("--c", preprocess=str.lower)], name="c")) + assert parser.parse_args(["--c", "RED", "Blue"]).c == [_Color.red, _Color.blue] + + def test_preprocess_keeps_enum_class_introspection(self) -> None: + """The wrapped converter still exposes ``_cmd2_enum_class`` for introspection.""" + action = _action_for(Annotated[_Color, Argument(preprocess=str.lower)]) + assert action.type._cmd2_enum_class is _Color + + def test_preprocess_keeps_inferred_converter_name(self) -> None: + """The wrapper copies the inner converter's ``__name__`` so argparse error messages stay meaningful.""" + action = _action_for(Annotated[_Color, Argument(preprocess=str.lower)]) + assert action.type.__name__ == _Color.__name__ + + def test_preprocess_runs_explicit_choices_through_composed_type(self) -> None: + """Explicit ``choices`` are run through the preprocess+converter wrapper (``RED`` -> lower -> Enum).""" + action = _action_for(Annotated[_Color, Argument(preprocess=str.lower, choices=["RED"])]) + assert action.choices == [_Color.red] + + +class TestConverterPreprocessConstraints: + """Build-time rejections for ``converter=`` / ``preprocess=`` misuse.""" + + def test_converter_and_preprocess_together_rejected(self) -> None: + """Supplying both is ambiguous -- fold the preprocessing into the converter.""" + _assert_build_error( + Annotated[int, Argument(converter=_hex, preprocess=str.strip)], + match="converter= and preprocess=", + ) + + def test_converter_on_value_less_action_rejected(self) -> None: + """A converter on a zero-argument action has nothing to convert.""" + _assert_build_error( + Annotated[bool, Option("--flag", action="store_true", converter=_hex)], + match="takes no value", + kind="kw", + ) + + def test_preprocess_on_value_less_action_rejected(self) -> None: + """A preprocess hook on a zero-argument action has nothing to transform.""" + _assert_build_error( + Annotated[bool, Option("--flag", action="store_true", preprocess=str.strip)], + match="takes no value", + kind="kw", + ) + + def test_reserved_type_hint_points_at_converter(self) -> None: + """A raw ``type=`` is still rejected, now pointing the user at ``converter=``.""" + with pytest.raises(TypeError, match="converter="): + Argument(type=int)