Skip to content

Commit cbf3a31

Browse files
chore: more clean up
1 parent 9034820 commit cbf3a31

5 files changed

Lines changed: 276 additions & 19 deletions

File tree

cmd2/argparse_completer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -750,7 +750,7 @@ def _get_raw_choices(self, arg_state: _ArgumentState) -> list[CompletionItem] |
750750
if isinstance(enum_from_converter, type) and issubclass(enum_from_converter, enum.Enum):
751751
return [CompletionItem(str(m.value), display_meta=m.name) for m in enum_from_converter]
752752

753-
if action_type.__name__ == '_parse_bool':
753+
if getattr(action_type, '__name__', None) == '_parse_bool':
754754
return [CompletionItem(v) for v in ['true', 'false', 'yes', 'no', 'on', 'off', '1', '0']]
755755

756756
return None

docs/features/argument_processing.md

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ required.
6464
### Basic usage
6565

6666
Parameters without defaults become positional arguments. Parameters with defaults become `--option`
67-
flags. The function receives typed keyword arguments directly instead of an `argparse.Namespace`.
67+
flags. Keyword-only parameters (after `*`) always become options, and without a default they become
68+
required options. The function receives typed keyword arguments directly instead of an
69+
`argparse.Namespace`.
6870

6971
```py
7072
from cmd2 import with_annotated
@@ -89,14 +91,14 @@ The decorator converts Python type annotations into `add_argument()` calls:
8991
| -------------------------------------------------------- | --------------------------------------------------- |
9092
| `str` | default (no `type=` needed) |
9193
| `int`, `float` | `type=int` or `type=float` |
92-
| `bool` (default `False`) | `--flag` with `action='store_true'` |
93-
| `bool` (default `True`) | `--no-flag` with `action='store_false'` |
94+
| `bool` with a default | boolean optional flag via `BooleanOptionalAction` |
9495
| positional `bool` | parsed from `true/false`, `yes/no`, `on/off`, `1/0` |
9596
| `Path` | `type=Path` |
9697
| `Enum` subclass | `type=converter`, `choices` from member values |
9798
| `decimal.Decimal` | `type=decimal.Decimal` |
9899
| `Literal[...]` | `type=literal-converter`, `choices` from values |
99100
| `Collection[T]` / `list[T]` / `set[T]` / `tuple[T, ...]` | `nargs='+'` (or `'*'` if it has a default) |
101+
| `tuple[T, T]` | fixed `nargs=N` with `type=T` |
100102
| `T \| None` | unwrapped to `T`, treated as optional |
101103

102104
When collection types are used with `@with_annotated`, parsed values are passed to the command
@@ -106,6 +108,15 @@ function as:
106108
- `set[T]` as `set`
107109
- `tuple[T, ...]` as `tuple`
108110

111+
Unsupported patterns raise `TypeError`, including:
112+
113+
- unions with multiple non-`None` members such as `str | int`
114+
- mixed-type tuples such as `tuple[int, str]`
115+
- `Annotated[T, meta] | None`; write `Annotated[T | None, meta]` instead
116+
117+
The parameter names `dest` and `subcommand` are reserved and may not be used as annotated parameter
118+
names.
119+
109120
### Annotated metadata
110121

111122
For finer control, use `typing.Annotated` with [Argument][cmd2.annotated.Argument] or
@@ -171,15 +182,69 @@ def do_greet(self, name: str, count: int = 1, loud: bool = False):
171182
self.poutput(msg.upper() if loud else msg)
172183
```
173184

174-
The annotated version is more concise and gives you typed parameters. The argparse version gives you
175-
more control (e.g. `ns_provider`, subcommand handlers via `cmd2_handler`).
185+
The annotated version is more concise and gives you typed parameters. It also supports several
186+
advanced cmd2 features directly, including `ns_provider`, `with_unknown_args`, and typed
187+
subcommands.
176188

177189
### Decorator options
178190

179-
`@with_annotated` accepts the same keyword arguments as `@with_argparser`:
191+
`@with_annotated` currently supports:
180192

