Skip to content

Commit 71c5778

Browse files
chore: more update
1 parent efe03d4 commit 71c5778

File tree

5 files changed

+275
-34
lines changed

5 files changed

+275
-34
lines changed

cmd2/annotated.py

Lines changed: 128 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,12 @@ def do_paint(
4141
- ``int``, ``float`` -- sets ``type=`` for argparse
4242
- ``bool`` with default ``False`` -- ``--flag`` with ``store_true``
4343
- ``bool`` with default ``True`` -- ``--no-flag`` with ``store_false``
44+
- positional ``bool`` -- parsed from ``true/false``, ``yes/no``, ``on/off``, ``1/0``
4445
- ``pathlib.Path`` -- sets ``type=Path``
4546
- ``enum.Enum`` subclass -- ``type=converter``, ``choices`` from member values
46-
- ``list[T]`` -- ``nargs='+'`` (or ``'*'`` if has a default)
47+
- ``decimal.Decimal`` -- sets ``type=Decimal``
48+
- ``Literal[...]`` -- sets ``type=converter`` and ``choices`` from literal values
49+
- ``Collection[T]`` / ``list[T]`` / ``set[T]`` / ``tuple[T, ...]`` -- ``nargs='+'`` (or ``'*'`` if has a default)
4750
- ``T | None`` -- unwrapped to ``T``, treated as optional
4851
4952
Note: ``Path`` and ``Enum`` types also get automatic tab completion via
@@ -52,14 +55,19 @@ def do_paint(
5255
"""
5356

5457
import argparse
58+
import decimal
5559
import enum
5660
import inspect
5761
import pathlib
5862
import types
59-
from collections.abc import Callable
63+
from collections.abc import (
64+
Callable,
65+
Collection,
66+
)
6067
from typing import (
6168
Annotated,
6269
Any,
70+
Literal,
6371
Union,
6472
get_args,
6573
get_origin,
@@ -144,6 +152,63 @@ def __init__(
144152

145153
_NoneType = type(None)
146154

155+
_BOOL_TRUE_VALUES = {'1', 'true', 't', 'yes', 'y', 'on'}
156+
_BOOL_FALSE_VALUES = {'0', 'false', 'f', 'no', 'n', 'off'}
157+
158+
159+
def _parse_bool(value: str) -> bool:
160+
"""Parse a string into a boolean value for argparse type conversion."""
161+
lowered = value.strip().lower()
162+
if lowered in _BOOL_TRUE_VALUES:
163+
return True
164+
if lowered in _BOOL_FALSE_VALUES:
165+
return False
166+
raise argparse.ArgumentTypeError(f"invalid boolean value: {value!r} (choose from: 1, 0, true, false, yes, no, on, off)")
167+
168+
169+
def _make_literal_type(literal_values: list[Any]) -> Callable[[str], Any]:
170+
"""Create an argparse converter for a Literal's exact values."""
171+
value_map = {str(value): value for value in literal_values}
172+
173+
def _convert(value: str) -> Any:
174+
if value in value_map:
175+
return value_map[value]
176+
if value.lower() in _BOOL_TRUE_VALUES:
177+
bool_value = True
178+
elif value.lower() in _BOOL_FALSE_VALUES:
179+
bool_value = False
180+
else:
181+
bool_value = None
182+
183+
if bool_value is not None and bool_value in literal_values:
184+
return bool_value
185+
186+
valid = ', '.join(str(v) for v in literal_values)
187+
raise argparse.ArgumentTypeError(f"invalid choice: {value!r} (choose from {valid})")
188+
189+
_convert.__name__ = 'literal'
190+
return _convert
191+
192+
193+
class _CollectionStoreAction(argparse._StoreAction):
194+
"""Store action that can coerce parsed collection values to a container type."""
195+
196+
def __init__(self, *args: Any, container_factory: Callable[[list[Any]], Any] | None = None, **kwargs: Any) -> None:
197+
super().__init__(*args, **kwargs)
198+
self._container_factory = container_factory
199+
200+
def __call__(
201+
self,
202+
_parser: argparse.ArgumentParser,
203+
namespace: argparse.Namespace,
204+
values: Any,
205+
_option_string: str | None = None,
206+
) -> None:
207+
result = values
208+
if self._container_factory is not None and isinstance(values, list):
209+
result = self._container_factory(values)
210+
setattr(namespace, self.dest, result)
211+
147212

148213
def _make_enum_type(enum_class: type[enum.Enum]) -> Callable[[str], enum.Enum]:
149214
"""Create an argparse *type* converter for an Enum class.
@@ -165,6 +230,8 @@ def _convert(value: str) -> enum.Enum:
165230
raise argparse.ArgumentTypeError(f"invalid choice: {value!r} (choose from {valid})") from err
166231

167232
_convert.__name__ = enum_class.__name__
233+
# Preserve the enum class for downstream consumers like tab completion.
234+
_convert._cmd2_enum_class = enum_class
168235
return _convert
169236

170237

@@ -194,13 +261,42 @@ def _unwrap_optional(tp: Any) -> tuple[Any, bool]:
194261
return tp, False
195262

196263

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:
264+
def _unwrap_collection(tp: Any) -> tuple[Any, str | None]:
265+
"""Strip collection[T] and return ``(inner_type, collection_kind)``."""
266+
origin = get_origin(tp)
267+
if origin is list:
200268
args = get_args(tp)
201269
if args:
202-
return args[0], True
203-
return tp, False
270+
return args[0], 'list'
271+
272+
if origin is set:
273+
args = get_args(tp)
274+
if args:
275+
return args[0], 'set'
276+
277+
if origin is Collection:
278+
args = get_args(tp)
279+
if args:
280+
return args[0], 'collection'
281+
282+
if origin is tuple:
283+
args = get_args(tp)
284+
if len(args) == 2 and args[1] is Ellipsis:
285+
return args[0], 'tuple'
286+
return tp, None
287+
288+
289+
def _unwrap_literal(tp: Any) -> tuple[Any, list[Any] | None]:
290+
"""Strip ``Literal[...]`` and return ``(base_type, literal_values)``."""
291+
if get_origin(tp) is Literal:
292+
literal_values = list(get_args(tp))
293+
if not literal_values:
294+
return Any, []
295+
first_type = type(literal_values[0])
296+
if all(type(v) is first_type for v in literal_values):
297+
return first_type, literal_values
298+
return Any, literal_values
299+
return tp, None
204300

205301

206302
# ---------------------------------------------------------------------------
@@ -243,12 +339,16 @@ def build_parser_from_function(func: Callable[..., Any]) -> argparse.ArgumentPar
243339
# 2. Unwrap Optional[T] / T | None
244340
base_type, is_optional = _unwrap_optional(base_type)
245341

246-
# 3. Unwrap list[T]
247-
inner_type, is_list = _unwrap_list(base_type)
248-
if is_list:
342+
# 3. Unwrap collection[T]
343+
inner_type, collection_kind = _unwrap_collection(base_type)
344+
is_collection = collection_kind is not None
345+
if is_collection:
249346
base_type = inner_type
250347

251-
# 4. Determine positional vs option
348+
# 4. Unwrap Literal[...]
349+
base_type, literal_choices = _unwrap_literal(base_type)
350+
351+
# 5. Determine positional vs option
252352
if isinstance(metadata, Argument):
253353
is_positional = True
254354
elif isinstance(metadata, Option):
@@ -258,7 +358,7 @@ def build_parser_from_function(func: Callable[..., Any]) -> argparse.ArgumentPar
258358
else:
259359
is_positional = False
260360

261-
# 5. Build add_argument kwargs
361+
# 6. Build add_argument kwargs
262362
kwargs: dict[str, Any] = {}
263363

264364
# Help text
@@ -275,12 +375,18 @@ def build_parser_from_function(func: Callable[..., Any]) -> argparse.ArgumentPar
275375
explicit_nargs = metadata.nargs if metadata else None
276376
if explicit_nargs is not None:
277377
kwargs['nargs'] = explicit_nargs
278-
elif is_list:
378+
elif is_collection:
279379
kwargs['nargs'] = '*' if has_default else '+'
380+
if collection_kind in ('set', 'tuple'):
381+
kwargs['action'] = _CollectionStoreAction
382+
kwargs['container_factory'] = set if collection_kind == 'set' else tuple
280383

281384
# Type-specific handling
282385
is_bool_flag = False
283-
if base_type is bool and not is_list and not is_positional:
386+
if literal_choices is not None:
387+
kwargs['type'] = _make_literal_type(literal_choices)
388+
kwargs['choices'] = literal_choices
389+
elif base_type is bool and not is_collection and not is_positional:
284390
is_bool_flag = True
285391
action_str = getattr(metadata, 'action', None) if metadata else None
286392
if action_str:
@@ -289,11 +395,17 @@ def build_parser_from_function(func: Callable[..., Any]) -> argparse.ArgumentPar
289395
kwargs['action'] = 'store_false'
290396
else:
291397
kwargs['action'] = 'store_true'
398+
elif base_type is bool:
399+
kwargs['type'] = _parse_bool
292400
elif isinstance(base_type, type) and issubclass(base_type, enum.Enum):
401+
# Keep validation in the converter to support any Enum subclass,
402+
# including enums whose members are not directly comparable to raw
403+
# argparse input strings.
293404
kwargs['type'] = _make_enum_type(base_type)
294-
kwargs['choices'] = [m.value for m in base_type]
295405
elif base_type is pathlib.Path or (isinstance(base_type, type) and issubclass(base_type, pathlib.Path)):
296406
kwargs['type'] = pathlib.Path
407+
elif base_type is decimal.Decimal:
408+
kwargs['type'] = decimal.Decimal
297409
elif base_type in (int, float, str):
298410
if base_type is not str:
299411
kwargs['type'] = base_type
@@ -321,7 +433,7 @@ def build_parser_from_function(func: Callable[..., Any]) -> argparse.ArgumentPar
321433
if suppress_tab_hint:
322434
kwargs['suppress_tab_hint'] = suppress_tab_hint
323435

324-
# 6. Call add_argument
436+
# 7. Call add_argument
325437
if is_positional:
326438
parser.add_argument(name, **kwargs)
327439
else:

cmd2/argparse_completer.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -746,6 +746,10 @@ def _get_raw_choices(self, arg_state: _ArgumentState) -> list[CompletionItem] |
746746
if isinstance(action_type, type) and issubclass(action_type, enum.Enum):
747747
return [CompletionItem(str(m.value), display_meta=m.name) for m in action_type]
748748

749+
enum_from_converter = getattr(action_type, '_cmd2_enum_class', None)
750+
if isinstance(enum_from_converter, type) and issubclass(enum_from_converter, enum.Enum):
751+
return [CompletionItem(str(m.value), display_meta=m.name) for m in enum_from_converter]
752+
749753
return None
750754

751755
def _prepare_callable_params(

cmd2/decorators.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import argparse
44
import functools
5+
import inspect
56
from collections.abc import (
67
Callable,
78
Sequence,
@@ -368,6 +369,13 @@ def do_raw(self, text: str): ...
368369
from .annotated import build_parser_from_function
369370

370371
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
372+
if with_unknown_args:
373+
unknown_param = inspect.signature(fn).parameters.get('_unknown')
374+
if unknown_param is None:
375+
raise TypeError('with_annotated(with_unknown_args=True) requires a parameter named _unknown')
376+
if unknown_param.kind is inspect.Parameter.POSITIONAL_ONLY:
377+
raise TypeError('Parameter _unknown must be keyword-compatible when with_unknown_args=True')
378+
371379
command_name = fn.__name__[len(constants.COMMAND_FUNC_PREFIX) :]
372380

373381
@functools.wraps(fn)

docs/features/argument_processing.md

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ following for you:
1919
These features are provided by two decorators:
2020

2121
- [@with_argparser][cmd2.with_argparser] -- build parsers manually with `add_argument()` calls
22-
- [@with_annotated][cmd2.annotated.with_annotated] -- build parsers automatically from type hints
22+
- [@with_annotated][cmd2.decorators.with_annotated] -- build parsers automatically from type hints
2323

2424
See the
2525
[argparse_completion](https://github.com/python-cmd2/cmd2/blob/main/examples/argparse_completion.py)
@@ -30,7 +30,7 @@ examples to compare the two styles side by side.
3030
arguments passed to commands:
3131

3232
- [cmd2.decorators.with_argparser][]
33-
- [cmd2.annotated.with_annotated][]
33+
- [cmd2.decorators.with_annotated][]
3434
- [cmd2.decorators.with_argument_list][]
3535

3636
All of these decorators accept an optional **preserve_quotes** argument which defaults to `False`.
@@ -57,7 +57,7 @@ stores internally. A consequence is that parsers don't need to be unique across
5757

5858
## with_annotated decorator
5959

60-
The [@with_annotated][cmd2.annotated.with_annotated] decorator builds an argparse parser
60+
The [@with_annotated][cmd2.decorators.with_annotated] decorator builds an argparse parser
6161
automatically from the decorated function's type annotations. No manual `add_argument()` calls are
6262
required.
6363

@@ -85,16 +85,26 @@ them as keyword arguments.
8585

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

88-
| Type annotation | Generated argparse setting |
89-
| ------------------------ | ---------------------------------------------- |
90-
| `str` | default (no `type=` needed) |
91-
| `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-
| `Path` | `type=Path` |
95-
| `Enum` subclass | `type=converter`, `choices` from member values |
96-
| `list[T]` | `nargs='+'` (or `'*'` if it has a default) |
97-
| `T \| None` | unwrapped to `T`, treated as optional |
88+
| Type annotation | Generated argparse setting |
89+
| -------------------------------------------------------- | --------------------------------------------------- |
90+
| `str` | default (no `type=` needed) |
91+
| `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+
| positional `bool` | parsed from `true/false`, `yes/no`, `on/off`, `1/0` |
95+
| `Path` | `type=Path` |
96+
| `Enum` subclass | `type=converter`, `choices` from member values |
97+
| `decimal.Decimal` | `type=decimal.Decimal` |
98+
| `Literal[...]` | `type=literal-converter`, `choices` from values |
99+
| `Collection[T]` / `list[T]` / `set[T]` / `tuple[T, ...]` | `nargs='+'` (or `'*'` if it has a default) |
100+
| `T \| None` | unwrapped to `T`, treated as optional |
101+
102+
When collection types are used with `@with_annotated`, parsed values are passed to the command
103+
function as:
104+
105+
- `list[T]` and `Collection[T]` as `list`
106+
- `set[T]` as `set`
107+
- `tuple[T, ...]` as `tuple`
98108

99109
### Annotated metadata
100110

0 commit comments

Comments
 (0)