Skip to content

Commit a9fa36a

Browse files
committed
Make __name__ and __qualname__ read-only properties in protocol classes
1 parent e7dac06 commit a9fa36a

3 files changed

Lines changed: 87 additions & 39 deletions

File tree

‎cmd2/annotated.py‎

Lines changed: 58 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -277,14 +277,14 @@ def do_build(self, target: str, common: CommonArgs):
277277
)
278278
from pathlib import Path
279279
from typing import (
280+
TYPE_CHECKING,
280281
Annotated,
281282
Any,
282283
ClassVar,
283284
Literal,
284285
NamedTuple,
285286
ParamSpec,
286287
Protocol,
287-
TypeAlias,
288288
TypedDict,
289289
TypeGuard,
290290
TypeVar,
@@ -314,15 +314,25 @@ def do_build(self, target: str, common: CommonArgs):
314314
BoundCommandFunc,
315315
CmdOrSetT,
316316
UnboundChoicesProvider,
317-
UnboundCommandFunc,
318317
UnboundCompleter,
319318
)
320319

321320
#: ``nargs`` values accepted by cmd2's patched ``add_argument`` (incl. ranged tuples).
322321
_NargsValue = int | str | tuple[int] | tuple[int, int] | tuple[int, float]
323322

324-
# Parser construction works with a command method before or after descriptor binding.
325-
_CommandFunc: TypeAlias = BoundCommandFunc[...] | UnboundCommandFunc[Any, ...]
323+
324+
class _CommandFunc(Protocol):
325+
"""Callable metadata needed to inspect a command before or after descriptor binding."""
326+
327+
if TYPE_CHECKING:
328+
329+
@property
330+
def __name__(self) -> str: ...
331+
332+
@property
333+
def __qualname__(self) -> str: ...
334+
335+
def __call__(self, *args: Any, **kwargs: Any) -> Any: ...
326336

327337

328338
class Cmd2ParserKwargs(TypedDict, total=False):
@@ -2879,38 +2889,58 @@ def parser_builder() -> Cmd2ArgumentParser:
28792889

28802890
_CommandParams = ParamSpec("_CommandParams")
28812891
_DecoratorParams = ParamSpec("_DecoratorParams")
2892+
_CommandReturn = TypeVar("_CommandReturn")
2893+
_CommandReturn_co = TypeVar("_CommandReturn_co", covariant=True)
2894+
2895+
2896+
class _BoundAnnotatedCommand(Protocol[_CommandParams, _CommandReturn_co]):
2897+
"""A bound annotated command whose complete call signature is preserved."""
28822898

2899+
if TYPE_CHECKING:
28832900

2884-
class _AnnotatedCommand(Protocol[CmdOrSetT, _CommandParams]):
2901+
@property
2902+
def __name__(self) -> str: ...
2903+
2904+
@property
2905+
def __qualname__(self) -> str: ...
2906+
2907+
def __call__(self, *args: _CommandParams.args, **kwargs: _CommandParams.kwargs) -> _CommandReturn_co: ...
2908+
2909+
2910+
class _AnnotatedCommand(Protocol[CmdOrSetT, _CommandParams, _CommandReturn_co]):
28852911
"""A callable command method with the metadata used by ``with_annotated``."""
28862912

28872913
__name__: str
28882914
__qualname__: str
28892915

2890-
def __call__(self, __self: CmdOrSetT, /, *args: _CommandParams.args, **kwargs: _CommandParams.kwargs) -> bool | None: ...
2916+
def __call__(
2917+
self, __self: CmdOrSetT, /, *args: _CommandParams.args, **kwargs: _CommandParams.kwargs
2918+
) -> _CommandReturn_co: ...
28912919

28922920
@overload
2893-
def __get__(self, instance: None, owner: Any = ...) -> "_AnnotatedCommand[CmdOrSetT, _CommandParams]": ...
2921+
def __get__(
2922+
self, instance: None, owner: Any = ...
2923+
) -> "_AnnotatedCommand[CmdOrSetT, _CommandParams, _CommandReturn_co]": ...
28942924

28952925
@overload
2896-
def __get__(self, instance: CmdOrSetT, owner: Any = ...) -> BoundCommandFunc[_CommandParams]: ...
2926+
def __get__(self, instance: CmdOrSetT, owner: Any = ...) -> _BoundAnnotatedCommand[_CommandParams, _CommandReturn_co]: ...
28972927

28982928

28992929
class _WithAnnotatedDecorator(Protocol):
29002930
"""The signature-preserving decorator ``with_annotated(...)`` returns (generic per call)."""
29012931

29022932
def __call__(
2903-
self, fn: _AnnotatedCommand[CmdOrSetT, _CommandParams], /
2904-
) -> _AnnotatedCommand[CmdOrSetT, _CommandParams]: ...
2933+
self, fn: _AnnotatedCommand[CmdOrSetT, _CommandParams, _CommandReturn], /
2934+
) -> _AnnotatedCommand[CmdOrSetT, _CommandParams, _CommandReturn]: ...
29052935

