Skip to content

# Annotated Argument Processing

Experimental

The `@with_annotated` decorator and its supporting `Argument` / `Option` metadata classes are experimental. The public API, the surface of accepted type annotations, and the generated argparse behavior may all change in future releases without a deprecation cycle. Pin a specific `cmd2` version if you depend on the exact current semantics, and expect to revisit your usage on upgrades.

For production code that needs stable behavior, use @with_argparser instead.

The @with_annotated decorator builds an argparse parser automatically from the decorated function's type annotations. No manual `add_argument()` calls are required, and the command body receives typed keyword arguments directly instead of an `argparse.Namespace`.

The two decorators are interchangeable -- here is the same command written both ways:

@with_annotated@with_argparser

    @with_annotated
    def do_greet(self, name: str, count: int = 1, loud: bool = False):
        for _ in range(count):
            msg = f"Hello {name}"
            self.poutput(msg.upper() if loud else msg)

    parser = Cmd2ArgumentParser()
    parser.add_argument('name', help='person to greet')
    parser.add_argument('--count', type=int, default=1, help='repetitions')
    parser.add_argument('--loud', action='store_true', help='shout')

    @with_argparser(parser)
    def do_greet(self, args):
        for _ in range(args.count):
            msg = f"Hello {args.name}"
            self.poutput(msg.upper() if args.loud else msg)

The annotated version is more concise, gives you typed parameters, and supports several advanced cmd2 features directly, including `ns_provider`, `with_unknown_args`, and typed subcommands. Pick `@with_argparser` when you need a stable, well-established API or fine-grained control over the parser; pick `@with_annotated` when you want type-hint-driven ergonomics and can accept the experimental status.

## Basic usage

Parameters without defaults become positional arguments. Parameters with defaults become `--option` flags. Keyword-only parameters (after `*`) always become options, and without a default they become required options.

Underscores in parameter names are converted to dashes in the generated flag, so `dry_run` becomes `--dry-run`. The Python identifier you read inside the function body keeps its underscored form (`args.dry_run`). To opt out, pass explicit names via `Option("--my_flag", ...)`.

    from cmd2.annotated import with_annotated

    class MyApp(cmd2.Cmd):
        @with_annotated
        def do_greet(self, name: str, count: int = 1, loud: bool = False):
            """Greet someone."""
            for _ in range(count):
                msg = f"Hello {name}"
                self.poutput(msg.upper() if loud else msg)

The command `greet Alice --count 3 --loud` parses `name="Alice"`, `count=3`, `loud=True` and passes them as keyword arguments.

## How annotations map to argparse

The decorator converts Python type annotations into `add_argument()` calls:

Type annotation | Generated argparse setting
--- | ---
`str` | default (no `type=` needed)
`int`, `float` | `type=int` or `type=float`
`bool` with a default | boolean optional flag via `BooleanOptionalAction`
positional `bool` | parsed from `true/false`, `yes/no`, `on/off`, `1/0`
`Path` | `type=Path`
`Enum` subclass | `type=converter`, `choices` from member values
`decimal.Decimal` | `type=decimal.Decimal`
`Literal[...]` | `type=literal-converter`, `choices` from values
`list[T]` / `set[T]` / `tuple[T, ...]` | `nargs='+'` (or `'*'` if it has a default or is `\| None`)
`tuple[T, T]` | fixed `nargs=N` with `type=T`
`T \| None` (no default) | positional with `nargs='?'` (accepts 0-or-1 tokens)
`T \| None = None` | `--flag` option with `default=None`

When collection types are used with `@with_annotated`, parsed values are passed to the command function as:

- `list[T]` as `list`
- `set[T]` as `set`
- `tuple[T, ...]` as `tuple`

Unsupported patterns raise `TypeError`, including:

- unions with multiple non-`None` members such as `str | int`
- mixed-type tuples such as `tuple[int, str]`
- `Annotated[T, meta] | None`; write `Annotated[T | None, meta]` instead
- `Annotated[T, Argument(nargs=N)]` where `N` is `'*'`, `'+'`, or an integer `>= 1` and `T` is not a collection type. `nargs` values that produce a list of values need a collection annotation such as `list[T]` or `tuple[T, ...]`.
- an optional fixed-arity positional, such as `Annotated[tuple[int, int], Argument()] = (1, 2)`, `Annotated[tuple[int, int] | None, Argument()]`, or any positional `Argument(nargs=N)` with a default or `| None`. argparse cannot make a fixed-arity positional optional (there is no `nargs` for "absent or exactly `N` tokens"), so use a variable-arity type like `tuple[T, ...]`, drop the default, or make it an option (give it a default without `Argument()`).
- `Annotated[tuple[T, T], Argument(nargs=N)]` where `N` differs from the number of elements declared by the tuple. The tuple type already pins `nargs`; user metadata cannot change it.

