Skip to content

Commit 58655e0

Browse files
authored
Merge branch 'main' into feat/annotated-paramspec-typing
2 parents 0ef6fb7 + 6c6d844 commit 58655e0

31 files changed

Lines changed: 681 additions & 438 deletions

.github/workflows/tests.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,14 @@ jobs:
4040

4141
- name: Upload test results to Codecov
4242
if: ${{ !cancelled() }}
43-
uses: codecov/codecov-action@v6
43+
uses: codecov/codecov-action@v7
4444
with:
4545
flags: python${{ matrix.python-version }}
4646
name: codecov-umbrella-test-results
4747
report_type: test_results
4848
token: ${{ secrets.CODECOV_TOKEN }}
4949
- name: Upload coverage to Codecov
50-
uses: codecov/codecov-action@v6
50+
uses: codecov/codecov-action@v7
5151
with:
5252
env_vars: OS,PYTHON
5353
fail_ci_if_error: true

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ repos:
1414
- id: trailing-whitespace
1515

1616
- repo: https://github.com/astral-sh/ruff-pre-commit
17-
rev: "v0.15.15"
17+
rev: "v0.15.16"
1818
hooks:
1919
- id: ruff-format
2020
args: [--config=ruff.toml]
@@ -30,7 +30,7 @@ repos:
3030
- prettier-plugin-toml@2.0.6
3131

3232
- repo: https://github.com/crate-ci/typos
33-
rev: v1.47.0
33+
rev: v1.47.2
3434
hooks:
3535
- id: typos
3636
exclude: |

CHANGELOG.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
## 4.0.0 (June TBD, 2026)
1+
## 4.1.0 (TBD)
2+
3+
- Enhancements
4+
- New `cmd2.Cmd` parameters
5+
- **complete_in_thread**: (boolean) if `True`, then completion will run in a separate
6+
thread. If `False` then completion runs in the main thread and causes it to block if slow.
7+
Defaults to `True`.
8+
9+
## 4.0.0 (June 5, 2026)
210

311
### Summary
412