29062936

29072937
def _build_subcommand_handler(
2908-
func: _AnnotatedCommand[Any, ...],
2938+
func: _AnnotatedCommand[Any, ..., Any],
29092939
subcommand_to: str,
29102940
*,
29112941
base_command: bool = False,
29122942
options: _ParserBuildOptions,
2913-
) -> tuple[_AnnotatedCommand[Any, ...], str, Callable[[], Cmd2ArgumentParser]]:
2943+
) -> tuple[_AnnotatedCommand[Any, ..., Any], str, Callable[[], Cmd2ArgumentParser]]:
29142944
"""Build a subcommand's parser and a handler that unpacks the Namespace into typed kwargs.
29152945
29162946
:param func: the subcommand handler function
@@ -2936,7 +2966,7 @@ def _build_subcommand_handler(
29362966
_leading_names, _var_positional_name = _var_positional_call_plan(func)
29372967

29382968
@functools.wraps(func)
2939-
def handler(*args: Any, **kwargs: Any) -> bool | None:
2969+
def handler(*args: Any, **kwargs: Any) -> Any:
29402970
"""Unpack Namespace into typed kwargs for the subcommand handler."""
29412971
if kwargs:
29422972
raise TypeError("subcommand handlers do not accept keyword arguments")
@@ -2952,19 +2982,18 @@ def handler(*args: Any, **kwargs: Any) -> bool | None:
29522982
if isinstance(cmd2_h, functools.partial) and getattr(cmd2_h.func, "__func__", cmd2_h.func) is handler:
29532983
filtered[constants.NS_ATTR_SUBCOMMAND_FUNC] = None
29542984
_reconstruct_dataclass_blocks(filtered, _blocks, ns)
2955-
result = _invoke_command_func(
2985+
return _invoke_command_func(
29562986
func, self_arg, filtered, leading_names=_leading_names, var_positional_name=_var_positional_name
29572987
)
2958-
if result is None or isinstance(result, bool):
2959-
return result
2960-
raise TypeError("annotated command functions must return bool or None")
29612988

29622989
parser_builder = _make_parser_builder(func, skip_params=_SKIP_PARAMS, base_command=base_command, options=options)
2963-
return cast(_AnnotatedCommand[Any, ...], handler), subcmd_name, parser_builder
2990+
return cast(_AnnotatedCommand[Any, ..., Any], handler), subcmd_name, parser_builder
29642991

29652992

29662993
@overload
2967-
def with_annotated(func: _AnnotatedCommand[CmdOrSetT, _CommandParams], /) -> _AnnotatedCommand[CmdOrSetT, _CommandParams]: ...
2994+
def with_annotated(
2995+
func: _AnnotatedCommand[CmdOrSetT, _CommandParams, _CommandReturn], /
2996+
) -> _AnnotatedCommand[CmdOrSetT, _CommandParams, _CommandReturn]: ...
29682997

29692998

29702999
@overload
@@ -2991,7 +3020,7 @@ def with_annotated(
29913020

29923021

29933022
def with_annotated(
2994-
func: _AnnotatedCommand[CmdOrSetT, _CommandParams] | None = None,
3023+
func: _AnnotatedCommand[CmdOrSetT, _CommandParams, _CommandReturn] | None = None,
29953024
*,
29963025
ns_provider: Callable[..., argparse.Namespace] | None = None,
29973026
preserve_quotes: bool = False,
@@ -3009,7 +3038,7 @@ def with_annotated(
30093038
subcommand_title: str | None = None,
30103039
subcommand_description: str | None = None,
30113040
**parser_kwargs: Unpack[Cmd2ParserKwargs],
3012-
) -> _AnnotatedCommand[CmdOrSetT, _CommandParams] | _WithAnnotatedDecorator:
3041+
) -> _AnnotatedCommand[CmdOrSetT, _CommandParams, _CommandReturn] | _WithAnnotatedDecorator:
30133042
"""Decorate a ``do_*`` method to build its argparse parser from type annotations.
30143043
30153044
:param func: the command function (when used without parentheses)
@@ -3071,8 +3100,8 @@ def with_annotated(
30713100
)
30723101

30733102
def decorator(
3074-
command_func: _AnnotatedCommand[CmdOrSetT, _DecoratorParams],
3075-
) -> _AnnotatedCommand[CmdOrSetT, _DecoratorParams]:
3103+
command_func: _AnnotatedCommand[CmdOrSetT, _DecoratorParams, _CommandReturn],
3104+
) -> _AnnotatedCommand[CmdOrSetT, _DecoratorParams, _CommandReturn]:
30763105
if with_unknown_args:
30773106
unknown_param = inspect.signature(command_func).parameters.get("_unknown")
30783107
if unknown_param is None:
@@ -3102,7 +3131,7 @@ def decorator(
31023131
parser_source=subcmd_parser_builder,
31033132
)
31043133
setattr(handler, constants.SUBCOMMAND_ATTR_SPEC, subcommand_spec)
3105-
return cast(_AnnotatedCommand[CmdOrSetT, _DecoratorParams], handler)
3134+
return cast(_AnnotatedCommand[CmdOrSetT, _DecoratorParams, _CommandReturn], handler)
31063135

