Skip to content

Commit bacaab3

Browse files
chore: move documentation
1 parent d4756eb commit bacaab3

3 files changed

Lines changed: 254 additions & 210 deletions

File tree

docs/features/annotated.md

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
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

Comments
 (0)