|
| 1 | +# Annotated Argument Processing |
| 2 | + |
| 3 | +!!! warning "Experimental" |
| 4 | + |
| 5 | + The `@with_annotated` decorator and its supporting `Argument` / `Option` metadata classes are |
| 6 | + **experimental**. The public API, the surface of accepted type annotations, and the generated |
| 7 | + argparse behavior may all change in future releases without a deprecation cycle. Pin a specific |
| 8 | + `cmd2` version if you depend on the exact current semantics, and expect to revisit your usage on |
| 9 | + upgrades. |
| 10 | + |
| 11 | + For production code that needs stable behavior, use |
| 12 | + [@with_argparser](argument_processing.md#with_argparser-decorator) instead. |
| 13 | + |
| 14 | +The [@with_annotated][cmd2.decorators.with_annotated] decorator builds an argparse parser |
| 15 | +automatically from the decorated function's type annotations. No manual `add_argument()` calls are |
| 16 | +required, and the command body receives typed keyword arguments directly instead of an |
| 17 | +`argparse.Namespace`. |
| 18 | + |
| 19 | +The two decorators are interchangeable -- here is the same command written both ways: |
| 20 | + |
| 21 | +=== "@with_annotated" |
| 22 | + |
| 23 | + ```py |
| 24 | + @with_annotated |
| 25 | + def do_greet(self, name: str, count: int = 1, loud: bool = False): |
| 26 | + for _ in range(count): |
| 27 | + msg = f"Hello {name}" |
| 28 | + self.poutput(msg.upper() if loud else msg) |
| 29 | + ``` |
| 30 | + |
| 31 | +=== "@with_argparser" |
| 32 | + |
| 33 | + ```py |
| 34 | + parser = Cmd2ArgumentParser() |
| 35 | + parser.add_argument('name', help='person to greet') |
| 36 | + parser.add_argument('--count', type=int, default=1, help='repetitions') |
| 37 | + parser.add_argument('--loud', action='store_true', help='shout') |
| 38 | + |
| 39 | + @with_argparser(parser) |
| 40 | + def do_greet(self, args): |
| 41 | + for _ in range(args.count): |
| 42 | + msg = f"Hello {args.name}" |
| 43 | + self.poutput(msg.upper() if args.loud else msg) |
| 44 | + ``` |
| 45 | + |
| 46 | +The annotated version is more concise, gives you typed parameters, and supports several advanced |
| 47 | +cmd2 features directly, including `ns_provider`, `with_unknown_args`, and typed subcommands. Pick |
| 48 | +`@with_argparser` when you need a stable, well-established API or fine-grained control over the |
| 49 | +parser; pick `@with_annotated` when you want type-hint-driven ergonomics and can accept the |
| 50 | +experimental status. |
| 51 | + |
| 52 | +## Basic usage |
| 53 | + |
| 54 | +Parameters without defaults become positional arguments. Parameters with defaults become `--option` |
| 55 | +flags. Keyword-only parameters (after `*`) always become options, and without a default they become |
| 56 | +required options. |
| 57 | + |
| 58 | +Underscores in parameter names are converted to dashes in the generated flag, so `dry_run` becomes |
| 59 | +`--dry-run`. The Python identifier you read inside the function body keeps its underscored form |
| 60 | +(`args.dry_run`). To opt out, pass explicit names via `Option("--my_flag", ...)`. |
| 61 | + |
| 62 | +```py |
| 63 | +from cmd2 import with_annotated |
| 64 | + |
| 65 | +class MyApp(cmd2.Cmd): |
| 66 | + @with_annotated |
| 67 | + def do_greet(self, name: str, count: int = 1, loud: bool = False): |
| 68 | + """Greet someone.""" |
| 69 | + for _ in range(count): |
| 70 | + msg = f"Hello {name}" |
| 71 | + self.poutput(msg.upper() if loud else msg) |
| 72 | +``` |
| 73 | + |
| 74 | +The command `greet Alice --count 3 --loud` parses `name="Alice"`, `count=3`, `loud=True` and passes |
| 75 | +them as keyword arguments. |
| 76 | + |
| 77 | +## How annotations map to argparse |
| 78 | + |
| 79 | +The decorator converts Python type annotations into `add_argument()` calls: |
| 80 | + |
| 81 | +| Type annotation | Generated argparse setting | |
| 82 | +| -------------------------------------------------------- | --------------------------------------------------- | |
| 83 | +| `str` | default (no `type=` needed) | |
| 84 | +| `int`, `float` | `type=int` or `type=float` | |
| 85 | +| `bool` with a default | boolean optional flag via `BooleanOptionalAction` | |
| 86 | +| positional `bool` | parsed from `true/false`, `yes/no`, `on/off`, `1/0` | |
| 87 | +| `Path` | `type=Path` | |
| 88 | +| `Enum` subclass | `type=converter`, `choices` from member values | |
| 89 | +| `decimal.Decimal` | `type=decimal.Decimal` | |
| 90 | +| `Literal[...]` | `type=literal-converter`, `choices` from values | |
| 91 | +| `Collection[T]` / `list[T]` / `set[T]` / `tuple[T, ...]` | `nargs='+'` (or `'*'` if it has a default) | |
| 92 | +| `tuple[T, T]` | fixed `nargs=N` with `type=T` | |
| 93 | +| `T \| None` | unwrapped to `T`, treated as optional | |
| 94 | + |
| 95 | +When collection types are used with `@with_annotated`, parsed values are passed to the command |
| 96 | +function as: |
| 97 | + |
| 98 | +- `list[T]` and `Collection[T]` as `list` |
| 99 | +- `set[T]` as `set` |
| 100 | +- `tuple[T, ...]` as `tuple` |
| 101 | + |
| 102 | +Unsupported patterns raise `TypeError`, including: |
| 103 | + |
| 104 | +- unions with multiple non-`None` members such as `str | int` |
| 105 | +- mixed-type tuples such as `tuple[int, str]` |
| 106 | +- `Annotated[T, meta] | None`; write `Annotated[T | None, meta]` instead |
| 107 | + |
| 108 | +The parameter names `dest` and `subcommand` are reserved and may not be used as annotated parameter |
| 109 | +names. |
| 110 | + |
| 111 | +## Annotated metadata |
| 112 | + |
| 113 | +For finer control, use `typing.Annotated` with [Argument][cmd2.annotated.Argument] or |
| 114 | +[Option][cmd2.annotated.Option] metadata: |
| 115 | + |
| 116 | +```py |
| 117 | +from typing import Annotated |
| 118 | +from cmd2 import Argument, Option, with_annotated |
| 119 | + |
| 120 | +class MyApp(cmd2.Cmd): |
| 121 | + def sport_choices(self) -> cmd2.Choices: |
| 122 | + return cmd2.Choices.from_values(["football", "basketball"]) |
| 123 | + |
| 124 | + @with_annotated |
| 125 | + def do_play( |
| 126 | + self, |
| 127 | + sport: Annotated[str, Argument( |
| 128 | + choices_provider=sport_choices, |
| 129 | + help_text="Sport to play", |
| 130 | + )], |
| 131 | + venue: Annotated[str, Option( |
| 132 | + "--venue", "-v", |
| 133 | + help_text="Where to play", |
| 134 | + completer=cmd2.Cmd.path_complete, |
| 135 | + )] = "home", |
| 136 | + ): |
| 137 | + self.poutput(f"Playing {sport} at {venue}") |
| 138 | +``` |
| 139 | + |
| 140 | +Both `Argument` and `Option` accept the same cmd2-specific fields as `add_argument()`: `choices`, |
| 141 | +`choices_provider`, `completer`, `table_columns`, `suppress_tab_hint`, `metavar`, `nargs`, and |
| 142 | +`help_text`. |
| 143 | + |
| 144 | +`Option` additionally accepts `action`, `required`, and positional `*names` for custom flag strings |
| 145 | +(e.g. `Option("--color", "-c")`). |
| 146 | + |
| 147 | +When an `Option(action=...)` uses an argparse action that does not accept `type=` (`count`, |
| 148 | +`store_true`, `store_false`, `store_const`, `help`, `version`), `@with_annotated` removes any |
| 149 | +inferred `type` converter before calling `add_argument()`. This matches argparse behavior and avoids |
| 150 | +parser-construction errors such as combining `action='count'` with `type=int`. |
| 151 | + |
| 152 | +When a user-supplied `choices_provider` or `completer` overrides an inferred `Enum` or `Literal`, |
| 153 | +the restrictive type converter is also dropped so the user-supplied values are not rejected at parse |
| 154 | +time. The `Path` converter is permissive and is preserved when a custom completer is provided. |
| 155 | + |
| 156 | +## Decorator options |
| 157 | + |
| 158 | +`@with_annotated` currently supports: |
| 159 | + |
| 160 | +- `ns_provider` -- prepopulate the namespace before parsing, mirroring `@with_argparser` |
| 161 | +- `preserve_quotes` -- if `True`, quotes in arguments are preserved |
| 162 | +- `with_unknown_args` -- if `True`, unrecognised arguments are passed as `_unknown` |
| 163 | +- `subcommand_to` -- register the function as an annotated subcommand under a parent command |
| 164 | +- `base_command` -- create a base command whose parser also adds subparsers and exposes |
| 165 | + `cmd2_handler`. A `cmd2_handler` parameter is only valid on a command decorated with |
| 166 | + `base_command=True`; declaring one elsewhere raises `TypeError`. |
| 167 | +- `help` -- help text for an annotated subcommand |
| 168 | +- `aliases` -- aliases for an annotated subcommand |
| 169 | + |
| 170 | +```py |
| 171 | +@with_annotated(with_unknown_args=True) |
| 172 | +def do_rawish(self, name: str, _unknown: list[str] | None = None): |
| 173 | + self.poutput((name, _unknown)) |
| 174 | +``` |
| 175 | + |
| 176 | +## Annotated subcommands |
| 177 | + |
| 178 | +`@with_annotated` can also build typed subcommand trees without manually constructing subparsers. |
| 179 | + |
| 180 | +```py |
| 181 | +@with_annotated(base_command=True) |
| 182 | +def do_manage(self, *, cmd2_handler): |
| 183 | + handler = cmd2_handler |
| 184 | + if handler: |
| 185 | + handler() |
| 186 | + |
| 187 | +@with_annotated(subcommand_to="manage", help="list projects") |
| 188 | +def manage_list(self): |
| 189 | + self.poutput("listing") |
| 190 | +``` |
| 191 | + |
| 192 | +For nested subcommands, `subcommand_to` can be space-delimited, for example |
| 193 | +`subcommand_to="manage project"`. The intermediate level must also be declared as a subcommand that |
| 194 | +creates its own subparsers: |
| 195 | + |
| 196 | +```py |
| 197 | +@with_annotated(subcommand_to="manage", base_command=True, help="manage projects") |
| 198 | +def manage_project(self, *, cmd2_handler): |
| 199 | + handler = cmd2_handler |
| 200 | + if handler: |
| 201 | + handler() |
| 202 | + |
| 203 | +@with_annotated(subcommand_to="manage project", help="add a project") |
| 204 | +def manage_project_add(self, name: str): |
| 205 | + self.poutput(f"added {name}") |
| 206 | +``` |
| 207 | + |
| 208 | +## Lower-level parser building |
| 209 | + |
| 210 | +If you need parser grouping or mutually-exclusive groups while still using annotation-driven parser |
| 211 | +generation, [cmd2.annotated.build_parser_from_function][cmd2.annotated.build_parser_from_function] |
| 212 | +also supports: |
| 213 | + |
| 214 | +- `groups=((...), (...))` |
| 215 | +- `mutually_exclusive_groups=((...), (...))` |
| 216 | + |
| 217 | +```py |
| 218 | +@with_annotated(preserve_quotes=True) |
| 219 | +def do_raw(self, text: str): |
| 220 | + self.poutput(f"raw: {text}") |
| 221 | +``` |
| 222 | + |
| 223 | +## Automatic completion from types |
| 224 | + |
| 225 | +With `@with_annotated`, arguments annotated as `Path` or `Enum` get automatic completion without |
| 226 | +needing an explicit `choices_provider` or `completer`. |
| 227 | + |
| 228 | +Specifically: |
| 229 | + |
| 230 | +- `Path` (or any `Path` subclass) triggers filesystem path completion |
| 231 | +- `MyEnum` (any `enum.Enum` subclass) triggers completion from enum member values |
| 232 | + |
| 233 | +With `@with_argparser`, provide `choices`, `choices_provider`, or `completer` explicitly when you |
| 234 | +want completion behavior. |
| 235 | + |
| 236 | +## Stability and feedback |
| 237 | + |
| 238 | +Because this feature is experimental: |
| 239 | + |
| 240 | +- Behavior of edge cases (mixed-type tuples, deeply-nested `Annotated`, conflicting metadata) may |
| 241 | + change. |
| 242 | +- Diagnostic error messages may be reworded. |
| 243 | +- The set of supported type annotations may be expanded or trimmed. |
| 244 | + |
| 245 | +If you depend on `@with_annotated`, please share feedback and edge cases via the |
| 246 | +[issue tracker](https://github.com/python-cmd2/cmd2/issues) so behavior can be locked in before the |
| 247 | +feature graduates out of experimental. |
0 commit comments