193+
- `ns_provider` -- prepopulate the namespace before parsing, mirroring `@with_argparser`
181194
- `preserve_quotes` -- if `True`, quotes in arguments are preserved
182195
- `with_unknown_args` -- if `True`, unrecognised arguments are passed as `_unknown`
196+
- `subcommand_to` -- register the function as an annotated subcommand under a parent command
197+
- `base_command` -- create a base command whose parser also adds subparsers and exposes
198+
`cmd2_handler`
199+
- `help` -- help text for an annotated subcommand
200+
- `aliases` -- aliases for an annotated subcommand
201+
202+
```py
203+
@with_annotated(with_unknown_args=True)
204+
def do_rawish(self, name: str, _unknown: list[str] | None = None):
205+
self.poutput((name, _unknown))
206+
```
207+
208+
### Annotated subcommands
209+
210+
`@with_annotated` can also build typed subcommand trees without manually constructing subparsers.
211+
212+
```py
213+
@with_annotated(base_command=True)
214+
def do_manage(self, *, cmd2_handler):
215+
handler = cmd2_handler.get()
216+
if handler:
217+
handler()
218+
219+
@with_annotated(subcommand_to="manage", help="list projects")
220+
def manage_list(self):
221+
self.poutput("listing")
222+
```
223+
224+
For nested subcommands, `subcommand_to` can be space-delimited, for example
225+
`subcommand_to="manage project"`. The intermediate level must also be declared as a subcommand that
226+
creates its own subparsers:
227+
228+
```py
229+
@with_annotated(subcommand_to="manage", base_command=True, help="manage projects")
230+
def manage_project(self, *, cmd2_handler):
231+
handler = cmd2_handler.get()
232+
if handler:
233+
handler()
234+
235+
@with_annotated(subcommand_to="manage project", help="add a project")
236+
def manage_project_add(self, name: str):
237+
self.poutput(f"added {name}")
238+
```
239+
240+
### Lower-level parser building
241+
242+
If you need parser grouping or mutually-exclusive groups while still using annotation-driven parser
243+
generation, [cmd2.annotated.build_parser_from_function][cmd2.annotated.build_parser_from_function]
244+
also supports:
245+
246+
- `groups=((...), (...))`
247+
- `mutually_exclusive_groups=((...), (...))`
183248

184249
```py
185250
@with_annotated(preserve_quotes=True)
@@ -211,7 +276,7 @@ def do_load(self, args):
211276
```
212277

213278
With `@with_annotated`, the same inference happens because `Path` and `Enum` annotations generate
214-
`type=Path` and `type=converter` in the underlying parser.
279+
the equivalent parser configuration automatically.
215280

216281
## Argument Parsing
217282

examples/annotated_example.py

Lines changed: 93 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,14 @@
1717
"""
1818

1919
import sys
20+
from argparse import Namespace
21+
from decimal import Decimal
2022
from enum import Enum
2123
from pathlib import Path
22-
from typing import Annotated
24+
from typing import (
25+
Annotated,
26+
Literal,
27+
)
2328

2429
import cmd2
2530
from cmd2 import (
@@ -54,6 +59,7 @@ class AnnotatedExample(Cmd):
5459
def __init__(self) -> None:
5560
super().__init__(include_ipy=True)
5661
self._sports = ['Basketball', 'Football', 'Tennis', 'Hockey']
62+
self._default_region = "staging"
5763

5864
# -- Type inference: int, float, bool ------------------------------------
5965
# With @with_argparser you'd manually set type=int and action='store_true'.
@@ -109,10 +115,8 @@ def do_copy(self, src: Path, dst: Path) -> None:
109115
self.poutput(f"Copying {src} -> {dst}")
110116

111117
# -- Bool flags ----------------------------------------------------------
112-
# With @with_argparser you'd set action='store_true' or 'store_false'.
113-
# Here bool defaults drive the flag style automatically.
114-
# False default -> --flag (store_true)
115-
# True default -> --no-flag (store_false)
118+
# With @with_argparser you'd spell out the action.
119+
# Here bool defaults drive the generated boolean option.
116120

117121
@cmd2.with_annotated
118122
@cmd2.with_category(ANNOTATED_CATEGORY)
@@ -124,8 +128,8 @@ def do_build(
124128
) -> None:
125129
"""Build a target. Bool flags are inferred from defaults.
126130
127-
``verbose: bool = False`` becomes ``--verbose`` (store_true).
128-
``color: bool = True`` becomes ``--no-color`` (store_false).
131+
``verbose: bool = False`` becomes a boolean optional flag.
132+
``color: bool = True`` becomes a ``--color`` / ``--no-color`` style option.
129133
130134
Try:
131135
build app --verbose --no-color
@@ -151,6 +155,25 @@ def do_sum(self, numbers: list[float]) -> None:
151155
"""
152156
self.poutput(f"{' + '.join(str(n) for n in numbers)} = {sum(numbers)}")
153157

158+
# -- Literal + Decimal ---------------------------------------------------
159+
# Literal values become validated choices. Decimal values preserve precision.
160+
161+
@cmd2.with_annotated
162+
@cmd2.with_category(ANNOTATED_CATEGORY)
163+
def do_deploy(
164+
self,
165+
service: str,
166+
mode: Literal["safe", "fast"] = "safe",
167+
budget: Decimal = Decimal("1.50"),
168+
) -> None:
169+
"""Deploy using Literal choices and Decimal parsing.
170+
171+
Try:
172+
deploy api --mode <TAB>
173+
deploy api --mode fast --budget 2.75
174+
"""
175+
self.poutput(f"Deploying {service} in {mode} mode with budget {budget}")
176+
154177
# -- Typed kwargs --------------------------------------------------------
155178
# With @with_argparser you'd access args.name, args.count on a Namespace.
156179
# Here each parameter is a typed local variable.
@@ -212,6 +235,69 @@ def do_score(
212235
"""
213236
self.poutput(f"{sport}: {play} for {points} point(s)")
214237

