Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ prompt is displayed.
- Removed `set_ap_completer_type()` and `get_ap_completer_type()` since `ap_completer_type` is
now a public member of `Cmd2ArgumentParser`.
- Moved `set_parser_prog()` to `Cmd2ArgumentParser.update_prog()`.
- Renamed `cmd2_handler` to `cmd2_subcmd_handler` in the `argparse.Namespace` for clarity.
- Renamed `cmd2_handler` to `cmd2_subcommand_func` in the `argparse.Namespace` for clarity.
- Removed `Cmd2AttributeWrapper` class. `argparse.Namespace` objects passed to command functions
now contain direct attributes for `cmd2_statement` and `cmd2_subcmd_handler`.
now contain direct attributes for `cmd2_statement` and `cmd2_subcommand_func`.
- Renamed `cmd2/command_definition.py` to `cmd2/command_set.py`.
- Removed `Cmd.doc_header` and the `with_default_category` decorator. Help categorization is now
driven by the `DEFAULT_CATEGORY` class variable (see **Simplified command categorization** in
Expand Down
72 changes: 46 additions & 26 deletions cmd2/annotated.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
flag (``dry_run`` -> ``--dry-run``); pass an explicit ``Option("--my_flag")`` to opt out.
Positional-only parameters (before ``/``) and ``**kwargs`` raise ``TypeError``. The parameter
names ``dest`` and ``subcommand`` are reserved; ``cmd2_statement`` receives the parsed
``Statement`` and (with ``base_command=True``) ``cmd2_handler`` receives the subcommand handler:
``Statement`` and (with ``base_command=True``) ``cmd2_subcommand_func`` receives the subcommand handler:

class MyApp(cmd2.Cmd):
@cmd2.with_annotated
Expand Down Expand Up @@ -181,8 +181,16 @@ def do_paint(
import functools
import inspect
import types
from collections.abc import Callable, Container, Iterable, Sequence
from dataclasses import dataclass, field
from collections.abc import (
Callable,
Container,
Iterable,
Sequence,
)
from dataclasses import (
dataclass,
field,
)
from pathlib import Path
from typing import (
TYPE_CHECKING,
Expand All @@ -203,12 +211,21 @@ def do_paint(
from rich.table import Column

from . import constants
from .argparse_utils import DEFAULT_ARGUMENT_PARSER, Cmd2ArgumentParser, SubcommandSpec
from .argparse_utils import (
DEFAULT_ARGUMENT_PARSER,
ApCommandSpec,
Cmd2ArgumentParser,
SubcommandSpec,
)
from .completion import CompletionItem
from .decorators import _parse_positionals
from .exceptions import Cmd2ArgparseError
from .rich_utils import Cmd2HelpFormatter, HelpContent
from .types import CmdOrSetT, UnboundChoicesProvider, UnboundCompleter
from .types import (
CmdOrSetT,
UnboundChoicesProvider,
UnboundCompleter,
)

if TYPE_CHECKING:
from .argparse_completer import ArgparseCompleter
Expand Down Expand Up @@ -1687,7 +1704,7 @@ def _const_mismatches_type(a: _ArgparseArgument) -> bool:

# Parameters handled specially by the decorator and not added to the parser. The first positional
# parameter (self/cls) is always skipped by position; these cover additional decorator-managed names.
_SKIP_PARAMS = frozenset({"cmd2_handler", "cmd2_statement"})
_SKIP_PARAMS = frozenset({constants.NS_ATTR_SUBCOMMAND_FUNC, constants.NS_ATTR_STATEMENT})


def _link_group_membership(
Expand Down Expand Up @@ -1718,14 +1735,17 @@ def _resolve_parameters(
"""Resolve a function signature into a list of argparse-argument builders.

``base_command`` marks each argument's context for the base-command :data:`_CONSTRAINTS` rows and
drives the function-level ``cmd2_handler`` check below. ``groups``/``mutually_exclusive_groups``
drives the function-level ``cmd2_subcommand_func`` check below. ``groups``/``mutually_exclusive_groups``
are linked onto each argument as membership facts for the cross-config constraint rows.
"""
sig = inspect.signature(func)
# Function-level check (not a per-argument _CONSTRAINTS row): a base command dispatches through
# cmd2_handler, so it must exist. Here so it also fires when the function has zero parameters.
if base_command and "cmd2_handler" not in sig.parameters:
raise TypeError(f"with_annotated(base_command=True) requires a 'cmd2_handler' parameter in {func.__qualname__}")
# cmd2_subcommand_func, so it must exist. Here so it also fires when the function has zero parameters.
if base_command and constants.NS_ATTR_SUBCOMMAND_FUNC not in sig.parameters:
raise TypeError(
f"with_annotated(base_command=True) requires a '{constants.NS_ATTR_SUBCOMMAND_FUNC}' "
f"parameter in {func.__qualname__}"
)
try:
hints = get_type_hints(func, include_extras=True)
except (NameError, AttributeError, TypeError) as exc:
Expand Down Expand Up @@ -1828,14 +1848,10 @@ def _filtered_namespace_kwargs(
exclude_subcommand: bool = False,
) -> dict[str, Any]:
"""Filter a parsed Namespace down to user-visible kwargs."""
from .constants import NS_ATTR_SUBCMD_HANDLER

filtered: dict[str, Any] = {}
for key, value in vars(ns).items():
if accepted is not None and key not in accepted:
continue
if key == NS_ATTR_SUBCMD_HANDLER:
continue
if exclude_subcommand and key == "subcommand":
continue
filtered[key] = value
Expand Down Expand Up @@ -2105,10 +2121,10 @@ def _build_subcommand_handler(
def handler(self_arg: Any, ns: Any) -> Any:
"""Unpack Namespace into typed kwargs for the subcommand handler."""
filtered = _filtered_namespace_kwargs(ns, accepted=_accepted)
if "cmd2_handler" in filtered:
cmd2_h = filtered["cmd2_handler"]
if constants.NS_ATTR_SUBCOMMAND_FUNC in filtered:
cmd2_h = filtered[constants.NS_ATTR_SUBCOMMAND_FUNC]
if isinstance(cmd2_h, functools.partial) and cmd2_h.func is handler:
filtered["cmd2_handler"] = None
filtered[constants.NS_ATTR_SUBCOMMAND_FUNC] = None
return _invoke_command_func(
func, self_arg, filtered, leading_names=_leading_names, var_positional_name=_var_positional_name
)
Expand Down Expand Up @@ -2170,7 +2186,7 @@ def with_annotated(
:param ns_provider: callable returning a prepopulated Namespace (not with ``subcommand_to``)
:param preserve_quotes: preserve quotes in arguments (not with ``subcommand_to``)
:param with_unknown_args: capture unknown args as the ``_unknown`` kwarg (not with ``subcommand_to``)
:param base_command: add ``add_subparsers()``; requires a ``cmd2_handler`` param and no positionals
:param base_command: add ``add_subparsers()``; requires a ``cmd2_subcommand_func`` param and no positionals
:param subcommand_to: parent command name; function must be named ``{parent_underscored}_{subcommand}``
:param help: subcommand help text (only with ``subcommand_to``)
:param aliases: alternative subcommand names (only with ``subcommand_to``)
Expand Down Expand Up @@ -2232,9 +2248,10 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
if unknown_param.kind is inspect.Parameter.POSITIONAL_ONLY:
raise TypeError("Parameter _unknown must be keyword-compatible when with_unknown_args=True")

if not base_command and "cmd2_handler" in inspect.signature(fn).parameters:
if not base_command and constants.NS_ATTR_SUBCOMMAND_FUNC in inspect.signature(fn).parameters:
raise TypeError(
f"Parameter 'cmd2_handler' in {fn.__qualname__} is only valid when with_annotated(base_command=True) is used."
f"Parameter '{constants.NS_ATTR_SUBCOMMAND_FUNC}' in {fn.__qualname__} "
"is only valid when with_annotated(base_command=True) is used."
)

if subcommand_to is not None:
Expand All @@ -2244,15 +2261,15 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
base_command=base_command,
options=options,
)
spec = SubcommandSpec(
subcommand_spec = SubcommandSpec(
name=subcmd_name,
command=subcommand_to,
help=help,
aliases=tuple(aliases),
deprecated=deprecated,
parser_source=subcmd_parser_builder,
)
setattr(handler, constants.SUBCMD_ATTR_SPEC, spec)
setattr(handler, constants.SUBCOMMAND_ATTR_SPEC, subcommand_spec)
return handler

command_name = fn.__name__[len(constants.COMMAND_FUNC_PREFIX) :]
Expand Down Expand Up @@ -2296,10 +2313,10 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
raise Cmd2ArgparseError from exc

setattr(ns, constants.NS_ATTR_STATEMENT, statement)
handler = getattr(ns, constants.NS_ATTR_SUBCMD_HANDLER, None)
handler = getattr(ns, constants.NS_ATTR_SUBCOMMAND_FUNC, None)
if base_command and handler is not None:
handler = functools.partial(handler, ns)
ns.cmd2_handler = handler
setattr(ns, constants.NS_ATTR_SUBCOMMAND_FUNC, handler)

func_kwargs = _filtered_namespace_kwargs(ns, accepted=accepted, exclude_subcommand=base_command)

Expand All @@ -2312,8 +2329,11 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
)
return result

setattr(cmd_wrapper, constants.CMD_ATTR_PARSER_SOURCE, parser_builder)
setattr(cmd_wrapper, constants.CMD_ATTR_PRESERVE_QUOTES, preserve_quotes)
ap_command_spec = ApCommandSpec(
parser_source=parser_builder,
preserve_quotes=preserve_quotes,
)
setattr(cmd_wrapper, constants.AP_COMMAND_ATTR_SPEC, ap_command_spec)

return cmd_wrapper

Expand Down
32 changes: 29 additions & 3 deletions cmd2/argparse_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,20 +291,44 @@ def get_choices(self) -> Choices:
]


@dataclass(kw_only=True)
class ApCommandSpec:
"""Metadata for an argparse-based command function.

:param parser_source: an existing Cmd2ArgumentParser instance or a factory
(callable, staticmethod, or classmethod) that returns one.
:param preserve_quotes: if True, then arguments passed to argparse maintain their quotes
"""

parser_source: ParserSource[Any]
preserve_quotes: bool = False


@dataclass(kw_only=True)
class _SubcommandBase:
"""Base metadata shared by all subcommand representations."""
"""Base metadata shared by all subcommand representations.

:param name: the name of the subcommand
:param command: the full parent command path (e.g., 'foo bar')
:param help: optional help message for this subcommand
:param aliases: optional alternative names for this subcommand
:param deprecated: whether this subcommand is deprecated (requires Python 3.13+).
"""

name: str
command: str # The full parent command path (e.g., 'foo bar')
command: str
help: str | None = None
aliases: tuple[str, ...] = ()
deprecated: bool = False


@dataclass(kw_only=True)
class SubcommandSpec(_SubcommandBase):
"""Metadata used to build and register a subcommand."""
"""Metadata used to build and register a subcommand.

:param parser_source: an existing Cmd2ArgumentParser instance or a factory
(callable, staticmethod, or classmethod) that returns one.
"""

parser_source: ParserSource[Any]

Expand All @@ -314,6 +338,8 @@ class SubcommandRecord(_SubcommandBase):
"""A record of a subcommand's configuration and parser.

Used primarily for attaching and detaching subcommands.

:param parser: the built Cmd2ArgumentParser instance for this subcommand
"""

parser: "Cmd2ArgumentParser"
Expand Down
Loading
Loading