@@ -67,12 +75,14 @@ prompt is displayed.
6775
- Removed `Cmd.ruler` since `cmd2` no longer uses it.
6876
- All parsers used with `cmd2` commands must be an instance of `Cmd2ArgumentParser` or a child
6977
class of it.
70-
- Removed `set_ap_completer_type()` and `get_ap_completer_type()` since `ap_completer_type` is
71-
now a public member of `Cmd2ArgumentParser`.
78+
- Renamed `set_default_argument_parser_type()` to `set_default_argument_parser()`.
79+
- Renamed `set_default_ap_completer_type()` to `set_default_argparse_completer()`.
80+
- Removed `set_ap_completer_type()` and `get_ap_completer_type()` since `completer_class` is now
81+
a public member of `Cmd2ArgumentParser`.
7282
- Moved `set_parser_prog()` to `Cmd2ArgumentParser.update_prog()`.
73-
- Renamed `cmd2_handler` to `cmd2_subcmd_handler` in the `argparse.Namespace` for clarity.
83+
- Renamed `cmd2_handler` to `cmd2_subcommand_func` in the `argparse.Namespace` for clarity.
7484
- Removed `Cmd2AttributeWrapper` class. `argparse.Namespace` objects passed to command functions
75-
now contain direct attributes for `cmd2_statement` and `cmd2_subcmd_handler`.
85+
now contain direct attributes for `cmd2_statement` and `cmd2_subcommand_func`.
7686
- Renamed `cmd2/command_definition.py` to `cmd2/command_set.py`.
7787
- Removed `Cmd.doc_header` and the `with_default_category` decorator. Help categorization is now
7888
driven by the `DEFAULT_CATEGORY` class variable (see **Simplified command categorization** in

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ of cmd to make your life easier and eliminates much of the boilerplate code whic
2626
when using cmd.
2727

2828
> :warning: **If you are upgrading from an older version of `cmd2`, both `3.x` and `4.x` have some
29-
> significant backwards incompatibilities from version `2.x`. Please see the
29+
> significant backwards incompatibilities from previous versions. Please see the
3030
> [CHANGELOG](./CHANGELOG.md) for info on what has changed and the
3131
> [Migration Guide](https://cmd2.readthedocs.io/en/latest/upgrades/) for tips on upgrading from an
3232
> older version of `cmd2` to `4.x`**

cmd2/__init__.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@
1212
string_utils,
1313
)
1414
from .annotated import with_annotated
15-
from .argparse_completer import set_default_ap_completer_type
15+
from .argparse_completer import set_default_argparse_completer
1616
from .argparse_utils import (
1717
Cmd2ArgumentParser,
1818
SubcommandRecord,
1919
register_argparse_argument_parameter,
20-
set_default_argument_parser_type,
20+
set_default_argument_parser,
2121
)
2222
from .cmd2 import Cmd
2323
from .colors import Color
@@ -75,8 +75,8 @@
7575
"Cmd2ArgumentParser",
7676
"SubcommandRecord",
7777
"register_argparse_argument_parameter",
78-
"set_default_ap_completer_type",
79-
"set_default_argument_parser_type",
78+
"set_default_argparse_completer",
79+
"set_default_argument_parser",
8080
# Cmd2
8181
"Cmd",
8282
"CommandResult",

cmd2/annotated.py

Lines changed: 73 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
flag (``dry_run`` -> ``--dry-run``); pass an explicit ``Option("--my_flag")`` to opt out.
1919
Positional-only parameters (before ``/``) and ``**kwargs`` raise ``TypeError``. The parameter
2020
names ``dest`` and ``subcommand`` are reserved; ``cmd2_statement`` receives the parsed
21-
``Statement`` and (with ``base_command=True``) ``cmd2_handler`` receives the subcommand handler:
21+
``Statement`` and (with ``base_command=True``) ``cmd2_subcommand_func`` receives the subcommand handler:
2222
2323
class MyApp(cmd2.Cmd):
2424
@cmd2.with_annotated
@@ -118,7 +118,7 @@ def do_paint(
118118
692 ``**parser_kwargs: Unpack[Cmd2ParserKwargs]``. Anything the parser ctor accepts -- ``description``,
119119
``epilog``, ``prog``, ``usage``, ``parents``, ``argument_default``, ``prefix_chars``,
120120
``fromfile_prefix_chars``, ``conflict_handler``, ``add_help``, ``allow_abbrev``, ``exit_on_error``,
121-
``formatter_class``, ``ap_completer_type``, and on Python >= 3.14 ``suggest_on_error`` / ``color`` --
121+
``formatter_class``, ``completer_class``, and on Python >= 3.14 ``suggest_on_error`` / ``color`` --
122122
flows straight through; the [`Cmd2ParserKwargs`][cmd2.annotated.Cmd2ParserKwargs] ``TypedDict`` is the single source of truth
123123
and gives type-checkers/IDEs autocomplete on the decorator's call site. ``parser_class`` stays as
124124
its own explicit kwarg because it selects the class itself, not a value passed to it. Two
@@ -181,8 +181,16 @@ def do_paint(
181181
import functools
182182
import inspect
183183
import types
184-
from collections.abc import Callable, Container, Iterable, Sequence
185-
from dataclasses import dataclass, field
184+
from collections.abc import (
185+
Callable,
186+
Container,
187+
Iterable,
188+
Sequence,
189+
)
190+
from dataclasses import (
191+
dataclass,
192+
field,
193+
)
186194
from pathlib import Path
187195
from typing import (
188196
TYPE_CHECKING,
@@ -205,14 +213,22 @@ def do_paint(
205213
from rich.table import Column
206214

207215
from . import constants
208-
from .argparse_utils import DEFAULT_ARGUMENT_PARSER, Cmd2ArgumentParser, SubcommandSpec
216+
from .argparse_utils import (
217+
ArgparseCommandSpec,
218+
Cmd2ArgumentParser,
219+
SubcommandSpec,
220+
)
209221
from .completion import CompletionItem
210222
from .decorators import _parse_positionals
211223
from .exceptions import Cmd2ArgparseError
212224
from .rich_utils import Cmd2HelpFormatter, HelpContent
213-
from .types import CmdOrSetT, UnboundChoicesProvider, UnboundCompleter
225+
from .types import (
226+
CmdOrSetT,
227+
UnboundChoicesProvider,
228+
UnboundCompleter,
229+
)
214230

215-
if TYPE_CHECKING:
231+
if TYPE_CHECKING: # pragma: no cover
216232
from .argparse_completer import ArgparseCompleter
217233

218234
#: ``nargs`` values accepted by cmd2's patched ``add_argument`` (incl. ranged tuples).
@@ -242,7 +258,7 @@ class Cmd2ParserKwargs(TypedDict, total=False):
242258
exit_on_error: bool
243259
suggest_on_error: bool
244260
color: bool
245-
ap_completer_type: "type[ArgparseCompleter] | None"
261+
completer_class: "type[ArgparseCompleter] | None"
246262

247263

248264
# ---------------------------------------------------------------------------
@@ -1689,7 +1705,7 @@ def _const_mismatches_type(a: _ArgparseArgument) -> bool:
16891705

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

16941710

16951711
def _link_group_membership(
@@ -1720,16 +1736,30 @@ def _resolve_parameters(
17201736
"""Resolve a function signature into a list of argparse-argument builders.
17211737
17221738
``base_command`` marks each argument's context for the base-command :data:`_CONSTRAINTS` rows and
1723-
drives the function-level ``cmd2_handler`` check below. ``groups``/``mutually_exclusive_groups``
1739+
drives the function-level ``cmd2_subcommand_func`` check below. ``groups``/``mutually_exclusive_groups``
17241740
are linked onto each argument as membership facts for the cross-config constraint rows.
17251741
"""
17261742
sig = inspect.signature(func)
17271743
# Function-level check (not a per-argument _CONSTRAINTS row): a base command dispatches through
1728-
# cmd2_handler, so it must exist. Here so it also fires when the function has zero parameters.
1729-
if base_command and "cmd2_handler" not in sig.parameters:
1730-
raise TypeError(f"with_annotated(base_command=True) requires a 'cmd2_handler' parameter in {func.__qualname__}")
1744+
# cmd2_subcommand_func, so it must exist. Here so it also fires when the function has zero parameters.
1745+
if base_command and constants.NS_ATTR_SUBCOMMAND_FUNC not in sig.parameters:
1746+
raise TypeError(
1747+
f"with_annotated(base_command=True) requires a '{constants.NS_ATTR_SUBCOMMAND_FUNC}' "
1748+
f"parameter in {func.__qualname__}"
1749+
)
1750+
# Resolve hints only for the parameters that become arguments: the bound first parameter
1751+
# (self/cls), the injected skip_params, and the "return" annotation never become arguments
1752+
ignored = {next(iter(sig.parameters), None), "return", *skip_params}
1753+
ignored.discard(None)
1754+
relevant_annotations = {name: ann for name, ann in getattr(func, "__annotations__", {}).items() if name not in ignored}
1755+
# Forward references resolve against the *original* function's module during functools.wraps wrapper.
1756+
unwrapped = inspect.unwrap(func)
17311757
try:
1732-
hints = get_type_hints(func, include_extras=True)
1758+
hints = get_type_hints(
1759+
types.SimpleNamespace(__annotations__=relevant_annotations),
1760+
globalns=getattr(unwrapped, "__globals__", {}),
1761+
include_extras=True,
1762+
)
17331763
except (NameError, AttributeError, TypeError) as exc:
17341764
raise TypeError(
17351765
f"Failed to resolve type hints for {func.__qualname__}. Ensure all annotations use valid, importable types."
@@ -1830,14 +1860,10 @@ def _filtered_namespace_kwargs(
18301860
exclude_subcommand: bool = False,
18311861
) -> dict[str, Any]:
18321862
"""Filter a parsed Namespace down to user-visible kwargs."""
1833-
from .constants import NS_ATTR_SUBCMD_HANDLER
1834-
18351863
filtered: dict[str, Any] = {}
18361864
for key, value in vars(ns).items():
18371865
if accepted is not None and key not in accepted:
18381866
continue
1839-
if key == NS_ATTR_SUBCMD_HANDLER:
1840-
continue
18411867
if exclude_subcommand and key == "subcommand":
18421868
continue
18431869
filtered[key] = value
@@ -1954,7 +1980,9 @@ def build_parser_from_function(
19541980
:param parser_kwargs: forwarded [`Cmd2ParserKwargs`][cmd2.annotated.Cmd2ParserKwargs]
19551981
:return: a fully configured ``Cmd2ArgumentParser``
19561982
"""
1957-
parser_cls = parser_class or DEFAULT_ARGUMENT_PARSER
1983+
from . import argparse_utils
1984+
1985+
parser_cls = parser_class or argparse_utils.DEFAULT_ARGUMENT_PARSER
19581986
if "description" not in parser_kwargs:
19591987
auto_description = _docstring_first_paragraph(func.__doc__)
19601988
if auto_description is not None:
@@ -1971,25 +1999,16 @@ def build_parser_from_function(
19711999
mutually_exclusive_groups=mutually_exclusive_groups,
19722000
)
19732001

1974-
# ``argument_default=argparse.SUPPRESS`` removes an absent argument from the parsed namespace.
1975-
# That is safe only for arguments that are always supplied (required) or carry their own default;
1976-
# an *omittable* argument with no default (e.g. a ``T | None`` positional -> nargs='?') would be
1977-
# dropped when absent, leaving the function without a keyword argument it expects. ``*args`` is
1978-
# exempt: the invocation path substitutes an empty tuple for it. Reject the combination here,
1979-
# mirroring the per-argument ``default=argparse.SUPPRESS`` rejection.
2002+
# ``argument_default=argparse.SUPPRESS`` drops an absent argument from the parsed namespace.
2003+
# @with_annotated builds the call from the function signature, so every declared parameter is
2004+
# expected at invocation -- an argument vanishing from the namespace can never be valid here.
2005+
# Reject it outright, mirroring the per-argument ``default=argparse.SUPPRESS`` rejection.
19802006
if parser_kwargs.get("argument_default") is argparse.SUPPRESS:
1981-
dropped = [
1982-
arg.name
1983-
for arg in resolved
1984-
if arg.default is _UNSET and arg.omittable and not arg.required and not arg.is_variadic
1985-
]
1986-
if dropped:
1987-
raise TypeError(
1988-
f"argument_default=argparse.SUPPRESS is not supported by @with_annotated for {func.__qualname__}: "
1989-
f"it would drop {dropped!r} from the parsed namespace when absent, but the function expects "
1990-
f"{'them' if len(dropped) > 1 else 'it'} as a keyword argument. Give each an explicit default or "
1991-
f"make it required, or drop argument_default=argparse.SUPPRESS."
1992-
)
2007+
raise TypeError(
2008+
f"argument_default=argparse.SUPPRESS is not supported by @with_annotated for {func.__qualname__}: "
2009+
f"it drops absent arguments from the parsed namespace, but every parameter built from the "
2010+
f"signature is expected at invocation. Drop argument_default=argparse.SUPPRESS."
2011+
)
19932012

19942013
# Build the group lookup (member references already validated by _resolve_parameters).
19952014
target_for, argument_group_for = _build_argument_group_targets(parser, groups=groups)
@@ -2107,10 +2126,10 @@ def _build_subcommand_handler(
21072126
def handler(self_arg: Any, ns: Any) -> Any:
21082127
"""Unpack Namespace into typed kwargs for the subcommand handler."""
21092128
filtered = _filtered_namespace_kwargs(ns, accepted=_accepted)
2110-
if "cmd2_handler" in filtered:
2111-
cmd2_h = filtered["cmd2_handler"]
2112-
if isinstance(cmd2_h, functools.partial) and cmd2_h.func is handler:
2113-
filtered["cmd2_handler"] = None
2129+
if constants.NS_ATTR_SUBCOMMAND_FUNC in filtered:
2130+
cmd2_h = filtered[constants.NS_ATTR_SUBCOMMAND_FUNC]
2131+
if isinstance(cmd2_h, functools.partial) and getattr(cmd2_h.func, "__func__", cmd2_h.func) is handler:
2132+
filtered[constants.NS_ATTR_SUBCOMMAND_FUNC] = None
21142133
return _invoke_command_func(
21152134
func, self_arg, filtered, leading_names=_leading_names, var_positional_name=_var_positional_name
21162135
)
@@ -2182,7 +2201,7 @@ def with_annotated(
21822201
:param ns_provider: callable returning a prepopulated Namespace (not with ``subcommand_to``)
21832202
:param preserve_quotes: preserve quotes in arguments (not with ``subcommand_to``)
21842203
:param with_unknown_args: capture unknown args as the ``_unknown`` kwarg (not with ``subcommand_to``)
2185-
:param base_command: add ``add_subparsers()``; requires a ``cmd2_handler`` param and no positionals
2204+
:param base_command: add ``add_subparsers()``; requires a ``cmd2_subcommand_func`` param and no positionals
21862205
:param subcommand_to: parent command name; function must be named ``{parent_underscored}_{subcommand}``
21872206
:param help: subcommand help text (only with ``subcommand_to``)
21882207
:param aliases: alternative subcommand names (only with ``subcommand_to``)
@@ -2244,9 +2263,10 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
22442263
if unknown_param.kind is inspect.Parameter.POSITIONAL_ONLY:
22452264
raise TypeError("Parameter _unknown must be keyword-compatible when with_unknown_args=True")
22462265

2247-
if not base_command and "cmd2_handler" in inspect.signature(fn).parameters:
2266+
if not base_command and constants.NS_ATTR_SUBCOMMAND_FUNC in inspect.signature(fn).parameters:
22482267
raise TypeError(
2249-
f"Parameter 'cmd2_handler' in {fn.__qualname__} is only valid when with_annotated(base_command=True) is used."
2268+
f"Parameter '{constants.NS_ATTR_SUBCOMMAND_FUNC}' in {fn.__qualname__} "
2269+
"is only valid when with_annotated(base_command=True) is used."
22502270
)
22512271

22522272
if subcommand_to is not None:
@@ -2256,15 +2276,15 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
22562276
base_command=base_command,
22572277
options=options,
22582278
)
2259-
spec = SubcommandSpec(
2279+
subcommand_spec = SubcommandSpec(
22602280
name=subcmd_name,
22612281
command=subcommand_to,
22622282
help=help,
22632283
aliases=tuple(aliases),
22642284
deprecated=deprecated,
22652285
parser_source=subcmd_parser_builder,
22662286
)
2267-
setattr(handler, constants.SUBCMD_ATTR_SPEC, spec)
2287+
setattr(handler, constants.SUBCOMMAND_ATTR_SPEC, subcommand_spec)
22682288
return handler
22692289

22702290
command_name = fn.__name__[len(constants.COMMAND_FUNC_PREFIX) :]
@@ -2308,10 +2328,10 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
23082328
raise Cmd2ArgparseError from exc
23092329

23102330
setattr(ns, constants.NS_ATTR_STATEMENT, statement)
2311-
handler = getattr(ns, constants.NS_ATTR_SUBCMD_HANDLER, None)
2331+
handler = getattr(ns, constants.NS_ATTR_SUBCOMMAND_FUNC, None)
23122332
if base_command and handler is not None:
23132333
handler = functools.partial(handler, ns)
2314-
ns.cmd2_handler = handler
2334+
setattr(ns, constants.NS_ATTR_SUBCOMMAND_FUNC, handler)
23152335

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

@@ -2324,8 +2344,11 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
23242344
)
23252345
return result
23262346

2327-
setattr(cmd_wrapper, constants.CMD_ATTR_PARSER_SOURCE, parser_builder)
2328-
setattr(cmd_wrapper, constants.CMD_ATTR_PRESERVE_QUOTES, preserve_quotes)
2347+
argparse_command_spec = ArgparseCommandSpec(
2348+
parser_source=parser_builder,
2349+
preserve_quotes=preserve_quotes,
2350+
)
2351+
setattr(cmd_wrapper, constants.ARGPARSE_COMMAND_ATTR_SPEC, argparse_command_spec)
23292352

23302353
return cmd_wrapper
23312354

0 commit comments

Comments
 (0)