Skip to content

Commit d4756eb

Browse files
chore: more clean up
1 parent 076defc commit d4756eb

2 files changed

Lines changed: 106 additions & 41 deletions

File tree

cmd2/annotated.py

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@
1313
Basic usage -- parameters without defaults become positional arguments,
1414
parameters with defaults become ``--option`` flags. Keyword-only
1515
parameters (after ``*``) always become options; without a default they
16-
are required. The parameter name ``dest`` is reserved and cannot be
17-
used::
16+
are required. Underscores in parameter names are auto-converted to
17+
dashes in the generated flag (``dry_run`` -> ``--dry-run``); pass
18+
explicit names via ``Option("--my_flag")`` to opt out. The parameter
19+
name ``dest`` is reserved and cannot be used::
1820
1921
class MyApp(cmd2.Cmd):
2022
@cmd2.with_annotated
@@ -76,7 +78,13 @@ def do_paint(
7678
Note: ``Path`` and ``Enum`` annotations with ``@with_annotated`` also get
7779
automatic tab completion via generated parser metadata.
7880
If a user-supplied ``choices_provider`` or ``completer`` is set on an argument,
79-
it always takes priority over the type-inferred completion.
81+
it always takes priority over the type-inferred completion. For ``Enum`` and
82+
``Literal``, the restrictive type converter is also stripped so user-supplied
83+
values are not rejected at parse time. The ``Path`` converter is permissive
84+
and is preserved when a custom completer is provided.
85+
86+
The parameter name ``cmd2_handler`` is reserved for base commands declared with
87+
``with_annotated(base_command=True)`` and may not be used elsewhere.
8088
"""
8189

8290
import argparse
@@ -100,7 +108,6 @@ def do_paint(
100108

101109
from . import constants
102110
from .argparse_utils import Cmd2ArgumentParser, SubcommandSpec
103-
from .cmd2 import Cmd
104111
from .completion import CompletionItem
105112
from .decorators import _parse_positionals
106113
from .exceptions import Cmd2ArgparseError
@@ -166,7 +173,8 @@ class Option(_BaseArgMetadata):
166173
"""Metadata for an optional/flag argument in an ``Annotated`` type hint.
167174
168175
Positional ``*names`` are the flag strings (e.g. ``"--color"``, ``"-c"``).
169-
When omitted, the decorator auto-generates ``--param_name``.
176+
When omitted, the decorator auto-generates ``--param-name`` (underscores
177+
in the parameter name are converted to dashes).
170178
171179
Example::
172180
@@ -193,10 +201,10 @@ def __init__(
193201
def to_kwargs(self) -> dict[str, Any]:
194202
"""Return non-None fields as an argparse kwargs dict."""
195203
kwargs = super().to_kwargs()
196-
if self.action:
204+
if self.action is not None:
197205
kwargs["action"] = self.action
198206
if self.required:
199-
kwargs["required"] = self.required
207+
kwargs["required"] = True
200208
return kwargs
201209

202210

@@ -265,6 +273,7 @@ def _convert(value: str) -> Any:
265273
raise argparse.ArgumentTypeError(f"invalid choice: {value!r} (choose from {valid})")
266274

267275
_convert.__name__ = "literal"
276+
_convert._cmd2_strict_choice_converter = True # type: ignore[attr-defined]
268277
return _convert
269278

270279

@@ -287,6 +296,7 @@ def _convert(value: str) -> enum.Enum:
287296

288297
_convert.__name__ = enum_class.__name__
289298
_convert._cmd2_enum_class = enum_class # type: ignore[attr-defined]
299+
_convert._cmd2_strict_choice_converter = True # type: ignore[attr-defined]
290300
return _convert
291301

292302

@@ -324,6 +334,8 @@ def _resolve(_tp: Any, _args: tuple[Any, ...], **_ctx: Any) -> dict[str, Any]:
324334

325335
def _resolve_path(_tp: Any, _args: tuple[Any, ...], **_ctx: Any) -> dict[str, Any]:
326336
"""Resolve Path and add completer."""
337+
from .cmd2 import Cmd
338+
327339
return {"type": Path, "completer": Cmd.path_complete}
328340

329341

@@ -339,8 +351,8 @@ def _resolve_bool(
339351
if not is_positional:
340352
action_str = getattr(metadata, "action", None) if metadata else None
341353
if action_str:
342-
return {"action": action_str, "is_bool_flag": True}
343-
return {"action": argparse.BooleanOptionalAction, "is_bool_flag": True}
354+
return {"action": action_str}
355+
return {"action": argparse.BooleanOptionalAction}
344356
return {"type": _parse_bool, "choices": list(_BOOL_CHOICES)}
345357

346358

@@ -504,6 +516,9 @@ def _resolve_type(
504516

505517
if kwargs.get("choices_provider") or kwargs.get("completer"):
506518
kwargs.pop("choices", None)
519+
converter = kwargs.get("type")
520+
if getattr(converter, "_cmd2_strict_choice_converter", False):
521+
kwargs.pop("type", None)
507522

508523
return base_type, kwargs
509524

@@ -572,8 +587,8 @@ def _resolve_annotation(
572587
has_default: bool = False,
573588
default: Any = None,
574589
is_kw_only: bool = False,
575-
) -> tuple[dict[str, Any], ArgMetadata, bool, bool]:
576-
"""Decompose a type annotation into ``(type_kwargs, metadata, is_positional, is_bool_flag)``.
590+
) -> tuple[dict[str, Any], ArgMetadata, bool]:
591+
"""Decompose a type annotation into ``(type_kwargs, metadata, is_positional)``.
577592
578593
Peels ``Annotated`` then ``Optional``. The only supported way to combine
579594
``Annotated`` with ``Optional`` is ``Annotated[T | None, meta]``.
@@ -585,7 +600,6 @@ def _resolve_annotation(
585600
not isinstance(metadata, Option) and not has_default and not is_optional and not is_kw_only
586601
)
587602

588-
# 4. Resolve type and finalize argparse kwargs
589603
tp, type_kwargs = _resolve_type(
590604
tp,
591605
is_positional=is_positional,
@@ -595,12 +609,10 @@ def _resolve_annotation(
595609
is_kw_only=is_kw_only,
596610
)
597611

598-
# Strip internal keys not meant for argparse
599-
is_bool_flag = type_kwargs.pop("is_bool_flag", False)
600612
type_kwargs.pop("is_collection", None)
601613
type_kwargs.pop("base_type", None)
602614

603-
return type_kwargs, metadata, is_positional, is_bool_flag
615+
return type_kwargs, metadata, is_positional
604616

605617

606618
# Parameter names that conflict with argparse internals and cannot be used
@@ -619,9 +631,7 @@ def _validate_base_command_params(
619631
skip_params: frozenset[str] | None = None,
620632
) -> None:
621633
"""Validate a ``base_command=True`` function has ``cmd2_handler`` and no positional args."""
622-
sig = inspect.signature(func)
623-
624-
if "cmd2_handler" not in sig.parameters:
634+
if "cmd2_handler" not in inspect.signature(func).parameters:
625635
raise TypeError(f"with_annotated(base_command=True) requires a 'cmd2_handler' parameter in {func.__qualname__}")
626636

627637
if skip_params is None:
@@ -689,7 +699,7 @@ def _resolve_parameters(
689699
default = param.default if has_default else None
690700
is_kw_only = param.kind == inspect.Parameter.KEYWORD_ONLY
691701

692-
kwargs, metadata, positional, _is_bool_flag = _resolve_annotation(
702+
kwargs, metadata, positional = _resolve_annotation(
693703
annotation,
694704
has_default=has_default,
695705
default=default,
@@ -699,7 +709,9 @@ def _resolve_parameters(
699709
if positional:
700710
flags: list[str] = []
701711
else:
702-
flags = list(metadata.names) if isinstance(metadata, Option) and metadata.names else [f"--{name}"]
712+
flags = (
713+
list(metadata.names) if isinstance(metadata, Option) and metadata.names else [f"--{name.replace('_', '-')}"]
714+
)
703715
kwargs["dest"] = name
704716

705717
resolved.append((name, metadata, positional, flags, kwargs))
@@ -987,6 +999,11 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
987999
if unknown_param.kind is inspect.Parameter.POSITIONAL_ONLY:
9881000
raise TypeError("Parameter _unknown must be keyword-compatible when with_unknown_args=True")
9891001

1002+
if not base_command and "cmd2_handler" in inspect.signature(fn).parameters:
1003+
raise TypeError(
1004+
f"Parameter 'cmd2_handler' in {fn.__qualname__} is only valid when with_annotated(base_command=True) is used."
1005+
)
1006+
9901007
if subcommand_to is not None:
9911008
handler, subcmd_name, subcmd_parser_builder = build_subcommand_handler(
9921009
fn,

tests/test_annotated.py

Lines changed: 69 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ class TestBuildParser:
254254
pytest.param(_func_kw_only, {"option_strings": ["--name"], "required": True}, id="kw_only_required"),
255255
pytest.param(_func_kw_only_with_default, {"option_strings": ["--name"], "default": "world"}, id="kw_only_default"),
256256
# --- Underscore in flag names ---
257-
pytest.param(_func_underscore_option, {"option_strings": ["--my_param"], "default": "x"}, id="underscore_flag"),
257+
pytest.param(_func_underscore_option, {"option_strings": ["--my-param"], "default": "x"}, id="underscore_flag"),
258258
# --- Default type preservation ---
259259
pytest.param(
260260
_func_default_type_mismatch, {"option_strings": ["--count"], "default": "1"}, id="default_not_coerced"
@@ -365,6 +365,28 @@ def test_choices_provider_overrides_inferred_enum_choices(self) -> None:
365365
assert action.get_choices_provider() is not None # type: ignore[attr-defined]
366366
assert action.get_completer() is None # type: ignore[attr-defined]
367367

368+
def test_choices_provider_strips_strict_enum_converter(self) -> None:
369+
"""User-supplied choices_provider on Enum drops the restrictive enum converter."""
370+
action = _get_param_action(_func_choices_provider_on_enum)
371+
assert action.type is None
372+
373+
def test_choices_provider_strips_strict_literal_converter(self) -> None:
374+
"""User-supplied choices_provider on Literal drops the restrictive literal converter."""
375+
376+
def func(
377+
self,
378+
mode: Annotated[Literal["fast", "slow"], Argument(choices_provider=_provider)],
379+
) -> None: ...
380+
381+
action = _get_param_action(func)
382+
assert action.type is None
383+
assert action.choices is None
384+
385+
def test_completer_keeps_path_converter(self) -> None:
386+
"""User-supplied completer on Path preserves the (non-restrictive) Path converter."""
387+
action = _get_param_action(_func_completer_on_path)
388+
assert action.type is Path
389+
368390
def test_completer_overrides_inferred_path_completion(self) -> None:
369391
action = _get_param_action(_func_completer_on_path)
370392
assert action.get_choices_provider() is None # type: ignore[attr-defined]
@@ -558,7 +580,7 @@ def test_apply_mutex_group_targets_rejects_cross_group_members(self) -> None:
558580

559581

560582
# ---------------------------------------------------------------------------
561-
# _resolve_annotation: positional vs option classification + bool flag
583+
# _resolve_annotation: positional vs option classification
562584
# ---------------------------------------------------------------------------
563585

564586
_ARG_META = Argument(help_text="Name")
@@ -567,39 +589,38 @@ def test_apply_mutex_group_targets_rejects_cross_group_members(self) -> None:
567589

568590
class TestResolveAnnotation:
569591
@pytest.mark.parametrize(
570-
("annotation", "has_default", "expected_positional", "expected_bool_flag"),
592+
("annotation", "has_default", "expected_positional"),
571593
[
572-
pytest.param(str, False, True, False, id="plain_str"),
573-
pytest.param(str | None, False, False, False, id="optional_str"),
574-
pytest.param(Annotated[str, _ARG_META], False, True, False, id="annotated_argument"),
575-
pytest.param(Annotated[str, _OPT_META], False, False, False, id="annotated_option"),
576-
pytest.param(Annotated[str, "some doc"], False, True, False, id="annotated_no_meta"),
577-
pytest.param(str, True, False, False, id="has_default"),
578-
pytest.param(bool, True, False, True, id="bool_flag"),
594+
pytest.param(str, False, True, id="plain_str"),
595+
pytest.param(str | None, False, False, id="optional_str"),
596+
pytest.param(Annotated[str, _ARG_META], False, True, id="annotated_argument"),
597+
pytest.param(Annotated[str, _OPT_META], False, False, id="annotated_option"),
598+
pytest.param(Annotated[str, "some doc"], False, True, id="annotated_no_meta"),
599+
pytest.param(str, True, False, id="has_default"),
600+
pytest.param(bool, True, False, id="bool_flag"),
579601
],
580602
)
581-
def test_classification(self, annotation, has_default, expected_positional, expected_bool_flag) -> None:
582-
_kwargs, _meta, positional, is_bool_flag = _resolve_annotation(annotation, has_default=has_default)
603+
def test_classification(self, annotation, has_default, expected_positional) -> None:
604+
_kwargs, _meta, positional = _resolve_annotation(annotation, has_default=has_default)
583605
assert positional is expected_positional
584-
assert is_bool_flag is expected_bool_flag
585606

586607
def test_optional_wrapping_annotated_with_none_inside(self) -> None:
587608
"""Optional[Annotated[T | None, meta]] is allowed (inner type contains None)."""
588609
ann = Annotated[str | None, _OPT_META] | None
589-
_kwargs, meta, positional, _bf = _resolve_annotation(ann)
610+
_kwargs, meta, positional = _resolve_annotation(ann)
590611
assert meta is _OPT_META
591612
assert positional is False
592613

593614
def test_typing_union_optional(self) -> None:
594615
ns: dict = {}
595616
exec("import typing; t = typing.Union[str, None]", ns)
596-
_kwargs, _meta, positional, _bool_flag = _resolve_annotation(ns["t"])
617+
_kwargs, _meta, positional = _resolve_annotation(ns["t"])
597618
assert positional is False
598619

599620
def test_annotated_multiple_metadata_picks_first(self) -> None:
600621
meta1 = Argument(help_text="first")
601622
meta2 = Option("--x", help_text="second")
602-
kwargs, meta, _, _ = _resolve_annotation(Annotated[str, meta1, meta2])
623+
kwargs, meta, _ = _resolve_annotation(Annotated[str, meta1, meta2])
603624
assert meta is meta1
604625
assert kwargs.get("help") == "first"
605626

@@ -639,7 +660,7 @@ def test_nested_collection_raises(self, annotation) -> None:
639660
],
640661
)
641662
def test_unsupported_collection_no_nargs(self, annotation) -> None:
642-
kwargs, _, _, _ = _resolve_annotation(annotation)
663+
kwargs, _, _ = _resolve_annotation(annotation)
643664
assert "nargs" not in kwargs
644665
assert "action" not in kwargs
645666

@@ -1085,7 +1106,9 @@ def do_transfer(
10851106

10861107
@pytest.fixture
10871108
def app() -> _IntegrationApp:
1088-
return _IntegrationApp()
1109+
app = _IntegrationApp()
1110+
app.stdout = cmd2.utils.StdSim(app.stdout)
1111+
return app
10891112

10901113

10911114
@pytest.fixture
@@ -1147,12 +1170,29 @@ def test_ns_provider(self, app) -> None:
11471170
assert app.ns_calls == 1
11481171

11491172
def test_cmd2_prefixed_param_is_preserved(self, app) -> None:
1150-
out, _err = run_cmd(app, "prefixed --cmd2_mode 5")
1173+
out, _err = run_cmd(app, "prefixed --cmd2-mode 5")
11511174
assert out == ["cmd2_mode=5"]
11521175

11531176
def test_kwargs_passthrough(self, app) -> None:
11541177
app.do_greet("Alice", keyword_arg="kwarg_value")
11551178

1179+
def test_direct_call_with_positional_only(self, app) -> None:
1180+
"""Calling do_* directly with a single statement string parses normally."""
1181+
app.do_greet("Alice")
1182+
assert app.stdout.getvalue().splitlines()[-1] == "Hello Alice"
1183+
1184+
def test_direct_call_with_options(self, app) -> None:
1185+
"""Direct call with a full statement string including options."""
1186+
app.do_greet("Alice --count 2 --loud")
1187+
out = app.stdout.getvalue().splitlines()
1188+
assert out[-2:] == ["HELLO ALICE", "HELLO ALICE"]
1189+
1190+
def test_direct_call_kwargs_override_parsed(self, app) -> None:
1191+
"""Explicit kwargs on a direct call override parsed values."""
1192+
app.do_greet("Alice", count=3)
1193+
out = app.stdout.getvalue().splitlines()
1194+
assert out[-3:] == ["Hello Alice", "Hello Alice", "Hello Alice"]
1195+
11561196
def test_bare_call_decorator(self) -> None:
11571197
"""@with_annotated() with empty parens works same as @with_annotated."""
11581198

@@ -1176,7 +1216,7 @@ def test_missing_parser_raises(self, app) -> None:
11761216

11771217
class TestGroupedParserIntegration:
11781218
def test_grouped_command_executes(self, grouped_app) -> None:
1179-
out, _err = run_cmd(grouped_app, "transfer --local build.tar.gz --dry_run")
1219+
out, _err = run_cmd(grouped_app, "transfer --local build.tar.gz --dry-run")
11801220
assert out == ["Transfer build.tar.gz in dry-run mode"]
11811221

11821222
def test_grouped_command_mutex_error(self, grouped_app) -> None:
@@ -1189,7 +1229,7 @@ def test_grouped_command_help_lists_flags(self, grouped_app) -> None:
11891229
assert "--local" in help_text
11901230
assert "--remote" in help_text
11911231
assert "--force" in help_text
1192-
assert "--dry_run" in help_text
1232+
assert "--dry-run" in help_text
11931233

11941234

11951235
# ---------------------------------------------------------------------------
@@ -1293,6 +1333,14 @@ def test_base_command_missing_handler_raises(self) -> None:
12931333
def do_bad(self, verbose: bool = False) -> None:
12941334
pass
12951335

1336+
def test_cmd2_handler_without_base_command_raises(self) -> None:
1337+
"""A 'cmd2_handler' parameter is only valid when base_command=True."""
1338+
with pytest.raises(TypeError, match="base_command=True"):
1339+
1340+
@cmd2.with_annotated
1341+
def do_bad(self, cmd2_handler, name: str = "") -> None:
1342+
pass
1343+
12961344
@pytest.mark.parametrize(
12971345
"kwargs",
12981346
[

0 commit comments

Comments
 (0)