31073136
command_name = command_func.__name__[len(constants.COMMAND_FUNC_PREFIX) :]
31083137

@@ -3125,14 +3154,14 @@ def decorator(
31253154
)
31263155

31273156
@functools.wraps(command_func)
3128-
def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
3157+
def cmd_wrapper(*args: Any, **kwargs: Any) -> _CommandReturn:
31293158
cmd2_app, statement_arg = _parse_positionals(args)
31303159
owner = args[0] # Cmd or CommandSet instance
31313160
statement, parsed_arglist = cmd2_app.statement_parser.get_command_arg_list(
31323161
command_name, statement_arg, preserve_quotes
31333162
)
31343163

3135-
arg_parser = cmd2_app.command_parsers.get(cmd_wrapper)
3164+
arg_parser = cmd2_app.command_parsers.get(cast(BoundCommandFunc[...], cmd_wrapper))
31363165
if arg_parser is None:
31373166
raise ValueError(f"No argument parser found for {command_name}")
31383167

@@ -3168,18 +3197,18 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
31683197

31693198
func_kwargs.update(kwargs)
31703199
_reconstruct_dataclass_blocks(func_kwargs, blocks, ns)
3171-
result: bool | None = _invoke_command_func(
3200+
result = _invoke_command_func(
31723201
command_func, owner, func_kwargs, leading_names=leading_names, var_positional_name=var_positional_name
31733202
)
3174-
return result
3203+
return cast(_CommandReturn, result)
31753204

31763205
argparse_command_spec = ArgparseCommandSpec(
31773206
parser_source=parser_builder,
31783207
preserve_quotes=preserve_quotes,
31793208
)
31803209
setattr(cmd_wrapper, constants.ARGPARSE_COMMAND_ATTR_SPEC, argparse_command_spec)
31813210

3182-
return cast(_AnnotatedCommand[CmdOrSetT, _DecoratorParams], cmd_wrapper)
3211+
return cast(_AnnotatedCommand[CmdOrSetT, _DecoratorParams, _CommandReturn], cmd_wrapper)
31833212

31843213
# Support both @with_annotated and @with_annotated(...)
31853214
if func is not None:

‎cmd2/types.py‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,15 @@
7272
class BoundCommandFunc(Protocol[P]):
7373
"""Protocol for a command function bound to a command instance."""
7474

75-
__name__: str
76-
__qualname__: str
75+
if TYPE_CHECKING:
76+
77+
@property
78+
def __name__(self) -> str:
79+
"""Name of the command function."""
80+
81+
@property
82+
def __qualname__(self) -> str:
83+
"""Qualified name of the command function."""
7784

7885
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> bool | None:
7986
"""Invoke the bound command function."""

‎tests/test_annotated.py‎

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2343,17 +2343,29 @@ def root_child(self) -> None:
23432343
root_child(object(), object())
23442344

23452345

2346-
def test_subcommand_handler_rejects_invalid_return_value() -> None:
2347-
"""The internal subcommand adapter enforces the command return contract."""
2348-
2349-
invalid_result: Any = object()
2346+
def test_subcommand_handler_preserves_truthy_return_value() -> None:
2347+
"""The internal subcommand adapter preserves any truthy command result."""
23502348

23512349
@with_annotated(subcommand_to="root")
2352-
def root_child(self) -> bool | None:
2353-
return invalid_result
2350+
def root_child(self) -> int:
2351+
return 1
2352+
2353+
assert root_child(object(), argparse.Namespace()) == 1
2354+
2355+
2356+
def test_truthy_subcommand_return_stops_command_loop() -> None:
2357+
"""A documented non-boolean truthy result propagates through the command loop."""
2358+
2359+
class TruthySubcommandApp(cmd2.Cmd):
2360+
@with_annotated(base_command=True)
2361+
def do_root(self, cmd2_subcommand_func) -> int | None:
2362+
return cmd2_subcommand_func() if cmd2_subcommand_func is not None else None
2363+
2364+
@with_annotated(subcommand_to="root")
2365+
def root_stop(self) -> int:
2366+
return 1
23542367

2355-
with pytest.raises(TypeError, match="must return bool or None"):
2356-
root_child(object(), argparse.Namespace())
2368+
assert TruthySubcommandApp().onecmd_plus_hooks("root stop")
23572369

23582370

23592371
class _IntegrationApp(cmd2.Cmd):

0 commit comments

Comments
 (0)