238+
# -- Namespace provider --------------------------------------------------
239+
# This mirrors one of @with_argparser's advanced features.
240+
241+
def default_namespace(self) -> Namespace:
242+
return Namespace(region=self._default_region)
243+
244+
@cmd2.with_annotated(ns_provider=default_namespace)
245+
@cmd2.with_category(ANNOTATED_CATEGORY)
246+
def do_ship(self, package: str, region: str = "local") -> None:
247+
"""Use ns_provider to prepopulate parser defaults at runtime.
248+
249+
Try:
250+
ship parcel
251+
ship parcel --region remote
252+
"""
253+
self.poutput(f"Shipping {package} to {region}")
254+
255+
# -- Unknown args --------------------------------------------------------
256+
257+
@cmd2.with_annotated(with_unknown_args=True)
258+
@cmd2.with_category(ANNOTATED_CATEGORY)
259+
def do_flex(self, name: str, _unknown: list[str] | None = None) -> None:
260+
"""Capture unknown arguments instead of failing parse.
261+
262+
Try:
263+
flex alice --future-flag value
264+
"""
265+
self.poutput(f"name={name}")
266+
if _unknown:
267+
self.poutput(f"unknown={_unknown}")
268+
269+
# -- Subcommands ---------------------------------------------------------
270+
# @with_annotated also supports typed subcommand trees.
271+
272+
@cmd2.with_annotated(base_command=True)
273+
@cmd2.with_category(ANNOTATED_CATEGORY)
274+
def do_manage(self, verbose: bool = False, *, cmd2_handler) -> None:
275+
"""Base command for annotated subcommands.
276+
277+
Try:
278+
help manage
279+
manage project add demo
280+
"""
281+
if verbose:
282+
self.poutput("verbose mode")
283+
handler = cmd2_handler.get()
284+
if handler:
285+
handler()
286+
287+
@cmd2.with_annotated(subcommand_to="manage", base_command=True, help="manage projects")
288+
def manage_project(self, *, cmd2_handler) -> None:
289+
handler = cmd2_handler.get()
290+
if handler:
291+
handler()
292+
293+
@cmd2.with_annotated(subcommand_to="manage project", help="add a project")
294+
def manage_project_add(self, name: str) -> None:
295+
self.poutput(f"project added: {name}")
296+
297+
@cmd2.with_annotated(subcommand_to="manage project", help="list projects")
298+
def manage_project_list(self) -> None:
299+
self.poutput("project list: demo")
300+
215301
# -- Preserve quotes -----------------------------------------------------
216302

217303
@cmd2.with_annotated(preserve_quotes=True)

0 commit comments

Comments
 (0)