diff --git a/.github/images/graph.drawio.png b/.github/images/graph.drawio.png deleted file mode 100644 index ecc33ce99..000000000 Binary files a/.github/images/graph.drawio.png and /dev/null differ diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 6a4120344..bd531f77a 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -45,7 +45,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.8 + uses: github/codeql-action/init@v4.37.9 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v4.37.8 + uses: github/codeql-action/autobuild@v4.37.9 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -70,4 +70,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.8 + uses: github/codeql-action/analyze@v4.37.9 diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index f5e875fe8..2c2d393a5 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -31,5 +31,8 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Check typing + - name: Check typing using ty run: uv run ty check + + - name: Check typing using mypy + run: uv run mypy . diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4326fd9f1..47ba02cb4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,6 +7,7 @@ repos: - id: check-merge-conflict - id: check-toml - id: check-yaml + exclude: mkdocs.yml - id: detect-private-key - id: end-of-file-fixer - id: fix-byte-order-marker @@ -14,7 +15,7 @@ repos: - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.16.3" + rev: "v0.16.5" hooks: - id: ruff-format args: [--config=ruff.toml] @@ -30,7 +31,7 @@ repos: - prettier-plugin-toml@2.0.6 - repo: https://github.com/crate-ci/typos - rev: v1.49.0 + rev: v1.50.0 hooks: - id: typos exclude: | diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..56d6998bf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,40 @@ +# Instructions for AI Agents + +This file provides context and instructions for the agentic AI tools when working with this Python +project, which utilizes `uv` for environment and package management. + +## General Instructions + +- **Environment Management:** Prefer using `uv` for all Python environment management tasks. +- **Package Installation:** Always use `uv` to install packages and ensure they are installed within + the project's virtual environment. +- **Running Scripts/Commands:** + - To run Python scripts within the project's virtual environment, use `uv run ...`. + - To run programs directly from a PyPI package (installing it on the fly if necessary), use + `uvx ...` (shortcut for `uv tool run`). +- **New Dependencies:** If a new dependency is required, please state the reason for its inclusion. +- Do not commit spec, plan, or markdown documents to git without asking first. +- Save plans to `~/.superpowers/plans/` instead of the project directory. + +## Python Code Standards + +To ensure Python code adheres to required standards, the following commands **must** be run before +creating or modifying any `.py` files: + +```bash +make check +``` + +To run unit tests use the following command: + +```bash +make test +``` + +To make sure the documentation builds properly, use the following command: + +```bash +make docs-test +``` + +All 3 of the above commands should be run prior to committing code. diff --git a/CHANGELOG.md b/CHANGELOG.md index eb449a5dc..55965220c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## 4.2.3 (September 2, 2026) + +- Bug Fixes + - Fix some links which were broken when published to PyPI +- Enhancements + - Converted `BoundCommandFunc` and `UnboundCommandFunc` TypeAliases in `types.py` to Protocol + classes for stricter type checking on `cmd2` command method references + ## 4.2.2 (August 25, 2026) - Documentation Improvements diff --git a/Makefile b/Makefile index 01f28658d..546fd2c5c 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,8 @@ check: ## Run code quality tools. @uv run prek run -a @echo "🚀 Static type checking: Running ty" @uv run ty check + @echo "🚀 Static type checking: Running mypy" + @uv run mypy . .PHONY: format format: ## Perform ruff formatting @@ -29,8 +31,17 @@ format: ## Perform ruff formatting lint: ## Perform ruff linting @uv run ruff check --fix +.PHONY: mypy +mypy: ## Perform type checking using mypy + @uv run mypy . + +.PHONY: ty +ty: ## Perform type checking using ty + @uv run ty check + .PHONY: typecheck -typecheck: ## Perform type checking +typecheck: ## Perform type checking using both mypy and ty + @uv run mypy . @uv run ty check .PHONY: test diff --git a/README.md b/README.md index 18b569969..14ddc7889 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -
- Developer's Toolbox • - Philosophy • + Quick start • + Why cmd2? • Installation • Documentation • - Tutorials • - Hello World • - Projects using cmd2 • + Projects using cmd2
-[](https://youtu.be/DDU_JH6cFsA) +`cmd2` makes it quick to build powerful, polished command-line applications and REPLs in Python. +Start with a subclass of the standard library's +[`cmd.Cmd`](https://docs.python.org/3/library/cmd.html), then add commands while `cmd2` handles the +details that make an application pleasant to build and use: argument parsing, generated help, tab +completion, command history, scripting, output styling, and much more. -cmd2 is a tool for building interactive command line applications in Python. Its goal is to make it -quick and easy for developers to build feature-rich and user-friendly interactive command line -applications. It provides a simple API which is an extension of Python's built-in -[cmd](https://docs.python.org/3/library/cmd.html) module. cmd2 provides a wealth of features on top -of cmd to make your life easier and eliminates much of the boilerplate code which would be necessary -when using cmd. +You can use `cmd2` for a small internal tool and grow the same application into an extensible, +scriptable interface without replacing the framework or writing the usual CLI boilerplate. -> :warning: **If you are upgrading from an older version of `cmd2`, both `3.x` and `4.x` have some -> significant backwards incompatibilities from previous versions. Please see the -> [CHANGELOG](./CHANGELOG.md) for info on what has changed and the -> [Migration Guide](https://cmd2.readthedocs.io/en/latest/upgrades/) for tips on upgrading from an -> older version of `cmd2` to `4.x`** +## Quick start -## The developers toolbox +Install `cmd2`: - +```bash +pip install cmd2 +``` + +Then create `app.py`: + +```python +from typing import Annotated + +import cmd2 +from cmd2.annotated import Argument, Option -When creating solutions developers have no shortage of tools to create rich and smart user -interfaces. System administrators have long been duct taping together brittle workflows based on a -menagerie of simple command line tools created by strangers on github and the guy down the hall. -Unfortunately, when CLIs become significantly complex the ease of command discoverability tends to -fade quickly. On the other hand, Web and traditional desktop GUIs are first in class when it comes -to easily discovering functionality. The price we pay for beautifully colored displays is complexity -required to aggregate disparate applications into larger systems. `cmd2` fills the niche between -high [ease of command discovery](https://clig.dev/#ease-of-discovery) applications and smart -workflow automation systems. -The `cmd2` framework provides a great mixture of both worlds. Application designers can easily -create complex applications and rely on the cmd2 library to offer effortless user facing help and -extensive tab completion. When users become comfortable with functionality, cmd2 turns into a -feature rich library enabling a smooth transition to full automation. If designed with enough -forethought, a well implemented cmd2 application can serve as a boutique workflow tool. `cmd2` pulls -off this flexibility based on two pillars of philosophy: +class App(cmd2.Cmd): + """A small interactive application.""" -- Tab Completion -- Automation Transition + @cmd2.with_annotated + def do_greet( + self, + name: Annotated[str, Argument(help_text="person to greet")], + count: Annotated[int, Option(help_text="number of greetings")] = 1, + ) -> None: + """Greet a person.""" + for _ in range(count): + self.poutput(f"Hello, {name}!") -## Philosophy -
+if __name__ == "__main__":
+ App().cmdloop()
+```
-Deep extensive tab completion and help text generation based on the argparse library create the
-first pillar of 'ease of command discovery'. The following is a list of features in this category.
+`cmd2` supports [Typer](https://typer.tiangolo.com/)-style syntax for specifying command arguments
+with type annotations. In this example, `@cmd2.with_annotated` turns `name` and `count` into a
+positional argument and an option.
-- Great tab completion of commands, subcommands, file system paths, and shell commands.
-- Custom tab completion for user designed commands via simple function overloading.
-- Tab completion from `persistent_history_file` sources added with very little friction.
-- Automatic tab completion of `argparse` flags and optional arguments.
-- Path completion easily enabled.
-- When all else fails, custom tab completion based on `choices_provider` can fill any gaps.
+Run it with `python app.py`. Your new `greet` command already has input validation, generated help,
+and tab completion for its options. The application also includes discoverable help, command
+history, aliases, macros, scripting, shell integration, and other built-in commands.
-
+```console
+(Cmd) help greet
+Usage: greet [-h] [--count COUNT] name
-cmd2 creates the second pillar of 'ease of transition to automation' through alias/macro creation,
-command line argument parsing and execution of cmd2 scripting.
+(Cmd) greet --count 2 Ada
+Hello, Ada!
+Hello, Ada!
+```
-- Flexible alias and macro creation for quick abstraction of commands.
-- Text file scripting of your application with `run_script` (`@`) and `_relative_run_script` (`@@`)
-- Powerful and flexible built-in Python scripting of your application using the `run_pyscript`
- command
+[](https://raw.githubusercontent.com/python-cmd2/cmd2/main/docs/assets/cmd2-readme-demo.gif)
+
+See the [getting started tutorial](https://cmd2.readthedocs.io/en/latest/examples/getting_started/)
+to build a more complete application.
+
+## Why cmd2?
+
+### Build more with less code
+
+- Define commands as Python methods and use familiar
+ [`argparse`](https://cmd2.readthedocs.io/en/latest/features/argument_processing/) parsers for
+ arguments and subcommands. A single parser definition drives validation, help, and completion.
+- Render Rich tables and other styled objects, with consistent helpers for
+ [normal output, errors, warnings, and paging](https://cmd2.readthedocs.io/en/latest/features/generating_output/).
+- Organize large applications into independently testable and dynamically loadable
+ [CommandSets](https://cmd2.readthedocs.io/en/latest/features/modular_commands/), and customize the
+ command lifecycle with [hooks](https://cmd2.readthedocs.io/en/latest/features/hooks/).
+- Integrate existing Python code, shell tools, or asynchronous work instead of restructuring your
+ application around the framework.
+- Run on Windows, macOS, and Linux with a pure-Python package and a small dependency footprint.
+
+### Give users a capable interface from day one
+
+- **Tab completion:** provide context-aware completion for commands, subcommands, options, choices,
+ file paths, and custom data sources, with descriptive hints for completion candidates. Learn more
+ about [completion](https://cmd2.readthedocs.io/en/latest/features/completion/).
+- **History:** search, edit, rerun, save, and optionally persist previously entered commands. Users
+ also get familiar navigation and reverse search such as Ctrl+R. Learn more
+ about [history](https://cmd2.readthedocs.io/en/latest/features/history/).
+- **Unicode:** accept Unicode in commands, arguments, file names, and output, and run UTF-8 command
+ scripts for applications used in any language. Learn more about
+ [command scripts](https://cmd2.readthedocs.io/en/latest/features/scripting/#command-scripts).
+- **Shell shortcuts and hotkeys:** use familiar Readline-style keyboard shortcuts for navigating and
+ editing the command line, including Emacs-style bindings provided by `prompt-toolkit`. Learn more
+ about [keyboard shortcuts](https://cmd2.readthedocs.io/en/latest/features/history/#for-users).
+- **Help:** generate discoverable, categorized help for commands, subcommands, and arguments
+ directly from their argument parsers. Learn more about
+ [help](https://cmd2.readthedocs.io/en/latest/features/help/).
+- **Rich UI/UX:** offer syntax highlighting, Fish-style history suggestions, multiline input,
+ configurable prompts, completion menus, themes, and an optional bottom toolbar. Learn more about
+ [prompts and toolbars](https://cmd2.readthedocs.io/en/latest/features/prompt/) and
+ [themes](https://cmd2.readthedocs.io/en/latest/features/theme/).
+
+### Grow from exploration to automation
+
+- **Shell scripting:** automate the same commands users run interactively by passing them as command
+ arguments or standard input from shell scripts. Learn more about
+ [automating cmd2 applications](https://cmd2.readthedocs.io/en/latest/features/os/#automating-cmd2-apps-from-other-cliclu-tools).
+- **Python scripting:** run Python scripts inside the application for loops, branching, complex
+ control flow, and integration with the application's commands and data. Learn more about
+ [Python scripts](https://cmd2.readthedocs.io/en/latest/features/scripting/#python-scripts).
+- **Run shell commands:** execute operating-system commands without leaving the application, using
+ the built-in `shell` command or its `!` shortcut. Learn more about
+ [OS integration](https://cmd2.readthedocs.io/en/latest/features/os/#executing-os-commands-from-within-cmd2).
+- **Redirect output:** send command output to files or the clipboard, or pipe it through one or more
+ shell commands. Learn more about
+ [output redirection and pipes](https://cmd2.readthedocs.io/en/latest/features/redirection/).
+- **Aliases, macros, and shortcuts:** let users customize names, parameterized commands, and terse
+ shortcuts for repetitive workflows without changing the application. Learn more about
+ [aliases, macros, and shortcuts](https://cmd2.readthedocs.io/en/latest/features/shortcuts_aliases_macros/).
+- **Startup scripts:** initialize an application consistently by running saved commands every time
+ it starts. Learn more about
+ [startup commands and scripts](https://cmd2.readthedocs.io/en/latest/features/startup_commands/).
+- **Embedded Python and IPython shells:** drop into an interactive Python or IPython session for
+ experimentation, debugging, object introspection, and access to application state. Learn more
+ about
+ [embedded Python shells](https://cmd2.readthedocs.io/en/latest/features/embedded_python_shells/).
+- **Color, style, and tables:** produce readable output with Rich colors and styles, custom themes,
+ paging, and flexible table layouts. Learn more about
+ [generating output](https://cmd2.readthedocs.io/en/latest/features/generating_output/) and
+ [creating tables](https://cmd2.readthedocs.io/en/latest/features/table_creation/).
## Installation
@@ -91,17 +159,21 @@ cmd2 works with Python 3.11+ on Windows, macOS, and Linux. It is pure Python cod
dependencies. It works with both conventional CPython and free-threaded variants.
For information on other installation options, see
-[Installation Instructions](https://cmd2.readthedocs.io/en/latest/overview/installation.html) in the
+[Installation Instructions](https://cmd2.readthedocs.io/en/latest/overview/installation/) in the
cmd2 documentation.
-## Documentation
-
-The latest documentation for cmd2 can be read online here: https://cmd2.readthedocs.io/en/latest/
+
+> [!IMPORTANT]
+> Upgrading from an older release? Versions 3.x and 4.x include significant
+> backwards-incompatible changes. Review the [changelog](https://github.com/python-cmd2/cmd2/blob/main/CHANGELOG.md)
+> and [migration guide](https://cmd2.readthedocs.io/en/latest/upgrades/) before upgrading.
-It is available in HTML, PDF, and ePub formats.
+## Documentation
-The best way to learn the cmd2 api is to delve into the example applications located in source under
-examples.
+Read the [latest documentation](https://cmd2.readthedocs.io/en/latest/) online or download it in
+HTML, PDF, and ePub formats. The
+[`examples`](https://github.com/python-cmd2/cmd2/tree/main/examples) directory contains focused,
+runnable demonstrations of individual features.
## Tutorials
@@ -116,29 +188,6 @@ examples.
- Advanced cookiecutter template with external plugin support :
https://github.com/jayrod/cookiecutter-python-cmd2-ext-plug
-## Hello World
-
-```python
-#!/usr/bin/env python
-"""A simple cmd2 application."""
-import cmd2
-
-
-class FirstApp(cmd2.Cmd):
- """A simple cmd2 application."""
-
- def do_hello_world(self, _: cmd2.Statement):
- self.poutput('Hello World')
-
-
-if __name__ == '__main__':
- import sys
-
- c = FirstApp()
- sys.exit(c.cmdloop())
-
-```
-
## Found a bug?
If you think you've found a bug, please first read through the open
diff --git a/cmd2.png b/cmd2.png
deleted file mode 100644
index 31a6e921f..000000000
Binary files a/cmd2.png and /dev/null differ
diff --git a/cmd2/annotated.py b/cmd2/annotated.py
index f11cce437..d4b9901f2 100644
--- a/cmd2/annotated.py
+++ b/cmd2/annotated.py
@@ -277,6 +277,7 @@ def do_build(self, target: str, common: CommonArgs):
)
from pathlib import Path
from typing import (
+ TYPE_CHECKING,
Annotated,
Any,
ClassVar,
@@ -289,6 +290,7 @@ def do_build(self, target: str, common: CommonArgs):
TypeVar,
Union,
Unpack,
+ cast,
get_args,
get_origin,
get_type_hints,
@@ -309,6 +311,7 @@ def do_build(self, target: str, common: CommonArgs):
from .exceptions import Cmd2ArgparseError
from .rich_utils import Cmd2HelpFormatter, HelpContent
from .types import (
+ BoundCommandFunc,
CmdOrSetT,
UnboundChoicesProvider,
UnboundCompleter,
@@ -318,6 +321,20 @@ def do_build(self, target: str, common: CommonArgs):
_NargsValue = int | str | tuple[int] | tuple[int, int] | tuple[int, float]
+class _CommandFunc(Protocol):
+ """Callable metadata needed to inspect a command before or after descriptor binding."""
+
+ if TYPE_CHECKING:
+
+ @property
+ def __name__(self) -> str: ...
+
+ @property
+ def __qualname__(self) -> str: ...
+
+ def __call__(self, *args: Any, **kwargs: Any) -> Any: ...
+
+
class Cmd2ParserKwargs(TypedDict, total=False):
"""Forwarded ctor kwargs for [`Cmd2ArgumentParser`][cmd2.argparse_utils.Cmd2ArgumentParser] (PEP 692 ``Unpack``).
@@ -695,7 +712,7 @@ def _convert(value: str) -> enum.Enum:
raise _invalid_choice(value, _value_map)
_convert.__name__ = enum_class.__name__
- _convert._cmd2_enum_class = enum_class # type: ignore[attr-defined]
+ _convert._cmd2_enum_class = enum_class # type: ignore[attr-defined, ty:unresolved-attribute]
return _convert
@@ -1101,7 +1118,7 @@ def _convert(value: str) -> Any:
_convert.__name__ = getattr(converter, "__name__", "preprocess")
enum_class = getattr(converter, "_cmd2_enum_class", None)
if enum_class is not None:
- _convert._cmd2_enum_class = enum_class # type: ignore[attr-defined]
+ _convert._cmd2_enum_class = enum_class # type: ignore[attr-defined, ty:unresolved-attribute]
return _convert
@@ -2118,7 +2135,7 @@ def _link_mutex_group_membership(
by_name[name].mutex_group_indices.append(index)
-def _resolve_func_hints(func: Callable[..., Any], *, skip_params: frozenset[str] = _SKIP_PARAMS) -> dict[str, Any]:
+def _resolve_func_hints(func: _CommandFunc, *, skip_params: frozenset[str] = _SKIP_PARAMS) -> dict[str, Any]:
"""Resolve the type hints for the parameters that become arguments.
The bound first parameter (self/cls), the injected ``skip_params``, and the ``return`` annotation
@@ -2296,7 +2313,7 @@ def _block_field_dest(spec: _BlockSpec, field_name: str) -> str:
return _shared_field_dest(spec.dc_type, field_name) if spec.shared else field_name
-def _dataclass_blocks(func: Callable[..., Any], *, skip_params: frozenset[str] = _SKIP_PARAMS) -> dict[str, _BlockSpec]:
+def _dataclass_blocks(func: _CommandFunc, *, skip_params: frozenset[str] = _SKIP_PARAMS) -> dict[str, _BlockSpec]:
"""Map each dataclass-block parameter name to its :class:`_BlockSpec`.
Used by the runtime handler to reconstruct the dataclass instance from the parsed namespace. A
@@ -2320,7 +2337,7 @@ def _dataclass_blocks(func: Callable[..., Any], *, skip_params: frozenset[str] =
def _lazy_block_resolver(
- func: Callable[..., Any],
+ func: _CommandFunc,
*,
base_accepted: set[str],
skip_params: frozenset[str],
@@ -2377,7 +2394,7 @@ def _reconstruct_dataclass_blocks(func_kwargs: dict[str, Any], blocks: dict[str,
def _resolve_parameters(
- func: Callable[..., Any],
+ func: _CommandFunc,
*,
skip_params: frozenset[str] = _SKIP_PARAMS,
base_command: bool = False,
@@ -2721,7 +2738,7 @@ def _docstring_first_paragraph(doc: str | None) -> str | None:
def build_parser_from_function(
- func: Callable[..., Any],
+ func: _CommandFunc,
*,
skip_params: frozenset[str] = _SKIP_PARAMS,
groups: tuple[Group, ...] | None = None,
@@ -2796,7 +2813,7 @@ def build_parser_from_function(
return parser
-def _derive_subcommand_name(func: Callable[..., Any], subcommand_to: str) -> str:
+def _derive_subcommand_name(func: _CommandFunc, subcommand_to: str) -> str:
"""Derive the subcommand name from the function name and validate the naming convention.
``subcommand_to='team member'`` + ``func.__name__='team_member_add'`` -> ``'add'``.
@@ -2832,7 +2849,7 @@ class _ParserBuildOptions:
def _make_parser_builder(
- func: Callable[..., Any],
+ func: _CommandFunc,
*,
skip_params: frozenset[str],
base_command: bool,
@@ -2870,13 +2887,60 @@ def parser_builder() -> Cmd2ArgumentParser:
return parser_builder
+_CommandParams = ParamSpec("_CommandParams")
+_DecoratorParams = ParamSpec("_DecoratorParams")
+_CommandReturn = TypeVar("_CommandReturn")
+_CommandReturn_co = TypeVar("_CommandReturn_co", covariant=True)
+
+
+class _BoundAnnotatedCommand(Protocol[_CommandParams, _CommandReturn_co]):
+ """A bound annotated command whose complete call signature is preserved."""
+
+ if TYPE_CHECKING:
+
+ @property
+ def __name__(self) -> str: ...
+
+ @property
+ def __qualname__(self) -> str: ...
+
+ def __call__(self, *args: _CommandParams.args, **kwargs: _CommandParams.kwargs) -> _CommandReturn_co: ...
+
+
+class _AnnotatedCommand(Protocol[CmdOrSetT, _CommandParams, _CommandReturn_co]):
+ """A callable command method with the metadata used by ``with_annotated``."""
+
+ __name__: str
+ __qualname__: str
+
+ def __call__(
+ self, __self: CmdOrSetT, /, *args: _CommandParams.args, **kwargs: _CommandParams.kwargs
+ ) -> _CommandReturn_co: ...
+
+ @overload
+ def __get__(
+ self, instance: None, owner: Any = ...
+ ) -> "_AnnotatedCommand[CmdOrSetT, _CommandParams, _CommandReturn_co]": ...
+
+ @overload
+ def __get__(self, instance: CmdOrSetT, owner: Any = ...) -> _BoundAnnotatedCommand[_CommandParams, _CommandReturn_co]: ...
+
+
+class _WithAnnotatedDecorator(Protocol):
+ """The signature-preserving decorator ``with_annotated(...)`` returns (generic per call)."""
+
+ def __call__(
+ self, fn: _AnnotatedCommand[CmdOrSetT, _CommandParams, _CommandReturn], /
+ ) -> _AnnotatedCommand[CmdOrSetT, _CommandParams, _CommandReturn]: ...
+
+
def _build_subcommand_handler(
- func: Callable[..., Any],
+ func: _AnnotatedCommand[Any, ..., Any],
subcommand_to: str,
*,
base_command: bool = False,
options: _ParserBuildOptions,
-) -> tuple[Callable[..., Any], str, Callable[[], Cmd2ArgumentParser]]:
+) -> tuple[_AnnotatedCommand[Any, ..., Any], str, Callable[[], Cmd2ArgumentParser]]:
"""Build a subcommand's parser and a handler that unpacks the Namespace into typed kwargs.
:param func: the subcommand handler function
@@ -2902,8 +2966,15 @@ def _build_subcommand_handler(
_leading_names, _var_positional_name = _var_positional_call_plan(func)
@functools.wraps(func)
- def handler(self_arg: Any, ns: Any) -> Any:
+ def handler(*args: Any, **kwargs: Any) -> Any:
"""Unpack Namespace into typed kwargs for the subcommand handler."""
+ if kwargs:
+ raise TypeError("subcommand handlers do not accept keyword arguments")
+ if len(args) != 2:
+ raise TypeError(f"subcommand handlers require 2 positional arguments (self, namespace), got {len(args)}")
+ self_arg, ns = args
+ if not isinstance(ns, argparse.Namespace):
+ raise TypeError("subcommand handlers require a parsed argparse.Namespace")
_blocks, _eff_accepted = _resolve_blocks()
filtered = _filtered_namespace_kwargs(ns, accepted=_eff_accepted)
if constants.NS_ATTR_SUBCOMMAND_FUNC in filtered:
@@ -2916,21 +2987,13 @@ def handler(self_arg: Any, ns: Any) -> Any:
)
parser_builder = _make_parser_builder(func, skip_params=_SKIP_PARAMS, base_command=base_command, options=options)
- return handler, subcmd_name, parser_builder
-
-
-_CommandParams = ParamSpec("_CommandParams")
-_CommandReturn = TypeVar("_CommandReturn")
-
-
-class _WithAnnotatedDecorator(Protocol):
- """The signature-preserving decorator ``with_annotated(...)`` returns (generic per call)."""
-
- def __call__(self, fn: Callable[_CommandParams, _CommandReturn], /) -> Callable[_CommandParams, _CommandReturn]: ...
+ return cast(_AnnotatedCommand[Any, ..., Any], handler), subcmd_name, parser_builder
@overload
-def with_annotated(func: Callable[_CommandParams, _CommandReturn]) -> Callable[_CommandParams, _CommandReturn]: ...
+def with_annotated(
+ func: _AnnotatedCommand[CmdOrSetT, _CommandParams, _CommandReturn], /
+) -> _AnnotatedCommand[CmdOrSetT, _CommandParams, _CommandReturn]: ...
@overload
@@ -2957,7 +3020,7 @@ def with_annotated(
def with_annotated(
- func: Callable[..., Any] | None = None,
+ func: _AnnotatedCommand[CmdOrSetT, _CommandParams, _CommandReturn] | None = None,
*,
ns_provider: Callable[..., argparse.Namespace] | None = None,
preserve_quotes: bool = False,
@@ -2975,7 +3038,7 @@ def with_annotated(
subcommand_title: str | None = None,
subcommand_description: str | None = None,
**parser_kwargs: Unpack[Cmd2ParserKwargs],
-) -> Callable[..., Any] | Callable[[Callable[..., Any]], Callable[..., Any]]:
+) -> _AnnotatedCommand[CmdOrSetT, _CommandParams, _CommandReturn] | _WithAnnotatedDecorator:
"""Decorate a ``do_*`` method to build its argparse parser from type annotations.
:param func: the command function (when used without parentheses)
@@ -3036,23 +3099,25 @@ def with_annotated(
subcommand_description=subcommand_description,
)
- def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
+ def decorator(
+ command_func: _AnnotatedCommand[CmdOrSetT, _DecoratorParams, _CommandReturn],
+ ) -> _AnnotatedCommand[CmdOrSetT, _DecoratorParams, _CommandReturn]:
if with_unknown_args:
- unknown_param = inspect.signature(fn).parameters.get("_unknown")
+ unknown_param = inspect.signature(command_func).parameters.get("_unknown")
if unknown_param is None:
raise TypeError("with_annotated(with_unknown_args=True) requires a parameter named _unknown")
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 constants.NS_ATTR_SUBCOMMAND_FUNC in inspect.signature(fn).parameters:
+ if not base_command and constants.NS_ATTR_SUBCOMMAND_FUNC in inspect.signature(command_func).parameters:
raise TypeError(
- f"Parameter '{constants.NS_ATTR_SUBCOMMAND_FUNC}' in {fn.__qualname__} "
+ f"Parameter '{constants.NS_ATTR_SUBCOMMAND_FUNC}' in {command_func.__qualname__} "
"is only valid when with_annotated(base_command=True) is used."
)
if subcommand_to is not None:
handler, subcmd_name, subcmd_parser_builder = _build_subcommand_handler(
- fn,
+ command_func,
subcommand_to,
base_command=base_command,
options=options,
@@ -3066,9 +3131,9 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
parser_source=subcmd_parser_builder,
)
setattr(handler, constants.SUBCOMMAND_ATTR_SPEC, subcommand_spec)
- return handler
+ return cast(_AnnotatedCommand[CmdOrSetT, _DecoratorParams, _CommandReturn], handler)
- command_name = fn.__name__[len(constants.COMMAND_FUNC_PREFIX) :]
+ command_name = command_func.__name__[len(constants.COMMAND_FUNC_PREFIX) :]
skip_params = _SKIP_PARAMS | ({"_unknown"} if with_unknown_args else frozenset())
# Validate the group specs eagerly (decoration time) so a misconfigured group hard-fails when
@@ -3077,24 +3142,26 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
_validate_group_specs(options.groups, options.mutually_exclusive_groups)
if base_command:
# Validate eagerly (decoration time); the base-command rows in _CONSTRAINTS fire here.
- _resolve_parameters(fn, skip_params=skip_params, base_command=True)
+ _resolve_parameters(command_func, skip_params=skip_params, base_command=True)
# Cache signature introspection at decoration time, not per-invocation
- accepted = set(list(inspect.signature(fn).parameters.keys())[1:])
- resolve_blocks = _lazy_block_resolver(fn, base_accepted=accepted, skip_params=skip_params)
- leading_names, var_positional_name = _var_positional_call_plan(fn)
+ accepted = set(list(inspect.signature(command_func).parameters.keys())[1:])
+ resolve_blocks = _lazy_block_resolver(command_func, base_accepted=accepted, skip_params=skip_params)
+ leading_names, var_positional_name = _var_positional_call_plan(command_func)
- parser_builder = _make_parser_builder(fn, skip_params=skip_params, base_command=base_command, options=options)
+ parser_builder = _make_parser_builder(
+ command_func, skip_params=skip_params, base_command=base_command, options=options
+ )
- @functools.wraps(fn)
- def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
+ @functools.wraps(command_func)
+ def cmd_wrapper(*args: Any, **kwargs: Any) -> _CommandReturn:
cmd2_app, statement_arg = _parse_positionals(args)
owner = args[0] # Cmd or CommandSet instance
statement, parsed_arglist = cmd2_app.statement_parser.get_command_arg_list(
command_name, statement_arg, preserve_quotes
)
- arg_parser = cmd2_app.command_parsers.get(cmd_wrapper)
+ arg_parser = cmd2_app.command_parsers.get(cast(BoundCommandFunc[...], cmd_wrapper))
if arg_parser is None:
raise ValueError(f"No argument parser found for {command_name}")
@@ -3130,10 +3197,10 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
func_kwargs.update(kwargs)
_reconstruct_dataclass_blocks(func_kwargs, blocks, ns)
- result: bool | None = _invoke_command_func(
- fn, owner, func_kwargs, leading_names=leading_names, var_positional_name=var_positional_name
+ result = _invoke_command_func(
+ command_func, owner, func_kwargs, leading_names=leading_names, var_positional_name=var_positional_name
)
- return result
+ return cast(_CommandReturn, result)
argparse_command_spec = ArgparseCommandSpec(
parser_source=parser_builder,
@@ -3141,9 +3208,9 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
)
setattr(cmd_wrapper, constants.ARGPARSE_COMMAND_ATTR_SPEC, argparse_command_spec)
- return cmd_wrapper
+ return cast(_AnnotatedCommand[CmdOrSetT, _DecoratorParams, _CommandReturn], cmd_wrapper)
# Support both @with_annotated and @with_annotated(...)
if func is not None:
return decorator(func)
- return decorator
+ return cast(_WithAnnotatedDecorator, decorator)
diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py
index 32466ad3e..dc2a7d4a8 100644
--- a/cmd2/argparse_completer.py
+++ b/cmd2/argparse_completer.py
@@ -52,7 +52,7 @@
def _build_hint(parser: Cmd2ArgumentParser, arg_action: argparse.Action) -> str:
"""Build completion hint for a given argument."""
# Check if hinting is disabled for this argument
- suppress_hint = arg_action.get_suppress_tab_hint() # type: ignore[attr-defined]
+ suppress_hint = arg_action.get_suppress_tab_hint() # type: ignore[attr-defined, ty:unresolved-attribute]
if suppress_hint or arg_action.help == argparse.SUPPRESS:
return ""
@@ -104,7 +104,7 @@ def __init__(self, arg_action: argparse.Action) -> None:
self.is_remainder = self.action.nargs == argparse.REMAINDER
# Check if nargs is a range
- nargs_range: tuple[int, int | float] | None = self.action.get_nargs_range() # type: ignore[attr-defined]
+ nargs_range: tuple[int, int | float] | None = self.action.get_nargs_range() # type: ignore[attr-defined, ty:unresolved-attribute]
if nargs_range is not None:
self.min = nargs_range[0]
self.max = nargs_range[1]
@@ -575,7 +575,7 @@ def _validate_table_data(arg_state: _ArgumentState, completions: Completions) ->
:raises ValueError: if there is an error with the data.
"""
- table_columns = arg_state.action.get_table_columns() # type: ignore[attr-defined]
+ table_columns = arg_state.action.get_table_columns() # type: ignore[attr-defined, ty:unresolved-attribute]
has_table_data = any(item.table_data for item in completions)
if table_columns is None:
@@ -606,7 +606,7 @@ def _build_completion_table(self, arg_state: _ArgumentState, completions: Comple
table_columns = cast(
Sequence[str | Column] | None,
- arg_state.action.get_table_columns(), # type: ignore[attr-defined]
+ arg_state.action.get_table_columns(), # type: ignore[attr-defined, ty:unresolved-attribute]
)
# Skip table generation if results are outside thresholds or no columns are defined
@@ -761,7 +761,7 @@ def _complete_arg(
:raises CompletionError: if the completer or choices function this calls raises one
"""
# Check if the argument uses a completer
- completer = arg_state.action.get_completer() # type: ignore[attr-defined]
+ completer = arg_state.action.get_completer() # type: ignore[attr-defined, ty:unresolved-attribute]
if completer is not None:
args, kwargs = self._prepare_callable_params(
completer,
@@ -775,7 +775,7 @@ def _complete_arg(
# Otherwise it uses a choices provider or choices list
else:
- choices_provider = arg_state.action.get_choices_provider() # type: ignore[attr-defined]
+ choices_provider = arg_state.action.get_choices_provider() # type: ignore[attr-defined, ty:unresolved-attribute]
if choices_provider is not None:
args, kwargs = self._prepare_callable_params(
choices_provider,
diff --git a/cmd2/argparse_utils.py b/cmd2/argparse_utils.py
index 0584103df..3bad19c09 100644
--- a/cmd2/argparse_utils.py
+++ b/cmd2/argparse_utils.py
@@ -564,11 +564,11 @@ def _ActionsContainer_add_argument( # noqa: N802
new_arg = orig_actions_container_add_argument(self, *args, **kwargs)
# Set the cmd2-specific attributes
- new_arg.set_nargs_range(nargs_range) # type: ignore[attr-defined]
- new_arg.set_choices_provider(choices_provider) # type: ignore[attr-defined]
- new_arg.set_completer(completer) # type: ignore[attr-defined]
- new_arg.set_suppress_tab_hint(suppress_tab_hint) # type: ignore[attr-defined]
- new_arg.set_table_columns(table_columns) # type: ignore[attr-defined]
+ new_arg.set_nargs_range(nargs_range) # type: ignore[attr-defined, ty:unresolved-attribute]
+ new_arg.set_choices_provider(choices_provider) # type: ignore[attr-defined, ty:unresolved-attribute]
+ new_arg.set_completer(completer) # type: ignore[attr-defined, ty:unresolved-attribute]
+ new_arg.set_suppress_tab_hint(suppress_tab_hint) # type: ignore[attr-defined, ty:unresolved-attribute]
+ new_arg.set_table_columns(table_columns) # type: ignore[attr-defined, ty:unresolved-attribute]
# Set other registered custom attributes
for keyword, value in custom_attribs.items():
@@ -666,14 +666,14 @@ def _SubParsersAction_remove_all_parsers( # noqa: N802
# Get the next subcommand name. remove_parser() will remove
# it and any associated aliases from _name_parser_map.
name = next(iter(self._name_parser_map))
- record = self.remove_parser(name) # type: ignore[attr-defined]
+ record = self.remove_parser(name) # type: ignore[attr-defined, ty:unresolved-attribute]
records.append(record)
return records
-argparse._SubParsersAction.remove_parser = _SubParsersAction_remove_parser # type: ignore[attr-defined]
-argparse._SubParsersAction.remove_all_parsers = _SubParsersAction_remove_all_parsers # type: ignore[attr-defined]
+argparse._SubParsersAction.remove_parser = _SubParsersAction_remove_parser # type: ignore[attr-defined, ty:unresolved-attribute]
+argparse._SubParsersAction.remove_all_parsers = _SubParsersAction_remove_all_parsers # type: ignore[attr-defined, ty:unresolved-attribute]
@dataclass
@@ -831,7 +831,7 @@ def _build_subparsers_prog_prefix(self, positionals: list[argparse.Action]) -> s
temp_parser = Cmd2ArgumentParser(
prog=self.prog,
usage=None,
- formatter_class=cast(type[Cmd2HelpFormatter], self.formatter_class),
+ formatter_class=cast(type[Cmd2HelpFormatter], self.formatter_class), # type: ignore[redundant-cast]
add_help=False,
)
@@ -984,7 +984,7 @@ def detach_subcommand(self, subcommand_path: Iterable[str], subcommand: str) ->
try:
record = cast(
SubcommandRecord,
- subparsers_action.remove_parser(subcommand), # type: ignore[attr-defined]
+ subparsers_action.remove_parser(subcommand), # type: ignore[attr-defined, ty:unresolved-attribute]
)
except ValueError:
raise ValueError(f"Subcommand '{subcommand}' does not exist for '{target_parser.prog}'") from None
@@ -1006,7 +1006,7 @@ def detach_all_subcommands(self, subcommand_path: Iterable[str]) -> list[Subcomm
records = cast(
list[SubcommandRecord],
- subparsers_action.remove_all_parsers(), # type: ignore[attr-defined]
+ subparsers_action.remove_all_parsers(), # type: ignore[attr-defined, ty:unresolved-attribute]
)
# Update command for each detached subcommand
for record in records:
@@ -1037,7 +1037,7 @@ def error(self, message: str) -> NoReturn:
def _get_formatter(self, *_args: Any, **_kwargs: Any) -> Cmd2HelpFormatter:
"""Override with customizations for Cmd2HelpFormatter."""
- formatter_class = cast(type[Cmd2HelpFormatter], self.formatter_class)
+ formatter_class = cast(type[Cmd2HelpFormatter], self.formatter_class) # type: ignore[redundant-cast]
return formatter_class(prog=self.prog, file=self._thread_locals.current_output_file)
def format_help(self, *args: Any, **kwargs: Any) -> str:
@@ -1046,7 +1046,7 @@ def format_help(self, *args: Any, **kwargs: Any) -> str:
def _get_nargs_pattern(self, action: argparse.Action) -> str:
"""Override to support nargs ranges."""
- nargs_range = action.get_nargs_range() # type: ignore[attr-defined]
+ nargs_range = action.get_nargs_range() # type: ignore[attr-defined, ty:unresolved-attribute]
if nargs_range:
range_max = "" if nargs_range[1] == constants.INFINITY else nargs_range[1]
nargs_pattern = f"(-*A{{{nargs_range[0]},{range_max}}}-*)"
@@ -1066,7 +1066,7 @@ def _match_argument(self, action: argparse.Action, arg_strings_pattern: str) ->
# raise an exception if we weren't able to find a match
if match is None:
- nargs_range = action.get_nargs_range() # type: ignore[attr-defined]
+ nargs_range = action.get_nargs_range() # type: ignore[attr-defined, ty:unresolved-attribute]
if nargs_range is not None:
raise ArgumentError(action, build_range_error(nargs_range[0], nargs_range[1]))
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index f5becb330..5ceffb085 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -228,7 +228,7 @@ class DisabledCommand(NamedTuple):
This data is used to restore its functions when the command is enabled.
"""
- command_func: BoundCommandFunc
+ command_func: BoundCommandFunc[...]
help_func: Callable[[], Any] | None
completer_func: BoundCompleter | None
@@ -251,14 +251,14 @@ def __init__(self, cmd_app: "Cmd") -> None:
self._parsers: dict[str, Cmd2ArgumentParser] = {}
@staticmethod
- def _fully_qualified_name(command_method: BoundCommandFunc) -> str:
+ def _fully_qualified_name(command_method: BoundCommandFunc[...]) -> str:
"""Return the fully qualified name of a method or None if a method wasn't passed in."""
try:
return f"{command_method.__module__}.{command_method.__qualname__}"
except AttributeError:
return ""
- def __contains__(self, command_method: BoundCommandFunc) -> bool:
+ def __contains__(self, command_method: BoundCommandFunc[...]) -> bool:
"""Return whether a given method's parser is in self.
If the parser does not yet exist, it will be created if applicable.
@@ -267,7 +267,7 @@ def __contains__(self, command_method: BoundCommandFunc) -> bool:
parser = self.get(command_method)
return bool(parser)
- def get(self, command_method: BoundCommandFunc) -> Cmd2ArgumentParser | None:
+ def get(self, command_method: BoundCommandFunc[...]) -> Cmd2ArgumentParser | None:
"""Return a given method's parser or None if the method is not argparse-based.
If the parser does not yet exist, it will be created.
@@ -300,7 +300,7 @@ def get(self, command_method: BoundCommandFunc) -> Cmd2ArgumentParser | None:
return self._parsers.get(full_method_name)
- def remove(self, command_method: BoundCommandFunc) -> None:
+ def remove(self, command_method: BoundCommandFunc[...]) -> None:
"""Remove a given method's parser if it exists."""
full_method_name = self._fully_qualified_name(command_method)
if full_method_name in self._parsers:
@@ -919,7 +919,7 @@ def register_command_set(self, cmdset: CommandSet[Any]) -> None:
cmdset.on_register(self)
methods = cast(
- list[tuple[str, Callable[..., Any]]],
+ list[tuple[str, BoundCommandFunc[...]]],
inspect.getmembers(
cmdset,
predicate=lambda meth: ( # type: ignore[arg-type]
@@ -1021,7 +1021,9 @@ def _build_parser(
return parser
- def _install_command_function(self, command_func_name: str, command_method: BoundCommandFunc, context: str = "") -> None:
+ def _install_command_function(
+ self, command_func_name: str, command_method: BoundCommandFunc[...], context: str = ""
+ ) -> None:
"""Install a new command function into the CLI.
:param command_func_name: name of command function to add
@@ -1086,12 +1088,15 @@ def unregister_command_set(self, cmdset: CommandSet[Any]) -> None:
cmdset.on_unregister()
self._unregister_subcommands(cmdset)
- methods: list[tuple[str, Callable[..., Any]]] = inspect.getmembers(
- cmdset,
- predicate=lambda meth: ( # type: ignore[arg-type]
- isinstance(meth, Callable) # type: ignore[arg-type]
- and hasattr(meth, "__name__")
- and meth.__name__.startswith(COMMAND_FUNC_PREFIX)
+ methods: list[tuple[str, BoundCommandFunc[...]]] = cast(
+ list[tuple[str, BoundCommandFunc[...]]],
+ inspect.getmembers(
+ cmdset,
+ predicate=lambda meth: ( # type: ignore[arg-type]
+ isinstance(meth, Callable) # type: ignore[arg-type]
+ and hasattr(meth, "__name__")
+ and meth.__name__.startswith(COMMAND_FUNC_PREFIX)
+ ),
),
)
@@ -1158,12 +1163,15 @@ def check_parser_uninstallable(parser: Cmd2ArgumentParser) -> None:
)
check_parser_uninstallable(subparser)
- methods: list[tuple[str, Callable[..., Any]]] = inspect.getmembers(
- cmdset,
- predicate=lambda meth: ( # type: ignore[arg-type]
- isinstance(meth, Callable) # type: ignore[arg-type]
- and hasattr(meth, "__name__")
- and meth.__name__.startswith(COMMAND_FUNC_PREFIX)
+ methods: list[tuple[str, BoundCommandFunc[...]]] = cast(
+ list[tuple[str, BoundCommandFunc[...]]],
+ inspect.getmembers(
+ cmdset,
+ predicate=lambda meth: ( # type: ignore[arg-type]
+ isinstance(meth, Callable) # type: ignore[arg-type]
+ and hasattr(meth, "__name__")
+ and meth.__name__.startswith(COMMAND_FUNC_PREFIX)
+ ),
),
)
@@ -2827,9 +2835,10 @@ def _get_commands_aliases_and_macros_choices(self) -> Choices:
# Add commands
for command in self.get_visible_commands():
- command_func = cast(BoundCommandFunc, self.get_command_func(command))
- description = strip_doc_annotations(command_func.__doc__).splitlines()[0] if command_func.__doc__ else ""
- items.append(CompletionItem(command, display_meta=description))
+ command_func = self.get_command_func(command)
+ if command_func is not None:
+ description = strip_doc_annotations(command_func.__doc__).splitlines()[0] if command_func.__doc__ else ""
+ items.append(CompletionItem(command, display_meta=description))
# Add aliases
for name, value in self.aliases.items():
@@ -3406,7 +3415,7 @@ def _restore_output(self, statement: Statement, saved_redir_state: utils.Redirec
self._cur_pipe_proc_reader = saved_redir_state.saved_pipe_proc_reader
self._redirecting = saved_redir_state.saved_redirecting
- def get_command_func(self, command: str) -> BoundCommandFunc | None:
+ def get_command_func(self, command: str) -> BoundCommandFunc[...] | None:
"""Get the bound command function for a command.
:param command: the name of the command
@@ -3414,9 +3423,9 @@ def get_command_func(self, command: str) -> BoundCommandFunc | None:
"""
command_func_name = constants.COMMAND_FUNC_PREFIX + command
command_func = getattr(self, command_func_name, None)
- return cast(BoundCommandFunc, command_func) if callable(command_func) else None
+ return cast(BoundCommandFunc[...], command_func) if callable(command_func) else None
- def _get_command_category(self, func: BoundCommandFunc) -> str:
+ def _get_command_category(self, func: BoundCommandFunc[...]) -> str:
"""Determine the category for a command.
:param func: the do_* function implementing the command
@@ -4345,9 +4354,10 @@ def _build_command_info(self) -> tuple[dict[str, list[str]], list[str]]:
help_topics.remove(command)
# Store the command within its category
- command_func = cast(BoundCommandFunc, self.get_command_func(command))
- category = self._get_command_category(command_func)
- cmds_cats.setdefault(category, []).append(command)
+ command_func = self.get_command_func(command)
+ if command_func is not None:
+ category = self._get_command_category(command_func)
+ cmds_cats.setdefault(category, []).append(command)
return cmds_cats, help_topics
@@ -5814,8 +5824,8 @@ def disable_category(self, category: str, message_to_print: str) -> None:
all_commands = self.get_all_commands()
for command in all_commands:
- command_func = cast(BoundCommandFunc, self.get_command_func(command))
- if self._get_command_category(command_func) == category:
+ command_func = self.get_command_func(command)
+ if command_func is not None and self._get_command_category(command_func) == category:
self.disable_command(command, message_to_print)
self.disabled_categories[category] = message_to_print
@@ -5918,7 +5928,7 @@ def _validate_callable_param_count(cls, func: Callable[..., Any], count: int) ->
nparam = len(signature.parameters)
if nparam != count:
plural = "" if nparam == 1 else "s"
- raise TypeError(f"{func.__name__} has {nparam} positional argument{plural}, expected {count}")
+ raise TypeError(f"{getattr(func, '__name__', 'hook')} has {nparam} positional argument{plural}, expected {count}")
@classmethod
def _validate_prepostloop_callable(cls, func: Callable[[], None]) -> None:
@@ -5927,7 +5937,7 @@ def _validate_prepostloop_callable(cls, func: Callable[[], None]) -> None:
# make sure there is no return annotation or the return is specified as None
_, ret_ann = get_types(func)
if ret_ann is not None:
- raise TypeError(f"{func.__name__} must have a return type of 'None', got: {ret_ann}")
+ raise TypeError(f"{getattr(func, '__name__', 'hook')} must have a return type of 'None', got: {ret_ann}")
def register_preloop_hook(self, func: Callable[[], None]) -> None:
"""Register a function to be called at the beginning of the command loop."""
@@ -5942,15 +5952,16 @@ def register_postloop_hook(self, func: Callable[[], None]) -> None:
@classmethod
def _validate_postparsing_callable(cls, func: Callable[[plugin.PostparsingData], plugin.PostparsingData]) -> None:
"""Check parameter and return types for postparsing hooks."""
- cls._validate_callable_param_count(cast(Callable[..., Any], func), 1)
+ cls._validate_callable_param_count(func, 1)
type_hints, ret_ann = get_types(func)
+ func_name = getattr(func, "__name__", "hook")
if not type_hints:
- raise TypeError(f"{func.__name__} parameter is missing a type hint, expected: 'cmd2.plugin.PostparsingData'")
+ raise TypeError(f"{func_name} parameter is missing a type hint, expected: 'cmd2.plugin.PostparsingData'")
par_ann = next(iter(type_hints.values()))
if par_ann != plugin.PostparsingData:
- raise TypeError(f"{func.__name__} must have one parameter declared with type 'cmd2.plugin.PostparsingData'")
+ raise TypeError(f"{func_name} must have one parameter declared with type 'cmd2.plugin.PostparsingData'")
if ret_ann != plugin.PostparsingData:
- raise TypeError(f"{func.__name__} must declare return a return type of 'cmd2.plugin.PostparsingData'")
+ raise TypeError(f"{func_name} must declare a return type of 'cmd2.plugin.PostparsingData'")
def register_postparsing_hook(self, func: Callable[[plugin.PostparsingData], plugin.PostparsingData]) -> None:
"""Register a function to be called after parsing user input but before running the command."""
@@ -5965,20 +5976,21 @@ def _validate_prepostcmd_hook(
) -> None:
"""Check parameter and return types for pre and post command hooks."""
# validate that the callable has the right number of parameters
- cls._validate_callable_param_count(cast(Callable[..., Any], func), 1)
+ cls._validate_callable_param_count(func, 1)
type_hints, ret_ann = get_types(func)
+ func_name = getattr(func, "__name__", "hook")
if not type_hints:
- raise TypeError(f"{func.__name__} parameter is missing a type hint, expected: {data_type}")
+ raise TypeError(f"{func_name} parameter is missing a type hint, expected: {data_type}")
_param_name, par_ann = next(iter(type_hints.items()))
# validate the parameter has the right annotation
if par_ann != data_type:
- raise TypeError(f"argument 1 of {func.__name__} has incompatible type {par_ann}, expected {data_type}")
+ raise TypeError(f"argument 1 of {func_name} has incompatible type {par_ann}, expected {data_type}")
# validate the return value has the right annotation
if ret_ann is None:
- raise TypeError(f"{func.__name__} does not have a declared return type, expected {data_type}")
+ raise TypeError(f"{func_name} does not have a declared return type, expected {data_type}")
if ret_ann != data_type:
- raise TypeError(f"{func.__name__} has incompatible return type {ret_ann}, expected {data_type}")
+ raise TypeError(f"{func_name} has incompatible return type {ret_ann}, expected {data_type}")
def register_precmd_hook(self, func: Callable[[plugin.PrecommandData], plugin.PrecommandData]) -> None:
"""Register a hook to be called before the command function."""
@@ -5997,15 +6009,16 @@ def _validate_cmdfinalization_callable(
"""Check parameter and return types for command finalization hooks."""
cls._validate_callable_param_count(func, 1)
type_hints, ret_ann = get_types(func)
+ func_name = getattr(func, "__name__", "hook")
if not type_hints:
- raise TypeError(f"{func.__name__} parameter is missing a type hint, expected: {plugin.CommandFinalizationData}")
+ raise TypeError(f"{func_name} parameter is missing a type hint, expected: {plugin.CommandFinalizationData}")
_, par_ann = next(iter(type_hints.items()))
if par_ann != plugin.CommandFinalizationData:
raise TypeError(
- f"{func.__name__} must have one parameter declared with type {plugin.CommandFinalizationData}, got: {par_ann}"
+ f"{func_name} must have one parameter declared with type {plugin.CommandFinalizationData}, got: {par_ann}"
)
if ret_ann != plugin.CommandFinalizationData:
- raise TypeError(f"{func.__name__} must declare return a return type of {plugin.CommandFinalizationData}")
+ raise TypeError(f"{func_name} must declare a return type of {plugin.CommandFinalizationData}")
def register_cmdfinalization_hook(
self, func: Callable[[plugin.CommandFinalizationData], plugin.CommandFinalizationData]
diff --git a/cmd2/decorators.py b/cmd2/decorators.py
index 8ad5bfd52..7bc56c90f 100644
--- a/cmd2/decorators.py
+++ b/cmd2/decorators.py
@@ -11,6 +11,7 @@
Any,
TypeAlias,
TypeVar,
+ cast,
overload,
)
@@ -196,7 +197,7 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX) :]
cmd_wrapper.__doc__ = func.__doc__
- return cmd_wrapper
+ return cast(RawCommandFunc[CmdOrSetT], cmd_wrapper)
if callable(cmd_func):
return arg_decorator(cmd_func)
@@ -372,7 +373,7 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
)
setattr(cmd_wrapper, constants.ARGPARSE_COMMAND_ATTR_SPEC, spec)
- return cmd_wrapper
+ return cast(RawCommandFunc[CmdOrSetT], cmd_wrapper)
return arg_decorator
diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py
index 9b4567175..011caf08e 100644
--- a/cmd2/rich_utils.py
+++ b/cmd2/rich_utils.py
@@ -202,7 +202,7 @@ def _format_args(self, action: argparse.Action, default_metavar: str) -> str:
get_metavar = self._metavar_formatter(action, default_metavar)
# Handle nargs specified as a range
- nargs_range = action.get_nargs_range() # type: ignore[attr-defined]
+ nargs_range = action.get_nargs_range() # type: ignore[attr-defined, ty:unresolved-attribute]
if nargs_range is not None:
arg_str = "%s" % get_metavar(1) # noqa: UP031
range_str = self._build_nargs_range_str(nargs_range)
@@ -229,7 +229,7 @@ def _rich_metavar_parts(
get_metavar = self._metavar_formatter(action, default_metavar)
# Handle nargs specified as a range
- nargs_range = action.get_nargs_range() # type: ignore[attr-defined]
+ nargs_range = action.get_nargs_range() # type: ignore[attr-defined, ty:unresolved-attribute]
if nargs_range is not None:
yield "%s" % get_metavar(1), True # noqa: UP031
yield self._build_nargs_range_str(nargs_range), False
diff --git a/cmd2/types.py b/cmd2/types.py
index ff019ad9a..1353b56cb 100644
--- a/cmd2/types.py
+++ b/cmd2/types.py
@@ -8,11 +8,12 @@
from typing import (
TYPE_CHECKING,
Any,
- Concatenate,
ParamSpec,
+ Protocol,
TypeAlias,
TypeVar,
Union,
+ overload,
)
if TYPE_CHECKING: # pragma: no cover
@@ -65,13 +66,42 @@
# Command Function Types
##################################################################################################
+
# A bound cmd2 command function (e.g. do_command).
# The 'self' argument is already tied to an instance and is omitted.
-BoundCommandFunc: TypeAlias = Callable[..., bool | None]
+class BoundCommandFunc(Protocol[P]):
+ """Protocol for a command function bound to a command instance."""
+
+ if TYPE_CHECKING:
+
+ @property
+ def __name__(self) -> str:
+ """Name of the command function."""
+
+ @property
+ def __qualname__(self) -> str:
+ """Qualified name of the command function."""
+
+ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> bool | None:
+ """Invoke the bound command function."""
+
# An unbound cmd2 command function (e.g. the class method do_command).
# The 'self' argument can be either a Cmd or CommandSet instance.
-UnboundCommandFunc: TypeAlias = Callable[Concatenate[CmdOrSetT, P], bool | None]
+class UnboundCommandFunc(Protocol[CmdOrSetT, P]):
+ """Protocol for an unbound command function."""
+
+ __name__: str
+ __qualname__: str
+
+ def __call__(self, __self: CmdOrSetT, /, *args: P.args, **kwargs: P.kwargs) -> bool | None:
+ """Invoke the unbound command function with its command instance."""
+
+ @overload
+ def __get__(self, instance: None, owner: Any = ...) -> "UnboundCommandFunc[CmdOrSetT, P]": ...
+
+ @overload
+ def __get__(self, instance: CmdOrSetT, owner: Any = ...) -> BoundCommandFunc[P]: ...
##################################################################################################
diff --git a/cmd2/utils.py b/cmd2/utils.py
index a0c8d9067..f88a46f20 100644
--- a/cmd2/utils.py
+++ b/cmd2/utils.py
@@ -615,7 +615,7 @@ def _reader_thread_func(self, read_stdout: bool) -> None:
# Run until process completes
while self._proc.poll() is None:
- available = read_stream.peek() # type: ignore[attr-defined]
+ available = read_stream.peek() # type: ignore[attr-defined, ty:unresolved-attribute]
if available:
read_stream.read(len(available))
self._write_bytes(write_stream, available)
diff --git a/docs/assets/cmd2-readme-demo.gif b/docs/assets/cmd2-readme-demo.gif
new file mode 100644
index 000000000..74e9d3ef4
Binary files /dev/null and b/docs/assets/cmd2-readme-demo.gif differ
diff --git a/docs/assets/getting-started-demo.gif b/docs/assets/getting-started-demo.gif
new file mode 100644
index 000000000..6a6b71171
Binary files /dev/null and b/docs/assets/getting-started-demo.gif differ
diff --git a/docs/examples/alternate_event_loops.md b/docs/examples/alternate_event_loops.md
index 0dbe1f01d..7fdc3b66e 100644
--- a/docs/examples/alternate_event_loops.md
+++ b/docs/examples/alternate_event_loops.md
@@ -36,13 +36,15 @@ by using code like the following:
```py
import cmd2
+
class Cmd2EventBased(cmd2.Cmd):
def __init__(self):
cmd2.Cmd.__init__(self)
# ... your class code here ...
-if __name__ == '__main__':
+
+if __name__ == "__main__":
app = Cmd2EventBased()
app.preloop()
diff --git a/docs/examples/getting_started.md b/docs/examples/getting_started.md
index 070158891..be4d20344 100644
--- a/docs/examples/getting_started.md
+++ b/docs/examples/getting_started.md
@@ -10,158 +10,202 @@ example application which demonstrates many features of `cmd2`:
- [Generating Output](../features/generating_output.md)
- [Help](../features/help.md)
- [Shortcuts](../features/shortcuts_aliases_macros.md#shortcuts)
-- [Multiline Commands](../features/multiline_commands.md)
- [History](../features/history.md)
- [Bottom Toolbar](../features/prompt.md#bottom-toolbar)
+The following animation shows the `cat`, `echo`, and `intro` commands in action:
+
+
+
If you don't want to type as we go, here is the complete source (you can click to expand and then
click the **Copy** button in the top-right):
!!! example "getting_started.py"
+
```py
--8<-- "examples/getting_started.py"
```
+
## Basic Application
-First we need to create a new `cmd2` application. Create a new file `getting_started.py` with the
-following contents:
+The example defines `BasicApp` as a subclass of [cmd2.Cmd][]:
```py
-#!/usr/bin/env python
-"""A basic cmd2 application."""
-import cmd2
-
-
class BasicApp(cmd2.Cmd):
"""Cmd2 application to demonstrate many common features."""
+```
+At the end of the file, the application creates an instance of that class and passes control to the
+[cmd2.Cmd.cmdloop][] method:
-if __name__ == '__main__':
- import sys
+```py
+if __name__ == "__main__":
app = BasicApp()
sys.exit(app.cmdloop())
```
-We have a new class `BasicApp` which is a subclass of [cmd2.Cmd][]. When we tell Python to run our
-file like this:
+Run the example from the repository root:
```shell
-$ python getting_started.py
+$ uv run python examples/getting_started.py
```
-The application creates an instance of our class, and calls the [cmd2.Cmd.cmdloop][] method. This
-method accepts user input and runs commands based on that input. Because we subclassed `cmd2.Cmd`,
-our new app already has a bunch of built-in features.
-
-Congratulations, you have a working `cmd2` app. You can run it, and then type `quit` to exit.
+The application displays its intro banner and the custom `myapp>` prompt. Because `BasicApp`
+subclasses `cmd2.Cmd`, it also includes `cmd2`'s built-in commands and features. Type `quit` to
+exit.
-## Create a New Setting
+## Create a Setting
-Before we create our first command, we are going to add a new setting to this app. `cmd2` includes
-robust support for [Settings](../features/settings.md). You configure settings during object
-initialization, so we need to add an initializer to our class:
+`cmd2` includes robust support for [Settings](../features/settings.md). The example stores the color
+used by the `echo` command in `foreground_color`, then exposes that attribute as a runtime setting.
+The choices are the color values supported by [cmd2.Color][]:
```py
-def __init__(self):
- super().__init__()
-
- # Make maxrepeats settable at runtime
- self.maxrepeats = 3
- self.add_settable(cmd2.Settable('maxrepeats', int, 'max repetitions for speak command', self))
+# Color to output text in with echo command
+self.foreground_color = Color.CYAN.value
+
+# Make foreground_color settable at runtime
+fg_colors = [c.value for c in Color]
+self.add_settable(
+ cmd2.Settable(
+ "foreground_color",
+ str,
+ Text.assemble(
+ "Foreground color to use with echo command ",
+ "(Options: ",
+ Text("Green", Style(color=Color.GREEN)),
+ ", ",
+ Text("Red", Style(color=Color.RED)),
+ ", ",
+ Text("Blue", Style(color=Color.BLUE)),
+ ", ...)",
+ ),
+ self,
+ choices=fg_colors,
+ )
+)
```
-In that initializer, the first thing to do is to make sure we initialize `cmd2`. That's what the
-`super().__init__()` line does. Next create an attribute to hold the setting. Finally, call the
-[cmd2.Cmd.add_settable][] method with a new instance of a [cmd2.utils.Settable][] class. Now if you
-run the script, and enter the `set` command to see the settings, like this:
+The [cmd2.Cmd.add_settable][] method registers a [cmd2.utils.Settable][] that validates new values
+against `fg_colors`. Use the built-in `set` command to inspect or change it:
```shell
-$ python getting_started.py
-(Cmd) set
+myapp> set foreground_color
+myapp> set foreground_color red
```
-you will see our `maxrepeats` setting show up with its default value of `3`.
+The first command displays the current value. The second changes the color used by subsequent `echo`
+output.
+
+## Commands
-## Create A Command
+Methods whose names start with `do_` become commands. `BasicApp` defines three commands: `cat`,
+`echo`, and `intro`. Each one demonstrates a different way to process arguments.
-Now we will create our first command, called `speak`, which will echo back whatever we tell it to
-say. We are going to use an [argument processor](../features/argument_processing.md) so the `speak`
-command can shout and talk Pig Latin. We will also use some built in methods for
-[generating output](../features/generating_output.md). Add this code to `getting_started.py`, so
-that the `speak_parser` attribute and the `do_speak()` method are part of the `BasicApp()` class:
+### cat
+
+The `cat` command uses [cmd2.with_annotated][] to build its argument parser from type annotations.
+The `pathlib.Path` annotation enables path completion, and [cmd2.annotated.Option][] defines the
+optional `-n`/`--number` flag:
```py
-speak_parser = cmd2.Cmd2ArgumentParser()
-speak_parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay')
-speak_parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE')
-speak_parser.add_argument('-r', '--repeat', type=int, help='output [n] times')
-speak_parser.add_argument('words', nargs='+', help='words to say')
-
-@cmd2.with_argparser(speak_parser)
-def do_speak(self, args):
- """Repeats what you tell me to."""
- words = []
- for word in args.words:
- if args.piglatin:
- word = '%s%say' % (word[1:], word[0])
- if args.shout:
- word = word.upper()
- words.append(word)
- repetitions = args.repeat or 1
- for _ in range(min(repetitions, self.maxrepeats)):
- # .poutput handles newlines, and accommodates output redirection too
- self.poutput(' '.join(words))
+@cmd2.with_annotated
+def do_cat(
+ self,
+ path: pathlib.Path, # Required positional argument with type annotation, tab-completes filesystem paths automatically
+ numbered: Annotated[ # Optional flag argument with type annotation, default value, and help text
+ bool, Option("-n", "--number", help_text="prefix each line with its number")
+ ] = False,
+) -> None:
+ """Print a file's contents. `path` tab-completes filesystem paths automatically.
+
+ Try:
+ cat