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 @@ -

cmd2 : immersive interactive command line applications

+

cmd2: build feature-rich command-line applications in Python

[![Latest Version](https://img.shields.io/pypi/v/cmd2.svg?style=flat-square&label=latest%20stable%20version)](https://pypi.python.org/pypi/cmd2/) [![Tests](https://github.com/python-cmd2/cmd2/actions/workflows/tests.yml/badge.svg)](https://github.com/python-cmd2/cmd2/actions/workflows/tests.yml) @@ -7,77 +7,145 @@ Chat

- Developer's Toolbox • - Philosophy • + Quick start • + Why cmd2? • Installation • Documentation • - Tutorials • - Hello World • - Projects using cmd2 • + Projects using cmd2

-[![Screenshot](https://raw.githubusercontent.com/python-cmd2/cmd2/main/cmd2.png)](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`: -![system schema](https://raw.githubusercontent.com/python-cmd2/cmd2/main/.github/images/graph.drawio.png) +```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 +[![Animated demonstration of the cmd2 quick-start application](https://raw.githubusercontent.com/python-cmd2/cmd2/main/docs/assets/cmd2-readme-demo.gif)](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: + +![Animated demonstration of the getting started application](../assets/getting-started-demo.gif) + 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 # path completes files/dirs -- no completer wired + cat notes.txt + cat notes.txt -n # -n / --number, declared via Option metadata + cat notes.txt --no-number + """ + text = path.read_text() + lines = text.splitlines() + if numbered: + numbered_lines = [] + for index, line in enumerate(lines, start=1): + numbered_lines.append(f"{index}: {line}") + self.ppaged("\n".join(numbered_lines)) + else: + # Just print the contents using a pager + self.ppaged(path.read_text()) ``` -Up at the top of the script, you'll also need to add: +The command uses [cmd2.Cmd.ppaged][] so longer files can be viewed in a pager. Try it on the startup +script included with the example: -```py -import argparse +```shell +myapp> cat examples/.cmd2rc --number ``` -There's a bit to unpack here, so let's walk through it. We created `speak_parser`, which uses the -[argparse](https://docs.python.org/3/library/argparse.html) module from the Python standard library -to parse command line input from a user. So far, there is nothing specific to `cmd2`. +### echo -There is also a new method called `do_speak()`. In both -[cmd](https://docs.python.org/3/library/cmd.html) and `cmd2`, methods that start with `do_` become -new commands, so by defining this method we have created a command called `speak`. +The `echo` command demonstrates [cmd2.with_argparser][]. Its parser factory defines options for +uppercasing and repeating the output, plus one or more words to print: -Note the `cmd2.decorators.with_argparser` decorator on the `do_speak()` method. This decorator does -3 useful things for us: +```py +@staticmethod +def _build_echo_parser() -> cmd2.Cmd2ArgumentParser: + """Parser factory method for use with the echo command.""" + echo_parser = cmd2.Cmd2ArgumentParser(description="Command that echoes input.") + echo_parser.add_argument("-u", "--upper", action="store_true", help="uppercase the output") + echo_parser.add_argument("-r", "--repeat", type=int, default=1, help="output [n] times") + echo_parser.add_argument("words", nargs="+", help="words to print") + return echo_parser + + +@cmd2.with_argparser(_build_echo_parser) +def do_echo(self, args: argparse.Namespace) -> None: + """Command using with_argparser decorator for parsing arguments.""" + output_str = " ".join(args.words) + if args.upper: + output_str = output_str.upper() + + for _ in range(args.repeat): + self.poutput( + stylize( + output_str, + style=Style(color=self.foreground_color), + ) + ) +``` -1. It tells `cmd2` to process all input for the `speak` command using the argparser we defined. If - the user input doesn't meet the requirements defined by the argparser, then an error will be - displayed for the user. -1. It alters our `do_speak` method so that instead of receiving the raw user input as a parameter, - we receive the namespace from the argument parser. -1. It creates a help message for us based on the argparser. +The decorator parses the command line and passes an `argparse.Namespace` to `do_echo()`. It also +generates command help from the parser. The method styles the text with the configured foreground +color and writes it with [cmd2.Cmd.poutput][], which supports `cmd2` output redirection: -You can see in the body of the method how we use the namespace from the argparser (passed in as the -variable `args`). We build a list of words which we will output, honoring both the `--piglatin` and -`--shout` options. +```shell +myapp> echo --upper --repeat 2 hello cmd2 +HELLO CMD2 +HELLO CMD2 +myapp> help echo +``` -At the end of the method, we use our `maxrepeats` setting as an upper limit to the number of times -we will print the output. +### intro -The last thing you'll notice is that we used the `self.poutput()` method to display our output. -`poutput()` is a method provided by `cmd2`, which I strongly recommend you use any time you want to -[generate output](../features/generating_output.md). It provides the following benefits: +The `intro` command takes no arguments, so it demonstrates the raw [cmd2.Statement][] interface: -1. Allows the user to redirect output to a text file or pipe it to a shell process -1. Gracefully handles `BrokenPipeError` exceptions for redirected output -1. Honors the setting to [strip embedded ANSI sequences](../features/settings.md#allow_style) - (typically used for background and foreground colors) +```py +def do_intro(self, _: cmd2.Statement) -> None: + """Display the intro banner. -Go run the script again, and try out the `speak` command. Try typing `help speak`, and you will see -a lovely usage message describing the various options for the command. + This command uses raw statement parsing. In general, we strongly recommend against this approach. But since this + command effectively takes no arguments, it is safe to use raw statement parsing here. -With those few lines of code, we created a [command](../features/commands.md), used an -[Argument Processor](../features/argument_processing.md), added a nice -[help message](../features/help.md) for our users, and -[generated some output](../features/generating_output.md). + The & key is also used as a shortcut for this command, so you can also type & to display the intro banner. + """ + self.poutput(self.intro) +``` + +Typing `intro` displays the same banner that the application shows at startup. ## Shortcuts @@ -181,82 +225,24 @@ you can type this: (Cmd) !ls -al ``` -Let's add a shortcut for our `speak` command. Change the `__init__()` method so it looks like this: +The example adds `&` as a shortcut for the `intro` command: ```py -def __init__(self): - shortcuts = cmd2.DEFAULT_SHORTCUTS - shortcuts.update({'&': 'speak'}) - super().__init__(shortcuts=shortcuts) - - # Make maxrepeats settable at runtime - self.maxrepeats = 3 - self.add_settable(cmd2.Settable('maxrepeats', int, 'max repetitions for speak command', self)) +shortcuts = cmd2.DEFAULT_SHORTCUTS +shortcuts.update({"&": "intro"}) ``` -Shortcuts are passed to the `cmd2` initializer, and if you want the built-in shortcuts of `cmd2` you -have to pass them. These shortcuts are defined as a dictionary, with the key being the shortcut, and -the value containing the command. When using the default shortcuts and adding your own, it's a good -idea to use the `.update()` method to modify the dictionary. This way, if you add a shortcut that -happens to already be in the default set, yours will override, and you won't get any errors at -runtime. +The `shortcuts` dictionary is then passed to the `cmd2.Cmd` initializer with the rest of the +application configuration. Starting with [cmd2.DEFAULT_SHORTCUTS][] retains the built-in shortcuts; +calling `.update()` adds the new shortcut or overrides an existing one with the same key. -Run your app again, and type: +Use the built-in `shortcuts` command to list them, or type `&` to invoke `intro`: ```shell -(Cmd) shortcuts -``` - -to see the list of all the shortcuts, including the one for speak that we just created. - -## Multiline Commands - -Some use cases benefit from commands that span more than one line. For example, you might want the -ability for your user to type in a SQL command, which can often span lines and which are terminated -with a semicolon. Let's add a [multiline command](../features/multiline_commands.md) to our -application. First we'll create a new command called `orate`. This code shows both the definition of -our `speak` command, and the `orate` command: - -```py -@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)) - -# orate is a synonym for speak which takes multiline input -do_orate = do_speak +myapp> shortcuts +myapp> & ``` -With the new command created, we need to tell `cmd2` to treat that command as a multi-line command. -Modify the super initialization line to look like this: - -```py -super().__init__(multiline_commands=['orate'], shortcuts=shortcuts) -``` - -Now when you run the example, you can type something like this: - -```text -(Cmd) orate O for a Muse of fire, that would ascend -> The brightest heaven of invention, -> A kingdom for a stage, princes to act -> And monarchs to behold the swelling scene! ; -``` - -Notice the prompt changes to indicate that input is still ongoing. `cmd2` will continue prompting -for input until it sees an unquoted semicolon (the default multi-line command termination -character). - ## History `cmd2` tracks the history of the commands that users enter. As a developer, you don't need to do diff --git a/docs/features/annotated.md b/docs/features/annotated.md index 98d087a97..ec2f790aa 100644 --- a/docs/features/annotated.md +++ b/docs/features/annotated.md @@ -31,9 +31,10 @@ The two decorators are interchangeable -- here is the same command written both ```py parser = Cmd2ArgumentParser() - parser.add_argument('name', help='person to greet') - parser.add_argument('--count', type=int, default=1, help='repetitions') - parser.add_argument('--loud', action='store_true', help='shout') + parser.add_argument("name", help="person to greet") + parser.add_argument("--count", type=int, default=1, help="repetitions") + parser.add_argument("--loud", action="store_true", help="shout") + @with_argparser(parser) def do_greet(self, args): @@ -61,6 +62,7 @@ Underscores in parameter names are converted to dashes in the generated flag, so ```py from cmd2.annotated import with_annotated + class MyApp(cmd2.Cmd): @with_annotated def do_greet(self, name: str, count: int = 1, loud: bool = False): @@ -132,6 +134,7 @@ For finer control, use `typing.Annotated` with [Argument][cmd2.annotated.Argumen from typing import Annotated from cmd2.annotated import Argument, Option, with_annotated + class MyApp(cmd2.Cmd): def sport_choices(self) -> cmd2.Choices: return cmd2.Choices.from_values(["football", "basketball"]) @@ -139,15 +142,22 @@ class MyApp(cmd2.Cmd): @with_annotated def do_play( self, - sport: Annotated[str, Argument( - choices_provider=sport_choices, - help_text="Sport to play", - )], - venue: Annotated[str, Option( - "--venue", "-v", - help_text="Where to play", - completer=cmd2.Cmd.path_complete, - )] = "home", + sport: Annotated[ + str, + Argument( + choices_provider=sport_choices, + help_text="Sport to play", + ), + ], + venue: Annotated[ + str, + Option( + "--venue", + "-v", + help_text="Where to play", + completer=cmd2.Cmd.path_complete, + ), + ] = "home", ): self.poutput(f"Playing {sport} at {venue}") ``` @@ -173,6 +183,7 @@ import enum from typing import Annotated from cmd2.annotated import Argument, with_annotated + class Color(enum.Enum): red = "red" green = "green" @@ -183,6 +194,7 @@ class Color(enum.Enum): # map a special keyword onto a real member; return None to reject return cls.red if str(value).lower() == "auto" else None + class MyApp(cmd2.Cmd): @with_annotated def do_theme(self, choice: Annotated[Color, Argument(allow_unknown_entry=True)]) -> None: @@ -265,6 +277,7 @@ class UpperAction(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): setattr(namespace, self.dest, values.upper()) + @with_annotated def do_shout(self, name: Annotated[str, Option("--name", action=UpperAction)] = ""): self.poutput(name) @@ -298,6 +311,7 @@ forms are equivalent: # Signature default def do_x(self, name: Annotated[str, Option("--name")] = "HI"): ... + # Metadata default (same behaviour) def do_x(self, name: Annotated[str, Option("--name", default="HI")]): ... ``` @@ -359,11 +373,13 @@ import datetime from typing import Annotated from cmd2.annotated import Argument, Option, with_annotated + def parse_size(value: str) -> int: """Parse an integer with an optional K/M/G suffix.""" multiplier = {"K": 1_000, "M": 1_000_000, "G": 1_000_000_000}.get(value[-1:].upper(), 1) return int(value[:-1] if multiplier != 1 else value) * multiplier + class MyApp(cmd2.Cmd): @with_annotated def do_alloc(self, size: Annotated[int, Argument(converter=parse_size)]) -> None: @@ -382,9 +398,11 @@ instead infer `nargs` and split the input across several tokens: ```py from typing import Annotated, Any + def parse_intset(value: str) -> set[int]: return {int(piece) for piece in value.split(",")} + @with_annotated def do_select(self, idx: Annotated[Any, Option("--idx", converter=parse_intset)]) -> None: self.poutput(sorted(idx)) # `select --idx 1,3,5` -> [1, 3, 5] @@ -404,6 +422,7 @@ import os from typing import Annotated from cmd2.annotated import Argument, with_annotated + class MyApp(cmd2.Cmd): @with_annotated def do_tag(self, color: Annotated[Color, Argument(preprocess=str.lower)]) -> None: @@ -486,6 +505,7 @@ and `description` for a titled help section (omit them for an untitled group): ```py from cmd2.annotated import Group, with_annotated + class App(cmd2.Cmd): @with_annotated( description="Open a network connection.", @@ -509,6 +529,8 @@ def do_greet(self, name: str): :param name: who to greet """ self.poutput(f"hello {name}") + + # parser.description == "Greet someone by name." ``` @@ -532,9 +554,7 @@ choice reads as `[--json | --csv]` instead of expanding to `--json`/`--no-json` ```py @with_annotated( - mutually_exclusive_groups=( - Group("json", "csv", title="output", description="how to write results"), - ), + mutually_exclusive_groups=(Group("json", "csv", title="output", description="how to write results"),), ) def do_render( self, @@ -560,6 +580,7 @@ class Conn(ArgumentBlock): host: Annotated[str, Option("--host")] = "localhost" port: Annotated[int, Option("--port")] = 8080 + @with_annotated(groups=(Group("host", "port", title="connection"),)) def do_connect(self, conn: Conn) -> None: ... ``` @@ -611,6 +632,7 @@ def do_manage(self, *, cmd2_subcommand_func): if cmd2_subcommand_func: cmd2_subcommand_func() + @with_annotated(subcommand_to="manage", help="list projects") def manage_list(self): self.poutput("listing") @@ -626,6 +648,7 @@ def manage_project(self, *, cmd2_subcommand_func): if cmd2_subcommand_func: cmd2_subcommand_func() + @with_annotated(subcommand_to="manage project", help="add a project") def manage_project_add(self, name: str): self.poutput(f"added {name}") @@ -770,9 +793,11 @@ decorator, it skips the first parameter as the method receiver (`self`/`cls`). ```py from cmd2.annotated import build_parser_from_function + def greet(self, name: str, count: int = 1): """Greet someone.""" + parser = build_parser_from_function(greet) namespace = parser.parse_args(["Alice", "--count", "3"]) # namespace.name == "Alice", namespace.count == 3 diff --git a/docs/features/argument_processing.md b/docs/features/argument_processing.md index aeadf7c8d..145965b15 100644 --- a/docs/features/argument_processing.md +++ b/docs/features/argument_processing.md @@ -81,17 +81,18 @@ Here's what it looks like: from cmd2 import Cmd2ArgumentParser, with_argparser argparser = Cmd2ArgumentParser() -argparser.add_argument('-p', '--piglatin', action='store_true', help='atinLay') -argparser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE') -argparser.add_argument('-r', '--repeat', type=int, help='output [n] times') -argparser.add_argument('word', nargs='?', help='word to say') +argparser.add_argument("-p", "--piglatin", action="store_true", help="atinLay") +argparser.add_argument("-s", "--shout", action="store_true", help="N00B EMULATION MODE") +argparser.add_argument("-r", "--repeat", type=int, help="output [n] times") +argparser.add_argument("word", nargs="?", help="word to say") + @with_argparser(argparser) def do_speak(self, opts): """Repeats what you tell me to.""" arg = opts.word if opts.piglatin: - arg = '%s%say' % (arg[1:], arg[0]) + arg = "%s%say" % (arg[1:], arg[0]) if opts.shout: arg = arg.upper() repetitions = opts.repeat or 1 @@ -125,13 +126,15 @@ With this code: from cmd2 import Cmd2ArgumentParser, with_argparser argparser = Cmd2ArgumentParser() -argparser.add_argument('tag', help='tag') -argparser.add_argument('content', nargs='+', help='content to surround with tag') +argparser.add_argument("tag", help="tag") +argparser.add_argument("content", nargs="+", help="content to surround with tag") + + @with_argparser(argparser) def do_tag(self, args): """Create an HTML tag""" - self.stdout.write('<{0}>{1}'.format(args.tag, ' '.join(args.content))) - self.stdout.write('\n') + self.stdout.write("<{0}>{1}".format(args.tag, " ".join(args.content))) + self.stdout.write("\n") ``` the `help tag` command displays: @@ -155,13 +158,15 @@ leave the docstring on your method blank: ```py from cmd2 import Cmd2ArgumentParser, with_argparser -argparser = Cmd2ArgumentParser(description='create an HTML tag') -argparser.add_argument('tag', help='tag') -argparser.add_argument('content', nargs='+', help='content to surround with tag') +argparser = Cmd2ArgumentParser(description="create an HTML tag") +argparser.add_argument("tag", help="tag") +argparser.add_argument("content", nargs="+", help="content to surround with tag") + + @with_argparser(argparser) def do_tag(self, args): - self.stdout.write('<{0}>{1}'.format(args.tag, ' '.join(args.content))) - self.stdout.write('\n') + self.stdout.write("<{0}>{1}".format(args.tag, " ".join(args.content))) + self.stdout.write("\n") ``` Now when the user enters `help tag` they see: @@ -184,14 +189,17 @@ To add additional text to the end of the generated help message, use the `epilog ```py from cmd2 import Cmd2ArgumentParser, with_argparser -argparser = Cmd2ArgumentParser(description='create an HTML tag', - epilog='This command cannot generate tags with no content, like
.') -argparser.add_argument('tag', help='tag') -argparser.add_argument('content', nargs='+', help='content to surround with tag') +argparser = Cmd2ArgumentParser( + description="create an HTML tag", epilog="This command cannot generate tags with no content, like
." +) +argparser.add_argument("tag", help="tag") +argparser.add_argument("content", nargs="+", help="content to surround with tag") + + @with_argparser(argparser) def do_tag(self, args): - self.stdout.write('<{0}>{1}'.format(args.tag, ' '.join(args.content))) - self.stdout.write('\n') + self.stdout.write("<{0}>{1}".format(args.tag, " ".join(args.content))) + self.stdout.write("\n") ``` Which yields: @@ -248,7 +256,7 @@ additional attributes that may be helpful, including `arg_list` and `argv`: ```py class CmdLineApp(cmd2.Cmd): - """ Example cmd2 application. """ + """Example cmd2 application.""" def do_say(self, statement): # statement contains a string @@ -276,8 +284,9 @@ methods that should receive an argument list instead of a string: ```py from cmd2 import with_argument_list + class CmdLineApp(cmd2.Cmd): - """ Example cmd2 application. """ + """Example cmd2 application.""" def do_say(self, cmdline): # cmdline contains a string @@ -300,8 +309,8 @@ Here's what it looks like: from cmd2 import Cmd2ArgumentParser, with_argparser dir_parser = Cmd2ArgumentParser() -dir_parser.add_argument('-l', '--long', action='store_true', - help="display in long format with one item per line") +dir_parser.add_argument("-l", "--long", action="store_true", help="display in long format with one item per line") + @with_argparser(dir_parser, with_unknown_args=True) def do_dir(self, args, unknown): @@ -309,8 +318,8 @@ def do_dir(self, args, unknown): # No arguments for this command if unknown: self.perror("dir does not take any positional arguments:") - self.do_help('dir') - self.last_result = 'Bad arguments' + self.do_help("dir") + self.last_result = "Bad arguments" return # Get the contents as a list diff --git a/docs/features/async_commands.md b/docs/features/async_commands.md index c8430d4ae..0cc5915ba 100644 --- a/docs/features/async_commands.md +++ b/docs/features/async_commands.md @@ -32,6 +32,7 @@ import cmd2 _event_loop = None _event_lock = threading.Lock() + def _get_event_loop() -> asyncio.AbstractEventLoop: """Get or create the background event loop.""" global _event_loop @@ -42,22 +43,26 @@ def _get_event_loop() -> asyncio.AbstractEventLoop: _event_loop = asyncio.new_event_loop() thread = threading.Thread( target=_event_loop.run_forever, - name='Async Runner', + name="Async Runner", daemon=True, ) thread.start() return _event_loop + def with_async_loop(func: Callable[..., Any]) -> Callable[..., Any]: """Decorator to run a command method asynchronously in a background thread.""" + @functools.wraps(func) def wrapper(self: cmd2.Cmd, *args: Any, **kwargs: Any) -> Any: loop = _get_event_loop() coro = func(self, *args, **kwargs) future = asyncio.run_coroutine_threadsafe(coro, loop) return future.result() + return wrapper + class AsyncApp(cmd2.Cmd): @with_async_loop async def do_my_async(self, _: cmd2.Statement) -> None: diff --git a/docs/features/builtin_commands.md b/docs/features/builtin_commands.md index 8957a1f0f..f2751f8ae 100644 --- a/docs/features/builtin_commands.md +++ b/docs/features/builtin_commands.md @@ -125,5 +125,5 @@ from your application: class NoShellApp(cmd2.Cmd): """A simple cmd2 application.""" - delattr(cmd2.Cmd, 'do_shell') + delattr(cmd2.Cmd, "do_shell") ``` diff --git a/docs/features/commands.md b/docs/features/commands.md index 21a7864a9..df46f1f1a 100644 --- a/docs/features/commands.md +++ b/docs/features/commands.md @@ -15,6 +15,7 @@ The simplest `cmd2` application looks like this: ```py #!/usr/bin/env python """A simple cmd2 application.""" + import cmd2 @@ -22,8 +23,9 @@ class App(cmd2.Cmd): """A simple cmd2 application.""" -if __name__ == '__main__': +if __name__ == "__main__": import sys + c = App() sys.exit(c.cmdloop()) ``` @@ -142,20 +144,24 @@ You can use this capability to easily return your own values to the operating sy ```py #!/usr/bin/env python """A simple cmd2 application.""" + import cmd2 class App(cmd2.Cmd): """A simple cmd2 application.""" + def do_bail(self, line): """Exit the application""" self.perror("fatal error, exiting") self.exit_code = 2 return True -if __name__ == '__main__': + +if __name__ == "__main__": import sys + c = App() sys.exit(c.cmdloop()) ``` diff --git a/docs/features/disable_commands.md b/docs/features/disable_commands.md index 1a22f6541..37e1dae50 100644 --- a/docs/features/disable_commands.md +++ b/docs/features/disable_commands.md @@ -40,22 +40,23 @@ the `hidden_commands` list: ```py class HiddenCommands(cmd2.Cmd): """An app which demonstrates how to hide a command""" + def __init__(self): super().__init__() - self.hidden_commands.append('py') + self.hidden_commands.append("py") ``` As shown above, you would typically do this as part of initializing your application. If you decide you want to unhide a command later in the execution of your application, you can by doing: ```py -self.hidden_commands = [cmd for cmd in self.hidden_commands if cmd != 'py'] +self.hidden_commands = [cmd for cmd in self.hidden_commands if cmd != "py"] ``` You might be thinking that the list comprehension is overkill and you'd rather do something like: ```py -self.hidden_commands.remove('py') +self.hidden_commands.remove("py") ``` You may be right, but `remove()` will raise a `ValueError` if `py` isn't in the list, and it will @@ -75,16 +76,16 @@ class DisabledCommands(cmd2.Cmd): """An application which disables and enables commands""" def do_lock(self, line): - self.disable_command('open', "you can't open the door because it is locked") - self.poutput('the door is locked') + self.disable_command("open", "you can't open the door because it is locked") + self.poutput("the door is locked") def do_unlock(self, line): - self.enable_command('open') - self.poutput('the door is unlocked') + self.enable_command("open") + self.poutput("the door is unlocked") def do_open(self, line): """open the door""" - self.poutput('opening the door') + self.poutput("opening the door") ``` This method has the added benefit of removing disabled commands from the help menu. But, this method @@ -99,14 +100,14 @@ all the commands in a category with a single method call. Say you have created a commands called "Server Information". You can disable all commands in that category: ```py -not_connected_msg = 'You must be connected to use this command' -self.disable_category('Server Information', not_connected_msg) +not_connected_msg = "You must be connected to use this command" +self.disable_category("Server Information", not_connected_msg) ``` Similarly, you can re-enable all the commands in a category: ```py -self.enable_category('Server Information') +self.enable_category("Server Information") ``` See [help_categories.py](https://github.com/python-cmd2/cmd2/blob/main/examples/help_categories.py) diff --git a/docs/features/embedded_python_shells.md b/docs/features/embedded_python_shells.md index 0a4b60f42..8d693a3fe 100644 --- a/docs/features/embedded_python_shells.md +++ b/docs/features/embedded_python_shells.md @@ -7,6 +7,8 @@ will be present and run an interactive Python shell: ```py from cmd2 import Cmd + + class App(Cmd): def __init__(self): Cmd.__init__(self, include_py=True) @@ -59,6 +61,8 @@ interactive IPython shell: ```py from cmd2 import Cmd + + class App(Cmd): def __init__(self): Cmd.__init__(self, include_ipy=True) diff --git a/docs/features/help.md b/docs/features/help.md index 11150ee46..8dee6fba5 100644 --- a/docs/features/help.md +++ b/docs/features/help.md @@ -70,7 +70,7 @@ By default, `cmd2.Cmd` defines its `DEFAULT_CATEGORY` as `"Cmd2 Commands"`. ```py class MyApp(cmd2.Cmd): # All commands defined in this class will be grouped here - DEFAULT_CATEGORY = 'Application Commands' + DEFAULT_CATEGORY = "Application Commands" def do_echo(self, arg): """Echo command""" @@ -81,11 +81,11 @@ This also works for [Command Sets](./modular_commands.md): ```py class Plugin(cmd2.CommandSet): - DEFAULT_CATEGORY = 'Plugin Commands' + DEFAULT_CATEGORY = "Plugin Commands" def do_plugin_cmd(self, _): """Plugin command""" - self._cmd.poutput('Plugin') + self._cmd.poutput("Plugin") ``` When using inheritance, `cmd2` uses the `DEFAULT_CATEGORY` of the class where the command was @@ -98,10 +98,10 @@ If you want to rename the built-in category itself, you can do so by reassigning ```py class MyApp(cmd2.Cmd): # Rename the framework's built-in category - cmd2.Cmd.DEFAULT_CATEGORY = 'Shell Commands' + cmd2.Cmd.DEFAULT_CATEGORY = "Shell Commands" # Set the category for your own commands - DEFAULT_CATEGORY = 'Application Commands' + DEFAULT_CATEGORY = "Application Commands" ``` For a complete demonstration of this functionality, see the @@ -117,10 +117,10 @@ precedence over the `DEFAULT_CATEGORY`. Using the `@with_category` decorator: ```py -@with_category('Connecting') +@with_category("Connecting") def do_which(self, _): """Which command""" - self.poutput('Which') + self.poutput("Which") ``` Using the `categorize()` function: @@ -130,7 +130,8 @@ You can call with a single function: ```py def do_connect(self, _): """Connect command""" - self.poutput('Connect') + self.poutput("Connect") + # Tag the above command functions under the category Connecting categorize(do_connect, CMD_CAT_CONNECTING) @@ -141,20 +142,21 @@ Or with an Iterable container of functions: ```py def do_undeploy(self, _): """Undeploy command""" - self.poutput('Undeploy') + self.poutput("Undeploy") + def do_stop(self, _): """Stop command""" - self.poutput('Stop') + self.poutput("Stop") + def do_findleakers(self, _): """Find Leakers command""" - self.poutput('Find Leakers') + self.poutput("Find Leakers") + # Tag the above command functions under the category Application Management -categorize((do_undeploy, - do_stop, - do_findleakers), CMD_CAT_APP_MGMT) +categorize((do_undeploy, do_stop, do_findleakers), CMD_CAT_APP_MGMT) ``` The `help` command also has a verbose option (`help -v` or `help --verbose`) that combines the help diff --git a/docs/features/hooks.md b/docs/features/hooks.md index 6fa59bb38..a34f70b98 100644 --- a/docs/features/hooks.md +++ b/docs/features/hooks.md @@ -172,8 +172,8 @@ simple example which shows the proper technique: ```py def myhookmethod(self, params: cmd2.plugin.PostparsingData) -> cmd2.plugin.PostparsingData: - if not '|' in params.statement.raw: - newinput = params.statement.raw + ' | less' + if not "|" in params.statement.raw: + newinput = params.statement.raw + " | less" params.statement = self.statement_parser.parse(newinput) return params ``` diff --git a/docs/features/initialization.md b/docs/features/initialization.md index ea93b2d78..fc084ff3d 100644 --- a/docs/features/initialization.md +++ b/docs/features/initialization.md @@ -4,9 +4,11 @@ Here is a basic example `cmd2` application which demonstrates many capabilities !!! example "examples/getting_started.py" + ```py --8<-- "examples/getting_started.py" ``` + ## Cmd class initializer diff --git a/docs/features/misc.md b/docs/features/misc.md index 7e5fa9628..a8fe7f723 100644 --- a/docs/features/misc.md +++ b/docs/features/misc.md @@ -20,10 +20,10 @@ Presents numbered options to user, as bash `select`. ```py def do_eat(self, arg): - sauce = self.select('sweet salty', 'Sauce? ') - result = '{food} with {sauce} sauce, yum!' + sauce = self.select("sweet salty", "Sauce? ") + result = "{food} with {sauce} sauce, yum!" result = result.format(food=arg, sauce=sauce) - self.stdout.write(result + '\n') + self.stdout.write(result + "\n") ``` ```text diff --git a/docs/features/modular_commands.md b/docs/features/modular_commands.md index dbd9c5d86..28c775eef 100644 --- a/docs/features/modular_commands.md +++ b/docs/features/modular_commands.md @@ -52,30 +52,33 @@ initializer arguments, see [Manual CommandSet Construction](#manual-commandset-c import cmd2 from cmd2 import CommandSet + class ExampleApp(cmd2.Cmd): """ CommandSets are automatically loaded. Nothing needs to be done. """ + def __init__(self, *args, **kwargs): super().__init__(*args, auto_load_commands=True, **kwargs) def do_something(self, arg): """Something Command.""" - self.poutput('this is the something command') + self.poutput("this is the something command") + class AutoLoadCommandSet(CommandSet[ExampleApp]): - DEFAULT_CATEGORY = 'My Category' + DEFAULT_CATEGORY = "My Category" def __init__(self): super().__init__() def do_hello(self, _: cmd2.Statement): """Hello Command.""" - self._cmd.poutput('Hello') + self._cmd.poutput("Hello") def do_world(self, _: cmd2.Statement): """World Command.""" - self._cmd.poutput('World') + self._cmd.poutput("World") ``` ### Manual CommandSet Construction @@ -87,10 +90,12 @@ construct CommandSets and pass in the initializer to Cmd2. import cmd2 from cmd2 import CommandSet + class ExampleApp(cmd2.Cmd): """ CommandSets with initializer parameters are provided in the initializer """ + def __init__(self, *args, **kwargs): # gotta have this or neither the plugin or cmd2 will initialize super().__init__(*args, auto_load_commands=True, **kwargs) @@ -98,10 +103,11 @@ class ExampleApp(cmd2.Cmd): def do_something(self, arg): """Something Command.""" self.last_result = 5 - self.poutput('this is the something command') + self.poutput("this is the something command") + class CustomInitCommandSet(CommandSet[ExampleApp]): - DEFAULT_CATEGORY = 'My Category' + DEFAULT_CATEGORY = "My Category" def __init__(self, arg1, arg2): super().__init__() @@ -111,11 +117,11 @@ class CustomInitCommandSet(CommandSet[ExampleApp]): def do_show_arg1(self, _: cmd2.Statement): """Show Arg 1.""" - self._cmd.poutput(f'Arg1: {self._arg1}') + self._cmd.poutput(f"Arg1: {self._arg1}") def do_show_arg2(self, _: cmd2.Statement): """Show Arg 2.""" - self._cmd.poutput(f'Arg2: {self._arg2}') + self._cmd.poutput(f"Arg2: {self._arg2}") def main(): @@ -139,11 +145,13 @@ validation when accessing custom attributes or methods on your main application import cmd2 from cmd2 import CommandSet + class MyApp(cmd2.Cmd): def __init__(self): super().__init__() self.custom_state = "Some important data" + class MyCommands(CommandSet[MyApp]): def do_check_state(self, _: cmd2.Statement): # Type checkers know self._cmd is an instance of MyApp @@ -165,33 +173,33 @@ from cmd2 import CommandSet, with_argparser, with_category class LoadableFruits(CommandSet["ExampleApp"]): - DEFAULT_CATEGORY = 'Fruits' + DEFAULT_CATEGORY = "Fruits" def __init__(self): super().__init__() def do_apple(self, _: cmd2.Statement): """Apple Command.""" - self._cmd.poutput('Apple') + self._cmd.poutput("Apple") def do_banana(self, _: cmd2.Statement): """Banana Command.""" - self._cmd.poutput('Banana') + self._cmd.poutput("Banana") class LoadableVegetables(CommandSet["ExampleApp"]): - DEFAULT_CATEGORY = 'Vegetables' + DEFAULT_CATEGORY = "Vegetables" def __init__(self): super().__init__() def do_arugula(self, _: cmd2.Statement): """Arugula Command.""" - self._cmd.poutput('Arugula') + self._cmd.poutput("Arugula") def do_bokchoy(self, _: cmd2.Statement): """Bok Choy Command.""" - self._cmd.poutput('Bok Choy') + self._cmd.poutput("Bok Choy") class ExampleApp(cmd2.Cmd): @@ -207,39 +215,39 @@ class ExampleApp(cmd2.Cmd): self._vegetables = LoadableVegetables() load_parser = cmd2.Cmd2ArgumentParser() - load_parser.add_argument('cmds', choices=['fruits', 'vegetables']) + load_parser.add_argument("cmds", choices=["fruits", "vegetables"]) @with_argparser(load_parser) - @with_category('Command Loading') + @with_category("Command Loading") def do_load(self, ns: argparse.Namespace): """Load Command.""" - if ns.cmds == 'fruits': + if ns.cmds == "fruits": try: self.register_command_set(self._fruits) - self.poutput('Fruits loaded') + self.poutput("Fruits loaded") except ValueError: - self.poutput('Fruits already loaded') + self.poutput("Fruits already loaded") - if ns.cmds == 'vegetables': + if ns.cmds == "vegetables": try: self.register_command_set(self._vegetables) - self.poutput('Vegetables loaded') + self.poutput("Vegetables loaded") except ValueError: - self.poutput('Vegetables already loaded') + self.poutput("Vegetables already loaded") @with_argparser(load_parser) def do_unload(self, ns: argparse.Namespace): """Unload Command.""" - if ns.cmds == 'fruits': + if ns.cmds == "fruits": self.unregister_command_set(self._fruits) - self.poutput('Fruits unloaded') + self.poutput("Fruits unloaded") - if ns.cmds == 'vegetables': + if ns.cmds == "vegetables": self.unregister_command_set(self._vegetables) - self.poutput('Vegetables unloaded') + self.poutput("Vegetables unloaded") -if __name__ == '__main__': +if __name__ == "__main__": app = ExampleApp() app.cmdloop() ``` @@ -296,41 +304,41 @@ from cmd2 import CommandSet, with_argparser, with_category class LoadableFruits(CommandSet["ExampleApp"]): - DEFAULT_CATEGORY = 'Fruits' + DEFAULT_CATEGORY = "Fruits" def __init__(self): super().__init__() def do_apple(self, _: cmd2.Statement): """Apple Command.""" - self._cmd.poutput('Apple') + self._cmd.poutput("Apple") banana_parser = cmd2.Cmd2ArgumentParser() - banana_parser.add_argument('direction', choices=['discs', 'lengthwise']) + banana_parser.add_argument("direction", choices=["discs", "lengthwise"]) - @cmd2.as_subcommand_to('cut', 'banana', banana_parser) + @cmd2.as_subcommand_to("cut", "banana", banana_parser) def cut_banana(self, ns: argparse.Namespace): """Cut banana""" - self._cmd.poutput('cutting banana: ' + ns.direction) + self._cmd.poutput("cutting banana: " + ns.direction) class LoadableVegetables(CommandSet["ExampleApp"]): - DEFAULT_CATEGORY = 'Vegetables' + DEFAULT_CATEGORY = "Vegetables" def __init__(self): super().__init__() def do_arugula(self, _: cmd2.Statement): """Arugula Command.""" - self._cmd.poutput('Arugula') + self._cmd.poutput("Arugula") bokchoy_parser = cmd2.Cmd2ArgumentParser() - bokchoy_parser.add_argument('style', choices=['quartered', 'diced']) + bokchoy_parser.add_argument("style", choices=["quartered", "diced"]) - @cmd2.as_subcommand_to('cut', 'bokchoy', bokchoy_parser) + @cmd2.as_subcommand_to("cut", "bokchoy", bokchoy_parser) def cut_bokchoy(self, _: argparse.Namespace): """Cut bok choy.""" - self._cmd.poutput('Bok Choy') + self._cmd.poutput("Bok Choy") class ExampleApp(cmd2.Cmd): @@ -346,36 +354,36 @@ class ExampleApp(cmd2.Cmd): self._vegetables = LoadableVegetables() load_parser = cmd2.Cmd2ArgumentParser() - load_parser.add_argument('cmds', choices=['fruits', 'vegetables']) + load_parser.add_argument("cmds", choices=["fruits", "vegetables"]) @with_argparser(load_parser) - @with_category('Command Loading') + @with_category("Command Loading") def do_load(self, ns: argparse.Namespace): """Load Command.""" - if ns.cmds == 'fruits': + if ns.cmds == "fruits": try: self.register_command_set(self._fruits) - self.poutput('Fruits loaded') + self.poutput("Fruits loaded") except ValueError: - self.poutput('Fruits already loaded') + self.poutput("Fruits already loaded") - if ns.cmds == 'vegetables': + if ns.cmds == "vegetables": try: self.register_command_set(self._vegetables) - self.poutput('Vegetables loaded') + self.poutput("Vegetables loaded") except ValueError: - self.poutput('Vegetables already loaded') + self.poutput("Vegetables already loaded") @with_argparser(load_parser) def do_unload(self, ns: argparse.Namespace): """Unload Command.""" - if ns.cmds == 'fruits': + if ns.cmds == "fruits": self.unregister_command_set(self._fruits) - self.poutput('Fruits unloaded') + self.poutput("Fruits unloaded") - if ns.cmds == 'vegetables': + if ns.cmds == "vegetables": self.unregister_command_set(self._vegetables) - self.poutput('Vegetables unloaded') + self.poutput("Vegetables unloaded") cut_parser = cmd2.Cmd2ArgumentParser() cut_parser.add_subparsers(title="item", help="item to cut", metavar="ITEM", required=True) @@ -387,8 +395,7 @@ class ExampleApp(cmd2.Cmd): ns.cmd2_subcommand_func(ns) - -if __name__ == '__main__': +if __name__ == "__main__": app = ExampleApp() app.cmdloop() ``` diff --git a/docs/features/plugins.md b/docs/features/plugins.md index 62fc9ab22..0c9bec76d 100644 --- a/docs/features/plugins.md +++ b/docs/features/plugins.md @@ -28,8 +28,10 @@ and an example app which uses the plugin: import cmd2 import cmd2_myplugin + class Example(cmd2_myplugin.MyPlugin, cmd2.Cmd): """An class to show how to use a plugin""" + def __init__(self, *args, **kwargs): # code placed here runs before cmd2.Cmd or # any plugins initialize @@ -75,8 +77,8 @@ class MyPlugin: # code placed here runs before cmd2.Cmd initializes super().__init__(*args, **kwargs) # code placed here runs after cmd2.Cmd initializes - self.mysetting = 'somevalue' - self.add_settable(cmd2.Settable('mysetting', str, 'short help message for mysetting', self)) + self.mysetting = "somevalue" + self.add_settable(cmd2.Settable("mysetting", str, "short help message for mysetting", self)) ``` You can hide settings from the user by calling [cmd2.Cmd.remove_settable][]. See @@ -119,7 +121,7 @@ class MyPlugin: def cmd2_myplugin_postparsing_hook(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.PostparsingData: """Method to be called after parsing user input, but before running the command""" - self.poutput('in postparsing_hook') + self.poutput("in postparsing_hook") return data ``` diff --git a/docs/features/prompt.md b/docs/features/prompt.md index 93fb6b495..0f8a09082 100644 --- a/docs/features/prompt.md +++ b/docs/features/prompt.md @@ -79,14 +79,15 @@ You can customize the content of the toolbar by overriding the [cmd2.Cmd.get_bot method. ```py - from prompt_toolkit.formatted_text import AnyFormattedText - - def get_bottom_toolbar(self) -> AnyFormattedText: - return [ - ('ansigreen', 'My Application Name'), - ('', ' - '), - ('ansiyellow', 'Current Status: Idle'), - ] +from prompt_toolkit.formatted_text import AnyFormattedText + + +def get_bottom_toolbar(self) -> AnyFormattedText: + return [ + ("ansigreen", "My Application Name"), + ("", " - "), + ("ansiyellow", "Current Status: Idle"), + ] ``` ### Refreshing the Toolbar diff --git a/docs/features/redirection.md b/docs/features/redirection.md index 8b2ab68e4..27a238caa 100644 --- a/docs/features/redirection.md +++ b/docs/features/redirection.md @@ -49,6 +49,8 @@ output of that to a file called _output.txt_. ```py from cmd2 import Cmd + + class App(Cmd): def __init__(self): super().__init__(allow_redirection=False) diff --git a/docs/features/scripting.md b/docs/features/scripting.md index 429fba210..531027afc 100644 --- a/docs/features/scripting.md +++ b/docs/features/scripting.md @@ -154,26 +154,27 @@ As a baseline, let's start with the following `cmd2` application called `FirstAp ```py #!/usr/bin/env python """A simple cmd2 application.""" + import cmd2 class FirstApp(cmd2.Cmd): """A simple cmd2 application.""" + def __init__(self): shortcuts = cmd2.DEFAULT_SHORTCUTS - shortcuts.update({'&': 'speak'}) + shortcuts.update({"&": "speak"}) super().__init__(shortcuts=shortcuts) # Make maxrepeats settable at runtime self.maxrepeats = 3 - self.add_settable(cmd2.Settable('maxrepeats', int, 'max repetitions for speak command', self)) - + self.add_settable(cmd2.Settable("maxrepeats", int, "max repetitions for speak command", self)) 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') + 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): @@ -181,17 +182,19 @@ class FirstApp(cmd2.Cmd): words = [] for word in args.words: if args.piglatin: - word = '%s%say' % (word[1:], word[0]) + 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)) + self.poutput(" ".join(words)) -if __name__ == '__main__': + +if __name__ == "__main__": import sys + c = FirstApp() sys.exit(c.cmdloop()) ``` @@ -226,7 +229,7 @@ Even though this is a fully qualified `cmd2` error, the pyscript must check for perform error checking.: ```py -app('speak') +app("speak") print("Working") ``` @@ -238,7 +241,7 @@ You should notice that no error message is printed. Let's utilize the `CommandRe inspect the actual returned data.: ```py -result = app('speak') +result = app("speak") print(result) ``` @@ -248,7 +251,7 @@ print(result) Now we can see that there has been an error. Let's rewrite the script to perform error checking.: ```py -result = app('speak') +result = app("speak") if not result: print(result.stderr) @@ -262,7 +265,7 @@ In Python development, it is good practice to fail fast after user input.: ```py import sys -result = app('speak TRUTH!!') +result = app("speak TRUTH!!") if not result: print("Something went wrong") @@ -280,8 +283,8 @@ the `CommandResult`: ```py import sys -#Syntax error -result = app('speak TRUTH!!!') +# Syntax error +result = app("speak TRUTH!!!") if not result: print("Something went wrong") sys.exit() @@ -317,8 +320,8 @@ In the following command example we return a list containing directory elements. ```py dir_parser = cmd2.Cmd2ArgumentParser() -dir_parser.add_argument('-l', '--long', action='store_true', - help="display in long format with one item per line") +dir_parser.add_argument("-l", "--long", action="store_true", help="display in long format with one item per line") + @cmd2.with_argparser(dir_parser, with_unknown_args=True) def do_dir(self, args, unknown): @@ -326,15 +329,15 @@ def do_dir(self, args, unknown): # No arguments for this command if unknown: self.perror("dir does not take any positional arguments:") - self.do_help('dir') + self.do_help("dir") return # Get the contents as a list contents = os.listdir(self.cwd) for f in contents: - self.poutput(f'{f}') - self.poutput('') + self.poutput(f"{f}") + self.poutput("") self.last_result = contents ``` @@ -342,7 +345,7 @@ def do_dir(self, args, unknown): The following script retrieves the array contents.: ```py -result = app('dir') +result = app("dir") print(result.data) ``` @@ -373,6 +376,7 @@ app.py: ```py #!/usr/bin/env python """A simple cmd2 application.""" + import sys from dataclasses import dataclass from random import choice, randint @@ -419,9 +423,7 @@ class FirstApp(cmd2.Cmd): status = self._start_build(args.name) self._status_cache[args.name] = status - self.poutput( - f"Build {args.name.upper()} successfully started with id : {status.id}" - ) + self.poutput(f"Build {args.name.upper()} successfully started with id : {status.id}") self.last_result = status status_parser = cmd2.Cmd2ArgumentParser() @@ -451,11 +453,11 @@ import sys import time # start build -result = app('build tower') +result = app("build tower") # If there was an error then exit if not result: - print('Build failed') + print("Build failed") sys.exit() # This is a BuildStatus dataclass object @@ -465,11 +467,10 @@ print(f"Build {build.name} : {build.status}") # Poll status while True: - # Perform status check - result = app('status tower') + result = app("status tower") - #error checking + # error checking if not result: print("Unable to determine status") break @@ -477,7 +478,7 @@ while True: build_status = result.data # If the status shows complete then the script is done - if build_status.status in ['finished', 'canceled']: + if build_status.status in ["finished", "canceled"]: print(f"Build {build.name} has completed") break diff --git a/docs/features/settings.md b/docs/features/settings.md index 568356686..ed663ef45 100644 --- a/docs/features/settings.md +++ b/docs/features/settings.md @@ -91,9 +91,11 @@ Here's an example, from !!! example "examples/environment.py" + ```py --8<-- "examples/environment.py" ``` + If you want to be notified when a setting changes (as we do above), then be sure to supply a method to the `onchange_cb` parameter of the `cmd2.utils.Settable`. This method will be called after the diff --git a/docs/features/shortcuts_aliases_macros.md b/docs/features/shortcuts_aliases_macros.md index cd14dce29..bf88b9fb3 100644 --- a/docs/features/shortcuts_aliases_macros.md +++ b/docs/features/shortcuts_aliases_macros.md @@ -19,7 +19,7 @@ format `{'shortcut': 'command_name'}` where you omit `do_` from the command name class App(Cmd): def __init__(self): shortcuts = cmd2.DEFAULT_SHORTCUTS - shortcuts.update({'*': 'sneeze', '~': 'squirm'}) + shortcuts.update({"*": "sneeze", "~": "squirm"}) cmd2.Cmd.__init__(self, shortcuts=shortcuts) ``` diff --git a/docs/features/startup_commands.md b/docs/features/startup_commands.md index fc44d505c..008efde57 100644 --- a/docs/features/startup_commands.md +++ b/docs/features/startup_commands.md @@ -32,6 +32,8 @@ application and easily used in automation. ```py from cmd2 import Cmd + + class App(Cmd): def __init__(self): super().__init__(allow_cli_args=False) @@ -56,7 +58,7 @@ You can execute commands from an initialization script by passing a file path to ```py class StartupApp(cmd2.Cmd): def __init__(self): - cmd2.Cmd.__init__(self, startup_script='.cmd2rc') + cmd2.Cmd.__init__(self, startup_script=".cmd2rc") ``` This text file should contain a [Command Script](./scripting.md#command-scripts). See the @@ -66,7 +68,7 @@ example for a demonstration. You can silence a startup script's output by setting `silence_startup_script` to True: ```py -cmd2.Cmd.__init__(self, startup_script='.cmd2rc', silence_startup_script=True) +cmd2.Cmd.__init__(self, startup_script=".cmd2rc", silence_startup_script=True) ``` !!! warning diff --git a/docs/mixins/mixin_template.md b/docs/mixins/mixin_template.md index 10aaa0b06..749717260 100644 --- a/docs/mixins/mixin_template.md +++ b/docs/mixins/mixin_template.md @@ -89,7 +89,6 @@ Your mixin can add user visible commands. You do it the same way in a mixin that ```python class MyMixin: - def do_say(self, statement): """Simple say command""" self.poutput(statement) @@ -108,8 +107,8 @@ class MyMixin: # code placed here runs before cmd2.Cmd initializes super().__init__(*args, **kwargs) # code placed here runs after cmd2.Cmd initializes - self.mysetting = 'somevalue' - self.settable.update({'mysetting': 'short help message for mysetting'}) + self.mysetting = "somevalue" + self.settable.update({"mysetting": "short help message for mysetting"}) ``` You can also hide settings from the user by removing them from `self.settable`. @@ -142,7 +141,6 @@ Here's a simple example: ```python class MyMixin: - def __init__(self, *args, **kwargs): # code placed here runs before cmd2 initializes super().__init__(*args, **kwargs) @@ -152,7 +150,7 @@ class MyMixin: def cmd2_mymixin_postparsing_hook(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.PostparsingData: """Method to be called after parsing user input, but before running the command""" - self.poutput('in postparsing_hook') + self.poutput("in postparsing_hook") return data ``` diff --git a/docs/testing.md b/docs/testing.md index 176e9d94b..d7379c19c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -46,8 +46,8 @@ Another one using [pytest-mock](https://pypi.org/project/pytest-mock) to provide ```py def test_mocked_methods2(mocker): - mock_cmdloop = mocker.patch("cmd2.Cmd.cmdloop", autospec=True) - cli = cmd2.Cmd() - cli.cmdloop() - assert mock_cmdloop.call_count == 1 + mock_cmdloop = mocker.patch("cmd2.Cmd.cmdloop", autospec=True) + cli = cmd2.Cmd() + cli.cmdloop() + assert mock_cmdloop.call_count == 1 ``` diff --git a/examples/getting_started.py b/examples/getting_started.py index 7f79d7bd7..a1573c853 100755 --- a/examples/getting_started.py +++ b/examples/getting_started.py @@ -84,7 +84,7 @@ def __init__(self) -> None: # Color to output text in with echo command self.foreground_color = Color.CYAN.value - # Make echo_fg settable at runtime + # Make foreground_color settable at runtime fg_colors = [c.value for c in Color] self.add_settable( cmd2.Settable( diff --git a/pyproject.toml b/pyproject.toml index 840670685..e125f0c9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,20 +40,21 @@ dev = [ "codecov>=2.1", "ipython>=8.23", "mkdocstrings[python]>=1", + "mypy>=2.3.1", "prek>=0.3.5", "pytest>=8.1.1", "pytest-cov>=5", "pytest-mock>=3.14.1", - "ruff>=0.14.10", - "ty>=0.0.73", + "ruff>=0.16.0", + "ty>=0.0.75", "uv-publish>=1.3", - "zensical>=0.0.17", + "zensical>=0.0.57", ] docs = [ "mkdocstrings[python]>=1", "setuptools>=80.8.0", "setuptools_scm>=8", - "zensical>=0.0.44", + "zensical>=0.0.57", ] quality = ["prek>=0.3.5"] test = [ @@ -63,7 +64,33 @@ test = [ "pytest-cov>=5", "pytest-mock>=3.14.1", ] -validate = ["ruff>=0.14.10", "ty>=0.0.73", "types-setuptools>=80.8.0"] +validate = ["mypy>=2.3.1", "ruff>=0.16.0", "ty>=0.0.75", "types-setuptools>=80.8.0"] + +[tool.mypy] +disallow_incomplete_defs = true +disallow_untyped_calls = true +disallow_untyped_defs = true +exclude = [ + "^.git/", + "^.venv/", + "^build/", # .build directory + "^docs/", # docs directory + "^dist/", + "^examples/", # examples directory + "^noxfile\\.py$", # nox config file + "setup\\.py$", # any files named setup.py + "^site/", + "^tests/", # tests directory +] +files = ['.'] +show_column_numbers = true +show_error_codes = true +show_error_context = true +strict = true +warn_redundant_casts = true +warn_return_any = true +warn_unreachable = true +warn_unused_ignores = false [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/ruff.toml b/ruff.toml index ca97d1ce7..fbbf32d54 100644 --- a/ruff.toml +++ b/ruff.toml @@ -7,6 +7,7 @@ exclude = [ ".git-rewrite", ".hg", ".ipynb_checkpoints", + ".mypy_cache", ".nox", ".pants.d", ".pyenv", diff --git a/tests/test_annotated.py b/tests/test_annotated.py index 1829147f8..8f53c960f 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -2307,6 +2307,67 @@ def test_command_set_completion(self, cmdset_app) -> None: # --------------------------------------------------------------------------- +def test_subcommand_handler_rejects_keyword_arguments() -> None: + """The internal subcommand adapter only accepts the owner and parsed namespace positionally.""" + + @with_annotated(subcommand_to="root") + def root_child(self) -> None: + pass + + with pytest.raises(TypeError, match="do not accept keyword arguments"): + root_child(object(), argparse.Namespace(), unexpected=True) + + +def test_subcommand_handler_rejects_wrong_positional_argument_count() -> None: + """The internal subcommand adapter requires exactly two positional arguments (self, namespace).""" + + @with_annotated(subcommand_to="root") + def root_child(self) -> None: + pass + + with pytest.raises(TypeError, match=r"require 2 positional arguments"): + root_child(object()) + + with pytest.raises(TypeError, match=r"require 2 positional arguments"): + root_child(object(), argparse.Namespace(), "extra") + + +def test_subcommand_handler_requires_namespace() -> None: + """The internal subcommand adapter rejects calls without a parsed namespace.""" + + @with_annotated(subcommand_to="root") + def root_child(self) -> None: + pass + + with pytest.raises(TypeError, match=r"parsed argparse.Namespace"): + root_child(object(), object()) + + +def test_subcommand_handler_preserves_truthy_return_value() -> None: + """The internal subcommand adapter preserves any truthy command result.""" + + @with_annotated(subcommand_to="root") + def root_child(self) -> int: + return 1 + + assert root_child(object(), argparse.Namespace()) == 1 + + +def test_truthy_subcommand_return_stops_command_loop() -> None: + """A documented non-boolean truthy result propagates through the command loop.""" + + class TruthySubcommandApp(cmd2.Cmd): + @with_annotated(base_command=True) + def do_root(self, cmd2_subcommand_func) -> int | None: + return cmd2_subcommand_func() if cmd2_subcommand_func is not None else None + + @with_annotated(subcommand_to="root") + def root_stop(self) -> int: + return 1 + + assert TruthySubcommandApp().onecmd_plus_hooks("root stop") + + class _IntegrationApp(cmd2.Cmd): def __init__(self) -> None: super().__init__() diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 38985efa9..d2822bcd6 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -38,7 +38,6 @@ ) from cmd2 import rich_utils as ru from cmd2 import string_utils as su -from cmd2.types import BoundCommandFunc from .conftest import ( SHORTCUTS_TXT, @@ -4178,7 +4177,8 @@ def test_help_disabled_no_help_func(base_app: cmd2.Cmd) -> None: # Intentionally bypass disable_command() to test the fallback in do_help() command = "quit" - command_func = cast(BoundCommandFunc, base_app.get_command_func(command)) + command_func = base_app.get_command_func(command) + assert command_func is not None base_app.disabled_commands[command] = DisabledCommand(command_func=command_func, help_func=None, completer_func=None) _out, err = run_cmd(base_app, f"help {command}") diff --git a/ty.toml b/ty.toml index 3a27737dd..fcdf055d7 100644 --- a/ty.toml +++ b/ty.toml @@ -5,4 +5,3 @@ python-version = "3.11" include = ["cmd2"] [rules] -unresolved-attribute = "ignore" # 64 warnings