The parameter names `dest` and `subcommand` are reserved and may not be used as annotated parameter names. `cmd2_statement` receives the parsed cmd2.Statement object, and `cmd2_subcommand_func` (only on a command decorated with `@with_annotated(base_command=True)`) receives the subcommand handler.

## Annotated metadata

For finer control, use `typing.Annotated` with Argument or Option metadata:

    from typing import Annotated
    from cmd2.annotated import Argument, Option, with_annotated

    class MyApp(cmd2.Cmd):
        def sport_choices(self) -> cmd2.Choices:
            return cmd2.Choices.from_values(["football", "basketball"])

        @with_annotated
        def do_play(
            self,
            sport: Annotated[str, Argument(
                choices_provider=sport_choices,
                help_text="Sport to play",
            )],
            venue: Annotated[str, Option(
                "--venue", "-v",
                help_text="Where to play",
                completer=cmd2.Cmd.path_complete,
            )] = "home",
        ):
            self.poutput(f"Playing {sport} at {venue}")

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`.

`Option` additionally accepts `action`, `required`, and positional `*names` for custom flag strings (e.g. `Option("--color", "-c")`).

### Actions

When an `Option(action=...)` uses a zero-argument argparse action that takes no value from the command line (`count`, `store_true`, `store_false`, `store_const`, `append_const`), `@with_annotated` strips the value-oriented metadata it inferred from the type before calling `add_argument()`:

- the `type` converter,
- the static `choices`, and
- any inferred tab-completer (such as the path completer for `Path`) or `choices_provider`.

This matches argparse behavior (which rejects a completer on a value-less action) and avoids parser-construction errors such as combining `action='count'` with `type=int`. Actions that do consume values (`append` / `extend` on a `list[T]`, or a plain value option) keep the inferred converter and completer.

Pairing `const` with an explicit `nargs` on a scalar `Option` selects argparse's optional-value idiom instead of `store_const`. `Annotated[str | None, Option("--log", nargs='?', const="CONSOLE")]` keeps the `store` action and the inferred `type` converter, so the flag is three-way:

- absent yields the default,
- a bare `--log` yields the `const`, and
- `--log VALUE` yields the converted `VALUE`.

The `const` is stored verbatim (it is not run through the converter), so it must already match the declared type. Without an explicit `nargs`, `const` alone still infers the value-less `store_const` (present yields the `const`, and supplying a value is an error).

`Option(action=...)` also accepts a custom `argparse.Action` subclass. The class is passed straight through to `add_argument()` and owns storage of the parsed value, so the type-inferred collection casting and the action-specific type/const/shape constraints are skipped; the inferred `type=` converter, default, and `required` are still applied so the class receives them like any hand-built `add_argument()` call.

    class UpperAction(argparse.Action):
        def __call__(self, parser, namespace, values, option_string=None):
            setattr(namespace, self.dest, values.upper())

    @with_annotated
    def do_shout(self, name: Annotated[str, Option("--name", action=UpperAction)] = ""):
        self.poutput(name)

`action='help'` and `action='version'` are not supported by `@with_annotated`; use `@with_argparser` if you need them.

### Reserved keyword arguments

`Argument()` and `Option()` refuse a handful of `add_argument()` kwargs that the decorator derives 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
- `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`

Every other `add_argument()` parameter passes through, including any custom parameter registered via register_argparse_argument_parameter.

### Defaults

A `default` may be supplied either through the function signature or as a metadata kwarg. The two forms are equivalent:

    # Signature default
    def do_x(self, name: Annotated[str, Option("--name")] = "HI"): ...

    # Metadata default (same behaviour)
    def do_x(self, name: Annotated[str, Option("--name", default="HI")]): ...

Specifying both at the same time is a conflict and raises `TypeError`. `argparse.SUPPRESS` is rejected as a default from either source, because suppressing the namespace attribute would call the function without the keyword argument it expects.

Parser-construction kwargs such as `add_help`, `prefix_chars`, `fromfile_prefix_chars`, `argument_default`, `conflict_handler`, and `allow_abbrev` are not exposed by `@with_annotated`. Set them on a custom `parser_class` subclass and pass it via `parser_class=`.

### Choices and enums

When a user-supplied `choices_provider` or `completer` is given for an inferred `Enum` or `Literal`, the inferred static `choices` list is dropped so completion is driven by the provider or completer. The inferred `type` converter is preserved, so parsed values still coerce to the declared type (`Literal[1, 2]` yields an `int`, an `Enum` yields its member) and values outside the type are rejected at parse time.

An explicit `choices=` is reconciled with the inferred type rather than fighting it:

- The values are run through the inferred `type` converter so they match argparse's post-conversion comparison. `Annotated[int, Option("--n", choices=["1", "2"])]` is normalized to `choices=[1, 2]`, so `--n 1` is accepted. A choice the converter rejects (`choices=["1", "nope"]` on an `int`) is a build-time `TypeError`. Values already of the declared type are left as-is.
- An explicit `choices=` takes precedence over a type-inferred completer (such as the `Path` completer): the choices are kept (so they validate and drive completion) and the inferred completer is dropped. A `choices_provider`/`completer` you pass yourself still wins over `choices=`.

An `Enum` parameter accepts both member values and member names on the command line (`Color.RED` with value `"red"` is selected by either `red` or `RED`); tab-completion and `--help` list the values.

## Decorator options

`@with_annotated` currently supports:

- `ns_provider` -- prepopulate the namespace before parsing, mirroring `@with_argparser`
- `preserve_quotes` -- if `True`, quotes in arguments are preserved
- `with_unknown_args` -- if `True`, unrecognised arguments are passed as `_unknown`
- `subcommand_to` -- register the function as an annotated subcommand under a parent command
- `base_command` -- create a base command whose parser also adds subparsers and exposes `cmd2_subcommand_func`. A `cmd2_subcommand_func` parameter is only valid on a command decorated with `base_command=True`; declaring one elsewhere raises `TypeError`.
- `subcommand_required` -- whether a subcommand must be supplied (only with `base_command=True`, default `True`)
- `subcommand_metavar` -- metavar shown for the subcommands group (only with `base_command=True`, default `"SUBCOMMAND"`)
- `subcommand_title` -- title for the subcommands `--help` section (only with `base_command=True`)
- `subcommand_description` -- description for the subcommands `--help` section (only with `base_command=True`)
- `help` -- help text for an annotated subcommand (only valid with `subcommand_to`)
- `aliases` -- aliases for an annotated subcommand (only valid with `subcommand_to`)
- `deprecated` -- mark the subcommand as deprecated in `--help` (only valid with `subcommand_to`)
- `groups` -- `Group` instances assigning parameter names to argument groups
- `mutually_exclusive_groups` -- `Group` instances of mutually exclusive parameters
- `parser_class` -- a custom parser class (defaults to the configured default)
- `**parser_kwargs` -- every other `Cmd2ArgumentParser` constructor kwarg, forwarded through PEP 692 `Unpack[Cmd2ParserKwargs]`. See Parser customization below for the full list and the `description` / `prog` special cases.

    @with_annotated(with_unknown_args=True)
    def do_rawish(self, name: str, _unknown: list[str] | None = None):
        self.poutput((name, _unknown))

## Parser customization

Every `Cmd2ArgumentParser` constructor kwarg flows straight through `@with_annotated` and `build_parser_from_function` via PEP 692 `Unpack[Cmd2ParserKwargs]`. The `Cmd2ParserKwargs` `TypedDict` is the single source of truth for the forwarded kwargs and gives type-checkers/IDEs autocomplete on the decorator's call site: adding a new ctor kwarg to `Cmd2ArgumentParser` only needs a matching field on `Cmd2ParserKwargs`, and the annotated decorator picks it up automatically.

The forwarded kwargs are `description`, `epilog`, `prog`, `usage`, `parents`, `argument_default`, `prefix_chars`, `fromfile_prefix_chars`, `conflict_handler`, `add_help`, `allow_abbrev`, `exit_on_error`, `formatter_class`, `completer_class`, and on Python ≥ 3.14 `suggest_on_error` / `color`. Two of them layer extra behavior on top of the raw passthrough:

- `description` -- when omitted, it is filled from the function's docstring (detailed below); pass an explicit value to override.
- `prog` -- rejected when `subcommand_to` is set; cmd2's subcommand machinery rewrites `prog` from the parent command hierarchy, so any value here would be silently overwritten.

`parser_class` stays as its own explicit kwarg because it selects the class itself rather than a value passed to it. Argument groups are declared with Group; pass `title` and `description` for a titled help section (omit them for an untitled group):

    from cmd2.annotated import Group, with_annotated

    class App(cmd2.Cmd):
        @with_annotated(
            description="Open a network connection.",
            epilog="Example: connect example.com --port 2222",
            groups=(Group("host", "port", title="connection", description="where to connect"),),
        )
        def do_connect(self, host: str, port: int = 22, verbose: bool = False):
            self.poutput(f"connecting to {host}:{port}")

If you omit `description`, the first paragraph of the function's docstring (everything up to the first blank line) is used as the parser description; subsequent paragraphs are dropped so rst field directives like `:param name:` don't leak into `--help`. Pass `description=""` to suppress the automatic fill, or `description="..."` to override it.

    @with_annotated
    def do_greet(self, name: str):
        """Greet someone by name.

        :param name: who to greet
        """
        self.poutput(f"hello {name}")
    # parser.description == "Greet someone by name."

`mutually_exclusive_groups` also takes `Group` instances (their `title`/`description` are ignored, since argparse mutually-exclusive groups have no header). Pass `Group(..., required=True)` to make the mutex group itself required -- argparse will then enforce that exactly one of its members must be supplied. `required=True` is rejected on a plain (non-mutex) `Group` because `add_argument_group` has no `required` flag.

    @with_annotated(
        mutually_exclusive_groups=(Group("verbose", "quiet", required=True),),
    )
    def do_run(self, verbose: bool = False, quiet: bool = False): ...

`parents=` mirrors argparse's standard parents mechanism for sharing argument definitions across parsers. `argument_default=argparse.SUPPRESS` is not supported and raises `TypeError`. It removes an absent argument from the parsed namespace, but `@with_annotated` builds the call from the function signature, so every declared parameter is expected at invocation; an argument vanishing from the namespace can never be valid here (mirroring the per-argument `default=argparse.SUPPRESS` rejection). Any other `argument_default` value is forwarded to the parser unchanged.

The remaining argparse kwargs cover less-common needs but are wired through unchanged:

- `prefix_chars="+-"` accepts options that start with `+` (e.g. `+verbose`); pair with an explicit `Option("+verbose")` to declare such flags.
- `fromfile_prefix_chars="@"` lets a user write `mycmd @args.txt` and have the file's contents spliced in as arguments.
- `conflict_handler="resolve"` lets a parent parser's option be redefined locally without an error -- useful with `parents=` when you want to override an inherited flag.
- `add_help=False` drops the auto-added `-h`/`--help` action (cmd2's standard parser keeps it on by default).
- `allow_abbrev=False` requires users to type the full long-option name (no `--verb` for `--verbose`).
- `exit_on_error=False` makes parse failures raise `argparse.ArgumentError` instead of calling `sys.exit`; useful when embedding the parser inside another flow.

## Annotated subcommands

`@with_annotated` can also build typed subcommand trees without manually constructing subparsers.

    @with_annotated(base_command=True)
    def do_manage(self, *, cmd2_subcommand_func):
        if cmd2_subcommand_func:
            cmd2_subcommand_func()

    @with_annotated(subcommand_to="manage", help="list projects")
    def manage_list(self):
        self.poutput("listing")

For nested subcommands, `subcommand_to` can be space-delimited, for example `subcommand_to="manage project"`. The intermediate level must also be declared as a subcommand that creates its own subparsers:

    @with_annotated(subcommand_to="manage", base_command=True, help="manage projects")
    def manage_project(self, *, cmd2_subcommand_func):
        if cmd2_subcommand_func:
            cmd2_subcommand_func()

    @with_annotated(subcommand_to="manage project", help="add a project")
    def manage_project_add(self, name: str):
        self.poutput(f"added {name}")

## Lower-level parser building

cmd2.annotated.build_parser_from_function builds the parser directly from a function without registering a command. It accepts the same `groups`, `mutually_exclusive_groups`, `parser_class`, and forwarded `Unpack[Cmd2ParserKwargs]` as `@with_annotated`. Like the decorator, it skips the first parameter as the method receiver (`self`/`cls`).

    from cmd2.annotated import build_parser_from_function

    def greet(self, name: str, count: int = 1):
        """Greet someone."""

    parser = build_parser_from_function(greet)
    namespace = parser.parse_args(["Alice", "--count", "3"])
    # namespace.name == "Alice", namespace.count == 3

## Automatic completion from types

With `@with_annotated`, arguments annotated as `Path` or `Enum` get automatic completion without needing an explicit `choices_provider` or `completer`.

Specifically:

- `Path` (or any `Path` subclass) triggers filesystem path completion
- `MyEnum` (any `enum.Enum` subclass) triggers completion from enum member values

With `@with_argparser`, provide `choices`, `choices_provider`, or `completer` explicitly when you want completion behavior.

## Stability and feedback

Because this feature is experimental:

- Behavior of edge cases (mixed-type tuples, deeply-nested `Annotated`, conflicting metadata) may change.
- Diagnostic error messages may be reworded.
- The set of supported type annotations may be expanded or trimmed.

If you depend on `@with_annotated`, please share feedback and edge cases via the issue tracker so behavior can be locked in before the feature graduates out of experimental.