Skip to content

Commit acb79b7

Browse files
chore: more edge case clean up
1 parent b8e0833 commit acb79b7

File tree

5 files changed

+203
-54
lines changed

5 files changed

+203
-54
lines changed

cmd2/annotated.py

Lines changed: 59 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
finer control is needed.
88
99
Basic usage -- parameters without defaults become positional arguments,
10-
parameters with defaults become ``--option`` flags::
10+
parameters with defaults become ``--option`` flags. Keyword-only
11+
parameters (after ``*``) always become options; without a default they
12+
are required. The parameter name ``dest`` is reserved and cannot be
13+
used::
1114
1215
class MyApp(cmd2.Cmd):
1316
@cmd2.with_annotated
@@ -55,6 +58,10 @@ def do_paint(
5558
- ``tuple[int, str, float]`` -- mixed element types are not currently supported
5659
because argparse can only apply a single ``type=`` converter per argument
5760
61+
When combining ``Annotated`` with ``Optional``, the union must go
62+
*inside*: ``Annotated[T | None, meta]``. Writing
63+
``Annotated[T, meta] | None`` is ambiguous and raises ``TypeError``.
64+
5865
Note: ``Path`` and ``Enum`` types also get automatic tab completion via
5966
``ArgparseCompleter`` type inference. This works for both ``@with_annotated``
6067
and ``@with_argparser`` -- see the ``argparse_completer`` module.
@@ -301,9 +308,9 @@ def _make_collection_resolver(collection_type: type) -> Callable[..., dict[str,
301308
"""Create a resolver for single-arg collections (list[T], set[T])."""
302309

303310
def _resolve(_tp: Any, args: tuple[Any, ...], *, has_default: bool = False, **_ctx: Any) -> dict[str, Any]:
311+
nargs = '*' if has_default else '+'
304312
if len(args) == 0:
305313
# Bare list/tuple without type args -- treat as list[str]/set[str]
306-
nargs = '*' if has_default else '+'
307314
return {
308315
'is_collection': True,
309316
'nargs': nargs,
@@ -314,7 +321,6 @@ def _resolve(_tp: Any, args: tuple[Any, ...], *, has_default: bool = False, **_c
314321
if len(args) != 1:
315322
return {} # pragma: no cover
316323
element_type, inner = _resolve_element(args[0])
317-
nargs = '*' if has_default else '+'
318324
return {
319325
**inner,
320326
'is_collection': True,
@@ -331,14 +337,13 @@ def _resolve_tuple(_tp: Any, args: tuple[Any, ...], *, has_default: bool = False
331337
"""Resolve tuple[T, ...] and tuple[T1, T2, ...]."""
332338
cast_kwargs = {'action': _CollectionCastingAction, 'container_factory': tuple}
333339

340+
nargs = '*' if has_default else '+'
334341
if not args:
335342
# Bare tuple without type args -- treat as tuple[str, ...]
336-
nargs = '*' if has_default else '+'
337343
return {'is_collection': True, 'nargs': nargs, 'base_type': str, **cast_kwargs}
338344

339345
if len(args) == 2 and args[1] is Ellipsis:
340346
element_type, inner = _resolve_element(args[0])
341-
nargs = '*' if has_default else '+'
342347
return {**inner, 'is_collection': True, 'nargs': nargs, 'base_type': element_type, **cast_kwargs}
343348

344349
if Ellipsis not in args:
@@ -424,6 +429,26 @@ def _resolve_type(
424429
return tp, {}
425430

426431

432+
def _unwrap_optional(tp: type) -> tuple[type, bool]:
433+
"""If *tp* is ``T | None``, return ``(T, True)``. Otherwise ``(tp, False)``.
434+
435+
Raises ``TypeError`` for ambiguous unions like ``str | int`` or ``str | int | None``.
436+
"""
437+
origin = get_origin(tp)
438+
if origin is Union or origin is types.UnionType: # type: ignore[comparison-overlap]
439+
all_args = get_args(tp)
440+
non_none = [a for a in all_args if a is not type(None)]
441+
has_none = len(non_none) < len(all_args)
442+
if len(non_none) == 1:
443+
if has_none:
444+
return non_none[0], True
445+
# Single-element union without None shouldn't happen, pass through
446+
return non_none[0], False # pragma: no cover
447+
type_names = ' | '.join(a.__name__ if hasattr(a, '__name__') else str(a) for a in non_none)
448+
raise TypeError(f"Union type {type_names} is ambiguous for auto-resolution. ")
449+
return tp, False
450+
451+
427452
# ---------------------------------------------------------------------------
428453
# Annotation resolution
429454
# ---------------------------------------------------------------------------
@@ -437,13 +462,27 @@ def _resolve_annotation(
437462
) -> tuple[dict[str, Any], ArgMetadata, bool, bool]:
438463
"""Decompose a type annotation into ``(type_kwargs, metadata, is_positional, is_bool_flag)``.
439464
440-
Peels in order: Annotated → Optional → type resolution.
465+
Peels ``Annotated`` then ``Optional``. The only supported way to combine
466+
``Annotated`` with ``Optional`` is ``Annotated[T | None, meta]``.
467+
Writing ``Annotated[T, meta] | None`` is ambiguous and raises ``TypeError``.
441468
"""
442469
tp = annotation
443470
metadata: ArgMetadata = None
444471
is_optional = False
445472

446-
# 1. Annotated
473+
# 1. If top level is Optional wrapping Annotated, only allow
474+
# Annotated[T | None, meta] form (inner type must contain None).
475+
tp, unwrapped = _unwrap_optional(tp)
476+
if unwrapped:
477+
is_optional = True
478+
if get_origin(tp) is Annotated: # type: ignore[comparison-overlap]
479+
inner_tp = get_args(tp)[0]
480+
inner_origin = get_origin(inner_tp)
481+
inner_is_union = inner_origin is Union or inner_origin is types.UnionType # type: ignore[comparison-overlap]
482+
if not (inner_is_union and type(None) in get_args(inner_tp)):
483+
raise TypeError("Annotated[T, meta] | None is ambiguous. Use Annotated[T | None, meta] instead.")
484+
485+
# 2. Peel Annotated to extract metadata
447486
if get_origin(tp) is Annotated: # type: ignore[comparison-overlap]
448487
args = get_args(tp)
449488
tp = args[0]
@@ -452,18 +491,10 @@ def _resolve_annotation(
452491
metadata = meta
453492
break
454493

455-
# 2. Optional / T | None
456-
origin = get_origin(tp)
457-
if origin is Union or origin is types.UnionType: # type: ignore[comparison-overlap]
458-
args = [a for a in get_args(tp) if a is not type(None)] # type: ignore[assignment]
459-
if len(args) == 1:
460-
tp = args[0]
461-
is_optional = True
462-
else:
463-
# len > 1: ambiguous union (e.g. str | int)
464-
# len == 0: all-None union -- unreachable via normal typing but handle defensively
465-
type_names = ' | '.join(a.__name__ if hasattr(a, '__name__') else str(a) for a in args)
466-
raise TypeError(f"Union type {type_names} is ambiguous for auto-resolution. ")
494+
# 3. Peel inner Optional (from Annotated[T | None, meta])
495+
tp, inner_unwrapped = _unwrap_optional(tp)
496+
if inner_unwrapped:
497+
is_optional = True
467498

468499
is_positional = isinstance(metadata, Argument) or (
469500
not isinstance(metadata, Option) and not has_default and not is_optional
@@ -548,12 +579,20 @@ def build_parser_from_function(func: Callable[..., Any]) -> argparse.ArgumentPar
548579
has_default = param.default is not inspect.Parameter.empty
549580
default = param.default if has_default else None
550581

582+
# Keyword-only params (after *) are always options, never positional
583+
is_kw_only = param.kind == inspect.Parameter.KEYWORD_ONLY
584+
551585
kwargs, metadata, positional, _is_bool_flag = _resolve_annotation(
552586
annotation,
553-
has_default=has_default,
587+
has_default=has_default or is_kw_only,
554588
default=default,
555589
)
556590

591+
# Keyword-only without default becomes a required option
592+
if is_kw_only and not has_default:
593+
positional = False
594+
kwargs['required'] = True
595+
557596
if positional:
558597
parser.add_argument(name, **kwargs)
559598
else:

cmd2/decorators.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,9 @@ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
383383

384384
command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX) :]
385385

386+
# Cache signature introspection at decoration time, not per-invocation
387+
_accepted_params = set(inspect.signature(func).parameters.keys()) - {'self'}
388+
386389
@functools.wraps(func)
387390
def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
388391
cmd2_app, statement_arg = _parse_positionals(args)
@@ -412,8 +415,6 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
412415
raise Cmd2ArgparseError from exc
413416

414417
# Unpack Namespace into function kwargs, filtering cmd2 internals.
415-
# Note: "dest" keys could conflict with annotation names -- may need validation.
416-
# Note: "required" handling when mixing with Options metadata needs verification.
417418
func_kwargs: dict[str, Any] = {}
418419
for key, value in vars(ns).items():
419420
if key.startswith('cmd2_') or key == constants.NS_ATTR_SUBCMD_HANDLER:
@@ -431,9 +432,7 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
431432
func_kwargs['_unknown'] = unknown
432433

433434
# Only pass kwargs the function actually accepts
434-
sig = inspect.signature(func)
435-
accepted = set(sig.parameters.keys()) - {'self'}
436-
filtered_kwargs = {k: v for k, v in func_kwargs.items() if k in accepted}
435+
filtered_kwargs = {k: v for k, v in func_kwargs.items() if k in _accepted_params}
437436

438437
# Explicit kwargs provided by direct wrapper invocation should override
439438
# parser-derived values.

docs/features/argument_processing.md

Lines changed: 40 additions & 1 deletion
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 regardless of whether they have a
68+
default -- without a default they become required options. The function receives typed keyword
69+
arguments directly instead of an `argparse.Namespace`.
6870

6971
```py
7072
from cmd2 import with_annotated
@@ -96,6 +98,7 @@ The decorator converts Python type annotations into `add_argument()` calls:
9698
| `decimal.Decimal` | `type=decimal.Decimal` |
9799
| `Literal[...]` | `type=literal-converter`, `choices` from values |
98100
| `list[T]` / `set[T]` / `tuple[T, ...]` | `nargs='+'` (or `'*'` if it has a default) |
101+
| `list` / `tuple` (bare, no type arg) | `nargs='+'` with `str` elements |
99102
| `tuple[T, T]` (fixed arity, same type) | `nargs=N` with `type=T` |
100103
| `T \| None` | unwrapped to `T`, treated as optional |
101104

@@ -106,6 +109,33 @@ function as:
106109
- `set[T]` as `set`
107110
- `tuple[T, ...]` as `tuple`
108111

112+
### Keyword-only parameters
113+
114+
Parameters after `*` in the function signature always become `--option` flags, even without a
115+
default value. Without a default, they become required options:
116+
117+
```py
118+
@with_annotated
119+
def do_search(self, query: str, *, limit: int, verbose: bool = False):
120+
# query → positional (no default)
121+
# limit → --limit (required, keyword-only without default)
122+
# verbose → --verbose / --no-verbose (keyword-only with default)
123+
...
124+
```
125+
126+
### Defaults
127+
128+
Default values are stored as-is on the argparse action and are not passed through the type
129+
converter. This means a default of `Color.blue` stays as the enum member, and `Path("/tmp")` stays
130+
as a `Path` object. If you provide a default of the wrong type (e.g. `count: int = "1"`), argparse
131+
will use the string `"1"` as the default without converting it.
132+
133+
### Reserved parameter names
134+
135+
The parameter name `dest` cannot be used with `@with_annotated` because this creates ambiguity in is
136+
the `dest` should be used or is the annotated variable name should be used. Using it raises
137+
`ValueError` at decoration time.
138+
109139
### Unsupported type patterns
110140

111141
The following type patterns raise `TypeError` at decoration time:
@@ -116,6 +146,15 @@ The following type patterns raise `TypeError` at decoration time:
116146
- **Mixed-type tuples** like `tuple[int, str, float]` -- not currently supported because argparse
117147
can only apply a single `type=` converter per argument. Use `tuple[T, T]` (same type for all
118148
elements) or `tuple[T, ...]` instead.
149+
- **`Annotated[T, meta] | None`** -- ambiguous. Place the union inside `Annotated` instead:
150+
151+
```py
152+
# Correct: union INSIDE Annotated
153+
name: Annotated[str | None, Option("--name")] = None
154+
155+
# Wrong: raises TypeError
156+
name: Annotated[str, Option("--name")] | None = None # don't do this
157+
```
119158

120159
### Annotated metadata
121160

0 commit comments

Comments
 (0)