Skip to content

Commit dc009ef

Browse files
feat: add annotated argparse building
1 parent 53a5c0f commit dc009ef

6 files changed

Lines changed: 820 additions & 6 deletions

File tree

cmd2/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
rich_utils,
1212
string_utils,
1313
)
14+
from .annotated import (
15+
Argument,
16+
Option,
17+
)
1418
from .argparse_completer import set_default_ap_completer_type
1519
from .argparse_custom import (
1620
Cmd2ArgumentParser,
@@ -35,6 +39,7 @@
3539
)
3640
from .decorators import (
3741
as_subcommand_to,
42+
with_annotated,
3843
with_argparser,
3944
with_argument_list,
4045
with_category,
@@ -78,7 +83,11 @@
7883
'Choices',
7984
'CompletionItem',
8085
'Completions',
86+
# Annotated
87+
'Argument',
88+
'Option',
8189
# Decorators
90+
'with_annotated',
8291
'with_argument_list',
8392
'with_argparser',
8493
'with_category',

cmd2/annotated.py

Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
"""Build argparse parsers from type-annotated function signatures.
2+
3+
This module provides the :func:`with_annotated` decorator that inspects a
4+
command function's type hints and default values to automatically construct
5+
a ``Cmd2ArgumentParser``. It also provides :class:`Argument` and
6+
:class:`Option` metadata classes for use with ``typing.Annotated`` when
7+
finer control is needed.
8+
9+
Basic usage -- parameters without defaults become positional arguments,
10+
parameters with defaults become ``--option`` flags::
11+
12+
class MyApp(cmd2.Cmd):
13+
@cmd2.with_annotated
14+
def do_greet(self, name: str, count: int = 1, loud: bool = False):
15+
for _ in range(count):
16+
msg = f"Hello {name}"
17+
self.poutput(msg.upper() if loud else msg)
18+
19+
Use ``Annotated`` with :class:`Argument` or :class:`Option` for finer
20+
control over individual parameters::
21+
22+
from typing import Annotated
23+
24+
class MyApp(cmd2.Cmd):
25+
def color_choices(self) -> cmd2.Choices:
26+
return cmd2.Choices.from_values(["red", "green", "blue"])
27+
28+
@cmd2.with_annotated
29+
def do_paint(
30+
self,
31+
item: str,
32+
color: Annotated[str, Option("--color", "-c",
33+
choices_provider=color_choices,
34+
help_text="Color to use")] = "blue",
35+
):
36+
self.poutput(f"Painting {item} {color}")
37+
38+
How annotations map to argparse settings:
39+
40+
- ``str`` -- default string argument
41+
- ``int``, ``float`` -- sets ``type=`` for argparse
42+
- ``bool`` with default ``False`` -- ``--flag`` with ``store_true``
43+
- ``bool`` with default ``True`` -- ``--no-flag`` with ``store_false``
44+
- ``pathlib.Path`` -- sets ``type=Path``
45+
- ``enum.Enum`` subclass -- ``type=converter``, ``choices`` from member values
46+
- ``list[T]`` -- ``nargs='+'`` (or ``'*'`` if has a default)
47+
- ``T | None`` -- unwrapped to ``T``, treated as optional
48+
49+
Note: ``Path`` and ``Enum`` types also get automatic tab completion via
50+
``ArgparseCompleter`` type inference. This works for both ``@with_annotated``
51+
and ``@with_argparser`` -- see the ``argparse_completer`` module.
52+
"""
53+
54+
import argparse
55+
import enum
56+
import inspect
57+
import pathlib
58+
import types
59+
from collections.abc import Callable
60+
from typing import (
61+
Annotated,
62+
Any,
63+
Union,
64+
get_args,
65+
get_origin,
66+
get_type_hints,
67+
)
68+
69+
from .types import ChoicesProviderUnbound, CmdOrSet, CompleterUnbound
70+
71+
# ---------------------------------------------------------------------------
72+
# Metadata classes
73+
# ---------------------------------------------------------------------------
74+
75+
76+
class _BaseArgMetadata:
77+
"""Shared fields for ``Argument`` and ``Option`` metadata."""
78+
79+
def __init__(
80+
self,
81+
*,
82+
help_text: str | None = None,
83+
metavar: str | None = None,
84+
nargs: int | str | tuple[int, ...] | None = None,
85+
choices: list[Any] | None = None,
86+
choices_provider: ChoicesProviderUnbound[CmdOrSet] | None = None,
87+
completer: CompleterUnbound[CmdOrSet] | None = None,
88+
table_columns: tuple[str, ...] | None = None,
89+
suppress_tab_hint: bool = False,
90+
) -> None:
91+
"""Initialise shared metadata fields."""
92+
self.help_text = help_text
93+
self.metavar = metavar
94+
self.nargs = nargs
95+
self.choices = choices
96+
self.choices_provider = choices_provider
97+
self.completer = completer
98+
self.table_columns = table_columns
99+
self.suppress_tab_hint = suppress_tab_hint
100+
101+
102+
class Argument(_BaseArgMetadata):
103+
"""Metadata for a positional argument in an ``Annotated`` type hint.
104+
105+
Example::
106+
107+
def do_greet(self, name: Annotated[str, Argument(help_text="Person to greet")]):
108+
...
109+
"""
110+
111+
112+
class Option(_BaseArgMetadata):
113+
"""Metadata for an optional/flag argument in an ``Annotated`` type hint.
114+
115+
Positional ``*names`` are the flag strings (e.g. ``"--color"``, ``"-c"``).
116+
When omitted, the decorator auto-generates ``--param_name``.
117+
118+
Example::
119+
120+
def do_paint(
121+
self,
122+
color: Annotated[str, Option("--color", "-c", help_text="Color")] = "blue",
123+
):
124+
...
125+
"""
126+
127+
def __init__(
128+
self,
129+
*names: str,
130+
action: str | None = None,
131+
required: bool = False,
132+
**kwargs: Any,
133+
) -> None:
134+
"""Initialise Option metadata."""
135+
super().__init__(**kwargs)
136+
self.names = names
137+
self.action = action
138+
self.required = required
139+
140+
141+
# ---------------------------------------------------------------------------
142+
# Type helpers
143+
# ---------------------------------------------------------------------------
144+
145+
_NoneType = type(None)
146+
147+
148+
def _make_enum_type(enum_class: type[enum.Enum]) -> Callable[[str], enum.Enum]:
149+
"""Create an argparse *type* converter for an Enum class.
150+
151+
Accepts both member *values* and member *names*.
152+
"""
153+
# Pre-build a value→member lookup for O(1) conversion
154+
_value_map = {str(m.value): m for m in enum_class}
155+
156+
def _convert(value: str) -> enum.Enum:
157+
member = _value_map.get(value)
158+
if member is not None:
159+
return member
160+
# Fallback to name lookup
161+
try:
162+
return enum_class[value]
163+
except KeyError as err:
164+
valid = ', '.join(_value_map)
165+
raise argparse.ArgumentTypeError(f"invalid choice: {value!r} (choose from {valid})") from err
166+
167+
_convert.__name__ = enum_class.__name__
168+
return _convert
169+
170+
171+
def _unwrap_type(annotation: Any) -> tuple[Any, Argument | Option | None]:
172+
"""Unwrap ``Annotated[T, metadata]`` and return ``(base_type, metadata)``.
173+
174+
Returns ``(annotation, None)`` when there is no ``Annotated`` wrapper or
175+
no ``Argument``/``Option`` metadata inside it.
176+
"""
177+
if get_origin(annotation) is Annotated:
178+
args = get_args(annotation)
179+
base_type = args[0]
180+
for meta in args[1:]:
181+
if isinstance(meta, (Argument, Option)):
182+
return base_type, meta
183+
return base_type, None
184+
return annotation, None
185+
186+
187+
def _unwrap_optional(tp: Any) -> tuple[Any, bool]:
188+
"""Strip ``Optional[T]`` / ``T | None`` and return ``(inner_type, is_optional)``."""
189+
origin = get_origin(tp)
190+
if origin is Union or origin is types.UnionType:
191+
args = [a for a in get_args(tp) if a is not _NoneType]
192+
if len(args) == 1:
193+
return args[0], True
194+
return tp, False
195+
196+
197+
def _unwrap_list(tp: Any) -> tuple[Any, bool]:
198+
"""Strip ``list[T]`` and return ``(inner_type, is_list)``."""
199+
if get_origin(tp) is list:
200+
args = get_args(tp)
201+
if args:
202+
return args[0], True
203+
return tp, False
204+
205+
206+
# ---------------------------------------------------------------------------
207+
# Signature → Parser conversion
208+
# ---------------------------------------------------------------------------
209+
210+
211+
def build_parser_from_function(func: Callable[..., Any]) -> argparse.ArgumentParser:
212+
"""Inspect a function's signature and build a ``Cmd2ArgumentParser``.
213+
214+
Parameters without defaults become positional arguments.
215+
Parameters with defaults become ``--option`` flags.
216+
``Annotated[T, Argument(...)]`` or ``Annotated[T, Option(...)]``
217+
overrides the default behaviour.
218+
219+
:param func: the command function to inspect
220+
:return: a fully configured ``Cmd2ArgumentParser``
221+
"""
222+
from .argparse_custom import DEFAULT_ARGUMENT_PARSER
223+
224+
parser = DEFAULT_ARGUMENT_PARSER()
225+
226+
sig = inspect.signature(func)
227+
try:
228+
hints = get_type_hints(func, include_extras=True)
229+
except (NameError, AttributeError, TypeError):
230+
hints = {}
231+
232+
for name, param in sig.parameters.items():
233+
if name == 'self':
234+
continue
235+
236+
annotation = hints.get(name, param.annotation)
237+
has_default = param.default is not inspect.Parameter.empty
238+
default = param.default if has_default else None
239+
240+
# 1. Unwrap Annotated[T, metadata]
241+
base_type, metadata = _unwrap_type(annotation)
242+
243+
# 2. Unwrap Optional[T] / T | None
244+
base_type, is_optional = _unwrap_optional(base_type)
245+
246+
# 3. Unwrap list[T]
247+
inner_type, is_list = _unwrap_list(base_type)
248+
if is_list:
249+
base_type = inner_type
250+
251+
# 4. Determine positional vs option
252+
if isinstance(metadata, Argument):
253+
is_positional = True
254+
elif isinstance(metadata, Option):
255+
is_positional = False
256+
elif not has_default and not is_optional:
257+
is_positional = True
258+
else:
259+
is_positional = False
260+
261+
# 5. Build add_argument kwargs
262+
kwargs: dict[str, Any] = {}
263+
264+
# Help text
265+
help_text = metadata.help_text if metadata else None
266+
if help_text:
267+
kwargs['help'] = help_text
268+
269+
# Metavar
270+
metavar = metadata.metavar if metadata else None
271+
if metavar:
272+
kwargs['metavar'] = metavar
273+
274+
# Nargs from metadata
275+
explicit_nargs = metadata.nargs if metadata else None
276+
if explicit_nargs is not None:
277+
kwargs['nargs'] = explicit_nargs
278+
elif is_list:
279+
kwargs['nargs'] = '*' if has_default else '+'
280+
281+
# Type-specific handling
282+
is_bool_flag = False
283+
if base_type is bool and not is_list and not is_positional:
284+
is_bool_flag = True
285+
action_str = getattr(metadata, 'action', None) if metadata else None
286+
if action_str:
287+
kwargs['action'] = action_str
288+
elif has_default and default is True:
289+
kwargs['action'] = 'store_false'
290+
else:
291+
kwargs['action'] = 'store_true'
292+
elif isinstance(base_type, type) and issubclass(base_type, enum.Enum):
293+
kwargs['type'] = _make_enum_type(base_type)
294+
kwargs['choices'] = [m.value for m in base_type]
295+
elif base_type is pathlib.Path or (isinstance(base_type, type) and issubclass(base_type, pathlib.Path)):
296+
kwargs['type'] = pathlib.Path
297+
elif base_type in (int, float, str):
298+
if base_type is not str:
299+
kwargs['type'] = base_type
300+
301+
if has_default:
302+
kwargs['default'] = default
303+
304+
# Static choices from metadata (unless already set by enum inference)
305+
explicit_choices = getattr(metadata, 'choices', None)
306+
if explicit_choices is not None and 'choices' not in kwargs:
307+
kwargs['choices'] = explicit_choices
308+
309+
# cmd2-specific fields from metadata
310+
choices_provider = getattr(metadata, 'choices_provider', None)
311+
completer_func = getattr(metadata, 'completer', None)
312+
table_columns = getattr(metadata, 'table_columns', None)
313+
suppress_tab_hint = getattr(metadata, 'suppress_tab_hint', False)
314+
315+
if choices_provider:
316+
kwargs['choices_provider'] = choices_provider
317+
if completer_func:
318+
kwargs['completer'] = completer_func
319+
if table_columns:
320+
kwargs['table_columns'] = table_columns
321+
if suppress_tab_hint:
322+
kwargs['suppress_tab_hint'] = suppress_tab_hint
323+
324+
# 6. Call add_argument
325+
if is_positional:
326+
parser.add_argument(name, **kwargs)
327+
else:
328+
# Option
329+
option_metadata = metadata if isinstance(metadata, Option) else None
330+
if option_metadata and option_metadata.names:
331+
flag_names = list(option_metadata.names)
332+
else:
333+
flag_names = [f'--{name}']
334+
if is_bool_flag and has_default and default is True:
335+
flag_names = [f'--no-{name}']
336+
337+
if option_metadata and option_metadata.required:
338+
kwargs['required'] = True
339+
340+
# Set dest explicitly so it matches the parameter name
341+
kwargs['dest'] = name
342+
343+
parser.add_argument(*flag_names, **kwargs)
344+
345+
return parser

0 commit comments

Comments
 (0)