diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 252703bb5..3e963a9ff 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -22,55 +22,60 @@ # You can also use email addresses if you prefer. #docs/* docs@example.com +# GitHub stuff +.github/* @tleonhardt + # cmd2 code -cmd2/__init__.py @tleonhardt @kotfu -cmd2/ansi.py @kmvanbrunt @tleonhardt +cmd2/__init__.py @kmvanbrunt @tleonhardt cmd2/argparse_*.py @kmvanbrunt @anselor cmd2/clipboard.py @tleonhardt -cmd2/cmd2.py @tleonhardt @kmvanbrunt @kotfu +cmd2/cmd2.py @tleonhardt @kmvanbrunt +cmd2/colors.py @tleonhardt @kmvanbrunt cmd2/command_definition.py @anselor -cmd2/constants.py @kotfu -cmd2/decorators.py @kotfu @kmvanbrunt @anselor +cmd2/constants.py @tleonhardt @kmvanbrunt +cmd2/decorators.py @kmvanbrunt @anselor cmd2/exceptions.py @kmvanbrunt @anselor -cmd2/history.py @kotfu @tleonhardt -cmd2/parsing.py @kotfu @kmvanbrunt -cmd2/plugin.py @kotfu +cmd2/history.py @tleonhardt +cmd2/parsing.py @kmvanbrunt +cmd2/plugin.py @anselor cmd2/py_bridge.py @kmvanbrunt +cmd2/rich_utils.py @kmvanbrunt cmd2/rl_utils.py @kmvanbrunt -cmd2/table_creator.py @kmvanbrunt -cmd2/transcript.py @kotfu -cmd2/utils.py @tleonhardt @kotfu @kmvanbrunt +cmd2/string_utils.py @kmvanbrunt +cmd2/styles.py @tleonhardt @kmvanbrunt +cmd2/terminal_utils.py @kmvanbrunt +cmd2/transcript.py @tleonhardt +cmd2/utils.py @tleonhardt @kmvanbrunt # Documentation -docs/* @tleonhardt @kotfu +docs/* @tleonhardt # Examples -examples/async_printing.py @kmvanbrunt -examples/environment.py @kotfu -examples/tab_*.py @kmvanbrunt -examples/modular_*.py @anselor -examples/modular_commands/* @anselor - -plugins/template/* @kotfu -plugins/ext_test/* @anselor +examples/modular* @anselor +examples/*.py @kmvanbrunt @tleonhardt -# Unit Tests -tests/pyscript/* @kmvanbrunt -tests/transcripts/* @kotfu -tests/__init__.py @kotfu -tests/conftest.py @kotfu @tleonhardt -tests/test_argparse.py @kotfu -tests/test_argparse_*.py @kmvanbrunt -tests/test_comp*.py @kmvanbrunt -tests/test_pars*.py @kotfu -tests/test_run_pyscript.py @kmvanbrunt -tests/test_transcript.py @kotfu +# Plugins +plugins/* @anselor -tests_isolated/test_commandset/* @anselor +# Unit and Integration Tests +tests/* @kmvanbrunt @tleonhardt +tests_isolated/* @anselor # Top-level project stuff -setup.py @tleonhardt @kotfu -tasks.py @kotfu - -# GitHub stuff -.github/* @tleonhardt +.coveragerc @tleonhardt +.gitignore @tleonhardt @kmvanbrunt +.pre-commit-config.yaml @tleonhardt +.prettierignore @tleonhardt +.prettierrc @tleonhardt +.readthedocs.yaml @tleonhardt +CHANGELOG.md @kmvanbrunt @tleonhardt +cmd2.png @kmvanbrunt @tleonhardt +codecov.yml @tleonhardt +LICENSE @kmvanbrunt @tleonhardt +Makefile @tleonhardt +MANIFEST.in @tleonhardt +mkdocs.yml @tleonhardt +package.json @tleonhardt +pyproject.toml @tleonhardt @kmvanbrunt +README.md @kmvanbrunt @tleonhardt +tasks.py @tleonhardt diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 48a4a2ed3..81c8d0872 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -60,15 +60,19 @@ Nearly all project configuration, including for dependencies and quality tools i See the `dependencies` list under the `[project]` heading in [pyproject.toml](../pyproject.toml). -| Prerequisite | Minimum Version | Purpose | -| --------------------------------------------------- | --------------- | -------------------------------------- | -| [python](https://www.python.org/downloads/) | `3.9` | Python programming language | -| [pyperclip](https://github.com/asweigart/pyperclip) | `1.8` | Cross-platform clipboard functions | -| [wcwidth](https://pypi.python.org/pypi/wcwidth) | `0.2.10` | Measure the displayed width of unicode | +| Prerequisite | Minimum Version | Purpose | +| ---------------------------------------------------------- | --------------- | ------------------------------------------------------ | +| [python](https://www.python.org/downloads/) | `3.10` | Python programming language | +| [pyperclip](https://github.com/asweigart/pyperclip) | `1.8` | Cross-platform clipboard functions | +| [rich](https://github.com/Textualize/rich) | `14.1.0` | Add rich text and beautiful formatting in the terminal | +| [rich-argparse](https://github.com/hamdanal/rich-argparse) | `1.7.1` | A rich-enabled help formatter for argparse | > `macOS` and `Windows` each have an extra dependency to ensure they have a viable alternative to > [readline](https://tiswww.case.edu/php/chet/readline/rltop.html) available. +> Python 3.10 depends on [backports.strenum](https://github.com/clbarnes/backports.strenum) to use +> the `enum.StrEnum` class introduced in Python 3.11. + #### Additional prerequisites to build and publish cmd2 See the `build` list under the `[dependency-groups]` heading in [pyproject.toml](../pyproject.toml) @@ -265,7 +269,7 @@ uv venv --python 3.12 Then you can run commands in this isolated virtual environment using `uv` like so: ```sh -uv run examples/basic.py +uv run examples/hello_cmd2.py ``` Alternatively you can activate the virtual environment using the OS-specific command such as this on @@ -325,7 +329,7 @@ environment is set up and working properly. You can also run the example app and see a prompt that says "(Cmd)" running the command: ```sh -$ uv run examples/example.py +$ uv run examples/getting_started.py ``` You can type `help` to get help or `quit` to quit. If you see that, then congratulations – you're @@ -520,13 +524,11 @@ on how to do it. 4. The title (also called the subject) of your PR should be descriptive of your changes and succinctly indicate what is being fixed - - **Do not add the issue number in the PR title or commit message** - Examples: `Add test cases for Unicode support`; `Correct typo in overview documentation` 5. In the body of your PR include a more detailed summary of the changes you made and why - - If the PR is meant to fix an existing bug/issue, then, at the end of your PR's description, append the keyword `closes` and #xxxx (where xxxx is the issue number). Example: `closes #1337`. This tells GitHub to close the existing issue if the PR is merged. diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 976b57547..820a47b9d 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -32,7 +32,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: # We must fetch at least the immediate parents so that if this is # a pull request then we can checkout the head. diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e21376181..5a0f77395 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 # Needed for setuptools_scm to work correctly diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index e0475282f..7626b0cf3 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 # Needed for setuptools_scm to work correctly diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 51d75f65c..cd8cd7c85 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.14t"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.14t"] fail-fast: false runs-on: ${{ matrix.os }} @@ -21,14 +21,14 @@ jobs: shell: bash steps: - name: Check out - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 # Needed for setuptools_scm to work correctly - name: Install uv uses: astral-sh/setup-uv@v6 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} allow-prereleases: true @@ -36,11 +36,12 @@ jobs: run: uv sync --all-extras --dev - name: Run tests - run: uv run python -m pytest --cov --cov-config=pyproject.toml --cov-report=xml tests + run: uv run python -Xutf8 -m pytest --cov --cov-config=pyproject.toml --cov-report=xml tests - name: Run isolated tests run: - uv run python -m pytest --cov --cov-config=pyproject.toml --cov-report=xml tests_isolated + uv run python -Xutf8 -m pytest --cov --cov-config=pyproject.toml --cov-report=xml + tests_isolated - name: Upload test results to Codecov if: ${{ !cancelled() }} diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index f4bae5ad6..79d60c311 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -15,14 +15,14 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] fail-fast: false defaults: run: shell: bash steps: - name: Check out - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 # Needed for setuptools_scm to work correctly diff --git a/.gitignore b/.gitignore index 51218eb83..10b3d3ce3 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,6 @@ uv.lock # Node/npm used for installing Prettier locally to override the outdated version that is bundled with the VSCode extension node_modules/ package-lock.json + +# macOS +.DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f2ac5356c..bae9bb139 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v5.0.0" + rev: "v6.0.0" hooks: - id: check-case-conflict - id: check-merge-conflict @@ -9,7 +9,7 @@ repos: - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.12.1" + rev: "v0.12.9" hooks: - id: ruff-format args: [--config=pyproject.toml] @@ -21,5 +21,5 @@ repos: hooks: - id: prettier additional_dependencies: - - prettier@3.5.3 - - prettier-plugin-toml@2.0.5 + - prettier@3.6.2 + - prettier-plugin-toml@2.0.6 diff --git a/CHANGELOG.md b/CHANGELOG.md index e3e06fa4e..a675a6006 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,56 @@ +## 3.0.0 (TBD, 2025) + +### Summary + +`cmd2` now has a dependency on [rich](https://github.com/Textualize/rich) for rich text and pretty +formatting in the terminal. Previously, `cmd2` had a large amount of custom code for this purpose +that predated the existence of `rich`. This opens the door to even more beautiful `cmd2` +applications. To get the most out of the new capabilities, we encourage you to spend a little bit of +time reading the [rich documentation](https://rich.readthedocs.io/). + +### Details + +- Breaking Changes + - Refactored and modernized styling and utility modules: + - Removed the legacy `table_creator.py` module in favor of `rich` tables (see the + [rich_tables.py](https://github.com/python-cmd2/cmd2/blob/main/examples/rich_tables.py) + example for more info) + - Moved all string-related functions from `utils.py` to a new `string_utils.py` module + - Consolidated all string styling functions from `ansi.py` into `string_utils.py` + - Replaced all text style enums from `ansi.py` with modern `rich` styles + - Renamed `ansi.py` to `terminal_utils.py` to better reflect its purpose + - Dropped support for Python 3.9. `cmd2` now requires Python 3.10 or later + - Replaced `Settable.get_value()` and `Settable.set_value()` methods with a more Pythonic + `value` property + - Removed redundant setting of a parser's `prog` value in the `with_argparser()` decorator, as + this is now handled centrally in `Cmd._build_parser()` + +- Enhancements + - Enhanced all print methods (`poutput()`, `perror()`, `ppaged()`, etc.) to natively render + `rich` objects, enabling beautiful and complex output + - Simplified the process for setting a custom parser for `cmd2`'s built-in commands. See the + [custom_parser.py](https://github.com/python-cmd2/cmd2/blob/main/examples/custom_parser.py) + example for an updated guide + - Introduced `Cmd.macro_arg_complete()` for tab-completing macro arguments, with default path + completion that can be easily customized + - Added `colors.py` and `styles.py` to provide easy access to `rich` color names and manage + `cmd2`-specific style definitions using `StrEnum` (see the + [colors.py](https://github.com/python-cmd2/cmd2/blob/main/examples/color.py) example for a + demonstration of all colors available to your `cmd2` application) + - Added ability to create a custom theme for a `cmd2` application using `rich_utils.set_theme` + (see the [rich_theme.py](https://github.com/python-cmd2/cmd2/blob/main/examples/rich_theme.py) + example for more info) + - Consolidated multiple redundant examples into a few more comprehensive ones, see: + - [argparse_example.py](https://github.com/python-cmd2/cmd2/blob/main/examples/argparse_example.py) + - [command_sets.py](https://github.com/python-cmd2/cmd2/blob/main/examples/command_sets.py) + - [getting_started.py](https://github.com/python-cmd2/cmd2/blob/main/examples/getting_started.py) + - Optimized performance of terminal fixup during command finalization by replacing `stty sane` + with `termios.tcsetattr` + +- Bug Fixes + - Fixed a redirection bug where `cmd2` could unintentionally overwrite an application's + `sys.stdout` + ## 2.7.0 (June 30, 2025) - Enhancements @@ -6,7 +59,6 @@ ## 2.6.2 (June 26, 2025) - Enhancements - - Added explicit support for free-threaded versions of Python, starting with version 3.14 - Bug Fixes @@ -1292,12 +1344,10 @@ ## 0.8.5 (April 15, 2018) - Bug Fixes - - Fixed a bug with all argument decorators where the wrapped function wasn't returning a value and thus couldn't cause the cmd2 app to quit - Enhancements - - Added support for verbose help with -v where it lists a brief summary of what each command does - Added support for categorizing commands into groups within the help menu @@ -1329,12 +1379,10 @@ ## 0.8.3 (April 09, 2018) - Bug Fixes - - Fixed `help` command not calling functions for help topics - Fixed not being able to use quoted paths when redirecting with `<` and `>` - Enhancements - - Tab completion has been overhauled and now supports completion of strings with quotes and spaces. - Tab completion will automatically add an opening quote if a string with a space is completed. diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 000000000..3d525c1c8 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,38 @@ +# Instructions for Gemini CLI in a `uv` Python project + +This `GEMINI.md` file provides context and instructions for the Gemini CLI 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. + +## 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/Makefile b/Makefile index 9c851c146..4f6a7daf2 100644 --- a/Makefile +++ b/Makefile @@ -33,8 +33,8 @@ typecheck: ## Perform type checking .PHONY: test test: ## Test the code with pytest. @echo "🚀 Testing code: Running pytest" - @uv run python -m pytest --cov --cov-config=pyproject.toml --cov-report=xml tests - @uv run python -m pytest --cov --cov-config=pyproject.toml --cov-report=xml tests_isolated + @uv run python -Xutf8 -m pytest --cov --cov-config=pyproject.toml --cov-report=xml tests + @uv run python -Xutf8 -m pytest --cov --cov-config=pyproject.toml --cov-report=xml tests_isolated .PHONY: docs-test docs-test: ## Test if documentation can be built without warnings or errors diff --git a/README.md b/README.md index 6e4c14444..12ed7733c 100755 --- a/README.md +++ b/README.md @@ -25,6 +25,10 @@ applications. It provides a simple API which is an extension of Python's built-i of cmd to make your life easier and eliminates much of the boilerplate code which would be necessary when using cmd. +> :warning: **cmd2 is now "feature complete" for the `2.x` branch and is actively working on the +> 3.0.0 release on the `main` branch. New features will only be addressed in 3.x moving forwards. If +> need be, we will still fix bugs in 2.x.** + ## The developers toolbox ![system schema](https://raw.githubusercontent.com/python-cmd2/cmd2/main/.github/images/graph.drawio.png) @@ -35,7 +39,7 @@ menagerie of simple command line tools created by strangers on github and the gu 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 disperate applications into larger systems. `cmd2` fills the niche between +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. @@ -83,7 +87,7 @@ On all operating systems, the latest stable version of `cmd2` can be installed u pip install -U cmd2 ``` -cmd2 works with Python 3.9+ on Windows, macOS, and Linux. It is pure Python code with few 3rd-party +cmd2 works with Python 3.10+ on Windows, macOS, and Linux. It is pure Python code with few 3rd-party dependencies. It works with both conventional CPython and free-threaded variants. For information on other installation options, see @@ -101,20 +105,16 @@ examples. ## Tutorials -- PyOhio 2019 presentation: - - [video](https://www.youtube.com/watch?v=pebeWrTqIIw) - - [slides](https://github.com/python-cmd2/talks/blob/master/PyOhio_2019/cmd2-PyOhio_2019.pdf) - - [example code](https://github.com/python-cmd2/talks/tree/master/PyOhio_2019/examples) -- [Cookiecutter](https://github.com/cookiecutter/cookiecutter) Templates from community - - Basic cookiecutter template for cmd2 application : - https://github.com/jayrod/cookiecutter-python-cmd2 - - Advanced cookiecutter template with external plugin support : - https://github.com/jayrod/cookiecutter-python-cmd2-ext-plug - [cmd2 example applications](https://github.com/python-cmd2/cmd2/tree/main/examples) - Basic cmd2 examples to demonstrate how to use various features - [Advanced Examples](https://github.com/jayrod/cmd2-example-apps) - More complex examples that demonstrate more featuers about how to put together a complete application +- [Cookiecutter](https://github.com/cookiecutter/cookiecutter) Templates from community + - Basic cookiecutter template for cmd2 application : + https://github.com/jayrod/cookiecutter-python-cmd2 + - Advanced cookiecutter template with external plugin support : + https://github.com/jayrod/cookiecutter-python-cmd2-ext-plug ## Hello World @@ -127,9 +127,8 @@ import cmd2 class FirstApp(cmd2.Cmd): """A simple cmd2 application.""" - -def do_hello_world(self, _: cmd2.Statement): - self.poutput('Hello World') + def do_hello_world(self, _: cmd2.Statement): + self.poutput('Hello World') if __name__ == '__main__': @@ -171,6 +170,7 @@ reproduce the bug. At a minimum, please state the following: | [tomcatmanager](https://github.com/tomcatmanager/tomcatmanager) | A command line tool and python library for managing a tomcat server | [tomcatmanager](https://github.com/tomcatmanager) | | [Falcon Toolkit](https://github.com/CrowdStrike/Falcon-Toolkit) | Unleash the power of the CrowdStrike Falcon Platform at the CLI | [CrowdStrike](https://github.com/CrowdStrike) | | [EXPLIoT](https://gitlab.com/expliot_framework/expliot) | Internet of Things Security Testing and Exploitation framework | [expliot_framework](https://gitlab.com/expliot_framework/) | +| [Pobshell](https://github.com/pdalloz/pobshell) | A Bash‑like shell for live Python objects: `cd`, `ls`, `cat`, `find` and _CLI piping_ for object code, str values & more | [Peter Dalloz](https://www.linkedin.com/in/pdalloz) | Possibly defunct but still good examples diff --git a/cmd2.png b/cmd2.png index a73df9b09..ca540129e 100644 Binary files a/cmd2.png and b/cmd2.png differ diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 09962e796..1313bc1a9 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -1,24 +1,17 @@ """Import certain things for backwards compatibility.""" -import argparse import contextlib import importlib.metadata as importlib_metadata -import sys with contextlib.suppress(importlib_metadata.PackageNotFoundError): __version__ = importlib_metadata.version(__name__) -from .ansi import ( - Bg, - Cursor, - EightBitBg, - EightBitFg, - Fg, - RgbBg, - RgbFg, - TextStyle, - style, +from . import ( + plugin, + rich_utils, + string_utils, ) +from .argparse_completer import set_default_ap_completer_type from .argparse_custom import ( Cmd2ArgumentParser, Cmd2AttributeWrapper, @@ -26,21 +19,22 @@ register_argparse_argument_parameter, set_default_argument_parser_type, ) - -# Check if user has defined a module that sets a custom value for argparse_custom.DEFAULT_ARGUMENT_PARSER. -# Do this before loading cmd2.Cmd class so its commands use the custom parser. -cmd2_parser_module = getattr(argparse, 'cmd2_parser_module', None) -if cmd2_parser_module is not None: - import importlib - - importlib.import_module(cmd2_parser_module) - -from . import plugin -from .argparse_completer import set_default_ap_completer_type from .cmd2 import Cmd -from .command_definition import CommandSet, with_default_category -from .constants import COMMAND_NAME, DEFAULT_SHORTCUTS -from .decorators import as_subcommand_to, with_argparser, with_argument_list, with_category +from .colors import Color +from .command_definition import ( + CommandSet, + with_default_category, +) +from .constants import ( + COMMAND_NAME, + DEFAULT_SHORTCUTS, +) +from .decorators import ( + as_subcommand_to, + with_argparser, + with_argument_list, + with_category, +) from .exceptions import ( Cmd2ArgparseError, CommandSetRegistrationError, @@ -50,33 +44,33 @@ ) from .parsing import Statement from .py_bridge import CommandResult -from .utils import CompletionMode, CustomCompletionSettings, Settable, categorize +from .rich_utils import RichPrintKwargs +from .string_utils import stylize +from .styles import Cmd2Style +from .utils import ( + CompletionMode, + CustomCompletionSettings, + Settable, + categorize, +) __all__: list[str] = [ # noqa: RUF022 'COMMAND_NAME', 'DEFAULT_SHORTCUTS', - # ANSI Exports - 'Cursor', - 'Bg', - 'Fg', - 'EightBitBg', - 'EightBitFg', - 'RgbBg', - 'RgbFg', - 'TextStyle', - 'style', # Argparse Exports 'Cmd2ArgumentParser', 'Cmd2AttributeWrapper', 'CompletionItem', 'register_argparse_argument_parameter', - 'set_default_argument_parser_type', 'set_default_ap_completer_type', + 'set_default_argument_parser_type', # Cmd2 'Cmd', 'CommandResult', 'CommandSet', 'Statement', + # Colors + "Color", # Decorators 'with_argument_list', 'with_argparser', @@ -87,9 +81,18 @@ 'Cmd2ArgparseError', 'CommandSetRegistrationError', 'CompletionError', + 'PassThroughException', 'SkipPostcommandHooks', # modules 'plugin', + 'rich_utils', + 'string_utils', + # Rich Utils + 'RichPrintKwargs', + # String Utils + 'stylize', + # Styles, + "Cmd2Style", # Utilities 'categorize', 'CompletionMode', diff --git a/cmd2/ansi.py b/cmd2/ansi.py deleted file mode 100644 index cca020188..000000000 --- a/cmd2/ansi.py +++ /dev/null @@ -1,1093 +0,0 @@ -"""Support for ANSI escape sequences. - -These are used for things like applying style to text, setting the window title, and asynchronous alerts. -""" - -import functools -import re -from enum import ( - Enum, -) -from typing import ( - IO, - Any, - Optional, - cast, -) - -from wcwidth import ( # type: ignore[import] - wcswidth, -) - -####################################################### -# Common ANSI escape sequence constants -####################################################### -ESC = '\x1b' -CSI = f'{ESC}[' -OSC = f'{ESC}]' -BEL = '\a' - - -class AllowStyle(Enum): - """Values for ``cmd2.ansi.allow_style``.""" - - ALWAYS = 'Always' # Always output ANSI style sequences - NEVER = 'Never' # Remove ANSI style sequences from all output - TERMINAL = 'Terminal' # Remove ANSI style sequences if the output is not going to the terminal - - def __str__(self) -> str: - """Return value instead of enum name for printing in cmd2's set command.""" - return str(self.value) - - def __repr__(self) -> str: - """Return quoted value instead of enum description for printing in cmd2's set command.""" - return repr(self.value) - - -# Controls when ANSI style sequences are allowed in output -allow_style = AllowStyle.TERMINAL -"""When using outside of a cmd2 app, set this variable to one of: - -- ``AllowStyle.ALWAYS`` - always output ANSI style sequences -- ``AllowStyle.NEVER`` - remove ANSI style sequences from all output -- ``AllowStyle.TERMINAL`` - remove ANSI style sequences if the output is not going to the terminal - -to control how ANSI style sequences are handled by ``style_aware_write()``. - -``style_aware_write()`` is called by cmd2 methods like ``poutput()``, ``perror()``, -``pwarning()``, etc. - -The default is ``AllowStyle.TERMINAL``. -""" - -# Regular expression to match ANSI style sequence -ANSI_STYLE_RE = re.compile(rf'{ESC}\[[^m]*m') - -# Matches standard foreground colors: CSI(30-37|90-97|39)m -STD_FG_RE = re.compile(rf'{ESC}\[(?:[39][0-7]|39)m') - -# Matches standard background colors: CSI(40-47|100-107|49)m -STD_BG_RE = re.compile(rf'{ESC}\[(?:(?:4|10)[0-7]|49)m') - -# Matches eight-bit foreground colors: CSI38;5;(0-255)m -EIGHT_BIT_FG_RE = re.compile(rf'{ESC}\[38;5;(?:1?[0-9]?[0-9]?|2[0-4][0-9]|25[0-5])m') - -# Matches eight-bit background colors: CSI48;5;(0-255)m -EIGHT_BIT_BG_RE = re.compile(rf'{ESC}\[48;5;(?:1?[0-9]?[0-9]?|2[0-4][0-9]|25[0-5])m') - -# Matches RGB foreground colors: CSI38;2;(0-255);(0-255);(0-255)m -RGB_FG_RE = re.compile(rf'{ESC}\[38;2(?:;(?:1?[0-9]?[0-9]?|2[0-4][0-9]|25[0-5])){{3}}m') - -# Matches RGB background colors: CSI48;2;(0-255);(0-255);(0-255)m -RGB_BG_RE = re.compile(rf'{ESC}\[48;2(?:;(?:1?[0-9]?[0-9]?|2[0-4][0-9]|25[0-5])){{3}}m') - - -def strip_style(text: str) -> str: - """Strip ANSI style sequences from a string. - - :param text: string which may contain ANSI style sequences - :return: the same string with any ANSI style sequences removed - """ - return ANSI_STYLE_RE.sub('', text) - - -def style_aware_wcswidth(text: str) -> int: - """Wrap wcswidth to make it compatible with strings that contain ANSI style sequences. - - This is intended for single line strings. If text contains a newline, this - function will return -1. For multiline strings, call widest_line() instead. - - :param text: the string being measured - :return: The width of the string when printed to the terminal if no errors occur. - If text contains characters with no absolute width (i.e. tabs), - then this function returns -1. Replace tabs with spaces before calling this. - """ - # Strip ANSI style sequences since they cause wcswidth to return -1 - return cast(int, wcswidth(strip_style(text))) - - -def widest_line(text: str) -> int: - """Return the width of the widest line in a multiline string. - - This wraps style_aware_wcswidth() so it handles ANSI style sequences and has the same - restrictions on non-printable characters. - - :param text: the string being measured - :return: The width of the string when printed to the terminal if no errors occur. - If text contains characters with no absolute width (i.e. tabs), - then this function returns -1. Replace tabs with spaces before calling this. - """ - if not text: - return 0 - - lines_widths = [style_aware_wcswidth(line) for line in text.splitlines()] - if -1 in lines_widths: - return -1 - - return max(lines_widths) - - -def style_aware_write(fileobj: IO[str], msg: str) -> None: - """Write a string to a fileobject and strip its ANSI style sequences if required by allow_style setting. - - :param fileobj: the file object being written to - :param msg: the string being written - """ - if allow_style == AllowStyle.NEVER or (allow_style == AllowStyle.TERMINAL and not fileobj.isatty()): - msg = strip_style(msg) - fileobj.write(msg) - - -#################################################################################### -# Utility functions which create various ANSI sequences -#################################################################################### -def set_title(title: str) -> str: - """Generate a string that, when printed, sets a terminal's window title. - - :param title: new title for the window - :return: the set title string - """ - return f"{OSC}2;{title}{BEL}" - - -def clear_screen(clear_type: int = 2) -> str: - """Generate a string that, when printed, clears a terminal screen based on value of clear_type. - - :param clear_type: integer which specifies how to clear the screen (Defaults to 2) - Possible values: - 0 - clear from cursor to end of screen - 1 - clear from cursor to beginning of the screen - 2 - clear entire screen - 3 - clear entire screen and delete all lines saved in the scrollback buffer - :return: the clear screen string - :raises ValueError: if clear_type is not a valid value - """ - if 0 <= clear_type <= 3: - return f"{CSI}{clear_type}J" - raise ValueError("clear_type must in an integer from 0 to 3") - - -def clear_line(clear_type: int = 2) -> str: - """Generate a string that, when printed, clears a line based on value of clear_type. - - :param clear_type: integer which specifies how to clear the line (Defaults to 2) - Possible values: - 0 - clear from cursor to the end of the line - 1 - clear from cursor to beginning of the line - 2 - clear entire line - :return: the clear line string - :raises ValueError: if clear_type is not a valid value - """ - if 0 <= clear_type <= 2: - return f"{CSI}{clear_type}K" - raise ValueError("clear_type must in an integer from 0 to 2") - - -#################################################################################### -# Base classes which are not intended to be used directly -#################################################################################### -class AnsiSequence: - """Base class to create ANSI sequence strings.""" - - def __add__(self, other: Any) -> str: - """Support building an ANSI sequence string when self is the left operand. - - e.g. Fg.LIGHT_MAGENTA + "hello" - """ - return str(self) + str(other) - - def __radd__(self, other: Any) -> str: - """Support building an ANSI sequence string when self is the right operand. - - e.g. "hello" + Fg.RESET - """ - return str(other) + str(self) - - -class FgColor(AnsiSequence): - """Base class for ANSI Sequences which set foreground text color.""" - - -class BgColor(AnsiSequence): - """Base class for ANSI Sequences which set background text color.""" - - -#################################################################################### -# Implementations intended for direct use (do NOT use outside of cmd2) -#################################################################################### -class Cursor: - """Create ANSI sequences to alter the cursor position.""" - - @staticmethod - def UP(count: int = 1) -> str: # noqa: N802 - """Move the cursor up a specified amount of lines (Defaults to 1).""" - return f"{CSI}{count}A" - - @staticmethod - def DOWN(count: int = 1) -> str: # noqa: N802 - """Move the cursor down a specified amount of lines (Defaults to 1).""" - return f"{CSI}{count}B" - - @staticmethod - def FORWARD(count: int = 1) -> str: # noqa: N802 - """Move the cursor forward a specified amount of lines (Defaults to 1).""" - return f"{CSI}{count}C" - - @staticmethod - def BACK(count: int = 1) -> str: # noqa: N802 - """Move the cursor back a specified amount of lines (Defaults to 1).""" - return f"{CSI}{count}D" - - @staticmethod - def SET_POS(x: int, y: int) -> str: # noqa: N802 - """Set the cursor position to coordinates which are 1-based.""" - return f"{CSI}{y};{x}H" - - -class TextStyle(AnsiSequence, Enum): - """Create text style ANSI sequences.""" - - # Resets all styles and colors of text - RESET_ALL = 0 - ALT_RESET_ALL = '' - - INTENSITY_BOLD = 1 - INTENSITY_DIM = 2 - INTENSITY_NORMAL = 22 - - ITALIC_ENABLE = 3 - ITALIC_DISABLE = 23 - - OVERLINE_ENABLE = 53 - OVERLINE_DISABLE = 55 - - STRIKETHROUGH_ENABLE = 9 - STRIKETHROUGH_DISABLE = 29 - - UNDERLINE_ENABLE = 4 - UNDERLINE_DISABLE = 24 - - def __str__(self) -> str: - """Return ANSI text style sequence instead of enum name. - - This is helpful when using a TextStyle in an f-string or format() call - e.g. my_str = f"{TextStyle.UNDERLINE_ENABLE}hello{TextStyle.UNDERLINE_DISABLE}". - """ - return f"{CSI}{self.value}m" - - -class Fg(FgColor, Enum): - """Create ANSI sequences for the 16 standard terminal foreground text colors. - - A terminal's color settings affect how these colors appear. - To reset any foreground color, use Fg.RESET. - """ - - BLACK = 30 - RED = 31 - GREEN = 32 - YELLOW = 33 - BLUE = 34 - MAGENTA = 35 - CYAN = 36 - LIGHT_GRAY = 37 - DARK_GRAY = 90 - LIGHT_RED = 91 - LIGHT_GREEN = 92 - LIGHT_YELLOW = 93 - LIGHT_BLUE = 94 - LIGHT_MAGENTA = 95 - LIGHT_CYAN = 96 - WHITE = 97 - - RESET = 39 - - def __str__(self) -> str: - """Return ANSI color sequence instead of enum name. - - This is helpful when using an Fg in an f-string or format() call - e.g. my_str = f"{Fg.BLUE}hello{Fg.RESET}". - """ - return f"{CSI}{self.value}m" - - -class Bg(BgColor, Enum): - """Create ANSI sequences for the 16 standard terminal background text colors. - - A terminal's color settings affect how these colors appear. - To reset any background color, use Bg.RESET. - """ - - BLACK = 40 - RED = 41 - GREEN = 42 - YELLOW = 43 - BLUE = 44 - MAGENTA = 45 - CYAN = 46 - LIGHT_GRAY = 47 - DARK_GRAY = 100 - LIGHT_RED = 101 - LIGHT_GREEN = 102 - LIGHT_YELLOW = 103 - LIGHT_BLUE = 104 - LIGHT_MAGENTA = 105 - LIGHT_CYAN = 106 - WHITE = 107 - - RESET = 49 - - def __str__(self) -> str: - """Return ANSI color sequence instead of enum name. - - This is helpful when using a Bg in an f-string or format() call - e.g. my_str = f"{Bg.BLACK}hello{Bg.RESET}". - """ - return f"{CSI}{self.value}m" - - -class EightBitFg(FgColor, Enum): - """Create ANSI sequences for 8-bit terminal foreground text colors. Most terminals support 8-bit/256-color mode. - - The first 16 colors correspond to the 16 colors from Fg and behave the same way. - To reset any foreground color, including 8-bit, use Fg.RESET. - """ - - BLACK = 0 - RED = 1 - GREEN = 2 - YELLOW = 3 - BLUE = 4 - MAGENTA = 5 - CYAN = 6 - LIGHT_GRAY = 7 - DARK_GRAY = 8 - LIGHT_RED = 9 - LIGHT_GREEN = 10 - LIGHT_YELLOW = 11 - LIGHT_BLUE = 12 - LIGHT_MAGENTA = 13 - LIGHT_CYAN = 14 - WHITE = 15 - GRAY_0 = 16 - NAVY_BLUE = 17 - DARK_BLUE = 18 - BLUE_3A = 19 - BLUE_3B = 20 - BLUE_1 = 21 - DARK_GREEN = 22 - DEEP_SKY_BLUE_4A = 23 - DEEP_SKY_BLUE_4B = 24 - DEEP_SKY_BLUE_4C = 25 - DODGER_BLUE_3 = 26 - DODGER_BLUE_2 = 27 - GREEN_4 = 28 - SPRING_GREEN_4 = 29 - TURQUOISE_4 = 30 - DEEP_SKY_BLUE_3A = 31 - DEEP_SKY_BLUE_3B = 32 - DODGER_BLUE_1 = 33 - GREEN_3A = 34 - SPRING_GREEN_3A = 35 - DARK_CYAN = 36 - LIGHT_SEA_GREEN = 37 - DEEP_SKY_BLUE_2 = 38 - DEEP_SKY_BLUE_1 = 39 - GREEN_3B = 40 - SPRING_GREEN_3B = 41 - SPRING_GREEN_2A = 42 - CYAN_3 = 43 - DARK_TURQUOISE = 44 - TURQUOISE_2 = 45 - GREEN_1 = 46 - SPRING_GREEN_2B = 47 - SPRING_GREEN_1 = 48 - MEDIUM_SPRING_GREEN = 49 - CYAN_2 = 50 - CYAN_1 = 51 - DARK_RED_1 = 52 - DEEP_PINK_4A = 53 - PURPLE_4A = 54 - PURPLE_4B = 55 - PURPLE_3 = 56 - BLUE_VIOLET = 57 - ORANGE_4A = 58 - GRAY_37 = 59 - MEDIUM_PURPLE_4 = 60 - SLATE_BLUE_3A = 61 - SLATE_BLUE_3B = 62 - ROYAL_BLUE_1 = 63 - CHARTREUSE_4 = 64 - DARK_SEA_GREEN_4A = 65 - PALE_TURQUOISE_4 = 66 - STEEL_BLUE = 67 - STEEL_BLUE_3 = 68 - CORNFLOWER_BLUE = 69 - CHARTREUSE_3A = 70 - DARK_SEA_GREEN_4B = 71 - CADET_BLUE_2 = 72 - CADET_BLUE_1 = 73 - SKY_BLUE_3 = 74 - STEEL_BLUE_1A = 75 - CHARTREUSE_3B = 76 - PALE_GREEN_3A = 77 - SEA_GREEN_3 = 78 - AQUAMARINE_3 = 79 - MEDIUM_TURQUOISE = 80 - STEEL_BLUE_1B = 81 - CHARTREUSE_2A = 82 - SEA_GREEN_2 = 83 - SEA_GREEN_1A = 84 - SEA_GREEN_1B = 85 - AQUAMARINE_1A = 86 - DARK_SLATE_GRAY_2 = 87 - DARK_RED_2 = 88 - DEEP_PINK_4B = 89 - DARK_MAGENTA_1 = 90 - DARK_MAGENTA_2 = 91 - DARK_VIOLET_1A = 92 - PURPLE_1A = 93 - ORANGE_4B = 94 - LIGHT_PINK_4 = 95 - PLUM_4 = 96 - MEDIUM_PURPLE_3A = 97 - MEDIUM_PURPLE_3B = 98 - SLATE_BLUE_1 = 99 - YELLOW_4A = 100 - WHEAT_4 = 101 - GRAY_53 = 102 - LIGHT_SLATE_GRAY = 103 - MEDIUM_PURPLE = 104 - LIGHT_SLATE_BLUE = 105 - YELLOW_4B = 106 - DARK_OLIVE_GREEN_3A = 107 - DARK_GREEN_SEA = 108 - LIGHT_SKY_BLUE_3A = 109 - LIGHT_SKY_BLUE_3B = 110 - SKY_BLUE_2 = 111 - CHARTREUSE_2B = 112 - DARK_OLIVE_GREEN_3B = 113 - PALE_GREEN_3B = 114 - DARK_SEA_GREEN_3A = 115 - DARK_SLATE_GRAY_3 = 116 - SKY_BLUE_1 = 117 - CHARTREUSE_1 = 118 - LIGHT_GREEN_2 = 119 - LIGHT_GREEN_3 = 120 - PALE_GREEN_1A = 121 - AQUAMARINE_1B = 122 - DARK_SLATE_GRAY_1 = 123 - RED_3A = 124 - DEEP_PINK_4C = 125 - MEDIUM_VIOLET_RED = 126 - MAGENTA_3A = 127 - DARK_VIOLET_1B = 128 - PURPLE_1B = 129 - DARK_ORANGE_3A = 130 - INDIAN_RED_1A = 131 - HOT_PINK_3A = 132 - MEDIUM_ORCHID_3 = 133 - MEDIUM_ORCHID = 134 - MEDIUM_PURPLE_2A = 135 - DARK_GOLDENROD = 136 - LIGHT_SALMON_3A = 137 - ROSY_BROWN = 138 - GRAY_63 = 139 - MEDIUM_PURPLE_2B = 140 - MEDIUM_PURPLE_1 = 141 - GOLD_3A = 142 - DARK_KHAKI = 143 - NAVAJO_WHITE_3 = 144 - GRAY_69 = 145 - LIGHT_STEEL_BLUE_3 = 146 - LIGHT_STEEL_BLUE = 147 - YELLOW_3A = 148 - DARK_OLIVE_GREEN_3 = 149 - DARK_SEA_GREEN_3B = 150 - DARK_SEA_GREEN_2 = 151 - LIGHT_CYAN_3 = 152 - LIGHT_SKY_BLUE_1 = 153 - GREEN_YELLOW = 154 - DARK_OLIVE_GREEN_2 = 155 - PALE_GREEN_1B = 156 - DARK_SEA_GREEN_5B = 157 - DARK_SEA_GREEN_5A = 158 - PALE_TURQUOISE_1 = 159 - RED_3B = 160 - DEEP_PINK_3A = 161 - DEEP_PINK_3B = 162 - MAGENTA_3B = 163 - MAGENTA_3C = 164 - MAGENTA_2A = 165 - DARK_ORANGE_3B = 166 - INDIAN_RED_1B = 167 - HOT_PINK_3B = 168 - HOT_PINK_2 = 169 - ORCHID = 170 - MEDIUM_ORCHID_1A = 171 - ORANGE_3 = 172 - LIGHT_SALMON_3B = 173 - LIGHT_PINK_3 = 174 - PINK_3 = 175 - PLUM_3 = 176 - VIOLET = 177 - GOLD_3B = 178 - LIGHT_GOLDENROD_3 = 179 - TAN = 180 - MISTY_ROSE_3 = 181 - THISTLE_3 = 182 - PLUM_2 = 183 - YELLOW_3B = 184 - KHAKI_3 = 185 - LIGHT_GOLDENROD_2A = 186 - LIGHT_YELLOW_3 = 187 - GRAY_84 = 188 - LIGHT_STEEL_BLUE_1 = 189 - YELLOW_2 = 190 - DARK_OLIVE_GREEN_1A = 191 - DARK_OLIVE_GREEN_1B = 192 - DARK_SEA_GREEN_1 = 193 - HONEYDEW_2 = 194 - LIGHT_CYAN_1 = 195 - RED_1 = 196 - DEEP_PINK_2 = 197 - DEEP_PINK_1A = 198 - DEEP_PINK_1B = 199 - MAGENTA_2B = 200 - MAGENTA_1 = 201 - ORANGE_RED_1 = 202 - INDIAN_RED_1C = 203 - INDIAN_RED_1D = 204 - HOT_PINK_1A = 205 - HOT_PINK_1B = 206 - MEDIUM_ORCHID_1B = 207 - DARK_ORANGE = 208 - SALMON_1 = 209 - LIGHT_CORAL = 210 - PALE_VIOLET_RED_1 = 211 - ORCHID_2 = 212 - ORCHID_1 = 213 - ORANGE_1 = 214 - SANDY_BROWN = 215 - LIGHT_SALMON_1 = 216 - LIGHT_PINK_1 = 217 - PINK_1 = 218 - PLUM_1 = 219 - GOLD_1 = 220 - LIGHT_GOLDENROD_2B = 221 - LIGHT_GOLDENROD_2C = 222 - NAVAJO_WHITE_1 = 223 - MISTY_ROSE1 = 224 - THISTLE_1 = 225 - YELLOW_1 = 226 - LIGHT_GOLDENROD_1 = 227 - KHAKI_1 = 228 - WHEAT_1 = 229 - CORNSILK_1 = 230 - GRAY_100 = 231 - GRAY_3 = 232 - GRAY_7 = 233 - GRAY_11 = 234 - GRAY_15 = 235 - GRAY_19 = 236 - GRAY_23 = 237 - GRAY_27 = 238 - GRAY_30 = 239 - GRAY_35 = 240 - GRAY_39 = 241 - GRAY_42 = 242 - GRAY_46 = 243 - GRAY_50 = 244 - GRAY_54 = 245 - GRAY_58 = 246 - GRAY_62 = 247 - GRAY_66 = 248 - GRAY_70 = 249 - GRAY_74 = 250 - GRAY_78 = 251 - GRAY_82 = 252 - GRAY_85 = 253 - GRAY_89 = 254 - GRAY_93 = 255 - - def __str__(self) -> str: - """Return ANSI color sequence instead of enum name. - - This is helpful when using an EightBitFg in an f-string or format() call - e.g. my_str = f"{EightBitFg.SLATE_BLUE_1}hello{Fg.RESET}". - """ - return f"{CSI}38;5;{self.value}m" - - -class EightBitBg(BgColor, Enum): - """Create ANSI sequences for 8-bit terminal background text colors. Most terminals support 8-bit/256-color mode. - - The first 16 colors correspond to the 16 colors from Bg and behave the same way. - To reset any background color, including 8-bit, use Bg.RESET. - """ - - BLACK = 0 - RED = 1 - GREEN = 2 - YELLOW = 3 - BLUE = 4 - MAGENTA = 5 - CYAN = 6 - LIGHT_GRAY = 7 - DARK_GRAY = 8 - LIGHT_RED = 9 - LIGHT_GREEN = 10 - LIGHT_YELLOW = 11 - LIGHT_BLUE = 12 - LIGHT_MAGENTA = 13 - LIGHT_CYAN = 14 - WHITE = 15 - GRAY_0 = 16 - NAVY_BLUE = 17 - DARK_BLUE = 18 - BLUE_3A = 19 - BLUE_3B = 20 - BLUE_1 = 21 - DARK_GREEN = 22 - DEEP_SKY_BLUE_4A = 23 - DEEP_SKY_BLUE_4B = 24 - DEEP_SKY_BLUE_4C = 25 - DODGER_BLUE_3 = 26 - DODGER_BLUE_2 = 27 - GREEN_4 = 28 - SPRING_GREEN_4 = 29 - TURQUOISE_4 = 30 - DEEP_SKY_BLUE_3A = 31 - DEEP_SKY_BLUE_3B = 32 - DODGER_BLUE_1 = 33 - GREEN_3A = 34 - SPRING_GREEN_3A = 35 - DARK_CYAN = 36 - LIGHT_SEA_GREEN = 37 - DEEP_SKY_BLUE_2 = 38 - DEEP_SKY_BLUE_1 = 39 - GREEN_3B = 40 - SPRING_GREEN_3B = 41 - SPRING_GREEN_2A = 42 - CYAN_3 = 43 - DARK_TURQUOISE = 44 - TURQUOISE_2 = 45 - GREEN_1 = 46 - SPRING_GREEN_2B = 47 - SPRING_GREEN_1 = 48 - MEDIUM_SPRING_GREEN = 49 - CYAN_2 = 50 - CYAN_1 = 51 - DARK_RED_1 = 52 - DEEP_PINK_4A = 53 - PURPLE_4A = 54 - PURPLE_4B = 55 - PURPLE_3 = 56 - BLUE_VIOLET = 57 - ORANGE_4A = 58 - GRAY_37 = 59 - MEDIUM_PURPLE_4 = 60 - SLATE_BLUE_3A = 61 - SLATE_BLUE_3B = 62 - ROYAL_BLUE_1 = 63 - CHARTREUSE_4 = 64 - DARK_SEA_GREEN_4A = 65 - PALE_TURQUOISE_4 = 66 - STEEL_BLUE = 67 - STEEL_BLUE_3 = 68 - CORNFLOWER_BLUE = 69 - CHARTREUSE_3A = 70 - DARK_SEA_GREEN_4B = 71 - CADET_BLUE_2 = 72 - CADET_BLUE_1 = 73 - SKY_BLUE_3 = 74 - STEEL_BLUE_1A = 75 - CHARTREUSE_3B = 76 - PALE_GREEN_3A = 77 - SEA_GREEN_3 = 78 - AQUAMARINE_3 = 79 - MEDIUM_TURQUOISE = 80 - STEEL_BLUE_1B = 81 - CHARTREUSE_2A = 82 - SEA_GREEN_2 = 83 - SEA_GREEN_1A = 84 - SEA_GREEN_1B = 85 - AQUAMARINE_1A = 86 - DARK_SLATE_GRAY_2 = 87 - DARK_RED_2 = 88 - DEEP_PINK_4B = 89 - DARK_MAGENTA_1 = 90 - DARK_MAGENTA_2 = 91 - DARK_VIOLET_1A = 92 - PURPLE_1A = 93 - ORANGE_4B = 94 - LIGHT_PINK_4 = 95 - PLUM_4 = 96 - MEDIUM_PURPLE_3A = 97 - MEDIUM_PURPLE_3B = 98 - SLATE_BLUE_1 = 99 - YELLOW_4A = 100 - WHEAT_4 = 101 - GRAY_53 = 102 - LIGHT_SLATE_GRAY = 103 - MEDIUM_PURPLE = 104 - LIGHT_SLATE_BLUE = 105 - YELLOW_4B = 106 - DARK_OLIVE_GREEN_3A = 107 - DARK_GREEN_SEA = 108 - LIGHT_SKY_BLUE_3A = 109 - LIGHT_SKY_BLUE_3B = 110 - SKY_BLUE_2 = 111 - CHARTREUSE_2B = 112 - DARK_OLIVE_GREEN_3B = 113 - PALE_GREEN_3B = 114 - DARK_SEA_GREEN_3A = 115 - DARK_SLATE_GRAY_3 = 116 - SKY_BLUE_1 = 117 - CHARTREUSE_1 = 118 - LIGHT_GREEN_2 = 119 - LIGHT_GREEN_3 = 120 - PALE_GREEN_1A = 121 - AQUAMARINE_1B = 122 - DARK_SLATE_GRAY_1 = 123 - RED_3A = 124 - DEEP_PINK_4C = 125 - MEDIUM_VIOLET_RED = 126 - MAGENTA_3A = 127 - DARK_VIOLET_1B = 128 - PURPLE_1B = 129 - DARK_ORANGE_3A = 130 - INDIAN_RED_1A = 131 - HOT_PINK_3A = 132 - MEDIUM_ORCHID_3 = 133 - MEDIUM_ORCHID = 134 - MEDIUM_PURPLE_2A = 135 - DARK_GOLDENROD = 136 - LIGHT_SALMON_3A = 137 - ROSY_BROWN = 138 - GRAY_63 = 139 - MEDIUM_PURPLE_2B = 140 - MEDIUM_PURPLE_1 = 141 - GOLD_3A = 142 - DARK_KHAKI = 143 - NAVAJO_WHITE_3 = 144 - GRAY_69 = 145 - LIGHT_STEEL_BLUE_3 = 146 - LIGHT_STEEL_BLUE = 147 - YELLOW_3A = 148 - DARK_OLIVE_GREEN_3 = 149 - DARK_SEA_GREEN_3B = 150 - DARK_SEA_GREEN_2 = 151 - LIGHT_CYAN_3 = 152 - LIGHT_SKY_BLUE_1 = 153 - GREEN_YELLOW = 154 - DARK_OLIVE_GREEN_2 = 155 - PALE_GREEN_1B = 156 - DARK_SEA_GREEN_5B = 157 - DARK_SEA_GREEN_5A = 158 - PALE_TURQUOISE_1 = 159 - RED_3B = 160 - DEEP_PINK_3A = 161 - DEEP_PINK_3B = 162 - MAGENTA_3B = 163 - MAGENTA_3C = 164 - MAGENTA_2A = 165 - DARK_ORANGE_3B = 166 - INDIAN_RED_1B = 167 - HOT_PINK_3B = 168 - HOT_PINK_2 = 169 - ORCHID = 170 - MEDIUM_ORCHID_1A = 171 - ORANGE_3 = 172 - LIGHT_SALMON_3B = 173 - LIGHT_PINK_3 = 174 - PINK_3 = 175 - PLUM_3 = 176 - VIOLET = 177 - GOLD_3B = 178 - LIGHT_GOLDENROD_3 = 179 - TAN = 180 - MISTY_ROSE_3 = 181 - THISTLE_3 = 182 - PLUM_2 = 183 - YELLOW_3B = 184 - KHAKI_3 = 185 - LIGHT_GOLDENROD_2A = 186 - LIGHT_YELLOW_3 = 187 - GRAY_84 = 188 - LIGHT_STEEL_BLUE_1 = 189 - YELLOW_2 = 190 - DARK_OLIVE_GREEN_1A = 191 - DARK_OLIVE_GREEN_1B = 192 - DARK_SEA_GREEN_1 = 193 - HONEYDEW_2 = 194 - LIGHT_CYAN_1 = 195 - RED_1 = 196 - DEEP_PINK_2 = 197 - DEEP_PINK_1A = 198 - DEEP_PINK_1B = 199 - MAGENTA_2B = 200 - MAGENTA_1 = 201 - ORANGE_RED_1 = 202 - INDIAN_RED_1C = 203 - INDIAN_RED_1D = 204 - HOT_PINK_1A = 205 - HOT_PINK_1B = 206 - MEDIUM_ORCHID_1B = 207 - DARK_ORANGE = 208 - SALMON_1 = 209 - LIGHT_CORAL = 210 - PALE_VIOLET_RED_1 = 211 - ORCHID_2 = 212 - ORCHID_1 = 213 - ORANGE_1 = 214 - SANDY_BROWN = 215 - LIGHT_SALMON_1 = 216 - LIGHT_PINK_1 = 217 - PINK_1 = 218 - PLUM_1 = 219 - GOLD_1 = 220 - LIGHT_GOLDENROD_2B = 221 - LIGHT_GOLDENROD_2C = 222 - NAVAJO_WHITE_1 = 223 - MISTY_ROSE1 = 224 - THISTLE_1 = 225 - YELLOW_1 = 226 - LIGHT_GOLDENROD_1 = 227 - KHAKI_1 = 228 - WHEAT_1 = 229 - CORNSILK_1 = 230 - GRAY_100 = 231 - GRAY_3 = 232 - GRAY_7 = 233 - GRAY_11 = 234 - GRAY_15 = 235 - GRAY_19 = 236 - GRAY_23 = 237 - GRAY_27 = 238 - GRAY_30 = 239 - GRAY_35 = 240 - GRAY_39 = 241 - GRAY_42 = 242 - GRAY_46 = 243 - GRAY_50 = 244 - GRAY_54 = 245 - GRAY_58 = 246 - GRAY_62 = 247 - GRAY_66 = 248 - GRAY_70 = 249 - GRAY_74 = 250 - GRAY_78 = 251 - GRAY_82 = 252 - GRAY_85 = 253 - GRAY_89 = 254 - GRAY_93 = 255 - - def __str__(self) -> str: - """Return ANSI color sequence instead of enum name. - - This is helpful when using an EightBitBg in an f-string or format() call - e.g. my_str = f"{EightBitBg.KHAKI_3}hello{Bg.RESET}". - """ - return f"{CSI}48;5;{self.value}m" - - -class RgbFg(FgColor): - """Create ANSI sequences for 24-bit (RGB) terminal foreground text colors. The terminal must support 24-bit/true-color. - - To reset any foreground color, including 24-bit, use Fg.RESET. - """ - - def __init__(self, r: int, g: int, b: int) -> None: - """RgbFg initializer. - - :param r: integer from 0-255 for the red component of the color - :param g: integer from 0-255 for the green component of the color - :param b: integer from 0-255 for the blue component of the color - :raises ValueError: if r, g, or b is not in the range 0-255 - """ - if any(c < 0 or c > 255 for c in [r, g, b]): - raise ValueError("RGB values must be integers in the range of 0 to 255") - - self._sequence = f"{CSI}38;2;{r};{g};{b}m" - - def __str__(self) -> str: - """Return ANSI color sequence instead of enum name. - - This is helpful when using an RgbFg in an f-string or format() call - e.g. my_str = f"{RgbFg(0, 55, 100)}hello{Fg.RESET}". - """ - return self._sequence - - -class RgbBg(BgColor): - """Create ANSI sequences for 24-bit (RGB) terminal background text colors. The terminal must support 24-bit/true-color. - - To reset any background color, including 24-bit, use Bg.RESET. - """ - - def __init__(self, r: int, g: int, b: int) -> None: - """RgbBg initializer. - - :param r: integer from 0-255 for the red component of the color - :param g: integer from 0-255 for the green component of the color - :param b: integer from 0-255 for the blue component of the color - :raises ValueError: if r, g, or b is not in the range 0-255 - """ - if any(c < 0 or c > 255 for c in [r, g, b]): - raise ValueError("RGB values must be integers in the range of 0 to 255") - - self._sequence = f"{CSI}48;2;{r};{g};{b}m" - - def __str__(self) -> str: - """Return ANSI color sequence instead of enum name. - - This is helpful when using an RgbBg in an f-string or format() call - e.g. my_str = f"{RgbBg(100, 255, 27)}hello{Bg.RESET}". - """ - return self._sequence - - -def style( - value: Any, - *, - fg: Optional[FgColor] = None, - bg: Optional[BgColor] = None, - bold: Optional[bool] = None, - dim: Optional[bool] = None, - italic: Optional[bool] = None, - overline: Optional[bool] = None, - strikethrough: Optional[bool] = None, - underline: Optional[bool] = None, -) -> str: - """Apply ANSI colors and/or styles to a string and return it. - - The styling is self contained which means that at the end of the string reset code(s) are issued - to undo whatever styling was done at the beginning. - - :param value: object whose text is to be styled - :param fg: foreground color provided as any subclass of FgColor (e.g. Fg, EightBitFg, RgbFg) - Defaults to no color. - :param bg: foreground color provided as any subclass of BgColor (e.g. Bg, EightBitBg, RgbBg) - Defaults to no color. - :param bold: apply the bold style if True. Defaults to False. - :param dim: apply the dim style if True. Defaults to False. - :param italic: apply the italic style if True. Defaults to False. - :param overline: apply the overline style if True. Defaults to False. - :param strikethrough: apply the strikethrough style if True. Defaults to False. - :param underline: apply the underline style if True. Defaults to False. - :raises TypeError: if fg isn't None or a subclass of FgColor - :raises TypeError: if bg isn't None or a subclass of BgColor - :return: the stylized string - """ - # list of strings that add style - additions: list[AnsiSequence] = [] - - # list of strings that remove style - removals: list[AnsiSequence] = [] - - # Process the style settings - if fg is not None: - if not isinstance(fg, FgColor): - raise TypeError("fg must be a subclass of FgColor") - additions.append(fg) - removals.append(Fg.RESET) - - if bg is not None: - if not isinstance(bg, BgColor): - raise TypeError("bg must a subclass of BgColor") - additions.append(bg) - removals.append(Bg.RESET) - - if bold: - additions.append(TextStyle.INTENSITY_BOLD) - removals.append(TextStyle.INTENSITY_NORMAL) - - if dim: - additions.append(TextStyle.INTENSITY_DIM) - removals.append(TextStyle.INTENSITY_NORMAL) - - if italic: - additions.append(TextStyle.ITALIC_ENABLE) - removals.append(TextStyle.ITALIC_DISABLE) - - if overline: - additions.append(TextStyle.OVERLINE_ENABLE) - removals.append(TextStyle.OVERLINE_DISABLE) - - if strikethrough: - additions.append(TextStyle.STRIKETHROUGH_ENABLE) - removals.append(TextStyle.STRIKETHROUGH_DISABLE) - - if underline: - additions.append(TextStyle.UNDERLINE_ENABLE) - removals.append(TextStyle.UNDERLINE_DISABLE) - - # Combine the ANSI style sequences with the value's text - return "".join(map(str, additions)) + str(value) + "".join(map(str, removals)) - - -# Default styles for printing strings of various types. -# These can be altered to suit an application's needs and only need to be a -# function with the following structure: func(str) -> str -style_success = functools.partial(style, fg=Fg.GREEN) -"""Partial function supplying arguments to [cmd2.ansi.style][] which colors text to signify success""" - -style_warning = functools.partial(style, fg=Fg.LIGHT_YELLOW) -"""Partial function supplying arguments to [cmd2.ansi.style][] which colors text to signify a warning""" - -style_error = functools.partial(style, fg=Fg.LIGHT_RED) -"""Partial function supplying arguments to [cmd2.ansi.style][] which colors text to signify an error""" - - -def async_alert_str(*, terminal_columns: int, prompt: str, line: str, cursor_offset: int, alert_msg: str) -> str: - """Calculate the desired string, including ANSI escape codes, for displaying an asynchronous alert message. - - :param terminal_columns: terminal width (number of columns) - :param prompt: current onscreen prompt - :param line: current contents of the Readline line buffer - :param cursor_offset: the offset of the current cursor position within line - :param alert_msg: the message to display to the user - :return: the correct string so that the alert message appears to the user to be printed above the current line. - """ - # Split the prompt lines since it can contain newline characters. - prompt_lines = prompt.splitlines() or [''] - - # Calculate how many terminal lines are taken up by all prompt lines except for the last one. - # That will be included in the input lines calculations since that is where the cursor is. - num_prompt_terminal_lines = 0 - for prompt_line in prompt_lines[:-1]: - prompt_line_width = style_aware_wcswidth(prompt_line) - num_prompt_terminal_lines += int(prompt_line_width / terminal_columns) + 1 - - # Now calculate how many terminal lines are take up by the input - last_prompt_line = prompt_lines[-1] - last_prompt_line_width = style_aware_wcswidth(last_prompt_line) - - input_width = last_prompt_line_width + style_aware_wcswidth(line) - - num_input_terminal_lines = int(input_width / terminal_columns) + 1 - - # Get the cursor's offset from the beginning of the first input line - cursor_input_offset = last_prompt_line_width + cursor_offset - - # Calculate what input line the cursor is on - cursor_input_line = int(cursor_input_offset / terminal_columns) + 1 - - # Create a string that when printed will clear all input lines and display the alert - terminal_str = '' - - # Move the cursor down to the last input line - if cursor_input_line != num_input_terminal_lines: - terminal_str += Cursor.DOWN(num_input_terminal_lines - cursor_input_line) - - # Clear each line from the bottom up so that the cursor ends up on the first prompt line - total_lines = num_prompt_terminal_lines + num_input_terminal_lines - terminal_str += (clear_line() + Cursor.UP(1)) * (total_lines - 1) - - # Clear the first prompt line - terminal_str += clear_line() - - # Move the cursor to the beginning of the first prompt line and print the alert - terminal_str += '\r' + alert_msg - return terminal_str diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 44f64ee1c..1e366509c 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -1,4 +1,4 @@ -"""Module efines the ArgparseCompleter class which provides argparse-based tab completion to cmd2 apps. +"""Module defines the ArgparseCompleter class which provides argparse-based tab completion to cmd2 apps. See the header of argparse_custom.py for instructions on how to use these features. """ @@ -9,25 +9,24 @@ from collections import ( deque, ) +from collections.abc import Sequence from typing import ( + IO, TYPE_CHECKING, - Optional, - Union, cast, ) -from .ansi import ( - style_aware_wcswidth, - widest_line, -) -from .constants import ( - INFINITY, -) +from .constants import INFINITY +from .rich_utils import Cmd2GeneralConsole if TYPE_CHECKING: # pragma: no cover - from .cmd2 import ( - Cmd, - ) + from .cmd2 import Cmd + +from rich.box import SIMPLE_HEAD +from rich.table import ( + Column, + Table, +) from .argparse_custom import ( ChoicesCallable, @@ -35,20 +34,12 @@ CompletionItem, generate_range_error, ) -from .command_definition import ( - CommandSet, -) -from .exceptions import ( - CompletionError, -) -from .table_creator import ( - Column, - HorizontalAlignment, - SimpleTable, -) +from .command_definition import CommandSet +from .exceptions import CompletionError +from .styles import Cmd2Style -# If no descriptive header is supplied, then this will be used instead -DEFAULT_DESCRIPTIVE_HEADER = 'Description' +# If no descriptive headers are supplied, then this will be used instead +DEFAULT_DESCRIPTIVE_HEADERS: Sequence[str | Column] = ('Description',) # Name of the choice/completer function argument that, if present, will be passed a dictionary of # command line tokens up through the token being completed mapped to their argparse destination name. @@ -104,8 +95,8 @@ class _ArgumentState: def __init__(self, arg_action: argparse.Action) -> None: self.action = arg_action - self.min: Union[int, str] - self.max: Union[float, int, str] + self.min: int | str + self.max: float | int | str self.count = 0 self.is_remainder = self.action.nargs == argparse.REMAINDER @@ -140,7 +131,7 @@ def __init__(self, flag_arg_state: _ArgumentState) -> None: :param flag_arg_state: information about the unfinished flag action. """ arg = f'{argparse._get_action_name(flag_arg_state.action)}' - err = f'{generate_range_error(cast(int, flag_arg_state.min), cast(Union[int, float], flag_arg_state.max))}' + err = f'{generate_range_error(cast(int, flag_arg_state.min), cast(int | float, flag_arg_state.max))}' error = f"Error: argument {arg}: {err} ({flag_arg_state.count} entered)" super().__init__(error) @@ -162,7 +153,7 @@ class ArgparseCompleter: """Automatic command line tab completion based on argparse parameters.""" def __init__( - self, parser: argparse.ArgumentParser, cmd2_app: 'Cmd', *, parent_tokens: Optional[dict[str, list[str]]] = None + self, parser: argparse.ArgumentParser, cmd2_app: 'Cmd', *, parent_tokens: dict[str, list[str]] | None = None ) -> None: """Create an ArgparseCompleter. @@ -202,7 +193,7 @@ def __init__( self._subcommand_action = action def complete( - self, text: str, line: str, begidx: int, endidx: int, tokens: list[str], *, cmd_set: Optional[CommandSet] = None + self, text: str, line: str, begidx: int, endidx: int, tokens: list[str], *, cmd_set: CommandSet | None = None ) -> list[str]: """Complete text using argparse metadata. @@ -227,10 +218,10 @@ def complete( skip_remaining_flags = False # _ArgumentState of the current positional - pos_arg_state: Optional[_ArgumentState] = None + pos_arg_state: _ArgumentState | None = None # _ArgumentState of the current flag - flag_arg_state: Optional[_ArgumentState] = None + flag_arg_state: _ArgumentState | None = None # Non-reusable flags that we've parsed matched_flags: list[str] = [] @@ -522,7 +513,7 @@ def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, matche return matches - def _format_completions(self, arg_state: _ArgumentState, completions: Union[list[str], list[CompletionItem]]) -> list[str]: + def _format_completions(self, arg_state: _ArgumentState, completions: list[str] | list[CompletionItem]) -> list[str]: """Format CompletionItems into hint table.""" # Nothing to do if we don't have at least 2 completions which are all CompletionItems if len(completions) < 2 or not all(isinstance(c, CompletionItem) for c in completions): @@ -537,7 +528,7 @@ def _format_completions(self, arg_state: _ArgumentState, completions: Union[list if not self._cmd2_app.matches_sorted: # If all orig_value types are numbers, then sort by that value if all_nums: - completion_items.sort(key=lambda c: c.orig_value) # type: ignore[no-any-return] + completion_items.sort(key=lambda c: c.orig_value) # Otherwise sort as strings else: @@ -547,8 +538,6 @@ def _format_completions(self, arg_state: _ArgumentState, completions: Union[list # Check if there are too many CompletionItems to display as a table if len(completions) <= self._cmd2_app.max_completion_items: - four_spaces = 4 * ' ' - # If a metavar was defined, use that instead of the dest field destination = arg_state.action.metavar if arg_state.action.metavar else arg_state.action.dest @@ -561,39 +550,45 @@ def _format_completions(self, arg_state: _ArgumentState, completions: Union[list tuple_index = min(len(destination) - 1, arg_state.count) destination = destination[tuple_index] - desc_header = arg_state.action.get_descriptive_header() # type: ignore[attr-defined] - if desc_header is None: - desc_header = DEFAULT_DESCRIPTIVE_HEADER - - # Replace tabs with 4 spaces so we can calculate width - desc_header = desc_header.replace('\t', four_spaces) - - # Calculate needed widths for the token and description columns of the table - token_width = style_aware_wcswidth(destination) - desc_width = widest_line(desc_header) - - for item in completion_items: - token_width = max(style_aware_wcswidth(item), token_width) - - # Replace tabs with 4 spaces so we can calculate width - item.description = item.description.replace('\t', four_spaces) - desc_width = max(widest_line(item.description), desc_width) + desc_headers = cast(Sequence[str | Column] | None, arg_state.action.get_descriptive_headers()) # type: ignore[attr-defined] + if desc_headers is None: + desc_headers = DEFAULT_DESCRIPTIVE_HEADERS - cols = [] - dest_alignment = HorizontalAlignment.RIGHT if all_nums else HorizontalAlignment.LEFT - cols.append( + # Build all headers for the hint table + headers: list[Column] = [] + headers.append( Column( destination.upper(), - width=token_width, - header_horiz_align=dest_alignment, - data_horiz_align=dest_alignment, + justify="right" if all_nums else "left", + no_wrap=True, + ) + ) + for desc_header in desc_headers: + header = ( + desc_header + if isinstance(desc_header, Column) + else Column( + desc_header, + overflow="fold", + ) ) + headers.append(header) + + # Build the hint table + hint_table = Table( + *headers, + box=SIMPLE_HEAD, + show_edge=False, + border_style=Cmd2Style.TABLE_BORDER, ) - cols.append(Column(desc_header, width=desc_width)) + for item in completion_items: + hint_table.add_row(item, *item.descriptive_data) - hint_table = SimpleTable(cols, divider_char=self._cmd2_app.ruler) - table_data = [[item, item.description] for item in completion_items] - self._cmd2_app.formatted_completions = hint_table.generate_table(table_data, row_spacing=0) + # Generate the hint table string + console = Cmd2GeneralConsole() + with console.capture() as capture: + console.print(hint_table, end="") + self._cmd2_app.formatted_completions = capture.get() # Return sorted list of completions return cast(list[str], completions) @@ -624,24 +619,28 @@ def complete_subcommand_help(self, text: str, line: str, begidx: int, endidx: in break return [] - def format_help(self, tokens: list[str]) -> str: - """Supports cmd2's help command in the retrieval of help text. + def print_help(self, tokens: list[str], file: IO[str] | None = None) -> None: + """Supports cmd2's help command in the printing of help text. :param tokens: arguments passed to help command - :return: help text of the command being queried. + :param file: optional file object where the argparse should write help text + If not supplied, argparse will write to sys.stdout. """ - # If our parser has subcommands, we must examine the tokens and check if they are subcommands + # If our parser has subcommands, we must examine the tokens and check if they are subcommands. # If so, we will let the subcommand's parser handle the rest of the tokens via another ArgparseCompleter. - if self._subcommand_action is not None: - for token_index, token in enumerate(tokens): - if token in self._subcommand_action.choices: - parser: argparse.ArgumentParser = self._subcommand_action.choices[token] - completer_type = self._cmd2_app._determine_ap_completer_type(parser) + if tokens and self._subcommand_action is not None: + parser = cast( + argparse.ArgumentParser | None, + self._subcommand_action.choices.get(tokens[0]), + ) - completer = completer_type(parser, self._cmd2_app) - return completer.format_help(tokens[token_index + 1 :]) - break - return self._parser.format_help() + if parser: + completer_type = self._cmd2_app._determine_ap_completer_type(parser) + completer = completer_type(parser, self._cmd2_app) + completer.print_help(tokens[1:]) + return + + self._parser.print_help(file=file) def _complete_arg( self, @@ -652,7 +651,7 @@ def _complete_arg( arg_state: _ArgumentState, consumed_arg_values: dict[str, list[str]], *, - cmd_set: Optional[CommandSet] = None, + cmd_set: CommandSet | None = None, ) -> list[str]: """Tab completion routine for an argparse argument. @@ -660,7 +659,7 @@ def _complete_arg( :raises CompletionError: if the completer or choices function this calls raises one. """ # Check if the arg provides choices to the user - arg_choices: Union[list[str], ChoicesCallable] + arg_choices: list[str] | ChoicesCallable if arg_state.action.choices is not None: arg_choices = list(arg_state.action.choices) if not arg_choices: @@ -722,12 +721,12 @@ def _complete_arg( if not arg_choices.is_completer: choices_func = arg_choices.choices_provider if isinstance(choices_func, ChoicesProviderFuncWithTokens): - completion_items = choices_func(*args, **kwargs) # type: ignore[arg-type] + completion_items = choices_func(*args, **kwargs) else: # pragma: no cover # This won't hit because runtime checking doesn't check function argument types and will always # resolve true above. Mypy, however, does see the difference and gives an error that can't be # ignored. Mypy issue #5485 discusses this problem - completion_items = choices_func(*args) # type: ignore[arg-type] + completion_items = choices_func(*args) # else case is already covered above else: completion_items = arg_choices diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index d67ccbe8d..f7ba4c4ca 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -6,7 +6,7 @@ parser that inherits from it. This will give a consistent look-and-feel between the help/error output of built-in cmd2 commands and the app-specific commands. If you wish to override the parser used by cmd2's built-in commands, see -override_parser.py example. +custom_parser.py example. Since the new capabilities are added by patching at the argparse API level, they are available whether or not Cmd2ArgumentParser is used. However, the help @@ -122,38 +122,25 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens) numbers isn't very helpful to a user without context. Returning a list of CompletionItems instead of a regular string for completion results will signal the ArgparseCompleter to output the completion results in a table of completion -tokens with descriptions instead of just a table of tokens:: +tokens with descriptive data instead of just a table of tokens:: Instead of this: 1 2 3 The user sees this: - ITEM_ID Item Name - ============================ - 1 My item - 2 Another item - 3 Yet another item + ITEM_ID Description + ──────────────────────────── + 1 My item + 2 Another item + 3 Yet another item The left-most column is the actual value being tab completed and its header is that value's name. The right column header is defined using the -descriptive_header parameter of add_argument(). The right column values come -from the CompletionItem.description value. - -Example:: - - token = 1 - token_description = "My Item" - completion_item = CompletionItem(token, token_description) - -Since descriptive_header and CompletionItem.description are just strings, you -can format them in such a way to have multiple columns:: - - ITEM_ID Item Name Checked Out Due Date - ========================================================== - 1 My item True 02/02/2022 - 2 Another item False - 3 Yet another item False +``descriptive_headers`` parameter of add_argument(), which is a list of header +names that defaults to ["Description"]. The right column values come from the +``CompletionItem.descriptive_data`` member, which is a list with the same number +of items as columns defined in descriptive_headers. To use CompletionItems, just return them from your choices_provider or completer functions. They can also be used as argparse choices. When a @@ -162,12 +149,59 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens) argparse so that when evaluating choices, input is compared to CompletionItem.orig_value instead of the CompletionItem instance. -To avoid printing a ton of information to the screen at once when a user +Example:: + + Add an argument and define its descriptive_headers. + + parser.add_argument( + add_argument( + "item_id", + type=int, + choices_provider=get_items, + descriptive_headers=["Item Name", "Checked Out", "Due Date"], + ) + + Implement the choices_provider to return CompletionItems. + + def get_items(self) -> list[CompletionItems]: + \"\"\"choices_provider which returns CompletionItems\"\"\" + + # CompletionItem's second argument is descriptive_data. + # Its item count should match that of descriptive_headers. + return [ + CompletionItem(1, ["My item", True, "02/02/2022"]), + CompletionItem(2, ["Another item", False, ""]), + CompletionItem(3, ["Yet another item", False, ""]), + ] + + This is what the user will see during tab completion. + + ITEM_ID Item Name Checked Out Due Date + ─────────────────────────────────────────────────────── + 1 My item True 02/02/2022 + 2 Another item False + 3 Yet another item False + +``descriptive_headers`` can be strings or ``Rich.table.Columns`` for more +control over things like alignment. + +- If a header is a string, it will render as a left-aligned column with its +overflow behavior set to "fold". This means a long string will wrap within its +cell, creating as many new lines as required to fit. + +- If a header is a ``Column``, it defaults to "ellipsis" overflow behavior. +This means a long string which exceeds the width of its column will be +truncated with an ellipsis at the end. You can override this and other settings +when you create the ``Column``. + +``descriptive_data`` items can include Rich objects, including styled Text and Tables. + +To avoid printing a excessive information to the screen at once when a user presses tab, there is a maximum threshold for the number of CompletionItems -that will be shown. Its value is defined in cmd2.Cmd.max_completion_items. It -defaults to 50, but can be changed. If the number of completion suggestions +that will be shown. Its value is defined in ``cmd2.Cmd.max_completion_items``. +It defaults to 50, but can be changed. If the number of completion suggestions exceeds this number, they will be displayed in the typical columnized format -and will not include the description value of the CompletionItems. +and will not include the descriptive_data of the CompletionItems. **Patched argparse functions** @@ -200,8 +234,8 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens) - ``argparse.Action.get_choices_callable()`` - See `action_get_choices_callable` for more details. - ``argparse.Action.set_choices_provider()`` - See `_action_set_choices_provider` for more details. - ``argparse.Action.set_completer()`` - See `_action_set_completer` for more details. -- ``argparse.Action.get_descriptive_header()`` - See `_action_get_descriptive_header` for more details. -- ``argparse.Action.set_descriptive_header()`` - See `_action_set_descriptive_header` for more details. +- ``argparse.Action.get_descriptive_headers()`` - See `_action_get_descriptive_headers` for more details. +- ``argparse.Action.set_descriptive_headers()`` - See `_action_set_descriptive_headers` for more details. - ``argparse.Action.get_nargs_range()`` - See `_action_get_nargs_range` for more details. - ``argparse.Action.set_nargs_range()`` - See `_action_set_nargs_range` for more details. - ``argparse.Action.get_suppress_tab_hint()`` - See `_action_get_suppress_tab_hint` for more details. @@ -236,25 +270,35 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens) ) from gettext import gettext from typing import ( - IO, TYPE_CHECKING, Any, ClassVar, NoReturn, - Optional, Protocol, - Union, cast, runtime_checkable, ) -from rich_argparse import RawTextRichHelpFormatter - -from . import ( - ansi, - constants, +from rich.console import ( + Group, + RenderableType, +) +from rich.protocol import is_renderable +from rich.table import Column +from rich.text import Text +from rich_argparse import ( + ArgumentDefaultsRichHelpFormatter, + MetavarTypeRichHelpFormatter, + RawDescriptionRichHelpFormatter, + RawTextRichHelpFormatter, + RichHelpFormatter, ) +from . import constants +from . import rich_utils as ru +from .rich_utils import Cmd2RichArgparseConsole +from .styles import Cmd2Style + if TYPE_CHECKING: # pragma: no cover from .argparse_completer import ( ArgparseCompleter, @@ -280,6 +324,56 @@ def generate_range_error(range_min: int, range_max: float) -> str: return err_str +def set_parser_prog(parser: argparse.ArgumentParser, prog: str) -> None: + """Recursively set prog attribute of a parser and all of its subparsers. + + Does so that the root command is a command name and not sys.argv[0]. + + :param parser: the parser being edited + :param prog: new value for the parser's prog attribute + """ + # Set the prog value for this parser + parser.prog = prog + req_args: list[str] = [] + + # Set the prog value for the parser's subcommands + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + # Set the _SubParsersAction's _prog_prefix value. That way if its add_parser() method is called later, + # the correct prog value will be set on the parser being added. + action._prog_prefix = parser.prog + + # The keys of action.choices are subcommand names as well as subcommand aliases. The aliases point to the + # same parser as the actual subcommand. We want to avoid placing an alias into a parser's prog value. + # Unfortunately there is nothing about an action.choices entry which tells us it's an alias. In most cases + # we can filter out the aliases by checking the contents of action._choices_actions. This list only contains + # help information and names for the subcommands and not aliases. However, subcommands without help text + # won't show up in that list. Since dictionaries are ordered in Python 3.6 and above and argparse inserts the + # subcommand name into choices dictionary before aliases, we should be OK assuming the first time we see a + # parser, the dictionary key is a subcommand and not alias. + processed_parsers = [] + + # Set the prog value for each subcommand's parser + for subcmd_name, subcmd_parser in action.choices.items(): + # Check if we've already edited this parser + if subcmd_parser in processed_parsers: + continue + + subcmd_prog = parser.prog + if req_args: + subcmd_prog += " " + " ".join(req_args) + subcmd_prog += " " + subcmd_name + set_parser_prog(subcmd_parser, subcmd_prog) + processed_parsers.append(subcmd_parser) + + # We can break since argparse only allows 1 group of subcommands per level + break + + # Need to save required args so they can be prepended to the subcommand usage + if action.required: + req_args.append(action.dest) + + class CompletionItem(str): # noqa: SLOT000 """Completion item with descriptive text attached. @@ -290,15 +384,22 @@ def __new__(cls, value: object, *_args: Any, **_kwargs: Any) -> 'CompletionItem' """Responsible for creating and returning a new instance, called before __init__ when an object is instantiated.""" return super().__new__(cls, value) - def __init__(self, value: object, description: str = '', *args: Any) -> None: + def __init__(self, value: object, descriptive_data: Sequence[Any], *args: Any) -> None: """CompletionItem Initializer. :param value: the value being tab completed - :param description: description text to display + :param descriptive_data: a list of descriptive data to display in the columns that follow + the completion value. The number of items in this list must equal + the number of descriptive headers defined for the argument. :param args: args for str __init__ """ super().__init__(*args) - self.description = description + + # Make sure all objects are renderable by a Rich table. + renderable_data = [obj if is_renderable(obj) else str(obj) for obj in descriptive_data] + + # Convert strings containing ANSI style sequences to Rich Text objects for correct display width. + self.descriptive_data = ru.prepare_objects_for_rendering(*renderable_data) # Save the original value to support CompletionItems as argparse choices. # cmd2 has patched argparse so input is compared to this value instead of the CompletionItem instance. @@ -331,7 +432,7 @@ def __call__(self, *, arg_tokens: dict[str, list[str]] = {}) -> list[str]: # pr """Enable instances to be called like functions.""" -ChoicesProviderFunc = Union[ChoicesProviderFuncBase, ChoicesProviderFuncWithTokens] +ChoicesProviderFunc = ChoicesProviderFuncBase | ChoicesProviderFuncWithTokens @runtime_checkable @@ -364,7 +465,7 @@ def __call__( """Enable instances to be called like functions.""" -CompleterFunc = Union[CompleterFuncBase, CompleterFuncWithTokens] +CompleterFunc = CompleterFuncBase | CompleterFuncWithTokens class ChoicesCallable: @@ -376,7 +477,7 @@ class ChoicesCallable: def __init__( self, is_completer: bool, - to_call: Union[CompleterFunc, ChoicesProviderFunc], + to_call: CompleterFunc | ChoicesProviderFunc, ) -> None: """Initialize the ChoiceCallable instance. @@ -424,7 +525,7 @@ def choices_provider(self) -> ChoicesProviderFunc: ATTR_CHOICES_CALLABLE = 'choices_callable' # Descriptive header that prints when using CompletionItems -ATTR_DESCRIPTIVE_HEADER = 'descriptive_header' +ATTR_DESCRIPTIVE_HEADERS = 'descriptive_headers' # A tuple specifying nargs as a range (min, max) ATTR_NARGS_RANGE = 'nargs_range' @@ -437,7 +538,7 @@ def choices_provider(self) -> ChoicesProviderFunc: ############################################################################################################ # Patch argparse.Action with accessors for choice_callable attribute ############################################################################################################ -def _action_get_choices_callable(self: argparse.Action) -> Optional[ChoicesCallable]: +def _action_get_choices_callable(self: argparse.Action) -> ChoicesCallable | None: """Get the choices_callable attribute of an argparse Action. This function is added by cmd2 as a method called ``get_choices_callable()`` to ``argparse.Action`` class. @@ -447,7 +548,7 @@ def _action_get_choices_callable(self: argparse.Action) -> Optional[ChoicesCalla :param self: argparse Action being queried :return: A ChoicesCallable instance or None if attribute does not exist """ - return cast(Optional[ChoicesCallable], getattr(self, ATTR_CHOICES_CALLABLE, None)) + return cast(ChoicesCallable | None, getattr(self, ATTR_CHOICES_CALLABLE, None)) setattr(argparse.Action, 'get_choices_callable', _action_get_choices_callable) @@ -521,44 +622,44 @@ def _action_set_completer( ############################################################################################################ -# Patch argparse.Action with accessors for descriptive_header attribute +# Patch argparse.Action with accessors for descriptive_headers attribute ############################################################################################################ -def _action_get_descriptive_header(self: argparse.Action) -> Optional[str]: - """Get the descriptive_header attribute of an argparse Action. +def _action_get_descriptive_headers(self: argparse.Action) -> Sequence[str | Column] | None: + """Get the descriptive_headers attribute of an argparse Action. - This function is added by cmd2 as a method called ``get_descriptive_header()`` to ``argparse.Action`` class. + This function is added by cmd2 as a method called ``get_descriptive_headers()`` to ``argparse.Action`` class. - To call: ``action.get_descriptive_header()`` + To call: ``action.get_descriptive_headers()`` :param self: argparse Action being queried - :return: The value of descriptive_header or None if attribute does not exist + :return: The value of descriptive_headers or None if attribute does not exist """ - return cast(Optional[str], getattr(self, ATTR_DESCRIPTIVE_HEADER, None)) + return cast(Sequence[str | Column] | None, getattr(self, ATTR_DESCRIPTIVE_HEADERS, None)) -setattr(argparse.Action, 'get_descriptive_header', _action_get_descriptive_header) +setattr(argparse.Action, 'get_descriptive_headers', _action_get_descriptive_headers) -def _action_set_descriptive_header(self: argparse.Action, descriptive_header: Optional[str]) -> None: - """Set the descriptive_header attribute of an argparse Action. +def _action_set_descriptive_headers(self: argparse.Action, descriptive_headers: Sequence[str | Column] | None) -> None: + """Set the descriptive_headers attribute of an argparse Action. - This function is added by cmd2 as a method called ``set_descriptive_header()`` to ``argparse.Action`` class. + This function is added by cmd2 as a method called ``set_descriptive_headers()`` to ``argparse.Action`` class. - To call: ``action.set_descriptive_header(descriptive_header)`` + To call: ``action.set_descriptive_headers(descriptive_headers)`` :param self: argparse Action being updated - :param descriptive_header: value being assigned + :param descriptive_headers: value being assigned """ - setattr(self, ATTR_DESCRIPTIVE_HEADER, descriptive_header) + setattr(self, ATTR_DESCRIPTIVE_HEADERS, descriptive_headers) -setattr(argparse.Action, 'set_descriptive_header', _action_set_descriptive_header) +setattr(argparse.Action, 'set_descriptive_headers', _action_set_descriptive_headers) ############################################################################################################ # Patch argparse.Action with accessors for nargs_range attribute ############################################################################################################ -def _action_get_nargs_range(self: argparse.Action) -> Optional[tuple[int, Union[int, float]]]: +def _action_get_nargs_range(self: argparse.Action) -> tuple[int, int | float] | None: """Get the nargs_range attribute of an argparse Action. This function is added by cmd2 as a method called ``get_nargs_range()`` to ``argparse.Action`` class. @@ -568,13 +669,13 @@ def _action_get_nargs_range(self: argparse.Action) -> Optional[tuple[int, Union[ :param self: argparse Action being queried :return: The value of nargs_range or None if attribute does not exist """ - return cast(Optional[tuple[int, Union[int, float]]], getattr(self, ATTR_NARGS_RANGE, None)) + return cast(tuple[int, int | float] | None, getattr(self, ATTR_NARGS_RANGE, None)) setattr(argparse.Action, 'get_nargs_range', _action_get_nargs_range) -def _action_set_nargs_range(self: argparse.Action, nargs_range: Optional[tuple[int, Union[int, float]]]) -> None: +def _action_set_nargs_range(self: argparse.Action, nargs_range: tuple[int, int | float] | None) -> None: """Set the nargs_range attribute of an argparse Action. This function is added by cmd2 as a method called ``set_nargs_range()`` to ``argparse.Action`` class. @@ -633,7 +734,7 @@ def _action_set_suppress_tab_hint(self: argparse.Action, suppress_tab_hint: bool _CUSTOM_ATTRIB_PFX = '_attr_' -def register_argparse_argument_parameter(param_name: str, param_type: Optional[type[Any]]) -> None: +def register_argparse_argument_parameter(param_name: str, param_type: type[Any] | None) -> None: """Register a custom argparse argument parameter. The registered name will then be a recognized keyword parameter to the parser's `add_argument()` function. @@ -699,11 +800,11 @@ def _action_set_custom_parameter(self: argparse.Action, value: Any) -> None: def _add_argument_wrapper( self: argparse._ActionsContainer, *args: Any, - nargs: Union[int, str, tuple[int], tuple[int, int], tuple[int, float], None] = None, - choices_provider: Optional[ChoicesProviderFunc] = None, - completer: Optional[CompleterFunc] = None, + nargs: int | str | tuple[int] | tuple[int, int] | tuple[int, float] | None = None, + choices_provider: ChoicesProviderFunc | None = None, + completer: CompleterFunc | None = None, suppress_tab_hint: bool = False, - descriptive_header: Optional[str] = None, + descriptive_headers: list[Column | str] | None = None, **kwargs: Any, ) -> argparse.Action: """Wrap ActionsContainer.add_argument() which supports more settings used by cmd2. @@ -723,8 +824,8 @@ def _add_argument_wrapper( current argument's help text as a hint. Set this to True to suppress the hint. If this argument's help text is set to argparse.SUPPRESS, then tab hints will not display regardless of the value passed for suppress_tab_hint. Defaults to False. - :param descriptive_header: if the provided choices are CompletionItems, then this header will display - during tab completion. Defaults to None. + :param descriptive_headers: if the provided choices are CompletionItems, then these are the headers + of the descriptive data. Defaults to None. # Args from original function :param kwargs: keyword-arguments recognized by argparse._ActionsContainer.add_argument @@ -749,7 +850,7 @@ def _add_argument_wrapper( nargs_range = None if nargs is not None: - nargs_adjusted: Union[int, str, tuple[int], tuple[int, int], tuple[int, float], None] + nargs_adjusted: int | str | tuple[int] | tuple[int, int] | tuple[int, float] | None # Check if nargs was given as a range if isinstance(nargs, tuple): # Handle 1-item tuple by setting max to INFINITY @@ -759,11 +860,11 @@ def _add_argument_wrapper( # Validate nargs tuple if ( len(nargs) != 2 - or not isinstance(nargs[0], int) # type: ignore[unreachable] - or not (isinstance(nargs[1], int) or nargs[1] == constants.INFINITY) # type: ignore[misc] + or not isinstance(nargs[0], int) + or not (isinstance(nargs[1], int) or nargs[1] == constants.INFINITY) ): raise ValueError('Ranged values for nargs must be a tuple of 1 or 2 integers') - if nargs[0] >= nargs[1]: # type: ignore[misc] + if nargs[0] >= nargs[1]: raise ValueError('Invalid nargs range. The first value must be less than the second') if nargs[0] < 0: raise ValueError('Negative numbers are invalid for nargs range') @@ -771,7 +872,7 @@ def _add_argument_wrapper( # Save the nargs tuple as our range setting nargs_range = nargs range_min = nargs_range[0] - range_max = nargs_range[1] # type: ignore[misc] + range_max = nargs_range[1] # Convert nargs into a format argparse recognizes if range_min == 0: @@ -807,7 +908,7 @@ def _add_argument_wrapper( new_arg = orig_actions_container_add_argument(self, *args, **kwargs) # Set the custom attributes - new_arg.set_nargs_range(nargs_range) # type: ignore[arg-type, attr-defined] + new_arg.set_nargs_range(nargs_range) # type: ignore[attr-defined] if choices_provider: new_arg.set_choices_provider(choices_provider) # type: ignore[attr-defined] @@ -815,7 +916,7 @@ def _add_argument_wrapper( 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_descriptive_header(descriptive_header) # type: ignore[attr-defined] + new_arg.set_descriptive_headers(descriptive_headers) # type: ignore[attr-defined] for keyword, value in custom_attribs.items(): attr_setter = getattr(new_arg, f'set_{keyword}', None) @@ -890,7 +991,7 @@ def _match_argument_wrapper(self: argparse.ArgumentParser, action: argparse.Acti ATTR_AP_COMPLETER_TYPE = 'ap_completer_type' -def _ArgumentParser_get_ap_completer_type(self: argparse.ArgumentParser) -> Optional[type['ArgparseCompleter']]: # noqa: N802 +def _ArgumentParser_get_ap_completer_type(self: argparse.ArgumentParser) -> type['ArgparseCompleter'] | None: # noqa: N802 """Get the ap_completer_type attribute of an argparse ArgumentParser. This function is added by cmd2 as a method called ``get_ap_completer_type()`` to ``argparse.ArgumentParser`` class. @@ -900,7 +1001,7 @@ def _ArgumentParser_get_ap_completer_type(self: argparse.ArgumentParser) -> Opti :param self: ArgumentParser being queried :return: An ArgparseCompleter-based class or None if attribute does not exist """ - return cast(Optional[type['ArgparseCompleter']], getattr(self, ATTR_AP_COMPLETER_TYPE, None)) + return cast(type['ArgparseCompleter'] | None, getattr(self, ATTR_AP_COMPLETER_TYPE, None)) setattr(argparse.ArgumentParser, 'get_ap_completer_type', _ArgumentParser_get_ap_completer_type) @@ -996,13 +1097,9 @@ def _SubParsersAction_remove_parser(self: argparse._SubParsersAction, name: str) ############################################################################################################ -class Cmd2HelpFormatter(RawTextRichHelpFormatter): +class Cmd2HelpFormatter(RichHelpFormatter): """Custom help formatter to configure ordering of help text.""" - # rich-argparse formats all group names with str.title(). - # Override their formatter to do nothing. - group_name_formatter: ClassVar[Callable[[str], str]] = str - # Disable automatic highlighting in the help text. highlights: ClassVar[list[str]] = [] @@ -1015,12 +1112,28 @@ class Cmd2HelpFormatter(RawTextRichHelpFormatter): help_markup: ClassVar[bool] = False text_markup: ClassVar[bool] = False + def __init__( + self, + prog: str, + indent_increment: int = 2, + max_help_position: int = 24, + width: int | None = None, + *, + console: Cmd2RichArgparseConsole | None = None, + **kwargs: Any, + ) -> None: + """Initialize Cmd2HelpFormatter.""" + if console is None: + console = Cmd2RichArgparseConsole() + + super().__init__(prog, indent_increment, max_help_position, width, console=console, **kwargs) + def _format_usage( self, - usage: Optional[str], + usage: str | None, actions: Iterable[argparse.Action], groups: Iterable[argparse._ArgumentGroup], - prefix: Optional[str] = None, + prefix: str | None = None, ) -> str: if prefix is None: prefix = gettext('Usage: ') @@ -1074,7 +1187,7 @@ def _format_usage( # End cmd2 customization # helper for wrapping lines - def get_lines(parts: list[str], indent: str, prefix: Optional[str] = None) -> list[str]: + def get_lines(parts: list[str], indent: str, prefix: str | None = None) -> list[str]: lines: list[str] = [] line: list[str] = [] line_len = len(prefix) - 1 if prefix is not None else len(indent) - 1 @@ -1154,8 +1267,8 @@ def _format_action_invocation(self, action: argparse.Action) -> str: def _determine_metavar( self, action: argparse.Action, - default_metavar: Union[str, tuple[str, ...]], - ) -> Union[str, tuple[str, ...]]: + default_metavar: str, + ) -> str | tuple[str, ...]: """Determine what to use as the metavar value of an action.""" if action.metavar is not None: result = action.metavar @@ -1171,7 +1284,7 @@ def _determine_metavar( def _metavar_formatter( self, action: argparse.Action, - default_metavar: Union[str, tuple[str, ...]], + default_metavar: str, ) -> Callable[[int], tuple[str, ...]]: metavar = self._determine_metavar(action, default_metavar) @@ -1182,7 +1295,7 @@ def format_tuple(tuple_size: int) -> tuple[str, ...]: return format_tuple - def _format_args(self, action: argparse.Action, default_metavar: Union[str, tuple[str, ...]]) -> str: + def _format_args(self, action: argparse.Action, default_metavar: str) -> str: """Handle ranged nargs and make other output less verbose.""" metavar = self._determine_metavar(action, default_metavar) metavar_formatter = self._metavar_formatter(action, default_metavar) @@ -1207,20 +1320,93 @@ def _format_args(self, action: argparse.Action, default_metavar: Union[str, tupl return super()._format_args(action, default_metavar) # type: ignore[arg-type] +class RawDescriptionCmd2HelpFormatter( + RawDescriptionRichHelpFormatter, + Cmd2HelpFormatter, +): + """Cmd2 help message formatter which retains any formatting in descriptions and epilogs.""" + + +class RawTextCmd2HelpFormatter( + RawTextRichHelpFormatter, + Cmd2HelpFormatter, +): + """Cmd2 help message formatter which retains formatting of all help text.""" + + +class ArgumentDefaultsCmd2HelpFormatter( + ArgumentDefaultsRichHelpFormatter, + Cmd2HelpFormatter, +): + """Cmd2 help message formatter which adds default values to argument help.""" + + +class MetavarTypeCmd2HelpFormatter( + MetavarTypeRichHelpFormatter, + Cmd2HelpFormatter, +): + """Cmd2 help message formatter which uses the argument 'type' as the default + metavar value (instead of the argument 'dest'). + """ # noqa: D205 + + +class TextGroup: + """A block of text which is formatted like an argparse argument group, including a title. + + Title: + Here is the first row of text. + Here is yet another row of text. + """ + + def __init__( + self, + title: str, + text: RenderableType, + formatter_creator: Callable[[], Cmd2HelpFormatter], + ) -> None: + """TextGroup initializer. + + :param title: the group's title + :param text: the group's text (string or object that may be rendered by Rich) + :param formatter_creator: callable which returns a Cmd2HelpFormatter instance + """ + self.title = title + self.text = text + self.formatter_creator = formatter_creator + + def __rich__(self) -> Group: + """Return a renderable Rich Group object for the class instance. + + This method formats the title and indents the text to match argparse + group styling, making the object displayable by a Rich console. + """ + formatter = self.formatter_creator() + + styled_title = Text( + type(formatter).group_name_formatter(f"{self.title}:"), + style=formatter.styles["argparse.groups"], + ) + + # Indent text like an argparse argument group does + indented_text = ru.indent(self.text, formatter._indent_increment) + + return Group(styled_title, indented_text) + + class Cmd2ArgumentParser(argparse.ArgumentParser): """Custom ArgumentParser class that improves error and help output.""" def __init__( self, - prog: Optional[str] = None, - usage: Optional[str] = None, - description: Optional[str] = None, - epilog: Optional[str] = None, + prog: str | None = None, + usage: str | None = None, + description: RenderableType | None = None, + epilog: RenderableType | None = None, parents: Sequence[argparse.ArgumentParser] = (), - formatter_class: type[argparse.HelpFormatter] = Cmd2HelpFormatter, + formatter_class: type[Cmd2HelpFormatter] = Cmd2HelpFormatter, prefix_chars: str = '-', - fromfile_prefix_chars: Optional[str] = None, - argument_default: Optional[str] = None, + fromfile_prefix_chars: str | None = None, + argument_default: str | None = None, conflict_handler: str = 'error', add_help: bool = True, allow_abbrev: bool = True, @@ -1228,7 +1414,7 @@ def __init__( suggest_on_error: bool = False, color: bool = False, *, - ap_completer_type: Optional[type['ArgparseCompleter']] = None, + ap_completer_type: type['ArgparseCompleter'] | None = None, ) -> None: """Initialize the Cmd2ArgumentParser instance, a custom ArgumentParser added by cmd2. @@ -1247,10 +1433,10 @@ def __init__( super().__init__( prog=prog, usage=usage, - description=description, - epilog=epilog, + description=description, # type: ignore[arg-type] + epilog=epilog, # type: ignore[arg-type] parents=parents if parents else [], - formatter_class=formatter_class, # type: ignore[arg-type] + formatter_class=formatter_class, prefix_chars=prefix_chars, fromfile_prefix_chars=fromfile_prefix_chars, argument_default=argument_default, @@ -1261,6 +1447,10 @@ def __init__( **kwargs, # added in Python 3.14 ) + # Recast to assist type checkers since these can be Rich renderables in a Cmd2HelpFormatter. + self.description: RenderableType | None = self.description # type: ignore[assignment] + self.epilog: RenderableType | None = self.epilog # type: ignore[assignment] + self.set_ap_completer_type(ap_completer_type) # type: ignore[attr-defined] def add_subparsers(self, **kwargs: Any) -> argparse._SubParsersAction: # type: ignore[type-arg] @@ -1290,8 +1480,18 @@ def error(self, message: str) -> NoReturn: formatted_message += '\n ' + line self.print_usage(sys.stderr) - formatted_message = ansi.style_error(formatted_message) - self.exit(2, f'{formatted_message}\n\n') + + # Add error style to message + console = self._get_formatter().console + with console.capture() as capture: + console.print(formatted_message, style=Cmd2Style.ERROR, crop=False) + formatted_message = f"{capture.get()}" + + self.exit(2, f'{formatted_message}\n') + + def _get_formatter(self) -> Cmd2HelpFormatter: + """Override _get_formatter with customizations for Cmd2HelpFormatter.""" + return cast(Cmd2HelpFormatter, super()._get_formatter()) def format_help(self) -> str: """Return a string containing a help message, including the program usage and information about the arguments. @@ -1301,7 +1501,7 @@ def format_help(self) -> str: formatter = self._get_formatter() # usage - formatter.add_usage(self.usage, self._actions, self._mutually_exclusive_groups) # type: ignore[arg-type] + formatter.add_usage(self.usage, self._actions, self._mutually_exclusive_groups) # description formatter.add_text(self.description) @@ -1310,10 +1510,7 @@ def format_help(self) -> str: # positionals, optionals and user-defined groups for action_group in self._action_groups: - if sys.version_info >= (3, 10): - default_options_group = action_group.title == 'options' - else: - default_options_group = action_group.title == 'optional arguments' + default_options_group = action_group.title == 'options' if default_options_group: # check if the arguments are required, group accordingly @@ -1350,12 +1547,9 @@ def format_help(self) -> str: # determine help from format above return formatter.format_help() + '\n' - def _print_message(self, message: str, file: Optional[IO[str]] = None) -> None: # type: ignore[override] - # Override _print_message to use style_aware_write() since we use ANSI escape characters to support color - if message: - if file is None: - file = sys.stderr - ansi.style_aware_write(file, message) + def create_text_group(self, title: str, text: RenderableType) -> TextGroup: + """Create a TextGroup using this parser's formatter creator.""" + return TextGroup(title, text, self._get_formatter) class Cmd2AttributeWrapper: @@ -1378,15 +1572,20 @@ def set(self, new_val: Any) -> None: self.__attribute = new_val -# The default ArgumentParser class for a cmd2 app -DEFAULT_ARGUMENT_PARSER: type[argparse.ArgumentParser] = Cmd2ArgumentParser +# Parser type used by cmd2's built-in commands. +# Set it using cmd2.set_default_argument_parser_type(). +DEFAULT_ARGUMENT_PARSER: type[Cmd2ArgumentParser] = Cmd2ArgumentParser + + +def set_default_argument_parser_type(parser_type: type[Cmd2ArgumentParser]) -> None: + """Set the default ArgumentParser class for cmd2's built-in commands. + Since built-in commands rely on customizations made in Cmd2ArgumentParser, + your custom parser class should inherit from Cmd2ArgumentParser. -def set_default_argument_parser_type(parser_type: type[argparse.ArgumentParser]) -> None: - """Set the default ArgumentParser class for a cmd2 app. + This should be called prior to instantiating your CLI object. - This must be called prior to loading cmd2.py if you want to override the parser for cmd2's built-in commands. - See examples/override_parser.py. + See examples/custom_parser.py. """ global DEFAULT_ARGUMENT_PARSER # noqa: PLW0603 DEFAULT_ARGUMENT_PARSER = parser_type diff --git a/cmd2/clipboard.py b/cmd2/clipboard.py index 284d57df5..4f78925cf 100644 --- a/cmd2/clipboard.py +++ b/cmd2/clipboard.py @@ -2,7 +2,7 @@ import typing -import pyperclip # type: ignore[import] +import pyperclip # type: ignore[import-untyped] def get_paste_buffer() -> str: diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 898aad076..a1c257917 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -10,11 +10,11 @@ Settable environment parameters Parsing commands with `argparse` argument parsers (flags) Redirection to file or paste buffer (clipboard) with > or >> -Easy transcript-based testing of applications (see examples/example.py) +Easy transcript-based testing of applications (see examples/transcript_example.py) Bash-style ``select`` available -Note that redirection with > and | will only work if `self.poutput()` -is used in place of `print`. +Note, if self.stdout is different than sys.stdout, then redirection with > and | +will only work if `self.poutput()` is used in place of `print`. - Catherine Devlin, Jan 03 2008 - catherinedevlin.blogspot.com @@ -24,10 +24,7 @@ # This module has many imports, quite a few of which are only # infrequently utilized. To reduce the initial overhead of # import this module, many of these imports are lazy-loaded -# i.e. we only import the module when we use it -# For example, we don't import the 'traceback' module -# until the pexcept() function is called and the debug -# setting is True +# i.e. we only import the module when we use it. import argparse import cmd import contextlib @@ -36,7 +33,6 @@ import glob import inspect import os -import pprint import pydoc import re import sys @@ -61,23 +57,36 @@ TYPE_CHECKING, Any, ClassVar, - Optional, TextIO, TypeVar, Union, cast, ) +import rich.box +from rich.console import Group +from rich.highlighter import ReprHighlighter +from rich.rule import Rule +from rich.style import Style, StyleType +from rich.table import ( + Column, + Table, +) +from rich.text import Text +from rich.traceback import Traceback + from . import ( - ansi, argparse_completer, argparse_custom, constants, plugin, utils, ) +from . import rich_utils as ru +from . import string_utils as su from .argparse_custom import ( ChoicesProviderFunc, + Cmd2ArgumentParser, CompleterFunc, CompletionItem, ) @@ -122,10 +131,16 @@ StatementParser, shlex_split, ) +from .rich_utils import ( + Cmd2ExceptionConsole, + Cmd2GeneralConsole, + RichPrintKwargs, +) +from .styles import Cmd2Style # NOTE: When using gnureadline with Python 3.13, start_ipython needs to be imported before any readline-related stuff with contextlib.suppress(ImportError): - from IPython import start_ipython # type: ignore[import] + from IPython import start_ipython from .rl_utils import ( RlType, @@ -139,10 +154,6 @@ rl_warning, vt100_support, ) -from .table_creator import ( - Column, - SimpleTable, -) from .utils import ( Settable, get_defining_class, @@ -153,9 +164,9 @@ # Set up readline if rl_type == RlType.NONE: # pragma: no cover - sys.stderr.write(ansi.style_warning(rl_warning)) + Cmd2GeneralConsole(sys.stderr).print(rl_warning, style=Cmd2Style.WARNING) else: - from .rl_utils import ( # type: ignore[attr-defined] + from .rl_utils import ( readline, rl_force_redisplay, ) @@ -185,7 +196,7 @@ class _SavedReadlineSettings: def __init__(self) -> None: self.completer = None self.delims = '' - self.basic_quotes: Optional[bytes] = None + self.basic_quotes: bytes | None = None class _SavedCmd2Env: @@ -193,10 +204,8 @@ class _SavedCmd2Env: def __init__(self) -> None: self.readline_settings = _SavedReadlineSettings() - self.readline_module: Optional[ModuleType] = None + self.readline_module: ModuleType | None = None self.history: list[str] = [] - self.sys_stdout: Optional[TextIO] = None - self.sys_stdin: Optional[TextIO] = None # Contains data about a disabled command which is used to restore its original functions when the command is enabled @@ -205,7 +214,7 @@ def __init__(self) -> None: if TYPE_CHECKING: # pragma: no cover StaticArgParseBuilder = staticmethod[[], argparse.ArgumentParser] - ClassArgParseBuilder = classmethod[Union['Cmd', CommandSet], [], argparse.ArgumentParser] + ClassArgParseBuilder = classmethod['Cmd' | CommandSet, [], argparse.ArgumentParser] else: StaticArgParseBuilder = staticmethod ClassArgParseBuilder = classmethod @@ -241,7 +250,7 @@ def __contains__(self, command_method: CommandFunc) -> bool: parser = self.get(command_method) return bool(parser) - def get(self, command_method: CommandFunc) -> Optional[argparse.ArgumentParser]: + def get(self, command_method: CommandFunc) -> argparse.ArgumentParser | 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. @@ -263,8 +272,8 @@ def get(self, command_method: CommandFunc) -> Optional[argparse.ArgumentParser]: parser = self._cmd._build_parser(parent, parser_builder, command) # If the description has not been set, then use the method docstring if one exists - if parser.description is None and hasattr(command_method, '__wrapped__') and command_method.__wrapped__.__doc__: - parser.description = strip_doc_annotations(command_method.__wrapped__.__doc__) + if parser.description is None and command_method.__doc__: + parser.description = strip_doc_annotations(command_method.__doc__) self._parsers[full_method_name] = parser @@ -288,12 +297,8 @@ class Cmd(cmd.Cmd): DEFAULT_EDITOR = utils.find_editor() - INTERNAL_COMMAND_EPILOG = ( - "Notes:\n This command is for internal use and is not intended to be called from the\n command line." - ) - # Sorting keys for strings - ALPHABETICAL_SORT_KEY = utils.norm_fold + ALPHABETICAL_SORT_KEY = su.norm_fold NATURAL_SORT_KEY = utils.natural_keys # List for storing transcript test file names @@ -302,8 +307,8 @@ class Cmd(cmd.Cmd): def __init__( self, completekey: str = 'tab', - stdin: Optional[TextIO] = None, - stdout: Optional[TextIO] = None, + stdin: TextIO | None = None, + stdout: TextIO | None = None, *, persistent_history_file: str = '', persistent_history_length: int = 1000, @@ -312,12 +317,12 @@ def __init__( include_py: bool = False, include_ipy: bool = False, allow_cli_args: bool = True, - transcript_files: Optional[list[str]] = None, + transcript_files: list[str] | None = None, allow_redirection: bool = True, - multiline_commands: Optional[list[str]] = None, - terminators: Optional[list[str]] = None, - shortcuts: Optional[dict[str, str]] = None, - command_sets: Optional[Iterable[CommandSet]] = None, + multiline_commands: list[str] | None = None, + terminators: list[str] | None = None, + shortcuts: dict[str, str] | None = None, + command_sets: Iterable[CommandSet] | None = None, auto_load_commands: bool = True, allow_clipboard: bool = True, suggest_similar_command: bool = False, @@ -417,7 +422,7 @@ def __init__( # Use as prompt for multiline commands on the 2nd+ line of input self.continuation_prompt: str = '> ' - # Allow access to your application in embedded Python shells and scripts py via self + # Allow access to your application in embedded Python shells and pyscripts via self self.self_in_py = False # Commands to exclude from the help menu and tab completion @@ -461,7 +466,7 @@ def __init__( # If the current command created a process to pipe to, then this will be a ProcReader object. # Otherwise it will be None. It's used to know when a pipe process can be killed and/or waited upon. - self._cur_pipe_proc_reader: Optional[utils.ProcReader] = None + self._cur_pipe_proc_reader: utils.ProcReader | None = None # Used to keep track of whether we are redirecting or piping output self._redirecting = False @@ -472,8 +477,24 @@ def __init__( # The multiline command currently being typed which is used to tab complete multiline commands. self._multiline_in_progress = '' - # Set the header used for the help function's listing of documented functions - self.doc_header = "Documented commands (use 'help -v' for verbose/'help ' for details):" + # Characters used to draw a horizontal rule. Should not be blank. + self.ruler = "─" + + # Set text which prints right before all of the help tables are listed. + self.doc_leader = "" + + # Set header for table listing documented commands. + self.doc_header = "Documented Commands" + + # Set header for table listing help topics not related to a command. + self.misc_header = "Miscellaneous Help Topics" + + # Set header for table listing commands that have no help info. + self.undoc_header = "Undocumented Commands" + + # If any command has been categorized, then all other documented commands that + # haven't been categorized will display under this section in the help output. + self.default_category = "Uncategorized Commands" # The error that prints when no help information can be found self.help_error = "No help on {}" @@ -487,17 +508,30 @@ def __init__( # Commands that will run at the beginning of the command loop self._startup_commands: list[str] = [] + # Store initial termios settings to restore after each command. + # This is a faster way of accomplishing what "stty sane" does. + self._initial_termios_settings = None + if not sys.platform.startswith('win') and self.stdin.isatty(): + try: + import io + import termios + + self._initial_termios_settings = termios.tcgetattr(self.stdin.fileno()) + except (ImportError, io.UnsupportedOperation, termios.error): + # This can happen if termios isn't available or stdin is a pseudo-TTY + self._initial_termios_settings = None + # If a startup script is provided and exists, then execute it in the startup commands if startup_script: startup_script = os.path.abspath(os.path.expanduser(startup_script)) if os.path.exists(startup_script): - script_cmd = f"run_script {utils.quote_string(startup_script)}" + script_cmd = f"run_script {su.quote(startup_script)}" if silence_startup_script: script_cmd += f" {constants.REDIRECTION_OUTPUT} {os.devnull}" self._startup_commands.append(script_cmd) # Transcript files to run instead of interactive command loop - self._transcript_files: Optional[list[str]] = None + self._transcript_files: list[str] | None = None # Check for command line args if allow_cli_args: @@ -514,7 +548,7 @@ def __init__( elif transcript_files: self._transcript_files = transcript_files - # Set the pager(s) for use with the ppaged() method for displaying output using a pager + # Set the pager(s) for use when displaying output using a pager if sys.platform.startswith('win'): self.pager = self.pager_chop = 'more' else: @@ -542,10 +576,6 @@ def __init__( # values are DisabledCommand objects. self.disabled_commands: dict[str, DisabledCommand] = {} - # If any command has been categorized, then all other commands that haven't been categorized - # will display under this section in the help output. - self.default_category = 'Uncategorized' - # The default key for sorting string results. Its default value performs a case-insensitive alphabetical sort. # If natural sorting is preferred, then set this to NATURAL_SORT_KEY. # cmd2 uses this key for sorting: @@ -626,7 +656,7 @@ def __init__( self.default_suggestion_message = "Did you mean {}?" # the current command being executed - self.current_command: Optional[Statement] = None + self.current_command: Statement | None = None def find_commandsets(self, commandset_type: type[CommandSet], *, subclass_match: bool = False) -> list[CommandSet]: """Find all CommandSets that match the provided CommandSet type. @@ -643,7 +673,7 @@ def find_commandsets(self, commandset_type: type[CommandSet], *, subclass_match: if type(cmdset) == commandset_type or (subclass_match and isinstance(cmdset, commandset_type)) # noqa: E721 ] - def find_commandset_for_command(self, command_name: str) -> Optional[CommandSet]: + def find_commandset_for_command(self, command_name: str) -> CommandSet | None: """Find the CommandSet that registered the command name. :param command_name: command name to search @@ -754,12 +784,10 @@ def register_command_set(self, cmdset: CommandSet) -> None: def _build_parser( self, parent: CommandParent, - parser_builder: Union[ - argparse.ArgumentParser, - Callable[[], argparse.ArgumentParser], - StaticArgParseBuilder, - ClassArgParseBuilder, - ], + parser_builder: argparse.ArgumentParser + | Callable[[], argparse.ArgumentParser] + | StaticArgParseBuilder + | ClassArgParseBuilder, prog: str, ) -> argparse.ArgumentParser: """Build argument parser for a command/subcommand. @@ -783,9 +811,7 @@ def _build_parser( else: raise TypeError(f"Invalid type for parser_builder: {type(parser_builder)}") - from .decorators import _set_parser_prog - - _set_parser_prog(parser, prog) + argparse_custom.set_parser_prog(parser, prog) return parser @@ -940,7 +966,7 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: subcommand_valid, errmsg = self.statement_parser.is_valid_command(subcommand_name, is_subcommand=True) if not subcommand_valid: - raise CommandSetRegistrationError(f'Subcommand {subcommand_name!s} is not valid: {errmsg}') + raise CommandSetRegistrationError(f'Subcommand {subcommand_name} is not valid: {errmsg}') command_tokens = full_command_name.split() command_name = command_tokens[0] @@ -953,11 +979,11 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: command_func = self.cmd_func(command_name) if command_func is None: - raise CommandSetRegistrationError(f"Could not find command '{command_name}' needed by subcommand: {method!s}") + raise CommandSetRegistrationError(f"Could not find command '{command_name}' needed by subcommand: {method}") command_parser = self._command_parsers.get(command_func) if command_parser is None: raise CommandSetRegistrationError( - f"Could not find argparser for command '{command_name}' needed by subcommand: {method!s}" + f"Could not find argparser for command '{command_name}' needed by subcommand: {method}" ) def find_subcommand(action: argparse.ArgumentParser, subcmd_names: list[str]) -> argparse.ArgumentParser: @@ -974,46 +1000,34 @@ def find_subcommand(action: argparse.ArgumentParser, subcmd_names: list[str]) -> target_parser = find_subcommand(command_parser, subcommand_names) + # Create the subcommand parser and configure it subcmd_parser = self._build_parser(cmdset, subcmd_parser_builder, f'{command_name} {subcommand_name}') if subcmd_parser.description is None and method.__doc__: subcmd_parser.description = strip_doc_annotations(method.__doc__) + # Set the subcommand handler + defaults = {constants.NS_ATTR_SUBCMD_HANDLER: method} + subcmd_parser.set_defaults(**defaults) + + # Set what instance the handler is bound to + setattr(subcmd_parser, constants.PARSER_ATTR_COMMANDSET, cmdset) + + # Find the argparse action that handles subcommands for action in target_parser._actions: if isinstance(action, argparse._SubParsersAction): # Get the kwargs for add_parser() add_parser_kwargs = getattr(method, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS, {}) - # Set subcmd_parser as the parent to the parser we're creating to get its arguments - add_parser_kwargs['parents'] = [subcmd_parser] - - # argparse only copies actions from a parent and not the following settings. - # To retain these settings, we will copy them from subcmd_parser and pass them - # as ArgumentParser constructor arguments to add_parser(). - add_parser_kwargs['prog'] = subcmd_parser.prog - add_parser_kwargs['usage'] = subcmd_parser.usage - add_parser_kwargs['description'] = subcmd_parser.description - add_parser_kwargs['epilog'] = subcmd_parser.epilog - add_parser_kwargs['formatter_class'] = subcmd_parser.formatter_class - add_parser_kwargs['prefix_chars'] = subcmd_parser.prefix_chars - add_parser_kwargs['fromfile_prefix_chars'] = subcmd_parser.fromfile_prefix_chars - add_parser_kwargs['argument_default'] = subcmd_parser.argument_default - add_parser_kwargs['conflict_handler'] = subcmd_parser.conflict_handler - add_parser_kwargs['allow_abbrev'] = subcmd_parser.allow_abbrev - - # Set add_help to False and use whatever help option subcmd_parser already has - add_parser_kwargs['add_help'] = False - - attached_parser = action.add_parser(subcommand_name, **add_parser_kwargs) - - # Set the subcommand handler - defaults = {constants.NS_ATTR_SUBCMD_HANDLER: method} - attached_parser.set_defaults(**defaults) - - # Copy value for custom ArgparseCompleter type, which will be None if not present on subcmd_parser - attached_parser.set_ap_completer_type(subcmd_parser.get_ap_completer_type()) # type: ignore[attr-defined] - - # Set what instance the handler is bound to - setattr(attached_parser, constants.PARSER_ATTR_COMMANDSET, cmdset) + # Use add_parser to register the subcommand name and any aliases + action.add_parser(subcommand_name, **add_parser_kwargs) + + # Replace the parser created by add_parser() with our pre-configured one + action._name_parser_map[subcommand_name] = subcmd_parser + + # Also remap any aliases to our pre-configured parser + for alias in add_parser_kwargs.get("aliases", []): + action._name_parser_map[alias] = subcmd_parser + break def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: @@ -1047,18 +1061,18 @@ def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: if command_func is None: # pragma: no cover # This really shouldn't be possible since _register_subcommands would prevent this from happening # but keeping in case it does for some strange reason - raise CommandSetRegistrationError(f"Could not find command '{command_name}' needed by subcommand: {method!s}") + raise CommandSetRegistrationError(f"Could not find command '{command_name}' needed by subcommand: {method}") command_parser = self._command_parsers.get(command_func) if command_parser is None: # pragma: no cover # This really shouldn't be possible since _register_subcommands would prevent this from happening # but keeping in case it does for some strange reason raise CommandSetRegistrationError( - f"Could not find argparser for command '{command_name}' needed by subcommand: {method!s}" + f"Could not find argparser for command '{command_name}' needed by subcommand: {method}" ) for action in command_parser._actions: if isinstance(action, argparse._SubParsersAction): - action.remove_parser(subcommand_name) # type: ignore[arg-type,attr-defined] + action.remove_parser(subcommand_name) # type: ignore[attr-defined] break @property @@ -1126,24 +1140,23 @@ def build_settables(self) -> None: def get_allow_style_choices(_cli_self: Cmd) -> list[str]: """Tab complete allow_style values.""" - return [val.name.lower() for val in ansi.AllowStyle] + return [val.name.lower() for val in ru.AllowStyle] - def allow_style_type(value: str) -> ansi.AllowStyle: - """Convert a string value into an ansi.AllowStyle.""" + def allow_style_type(value: str) -> ru.AllowStyle: + """Convert a string value into an ru.AllowStyle.""" try: - return ansi.AllowStyle[value.upper()] - except KeyError as esc: + return ru.AllowStyle[value.upper()] + except KeyError as ex: raise ValueError( - f"must be {ansi.AllowStyle.ALWAYS}, {ansi.AllowStyle.NEVER}, or " - f"{ansi.AllowStyle.TERMINAL} (case-insensitive)" - ) from esc + f"must be {ru.AllowStyle.ALWAYS}, {ru.AllowStyle.NEVER}, or {ru.AllowStyle.TERMINAL} (case-insensitive)" + ) from ex self.add_settable( Settable( 'allow_style', allow_style_type, 'Allow ANSI text style sequences in output (valid values: ' - f'{ansi.AllowStyle.ALWAYS}, {ansi.AllowStyle.NEVER}, {ansi.AllowStyle.TERMINAL})', + f'{ru.AllowStyle.ALWAYS}, {ru.AllowStyle.NEVER}, {ru.AllowStyle.TERMINAL})', self, choices_provider=cast(ChoicesProviderFunc, get_allow_style_choices), ) @@ -1155,7 +1168,7 @@ def allow_style_type(value: str) -> ansi.AllowStyle: self.add_settable(Settable('debug', bool, "Show full traceback on exception", self)) self.add_settable(Settable('echo', bool, "Echo command issued into output", self)) self.add_settable(Settable('editor', str, "Program used by 'edit'", self)) - self.add_settable(Settable('feedback_to_output', bool, "Include nonessentials in '|', '>' results", self)) + self.add_settable(Settable('feedback_to_output', bool, "Include nonessentials in '|' and '>' results", self)) self.add_settable( Settable('max_completion_items', int, "Maximum number of CompletionItems to display during tab completion", self) ) @@ -1166,14 +1179,14 @@ def allow_style_type(value: str) -> ansi.AllowStyle: # ----- Methods related to presenting output to the user ----- @property - def allow_style(self) -> ansi.AllowStyle: + def allow_style(self) -> ru.AllowStyle: """Read-only property needed to support do_set when it reads allow_style.""" - return ansi.allow_style + return ru.ALLOW_STYLE @allow_style.setter - def allow_style(self, new_val: ansi.AllowStyle) -> None: + def allow_style(self, new_val: ru.AllowStyle) -> None: """Setter property needed to support do_set when it updates allow_style.""" - ansi.allow_style = new_val + ru.ALLOW_STYLE = new_val def _completion_supported(self) -> bool: """Return whether tab completion is supported.""" @@ -1181,181 +1194,392 @@ def _completion_supported(self) -> bool: @property def visible_prompt(self) -> str: - """Read-only property to get the visible prompt with any ANSI style escape codes stripped. + """Read-only property to get the visible prompt with any ANSI style sequences stripped. - Used by transcript testing to make it easier and more reliable when users are doing things like coloring the - prompt using ANSI color codes. + Used by transcript testing to make it easier and more reliable when users are doing things like + coloring the prompt. - :return: prompt stripped of any ANSI escape codes + :return: the stripped prompt """ - return ansi.strip_style(self.prompt) + return su.strip_style(self.prompt) def print_to( self, - dest: IO[str], - msg: Any, - *, - end: str = '\n', - style: Optional[Callable[[str], str]] = None, + file: IO[str], + *objects: Any, + sep: str = " ", + end: str = "\n", + style: StyleType | None = None, + soft_wrap: bool = True, + emoji: bool = False, + markup: bool = False, + highlight: bool = False, + rich_print_kwargs: RichPrintKwargs | None = None, + **kwargs: Any, # noqa: ARG002 ) -> None: - """Print message to a given file object. - - :param dest: the file object being written to - :param msg: object to print - :param end: string appended after the end of the message, default a newline - :param style: optional style function to format msg with (e.g. ansi.style_success) + """Print objects to a given file stream. + + This method is configured for general-purpose printing. By default, it enables + soft wrap and disables Rich's automatic detection for markup, emoji, and highlighting. + These defaults can be overridden by passing explicit keyword arguments. + + :param file: file stream being written to + :param objects: objects to print + :param sep: string to write between printed text. Defaults to " ". + :param end: string to write at end of printed text. Defaults to a newline. + :param style: optional style to apply to output + :param soft_wrap: Enable soft wrap mode. If True, lines of text will not be + word-wrapped or cropped to fit the terminal width. Defaults to True. + :param emoji: If True, Rich will replace emoji codes (e.g., :smiley:) with their + corresponding Unicode characters. Defaults to False. + :param markup: If True, Rich will interpret strings with tags (e.g., [bold]hello[/bold]) + as styled output. Defaults to False. + :param highlight: If True, Rich will automatically apply highlighting to elements within + strings, such as common Python data types like numbers, booleans, or None. + This is particularly useful when pretty printing objects like lists and + dictionaries to display them in color. Defaults to False. + :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). + :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this + method and still call `super()` without encountering unexpected keyword argument errors. + These arguments are not passed to Rich's Console.print(). + + See the Rich documentation for more details on emoji codes, markup tags, and highlighting. """ - final_msg = style(msg) if style is not None else msg + prepared_objects = ru.prepare_objects_for_rendering(*objects) + try: - ansi.style_aware_write(dest, f'{final_msg}{end}') + Cmd2GeneralConsole(file).print( + *prepared_objects, + sep=sep, + end=end, + style=style, + soft_wrap=soft_wrap, + emoji=emoji, + markup=markup, + highlight=highlight, + **(rich_print_kwargs if rich_print_kwargs is not None else {}), + ) except BrokenPipeError: # This occurs if a command's output is being piped to another - # process and that process closes before the command is - # finished. If you would like your application to print a + # process which closes the pipe before the command is finished + # writing. If you would like your application to print a # warning message, then set the broken_pipe_warning attribute # to the message you want printed. - if self.broken_pipe_warning: - sys.stderr.write(self.broken_pipe_warning) + if self.broken_pipe_warning and file != sys.stderr: + Cmd2GeneralConsole(sys.stderr).print(self.broken_pipe_warning) - def poutput(self, msg: Any = '', *, end: str = '\n') -> None: - """Print message to self.stdout and appends a newline by default. + def poutput( + self, + *objects: Any, + sep: str = " ", + end: str = "\n", + style: StyleType | None = None, + soft_wrap: bool = True, + emoji: bool = False, + markup: bool = False, + highlight: bool = False, + rich_print_kwargs: RichPrintKwargs | None = None, + **kwargs: Any, # noqa: ARG002 + ) -> None: + """Print objects to self.stdout. - :param msg: object to print - :param end: string appended after the end of the message, default a newline + For details on the parameters, refer to the `print_to` method documentation. """ - self.print_to(self.stdout, msg, end=end) + self.print_to( + self.stdout, + *objects, + sep=sep, + end=end, + style=style, + soft_wrap=soft_wrap, + emoji=emoji, + markup=markup, + highlight=highlight, + rich_print_kwargs=rich_print_kwargs, + ) + + def perror( + self, + *objects: Any, + sep: str = " ", + end: str = "\n", + style: StyleType | None = Cmd2Style.ERROR, + soft_wrap: bool = True, + emoji: bool = False, + markup: bool = False, + highlight: bool = False, + rich_print_kwargs: RichPrintKwargs | None = None, + **kwargs: Any, # noqa: ARG002 + ) -> None: + """Print objects to sys.stderr. - def perror(self, msg: Any = '', *, end: str = '\n', apply_style: bool = True) -> None: - """Print message to sys.stderr. + :param style: optional style to apply to output. Defaults to Cmd2Style.ERROR. - :param msg: object to print - :param end: string appended after the end of the message, default a newline - :param apply_style: If True, then ansi.style_error will be applied to the message text. Set to False in cases - where the message text already has the desired style. Defaults to True. + For details on the other parameters, refer to the `print_to` method documentation. """ - self.print_to(sys.stderr, msg, end=end, style=ansi.style_error if apply_style else None) + self.print_to( + sys.stderr, + *objects, + sep=sep, + end=end, + style=style, + soft_wrap=soft_wrap, + emoji=emoji, + markup=markup, + highlight=highlight, + rich_print_kwargs=rich_print_kwargs, + ) - def psuccess(self, msg: Any = '', *, end: str = '\n') -> None: - """Wrap poutput, but applies ansi.style_success by default. + def psuccess( + self, + *objects: Any, + sep: str = " ", + end: str = "\n", + soft_wrap: bool = True, + emoji: bool = False, + markup: bool = False, + highlight: bool = False, + rich_print_kwargs: RichPrintKwargs | None = None, + **kwargs: Any, # noqa: ARG002 + ) -> None: + """Wrap poutput, but apply Cmd2Style.SUCCESS. - :param msg: object to print - :param end: string appended after the end of the message, default a newline + For details on the parameters, refer to the `print_to` method documentation. """ - msg = ansi.style_success(msg) - self.poutput(msg, end=end) + self.poutput( + *objects, + sep=sep, + end=end, + style=Cmd2Style.SUCCESS, + soft_wrap=soft_wrap, + emoji=emoji, + markup=markup, + highlight=highlight, + rich_print_kwargs=rich_print_kwargs, + ) - def pwarning(self, msg: Any = '', *, end: str = '\n') -> None: - """Wrap perror, but applies ansi.style_warning by default. + def pwarning( + self, + *objects: Any, + sep: str = " ", + end: str = "\n", + soft_wrap: bool = True, + emoji: bool = False, + markup: bool = False, + highlight: bool = False, + rich_print_kwargs: RichPrintKwargs | None = None, + **kwargs: Any, # noqa: ARG002 + ) -> None: + """Wrap perror, but apply Cmd2Style.WARNING. - :param msg: object to print - :param end: string appended after the end of the message, default a newline + For details on the parameters, refer to the `print_to` method documentation. """ - msg = ansi.style_warning(msg) - self.perror(msg, end=end, apply_style=False) + self.perror( + *objects, + sep=sep, + end=end, + style=Cmd2Style.WARNING, + soft_wrap=soft_wrap, + emoji=emoji, + markup=markup, + highlight=highlight, + rich_print_kwargs=rich_print_kwargs, + ) - def pexcept(self, msg: Any, *, end: str = '\n', apply_style: bool = True) -> None: - """Print Exception message to sys.stderr. If debug is true, print exception traceback if one exists. + def pexcept( + self, + exception: BaseException, + **kwargs: Any, # noqa: ARG002 + ) -> None: + """Print an exception to sys.stderr. + + If `debug` is true, a full traceback is also printed, if one exists. - :param msg: message or Exception to print - :param end: string appended after the end of the message, default a newline - :param apply_style: If True, then ansi.style_error will be applied to the message text. Set to False in cases - where the message text already has the desired style. Defaults to True. + :param exception: the exception to be printed. + :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this + method and still call `super()` without encountering unexpected keyword argument errors. """ + console = Cmd2ExceptionConsole(sys.stderr) + + # Only print a traceback if we're in debug mode and one exists. if self.debug and sys.exc_info() != (None, None, None): - import traceback + traceback = Traceback( + width=None, # Use all available width + code_width=None, # Use all available width + show_locals=True, + max_frames=0, # 0 means full traceback. + word_wrap=True, # Wrap long lines of code instead of truncate + ) + console.print(traceback) + console.print() + return - traceback.print_exc() + # Print the exception in the same style Rich uses after a traceback. + exception_str = str(exception) - if isinstance(msg, Exception): - final_msg = f"EXCEPTION of type '{type(msg).__name__}' occurred with message: {msg}" - else: - final_msg = str(msg) + if exception_str: + highlighter = ReprHighlighter() - if apply_style: - final_msg = ansi.style_error(final_msg) + final_msg = Text.assemble( + (f"{type(exception).__name__}: ", "traceback.exc_type"), + highlighter(exception_str), + ) + else: + final_msg = Text(f"{type(exception).__name__}", style="traceback.exc_type") + # If not in debug mode and the 'debug' setting is available, + # inform the user how to enable full tracebacks. if not self.debug and 'debug' in self.settables: - warning = "\nTo enable full traceback, run the following command: 'set debug true'" - final_msg += ansi.style_warning(warning) + help_msg = Text.assemble( + "\n\n", + ("To enable full traceback, run the following command: ", Cmd2Style.WARNING), + ("set debug true", Cmd2Style.COMMAND_LINE), + ) + final_msg.append(help_msg) - self.perror(final_msg, end=end, apply_style=False) + console.print(final_msg) + console.print() - def pfeedback(self, msg: Any, *, end: str = '\n') -> None: - """Print nonessential feedback. Can be silenced with `quiet`. + def pfeedback( + self, + *objects: Any, + sep: str = " ", + end: str = "\n", + style: StyleType | None = None, + soft_wrap: bool = True, + emoji: bool = False, + markup: bool = False, + highlight: bool = False, + rich_print_kwargs: RichPrintKwargs | None = None, + **kwargs: Any, # noqa: ARG002 + ) -> None: + """Print nonessential feedback. - Inclusion in redirected output is controlled by `feedback_to_output`. + The output can be silenced with the `quiet` setting and its inclusion in redirected output + is controlled by the `feedback_to_output` setting. - :param msg: object to print - :param end: string appended after the end of the message, default a newline + For details on the parameters, refer to the `print_to` method documentation. """ if not self.quiet: if self.feedback_to_output: - self.poutput(msg, end=end) + self.poutput( + *objects, + sep=sep, + end=end, + style=style, + soft_wrap=soft_wrap, + emoji=emoji, + markup=markup, + highlight=highlight, + rich_print_kwargs=rich_print_kwargs, + ) else: - self.perror(msg, end=end, apply_style=False) + self.perror( + *objects, + sep=sep, + end=end, + style=style, + soft_wrap=soft_wrap, + emoji=emoji, + markup=markup, + highlight=highlight, + rich_print_kwargs=rich_print_kwargs, + ) - def ppaged(self, msg: Any, *, end: str = '\n', chop: bool = False) -> None: - """Print output using a pager if it would go off screen and stdout isn't currently being redirected. + def ppaged( + self, + *objects: Any, + sep: str = " ", + end: str = "\n", + style: StyleType | None = None, + chop: bool = False, + soft_wrap: bool = True, + emoji: bool = False, + markup: bool = False, + highlight: bool = False, + rich_print_kwargs: RichPrintKwargs | None = None, + **kwargs: Any, # noqa: ARG002 + ) -> None: + """Print output using a pager. - Never uses a pager inside a script (Python or text) or when output is being redirected or piped or when - stdout or stdin are not a fully functional terminal. + A pager is used when the terminal is interactive and may exit immediately if the output + fits on the screen. A pager is not used inside a script (Python or text) or when output is + redirected or piped, and in these cases, output is sent to `poutput`. - :param msg: object to print - :param end: string appended after the end of the message, default a newline :param chop: True -> causes lines longer than the screen width to be chopped (truncated) rather than wrapped - truncated text is still accessible by scrolling with the right & left arrow keys - chopping is ideal for displaying wide tabular data as is done in utilities like pgcli False -> causes lines longer than the screen width to wrap to the next line - wrapping is ideal when you want to keep users from having to use horizontal scrolling + WARNING: On Windows, the text always wraps regardless of what the chop argument is set to + :param soft_wrap: Enable soft wrap mode. If True, lines of text will not be word-wrapped or cropped to + fit the terminal width. Defaults to True. + + Note: If chop is True and a pager is used, soft_wrap is automatically set to True to + prevent wrapping and allow for horizontal scrolling. - WARNING: On Windows, the text always wraps regardless of what the chop argument is set to + For details on the other parameters, refer to the `print_to` method documentation. """ - # Attempt to detect if we are not running within a fully functional terminal. + # Detect if we are running within an interactive terminal. # Don't try to use the pager when being run by a continuous integration system like Jenkins + pexpect. - functional_terminal = False + functional_terminal = ( + self.stdin.isatty() + and self.stdout.isatty() + and (sys.platform.startswith('win') or os.environ.get('TERM') is not None) + ) - if self.stdin.isatty() and self.stdout.isatty(): # noqa: SIM102 - if sys.platform.startswith('win') or os.environ.get('TERM') is not None: - functional_terminal = True + # A pager application blocks, so only run one if not redirecting or running a script (either text or Python). + can_block = not (self._redirecting or self.in_pyscript() or self.in_script()) - # Don't attempt to use a pager that can block if redirecting or running a script (either text or Python). - # Also only attempt to use a pager if actually running in a real fully functional terminal. - if functional_terminal and not self._redirecting and not self.in_pyscript() and not self.in_script(): - final_msg = f"{msg}{end}" - if ansi.allow_style == ansi.AllowStyle.NEVER: - final_msg = ansi.strip_style(final_msg) + # Check if we are outputting to a pager. + if functional_terminal and can_block: + prepared_objects = ru.prepare_objects_for_rendering(*objects) - pager = self.pager + # Chopping overrides soft_wrap if chop: - pager = self.pager_chop + soft_wrap = True + + # Generate the bytes to send to the pager + console = Cmd2GeneralConsole(self.stdout) + with console.capture() as capture: + console.print( + *prepared_objects, + sep=sep, + end=end, + style=style, + soft_wrap=soft_wrap, + emoji=emoji, + markup=markup, + highlight=highlight, + **(rich_print_kwargs if rich_print_kwargs is not None else {}), + ) + output_bytes = capture.get().encode('utf-8', 'replace') - try: - # Prevent KeyboardInterrupts while in the pager. The pager application will - # still receive the SIGINT since it is in the same process group as us. - with self.sigint_protection: - import subprocess - - pipe_proc = subprocess.Popen(pager, shell=True, stdin=subprocess.PIPE, stdout=self.stdout) # noqa: S602 - pipe_proc.communicate(final_msg.encode('utf-8', 'replace')) - except BrokenPipeError: - # This occurs if a command's output is being piped to another process and that process closes before the - # command is finished. If you would like your application to print a warning message, then set the - # broken_pipe_warning attribute to the message you want printed.` - if self.broken_pipe_warning: - sys.stderr.write(self.broken_pipe_warning) - else: - self.poutput(msg, end=end) + # Prevent KeyboardInterrupts while in the pager. The pager application will + # still receive the SIGINT since it is in the same process group as us. + with self.sigint_protection: + import subprocess - def ppretty(self, data: Any, *, indent: int = 2, width: int = 80, depth: Optional[int] = None, end: str = '\n') -> None: - """Pretty print arbitrary Python data structures to self.stdout and appends a newline by default. + pipe_proc = subprocess.Popen( # noqa: S602 + self.pager_chop if chop else self.pager, + shell=True, + stdin=subprocess.PIPE, + stdout=self.stdout, + ) + pipe_proc.communicate(output_bytes) - :param data: object to print - :param indent: the amount of indentation added for each nesting level - :param width: the desired maximum number of characters per line in the output, a best effort will be made for long data - :param depth: the number of nesting levels which may be printed, if data is too deep, the next level replaced by ... - :param end: string appended after the end of the message, default a newline - """ - self.print_to(self.stdout, pprint.pformat(data, indent, width, depth), end=end) + else: + self.poutput( + *objects, + sep=sep, + end=end, + style=style, + soft_wrap=soft_wrap, + emoji=emoji, + markup=markup, + highlight=highlight, + rich_print_kwargs=rich_print_kwargs, + ) # ----- Methods related to tab completion ----- @@ -1432,7 +1656,7 @@ def tokens_for_completion(self, line: str, begidx: int, endidx: int) -> tuple[li raw_tokens = self.statement_parser.split_on_punctuation(initial_tokens) # Save the unquoted tokens - tokens = [utils.strip_quotes(cur_token) for cur_token in raw_tokens] + tokens = [su.strip_quotes(cur_token) for cur_token in raw_tokens] # If the token being completed had an unclosed quote, we need # to remove the closing quote that was added in order for it @@ -1537,9 +1761,9 @@ def flag_based_complete( line: str, begidx: int, endidx: int, - flag_dict: dict[str, Union[Iterable[str], CompleterFunc]], + flag_dict: dict[str, Iterable[str] | CompleterFunc], *, - all_else: Union[None, Iterable[str], CompleterFunc] = None, + all_else: None | Iterable[str] | CompleterFunc = None, ) -> list[str]: """Tab completes based on a particular flag preceding the token being completed. @@ -1586,9 +1810,9 @@ def index_based_complete( line: str, begidx: int, endidx: int, - index_dict: Mapping[int, Union[Iterable[str], CompleterFunc]], + index_dict: Mapping[int, Iterable[str] | CompleterFunc], *, - all_else: Optional[Union[Iterable[str], CompleterFunc]] = None, + all_else: Iterable[str] | CompleterFunc | None = None, ) -> list[str]: """Tab completes based on a fixed position in the input string. @@ -1616,7 +1840,7 @@ def index_based_complete( index = len(tokens) - 1 # Check if token is at an index in the dictionary - match_against: Optional[Union[Iterable[str], CompleterFunc]] + match_against: Iterable[str] | CompleterFunc | None match_against = index_dict.get(index, all_else) # Perform tab completion using a Iterable @@ -1636,7 +1860,7 @@ def path_complete( begidx: int, # noqa: ARG002 endidx: int, *, - path_filter: Optional[Callable[[str], bool]] = None, + path_filter: Callable[[str], bool] | None = None, ) -> list[str]: """Perform completion of local file system paths. @@ -1922,7 +2146,7 @@ def _display_matches_gnu_readline( if self.formatted_completions: if not hint_printed: sys.stdout.write('\n') - sys.stdout.write('\n' + self.formatted_completions + '\n\n') + sys.stdout.write('\n' + self.formatted_completions + '\n') # Otherwise use readline's formatter else: @@ -1934,7 +2158,7 @@ def _display_matches_gnu_readline( longest_match_length = 0 for cur_match in matches_to_display: - cur_length = ansi.style_aware_wcswidth(cur_match) + cur_length = su.str_width(cur_match) longest_match_length = max(longest_match_length, cur_length) else: matches_to_display = matches @@ -1950,7 +2174,7 @@ def _display_matches_gnu_readline( # rl_display_match_list() expects matches to be in argv format where # substitution is the first element, followed by the matches, and then a NULL. - strings_array = cast(list[Optional[bytes]], (ctypes.c_char_p * (1 + len(encoded_matches) + 1))()) + strings_array = cast(list[bytes | None], (ctypes.c_char_p * (1 + len(encoded_matches) + 1))()) # Copy in the encoded strings and add a NULL to the end strings_array[0] = encoded_substitution @@ -1973,13 +2197,13 @@ def _display_matches_pyreadline(self, matches: list[str]) -> None: # pragma: no hint_printed = False if self.always_show_hint and self.completion_hint: hint_printed = True - readline.rl.mode.console.write('\n' + self.completion_hint) + sys.stdout.write('\n' + self.completion_hint) # Check if we already have formatted results to print if self.formatted_completions: if not hint_printed: - readline.rl.mode.console.write('\n') - readline.rl.mode.console.write('\n' + self.formatted_completions + '\n\n') + sys.stdout.write('\n') + sys.stdout.write('\n' + self.formatted_completions + '\n') # Redraw the prompt and input lines rl_force_redisplay() @@ -2000,11 +2224,10 @@ def _determine_ap_completer_type(parser: argparse.ArgumentParser) -> type[argpar """Determine what type of ArgparseCompleter to use on a given parser. If the parser does not have one set, then use argparse_completer.DEFAULT_AP_COMPLETER. - :param parser: the parser to examine :return: type of ArgparseCompleter """ - Completer = Optional[type[argparse_completer.ArgparseCompleter]] # noqa: N806 + Completer = type[argparse_completer.ArgparseCompleter] | None # noqa: N806 completer_type: Completer = parser.get_ap_completer_type() # type: ignore[attr-defined] if completer_type is None: @@ -2012,7 +2235,7 @@ def _determine_ap_completer_type(parser: argparse.ArgumentParser) -> type[argpar return completer_type def _perform_completion( - self, text: str, line: str, begidx: int, endidx: int, custom_settings: Optional[utils.CustomCompletionSettings] = None + self, text: str, line: str, begidx: int, endidx: int, custom_settings: utils.CustomCompletionSettings | None = None ) -> None: """Perform the actual completion, helper function for complete(). @@ -2062,7 +2285,7 @@ def _perform_completion( if custom_settings is None: # Check if a macro was entered if command in self.macros: - completer_func = self.path_complete + completer_func = self.macro_arg_complete # Check if a command was entered elif command in self.get_all_commands(): @@ -2190,8 +2413,8 @@ def _perform_completion( self.completion_matches[0] += completion_token_quote def complete( # type: ignore[override] - self, text: str, state: int, custom_settings: Optional[utils.CustomCompletionSettings] = None - ) -> Optional[str]: + self, text: str, state: int, custom_settings: utils.CustomCompletionSettings | None = None + ) -> str | None: """Override of cmd's complete method which returns the next possible completion for 'text'. This completer function is called by readline as complete(text, state), for state in 0, 1, 2, …, @@ -2281,9 +2504,13 @@ def complete( # type: ignore[override] # Don't print error and redraw the prompt unless the error has length err_str = str(ex) if err_str: - if ex.apply_style: - err_str = ansi.style_error(err_str) - ansi.style_aware_write(sys.stdout, '\n' + err_str + '\n') + self.print_to( + sys.stdout, + Text.assemble( + "\n", + (err_str, Cmd2Style.ERROR if ex.apply_style else ""), + ), + ) rl_force_redisplay() return None except Exception as ex: # noqa: BLE001 @@ -2326,42 +2553,36 @@ def get_visible_commands(self) -> list[str]: if command not in self.hidden_commands and command not in self.disabled_commands ] - # Table displayed when tab completing aliases - _alias_completion_table = SimpleTable([Column('Value', width=80)], divider_char=None) - def _get_alias_completion_items(self) -> list[CompletionItem]: """Return list of alias names and values as CompletionItems.""" results: list[CompletionItem] = [] - for cur_key in self.aliases: - row_data = [self.aliases[cur_key]] - results.append(CompletionItem(cur_key, self._alias_completion_table.generate_data_row(row_data))) + for name, value in self.aliases.items(): + descriptive_data = [value] + results.append(CompletionItem(name, descriptive_data)) return results - # Table displayed when tab completing macros - _macro_completion_table = SimpleTable([Column('Value', width=80)], divider_char=None) - def _get_macro_completion_items(self) -> list[CompletionItem]: """Return list of macro names and values as CompletionItems.""" results: list[CompletionItem] = [] - for cur_key in self.macros: - row_data = [self.macros[cur_key].value] - results.append(CompletionItem(cur_key, self._macro_completion_table.generate_data_row(row_data))) + for name, macro in self.macros.items(): + descriptive_data = [macro.value] + results.append(CompletionItem(name, descriptive_data)) return results - # Table displayed when tab completing Settables - _settable_completion_table = SimpleTable([Column('Value', width=30), Column('Description', width=60)], divider_char=None) - def _get_settable_completion_items(self) -> list[CompletionItem]: """Return list of Settable names, values, and descriptions as CompletionItems.""" results: list[CompletionItem] = [] - for cur_key in self.settables: - row_data = [self.settables[cur_key].get_value(), self.settables[cur_key].description] - results.append(CompletionItem(cur_key, self._settable_completion_table.generate_data_row(row_data))) + for name, settable in self.settables.items(): + descriptive_data = [ + str(settable.value), + settable.description, + ] + results.append(CompletionItem(name, descriptive_data)) return results @@ -2383,13 +2604,17 @@ def get_help_topics(self) -> list[str]: # Filter out hidden and disabled commands return [topic for topic in all_topics if topic not in self.hidden_commands and topic not in self.disabled_commands] - def sigint_handler(self, signum: int, _: Optional[FrameType]) -> None: # noqa: ARG002 + def sigint_handler( + self, + signum: int, # noqa: ARG002, + frame: FrameType | None, # noqa: ARG002, + ) -> None: """Signal handler for SIGINTs which typically come from Ctrl-C events. If you need custom SIGINT behavior, then override this method. :param signum: signal number - :param _: the current stack frame or None + :param frame: the current stack frame or None """ if self._cur_pipe_proc_reader is not None: # Pass the SIGINT to the current pipe process @@ -2405,7 +2630,7 @@ def sigint_handler(self, signum: int, _: Optional[FrameType]) -> None: # noqa: if raise_interrupt: self._raise_keyboard_interrupt() - def termination_signal_handler(self, signum: int, _: Optional[FrameType]) -> None: + def termination_signal_handler(self, signum: int, _: FrameType | None) -> None: """Signal handler for SIGHUP and SIGTERM. Only runs on Linux and Mac. SIGHUP - received when terminal window is closed @@ -2425,7 +2650,7 @@ def _raise_keyboard_interrupt(self) -> None: """Raise a KeyboardInterrupt.""" raise KeyboardInterrupt("Got a keyboard interrupt") - def precmd(self, statement: Union[Statement, str]) -> Statement: + def precmd(self, statement: Statement | str) -> Statement: """Ran just before the command is executed by [cmd2.Cmd.onecmd][] and after adding it to history (cmd Hook method). :param statement: subclass of str which also contains the parsed input @@ -2436,7 +2661,7 @@ def precmd(self, statement: Union[Statement, str]) -> Statement: """ return Statement(statement) if not isinstance(statement, Statement) else statement - def postcmd(self, stop: bool, statement: Union[Statement, str]) -> bool: # noqa: ARG002 + def postcmd(self, stop: bool, statement: Statement | str) -> bool: # noqa: ARG002 """Ran just after a command is executed by [cmd2.Cmd.onecmd][] (cmd inherited Hook method). :param stop: return `True` to request the command loop terminate @@ -2485,7 +2710,7 @@ def onecmd_plus_hooks( add_to_history: bool = True, raise_keyboard_interrupt: bool = False, py_bridge_call: bool = False, - orig_rl_history_length: Optional[int] = None, + orig_rl_history_length: int | None = None, ) -> bool: """Top-level function called by cmdloop() to handle parsing a line and running the command and all of its hooks. @@ -2526,7 +2751,7 @@ def onecmd_plus_hooks( # we need to run the finalization hooks raise EmptyStatement # noqa: TRY301 - redir_saved_state: Optional[utils.RedirectionSavedState] = None + redir_saved_state: utils.RedirectionSavedState | None = None try: # Get sigint protection while we set up redirection @@ -2608,16 +2833,17 @@ def onecmd_plus_hooks( return stop - def _run_cmdfinalization_hooks(self, stop: bool, statement: Optional[Statement]) -> bool: + def _run_cmdfinalization_hooks(self, stop: bool, statement: Statement | None) -> bool: """Run the command finalization hooks.""" - with self.sigint_protection: - if not sys.platform.startswith('win') and self.stdin.isatty(): - # Before the next command runs, fix any terminal problems like those - # caused by certain binary characters having been printed to it. - import subprocess + if self._initial_termios_settings is not None and self.stdin.isatty(): + import io + import termios - proc = subprocess.Popen(['stty', 'sane']) # noqa: S607 - proc.communicate() + # Before the next command runs, fix any terminal problems like those + # caused by certain binary characters having been printed to it. + with self.sigint_protection, contextlib.suppress(io.UnsupportedOperation, termios.error): + # This can fail if stdin is a pseudo-TTY, in which case we just ignore it + termios.tcsetattr(self.stdin.fileno(), termios.TCSANOW, self._initial_termios_settings) data = plugin.CommandFinalizationData(stop, statement) for func in self._cmdfinalization_hooks: @@ -2628,7 +2854,7 @@ def _run_cmdfinalization_hooks(self, stop: bool, statement: Optional[Statement]) def runcmds_plus_hooks( self, - cmds: Union[list[HistoryItem], list[str]], + cmds: list[HistoryItem] | list[str], *, add_to_history: bool = True, stop_on_keyboard_interrupt: bool = False, @@ -2663,7 +2889,7 @@ def runcmds_plus_hooks( return False - def _complete_statement(self, line: str, *, orig_rl_history_length: Optional[int] = None) -> Statement: + def _complete_statement(self, line: str, *, orig_rl_history_length: int | None = None) -> Statement: """Keep accepting lines of input until the command is complete. There is some pretty hacky code here to handle some quirks of @@ -2753,7 +2979,7 @@ def combine_rl_history(statement: Statement) -> None: return statement - def _input_line_to_statement(self, line: str, *, orig_rl_history_length: Optional[int] = None) -> Statement: + def _input_line_to_statement(self, line: str, *, orig_rl_history_length: int | None = None) -> Statement: """Parse the user's input line and convert it to a Statement, ensuring that all macros are also resolved. :param line: the line being parsed @@ -2806,7 +3032,7 @@ def _input_line_to_statement(self, line: str, *, orig_rl_history_length: Optiona ) return statement - def _resolve_macro(self, statement: Statement) -> Optional[str]: + def _resolve_macro(self, statement: Statement) -> str | None: """Resolve a macro and return the resulting string. :param statement: the parsed statement from the command line @@ -2855,13 +3081,16 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: """ import subprocess + # Only redirect sys.stdout if it's the same as self.stdout + stdouts_match = self.stdout == sys.stdout + # Initialize the redirection saved state redir_saved_state = utils.RedirectionSavedState( - cast(TextIO, self.stdout), sys.stdout, self._cur_pipe_proc_reader, self._redirecting + cast(TextIO, self.stdout), stdouts_match, self._cur_pipe_proc_reader, self._redirecting ) # The ProcReader for this command - cmd_pipe_proc_reader: Optional[utils.ProcReader] = None + cmd_pipe_proc_reader: utils.ProcReader | None = None if not self.allow_redirection: # Don't return since we set some state variables at the end of the function @@ -2890,11 +3119,11 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: kwargs['executable'] = shell # For any stream that is a StdSim, we will use a pipe so we can capture its output - proc = subprocess.Popen( # type: ignore[call-overload] # noqa: S602 + proc = subprocess.Popen( # noqa: S602 statement.pipe_to, stdin=subproc_stdin, stdout=subprocess.PIPE if isinstance(self.stdout, utils.StdSim) else self.stdout, # type: ignore[unreachable] - stderr=subprocess.PIPE if isinstance(sys.stderr, utils.StdSim) else sys.stderr, # type: ignore[unreachable] + stderr=subprocess.PIPE if isinstance(sys.stderr, utils.StdSim) else sys.stderr, shell=True, **kwargs, ) @@ -2911,9 +3140,12 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: subproc_stdin.close() new_stdout.close() raise RedirectionError(f'Pipe process exited with code {proc.returncode} before command could run') - redir_saved_state.redirecting = True # type: ignore[unreachable] + redir_saved_state.redirecting = True cmd_pipe_proc_reader = utils.ProcReader(proc, cast(TextIO, self.stdout), sys.stderr) - sys.stdout = self.stdout = new_stdout + + self.stdout = new_stdout + if stdouts_match: + sys.stdout = self.stdout elif statement.output: if statement.output_to: @@ -2922,12 +3154,15 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: mode = 'a' if statement.output == constants.REDIRECTION_APPEND else 'w' try: # Use line buffering - new_stdout = cast(TextIO, open(utils.strip_quotes(statement.output_to), mode=mode, buffering=1)) # noqa: SIM115 + new_stdout = cast(TextIO, open(su.strip_quotes(statement.output_to), mode=mode, buffering=1)) # noqa: SIM115 except OSError as ex: raise RedirectionError('Failed to redirect output') from ex redir_saved_state.redirecting = True - sys.stdout = self.stdout = new_stdout + + self.stdout = new_stdout + if stdouts_match: + sys.stdout = self.stdout else: # Redirecting to a paste buffer @@ -2945,7 +3180,10 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: # create a temporary file to store output new_stdout = cast(TextIO, tempfile.TemporaryFile(mode="w+")) # noqa: SIM115 redir_saved_state.redirecting = True - sys.stdout = self.stdout = new_stdout + + self.stdout = new_stdout + if stdouts_match: + sys.stdout = self.stdout if statement.output == constants.REDIRECTION_APPEND: self.stdout.write(current_paste_buffer) @@ -2975,7 +3213,8 @@ def _restore_output(self, statement: Statement, saved_redir_state: utils.Redirec # Restore the stdout values self.stdout = cast(TextIO, saved_redir_state.saved_self_stdout) - sys.stdout = cast(TextIO, saved_redir_state.saved_sys_stdout) + if saved_redir_state.stdouts_match: + sys.stdout = self.stdout # Check if we need to wait for the process being piped to if self._cur_pipe_proc_reader is not None: @@ -2985,7 +3224,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 cmd_func(self, command: str) -> Optional[CommandFunc]: + def cmd_func(self, command: str) -> CommandFunc | None: """Get the function for a command. :param command: the name of the command @@ -3002,7 +3241,7 @@ def cmd_func(self, command: str) -> Optional[CommandFunc]: func = getattr(self, func_name, None) return cast(CommandFunc, func) if callable(func) else None - def onecmd(self, statement: Union[Statement, str], *, add_to_history: bool = True) -> bool: + def onecmd(self, statement: Statement | str, *, add_to_history: bool = True) -> bool: """Execute the actual do_* method for a command. If the command provided doesn't exist, then it executes default() instead. @@ -3037,7 +3276,7 @@ def onecmd(self, statement: Union[Statement, str], *, add_to_history: bool = Tru return stop if stop is not None else False - def default(self, statement: Statement) -> Optional[bool]: # type: ignore[override] + def default(self, statement: Statement) -> bool | None: # type: ignore[override] """Execute when the command given isn't a recognized command implemented by a do_* method. :param statement: Statement object with parsed input @@ -3045,30 +3284,29 @@ def default(self, statement: Statement) -> Optional[bool]: # type: ignore[overr if self.default_to_shell: if 'shell' not in self.exclude_from_history: self.history.append(statement) - return self.do_shell(statement.command_and_args) + err_msg = self.default_error.format(statement.command) if self.suggest_similar_command and (suggested_command := self._suggest_similar_command(statement.command)): err_msg += f"\n{self.default_suggestion_message.format(suggested_command)}" - # Set apply_style to False so styles for default_error and default_suggestion_message are not overridden - self.perror(err_msg, apply_style=False) + self.perror(err_msg, style=None) return None - def _suggest_similar_command(self, command: str) -> Optional[str]: + def _suggest_similar_command(self, command: str) -> str | None: return suggest_similar(command, self.get_visible_commands()) def read_input( self, prompt: str, *, - history: Optional[list[str]] = None, + history: list[str] | None = None, completion_mode: utils.CompletionMode = utils.CompletionMode.NONE, preserve_quotes: bool = False, - choices: Optional[Iterable[Any]] = None, - choices_provider: Optional[ChoicesProviderFunc] = None, - completer: Optional[CompleterFunc] = None, - parser: Optional[argparse.ArgumentParser] = None, + choices: Iterable[Any] | None = None, + choices_provider: ChoicesProviderFunc | None = None, + completer: CompleterFunc | None = None, + parser: argparse.ArgumentParser | None = None, ) -> str: """Read input from appropriate stdin value. @@ -3102,8 +3340,8 @@ def read_input( :raises Exception: any exceptions raised by input() and stdin.readline() """ readline_configured = False - saved_completer: Optional[CompleterFunc] = None - saved_history: Optional[list[str]] = None + saved_completer: CompleterFunc | None = None + saved_history: list[str] | None = None def configure_readline() -> None: """Configure readline tab completion and history.""" @@ -3122,7 +3360,7 @@ def configure_readline() -> None: # Disable completion if completion_mode == utils.CompletionMode.NONE: - def complete_none(text: str, state: int) -> Optional[str]: # pragma: no cover # noqa: ARG001 + def complete_none(text: str, state: int) -> str | None: # pragma: no cover # noqa: ARG001 return None complete_func = complete_none @@ -3138,7 +3376,7 @@ def complete_none(text: str, state: int) -> Optional[str]: # pragma: no cover parser.add_argument( 'arg', suppress_tab_hint=True, - choices=choices, # type: ignore[arg-type] + choices=choices, choices_provider=choices_provider, completer=completer, ) @@ -3339,13 +3577,24 @@ def _cmdloop(self) -> None: ############################################################# # Top-level parser for alias - alias_description = "Manage aliases\n\nAn alias is a command that enables replacement of a word by another string." - alias_epilog = "See also:\n macro" - alias_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_description, epilog=alias_epilog) - alias_parser.add_subparsers(metavar='SUBCOMMAND', required=True) + @staticmethod + def _build_alias_parser() -> Cmd2ArgumentParser: + alias_description = Text.assemble( + "Manage aliases.", + "\n\n", + "An alias is a command that enables replacement of a word by another string.", + ) + alias_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_description) + alias_parser.epilog = alias_parser.create_text_group( + "See Also", + "macro", + ) + alias_parser.add_subparsers(metavar='SUBCOMMAND', required=True) + + return alias_parser # Preserve quotes since we are passing strings to other commands - @with_argparser(alias_parser, preserve_quotes=True) + @with_argparser(_build_alias_parser, preserve_quotes=True) def do_alias(self, args: argparse.Namespace) -> None: """Manage aliases.""" # Call handler for whatever subcommand was selected @@ -3353,34 +3602,41 @@ def do_alias(self, args: argparse.Namespace) -> None: handler(args) # alias -> create - alias_create_description = "Create or overwrite an alias" - - alias_create_epilog = ( - "Notes:\n" - " If you want to use redirection, pipes, or terminators in the value of the\n" - " alias, then quote them.\n" - "\n" - " Since aliases are resolved during parsing, tab completion will function as\n" - " it would for the actual command the alias resolves to.\n" - "\n" - "Examples:\n" - " alias create ls !ls -lF\n" - " alias create show_log !cat \"log file.txt\"\n" - " alias create save_results print_results \">\" out.txt\n" - ) + @classmethod + def _build_alias_create_parser(cls) -> Cmd2ArgumentParser: + alias_create_description = "Create or overwrite an alias." + alias_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_create_description) + + # Add Notes epilog + alias_create_notes = Text.assemble( + "If you want to use redirection, pipes, or terminators in the value of the alias, then quote them.", + "\n\n", + (" alias create save_results print_results \">\" out.txt\n", Cmd2Style.COMMAND_LINE), + "\n\n", + ( + "Since aliases are resolved during parsing, tab completion will function as it would " + "for the actual command the alias resolves to." + ), + ) + alias_create_parser.epilog = alias_create_parser.create_text_group("Notes", alias_create_notes) + + # Add arguments + alias_create_parser.add_argument('name', help='name of this alias') + alias_create_parser.add_argument( + 'command', + help='command, alias, or macro to run', + choices_provider=cls._get_commands_aliases_and_macros_for_completion, + ) + alias_create_parser.add_argument( + 'command_args', + nargs=argparse.REMAINDER, + help='arguments to pass to command', + completer=cls.path_complete, + ) - alias_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( - description=alias_create_description, epilog=alias_create_epilog - ) - alias_create_parser.add_argument('name', help='name of this alias') - alias_create_parser.add_argument( - 'command', help='what the alias resolves to', choices_provider=_get_commands_aliases_and_macros_for_completion - ) - alias_create_parser.add_argument( - 'command_args', nargs=argparse.REMAINDER, help='arguments to pass to command', completer=path_complete - ) + return alias_create_parser - @as_subcommand_to('alias', 'create', alias_create_parser, help=alias_create_description.lower()) + @as_subcommand_to('alias', 'create', _build_alias_create_parser, help="create or overwrite an alias") def _alias_create(self, args: argparse.Namespace) -> None: """Create or overwrite an alias.""" self.last_result = False @@ -3417,20 +3673,23 @@ def _alias_create(self, args: argparse.Namespace) -> None: self.last_result = True # alias -> delete - alias_delete_help = "delete aliases" - alias_delete_description = "Delete specified aliases or all aliases if --all is used" + @classmethod + def _build_alias_delete_parser(cls) -> Cmd2ArgumentParser: + alias_delete_description = "Delete specified aliases or all aliases if --all is used." + + alias_delete_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_delete_description) + alias_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all aliases") + alias_delete_parser.add_argument( + 'names', + nargs=argparse.ZERO_OR_MORE, + help='alias(es) to delete', + choices_provider=cls._get_alias_completion_items, + descriptive_headers=["Value"], + ) - alias_delete_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_delete_description) - alias_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all aliases") - alias_delete_parser.add_argument( - 'names', - nargs=argparse.ZERO_OR_MORE, - help='alias(es) to delete', - choices_provider=_get_alias_completion_items, - descriptive_header=_alias_completion_table.generate_header(), - ) + return alias_delete_parser - @as_subcommand_to('alias', 'delete', alias_delete_parser, help=alias_delete_help) + @as_subcommand_to('alias', 'delete', _build_alias_delete_parser, help="delete aliases") def _alias_delete(self, args: argparse.Namespace) -> None: """Delete aliases.""" self.last_result = True @@ -3450,24 +3709,29 @@ def _alias_delete(self, args: argparse.Namespace) -> None: self.perror(f"Alias '{cur_name}' does not exist") # alias -> list - alias_list_help = "list aliases" - alias_list_description = ( - "List specified aliases in a reusable form that can be saved to a startup\n" - "script to preserve aliases across sessions\n" - "\n" - "Without arguments, all aliases will be listed." - ) + @classmethod + def _build_alias_list_parser(cls) -> Cmd2ArgumentParser: + alias_list_description = Text.assemble( + ( + "List specified aliases in a reusable form that can be saved to a startup " + "script to preserve aliases across sessions." + ), + "\n\n", + "Without arguments, all aliases will be listed.", + ) - alias_list_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_list_description) - alias_list_parser.add_argument( - 'names', - nargs=argparse.ZERO_OR_MORE, - help='alias(es) to list', - choices_provider=_get_alias_completion_items, - descriptive_header=_alias_completion_table.generate_header(), - ) + alias_list_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_list_description) + alias_list_parser.add_argument( + 'names', + nargs=argparse.ZERO_OR_MORE, + help='alias(es) to list', + choices_provider=cls._get_alias_completion_items, + descriptive_headers=["Value"], + ) + + return alias_list_parser - @as_subcommand_to('alias', 'list', alias_list_parser, help=alias_list_help) + @as_subcommand_to('alias', 'list', _build_alias_list_parser, help="list aliases") def _alias_list(self, args: argparse.Namespace) -> None: """List some or all aliases as 'alias create' commands.""" self.last_result = {} # dict[alias_name, alias_value] @@ -3503,14 +3767,46 @@ def _alias_list(self, args: argparse.Namespace) -> None: # Parsers and functions for macro command and subcommands ############################################################# + def macro_arg_complete( + self, + text: str, + line: str, + begidx: int, + endidx: int, + ) -> list[str]: + """Tab completes arguments to a macro. + + Its default behavior is to call path_complete, but you can override this as needed. + + The args required by this function are defined in the header of Python's cmd.py. + + :param text: the string prefix we are attempting to match (all matches must begin with it) + :param line: the current input line with leading whitespace removed + :param begidx: the beginning index of the prefix text + :param endidx: the ending index of the prefix text + :return: a list of possible tab completions + """ + return self.path_complete(text, line, begidx, endidx) + # Top-level parser for macro - macro_description = "Manage macros\n\nA macro is similar to an alias, but it can contain argument placeholders." - macro_epilog = "See also:\n alias" - macro_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_description, epilog=macro_epilog) - macro_parser.add_subparsers(metavar='SUBCOMMAND', required=True) + @staticmethod + def _build_macro_parser() -> Cmd2ArgumentParser: + macro_description = Text.assemble( + "Manage macros.", + "\n\n", + "A macro is similar to an alias, but it can contain argument placeholders.", + ) + macro_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_description) + macro_parser.epilog = macro_parser.create_text_group( + "See Also", + "alias", + ) + macro_parser.add_subparsers(metavar='SUBCOMMAND', required=True) + + return macro_parser # Preserve quotes since we are passing strings to other commands - @with_argparser(macro_parser, preserve_quotes=True) + @with_argparser(_build_macro_parser, preserve_quotes=True) def do_macro(self, args: argparse.Namespace) -> None: """Manage macros.""" # Call handler for whatever subcommand was selected @@ -3518,58 +3814,72 @@ def do_macro(self, args: argparse.Namespace) -> None: handler(args) # macro -> create - macro_create_help = "create or overwrite a macro" - macro_create_description = "Create or overwrite a macro" - - macro_create_epilog = ( - "A macro is similar to an alias, but it can contain argument placeholders.\n" - "Arguments are expressed when creating a macro using {#} notation where {1}\n" - "means the first argument.\n" - "\n" - "The following creates a macro called my_macro that expects two arguments:\n" - "\n" - " macro create my_macro make_dinner --meat {1} --veggie {2}\n" - "\n" - "When the macro is called, the provided arguments are resolved and the\n" - "assembled command is run. For example:\n" - "\n" - " my_macro beef broccoli ---> make_dinner --meat beef --veggie broccoli\n" - "\n" - "Notes:\n" - " To use the literal string {1} in your command, escape it this way: {{1}}.\n" - "\n" - " Extra arguments passed to a macro are appended to resolved command.\n" - "\n" - " An argument number can be repeated in a macro. In the following example the\n" - " first argument will populate both {1} instances.\n" - "\n" - " macro create ft file_taxes -p {1} -q {2} -r {1}\n" - "\n" - " To quote an argument in the resolved command, quote it during creation.\n" - "\n" - " macro create backup !cp \"{1}\" \"{1}.orig\"\n" - "\n" - " If you want to use redirection, pipes, or terminators in the value of the\n" - " macro, then quote them.\n" - "\n" - " macro create show_results print_results -type {1} \"|\" less\n" - "\n" - " Because macros do not resolve until after hitting Enter, tab completion\n" - " will only complete paths while typing a macro." - ) + @classmethod + def _build_macro_create_parser(cls) -> Cmd2ArgumentParser: + macro_create_description = Text.assemble( + "Create or overwrite a macro.", + "\n\n", + "A macro is similar to an alias, but it can contain argument placeholders.", + "\n\n", + "Arguments are expressed when creating a macro using {#} notation where {1} means the first argument.", + "\n\n", + "The following creates a macro called my_macro that expects two arguments:", + "\n\n", + (" macro create my_macro make_dinner --meat {1} --veggie {2}", Cmd2Style.COMMAND_LINE), + "\n\n", + "When the macro is called, the provided arguments are resolved and the assembled command is run. For example:", + "\n\n", + (" my_macro beef broccoli", Cmd2Style.COMMAND_LINE), + (" ───> ", Style(bold=True)), + ("make_dinner --meat beef --veggie broccoli", Cmd2Style.COMMAND_LINE), + ) + macro_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_create_description) + + # Add Notes epilog + macro_create_notes = Text.assemble( + "To use the literal string {1} in your command, escape it this way: {{1}}.", + "\n\n", + "Extra arguments passed to a macro are appended to resolved command.", + "\n\n", + ( + "An argument number can be repeated in a macro. In the following example the " + "first argument will populate both {1} instances." + ), + "\n\n", + (" macro create ft file_taxes -p {1} -q {2} -r {1}", Cmd2Style.COMMAND_LINE), + "\n\n", + "To quote an argument in the resolved command, quote it during creation.", + "\n\n", + (" macro create backup !cp \"{1}\" \"{1}.orig\"", Cmd2Style.COMMAND_LINE), + "\n\n", + "If you want to use redirection, pipes, or terminators in the value of the macro, then quote them.", + "\n\n", + (" macro create show_results print_results -type {1} \"|\" less", Cmd2Style.COMMAND_LINE), + "\n\n", + ( + "Since macros don't resolve until after you press Enter, their arguments tab complete as paths. " + "This default behavior changes if custom tab completion for macro arguments has been implemented." + ), + ) + macro_create_parser.epilog = macro_create_parser.create_text_group("Notes", macro_create_notes) + + # Add arguments + macro_create_parser.add_argument('name', help='name of this macro') + macro_create_parser.add_argument( + 'command', + help='command, alias, or macro to run', + choices_provider=cls._get_commands_aliases_and_macros_for_completion, + ) + macro_create_parser.add_argument( + 'command_args', + nargs=argparse.REMAINDER, + help='arguments to pass to command', + completer=cls.path_complete, + ) - macro_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( - description=macro_create_description, epilog=macro_create_epilog - ) - macro_create_parser.add_argument('name', help='name of this macro') - macro_create_parser.add_argument( - 'command', help='what the macro resolves to', choices_provider=_get_commands_aliases_and_macros_for_completion - ) - macro_create_parser.add_argument( - 'command_args', nargs=argparse.REMAINDER, help='arguments to pass to command', completer=path_complete - ) + return macro_create_parser - @as_subcommand_to('macro', 'create', macro_create_parser, help=macro_create_help) + @as_subcommand_to('macro', 'create', _build_macro_create_parser, help="create or overwrite a macro") def _macro_create(self, args: argparse.Namespace) -> None: """Create or overwrite a macro.""" self.last_result = False @@ -3649,19 +3959,23 @@ def _macro_create(self, args: argparse.Namespace) -> None: self.last_result = True # macro -> delete - macro_delete_help = "delete macros" - macro_delete_description = "Delete specified macros or all macros if --all is used" - macro_delete_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_delete_description) - macro_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all macros") - macro_delete_parser.add_argument( - 'names', - nargs=argparse.ZERO_OR_MORE, - help='macro(s) to delete', - choices_provider=_get_macro_completion_items, - descriptive_header=_macro_completion_table.generate_header(), - ) + @classmethod + def _build_macro_delete_parser(cls) -> Cmd2ArgumentParser: + macro_delete_description = "Delete specified macros or all macros if --all is used." + + macro_delete_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_delete_description) + macro_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all macros") + macro_delete_parser.add_argument( + 'names', + nargs=argparse.ZERO_OR_MORE, + help='macro(s) to delete', + choices_provider=cls._get_macro_completion_items, + descriptive_headers=["Value"], + ) - @as_subcommand_to('macro', 'delete', macro_delete_parser, help=macro_delete_help) + return macro_delete_parser + + @as_subcommand_to('macro', 'delete', _build_macro_delete_parser, help="delete macros") def _macro_delete(self, args: argparse.Namespace) -> None: """Delete macros.""" self.last_result = True @@ -3682,11 +3996,10 @@ def _macro_delete(self, args: argparse.Namespace) -> None: # macro -> list macro_list_help = "list macros" - macro_list_description = ( - "List specified macros in a reusable form that can be saved to a startup script\n" - "to preserve macros across sessions\n" - "\n" - "Without arguments, all macros will be listed." + macro_list_description = Text.assemble( + "List specified macros in a reusable form that can be saved to a startup script to preserve macros across sessions.", + "\n\n", + "Without arguments, all macros will be listed.", ) macro_list_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_list_description) @@ -3695,7 +4008,7 @@ def _macro_delete(self, args: argparse.Namespace) -> None: nargs=argparse.ZERO_OR_MORE, help='macro(s) to list', choices_provider=_get_macro_completion_items, - descriptive_header=_macro_completion_table.generate_header(), + descriptive_headers=["Value"], ) @as_subcommand_to('macro', 'list', macro_list_parser, help=macro_list_help) @@ -3754,30 +4067,110 @@ def complete_help_subcommands( completer = argparse_completer.DEFAULT_AP_COMPLETER(argparser, self) return completer.complete_subcommand_help(text, line, begidx, endidx, arg_tokens['subcommands']) - help_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( - description="List available commands or provide detailed help for a specific command" - ) - help_parser.add_argument( - '-v', '--verbose', action='store_true', help="print a list of all commands with descriptions of each" - ) - help_parser.add_argument( - 'command', nargs=argparse.OPTIONAL, help="command to retrieve help for", completer=complete_help_command - ) - help_parser.add_argument( - 'subcommands', nargs=argparse.REMAINDER, help="subcommand(s) to retrieve help for", completer=complete_help_subcommands - ) + def _build_command_info(self) -> tuple[dict[str, list[str]], list[str], list[str], list[str]]: + """Categorizes and sorts visible commands and help topics for display. + + :return: tuple containing: + - dictionary mapping category names to lists of command names + - list of documented command names + - list of undocumented command names + - list of help topic names that are not also commands + """ + # Get a sorted list of help topics + help_topics = sorted(self.get_help_topics(), key=self.default_sort_key) + + # Get a sorted list of visible command names + visible_commands = sorted(self.get_visible_commands(), key=self.default_sort_key) + cmds_doc: list[str] = [] + cmds_undoc: list[str] = [] + cmds_cats: dict[str, list[str]] = {} + for command in visible_commands: + func = cast(CommandFunc, self.cmd_func(command)) + has_help_func = False + has_parser = func in self._command_parsers + + if command in help_topics: + # Prevent the command from showing as both a command and help topic in the output + help_topics.remove(command) + + # Non-argparse commands can have help_functions for their documentation + has_help_func = not has_parser + + if hasattr(func, constants.CMD_ATTR_HELP_CATEGORY): + category: str = getattr(func, constants.CMD_ATTR_HELP_CATEGORY) + cmds_cats.setdefault(category, []) + cmds_cats[category].append(command) + elif func.__doc__ or has_help_func or has_parser: + cmds_doc.append(command) + else: + cmds_undoc.append(command) + return cmds_cats, cmds_doc, cmds_undoc, help_topics + + @classmethod + def _build_help_parser(cls) -> Cmd2ArgumentParser: + help_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( + description="List available commands or provide detailed help for a specific command." + ) + help_parser.add_argument( + '-v', + '--verbose', + action='store_true', + help="print a list of all commands with descriptions of each", + ) + help_parser.add_argument( + 'command', + nargs=argparse.OPTIONAL, + help="command to retrieve help for", + completer=cls.complete_help_command, + ) + help_parser.add_argument( + 'subcommands', + nargs=argparse.REMAINDER, + help="subcommand(s) to retrieve help for", + completer=cls.complete_help_subcommands, + ) + return help_parser # Get rid of cmd's complete_help() functions so ArgparseCompleter will complete the help command if getattr(cmd.Cmd, 'complete_help', None) is not None: delattr(cmd.Cmd, 'complete_help') - @with_argparser(help_parser) + @with_argparser(_build_help_parser) def do_help(self, args: argparse.Namespace) -> None: """List available commands or provide detailed help for a specific command.""" self.last_result = True if not args.command or args.verbose: - self._help_menu(args.verbose) + cmds_cats, cmds_doc, cmds_undoc, help_topics = self._build_command_info() + + if self.doc_leader: + self.poutput() + self.poutput(Text(self.doc_leader, style=Cmd2Style.HELP_LEADER)) + self.poutput() + + # Print any categories first and then the remaining documented commands. + sorted_categories = sorted(cmds_cats.keys(), key=self.default_sort_key) + all_cmds = {category: cmds_cats[category] for category in sorted_categories} + if all_cmds: + all_cmds[self.default_category] = cmds_doc + else: + all_cmds[self.doc_header] = cmds_doc + + # Used to provide verbose table separation for better readability. + previous_table_printed = False + + for category, commands in all_cmds.items(): + if previous_table_printed: + self.poutput() + + self._print_documented_command_topics(category, commands, args.verbose) + previous_table_printed = bool(commands) and args.verbose + + if previous_table_printed and (help_topics or cmds_undoc): + self.poutput() + + self.print_topics(self.misc_header, help_topics, 15, 80) + self.print_topics(self.undoc_header, cmds_undoc, 15, 80) else: # Getting help for a specific command @@ -3788,63 +4181,131 @@ def do_help(self, args: argparse.Namespace) -> None: # If the command function uses argparse, then use argparse's help if func is not None and argparser is not None: completer = argparse_completer.DEFAULT_AP_COMPLETER(argparser, self) + completer.print_help(args.subcommands, self.stdout) - # Set end to blank so the help output matches how it looks when "command -h" is used - self.poutput(completer.format_help(args.subcommands), end='') - - # If there is a help func delegate to do_help + # If the command has a custom help function, then call it elif help_func is not None: - super().do_help(args.command) + help_func() - # If there's no help_func __doc__ then format and output it + # If the command function has a docstring, then print it elif func is not None and func.__doc__ is not None: self.poutput(pydoc.getdoc(func)) # If there is no help information then print an error else: err_msg = self.help_error.format(args.command) - - # Set apply_style to False so help_error's style is not overridden - self.perror(err_msg, apply_style=False) + self.perror(err_msg, style=None) self.last_result = False - def print_topics(self, header: str, cmds: Optional[list[str]], cmdlen: int, maxcol: int) -> None: # noqa: ARG002 + def print_topics(self, header: str, cmds: list[str] | None, cmdlen: int, maxcol: int) -> None: # noqa: ARG002 """Print groups of commands and topics in columns and an optional header. - Override of cmd's print_topics() to handle headers with newlines, ANSI style sequences, and wide characters. + Override of cmd's print_topics() to use Rich. :param header: string to print above commands being printed :param cmds: list of topics to print :param cmdlen: unused, even by cmd's version :param maxcol: max number of display columns to fit into """ - if cmds: - self.poutput(header) - if self.ruler: - divider = utils.align_left('', fill_char=self.ruler, width=ansi.widest_line(header)) - self.poutput(divider) - self.columnize(cmds, maxcol - 1) - self.poutput() + if not cmds: + return - def columnize(self, str_list: Optional[list[str]], display_width: int = 80) -> None: - """Display a list of single-line strings as a compact set of columns. + # Print a row that looks like a table header. + if header: + header_grid = Table.grid() + header_grid.add_row(Text(header, style=Cmd2Style.HELP_HEADER)) + header_grid.add_row(Rule(characters=self.ruler, style=Cmd2Style.TABLE_BORDER)) + self.poutput(header_grid) - Override of cmd's columnize() to handle strings with ANSI style sequences and wide characters. + # Subtract 1 from maxcol to account for a one-space right margin. + maxcol = min(maxcol, ru.console_width()) - 1 + self.columnize(cmds, maxcol) + self.poutput() + + def _print_documented_command_topics(self, header: str, cmds: list[str], verbose: bool) -> None: + """Print topics which are documented commands, switching between verbose or traditional output.""" + import io - Each column is only as wide as necessary. - Columns are separated by two spaces (one was not legible enough). + if not cmds: + return + + if not verbose: + self.print_topics(header, cmds, 15, 80) + return + + # Create a grid to hold the header and the topics table + category_grid = Table.grid() + category_grid.add_row(Text(header, style=Cmd2Style.HELP_HEADER)) + category_grid.add_row(Rule(characters=self.ruler, style=Cmd2Style.TABLE_BORDER)) + + topics_table = Table( + Column("Name", no_wrap=True), + Column("Description", overflow="fold"), + box=rich.box.SIMPLE_HEAD, + show_edge=False, + border_style=Cmd2Style.TABLE_BORDER, + ) + + # Try to get the documentation string for each command + topics = self.get_help_topics() + for command in cmds: + if (cmd_func := self.cmd_func(command)) is None: + continue + + doc: str | None + + # Non-argparse commands can have help_functions for their documentation + if command in topics: + help_func = getattr(self, constants.HELP_FUNC_PREFIX + command) + result = io.StringIO() + + # try to redirect system stdout + with contextlib.redirect_stdout(result): + # save our internal stdout + stdout_orig = self.stdout + try: + # redirect our internal stdout + self.stdout = cast(TextIO, result) + help_func() + finally: + with self.sigint_protection: + # restore internal stdout + self.stdout = stdout_orig + doc = result.getvalue() + + else: + doc = cmd_func.__doc__ + + # Attempt to locate the first documentation block + cmd_desc = strip_doc_annotations(doc) if doc else '' + + # Add this command to the table + topics_table.add_row(command, cmd_desc) + + category_grid.add_row(topics_table) + self.poutput(category_grid) + self.poutput() + + def render_columns(self, str_list: list[str] | None, display_width: int = 80) -> str: + """Render a list of single-line strings as a compact set of columns. + + This method correctly handles strings containing ANSI style sequences and + full-width characters (like those used in CJK languages). Each column is + only as wide as necessary and columns are separated by two spaces. + + :param str_list: list of single-line strings to display + :param display_width: max number of display columns to fit into + :return: a string containing the columnized output """ if not str_list: - self.poutput("") - return + return "" - nonstrings = [i for i in range(len(str_list)) if not isinstance(str_list[i], str)] - if nonstrings: - raise TypeError(f"str_list[i] not a string for i in {nonstrings}") size = len(str_list) if size == 1: - self.poutput(str_list[0]) - return + return str_list[0] + + rows: list[str] = [] + # Try every row count from 1 upwards for nrows in range(1, len(str_list)): ncols = (size + nrows - 1) // nrows @@ -3857,7 +4318,7 @@ def columnize(self, str_list: Optional[list[str]], display_width: int = 80) -> N if i >= size: break x = str_list[i] - colwidth = max(colwidth, ansi.style_aware_wcswidth(x)) + colwidth = max(colwidth, su.str_width(x)) colwidths.append(colwidth) totwidth += colwidth + 2 if totwidth > display_width: @@ -3868,7 +4329,8 @@ def columnize(self, str_list: Optional[list[str]], display_width: int = 80) -> N # The output is wider than display_width. Print 1 column with each string on its own row. nrows = len(str_list) ncols = 1 - colwidths = [1] + max_width = max(su.str_width(s) for s in str_list) + colwidths = [max_width] for row in range(nrows): texts = [] for col in range(ncols): @@ -3878,130 +4340,29 @@ def columnize(self, str_list: Optional[list[str]], display_width: int = 80) -> N while texts and not texts[-1]: del texts[-1] for col in range(len(texts)): - texts[col] = utils.align_left(texts[col], width=colwidths[col]) - self.poutput(" ".join(texts)) - - def _help_menu(self, verbose: bool = False) -> None: - """Show a list of commands which help can be displayed for.""" - cmds_cats, cmds_doc, cmds_undoc, help_topics = self._build_command_info() + texts[col] = su.align_left(texts[col], width=colwidths[col]) + rows.append(" ".join(texts)) - if not cmds_cats: - # No categories found, fall back to standard behavior - self.poutput(self.doc_leader) - self._print_topics(self.doc_header, cmds_doc, verbose) - else: - # Categories found, Organize all commands by category - self.poutput(self.doc_leader) - self.poutput(self.doc_header, end="\n\n") - for category in sorted(cmds_cats.keys(), key=self.default_sort_key): - self._print_topics(category, cmds_cats[category], verbose) - self._print_topics(self.default_category, cmds_doc, verbose) - - self.print_topics(self.misc_header, help_topics, 15, 80) - self.print_topics(self.undoc_header, cmds_undoc, 15, 80) + return "\n".join(rows) - def _build_command_info(self) -> tuple[dict[str, list[str]], list[str], list[str], list[str]]: - # Get a sorted list of help topics - help_topics = sorted(self.get_help_topics(), key=self.default_sort_key) - - # Get a sorted list of visible command names - visible_commands = sorted(self.get_visible_commands(), key=self.default_sort_key) - cmds_doc: list[str] = [] - cmds_undoc: list[str] = [] - cmds_cats: dict[str, list[str]] = {} - for command in visible_commands: - func = cast(CommandFunc, self.cmd_func(command)) - has_help_func = False - has_parser = func in self._command_parsers - - if command in help_topics: - # Prevent the command from showing as both a command and help topic in the output - help_topics.remove(command) - - # Non-argparse commands can have help_functions for their documentation - has_help_func = not has_parser - - if hasattr(func, constants.CMD_ATTR_HELP_CATEGORY): - category: str = getattr(func, constants.CMD_ATTR_HELP_CATEGORY) - cmds_cats.setdefault(category, []) - cmds_cats[category].append(command) - elif func.__doc__ or has_help_func or has_parser: - cmds_doc.append(command) - else: - cmds_undoc.append(command) - return cmds_cats, cmds_doc, cmds_undoc, help_topics - - def _print_topics(self, header: str, cmds: list[str], verbose: bool) -> None: - """Print topics, switching between verbose or traditional output.""" - import io - - if cmds: - if not verbose: - self.print_topics(header, cmds, 15, 80) - else: - # Find the widest command - widest = max([ansi.style_aware_wcswidth(command) for command in cmds]) - - # Define the table structure - name_column = Column('', width=max(widest, 20)) - desc_column = Column('', width=80) - - topic_table = SimpleTable([name_column, desc_column], divider_char=self.ruler) - - # Build the topic table - table_str_buf = io.StringIO() - if header: - table_str_buf.write(header + "\n") - - divider = topic_table.generate_divider() - if divider: - table_str_buf.write(divider + "\n") - - # Try to get the documentation string for each command - topics = self.get_help_topics() - for command in cmds: - if (cmd_func := self.cmd_func(command)) is None: - continue - - doc: Optional[str] - - # If this is an argparse command, use its description. - if (cmd_parser := self._command_parsers.get(cmd_func)) is not None: - doc = cmd_parser.description - - # Non-argparse commands can have help_functions for their documentation - elif command in topics: - help_func = getattr(self, constants.HELP_FUNC_PREFIX + command) - result = io.StringIO() - - # try to redirect system stdout - with contextlib.redirect_stdout(result): - # save our internal stdout - stdout_orig = self.stdout - try: - # redirect our internal stdout - self.stdout = cast(TextIO, result) - help_func() - finally: - # restore internal stdout - self.stdout = stdout_orig - doc = result.getvalue() - - else: - doc = cmd_func.__doc__ - - # Attempt to locate the first documentation block - cmd_desc = strip_doc_annotations(doc) if doc else '' + def columnize(self, str_list: list[str] | None, display_width: int = 80) -> None: + """Display a list of single-line strings as a compact set of columns. - # Add this command to the table - table_row = topic_table.generate_data_row([command, cmd_desc]) - table_str_buf.write(table_row + '\n') + Override of cmd's columnize() that uses the render_columns() method. + The method correctly handles strings with ANSI style sequences and + full-width characters (like those used in CJK languages). - self.poutput(table_str_buf.getvalue()) + :param str_list: list of single-line strings to display + :param display_width: max number of display columns to fit into + """ + columnized_strs = self.render_columns(str_list, display_width) + self.poutput(columnized_strs) - shortcuts_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="List available shortcuts") + @staticmethod + def _build_shortcuts_parser() -> Cmd2ArgumentParser: + return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="List available shortcuts.") - @with_argparser(shortcuts_parser) + @with_argparser(_build_shortcuts_parser) def do_shortcuts(self, _: argparse.Namespace) -> None: """List available shortcuts.""" # Sort the shortcut tuples by name @@ -4010,12 +4371,18 @@ def do_shortcuts(self, _: argparse.Namespace) -> None: self.poutput(f"Shortcuts for other commands:\n{result}") self.last_result = True - eof_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( - description="Called when Ctrl-D is pressed", epilog=INTERNAL_COMMAND_EPILOG - ) + @staticmethod + def _build_eof_parser() -> Cmd2ArgumentParser: + eof_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Called when Ctrl-D is pressed.") + eof_parser.epilog = eof_parser.create_text_group( + "Note", + "This command is for internal use and is not intended to be called from the command line.", + ) - @with_argparser(eof_parser) - def do_eof(self, _: argparse.Namespace) -> Optional[bool]: + return eof_parser + + @with_argparser(_build_eof_parser) + def do_eof(self, _: argparse.Namespace) -> bool | None: """Quit with no arguments, called when Ctrl-D is pressed. This can be overridden if quit should be called differently. @@ -4025,16 +4392,18 @@ def do_eof(self, _: argparse.Namespace) -> Optional[bool]: # self.last_result will be set by do_quit() return self.do_quit('') - quit_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Exit this application") + @staticmethod + def _build_quit_parser() -> Cmd2ArgumentParser: + return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Exit this application.") - @with_argparser(quit_parser) - def do_quit(self, _: argparse.Namespace) -> Optional[bool]: + @with_argparser(_build_quit_parser) + def do_quit(self, _: argparse.Namespace) -> bool | None: """Exit this application.""" # Return True to stop the command loop self.last_result = True return True - def select(self, opts: Union[str, list[str], list[tuple[Any, Optional[str]]]], prompt: str = 'Your choice? ') -> Any: + def select(self, opts: str | list[str] | list[tuple[Any, str | None]], prompt: str = 'Your choice? ') -> Any: """Present a numbered menu to the user. Modeled after the bash shell's SELECT. Returns the item chosen. @@ -4047,12 +4416,12 @@ def select(self, opts: Union[str, list[str], list[tuple[Any, Optional[str]]]], p that the return value can differ from the text advertised to the user """ - local_opts: Union[list[str], list[tuple[Any, Optional[str]]]] + local_opts: list[str] | list[tuple[Any, str | None]] if isinstance(opts, str): - local_opts = cast(list[tuple[Any, Optional[str]]], list(zip(opts.split(), opts.split()))) + local_opts = cast(list[tuple[Any, str | None]], list(zip(opts.split(), opts.split(), strict=False))) else: local_opts = opts - fulloptions: list[tuple[Any, Optional[str]]] = [] + fulloptions: list[tuple[Any, str | None]] = [] for opt in local_opts: if isinstance(opt, str): fulloptions.append((opt, opt)) @@ -4085,6 +4454,29 @@ def select(self, opts: Union[str, list[str], list[tuple[Any, Optional[str]]]], p except (ValueError, IndexError): self.poutput(f"'{response}' isn't a valid choice. Pick a number between 1 and {len(fulloptions)}:") + @classmethod + def _build_base_set_parser(cls) -> Cmd2ArgumentParser: + # When tab completing value, we recreate the set command parser with a value argument specific to + # the settable being edited. To make this easier, define a base parser with all the common elements. + set_description = Text.assemble( + "Set a settable parameter or show current settings of parameters.", + "\n\n", + ( + "Call without arguments for a list of all settable parameters with their values. " + "Call with just param to view that parameter's value." + ), + ) + base_set_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=set_description) + base_set_parser.add_argument( + 'param', + nargs=argparse.OPTIONAL, + help='parameter to set or view', + choices_provider=cls._get_settable_completion_items, + descriptive_headers=["Value", "Description"], + ) + + return base_set_parser + def complete_set_value( self, text: str, line: str, begidx: int, endidx: int, arg_tokens: dict[str, list[str]] ) -> list[str]: @@ -4096,7 +4488,7 @@ def complete_set_value( raise CompletionError(param + " is not a settable parameter") from exc # Create a parser with a value field based on this settable - settable_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(parents=[Cmd.set_parser_parent]) + settable_parser = self._build_base_set_parser() # Settables with choices list the values of those choices instead of the arg name # in help text and this shows in tab completion hints. Set metavar to avoid this. @@ -4105,7 +4497,7 @@ def complete_set_value( arg_name, metavar=arg_name, help=settable.description, - choices=settable.choices, # type: ignore[arg-type] + choices=settable.choices, choices_provider=settable.choices_provider, completer=settable.completer, ) @@ -4116,30 +4508,22 @@ def complete_set_value( _, raw_tokens = self.tokens_for_completion(line, begidx, endidx) return completer.complete(text, line, begidx, endidx, raw_tokens[1:]) - # When tab completing value, we recreate the set command parser with a value argument specific to - # the settable being edited. To make this easier, define a parent parser with all the common elements. - set_description = ( - "Set a settable parameter or show current settings of parameters\n" - "Call without arguments for a list of all settable parameters with their values.\n" - "Call with just param to view that parameter's value." - ) - set_parser_parent = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=set_description, add_help=False) - set_parser_parent.add_argument( - 'param', - nargs=argparse.OPTIONAL, - help='parameter to set or view', - choices_provider=_get_settable_completion_items, - descriptive_header=_settable_completion_table.generate_header(), - ) + @classmethod + def _build_set_parser(cls) -> Cmd2ArgumentParser: + # Create the parser for the set command + set_parser = cls._build_base_set_parser() + set_parser.add_argument( + 'value', + nargs=argparse.OPTIONAL, + help='new value for settable', + completer=cls.complete_set_value, + suppress_tab_hint=True, + ) - # Create the parser for the set command - set_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(parents=[set_parser_parent]) - set_parser.add_argument( - 'value', nargs=argparse.OPTIONAL, help='new value for settable', completer=complete_set_value, suppress_tab_hint=True - ) + return set_parser # Preserve quotes so users can pass in quoted empty strings and flags (e.g. -h) as the value - @with_argparser(set_parser, preserve_quotes=True) + @with_argparser(_build_set_parser, preserve_quotes=True) def do_set(self, args: argparse.Namespace) -> None: """Set a settable parameter or show current settings of parameters.""" self.last_result = False @@ -4158,52 +4542,59 @@ def do_set(self, args: argparse.Namespace) -> None: if args.value: # Try to update the settable's value try: - orig_value = settable.get_value() - settable.set_value(utils.strip_quotes(args.value)) + orig_value = settable.value + settable.value = su.strip_quotes(args.value) except ValueError as ex: self.perror(f"Error setting {args.param}: {ex}") else: - self.poutput(f"{args.param} - was: {orig_value!r}\nnow: {settable.get_value()!r}") + self.poutput(f"{args.param} - was: {orig_value!r}\nnow: {settable.value!r}") self.last_result = True return # Show one settable - to_show = [args.param] + to_show: list[str] = [args.param] else: # Show all settables to_show = list(self.settables.keys()) # Define the table structure - name_label = 'Name' - max_name_width = max([ansi.style_aware_wcswidth(param) for param in to_show]) - max_name_width = max(max_name_width, ansi.style_aware_wcswidth(name_label)) - - cols: list[Column] = [ - Column(name_label, width=max_name_width), - Column('Value', width=30), - Column('Description', width=60), - ] - - table = SimpleTable(cols, divider_char=self.ruler) - self.poutput(table.generate_header()) + settable_table = Table( + Column("Name", no_wrap=True), + Column("Value", overflow="fold"), + Column("Description", overflow="fold"), + box=rich.box.SIMPLE_HEAD, + show_edge=False, + border_style=Cmd2Style.TABLE_BORDER, + ) # Build the table and populate self.last_result self.last_result = {} # dict[settable_name, settable_value] for param in sorted(to_show, key=self.default_sort_key): settable = self.settables[param] - row_data = [param, settable.get_value(), settable.description] - self.poutput(table.generate_data_row(row_data)) - self.last_result[param] = settable.get_value() - - shell_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Execute a command as if at the OS prompt") - shell_parser.add_argument('command', help='the command to run', completer=shell_cmd_complete) - shell_parser.add_argument( - 'command_args', nargs=argparse.REMAINDER, help='arguments to pass to command', completer=path_complete - ) + settable_table.add_row( + param, + str(settable.value), + settable.description, + ) + self.last_result[param] = settable.value + + self.poutput() + self.poutput(settable_table) + self.poutput() + + @classmethod + def _build_shell_parser(cls) -> Cmd2ArgumentParser: + shell_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Execute a command as if at the OS prompt.") + shell_parser.add_argument('command', help='the command to run', completer=cls.shell_cmd_complete) + shell_parser.add_argument( + 'command_args', nargs=argparse.REMAINDER, help='arguments to pass to command', completer=cls.path_complete + ) + + return shell_parser # Preserve quotes since we are passing these strings to the shell - @with_argparser(shell_parser, preserve_quotes=True) + @with_argparser(_build_shell_parser, preserve_quotes=True) def do_shell(self, args: argparse.Namespace) -> None: """Execute a command as if at the OS prompt.""" import signal @@ -4241,15 +4632,15 @@ def do_shell(self, args: argparse.Namespace) -> None: # still receive the SIGINT since it is in the same process group as us. with self.sigint_protection: # For any stream that is a StdSim, we will use a pipe so we can capture its output - proc = subprocess.Popen( # type: ignore[call-overload] # noqa: S602 + proc = subprocess.Popen( # noqa: S602 expanded_command, stdout=subprocess.PIPE if isinstance(self.stdout, utils.StdSim) else self.stdout, # type: ignore[unreachable] - stderr=subprocess.PIPE if isinstance(sys.stderr, utils.StdSim) else sys.stderr, # type: ignore[unreachable] + stderr=subprocess.PIPE if isinstance(sys.stderr, utils.StdSim) else sys.stderr, shell=True, **kwargs, ) - proc_reader = utils.ProcReader(proc, cast(TextIO, self.stdout), sys.stderr) # type: ignore[arg-type] + proc_reader = utils.ProcReader(proc, cast(TextIO, self.stdout), sys.stderr) proc_reader.wait() # Save the return code of the application for use in a pyscript @@ -4331,19 +4722,13 @@ def _set_up_py_shell_env(self, interp: InteractiveConsole) -> _SavedCmd2Env: # Save off the current completer and set a new one in the Python console # Make sure it tab completes from its locals() dictionary cmd2_env.readline_settings.completer = readline.get_completer() - interp.runcode("from rlcompleter import Completer") # type: ignore[arg-type] - interp.runcode("import readline") # type: ignore[arg-type] - interp.runcode("readline.set_completer(Completer(locals()).complete)") # type: ignore[arg-type] + interp.runcode(compile("from rlcompleter import Completer", "", "exec")) + interp.runcode(compile("import readline", "", "exec")) + interp.runcode(compile("readline.set_completer(Completer(locals()).complete)", "", "exec")) # Set up sys module for the Python console self._reset_py_display() - cmd2_env.sys_stdout = sys.stdout - sys.stdout = self.stdout # type: ignore[assignment] - - cmd2_env.sys_stdin = sys.stdin - sys.stdin = self.stdin # type: ignore[assignment] - return cmd2_env def _restore_cmd2_env(self, cmd2_env: _SavedCmd2Env) -> None: @@ -4351,9 +4736,6 @@ def _restore_cmd2_env(self, cmd2_env: _SavedCmd2Env) -> None: :param cmd2_env: the environment settings to restore """ - sys.stdout = cmd2_env.sys_stdout # type: ignore[assignment] - sys.stdin = cmd2_env.sys_stdin # type: ignore[assignment] - # Set up readline for cmd2 if rl_type != RlType.NONE: # Save py's history @@ -4382,7 +4764,7 @@ def _restore_cmd2_env(self, cmd2_env: _SavedCmd2Env) -> None: else: sys.modules['readline'] = cmd2_env.readline_module - def _run_python(self, *, pyscript: Optional[str] = None) -> Optional[bool]: + def _run_python(self, *, pyscript: str | None = None) -> bool | None: """Run an interactive Python shell or execute a pyscript file. Called by do_py() and do_run_pyscript(). @@ -4500,10 +4882,12 @@ def py_quit() -> None: return py_bridge.stop - py_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive Python shell") + @staticmethod + def _build_py_parser() -> Cmd2ArgumentParser: + return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive Python shell.") - @with_argparser(py_parser) - def do_py(self, _: argparse.Namespace) -> Optional[bool]: + @with_argparser(_build_py_parser) + def do_py(self, _: argparse.Namespace) -> bool | None: """Run an interactive Python shell. :return: True if running of commands should stop. @@ -4511,15 +4895,21 @@ def do_py(self, _: argparse.Namespace) -> Optional[bool]: # self.last_result will be set by _run_python() return self._run_python() - run_pyscript_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run a Python script file inside the console") - run_pyscript_parser.add_argument('script_path', help='path to the script file', completer=path_complete) - run_pyscript_parser.add_argument( - 'script_arguments', nargs=argparse.REMAINDER, help='arguments to pass to script', completer=path_complete - ) + @classmethod + def _build_run_pyscript_parser(cls) -> Cmd2ArgumentParser: + run_pyscript_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( + description="Run Python script within this application's environment." + ) + run_pyscript_parser.add_argument('script_path', help='path to the script file', completer=cls.path_complete) + run_pyscript_parser.add_argument( + 'script_arguments', nargs=argparse.REMAINDER, help='arguments to pass to script', completer=cls.path_complete + ) + + return run_pyscript_parser - @with_argparser(run_pyscript_parser) - def do_run_pyscript(self, args: argparse.Namespace) -> Optional[bool]: - """Run a Python script file inside the console. + @with_argparser(_build_run_pyscript_parser) + def do_run_pyscript(self, args: argparse.Namespace) -> bool | None: + """Run Python script within this application's environment. :return: True if running of commands should stop """ @@ -4551,11 +4941,13 @@ def do_run_pyscript(self, args: argparse.Namespace) -> Optional[bool]: return py_return - ipython_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive IPython shell") + @staticmethod + def _build_ipython_parser() -> Cmd2ArgumentParser: + return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive IPython shell.") - @with_argparser(ipython_parser) - def do_ipy(self, _: argparse.Namespace) -> Optional[bool]: # pragma: no cover - """Enter an interactive IPython shell. + @with_argparser(_build_ipython_parser) + def do_ipy(self, _: argparse.Namespace) -> bool | None: # pragma: no cover + """Run an interactive IPython shell. :return: True if running of commands should stop """ @@ -4563,18 +4955,18 @@ def do_ipy(self, _: argparse.Namespace) -> Optional[bool]: # pragma: no cover # Detect whether IPython is installed try: - import traitlets.config.loader as traitlets_loader # type: ignore[import] + import traitlets.config.loader as traitlets_loader # Allow users to install ipython from a cmd2 prompt when needed and still have ipy command work try: _dummy = start_ipython # noqa: F823 except NameError: - from IPython import start_ipython # type: ignore[import] + from IPython import start_ipython - from IPython.terminal.interactiveshell import ( # type: ignore[import] + from IPython.terminal.interactiveshell import ( TerminalInteractiveShell, ) - from IPython.terminal.ipapp import ( # type: ignore[import] + from IPython.terminal.ipapp import ( TerminalIPythonApp, ) except ImportError: @@ -4625,55 +5017,71 @@ def do_ipy(self, _: argparse.Namespace) -> Optional[bool]: # pragma: no cover finally: self._in_py = False - history_description = "View, run, edit, save, or clear previously entered commands" + @classmethod + def _build_history_parser(cls) -> Cmd2ArgumentParser: + history_description = "View, run, edit, save, or clear previously entered commands." - history_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=history_description) - history_action_group = history_parser.add_mutually_exclusive_group() - history_action_group.add_argument('-r', '--run', action='store_true', help='run selected history items') - history_action_group.add_argument('-e', '--edit', action='store_true', help='edit and then run selected history items') - history_action_group.add_argument( - '-o', '--output_file', metavar='FILE', help='output commands to a script file, implies -s', completer=path_complete - ) - history_action_group.add_argument( - '-t', - '--transcript', - metavar='TRANSCRIPT_FILE', - help='create a transcript file by re-running the commands,\nimplies both -r and -s', - completer=path_complete, - ) - history_action_group.add_argument('-c', '--clear', action='store_true', help='clear all history') + history_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( + description=history_description, formatter_class=argparse_custom.RawTextCmd2HelpFormatter + ) + history_action_group = history_parser.add_mutually_exclusive_group() + history_action_group.add_argument('-r', '--run', action='store_true', help='run selected history items') + history_action_group.add_argument('-e', '--edit', action='store_true', help='edit and then run selected history items') + history_action_group.add_argument( + '-o', + '--output_file', + metavar='FILE', + help='output commands to a script file, implies -s', + completer=cls.path_complete, + ) + history_action_group.add_argument( + '-t', + '--transcript', + metavar='TRANSCRIPT_FILE', + help='create a transcript file by re-running the commands, implies both -r and -s', + completer=cls.path_complete, + ) + history_action_group.add_argument('-c', '--clear', action='store_true', help='clear all history') + + history_format_group = history_parser.add_argument_group(title='formatting') + history_format_group.add_argument( + '-s', + '--script', + action='store_true', + help='output commands in script format, i.e. without command numbers', + ) + history_format_group.add_argument( + '-x', + '--expanded', + action='store_true', + help='output fully parsed commands with shortcuts, aliases, and macros expanded', + ) + history_format_group.add_argument( + '-v', + '--verbose', + action='store_true', + help='display history and include expanded commands if they differ from the typed command', + ) + history_format_group.add_argument( + '-a', + '--all', + action='store_true', + help='display all commands, including ones persisted from previous sessions', + ) - history_format_group = history_parser.add_argument_group(title='formatting') - history_format_group.add_argument( - '-s', '--script', action='store_true', help='output commands in script format, i.e. without command\nnumbers' - ) - history_format_group.add_argument( - '-x', - '--expanded', - action='store_true', - help='output fully parsed commands with any aliases and\nmacros expanded, instead of typed commands', - ) - history_format_group.add_argument( - '-v', - '--verbose', - action='store_true', - help='display history and include expanded commands if they\ndiffer from the typed command', - ) - history_format_group.add_argument( - '-a', '--all', action='store_true', help='display all commands, including ones persisted from\nprevious sessions' - ) + history_arg_help = ( + "empty all history items\n" + "a one history item by number\n" + "a..b, a:b, a:, ..b items by indices (inclusive)\n" + "string items containing string\n" + "/regex/ items matching regular expression" + ) + history_parser.add_argument('arg', nargs=argparse.OPTIONAL, help=history_arg_help) - history_arg_help = ( - "empty all history items\n" - "a one history item by number\n" - "a..b, a:b, a:, ..b items by indices (inclusive)\n" - "string items containing string\n" - "/regex/ items matching regular expression" - ) - history_parser.add_argument('arg', nargs=argparse.OPTIONAL, help=history_arg_help) + return history_parser - @with_argparser(history_parser) - def do_history(self, args: argparse.Namespace) -> Optional[bool]: + @with_argparser(_build_history_parser) + def do_history(self, args: argparse.Namespace) -> bool | None: """View, run, edit, save, or clear previously entered commands. :return: True if running of commands should stop @@ -4684,13 +5092,11 @@ def do_history(self, args: argparse.Namespace) -> Optional[bool]: if args.verbose: # noqa: SIM102 if args.clear or args.edit or args.output_file or args.run or args.transcript or args.expanded or args.script: self.poutput("-v cannot be used with any other options") - self.poutput(self.history_parser.format_usage()) return None # -s and -x can only be used if none of these options are present: [-c -r -e -o -t] if (args.script or args.expanded) and (args.clear or args.edit or args.output_file or args.run or args.transcript): self.poutput("-s and -x cannot be used with -c, -r, -e, -o, or -t") - self.poutput(self.history_parser.format_usage()) return None if args.clear: @@ -4737,7 +5143,7 @@ def do_history(self, args: argparse.Namespace) -> Optional[bool]: self.run_editor(fname) # self.last_result will be set by do_run_script() - return self.do_run_script(utils.quote_string(fname)) + return self.do_run_script(su.quote(fname)) finally: os.remove(fname) elif args.output_file: @@ -4904,7 +5310,7 @@ def _persist_history(self) -> None: def _generate_transcript( self, - history: Union[list[HistoryItem], list[str]], + history: list[HistoryItem] | list[str], transcript_file: str, *, add_to_history: bool = True, @@ -4997,70 +5403,87 @@ def _generate_transcript( self.pfeedback(f"{commands_run} {plural} saved to transcript file '{transcript_path}'") self.last_result = True - edit_description = ( - "Run a text editor and optionally open a file with it\n" - "\n" - "The editor used is determined by a settable parameter. To set it:\n" - "\n" - " set editor (program-name)" - ) + @classmethod + def _build_edit_parser(cls) -> Cmd2ArgumentParser: + edit_description = "Run a text editor and optionally open a file with it." + edit_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=edit_description) + edit_parser.epilog = edit_parser.create_text_group( + "Note", + Text.assemble( + "To set a new editor, run: ", + ("set editor ", Cmd2Style.COMMAND_LINE), + ), + ) - edit_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=edit_description) - edit_parser.add_argument( - 'file_path', nargs=argparse.OPTIONAL, help="optional path to a file to open in editor", completer=path_complete - ) + edit_parser.add_argument( + 'file_path', + nargs=argparse.OPTIONAL, + help="optional path to a file to open in editor", + completer=cls.path_complete, + ) + return edit_parser - @with_argparser(edit_parser) + @with_argparser(_build_edit_parser) def do_edit(self, args: argparse.Namespace) -> None: """Run a text editor and optionally open a file with it.""" # self.last_result will be set by do_shell() which is called by run_editor() self.run_editor(args.file_path) - def run_editor(self, file_path: Optional[str] = None) -> None: + def run_editor(self, file_path: str | None = None) -> None: """Run a text editor and optionally open a file with it. :param file_path: optional path of the file to edit. Defaults to None. - :raises EnvironmentError: if self.editor is not set + :raises ValueError: if self.editor is not set """ if not self.editor: - raise OSError("Please use 'set editor' to specify your text editing program of choice.") + raise ValueError("Please use 'set editor' to specify your text editing program of choice.") - command = utils.quote_string(os.path.expanduser(self.editor)) + command = su.quote(os.path.expanduser(self.editor)) if file_path: - command += " " + utils.quote_string(os.path.expanduser(file_path)) + command += " " + su.quote(os.path.expanduser(file_path)) self.do_shell(command) @property - def _current_script_dir(self) -> Optional[str]: + def _current_script_dir(self) -> str | None: """Accessor to get the current script directory from the _script_dir LIFO queue.""" if self._script_dir: return self._script_dir[-1] return None - run_script_description = ( - "Run commands in script file that is encoded as either ASCII or UTF-8 text\n" - "\n" - "Script should contain one command per line, just like the command would be\n" - "typed in the console.\n" - "\n" - "If the -t/--transcript flag is used, this command instead records\n" - "the output of the script commands to a transcript for testing purposes.\n" - ) + @classmethod + def _build_base_run_script_parser(cls) -> Cmd2ArgumentParser: + run_script_description = Text.assemble( + "Run text script.", + "\n\n", + "Scripts should contain one command per line, entered as you would in the console.", + ) - run_script_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=run_script_description) - run_script_parser.add_argument( - '-t', - '--transcript', - metavar='TRANSCRIPT_FILE', - help='record the output of the script as a transcript file', - completer=path_complete, - ) - run_script_parser.add_argument('script_path', help="path to the script file", completer=path_complete) + run_script_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=run_script_description) + run_script_parser.add_argument( + 'script_path', + help="path to the script file", + completer=cls.path_complete, + ) - @with_argparser(run_script_parser) - def do_run_script(self, args: argparse.Namespace) -> Optional[bool]: - """Run commands in script file that is encoded as either ASCII or UTF-8 text. + return run_script_parser + + @classmethod + def _build_run_script_parser(cls) -> Cmd2ArgumentParser: + run_script_parser = cls._build_base_run_script_parser() + run_script_parser.add_argument( + '-t', + '--transcript', + metavar='TRANSCRIPT_FILE', + help='record the output of the script as a transcript file', + completer=cls.path_complete, + ) + + return run_script_parser + + @with_argparser(_build_run_script_parser) + def do_run_script(self, args: argparse.Namespace) -> bool | None: + """Run text script. :return: True if running of commands should stop """ @@ -5121,32 +5544,41 @@ def do_run_script(self, args: argparse.Namespace) -> Optional[bool]: self._script_dir.pop() return None - relative_run_script_description = run_script_description - relative_run_script_description += ( - "\n\n" - "If this is called from within an already-running script, the filename will be\n" - "interpreted relative to the already-running script's directory." - ) + @classmethod + def _build_relative_run_script_parser(cls) -> Cmd2ArgumentParser: + relative_run_script_parser = cls._build_base_run_script_parser() + + # Append to existing description + relative_run_script_parser.description = Group( + cast(Group, relative_run_script_parser.description), + "\n", + ( + "If this is called from within an already-running script, the filename will be " + "interpreted relative to the already-running script's directory." + ), + ) - relative_run_script_epilog = "Notes:\n This command is intended to only be used within text file scripts." + relative_run_script_parser.epilog = relative_run_script_parser.create_text_group( + "Note", + "This command is intended to be used from within a text script.", + ) - relative_run_script_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( - description=relative_run_script_description, epilog=relative_run_script_epilog - ) - relative_run_script_parser.add_argument('file_path', help='a file path pointing to a script') + return relative_run_script_parser - @with_argparser(relative_run_script_parser) - def do__relative_run_script(self, args: argparse.Namespace) -> Optional[bool]: - """Run commands in script file that is encoded as either ASCII or UTF-8 text. + @with_argparser(_build_relative_run_script_parser) + def do__relative_run_script(self, args: argparse.Namespace) -> bool | None: + """Run text script. + + This command is intended to be used from within a text script. :return: True if running of commands should stop """ - file_path = args.file_path + script_path = args.script_path # NOTE: Relative path is an absolute path, it is just relative to the current script directory - relative_path = os.path.join(self._current_script_dir or '', file_path) + relative_path = os.path.join(self._current_script_dir or '', script_path) # self.last_result will be set by do_run_script() - return self.do_run_script(utils.quote_string(relative_path)) + return self.do_run_script(su.quote(relative_path)) def _run_transcript_tests(self, transcript_paths: list[str]) -> None: """Run transcript tests for provided file(s). @@ -5178,11 +5610,14 @@ class TestMyAppCase(Cmd2TestCase): verinfo = ".".join(map(str, sys.version_info[:3])) num_transcripts = len(transcripts_expanded) plural = '' if len(transcripts_expanded) == 1 else 's' - self.poutput(ansi.style(utils.align_center(' cmd2 transcript test ', fill_char='='), bold=True)) + self.poutput( + Rule("cmd2 transcript test", characters=self.ruler, style=Style.null()), + style=Style(bold=True), + ) self.poutput(f'platform {sys.platform} -- Python {verinfo}, cmd2-{cmd2.__version__}, readline-{rl_type}') self.poutput(f'cwd: {os.getcwd()}') self.poutput(f'cmd2 app: {sys.argv[0]}') - self.poutput(ansi.style(f'collected {num_transcripts} transcript{plural}', bold=True)) + self.poutput(f'collected {num_transcripts} transcript{plural}', style=Style(bold=True)) self.__class__.testfiles = transcripts_expanded sys.argv = [sys.argv[0]] # the --test argument upsets unittest.main() @@ -5193,10 +5628,9 @@ class TestMyAppCase(Cmd2TestCase): test_results = runner.run(testcase) execution_time = time.time() - start_time if test_results.wasSuccessful(): - ansi.style_aware_write(sys.stderr, stream.read()) - finish_msg = f' {num_transcripts} transcript{plural} passed in {execution_time:.3f} seconds ' - finish_msg = utils.align_center(finish_msg, fill_char='=') - self.psuccess(finish_msg) + self.perror(stream.read(), end="", style=None) + finish_msg = f'{num_transcripts} transcript{plural} passed in {execution_time:.3f} seconds' + self.psuccess(Rule(finish_msg, characters=self.ruler, style=Style.null())) else: # Strip off the initial traceback which isn't particularly useful for end users error_str = stream.read() @@ -5210,7 +5644,7 @@ class TestMyAppCase(Cmd2TestCase): # Return a failure error code to support automated transcript-based testing self.exit_code = 1 - def async_alert(self, alert_msg: str, new_prompt: Optional[str] = None) -> None: # pragma: no cover + def async_alert(self, alert_msg: str, new_prompt: str | None = None) -> None: # pragma: no cover """Display an important message to the user while they are at a command line prompt. To the user it appears as if an alert message is printed above the prompt and their @@ -5255,24 +5689,19 @@ def async_alert(self, alert_msg: str, new_prompt: Optional[str] = None) -> None: rl_set_prompt(self.prompt) if update_terminal: - import shutil - - # Prior to Python 3.11 this can return 0, so use a fallback if needed. - terminal_columns = shutil.get_terminal_size().columns or constants.DEFAULT_TERMINAL_WIDTH + from .terminal_utils import async_alert_str # Print a string which replaces the onscreen prompt and input lines with the alert. - terminal_str = ansi.async_alert_str( - terminal_columns=terminal_columns, + terminal_str = async_alert_str( + terminal_columns=ru.console_width(), prompt=rl_get_display_prompt(), line=readline.get_line_buffer(), cursor_offset=rl_get_point(), alert_msg=alert_msg, ) - if rl_type == RlType.GNU: - sys.stderr.write(terminal_str) - sys.stderr.flush() - elif rl_type == RlType.PYREADLINE: - readline.rl.mode.console.write(terminal_str) + + sys.stdout.write(terminal_str) + sys.stdout.flush() # Redraw the prompt and input lines below the alert rl_force_redisplay() @@ -5330,17 +5759,16 @@ def need_prompt_refresh(self) -> bool: # pragma: no cover def set_window_title(title: str) -> None: # pragma: no cover """Set the terminal window title. - NOTE: This function writes to stderr. Therefore, if you call this during a command run by a pyscript, - the string which updates the title will appear in that command's CommandResult.stderr data. - :param title: the new window title """ if not vt100_support: return + from .terminal_utils import set_title_str + try: - sys.stderr.write(ansi.set_title(title)) - sys.stderr.flush() + sys.stdout.write(set_title_str(title)) + sys.stdout.flush() except AttributeError: # Debugging in Pycharm has issues with setting terminal title pass @@ -5450,10 +5878,9 @@ def _report_disabled_command_usage(self, *_args: Any, message_to_print: str, **_ :param message_to_print: the message reporting that the command is disabled :param _kwargs: not used """ - # Set apply_style to False so message_to_print's style is not overridden - self.perror(message_to_print, apply_style=False) + self.perror(message_to_print, style=None) - def cmdloop(self, intro: Optional[str] = None) -> int: # type: ignore[override] + def cmdloop(self, intro: str | None = None) -> int: # type: ignore[override] """Deal with extra features provided by cmd2, this is an outer wrapper around _cmdloop(). _cmdloop() provides the main loop equivalent to cmd.cmdloop(). This is a wrapper around that which deals with @@ -5645,7 +6072,7 @@ def _resolve_func_self( self, cmd_support_func: Callable[..., Any], cmd_self: Union[CommandSet, 'Cmd', None], - ) -> Optional[object]: + ) -> object | None: """Attempt to resolve a candidate instance to pass as 'self'. Used for an unbound class method that was used when defining command's argparse object. @@ -5657,7 +6084,7 @@ def _resolve_func_self( :param cmd_self: The `self` associated with the command or subcommand """ # figure out what class the command support function was defined in - func_class: Optional[type[Any]] = get_defining_class(cmd_support_func) + func_class: type[Any] | None = get_defining_class(cmd_support_func) # Was there a defining class identified? If so, is it a sub-class of CommandSet? if func_class is not None and issubclass(func_class, CommandSet): @@ -5668,7 +6095,7 @@ def _resolve_func_self( # 2. Do any of the registered CommandSets in the Cmd2 application exactly match the type? # 3. Is there a registered CommandSet that is is the only matching subclass? - func_self: Optional[Union[CommandSet, Cmd]] + func_self: CommandSet | Cmd | None # check if the command's CommandSet is a sub-class of the support function's defining class if isinstance(cmd_self, func_class): diff --git a/cmd2/colors.py b/cmd2/colors.py new file mode 100644 index 000000000..1e6853c40 --- /dev/null +++ b/cmd2/colors.py @@ -0,0 +1,270 @@ +"""Provides a convenient StrEnum for Rich color names.""" + +import sys + +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + from backports.strenum import StrEnum + + +class Color(StrEnum): + """An enumeration of all color names supported by the Rich library. + + Using this enum allows for autocompletion and prevents typos when referencing + color names. The members can be used for both foreground and background colors. + + Aside from DEFAULT, these colors come from the rich.color.ANSI_COLOR_NAMES dictionary. + + Note: The terminal color settings determines the appearance of the follow 16 colors. + + | | | + |----------------|---------------| + | BLACK | BRIGHT_WHITE | + | BLUE | BRIGHT_YELLOW | + | BRIGHT_BLACK | CYAN | + | BRIGHT_BLUE | GREEN | + | BRIGHT_CYAN | MAGENTA | + | BRIGHT_GREEN | RED | + | BRIGHT_MAGENTA | WHITE | + | BRIGHT_RED | YELLOW | + """ + + DEFAULT = "default" + """Represents the terminal's default foreground or background color.""" + + AQUAMARINE1 = "aquamarine1" + AQUAMARINE3 = "aquamarine3" + BLACK = "black" + BLUE = "blue" + BLUE1 = "blue1" + BLUE3 = "blue3" + BLUE_VIOLET = "blue_violet" + BRIGHT_BLACK = "bright_black" + BRIGHT_BLUE = "bright_blue" + BRIGHT_CYAN = "bright_cyan" + BRIGHT_GREEN = "bright_green" + BRIGHT_MAGENTA = "bright_magenta" + BRIGHT_RED = "bright_red" + BRIGHT_WHITE = "bright_white" + BRIGHT_YELLOW = "bright_yellow" + CADET_BLUE = "cadet_blue" + CHARTREUSE1 = "chartreuse1" + CHARTREUSE2 = "chartreuse2" + CHARTREUSE3 = "chartreuse3" + CHARTREUSE4 = "chartreuse4" + CORNFLOWER_BLUE = "cornflower_blue" + CORNSILK1 = "cornsilk1" + CYAN = "cyan" + CYAN1 = "cyan1" + CYAN2 = "cyan2" + CYAN3 = "cyan3" + DARK_BLUE = "dark_blue" + DARK_CYAN = "dark_cyan" + DARK_GOLDENROD = "dark_goldenrod" + DARK_GREEN = "dark_green" + DARK_KHAKI = "dark_khaki" + DARK_MAGENTA = "dark_magenta" + DARK_OLIVE_GREEN1 = "dark_olive_green1" + DARK_OLIVE_GREEN2 = "dark_olive_green2" + DARK_OLIVE_GREEN3 = "dark_olive_green3" + DARK_ORANGE = "dark_orange" + DARK_ORANGE3 = "dark_orange3" + DARK_RED = "dark_red" + DARK_SEA_GREEN = "dark_sea_green" + DARK_SEA_GREEN1 = "dark_sea_green1" + DARK_SEA_GREEN2 = "dark_sea_green2" + DARK_SEA_GREEN3 = "dark_sea_green3" + DARK_SEA_GREEN4 = "dark_sea_green4" + DARK_SLATE_GRAY1 = "dark_slate_gray1" + DARK_SLATE_GRAY2 = "dark_slate_gray2" + DARK_SLATE_GRAY3 = "dark_slate_gray3" + DARK_TURQUOISE = "dark_turquoise" + DARK_VIOLET = "dark_violet" + DEEP_PINK1 = "deep_pink1" + DEEP_PINK2 = "deep_pink2" + DEEP_PINK3 = "deep_pink3" + DEEP_PINK4 = "deep_pink4" + DEEP_SKY_BLUE1 = "deep_sky_blue1" + DEEP_SKY_BLUE2 = "deep_sky_blue2" + DEEP_SKY_BLUE3 = "deep_sky_blue3" + DEEP_SKY_BLUE4 = "deep_sky_blue4" + DODGER_BLUE1 = "dodger_blue1" + DODGER_BLUE2 = "dodger_blue2" + DODGER_BLUE3 = "dodger_blue3" + GOLD1 = "gold1" + GOLD3 = "gold3" + GRAY0 = "gray0" + GRAY3 = "gray3" + GRAY7 = "gray7" + GRAY11 = "gray11" + GRAY15 = "gray15" + GRAY19 = "gray19" + GRAY23 = "gray23" + GRAY27 = "gray27" + GRAY30 = "gray30" + GRAY35 = "gray35" + GRAY37 = "gray37" + GRAY39 = "gray39" + GRAY42 = "gray42" + GRAY46 = "gray46" + GRAY50 = "gray50" + GRAY53 = "gray53" + GRAY54 = "gray54" + GRAY58 = "gray58" + GRAY62 = "gray62" + GRAY63 = "gray63" + GRAY66 = "gray66" + GRAY69 = "gray69" + GRAY70 = "gray70" + GRAY74 = "gray74" + GRAY78 = "gray78" + GRAY82 = "gray82" + GRAY84 = "gray84" + GRAY85 = "gray85" + GRAY89 = "gray89" + GRAY93 = "gray93" + GRAY100 = "gray100" + GREEN = "green" + GREEN1 = "green1" + GREEN3 = "green3" + GREEN4 = "green4" + GREEN_YELLOW = "green_yellow" + GREY0 = "grey0" + GREY3 = "grey3" + GREY7 = "grey7" + GREY11 = "grey11" + GREY15 = "grey15" + GREY19 = "grey19" + GREY23 = "grey23" + GREY27 = "grey27" + GREY30 = "grey30" + GREY35 = "grey35" + GREY37 = "grey37" + GREY39 = "grey39" + GREY42 = "grey42" + GREY46 = "grey46" + GREY50 = "grey50" + GREY53 = "grey53" + GREY54 = "grey54" + GREY58 = "grey58" + GREY62 = "grey62" + GREY63 = "grey63" + GREY66 = "grey66" + GREY69 = "grey69" + GREY70 = "grey70" + GREY74 = "grey74" + GREY78 = "grey78" + GREY82 = "grey82" + GREY84 = "grey84" + GREY85 = "grey85" + GREY89 = "grey89" + GREY93 = "grey93" + GREY100 = "grey100" + HONEYDEW2 = "honeydew2" + HOT_PINK = "hot_pink" + HOT_PINK2 = "hot_pink2" + HOT_PINK3 = "hot_pink3" + INDIAN_RED = "indian_red" + INDIAN_RED1 = "indian_red1" + KHAKI1 = "khaki1" + KHAKI3 = "khaki3" + LIGHT_CORAL = "light_coral" + LIGHT_CYAN1 = "light_cyan1" + LIGHT_CYAN3 = "light_cyan3" + LIGHT_GOLDENROD1 = "light_goldenrod1" + LIGHT_GOLDENROD2 = "light_goldenrod2" + LIGHT_GOLDENROD3 = "light_goldenrod3" + LIGHT_GREEN = "light_green" + LIGHT_PINK1 = "light_pink1" + LIGHT_PINK3 = "light_pink3" + LIGHT_PINK4 = "light_pink4" + LIGHT_SALMON1 = "light_salmon1" + LIGHT_SALMON3 = "light_salmon3" + LIGHT_SEA_GREEN = "light_sea_green" + LIGHT_SKY_BLUE1 = "light_sky_blue1" + LIGHT_SKY_BLUE3 = "light_sky_blue3" + LIGHT_SLATE_BLUE = "light_slate_blue" + LIGHT_SLATE_GRAY = "light_slate_gray" + LIGHT_SLATE_GREY = "light_slate_grey" + LIGHT_STEEL_BLUE = "light_steel_blue" + LIGHT_STEEL_BLUE1 = "light_steel_blue1" + LIGHT_STEEL_BLUE3 = "light_steel_blue3" + LIGHT_YELLOW3 = "light_yellow3" + MAGENTA = "magenta" + MAGENTA1 = "magenta1" + MAGENTA2 = "magenta2" + MAGENTA3 = "magenta3" + MEDIUM_ORCHID = "medium_orchid" + MEDIUM_ORCHID1 = "medium_orchid1" + MEDIUM_ORCHID3 = "medium_orchid3" + MEDIUM_PURPLE = "medium_purple" + MEDIUM_PURPLE1 = "medium_purple1" + MEDIUM_PURPLE2 = "medium_purple2" + MEDIUM_PURPLE3 = "medium_purple3" + MEDIUM_PURPLE4 = "medium_purple4" + MEDIUM_SPRING_GREEN = "medium_spring_green" + MEDIUM_TURQUOISE = "medium_turquoise" + MEDIUM_VIOLET_RED = "medium_violet_red" + MISTY_ROSE1 = "misty_rose1" + MISTY_ROSE3 = "misty_rose3" + NAVAJO_WHITE1 = "navajo_white1" + NAVAJO_WHITE3 = "navajo_white3" + NAVY_BLUE = "navy_blue" + ORANGE1 = "orange1" + ORANGE3 = "orange3" + ORANGE4 = "orange4" + ORANGE_RED1 = "orange_red1" + ORCHID = "orchid" + ORCHID1 = "orchid1" + ORCHID2 = "orchid2" + PALE_GREEN1 = "pale_green1" + PALE_GREEN3 = "pale_green3" + PALE_TURQUOISE1 = "pale_turquoise1" + PALE_TURQUOISE4 = "pale_turquoise4" + PALE_VIOLET_RED1 = "pale_violet_red1" + PINK1 = "pink1" + PINK3 = "pink3" + PLUM1 = "plum1" + PLUM2 = "plum2" + PLUM3 = "plum3" + PLUM4 = "plum4" + PURPLE = "purple" + PURPLE3 = "purple3" + PURPLE4 = "purple4" + RED = "red" + RED1 = "red1" + RED3 = "red3" + ROSY_BROWN = "rosy_brown" + ROYAL_BLUE1 = "royal_blue1" + SALMON1 = "salmon1" + SANDY_BROWN = "sandy_brown" + SEA_GREEN1 = "sea_green1" + SEA_GREEN2 = "sea_green2" + SEA_GREEN3 = "sea_green3" + SKY_BLUE1 = "sky_blue1" + SKY_BLUE2 = "sky_blue2" + SKY_BLUE3 = "sky_blue3" + SLATE_BLUE1 = "slate_blue1" + SLATE_BLUE3 = "slate_blue3" + SPRING_GREEN1 = "spring_green1" + SPRING_GREEN2 = "spring_green2" + SPRING_GREEN3 = "spring_green3" + SPRING_GREEN4 = "spring_green4" + STEEL_BLUE = "steel_blue" + STEEL_BLUE1 = "steel_blue1" + STEEL_BLUE3 = "steel_blue3" + TAN = "tan" + THISTLE1 = "thistle1" + THISTLE3 = "thistle3" + TURQUOISE2 = "turquoise2" + TURQUOISE4 = "turquoise4" + VIOLET = "violet" + WHEAT1 = "wheat1" + WHEAT4 = "wheat4" + WHITE = "white" + YELLOW = "yellow" + YELLOW1 = "yellow1" + YELLOW2 = "yellow2" + YELLOW3 = "yellow3" + YELLOW4 = "yellow4" diff --git a/cmd2/command_definition.py b/cmd2/command_definition.py index 860fd5d12..e07db9028 100644 --- a/cmd2/command_definition.py +++ b/cmd2/command_definition.py @@ -3,7 +3,6 @@ from collections.abc import Callable, Mapping from typing import ( TYPE_CHECKING, - Optional, TypeVar, ) @@ -23,7 +22,7 @@ #: Callable signature for a basic command function #: Further refinements are needed to define the input parameters -CommandFunc = Callable[..., Optional[bool]] +CommandFunc = Callable[..., bool | None] CommandSetType = TypeVar('CommandSetType', bound=type['CommandSet']) @@ -91,7 +90,7 @@ def __init__(self) -> None: This will be set when the CommandSet is registered and it should be accessed by child classes using the self._cmd property. """ - self.__cmd_internal: Optional[cmd2.Cmd] = None + self.__cmd_internal: cmd2.Cmd | None = None self._settables: dict[str, Settable] = {} self._settable_prefix = self.__class__.__name__ @@ -103,8 +102,17 @@ def _cmd(self) -> 'cmd2.Cmd': Using this property ensures that self.__cmd_internal has been set and it tells type checkers that it's no longer a None type. - Override this property if you need to change its return type to a - child class of Cmd. + Override this property to specify a more specific return type for static + type checking. The typing.cast function can be used to assert to the + type checker that the parent cmd2.Cmd instance is of a more specific + subclass, enabling better autocompletion and type safety in the child class. + + For example: + + @property + def _cmd(self) -> CustomCmdApp: + return cast(CustomCmdApp, super()._cmd) + :raises CommandSetRegistrationError: if CommandSet is not registered. """ diff --git a/cmd2/constants.py b/cmd2/constants.py index c82b3ca10..5d3351ebb 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -18,9 +18,6 @@ LINE_FEED = '\n' -# One character ellipsis -HORIZONTAL_ELLIPSIS = '…' - DEFAULT_SHORTCUTS = {'?': 'help', '!': 'shell', '@': 'run_script', '@@': '_relative_run_script'} # Used as the command name placeholder in disabled command messages. @@ -55,6 +52,3 @@ # custom attributes added to argparse Namespaces NS_ATTR_SUBCMD_HANDLER = '__subcmd_handler__' - -# For cases prior to Python 3.11 when shutil.get_terminal_size().columns can return 0. -DEFAULT_TERMINAL_WIDTH = 80 diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 21870cdc2..de4bc2e50 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -5,7 +5,6 @@ from typing import ( TYPE_CHECKING, Any, - Optional, TypeVar, Union, ) @@ -62,10 +61,10 @@ def cat_decorator(func: CommandFunc) -> CommandFunc: CommandParent = TypeVar('CommandParent', bound=Union['cmd2.Cmd', CommandSet]) -CommandParentType = TypeVar('CommandParentType', bound=Union[type['cmd2.Cmd'], type[CommandSet]]) +CommandParentType = TypeVar('CommandParentType', bound=type['cmd2.Cmd'] | type[CommandSet]) -RawCommandFuncOptionalBoolReturn = Callable[[CommandParent, Union[Statement, str]], Optional[bool]] +RawCommandFuncOptionalBoolReturn = Callable[[CommandParent, Statement | str], bool | None] ########################## @@ -73,7 +72,7 @@ def cat_decorator(func: CommandFunc) -> CommandFunc: # in cmd2 command functions/callables. As long as the 2-ple of arguments we expect to be there can be # found we can swap out the statement with each decorator's specific parameters ########################## -def _parse_positionals(args: tuple[Any, ...]) -> tuple['cmd2.Cmd', Union[Statement, str]]: +def _parse_positionals(args: tuple[Any, ...]) -> tuple['cmd2.Cmd', Statement | str]: """Inspect the positional arguments until the cmd2.Cmd argument is found. Assumes that we will find cmd2.Cmd followed by the command statement object or string. @@ -98,7 +97,7 @@ def _parse_positionals(args: tuple[Any, ...]) -> tuple['cmd2.Cmd', Union[Stateme raise TypeError('Expected arguments: cmd: cmd2.Cmd, statement: Union[Statement, str] Not found') -def _arg_swap(args: Union[Sequence[Any]], search_arg: Any, *replace_arg: Any) -> list[Any]: +def _arg_swap(args: Sequence[Any], search_arg: Any, *replace_arg: Any) -> list[Any]: """Swap the Statement parameter with one or more decorator-specific parameters. :param args: The original positional arguments @@ -114,7 +113,7 @@ def _arg_swap(args: Union[Sequence[Any]], search_arg: Any, *replace_arg: Any) -> #: Function signature for a command function that accepts a pre-processed argument list from user input #: and optionally returns a boolean -ArgListCommandFuncOptionalBoolReturn = Callable[[CommandParent, list[str]], Optional[bool]] +ArgListCommandFuncOptionalBoolReturn = Callable[[CommandParent, list[str]], bool | None] #: Function signature for a command function that accepts a pre-processed argument list from user input #: and returns a boolean ArgListCommandFuncBoolReturn = Callable[[CommandParent, list[str]], bool] @@ -123,21 +122,21 @@ def _arg_swap(args: Union[Sequence[Any]], search_arg: Any, *replace_arg: Any) -> ArgListCommandFuncNoneReturn = Callable[[CommandParent, list[str]], None] #: Aggregate of all accepted function signatures for command functions that accept a pre-processed argument list -ArgListCommandFunc = Union[ - ArgListCommandFuncOptionalBoolReturn[CommandParent], - ArgListCommandFuncBoolReturn[CommandParent], - ArgListCommandFuncNoneReturn[CommandParent], -] +ArgListCommandFunc = ( + ArgListCommandFuncOptionalBoolReturn[CommandParent] + | ArgListCommandFuncBoolReturn[CommandParent] + | ArgListCommandFuncNoneReturn[CommandParent] +) def with_argument_list( - func_arg: Optional[ArgListCommandFunc[CommandParent]] = None, + func_arg: ArgListCommandFunc[CommandParent] | None = None, *, preserve_quotes: bool = False, -) -> Union[ - RawCommandFuncOptionalBoolReturn[CommandParent], - Callable[[ArgListCommandFunc[CommandParent]], RawCommandFuncOptionalBoolReturn[CommandParent]], -]: +) -> ( + RawCommandFuncOptionalBoolReturn[CommandParent] + | Callable[[ArgListCommandFunc[CommandParent]], RawCommandFuncOptionalBoolReturn[CommandParent]] +): """Decorate a ``do_*`` method to alter the arguments passed to it so it is passed a list[str]. Default passes a string of whatever the user typed. With this decorator, the @@ -169,7 +168,7 @@ def arg_decorator(func: ArgListCommandFunc[CommandParent]) -> RawCommandFuncOpti """ @functools.wraps(func) - def cmd_wrapper(*args: Any, **kwargs: Any) -> Optional[bool]: + def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: """Command function wrapper which translates command line into an argument list and calls actual command function. :param args: All positional arguments to this function. We're expecting there to be: @@ -181,7 +180,7 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> Optional[bool]: cmd2_app, statement = _parse_positionals(args) _, parsed_arglist = cmd2_app.statement_parser.get_command_arg_list(command_name, statement, preserve_quotes) args_list = _arg_swap(args, statement, parsed_arglist) - return func(*args_list, **kwargs) # type: ignore[call-arg] + return func(*args_list, **kwargs) command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX) :] cmd_wrapper.__doc__ = func.__doc__ @@ -192,60 +191,10 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> Optional[bool]: return arg_decorator -def _set_parser_prog(parser: argparse.ArgumentParser, prog: str) -> None: - """Recursively set prog attribute of a parser and all of its subparsers. - - Does so that the root command is a command name and not sys.argv[0]. - - :param parser: the parser being edited - :param prog: new value for the parser's prog attribute - """ - # Set the prog value for this parser - parser.prog = prog - req_args: list[str] = [] - - # Set the prog value for the parser's subcommands - for action in parser._actions: - if isinstance(action, argparse._SubParsersAction): - # Set the _SubParsersAction's _prog_prefix value. That way if its add_parser() method is called later, - # the correct prog value will be set on the parser being added. - action._prog_prefix = parser.prog - - # The keys of action.choices are subcommand names as well as subcommand aliases. The aliases point to the - # same parser as the actual subcommand. We want to avoid placing an alias into a parser's prog value. - # Unfortunately there is nothing about an action.choices entry which tells us it's an alias. In most cases - # we can filter out the aliases by checking the contents of action._choices_actions. This list only contains - # help information and names for the subcommands and not aliases. However, subcommands without help text - # won't show up in that list. Since dictionaries are ordered in Python 3.6 and above and argparse inserts the - # subcommand name into choices dictionary before aliases, we should be OK assuming the first time we see a - # parser, the dictionary key is a subcommand and not alias. - processed_parsers = [] - - # Set the prog value for each subcommand's parser - for subcmd_name, subcmd_parser in action.choices.items(): - # Check if we've already edited this parser - if subcmd_parser in processed_parsers: - continue - - subcmd_prog = parser.prog - if req_args: - subcmd_prog += " " + " ".join(req_args) - subcmd_prog += " " + subcmd_name - _set_parser_prog(subcmd_parser, subcmd_prog) - processed_parsers.append(subcmd_parser) - - # We can break since argparse only allows 1 group of subcommands per level - break - - # Need to save required args so they can be prepended to the subcommand usage - if action.required: - req_args.append(action.dest) - - #: Function signatures for command functions that use an argparse.ArgumentParser to process user input #: and optionally return a boolean -ArgparseCommandFuncOptionalBoolReturn = Callable[[CommandParent, argparse.Namespace], Optional[bool]] -ArgparseCommandFuncWithUnknownArgsOptionalBoolReturn = Callable[[CommandParent, argparse.Namespace, list[str]], Optional[bool]] +ArgparseCommandFuncOptionalBoolReturn = Callable[[CommandParent, argparse.Namespace], bool | None] +ArgparseCommandFuncWithUnknownArgsOptionalBoolReturn = Callable[[CommandParent, argparse.Namespace, list[str]], bool | None] #: Function signatures for command functions that use an argparse.ArgumentParser to process user input #: and return a boolean @@ -258,30 +207,28 @@ def _set_parser_prog(parser: argparse.ArgumentParser, prog: str) -> None: ArgparseCommandFuncWithUnknownArgsNoneReturn = Callable[[CommandParent, argparse.Namespace, list[str]], None] #: Aggregate of all accepted function signatures for an argparse command function -ArgparseCommandFunc = Union[ - ArgparseCommandFuncOptionalBoolReturn[CommandParent], - ArgparseCommandFuncWithUnknownArgsOptionalBoolReturn[CommandParent], - ArgparseCommandFuncBoolReturn[CommandParent], - ArgparseCommandFuncWithUnknownArgsBoolReturn[CommandParent], - ArgparseCommandFuncNoneReturn[CommandParent], - ArgparseCommandFuncWithUnknownArgsNoneReturn[CommandParent], -] +ArgparseCommandFunc = ( + ArgparseCommandFuncOptionalBoolReturn[CommandParent] + | ArgparseCommandFuncWithUnknownArgsOptionalBoolReturn[CommandParent] + | ArgparseCommandFuncBoolReturn[CommandParent] + | ArgparseCommandFuncWithUnknownArgsBoolReturn[CommandParent] + | ArgparseCommandFuncNoneReturn[CommandParent] + | ArgparseCommandFuncWithUnknownArgsNoneReturn[CommandParent] +) def with_argparser( - parser: Union[ - argparse.ArgumentParser, # existing parser - Callable[[], argparse.ArgumentParser], # function or staticmethod - Callable[[CommandParentType], argparse.ArgumentParser], # Cmd or CommandSet classmethod - ], + parser: argparse.ArgumentParser # existing parser + | Callable[[], argparse.ArgumentParser] # function or staticmethod + | Callable[[CommandParentType], argparse.ArgumentParser], # Cmd or CommandSet classmethod *, - ns_provider: Optional[Callable[..., argparse.Namespace]] = None, + ns_provider: Callable[..., argparse.Namespace] | None = None, preserve_quotes: bool = False, with_unknown_args: bool = False, ) -> Callable[[ArgparseCommandFunc[CommandParent]], RawCommandFuncOptionalBoolReturn[CommandParent]]: """Decorate a ``do_*`` method to populate its ``args`` argument with the given instance of argparse.ArgumentParser. - :param parser: unique instance of ArgumentParser or a callable that returns an ArgumentParser + :param parser: instance of ArgumentParser or a callable that returns an ArgumentParser for this command :param ns_provider: An optional function that accepts a cmd2.Cmd or cmd2.CommandSet object as an argument and returns an argparse.Namespace. This is useful if the Namespace needs to be prepopulated with state data that affects parsing. @@ -336,7 +283,7 @@ def arg_decorator(func: ArgparseCommandFunc[CommandParent]) -> RawCommandFuncOpt """ @functools.wraps(func) - def cmd_wrapper(*args: Any, **kwargs: dict[str, Any]) -> Optional[bool]: + def cmd_wrapper(*args: Any, **kwargs: dict[str, Any]) -> bool | None: """Command function wrapper which translates command line into argparse Namespace and call actual command function. :param args: All positional arguments to this function. We're expecting there to be: @@ -367,7 +314,7 @@ def cmd_wrapper(*args: Any, **kwargs: dict[str, Any]) -> Optional[bool]: namespace = ns_provider(provider_self if provider_self is not None else cmd2_app) try: - new_args: Union[tuple[argparse.Namespace], tuple[argparse.Namespace, list[str]]] + new_args: tuple[argparse.Namespace] | tuple[argparse.Namespace, list[str]] if with_unknown_args: new_args = arg_parser.parse_known_args(parsed_arglist, namespace) else: @@ -389,18 +336,10 @@ def cmd_wrapper(*args: Any, **kwargs: dict[str, Any]) -> Optional[bool]: delattr(ns, constants.NS_ATTR_SUBCMD_HANDLER) args_list = _arg_swap(args, statement_arg, *new_args) - return func(*args_list, **kwargs) # type: ignore[call-arg] + return func(*args_list, **kwargs) command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX) :] - if isinstance(parser, argparse.ArgumentParser): - # Set parser's prog value for backward compatibility within the cmd2 2.0 family. - # This will be removed in cmd2 3.0 since we never reference this parser object's prog value. - # Since it's possible for the same parser object to be passed into multiple with_argparser() - # calls, we only set prog on the deep copies of this parser based on the specific do_xxxx - # instance method they are associated with. - _set_parser_prog(parser, command_name) - # Set some custom attributes for this command setattr(cmd_wrapper, constants.CMD_ATTR_ARGPARSER, parser) setattr(cmd_wrapper, constants.CMD_ATTR_PRESERVE_QUOTES, preserve_quotes) @@ -413,20 +352,18 @@ def cmd_wrapper(*args: Any, **kwargs: dict[str, Any]) -> Optional[bool]: def as_subcommand_to( command: str, subcommand: str, - parser: Union[ - argparse.ArgumentParser, # existing parser - Callable[[], argparse.ArgumentParser], # function or staticmethod - Callable[[CommandParentType], argparse.ArgumentParser], # Cmd or CommandSet classmethod - ], + parser: argparse.ArgumentParser # existing parser + | Callable[[], argparse.ArgumentParser] # function or staticmethod + | Callable[[CommandParentType], argparse.ArgumentParser], # Cmd or CommandSet classmethod *, - help: Optional[str] = None, # noqa: A002 - aliases: Optional[list[str]] = None, + help: str | None = None, # noqa: A002 + aliases: list[str] | None = None, ) -> Callable[[ArgparseCommandFunc[CommandParent]], ArgparseCommandFunc[CommandParent]]: """Tag this method as a subcommand to an existing argparse decorated command. :param command: Command Name. Space-delimited subcommands may optionally be specified :param subcommand: Subcommand name - :param parser: argparse Parser for this subcommand + :param parser: instance of ArgumentParser or a callable that returns an ArgumentParser for this subcommand :param help: Help message for this subcommand which displays in the list of subcommands of the command we are adding to. This is passed as the help argument to subparsers.add_parser(). :param aliases: Alternative names for this subcommand. This is passed as the alias argument to diff --git a/cmd2/exceptions.py b/cmd2/exceptions.py index 5d0cd1904..052c93eed 100644 --- a/cmd2/exceptions.py +++ b/cmd2/exceptions.py @@ -40,7 +40,7 @@ class CompletionError(Exception): def __init__(self, *args: Any, apply_style: bool = True) -> None: """Initialize CompletionError instance. - :param apply_style: If True, then ansi.style_error will be applied to the message text when printed. + :param apply_style: If True, then styles.ERROR will be applied to the message text when printed. Set to False in cases where the message text already has the desired style. Defaults to True. """ diff --git a/cmd2/history.py b/cmd2/history.py index 1a8582b68..e2bd67df4 100644 --- a/cmd2/history.py +++ b/cmd2/history.py @@ -11,14 +11,10 @@ ) from typing import ( Any, - Optional, - Union, overload, ) -from . import ( - utils, -) +from . import string_utils as su from .parsing import ( Statement, shlex_split, @@ -164,7 +160,7 @@ def start_session(self) -> None: """Start a new session, thereby setting the next index as the first index in the new session.""" self.session_start_index = len(self) - def _zero_based_index(self, onebased: Union[int, str]) -> int: + def _zero_based_index(self, onebased: int | str) -> int: """Convert a one-based index to a zero-based index.""" result = int(onebased) if result > 0: @@ -177,7 +173,7 @@ def append(self, new: HistoryItem) -> None: ... # pragma: no cover @overload def append(self, new: Statement) -> None: ... # pragma: no cover - def append(self, new: Union[Statement, HistoryItem]) -> None: + def append(self, new: Statement | HistoryItem) -> None: """Append a new statement to the end of the History list. :param new: Statement object which will be composed into a HistoryItem @@ -289,9 +285,9 @@ def str_search(self, search: str, include_persisted: bool = False) -> 'OrderedDi def isin(history_item: HistoryItem) -> bool: """Filter function for string search of history.""" - sloppy = utils.norm_fold(search) - inraw = sloppy in utils.norm_fold(history_item.raw) - inexpanded = sloppy in utils.norm_fold(history_item.expanded) + sloppy = su.norm_fold(search) + inraw = sloppy in su.norm_fold(history_item.raw) + inexpanded = sloppy in su.norm_fold(history_item.expanded) return inraw or inexpanded start = 0 if include_persisted else self.session_start_index @@ -332,7 +328,7 @@ def truncate(self, max_length: int) -> None: del self[0:last_element] def _build_result_dictionary( - self, start: int, end: int, filter_func: Optional[Callable[[HistoryItem], bool]] = None + self, start: int, end: int, filter_func: Callable[[HistoryItem], bool] | None = None ) -> 'OrderedDict[int, HistoryItem]': """Build history search results. diff --git a/cmd2/parsing.py b/cmd2/parsing.py index e12f799cd..5dca03d3b 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -7,19 +7,14 @@ dataclass, field, ) -from typing import ( - Any, - Optional, - Union, -) +from typing import Any from . import ( constants, utils, ) -from .exceptions import ( - Cmd2ShlexError, -) +from . import string_utils as su +from .exceptions import Cmd2ShlexError def shlex_split(str_to_split: str) -> list[str]: @@ -86,7 +81,7 @@ class Macro: @dataclass(frozen=True) -class Statement(str): # type: ignore[override] # noqa: SLOT000 +class Statement(str): # noqa: SLOT000 """String subclass with additional attributes to store the results of parsing. The ``cmd`` module in the standard library passes commands around as a @@ -213,8 +208,8 @@ def argv(self) -> list[str]: If you want to strip quotes from the input, you can use ``argv[1:]``. """ if self.command: - rtn = [utils.strip_quotes(self.command)] - rtn.extend(utils.strip_quotes(cur_token) for cur_token in self.arg_list) + rtn = [su.strip_quotes(self.command)] + rtn.extend(su.strip_quotes(cur_token) for cur_token in self.arg_list) else: rtn = [] @@ -250,10 +245,10 @@ class StatementParser: def __init__( self, - terminators: Optional[Iterable[str]] = None, - multiline_commands: Optional[Iterable[str]] = None, - aliases: Optional[dict[str, str]] = None, - shortcuts: Optional[dict[str, str]] = None, + terminators: Iterable[str] | None = None, + multiline_commands: Iterable[str] | None = None, + aliases: dict[str, str] | None = None, + shortcuts: dict[str, str] | None = None, ) -> None: """Initialize an instance of StatementParser. @@ -490,7 +485,7 @@ def parse(self, line: str) -> Statement: # Check if we are redirecting to a file if len(tokens) > output_index + 1: - unquoted_path = utils.strip_quotes(tokens[output_index + 1]) + unquoted_path = su.strip_quotes(tokens[output_index + 1]) if unquoted_path: output_to = utils.expand_user(tokens[output_index + 1]) @@ -585,7 +580,7 @@ def parse_command_only(self, rawinput: str) -> Statement: return Statement(args, raw=rawinput, command=command, multiline_command=multiline_command) def get_command_arg_list( - self, command_name: str, to_parse: Union[Statement, str], preserve_quotes: bool + self, command_name: str, to_parse: Statement | str, preserve_quotes: bool ) -> tuple[Statement, list[str]]: """Retrieve just the arguments being passed to their ``do_*`` methods as a list. diff --git a/cmd2/plugin.py b/cmd2/plugin.py index 92cb80bd1..9243d232f 100644 --- a/cmd2/plugin.py +++ b/cmd2/plugin.py @@ -3,7 +3,6 @@ from dataclasses import ( dataclass, ) -from typing import Optional from .parsing import ( Statement, @@ -38,4 +37,4 @@ class CommandFinalizationData: """Data class containing information passed to command finalization hook methods.""" stop: bool - statement: Optional[Statement] + statement: Statement | None diff --git a/cmd2/py_bridge.py b/cmd2/py_bridge.py index 2a147583c..3cd084943 100644 --- a/cmd2/py_bridge.py +++ b/cmd2/py_bridge.py @@ -4,18 +4,13 @@ """ import sys -from contextlib import ( - redirect_stderr, - redirect_stdout, -) +from contextlib import redirect_stderr from typing import ( IO, TYPE_CHECKING, Any, NamedTuple, - Optional, TextIO, - Union, cast, ) @@ -101,7 +96,7 @@ def __dir__(self) -> list[str]: attributes.insert(0, 'cmd_echo') return attributes - def __call__(self, command: str, *, echo: Optional[bool] = None) -> CommandResult: + def __call__(self, command: str, *, echo: bool | None = None) -> CommandResult: """Provide functionality to call application commands by calling PyBridge. ex: app('help') @@ -113,8 +108,11 @@ def __call__(self, command: str, *, echo: Optional[bool] = None) -> CommandResul if echo is None: echo = self.cmd_echo + # Only capture sys.stdout if it's the same stream as self.stdout + stdouts_match = self._cmd2_app.stdout == sys.stdout + # This will be used to capture _cmd2_app.stdout and sys.stdout - copy_cmd_stdout = StdSim(cast(Union[TextIO, StdSim], self._cmd2_app.stdout), echo=echo) + copy_cmd_stdout = StdSim(cast(TextIO | StdSim, self._cmd2_app.stdout), echo=echo) # Pause the storing of stdout until onecmd_plus_hooks enables it copy_cmd_stdout.pause_storage = True @@ -126,8 +124,12 @@ def __call__(self, command: str, *, echo: Optional[bool] = None) -> CommandResul stop = False try: - self._cmd2_app.stdout = cast(TextIO, copy_cmd_stdout) - with redirect_stdout(cast(IO[str], copy_cmd_stdout)), redirect_stderr(cast(IO[str], copy_stderr)): + with self._cmd2_app.sigint_protection: + self._cmd2_app.stdout = cast(TextIO, copy_cmd_stdout) + if stdouts_match: + sys.stdout = self._cmd2_app.stdout + + with redirect_stderr(cast(IO[str], copy_stderr)): stop = self._cmd2_app.onecmd_plus_hooks( command, add_to_history=self._add_to_history, @@ -136,6 +138,9 @@ def __call__(self, command: str, *, echo: Optional[bool] = None) -> CommandResul finally: with self._cmd2_app.sigint_protection: self._cmd2_app.stdout = cast(IO[str], copy_cmd_stdout.inner_stream) + if stdouts_match: + sys.stdout = self._cmd2_app.stdout + self.stop = stop or self.stop # Save the result diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py new file mode 100644 index 000000000..d8bf2f337 --- /dev/null +++ b/cmd2/rich_utils.py @@ -0,0 +1,450 @@ +"""Provides common utilities to support Rich in cmd2-based applications.""" + +import re +from collections.abc import ( + Iterable, + Mapping, +) +from enum import Enum +from typing import ( + IO, + Any, + TypedDict, +) + +from rich.console import ( + Console, + ConsoleRenderable, + JustifyMethod, + OverflowMethod, + RenderableType, +) +from rich.padding import Padding +from rich.pretty import is_expandable +from rich.protocol import rich_cast +from rich.segment import Segment +from rich.style import StyleType +from rich.table import ( + Column, + Table, +) +from rich.text import Text +from rich.theme import Theme +from rich_argparse import RichHelpFormatter + +from .styles import DEFAULT_CMD2_STYLES + +# A compiled regular expression to detect ANSI style sequences. +ANSI_STYLE_SEQUENCE_RE = re.compile(r"\x1b\[[0-9;?]*m") + + +class AllowStyle(Enum): + """Values for ``cmd2.rich_utils.ALLOW_STYLE``.""" + + ALWAYS = "Always" # Always output ANSI style sequences + NEVER = "Never" # Remove ANSI style sequences from all output + TERMINAL = "Terminal" # Remove ANSI style sequences if the output is not going to the terminal + + def __str__(self) -> str: + """Return value instead of enum name for printing in cmd2's set command.""" + return str(self.value) + + def __repr__(self) -> str: + """Return quoted value instead of enum description for printing in cmd2's set command.""" + return repr(self.value) + + +# Controls when ANSI style sequences are allowed in output +ALLOW_STYLE = AllowStyle.TERMINAL + + +def _create_default_theme() -> Theme: + """Create a default theme for the application. + + This theme combines the default styles from cmd2, rich-argparse, and Rich. + """ + app_styles = DEFAULT_CMD2_STYLES.copy() + app_styles.update(RichHelpFormatter.styles.copy()) + return Theme(app_styles, inherit=True) + + +def set_theme(styles: Mapping[str, StyleType] | None = None) -> None: + """Set the Rich theme used by cmd2. + + Call set_theme() with no arguments to reset to the default theme. + This will clear any custom styles that were previously applied. + + :param styles: optional mapping of style names to styles + """ + global APP_THEME # noqa: PLW0603 + + # Start with a fresh copy of the default styles. + app_styles: dict[str, StyleType] = {} + app_styles.update(_create_default_theme().styles) + + # Incorporate custom styles. + if styles is not None: + app_styles.update(styles) + + APP_THEME = Theme(app_styles) + + # Synchronize rich-argparse styles with the main application theme. + for name in RichHelpFormatter.styles.keys() & APP_THEME.styles.keys(): + RichHelpFormatter.styles[name] = APP_THEME.styles[name] + + +# The application-wide theme. You can change it with set_theme(). +APP_THEME = _create_default_theme() + + +class RichPrintKwargs(TypedDict, total=False): + """Keyword arguments that can be passed to rich.console.Console.print() via cmd2's print methods. + + See Rich's Console.print() documentation for full details on these parameters. + https://rich.readthedocs.io/en/stable/reference/console.html#rich.console.Console.print + + Note: All fields are optional (total=False). If a key is not present in the + dictionary, Rich's default behavior for that argument will apply. + """ + + justify: JustifyMethod | None + overflow: OverflowMethod | None + no_wrap: bool | None + width: int | None + height: int | None + crop: bool + new_line_start: bool + + +class Cmd2BaseConsole(Console): + """Base class for all cmd2 Rich consoles. + + This class handles the core logic for managing Rich behavior based on + cmd2's global settings, such as `ALLOW_STYLE` and `APP_THEME`. + """ + + def __init__( + self, + file: IO[str] | None = None, + **kwargs: Any, + ) -> None: + """Cmd2BaseConsole initializer. + + :param file: optional file object where the console should write to. + Defaults to sys.stdout. + :param kwargs: keyword arguments passed to the parent Console class. + """ + # Don't allow force_terminal or force_interactive to be passed in, as their + # behavior is controlled by the ALLOW_STYLE setting. + if "force_terminal" in kwargs: + raise TypeError( + "Passing 'force_terminal' is not allowed. Its behavior is controlled by the 'ALLOW_STYLE' setting." + ) + if "force_interactive" in kwargs: + raise TypeError( + "Passing 'force_interactive' is not allowed. Its behavior is controlled by the 'ALLOW_STYLE' setting." + ) + + # Don't allow a theme to be passed in, as it is controlled by the global APP_THEME. + # Use cmd2.rich_utils.set_theme() to set the global theme or use a temporary + # theme with console.use_theme(). + if "theme" in kwargs: + raise TypeError( + "Passing 'theme' is not allowed. Its behavior is controlled by the global APP_THEME and set_theme()." + ) + + force_terminal: bool | None = None + force_interactive: bool | None = None + + if ALLOW_STYLE == AllowStyle.ALWAYS: + force_terminal = True + + # Turn off interactive mode if dest is not actually a terminal which supports it + tmp_console = Console(file=file) + force_interactive = tmp_console.is_interactive + elif ALLOW_STYLE == AllowStyle.NEVER: + force_terminal = False + + super().__init__( + file=file, + force_terminal=force_terminal, + force_interactive=force_interactive, + theme=APP_THEME, + **kwargs, + ) + + def on_broken_pipe(self) -> None: + """Override which raises BrokenPipeError instead of SystemExit.""" + self.quiet = True + raise BrokenPipeError + + +class Cmd2GeneralConsole(Cmd2BaseConsole): + """Rich console for general-purpose printing.""" + + def __init__(self, file: IO[str] | None = None) -> None: + """Cmd2GeneralConsole initializer. + + :param file: optional file object where the console should write to. + Defaults to sys.stdout. + """ + # This console is configured for general-purpose printing. It enables soft wrap + # and disables Rich's automatic detection for markup, emoji, and highlighting. + # These defaults can be overridden in calls to the console's or cmd2's print methods. + super().__init__( + file=file, + soft_wrap=True, + markup=False, + emoji=False, + highlight=False, + ) + + +class Cmd2RichArgparseConsole(Cmd2BaseConsole): + """Rich console for rich-argparse output. + + This class ensures long lines in help text are not truncated by avoiding soft_wrap, + which conflicts with rich-argparse's explicit no_wrap and overflow settings. + """ + + def __init__(self, file: IO[str] | None = None) -> None: + """Cmd2RichArgparseConsole initializer. + + :param file: optional file object where the console should write to. + Defaults to sys.stdout. + """ + # Since this console is used to print error messages which may not have + # been pre-formatted by rich-argparse, disable Rich's automatic detection + # for markup, emoji, and highlighting. rich-argparse does markup and + # highlighting without involving the console so these won't affect its + # internal functionality. + super().__init__( + file=file, + markup=False, + emoji=False, + highlight=False, + ) + + +class Cmd2ExceptionConsole(Cmd2BaseConsole): + """Rich console for printing exceptions. + + Ensures that long exception messages word wrap for readability by keeping soft_wrap disabled. + """ + + +def console_width() -> int: + """Return the width of the console.""" + return Console().width + + +def rich_text_to_string(text: Text) -> str: + """Convert a Rich Text object to a string. + + This function's purpose is to render a Rich Text object, including any styles (e.g., color, bold), + to a plain Python string with ANSI style sequences. It differs from `text.plain`, which strips + all formatting. + + :param text: the text object to convert + :return: the resulting string with ANSI styles preserved. + """ + console = Console( + force_terminal=True, + soft_wrap=True, + no_color=False, + markup=False, + emoji=False, + highlight=False, + theme=APP_THEME, + ) + with console.capture() as capture: + console.print(text, end="") + return capture.get() + + +def indent(renderable: RenderableType, level: int) -> Padding: + """Indent a Rich renderable. + + When soft-wrapping is enabled, a Rich console is unable to properly print a + Padding object of indented text, as it truncates long strings instead of wrapping + them. This function provides a workaround for this issue, ensuring that indented + text is printed correctly regardless of the soft-wrap setting. + + For non-text objects, this function merely serves as a convenience + wrapper around Padding.indent(). + + :param renderable: a Rich renderable to indent. + :param level: number of characters to indent. + :return: a Padding object containing the indented content. + """ + if isinstance(renderable, (str, Text)): + # Wrap text in a grid to handle the wrapping. + text_grid = Table.grid(Column(overflow="fold")) + text_grid.add_row(renderable) + renderable = text_grid + + return Padding.indent(renderable, level) + + +def prepare_objects_for_rendering(*objects: Any) -> tuple[Any, ...]: + """Prepare a tuple of objects for printing by Rich's Console.print(). + + This function processes objects to ensure they are rendered correctly by Rich. + It inspects each object and, if its string representation contains ANSI style + sequences, it converts the object to a Rich Text object. This ensures Rich can + properly parse the non-printing codes for accurate display width calculation. + + Objects that already implement the Rich console protocol or are expandable + by its pretty printer are left untouched, as they can be handled directly by + Rich's native renderers. + + :param objects: objects to prepare + :return: a tuple containing the processed objects. + """ + object_list = list(objects) + + for i, obj in enumerate(object_list): + # Resolve the object's final renderable form, including those + # with a __rich__ method that might return a string. + renderable = rich_cast(obj) + + # No preprocessing is needed for Rich-compatible or expandable objects. + if isinstance(renderable, ConsoleRenderable) or is_expandable(renderable): + continue + + # Check for ANSI style sequences in its string representation. + renderable_as_str = str(renderable) + if ANSI_STYLE_SEQUENCE_RE.search(renderable_as_str): + object_list[i] = Text.from_ansi(renderable_as_str) + + return tuple(object_list) + + +################################################################################### +# Rich Library Monkey Patches +# +# These patches fix specific bugs in the Rich library. They are conditional and +# will only be applied if the bug is detected. When the bugs are fixed in a +# future Rich release, these patches and their corresponding tests should be +# removed. +################################################################################### + +################################################################################### +# Text.from_ansi() monkey patch +################################################################################### + +# Save original Text.from_ansi() so we can call it in our wrapper +_orig_text_from_ansi = Text.from_ansi + + +@classmethod # type: ignore[misc] +def _from_ansi_wrapper(cls: type[Text], text: str, *args: Any, **kwargs: Any) -> Text: # noqa: ARG001 + r"""Wrap Text.from_ansi() to fix its trailing newline bug. + + This wrapper handles an issue where Text.from_ansi() removes the + trailing line break from a string (e.g. "Hello\n" becomes "Hello"). + + There is currently a pull request on Rich to fix this. + https://github.com/Textualize/rich/pull/3793 + """ + result = _orig_text_from_ansi(text, *args, **kwargs) + + # If the original string ends with a recognized line break character, + # then restore the missing newline. We use "\n" because Text.from_ansi() + # converts all line breaks into newlines. + # Source: https://docs.python.org/3/library/stdtypes.html#str.splitlines + line_break_chars = { + "\n", # Line Feed + "\r", # Carriage Return + "\v", # Vertical Tab + "\f", # Form Feed + "\x1c", # File Separator + "\x1d", # Group Separator + "\x1e", # Record Separator + "\x85", # Next Line (NEL) + "\u2028", # Line Separator + "\u2029", # Paragraph Separator + } + if text and text[-1] in line_break_chars: + result.append("\n") + + return result + + +def _from_ansi_has_newline_bug() -> bool: + """Check if Test.from_ansi() strips the trailing line break from a string.""" + return Text.from_ansi("\n") == Text.from_ansi("") + + +# Only apply the monkey patch if the bug is present +if _from_ansi_has_newline_bug(): + Text.from_ansi = _from_ansi_wrapper # type: ignore[assignment] + + +################################################################################### +# Segment.apply_style() monkey patch +################################################################################### + +# Save original Segment.apply_style() so we can call it in our wrapper +_orig_segment_apply_style = Segment.apply_style + + +@classmethod # type: ignore[misc] +def _apply_style_wrapper(cls: type[Segment], *args: Any, **kwargs: Any) -> Iterable["Segment"]: + r"""Wrap Segment.apply_style() to fix bug with styling newlines. + + This wrapper handles an issue where Segment.apply_style() includes newlines + within styled Segments. As a result, when printing text using a background color + and soft wrapping, the background color incorrectly carries over onto the following line. + + You can reproduce this behavior by calling console.print() using a background color + and soft wrapping. + + For example: + console.print("line_1", style="blue on white", soft_wrap=True) + + When soft wrapping is disabled, console.print() splits Segments into their individual + lines, which separates the newlines from the styled text. Therefore, the background color + issue does not occur in that mode. + + This function copies that behavior to fix this the issue even when soft wrapping is enabled. + + There is currently a pull request on Rich to fix this. + https://github.com/Textualize/rich/pull/3839 + """ + styled_segments = list(_orig_segment_apply_style(*args, **kwargs)) + newline_segment = cls.line() + + # If the final segment is a newline, it will be stripped by Segment.split_lines(). + # Save an unstyled newline to restore later. + end_segment = newline_segment if styled_segments and styled_segments[-1].text == "\n" else None + + # Use Segment.split_lines() to separate the styled text from the newlines. + # This way the ANSI reset code will appear before any newline. + sanitized_segments: list[Segment] = [] + + lines = list(Segment.split_lines(styled_segments)) + for index, line in enumerate(lines): + sanitized_segments.extend(line) + if index < len(lines) - 1: + sanitized_segments.append(newline_segment) + + if end_segment is not None: + sanitized_segments.append(end_segment) + + return sanitized_segments + + +def _rich_has_styled_newline_bug() -> bool: + """Check if newlines are styled when soft wrapping.""" + console = Console(force_terminal=True) + with console.capture() as capture: + console.print("line_1", style="blue on white", soft_wrap=True) + + # Check if we see a styled newline in the output + return "\x1b[34;47m\n\x1b[0m" in capture.get() + + +# Only apply the monkey patch if the bug is present +if _rich_has_styled_newline_bug(): + Segment.apply_style = _apply_style_wrapper # type: ignore[assignment] diff --git a/cmd2/rl_utils.py b/cmd2/rl_utils.py index a07479c7b..c7f37a0d1 100644 --- a/cmd2/rl_utils.py +++ b/cmd2/rl_utils.py @@ -5,7 +5,6 @@ from enum import ( Enum, ) -from typing import Union ######################################################################################################################### # NOTE ON LIBEDIT: @@ -25,11 +24,11 @@ # Prefer statically linked gnureadline if installed due to compatibility issues with libedit try: - import gnureadline as readline # type: ignore[import] + import gnureadline as readline # type: ignore[import-not-found] except ImportError: # Note: If this actually fails, you should install gnureadline on Linux/Mac or pyreadline3 on Windows. with contextlib.suppress(ImportError): - import readline # type: ignore[no-redef] + import readline class RlType(Enum): @@ -133,7 +132,7 @@ def pyreadline_remove_history_item(pos: int) -> None: readline_lib = ctypes.CDLL(readline.__file__) except (AttributeError, OSError): # pragma: no cover _rl_warn_reason = ( - "this application is running in a non-standard Python environment in\n" + "this application is running in a non-standard Python environment in " "which GNU readline is not loaded dynamically from a shared library file." ) else: @@ -144,10 +143,10 @@ def pyreadline_remove_history_item(pos: int) -> None: if rl_type == RlType.NONE: # pragma: no cover if not _rl_warn_reason: _rl_warn_reason = ( - "no supported version of readline was found. To resolve this, install\n" + "no supported version of readline was found. To resolve this, install " "pyreadline3 on Windows or gnureadline on Linux/Mac." ) - rl_warning = "Readline features including tab completion have been disabled because\n" + _rl_warn_reason + '\n\n' + rl_warning = f"Readline features including tab completion have been disabled because {_rl_warn_reason}\n\n" else: rl_warning = '' @@ -191,7 +190,7 @@ def rl_get_prompt() -> str: # pragma: no cover prompt = '' if encoded_prompt is None else encoded_prompt.decode(encoding='utf-8') elif rl_type == RlType.PYREADLINE: - prompt_data: Union[str, bytes] = readline.rl.prompt + prompt_data: str | bytes = readline.rl.prompt prompt = prompt_data.decode(encoding='utf-8') if isinstance(prompt_data, bytes) else prompt_data else: @@ -288,10 +287,15 @@ def rl_in_search_mode() -> bool: # pragma: no cover if not isinstance(readline.rl.mode, EmacsMode): return False - # While in search mode, the current keyevent function is set one of the following. + # While in search mode, the current keyevent function is set to one of the following. search_funcs = ( readline.rl.mode._process_incremental_search_keyevent, readline.rl.mode._process_non_incremental_search_keyevent, ) return readline.rl.mode.process_keyevent_queue[-1] in search_funcs return False + + +__all__ = [ + 'readline', +] diff --git a/cmd2/string_utils.py b/cmd2/string_utils.py new file mode 100644 index 000000000..384dcc2a0 --- /dev/null +++ b/cmd2/string_utils.py @@ -0,0 +1,166 @@ +"""Provides string utility functions. + +This module offers a collection of string utility functions built on the Rich library. +These utilities are designed to correctly handle strings with ANSI style sequences and +full-width characters (like those used in CJK languages). +""" + +from rich.align import AlignMethod +from rich.style import StyleType +from rich.text import Text + +from . import rich_utils as ru + + +def align( + val: str, + align: AlignMethod, + width: int | None = None, + character: str = " ", +) -> str: + """Align string to a given width. + + There are convenience wrappers around this function: align_left(), align_center(), and align_right() + + :param val: string to align + :param align: one of "left", "center", or "right". + :param width: Desired width. Defaults to width of the terminal. + :param character: Character to pad with. Defaults to " ". + + """ + if width is None: + width = ru.console_width() + + text = Text.from_ansi(val) + text.align(align, width=width, character=character) + return ru.rich_text_to_string(text) + + +def align_left( + val: str, + width: int | None = None, + character: str = " ", +) -> str: + """Left-align string to a given width. + + :param val: string to align + :param width: Desired width. Defaults to width of the terminal. + :param character: Character to pad with. Defaults to " ". + + """ + return align(val, "left", width=width, character=character) + + +def align_center( + val: str, + width: int | None = None, + character: str = " ", +) -> str: + """Center-align string to a given width. + + :param val: string to align + :param width: Desired width. Defaults to width of the terminal. + :param character: Character to pad with. Defaults to " ". + + """ + return align(val, "center", width=width, character=character) + + +def align_right( + val: str, + width: int | None = None, + character: str = " ", +) -> str: + """Right-align string to a given width. + + :param val: string to align + :param width: Desired width. Defaults to width of the terminal. + :param character: Character to pad with. Defaults to " ". + + """ + return align(val, "right", width=width, character=character) + + +def stylize(val: str, style: StyleType) -> str: + """Apply an ANSI style to a string, preserving any existing styles. + + :param val: string to be styled + :param style: style instance or style definition to apply. + :return: the stylized string + """ + # Convert to a Rich Text object to parse and preserve existing ANSI styles. + text = Text.from_ansi(val) + text.stylize(style) + return ru.rich_text_to_string(text) + + +def strip_style(val: str) -> str: + """Strip all ANSI style sequences from a string. + + :param val: string to be stripped + :return: the stripped string + """ + return ru.ANSI_STYLE_SEQUENCE_RE.sub("", val) + + +def str_width(val: str) -> int: + """Return the display width of a string. + + This is intended for single-line strings. + Replace tabs with spaces before calling this. + + :param val: the string being measured + :return: width of the string when printed to the terminal + """ + text = Text.from_ansi(val) + return text.cell_len + + +def is_quoted(val: str) -> bool: + """Check if a string is quoted. + + :param val: the string being checked for quotes + :return: True if a string is quoted + """ + from . import constants + + return len(val) > 1 and val[0] == val[-1] and val[0] in constants.QUOTES + + +def quote(val: str) -> str: + """Quote a string.""" + quote = "'" if '"' in val else '"' + + return quote + val + quote + + +def quote_if_needed(val: str) -> str: + """Quote a string if it contains spaces and isn't already quoted.""" + if is_quoted(val) or ' ' not in val: + return val + + return quote(val) + + +def strip_quotes(val: str) -> str: + """Strip outer quotes from a string. + + Applies to both single and double quotes. + + :param val: string to strip outer quotes from + :return: same string with potentially outer quotes stripped + """ + if is_quoted(val): + val = val[1:-1] + return val + + +def norm_fold(val: str) -> str: + """Normalize and casefold Unicode strings for saner comparisons. + + :param val: input unicode string + :return: a normalized and case-folded version of the input string + """ + import unicodedata + + return unicodedata.normalize("NFC", val).casefold() diff --git a/cmd2/styles.py b/cmd2/styles.py new file mode 100644 index 000000000..56ebb0d71 --- /dev/null +++ b/cmd2/styles.py @@ -0,0 +1,72 @@ +"""Defines custom Rich styles and their corresponding names for cmd2. + +This module provides a centralized and discoverable way to manage Rich styles +used within the cmd2 framework. It defines a StrEnum for style names and a +dictionary that maps these names to their default style objects. + +**Notes** + +Cmd2 uses Rich for its terminal output, and while this module defines a set of +cmd2-specific styles, it's important to understand that these aren't the only +styles that can appear. Components like Rich tracebacks and the rich-argparse +library, which cmd2 uses for its help output, also apply their own built-in +styles. Additionally, app developers may use other Rich objects that have +their own default styles. + +For a complete theming experience, you can create a custom theme that includes +styles from Rich and rich-argparse. The `cmd2.rich_utils.set_theme()` function +automatically updates rich-argparse's styles with any custom styles provided in +your theme dictionary, so you don't have to modify them directly. + +You can find Rich's default styles in the `rich.default_styles` module. +For rich-argparse, the style names are defined in the +`rich_argparse.RichHelpFormatter.styles` dictionary. + +""" + +import sys + +from rich.style import ( + Style, + StyleType, +) + +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + from backports.strenum import StrEnum + +from .colors import Color + + +class Cmd2Style(StrEnum): + """An enumeration of the names of custom Rich styles used in cmd2. + + Using this enum allows for autocompletion and prevents typos when + referencing cmd2-specific styles. + + This StrEnum is tightly coupled with `DEFAULT_CMD2_STYLES`. Any name + added here must have a corresponding style definition there. + """ + + COMMAND_LINE = "cmd2.example" # Command line examples in help text + ERROR = "cmd2.error" # Error text (used by perror()) + EXCEPTION_TYPE = "cmd2.exception.type" # Used by pexcept to mark an exception type + HELP_HEADER = "cmd2.help.header" # Help table header text + HELP_LEADER = "cmd2.help.leader" # Text right before the help tables are listed + SUCCESS = "cmd2.success" # Success text (used by psuccess()) + TABLE_BORDER = "cmd2.table_border" # Applied to cmd2's table borders + WARNING = "cmd2.warning" # Warning text (used by pwarning()) + + +# Default styles used by cmd2. Tightly coupled with the Cmd2Style enum. +DEFAULT_CMD2_STYLES: dict[str, StyleType] = { + Cmd2Style.COMMAND_LINE: Style(color=Color.CYAN, bold=True), + Cmd2Style.ERROR: Style(color=Color.BRIGHT_RED), + Cmd2Style.EXCEPTION_TYPE: Style(color=Color.DARK_ORANGE, bold=True), + Cmd2Style.HELP_HEADER: Style(color=Color.BRIGHT_GREEN, bold=True), + Cmd2Style.HELP_LEADER: Style(color=Color.CYAN, bold=True), + Cmd2Style.SUCCESS: Style(color=Color.GREEN), + Cmd2Style.TABLE_BORDER: Style(color=Color.BRIGHT_GREEN), + Cmd2Style.WARNING: Style(color=Color.BRIGHT_YELLOW), +} diff --git a/cmd2/table_creator.py b/cmd2/table_creator.py deleted file mode 100644 index 35c89e102..000000000 --- a/cmd2/table_creator.py +++ /dev/null @@ -1,1122 +0,0 @@ -"""cmd2 table creation API. - -This API is built upon two core classes: Column and TableCreator -The general use case is to inherit from TableCreator to create a table class with custom formatting options. -There are already implemented and ready-to-use examples of this below TableCreator's code. -""" - -import copy -import io -from collections import ( - deque, -) -from collections.abc import Sequence -from enum import ( - Enum, -) -from typing import ( - Any, - Optional, -) - -from wcwidth import ( # type: ignore[import] - wcwidth, -) - -from . import ( - ansi, - constants, - utils, -) - -# Constants -EMPTY = '' -SPACE = ' ' - - -class HorizontalAlignment(Enum): - """Horizontal alignment of text in a cell.""" - - LEFT = 1 - CENTER = 2 - RIGHT = 3 - - -class VerticalAlignment(Enum): - """Vertical alignment of text in a cell.""" - - TOP = 1 - MIDDLE = 2 - BOTTOM = 3 - - -class Column: - """Table column configuration.""" - - def __init__( - self, - header: str, - *, - width: Optional[int] = None, - header_horiz_align: HorizontalAlignment = HorizontalAlignment.LEFT, - header_vert_align: VerticalAlignment = VerticalAlignment.BOTTOM, - style_header_text: bool = True, - data_horiz_align: HorizontalAlignment = HorizontalAlignment.LEFT, - data_vert_align: VerticalAlignment = VerticalAlignment.TOP, - style_data_text: bool = True, - max_data_lines: float = constants.INFINITY, - ) -> None: - """Column initializer. - - :param header: label for column header - :param width: display width of column. This does not account for any borders or padding which - may be added (e.g pre_line, inter_cell, and post_line). Header and data text wrap within - this width using word-based wrapping (defaults to actual width of header or 1 if header is blank) - :param header_horiz_align: horizontal alignment of header cells (defaults to left) - :param header_vert_align: vertical alignment of header cells (defaults to bottom) - :param style_header_text: if True, then the table is allowed to apply styles to the header text, which may - conflict with any styles the header already has. If False, the header is printed as is. - Table classes which apply style to headers must account for the value of this flag. - (defaults to True) - :param data_horiz_align: horizontal alignment of data cells (defaults to left) - :param data_vert_align: vertical alignment of data cells (defaults to top) - :param style_data_text: if True, then the table is allowed to apply styles to the data text, which may - conflict with any styles the data already has. If False, the data is printed as is. - Table classes which apply style to data must account for the value of this flag. - (defaults to True) - :param max_data_lines: maximum lines allowed in a data cell. If line count exceeds this, then the final - line displayed will be truncated with an ellipsis. (defaults to INFINITY) - :raises ValueError: if width is less than 1 - :raises ValueError: if max_data_lines is less than 1 - """ - self.header = header - - if width is not None and width < 1: - raise ValueError("Column width cannot be less than 1") - self.width: int = width if width is not None else -1 - - self.header_horiz_align = header_horiz_align - self.header_vert_align = header_vert_align - self.style_header_text = style_header_text - - self.data_horiz_align = data_horiz_align - self.data_vert_align = data_vert_align - self.style_data_text = style_data_text - - if max_data_lines < 1: - raise ValueError("Max data lines cannot be less than 1") - - self.max_data_lines = max_data_lines - - -class TableCreator: - """Base table creation class. - - This class handles ANSI style sequences and characters with display widths greater than 1 - when performing width calculations. It was designed with the ability to build tables one row at a time. This helps - when you have large data sets that you don't want to hold in memory or when you receive portions of the data set - incrementally. - - TableCreator has one public method: generate_row() - - This function and the Column class provide all features needed to build tables with headers, borders, colors, - horizontal and vertical alignment, and wrapped text. However, it's generally easier to inherit from this class and - implement a more granular API rather than use TableCreator directly. There are ready-to-use examples of this - defined after this class. - """ - - def __init__(self, cols: Sequence[Column], *, tab_width: int = 4) -> None: - """TableCreator initializer. - - :param cols: column definitions for this table - :param tab_width: all tabs will be replaced with this many spaces. If a row's fill_char is a tab, - then it will be converted to one space. - :raises ValueError: if tab_width is less than 1 - """ - if tab_width < 1: - raise ValueError("Tab width cannot be less than 1") - - self.cols = copy.copy(cols) - self.tab_width = tab_width - - for col in self.cols: - # Replace tabs before calculating width of header strings - col.header = col.header.replace('\t', SPACE * self.tab_width) - - # For headers with the width not yet set, use the width of the - # widest line in the header or 1 if the header has no width - if col.width <= 0: - col.width = max(1, ansi.widest_line(col.header)) - - @staticmethod - def _wrap_long_word(word: str, max_width: int, max_lines: float, is_last_word: bool) -> tuple[str, int, int]: - """Wrap a long word over multiple lines, used by _wrap_text(). - - :param word: word being wrapped - :param max_width: maximum display width of a line - :param max_lines: maximum lines to wrap before ending the last line displayed with an ellipsis - :param is_last_word: True if this is the last word of the total text being wrapped - :return: Tuple(wrapped text, lines used, display width of last line) - """ - styles_dict = utils.get_styles_dict(word) - wrapped_buf = io.StringIO() - - # How many lines we've used - total_lines = 1 - - # Display width of the current line we are building - cur_line_width = 0 - - char_index = 0 - while char_index < len(word): - # We've reached the last line. Let truncate_line do the rest. - if total_lines == max_lines: - # If this isn't the last word, but it's gonna fill the final line, then force truncate_line - # to place an ellipsis at the end of it by making the word too wide. - remaining_word = word[char_index:] - if not is_last_word and ansi.style_aware_wcswidth(remaining_word) == max_width: - remaining_word += "EXTRA" - - truncated_line = utils.truncate_line(remaining_word, max_width) - cur_line_width = ansi.style_aware_wcswidth(truncated_line) - wrapped_buf.write(truncated_line) - break - - # Check if we're at a style sequence. These don't count toward display width. - if char_index in styles_dict: - wrapped_buf.write(styles_dict[char_index]) - char_index += len(styles_dict[char_index]) - continue - - cur_char = word[char_index] - cur_char_width = wcwidth(cur_char) - - if cur_char_width > max_width: - # We have a case where the character is wider than max_width. This can happen if max_width - # is 1 and the text contains wide characters (e.g. East Asian). Replace it with an ellipsis. - cur_char = constants.HORIZONTAL_ELLIPSIS - cur_char_width = wcwidth(cur_char) - - if cur_line_width + cur_char_width > max_width: - # Adding this char will exceed the max_width. Start a new line. - wrapped_buf.write('\n') - total_lines += 1 - cur_line_width = 0 - continue - - # Add this character and move to the next one - cur_line_width += cur_char_width - wrapped_buf.write(cur_char) - char_index += 1 - - return wrapped_buf.getvalue(), total_lines, cur_line_width - - @staticmethod - def _wrap_text(text: str, max_width: int, max_lines: float) -> str: - """Wrap text into lines with a display width no longer than max_width. - - This function breaks words on whitespace boundaries. If a word is longer than the space remaining on a line, - then it will start on a new line. ANSI escape sequences do not count toward the width of a line. - - :param text: text to be wrapped - :param max_width: maximum display width of a line - :param max_lines: maximum lines to wrap before ending the last line displayed with an ellipsis - :return: wrapped text - """ - # MyPy Issue #7057 documents regression requiring nonlocals to be defined earlier - cur_line_width = 0 - total_lines = 0 - - def add_word(word_to_add: str, is_last_word: bool) -> None: - """Aadd a word to the wrapped text, called from loop. - - :param word_to_add: the word being added - :param is_last_word: True if this is the last word of the total text being wrapped - """ - nonlocal cur_line_width - nonlocal total_lines - - # No more space to add word - if total_lines == max_lines and cur_line_width == max_width: - return - - word_width = ansi.style_aware_wcswidth(word_to_add) - - # If the word is wider than max width of a line, attempt to start it on its own line and wrap it - if word_width > max_width: - room_to_add = True - - if cur_line_width > 0: - # The current line already has text, check if there is room to create a new line - if total_lines < max_lines: - wrapped_buf.write('\n') - total_lines += 1 - else: - # We will truncate this word on the remaining line - room_to_add = False - - if room_to_add: - wrapped_word, lines_used, cur_line_width = TableCreator._wrap_long_word( - word_to_add, max_width, max_lines - total_lines + 1, is_last_word - ) - # Write the word to the buffer - wrapped_buf.write(wrapped_word) - total_lines += lines_used - 1 - return - - # We aren't going to wrap the word across multiple lines - remaining_width = max_width - cur_line_width - - # Check if we need to start a new line - if word_width > remaining_width and total_lines < max_lines: - # Save the last character in wrapped_buf, which can't be empty at this point. - seek_pos = wrapped_buf.tell() - 1 - wrapped_buf.seek(seek_pos) - last_char = wrapped_buf.read() - - wrapped_buf.write('\n') - total_lines += 1 - cur_line_width = 0 - remaining_width = max_width - - # Only when a space is following a space do we want to start the next line with it. - if word_to_add == SPACE and last_char != SPACE: - return - - # Check if we've hit the last line we're allowed to create - if total_lines == max_lines: - # If this word won't fit, truncate it - if word_width > remaining_width: - word_to_add = utils.truncate_line(word_to_add, remaining_width) - word_width = remaining_width - - # If this isn't the last word, but it's gonna fill the final line, then force truncate_line - # to place an ellipsis at the end of it by making the word too wide. - elif not is_last_word and word_width == remaining_width: - word_to_add = utils.truncate_line(word_to_add + "EXTRA", remaining_width) - - cur_line_width += word_width - wrapped_buf.write(word_to_add) - - ############################################################################################################ - # _wrap_text() main code - ############################################################################################################ - # Buffer of the wrapped text - wrapped_buf = io.StringIO() - - # How many lines we've used - total_lines = 0 - - # Respect the existing line breaks - data_str_lines = text.splitlines() - for data_line_index, data_line in enumerate(data_str_lines): - total_lines += 1 - - if data_line_index > 0: - wrapped_buf.write('\n') - - # If the last line is empty, then add a newline and stop - if data_line_index == len(data_str_lines) - 1 and not data_line: - wrapped_buf.write('\n') - break - - # Locate the styles in this line - styles_dict = utils.get_styles_dict(data_line) - - # Display width of the current line we are building - cur_line_width = 0 - - # Current word being built - cur_word_buf = io.StringIO() - - char_index = 0 - while char_index < len(data_line): - if total_lines == max_lines and cur_line_width == max_width: - break - - # Check if we're at a style sequence. These don't count toward display width. - if char_index in styles_dict: - cur_word_buf.write(styles_dict[char_index]) - char_index += len(styles_dict[char_index]) - continue - - cur_char = data_line[char_index] - if cur_char == SPACE: - # If we've reached the end of a word, then add the word to the wrapped text - if cur_word_buf.tell() > 0: - # is_last_word is False since there is a space after the word - add_word(cur_word_buf.getvalue(), is_last_word=False) - cur_word_buf = io.StringIO() - - # Add the space to the wrapped text - last_word = data_line_index == len(data_str_lines) - 1 and char_index == len(data_line) - 1 - add_word(cur_char, last_word) - else: - # Add this character to the word buffer - cur_word_buf.write(cur_char) - - char_index += 1 - - # Add the final word of this line if it's been started - if cur_word_buf.tell() > 0: - last_word = data_line_index == len(data_str_lines) - 1 and char_index == len(data_line) - add_word(cur_word_buf.getvalue(), last_word) - - # Stop line loop if we've written to max_lines - if total_lines == max_lines: - # If this isn't the last data line and there is space - # left on the final wrapped line, then add an ellipsis - if data_line_index < len(data_str_lines) - 1 and cur_line_width < max_width: - wrapped_buf.write(constants.HORIZONTAL_ELLIPSIS) - break - - return wrapped_buf.getvalue() - - def _generate_cell_lines(self, cell_data: Any, is_header: bool, col: Column, fill_char: str) -> tuple[deque[str], int]: - """Generate the lines of a table cell. - - :param cell_data: data to be included in cell - :param is_header: True if writing a header cell, otherwise writing a data cell. This determines whether to - use header or data alignment settings as well as maximum lines to wrap. - :param col: Column definition for this cell - :param fill_char: character that fills remaining space in a cell. If your text has a background color, - then give fill_char the same background color. (Cannot be a line breaking character) - :return: Tuple(deque of cell lines, display width of the cell) - """ - # Convert data to string and replace tabs with spaces - data_str = str(cell_data).replace('\t', SPACE * self.tab_width) - - # Wrap text in this cell - max_lines = constants.INFINITY if is_header else col.max_data_lines - wrapped_text = self._wrap_text(data_str, col.width, max_lines) - - # Align the text horizontally - horiz_alignment = col.header_horiz_align if is_header else col.data_horiz_align - if horiz_alignment == HorizontalAlignment.LEFT: - text_alignment = utils.TextAlignment.LEFT - elif horiz_alignment == HorizontalAlignment.CENTER: - text_alignment = utils.TextAlignment.CENTER - else: - text_alignment = utils.TextAlignment.RIGHT - - aligned_text = utils.align_text(wrapped_text, fill_char=fill_char, width=col.width, alignment=text_alignment) - - # Calculate cell_width first to avoid having 2 copies of aligned_text.splitlines() in memory - cell_width = ansi.widest_line(aligned_text) - lines = deque(aligned_text.splitlines()) - - return lines, cell_width - - def generate_row( - self, - row_data: Sequence[Any], - is_header: bool, - *, - fill_char: str = SPACE, - pre_line: str = EMPTY, - inter_cell: str = (2 * SPACE), - post_line: str = EMPTY, - ) -> str: - """Generate a header or data table row. - - :param row_data: data with an entry for each column in the row - :param is_header: True if writing a header cell, otherwise writing a data cell. This determines whether to - use header or data alignment settings as well as maximum lines to wrap. - :param fill_char: character that fills remaining space in a cell. Defaults to space. If this is a tab, - then it will be converted to one space. (Cannot be a line breaking character) - :param pre_line: string to print before each line of a row. This can be used for a left row border and - padding before the first cell's text. (Defaults to blank) - :param inter_cell: string to print where two cells meet. This can be used for a border between cells and padding - between it and the 2 cells' text. (Defaults to 2 spaces) - :param post_line: string to print after each line of a row. This can be used for padding after - the last cell's text and a right row border. (Defaults to blank) - :return: row string - :raises ValueError: if row_data isn't the same length as self.cols - :raises TypeError: if fill_char is more than one character (not including ANSI style sequences) - :raises ValueError: if fill_char, pre_line, inter_cell, or post_line contains an unprintable - character like a newline - """ - - class Cell: - """Inner class which represents a table cell.""" - - def __init__(self) -> None: - # Data in this cell split into individual lines - self.lines: deque[str] = deque() - - # Display width of this cell - self.width = 0 - - if len(row_data) != len(self.cols): - raise ValueError("Length of row_data must match length of cols") - - # Replace tabs (tabs in data strings will be handled in _generate_cell_lines()) - fill_char = fill_char.replace('\t', SPACE) - pre_line = pre_line.replace('\t', SPACE * self.tab_width) - inter_cell = inter_cell.replace('\t', SPACE * self.tab_width) - post_line = post_line.replace('\t', SPACE * self.tab_width) - - # Validate fill_char character count - if len(ansi.strip_style(fill_char)) != 1: - raise TypeError("Fill character must be exactly one character long") - - # Look for unprintable characters - validation_dict = {'fill_char': fill_char, 'pre_line': pre_line, 'inter_cell': inter_cell, 'post_line': post_line} - for key, val in validation_dict.items(): - if ansi.style_aware_wcswidth(val) == -1: - raise ValueError(f"{key} contains an unprintable character") - - # Number of lines this row uses - total_lines = 0 - - # Generate the cells for this row - cells = [] - - for col_index, col in enumerate(self.cols): - cell = Cell() - cell.lines, cell.width = self._generate_cell_lines(row_data[col_index], is_header, col, fill_char) - cells.append(cell) - total_lines = max(len(cell.lines), total_lines) - - row_buf = io.StringIO() - - # Vertically align each cell - for cell_index, cell in enumerate(cells): - col = self.cols[cell_index] - vert_align = col.header_vert_align if is_header else col.data_vert_align - - # Check if this cell need vertical filler - line_diff = total_lines - len(cell.lines) - if line_diff == 0: - continue - - # Add vertical filler lines - padding_line = utils.align_left(EMPTY, fill_char=fill_char, width=cell.width) - if vert_align == VerticalAlignment.TOP: - to_top = 0 - to_bottom = line_diff - elif vert_align == VerticalAlignment.MIDDLE: - to_top = line_diff // 2 - to_bottom = line_diff - to_top - else: - to_top = line_diff - to_bottom = 0 - - for _ in range(to_top): - cell.lines.appendleft(padding_line) - for _ in range(to_bottom): - cell.lines.append(padding_line) - - # Build this row one line at a time - for line_index in range(total_lines): - for cell_index, cell in enumerate(cells): - if cell_index == 0: - row_buf.write(pre_line) - - row_buf.write(cell.lines[line_index]) - - if cell_index < len(self.cols) - 1: - row_buf.write(inter_cell) - if cell_index == len(self.cols) - 1: - row_buf.write(post_line) - - # Add a newline if this is not the last line - if line_index < total_lines - 1: - row_buf.write('\n') - - return row_buf.getvalue() - - -############################################################################################################ -# The following are implementations of TableCreator which demonstrate how to make various types -# of tables. They can be used as-is or serve as inspiration for other custom table classes. -############################################################################################################ -class SimpleTable(TableCreator): - """Implementation of TableCreator which generates a borderless table with an optional divider row after the header. - - This class can be used to create the whole table at once or one row at a time. - """ - - def __init__( - self, - cols: Sequence[Column], - *, - column_spacing: int = 2, - tab_width: int = 4, - divider_char: Optional[str] = '-', - header_bg: Optional[ansi.BgColor] = None, - data_bg: Optional[ansi.BgColor] = None, - ) -> None: - """SimpleTable initializer. - - :param cols: column definitions for this table - :param column_spacing: how many spaces to place between columns. Defaults to 2. - :param tab_width: all tabs will be replaced with this many spaces. If a row's fill_char is a tab, - then it will be converted to one space. - :param divider_char: optional character used to build the header divider row. Set this to blank or None if you don't - want a divider row. Defaults to dash. (Cannot be a line breaking character) - :param header_bg: optional background color for header cells (defaults to None) - :param data_bg: optional background color for data cells (defaults to None) - :raises ValueError: if tab_width is less than 1 - :raises ValueError: if column_spacing is less than 0 - :raises TypeError: if divider_char is longer than one character - :raises ValueError: if divider_char is an unprintable character - """ - super().__init__(cols, tab_width=tab_width) - - if column_spacing < 0: - raise ValueError("Column spacing cannot be less than 0") - - self.column_spacing = column_spacing - - if divider_char == '': - divider_char = None - - if divider_char is not None: - if len(ansi.strip_style(divider_char)) != 1: - raise TypeError("Divider character must be exactly one character long") - - divider_char_width = ansi.style_aware_wcswidth(divider_char) - if divider_char_width == -1: - raise ValueError("Divider character is an unprintable character") - - self.divider_char = divider_char - self.header_bg = header_bg - self.data_bg = data_bg - - def apply_header_bg(self, value: Any) -> str: - """If defined, apply the header background color to header text. - - :param value: object whose text is to be colored - :return: formatted text. - """ - if self.header_bg is None: - return str(value) - return ansi.style(value, bg=self.header_bg) - - def apply_data_bg(self, value: Any) -> str: - """If defined, apply the data background color to data text. - - :param value: object whose text is to be colored - :return: formatted data string. - """ - if self.data_bg is None: - return str(value) - return ansi.style(value, bg=self.data_bg) - - @classmethod - def base_width(cls, num_cols: int, *, column_spacing: int = 2) -> int: - """Calculate the display width required for a table before data is added to it. - - This is useful when determining how wide to make your columns to have a table be a specific width. - - :param num_cols: how many columns the table will have - :param column_spacing: how many spaces to place between columns. Defaults to 2. - :return: base width - :raises ValueError: if column_spacing is less than 0 - :raises ValueError: if num_cols is less than 1 - """ - if num_cols < 1: - raise ValueError("Column count cannot be less than 1") - - data_str = SPACE - data_width = ansi.style_aware_wcswidth(data_str) * num_cols - - tbl = cls([Column(data_str)] * num_cols, column_spacing=column_spacing) - data_row = tbl.generate_data_row([data_str] * num_cols) - - return ansi.style_aware_wcswidth(data_row) - data_width - - def total_width(self) -> int: - """Calculate the total display width of this table.""" - base_width = self.base_width(len(self.cols), column_spacing=self.column_spacing) - data_width = sum(col.width for col in self.cols) - return base_width + data_width - - def generate_header(self) -> str: - """Generate table header with an optional divider row.""" - header_buf = io.StringIO() - - fill_char = self.apply_header_bg(SPACE) - inter_cell = self.apply_header_bg(self.column_spacing * SPACE) - - # Apply background color to header text in Columns which allow it - to_display: list[Any] = [] - for col in self.cols: - if col.style_header_text: - to_display.append(self.apply_header_bg(col.header)) - else: - to_display.append(col.header) - - # Create the header labels - header_labels = self.generate_row(to_display, is_header=True, fill_char=fill_char, inter_cell=inter_cell) - header_buf.write(header_labels) - - # Add the divider if necessary - divider = self.generate_divider() - if divider: - header_buf.write('\n' + divider) - - return header_buf.getvalue() - - def generate_divider(self) -> str: - """Generate divider row.""" - if self.divider_char is None: - return '' - - return utils.align_left('', fill_char=self.divider_char, width=self.total_width()) - - def generate_data_row(self, row_data: Sequence[Any]) -> str: - """Generate a data row. - - :param row_data: data with an entry for each column in the row - :return: data row string - :raises ValueError: if row_data isn't the same length as self.cols - """ - if len(row_data) != len(self.cols): - raise ValueError("Length of row_data must match length of cols") - - fill_char = self.apply_data_bg(SPACE) - inter_cell = self.apply_data_bg(self.column_spacing * SPACE) - - # Apply background color to data text in Columns which allow it - to_display: list[Any] = [] - for index, col in enumerate(self.cols): - if col.style_data_text: - to_display.append(self.apply_data_bg(row_data[index])) - else: - to_display.append(row_data[index]) - - return self.generate_row(to_display, is_header=False, fill_char=fill_char, inter_cell=inter_cell) - - def generate_table(self, table_data: Sequence[Sequence[Any]], *, include_header: bool = True, row_spacing: int = 1) -> str: - """Generate a table from a data set. - - :param table_data: Data with an entry for each data row of the table. Each entry should have data for - each column in the row. - :param include_header: If True, then a header will be included at top of table. (Defaults to True) - :param row_spacing: A number 0 or greater specifying how many blank lines to place between - each row (Defaults to 1) - :raises ValueError: if row_spacing is less than 0 - """ - if row_spacing < 0: - raise ValueError("Row spacing cannot be less than 0") - - table_buf = io.StringIO() - - if include_header: - header = self.generate_header() - table_buf.write(header) - if len(table_data) > 0: - table_buf.write('\n') - - row_divider = utils.align_left('', fill_char=self.apply_data_bg(SPACE), width=self.total_width()) + '\n' - - for index, row_data in enumerate(table_data): - if index > 0 and row_spacing > 0: - table_buf.write(row_spacing * row_divider) - - row = self.generate_data_row(row_data) - table_buf.write(row) - if index < len(table_data) - 1: - table_buf.write('\n') - - return table_buf.getvalue() - - -class BorderedTable(TableCreator): - """Implementation of TableCreator which generates a table with borders around the table and between rows. - - Borders between columns can also be toggled. This class can be used to create the whole table at once or one row at a time. - """ - - def __init__( - self, - cols: Sequence[Column], - *, - tab_width: int = 4, - column_borders: bool = True, - padding: int = 1, - border_fg: Optional[ansi.FgColor] = None, - border_bg: Optional[ansi.BgColor] = None, - header_bg: Optional[ansi.BgColor] = None, - data_bg: Optional[ansi.BgColor] = None, - ) -> None: - """BorderedTable initializer. - - :param cols: column definitions for this table - :param tab_width: all tabs will be replaced with this many spaces. If a row's fill_char is a tab, - then it will be converted to one space. - :param column_borders: if True, borders between columns will be included. This gives the table a grid-like - appearance. Turning off column borders results in a unified appearance between - a row's cells. (Defaults to True) - :param padding: number of spaces between text and left/right borders of cell - :param border_fg: optional foreground color for borders (defaults to None) - :param border_bg: optional background color for borders (defaults to None) - :param header_bg: optional background color for header cells (defaults to None) - :param data_bg: optional background color for data cells (defaults to None) - :raises ValueError: if tab_width is less than 1 - :raises ValueError: if padding is less than 0 - """ - super().__init__(cols, tab_width=tab_width) - self.empty_data = [EMPTY] * len(self.cols) - self.column_borders = column_borders - - if padding < 0: - raise ValueError("Padding cannot be less than 0") - self.padding = padding - - self.border_fg = border_fg - self.border_bg = border_bg - self.header_bg = header_bg - self.data_bg = data_bg - - def apply_border_color(self, value: Any) -> str: - """If defined, apply the border foreground and background colors. - - :param value: object whose text is to be colored - :return: formatted text. - """ - if self.border_fg is None and self.border_bg is None: - return str(value) - return ansi.style(value, fg=self.border_fg, bg=self.border_bg) - - def apply_header_bg(self, value: Any) -> str: - """If defined, apply the header background color to header text. - - :param value: object whose text is to be colored - :return: formatted text. - """ - if self.header_bg is None: - return str(value) - return ansi.style(value, bg=self.header_bg) - - def apply_data_bg(self, value: Any) -> str: - """If defined, apply the data background color to data text. - - :param value: object whose text is to be colored - :return: formatted data string. - """ - if self.data_bg is None: - return str(value) - return ansi.style(value, bg=self.data_bg) - - @classmethod - def base_width(cls, num_cols: int, *, column_borders: bool = True, padding: int = 1) -> int: - """Calculate the display width required for a table before data is added to it. - - This is useful when determining how wide to make your columns to have a table be a specific width. - - :param num_cols: how many columns the table will have - :param column_borders: if True, borders between columns will be included in the calculation (Defaults to True) - :param padding: number of spaces between text and left/right borders of cell - :return: base width - :raises ValueError: if num_cols is less than 1 - """ - if num_cols < 1: - raise ValueError("Column count cannot be less than 1") - - data_str = SPACE - data_width = ansi.style_aware_wcswidth(data_str) * num_cols - - tbl = cls([Column(data_str)] * num_cols, column_borders=column_borders, padding=padding) - data_row = tbl.generate_data_row([data_str] * num_cols) - - return ansi.style_aware_wcswidth(data_row) - data_width - - def total_width(self) -> int: - """Calculate the total display width of this table.""" - base_width = self.base_width(len(self.cols), column_borders=self.column_borders, padding=self.padding) - data_width = sum(col.width for col in self.cols) - return base_width + data_width - - def generate_table_top_border(self) -> str: - """Generate a border which appears at the top of the header and data section.""" - fill_char = '═' - - pre_line = '╔' + self.padding * '═' - - inter_cell = self.padding * '═' - if self.column_borders: - inter_cell += "╤" - inter_cell += self.padding * '═' - - post_line = self.padding * '═' + '╗' - - return self.generate_row( - self.empty_data, - is_header=False, - fill_char=self.apply_border_color(fill_char), - pre_line=self.apply_border_color(pre_line), - inter_cell=self.apply_border_color(inter_cell), - post_line=self.apply_border_color(post_line), - ) - - def generate_header_bottom_border(self) -> str: - """Generate a border which appears at the bottom of the header.""" - fill_char = '═' - - pre_line = '╠' + self.padding * '═' - - inter_cell = self.padding * '═' - if self.column_borders: - inter_cell += '╪' - inter_cell += self.padding * '═' - - post_line = self.padding * '═' + '╣' - - return self.generate_row( - self.empty_data, - is_header=False, - fill_char=self.apply_border_color(fill_char), - pre_line=self.apply_border_color(pre_line), - inter_cell=self.apply_border_color(inter_cell), - post_line=self.apply_border_color(post_line), - ) - - def generate_row_bottom_border(self) -> str: - """Generate a border which appears at the bottom of rows.""" - fill_char = '─' - - pre_line = '╟' + self.padding * '─' - - inter_cell = self.padding * '─' - if self.column_borders: - inter_cell += '┼' - inter_cell += self.padding * '─' - - post_line = self.padding * '─' + '╢' - - return self.generate_row( - self.empty_data, - is_header=False, - fill_char=self.apply_border_color(fill_char), - pre_line=self.apply_border_color(pre_line), - inter_cell=self.apply_border_color(inter_cell), - post_line=self.apply_border_color(post_line), - ) - - def generate_table_bottom_border(self) -> str: - """Generate a border which appears at the bottom of the table.""" - fill_char = '═' - - pre_line = '╚' + self.padding * '═' - - inter_cell = self.padding * '═' - if self.column_borders: - inter_cell += '╧' - inter_cell += self.padding * '═' - - post_line = self.padding * '═' + '╝' - - return self.generate_row( - self.empty_data, - is_header=False, - fill_char=self.apply_border_color(fill_char), - pre_line=self.apply_border_color(pre_line), - inter_cell=self.apply_border_color(inter_cell), - post_line=self.apply_border_color(post_line), - ) - - def generate_header(self) -> str: - """Generate table header.""" - fill_char = self.apply_header_bg(SPACE) - - pre_line = self.apply_border_color('║') + self.apply_header_bg(self.padding * SPACE) - - inter_cell = self.apply_header_bg(self.padding * SPACE) - if self.column_borders: - inter_cell += self.apply_border_color('│') - inter_cell += self.apply_header_bg(self.padding * SPACE) - - post_line = self.apply_header_bg(self.padding * SPACE) + self.apply_border_color('║') - - # Apply background color to header text in Columns which allow it - to_display: list[Any] = [] - for col in self.cols: - if col.style_header_text: - to_display.append(self.apply_header_bg(col.header)) - else: - to_display.append(col.header) - - # Create the bordered header - header_buf = io.StringIO() - header_buf.write(self.generate_table_top_border()) - header_buf.write('\n') - header_buf.write( - self.generate_row( - to_display, is_header=True, fill_char=fill_char, pre_line=pre_line, inter_cell=inter_cell, post_line=post_line - ) - ) - header_buf.write('\n') - header_buf.write(self.generate_header_bottom_border()) - - return header_buf.getvalue() - - def generate_data_row(self, row_data: Sequence[Any]) -> str: - """Generate a data row. - - :param row_data: data with an entry for each column in the row - :return: data row string - :raises ValueError: if row_data isn't the same length as self.cols - """ - if len(row_data) != len(self.cols): - raise ValueError("Length of row_data must match length of cols") - - fill_char = self.apply_data_bg(SPACE) - - pre_line = self.apply_border_color('║') + self.apply_data_bg(self.padding * SPACE) - - inter_cell = self.apply_data_bg(self.padding * SPACE) - if self.column_borders: - inter_cell += self.apply_border_color('│') - inter_cell += self.apply_data_bg(self.padding * SPACE) - - post_line = self.apply_data_bg(self.padding * SPACE) + self.apply_border_color('║') - - # Apply background color to data text in Columns which allow it - to_display: list[Any] = [] - for index, col in enumerate(self.cols): - if col.style_data_text: - to_display.append(self.apply_data_bg(row_data[index])) - else: - to_display.append(row_data[index]) - - return self.generate_row( - to_display, is_header=False, fill_char=fill_char, pre_line=pre_line, inter_cell=inter_cell, post_line=post_line - ) - - def generate_table(self, table_data: Sequence[Sequence[Any]], *, include_header: bool = True) -> str: - """Generate a table from a data set. - - :param table_data: Data with an entry for each data row of the table. Each entry should have data for - each column in the row. - :param include_header: If True, then a header will be included at top of table. (Defaults to True) - """ - table_buf = io.StringIO() - - if include_header: - header = self.generate_header() - table_buf.write(header) - else: - top_border = self.generate_table_top_border() - table_buf.write(top_border) - - table_buf.write('\n') - - for index, row_data in enumerate(table_data): - if index > 0: - row_bottom_border = self.generate_row_bottom_border() - table_buf.write(row_bottom_border) - table_buf.write('\n') - - row = self.generate_data_row(row_data) - table_buf.write(row) - table_buf.write('\n') - - table_buf.write(self.generate_table_bottom_border()) - return table_buf.getvalue() - - -class AlternatingTable(BorderedTable): - """Implementation of BorderedTable which uses background colors to distinguish between rows instead of row border lines. - - This class can be used to create the whole table at once or one row at a time. - - To nest an AlternatingTable within another AlternatingTable, set style_data_text to False on the Column - which contains the nested table. That will prevent the current row's background color from affecting the colors - of the nested table. - """ - - def __init__( - self, - cols: Sequence[Column], - *, - tab_width: int = 4, - column_borders: bool = True, - padding: int = 1, - border_fg: Optional[ansi.FgColor] = None, - border_bg: Optional[ansi.BgColor] = None, - header_bg: Optional[ansi.BgColor] = None, - odd_bg: Optional[ansi.BgColor] = None, - even_bg: Optional[ansi.BgColor] = ansi.Bg.DARK_GRAY, - ) -> None: - """AlternatingTable initializer. - - Note: Specify background colors using subclasses of BgColor (e.g. Bg, EightBitBg, RgbBg) - - :param cols: column definitions for this table - :param tab_width: all tabs will be replaced with this many spaces. If a row's fill_char is a tab, - then it will be converted to one space. - :param column_borders: if True, borders between columns will be included. This gives the table a grid-like - appearance. Turning off column borders results in a unified appearance between - a row's cells. (Defaults to True) - :param padding: number of spaces between text and left/right borders of cell - :param border_fg: optional foreground color for borders (defaults to None) - :param border_bg: optional background color for borders (defaults to None) - :param header_bg: optional background color for header cells (defaults to None) - :param odd_bg: optional background color for odd numbered data rows (defaults to None) - :param even_bg: optional background color for even numbered data rows (defaults to StdBg.DARK_GRAY) - :raises ValueError: if tab_width is less than 1 - :raises ValueError: if padding is less than 0 - """ - super().__init__( - cols, - tab_width=tab_width, - column_borders=column_borders, - padding=padding, - border_fg=border_fg, - border_bg=border_bg, - header_bg=header_bg, - ) - self.row_num = 1 - self.odd_bg = odd_bg - self.even_bg = even_bg - - def apply_data_bg(self, value: Any) -> str: - """Apply background color to data text based on what row is being generated and whether a color has been defined. - - :param value: object whose text is to be colored - :return: formatted data string. - """ - if self.row_num % 2 == 0 and self.even_bg is not None: - return ansi.style(value, bg=self.even_bg) - if self.row_num % 2 != 0 and self.odd_bg is not None: - return ansi.style(value, bg=self.odd_bg) - return str(value) - - def generate_data_row(self, row_data: Sequence[Any]) -> str: - """Generate a data row. - - :param row_data: data with an entry for each column in the row - :return: data row string - """ - row = super().generate_data_row(row_data) - self.row_num += 1 - return row - - def generate_table(self, table_data: Sequence[Sequence[Any]], *, include_header: bool = True) -> str: - """Generate a table from a data set. - - :param table_data: Data with an entry for each data row of the table. Each entry should have data for - each column in the row. - :param include_header: If True, then a header will be included at top of table. (Defaults to True) - """ - table_buf = io.StringIO() - - if include_header: - header = self.generate_header() - table_buf.write(header) - else: - top_border = self.generate_table_top_border() - table_buf.write(top_border) - - table_buf.write('\n') - - for row_data in table_data: - row = self.generate_data_row(row_data) - table_buf.write(row) - table_buf.write('\n') - - table_buf.write(self.generate_table_bottom_border()) - return table_buf.getvalue() diff --git a/cmd2/terminal_utils.py b/cmd2/terminal_utils.py new file mode 100644 index 000000000..1245803f0 --- /dev/null +++ b/cmd2/terminal_utils.py @@ -0,0 +1,144 @@ +r"""Support for terminal control escape sequences. + +These are used for things like setting the window title and asynchronous alerts. +""" + +from . import string_utils as su + +####################################################### +# Common ANSI escape sequence constants +####################################################### +ESC = '\x1b' +CSI = f'{ESC}[' +OSC = f'{ESC}]' +BEL = '\a' + + +#################################################################################### +# Utility functions which create various ANSI sequences +#################################################################################### +def set_title_str(title: str) -> str: + """Generate a string that, when printed, sets a terminal's window title. + + :param title: new title for the window + :return: the set title string + """ + return f"{OSC}2;{title}{BEL}" + + +def clear_screen_str(clear_type: int = 2) -> str: + """Generate a string that, when printed, clears a terminal screen based on value of clear_type. + + :param clear_type: integer which specifies how to clear the screen (Defaults to 2) + Possible values: + 0 - clear from cursor to end of screen + 1 - clear from cursor to beginning of the screen + 2 - clear entire screen + 3 - clear entire screen and delete all lines saved in the scrollback buffer + :return: the clear screen string + :raises ValueError: if clear_type is not a valid value + """ + if 0 <= clear_type <= 3: + return f"{CSI}{clear_type}J" + raise ValueError("clear_type must in an integer from 0 to 3") + + +def clear_line_str(clear_type: int = 2) -> str: + """Generate a string that, when printed, clears a line based on value of clear_type. + + :param clear_type: integer which specifies how to clear the line (Defaults to 2) + Possible values: + 0 - clear from cursor to the end of the line + 1 - clear from cursor to beginning of the line + 2 - clear entire line + :return: the clear line string + :raises ValueError: if clear_type is not a valid value + """ + if 0 <= clear_type <= 2: + return f"{CSI}{clear_type}K" + raise ValueError("clear_type must in an integer from 0 to 2") + + +#################################################################################### +# Implementations intended for direct use (do NOT use outside of cmd2) +#################################################################################### +class Cursor: + """Create ANSI sequences to alter the cursor position.""" + + @staticmethod + def UP(count: int = 1) -> str: # noqa: N802 + """Move the cursor up a specified amount of lines (Defaults to 1).""" + return f"{CSI}{count}A" + + @staticmethod + def DOWN(count: int = 1) -> str: # noqa: N802 + """Move the cursor down a specified amount of lines (Defaults to 1).""" + return f"{CSI}{count}B" + + @staticmethod + def FORWARD(count: int = 1) -> str: # noqa: N802 + """Move the cursor forward a specified amount of lines (Defaults to 1).""" + return f"{CSI}{count}C" + + @staticmethod + def BACK(count: int = 1) -> str: # noqa: N802 + """Move the cursor back a specified amount of lines (Defaults to 1).""" + return f"{CSI}{count}D" + + @staticmethod + def SET_POS(x: int, y: int) -> str: # noqa: N802 + """Set the cursor position to coordinates which are 1-based.""" + return f"{CSI}{y};{x}H" + + +def async_alert_str(*, terminal_columns: int, prompt: str, line: str, cursor_offset: int, alert_msg: str) -> str: + """Calculate the desired string, including ANSI escape codes, for displaying an asynchronous alert message. + + :param terminal_columns: terminal width (number of columns) + :param prompt: current onscreen prompt + :param line: current contents of the Readline line buffer + :param cursor_offset: the offset of the current cursor position within line + :param alert_msg: the message to display to the user + :return: the correct string so that the alert message appears to the user to be printed above the current line. + """ + # Split the prompt lines since it can contain newline characters. + prompt_lines = prompt.splitlines() or [''] + + # Calculate how many terminal lines are taken up by all prompt lines except for the last one. + # That will be included in the input lines calculations since that is where the cursor is. + num_prompt_terminal_lines = 0 + for prompt_line in prompt_lines[:-1]: + prompt_line_width = su.str_width(prompt_line) + num_prompt_terminal_lines += int(prompt_line_width / terminal_columns) + 1 + + # Now calculate how many terminal lines are take up by the input + last_prompt_line = prompt_lines[-1] + last_prompt_line_width = su.str_width(last_prompt_line) + + input_width = last_prompt_line_width + su.str_width(line) + + num_input_terminal_lines = int(input_width / terminal_columns) + 1 + + # Get the cursor's offset from the beginning of the first input line + cursor_input_offset = last_prompt_line_width + cursor_offset + + # Calculate what input line the cursor is on + cursor_input_line = int(cursor_input_offset / terminal_columns) + 1 + + # Create a string that when printed will clear all input lines and display the alert + terminal_str = '' + + # Move the cursor down to the last input line + if cursor_input_line != num_input_terminal_lines: + terminal_str += Cursor.DOWN(num_input_terminal_lines - cursor_input_line) + + # Clear each line from the bottom up so that the cursor ends up on the first prompt line + total_lines = num_prompt_terminal_lines + num_input_terminal_lines + terminal_str += (clear_line_str() + Cursor.UP(1)) * (total_lines - 1) + + # Clear the first prompt line + terminal_str += clear_line_str() + + # Move the cursor to the beginning of the first prompt line and print the alert + terminal_str += '\r' + alert_msg + return terminal_str diff --git a/cmd2/transcript.py b/cmd2/transcript.py index 05c5db6c3..430ad8cef 100644 --- a/cmd2/transcript.py +++ b/cmd2/transcript.py @@ -18,10 +18,8 @@ class is used in cmd2.py::run_transcript_tests() cast, ) -from . import ( - ansi, - utils, -) +from . import string_utils as su +from . import utils if TYPE_CHECKING: # pragma: no cover from cmd2 import ( @@ -36,7 +34,7 @@ class Cmd2TestCase(unittest.TestCase): that will execute the commands in a transcript file and expect the results shown. - See example.py + See transcript_example.py """ cmdapp: Optional['Cmd'] = None @@ -76,13 +74,13 @@ def _test_transcript(self, fname: str, transcript: Iterator[str]) -> None: line_num = 0 finished = False - line = ansi.strip_style(next(transcript)) + line = su.strip_style(next(transcript)) line_num += 1 while not finished: # Scroll forward to where actual commands begin while not line.startswith(self.cmdapp.visible_prompt): try: - line = ansi.strip_style(next(transcript)) + line = su.strip_style(next(transcript)) except StopIteration: finished = True break @@ -108,14 +106,14 @@ def _test_transcript(self, fname: str, transcript: Iterator[str]) -> None: result = self.cmdapp.stdout.read() stop_msg = 'Command indicated application should quit, but more commands in transcript' # Read the expected result from transcript - if ansi.strip_style(line).startswith(self.cmdapp.visible_prompt): + if su.strip_style(line).startswith(self.cmdapp.visible_prompt): message = f'\nFile {fname}, line {line_num}\nCommand was:\n{command}\nExpected: (nothing)\nGot:\n{result}\n' assert not result.strip(), message # noqa: S101 # If the command signaled the application to quit there should be no more commands assert not stop, stop_msg # noqa: S101 continue expected_parts = [] - while not ansi.strip_style(line).startswith(self.cmdapp.visible_prompt): + while not su.strip_style(line).startswith(self.cmdapp.visible_prompt): expected_parts.append(line) try: line = next(transcript) diff --git a/cmd2/utils.py b/cmd2/utils.py index 1c3506e6b..b0a03f9b1 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -12,14 +12,27 @@ import subprocess import sys import threading -import unicodedata -from collections.abc import Callable, Iterable +from collections.abc import ( + Callable, + Iterable, +) from difflib import SequenceMatcher from enum import Enum -from typing import TYPE_CHECKING, Any, Optional, TextIO, TypeVar, Union, cast, get_type_hints +from typing import ( + TYPE_CHECKING, + Any, + TextIO, + TypeVar, + Union, + cast, +) from . import constants -from .argparse_custom import ChoicesProviderFunc, CompleterFunc +from . import string_utils as su +from .argparse_custom import ( + ChoicesProviderFunc, + CompleterFunc, +) if TYPE_CHECKING: # pragma: no cover import cmd2 # noqa: F401 @@ -31,43 +44,6 @@ _T = TypeVar('_T') -def is_quoted(arg: str) -> bool: - """Check if a string is quoted. - - :param arg: the string being checked for quotes - :return: True if a string is quoted - """ - return len(arg) > 1 and arg[0] == arg[-1] and arg[0] in constants.QUOTES - - -def quote_string(arg: str) -> str: - """Quote a string.""" - quote = "'" if '"' in arg else '"' - - return quote + arg + quote - - -def quote_string_if_needed(arg: str) -> str: - """Quote a string if it contains spaces and isn't already quoted.""" - if is_quoted(arg) or ' ' not in arg: - return arg - - return quote_string(arg) - - -def strip_quotes(arg: str) -> str: - """Strip outer quotes from a string. - - Applies to both single and double quotes. - - :param arg: string to strip outer quotes from - :return: same string with potentially outer quotes stripped - """ - if is_quoted(arg): - arg = arg[1:-1] - return arg - - def to_bool(val: Any) -> bool: """Convert anything to a boolean based on its value. @@ -95,37 +71,44 @@ class Settable: def __init__( self, name: str, - val_type: Union[type[Any], Callable[[Any], Any]], + val_type: type[Any] | Callable[[Any], Any], description: str, settable_object: object, *, - settable_attrib_name: Optional[str] = None, - onchange_cb: Optional[Callable[[str, _T, _T], Any]] = None, - choices: Optional[Iterable[Any]] = None, - choices_provider: Optional[ChoicesProviderFunc] = None, - completer: Optional[CompleterFunc] = None, + settable_attrib_name: str | None = None, + onchange_cb: Callable[[str, _T, _T], Any] | None = None, + choices: Iterable[Any] | None = None, + choices_provider: ChoicesProviderFunc | None = None, + completer: CompleterFunc | None = None, ) -> None: """Settable Initializer. - :param name: name of the instance attribute being made settable - :param val_type: callable used to cast the string value from the command line into its proper type and - even validate its value. Setting this to bool provides tab completion for true/false and - validation using to_bool(). The val_type function should raise an exception if it fails. - This exception will be caught and printed by Cmd.do_set(). - :param description: string describing this setting - :param settable_object: object to which the instance attribute belongs (e.g. self) - :param settable_attrib_name: name which displays to the user in the output of the set command. - Defaults to `name` if not specified. - :param onchange_cb: optional function or method to call when the value of this settable is altered - by the set command. (e.g. onchange_cb=self.debug_changed) - - Cmd.do_set() passes the following 3 arguments to onchange_cb: - param_name: str - name of the changed parameter - old_value: Any - the value before being changed - new_value: Any - the value after being changed - - The following optional settings provide tab completion for a parameter's values. They correspond to the - same settings in argparse-based tab completion. A maximum of one of these should be provided. + :param name: The user-facing name for this setting in the CLI. + :param val_type: A callable used to cast the string value from the CLI into its + proper type and validate it. This function should raise an + exception (like ValueError or TypeError) if the conversion or + validation fails, which will be caught and displayed to the user + by the set command. For example, setting this to int ensures the + input is a valid integer. Specifying bool automatically provides + tab completion for 'true' and 'false' and uses a built-in function + for conversion and validation. + :param description: A concise string that describes the purpose of this setting. + :param settable_object: The object that owns the attribute being made settable (e.g. self). + :param settable_attrib_name: The name of the attribute on the settable_object that + will be modified. This defaults to the value of the name + parameter if not specified. + :param onchange_cb: An optional function or method to call when the value of this + setting is altered by the set command. The callback is invoked + only if the new value is different from the old one. + + It receives three arguments: + param_name: str - name of the parameter + old_value: Any - the parameter's old value + new_value: Any - the parameter's new value + + The following optional settings provide tab completion for a parameter's values. + They correspond to the same settings in argparse-based tab completion. A maximum + of one of these should be provided. :param choices: iterable of accepted values :param choices_provider: function that provides choices for this argument @@ -150,11 +133,13 @@ def get_bool_choices(_: str) -> list[str]: self.choices_provider = choices_provider self.completer = completer - def get_value(self) -> Any: + @property + def value(self) -> Any: """Get the value of the settable attribute.""" return getattr(self.settable_obj, self.settable_attrib_name) - def set_value(self, value: Any) -> None: + @value.setter + def value(self, value: Any) -> None: """Set the settable attribute on the specified destination object. :param value: new value to set @@ -168,7 +153,7 @@ def set_value(self, value: Any) -> None: raise ValueError(f"invalid choice: {new_value!r} (choose from {choices_str})") # Try to update the settable's value - orig_value = self.get_value() + orig_value = self.value setattr(self.settable_obj, self.settable_attrib_name, new_value) # Check if we need to call an onchange callback @@ -183,14 +168,12 @@ def is_text_file(file_path: str) -> bool: :return: True if the file is a non-empty text file, otherwise False :raises OSError: if file can't be read """ - import codecs - expanded_path = os.path.abspath(os.path.expanduser(file_path.strip())) valid_text_file = False # Only need to check for utf-8 compliance since that covers ASCII, too try: - with codecs.open(expanded_path, encoding='utf-8', errors='strict') as f: + with open(expanded_path, encoding='utf-8', errors='strict') as f: # Make sure the file has only utf-8 text and is not empty if sum(1 for _ in f) > 0: valid_text_file = True @@ -216,15 +199,6 @@ def remove_duplicates(list_to_prune: list[_T]) -> list[_T]: return list(temp_dict.keys()) -def norm_fold(astr: str) -> str: - """Normalize and casefold Unicode strings for saner comparisons. - - :param astr: input unicode string - :return: a normalized and case-folded version of the input string - """ - return unicodedata.normalize('NFC', astr).casefold() - - def alphabetical_sort(list_to_sort: Iterable[str]) -> list[str]: """Sorts a list of strings alphabetically. @@ -237,10 +211,10 @@ def alphabetical_sort(list_to_sort: Iterable[str]) -> list[str]: :param list_to_sort: the list being sorted :return: the sorted list """ - return sorted(list_to_sort, key=norm_fold) + return sorted(list_to_sort, key=su.norm_fold) -def try_int_or_force_to_lower_case(input_str: str) -> Union[int, str]: +def try_int_or_force_to_lower_case(input_str: str) -> int | str: """Try to convert the passed-in string to an integer. If that fails, it converts it to lower case using norm_fold. :param input_str: string to convert @@ -249,10 +223,10 @@ def try_int_or_force_to_lower_case(input_str: str) -> Union[int, str]: try: return int(input_str) except ValueError: - return norm_fold(input_str) + return su.norm_fold(input_str) -def natural_keys(input_str: str) -> list[Union[int, str]]: +def natural_keys(input_str: str) -> list[int | str]: """Convert a string into a list of integers and strings to support natural sorting (see natural_sort). For example: natural_keys('abc123def') -> ['abc', '123', 'def'] @@ -285,7 +259,7 @@ def quote_specific_tokens(tokens: list[str], tokens_to_quote: list[str]) -> None """ for i, token in enumerate(tokens): if token in tokens_to_quote: - tokens[i] = quote_string(token) + tokens[i] = su.quote(token) def unquote_specific_tokens(tokens: list[str], tokens_to_unquote: list[str]) -> None: @@ -295,7 +269,7 @@ def unquote_specific_tokens(tokens: list[str], tokens_to_unquote: list[str]) -> :param tokens_to_unquote: the tokens, which if present in tokens, to unquote """ for i, token in enumerate(tokens): - unquoted_token = strip_quotes(token) + unquoted_token = su.strip_quotes(token) if unquoted_token in tokens_to_unquote: tokens[i] = unquoted_token @@ -306,9 +280,9 @@ def expand_user(token: str) -> str: :param token: the string to expand """ if token: - if is_quoted(token): + if su.is_quoted(token): quote_char = token[0] - token = strip_quotes(token) + token = su.strip_quotes(token) else: quote_char = '' @@ -330,7 +304,7 @@ def expand_user_in_tokens(tokens: list[str]) -> None: tokens[index] = expand_user(tokens[index]) -def find_editor() -> Optional[str]: +def find_editor() -> str | None: """Set cmd2.Cmd.DEFAULT_EDITOR. If EDITOR env variable is set, that will be used. Otherwise the function will look for a known editor in directories specified by PATH env variable. @@ -339,9 +313,9 @@ def find_editor() -> Optional[str]: editor = os.environ.get('EDITOR') if not editor: if sys.platform[:3] == 'win': - editors = ['code.cmd', 'notepad++.exe', 'notepad.exe'] + editors = ['edit', 'code.cmd', 'notepad++.exe', 'notepad.exe'] else: - editors = ['vim', 'vi', 'emacs', 'nano', 'pico', 'joe', 'code', 'subl', 'atom', 'gedit', 'geany', 'kate'] + editors = ['vim', 'vi', 'emacs', 'nano', 'pico', 'joe', 'code', 'subl', 'gedit', 'kate'] # Get a list of every directory in the PATH environment variable and ignore symbolic links env_path = os.getenv('PATH') @@ -469,7 +443,7 @@ def getbytes(self) -> bytes: """Get the internal contents as bytes.""" return bytes(self.buffer.byte_buf) - def read(self, size: Optional[int] = -1) -> str: + def read(self, size: int | None = -1) -> str: """Read from the internal contents as a str and then clear them out. :param size: Number of bytes to read from the stream @@ -551,7 +525,7 @@ class ProcReader: If neither are pipes, then the process will run normally and no output will be captured. """ - def __init__(self, proc: PopenTextIO, stdout: Union[StdSim, TextIO], stderr: Union[StdSim, TextIO]) -> None: + def __init__(self, proc: PopenTextIO, stdout: StdSim | TextIO, stderr: StdSim | TextIO) -> None: """ProcReader initializer. :param proc: the Popen process being read from @@ -633,7 +607,7 @@ def _reader_thread_func(self, read_stdout: bool) -> None: self._write_bytes(write_stream, available) @staticmethod - def _write_bytes(stream: Union[StdSim, TextIO], to_write: Union[bytes, str]) -> None: + def _write_bytes(stream: StdSim | TextIO, to_write: bytes | str) -> None: """Write bytes to a stream. :param stream: the stream being written to @@ -682,422 +656,31 @@ class RedirectionSavedState: def __init__( self, - self_stdout: Union[StdSim, TextIO], - sys_stdout: Union[StdSim, TextIO], - pipe_proc_reader: Optional[ProcReader], + self_stdout: StdSim | TextIO, + stdouts_match: bool, + pipe_proc_reader: ProcReader | None, saved_redirecting: bool, ) -> None: """RedirectionSavedState initializer. :param self_stdout: saved value of Cmd.stdout - :param sys_stdout: saved value of sys.stdout + :param stdouts_match: True if Cmd.stdout is equal to sys.stdout :param pipe_proc_reader: saved value of Cmd._cur_pipe_proc_reader :param saved_redirecting: saved value of Cmd._redirecting. """ # Tells if command is redirecting self.redirecting = False - # Used to restore values after redirection ends + # Used to restore stdout values after redirection ends self.saved_self_stdout = self_stdout - self.saved_sys_stdout = sys_stdout + self.stdouts_match = stdouts_match # Used to restore values after command ends regardless of whether the command redirected self.saved_pipe_proc_reader = pipe_proc_reader self.saved_redirecting = saved_redirecting -def _remove_overridden_styles(styles_to_parse: list[str]) -> list[str]: - """Filter a style list down to only those which would still be in effect if all were processed in order. - - Utility function for align_text() / truncate_line(). - - This is mainly used to reduce how many style strings are stored in memory when - building large multiline strings with ANSI styles. We only need to carry over - styles from previous lines that are still in effect. - - :param styles_to_parse: list of styles to evaluate. - :return: list of styles that are still in effect. - """ - from . import ( - ansi, - ) - - class StyleState: - """Keeps track of what text styles are enabled.""" - - def __init__(self) -> None: - # Contains styles still in effect, keyed by their index in styles_to_parse - self.style_dict: dict[int, str] = {} - - # Indexes into style_dict - self.reset_all: Optional[int] = None - self.fg: Optional[int] = None - self.bg: Optional[int] = None - self.intensity: Optional[int] = None - self.italic: Optional[int] = None - self.overline: Optional[int] = None - self.strikethrough: Optional[int] = None - self.underline: Optional[int] = None - - # Read the previous styles in order and keep track of their states - style_state = StyleState() - - for index, style in enumerate(styles_to_parse): - # For styles types that we recognize, only keep their latest value from styles_to_parse. - # All unrecognized style types will be retained and their order preserved. - if style in (str(ansi.TextStyle.RESET_ALL), str(ansi.TextStyle.ALT_RESET_ALL)): - style_state = StyleState() - style_state.reset_all = index - elif ansi.STD_FG_RE.match(style) or ansi.EIGHT_BIT_FG_RE.match(style) or ansi.RGB_FG_RE.match(style): - if style_state.fg is not None: - style_state.style_dict.pop(style_state.fg) - style_state.fg = index - elif ansi.STD_BG_RE.match(style) or ansi.EIGHT_BIT_BG_RE.match(style) or ansi.RGB_BG_RE.match(style): - if style_state.bg is not None: - style_state.style_dict.pop(style_state.bg) - style_state.bg = index - elif style in ( - str(ansi.TextStyle.INTENSITY_BOLD), - str(ansi.TextStyle.INTENSITY_DIM), - str(ansi.TextStyle.INTENSITY_NORMAL), - ): - if style_state.intensity is not None: - style_state.style_dict.pop(style_state.intensity) - style_state.intensity = index - elif style in (str(ansi.TextStyle.ITALIC_ENABLE), str(ansi.TextStyle.ITALIC_DISABLE)): - if style_state.italic is not None: - style_state.style_dict.pop(style_state.italic) - style_state.italic = index - elif style in (str(ansi.TextStyle.OVERLINE_ENABLE), str(ansi.TextStyle.OVERLINE_DISABLE)): - if style_state.overline is not None: - style_state.style_dict.pop(style_state.overline) - style_state.overline = index - elif style in (str(ansi.TextStyle.STRIKETHROUGH_ENABLE), str(ansi.TextStyle.STRIKETHROUGH_DISABLE)): - if style_state.strikethrough is not None: - style_state.style_dict.pop(style_state.strikethrough) - style_state.strikethrough = index - elif style in (str(ansi.TextStyle.UNDERLINE_ENABLE), str(ansi.TextStyle.UNDERLINE_DISABLE)): - if style_state.underline is not None: - style_state.style_dict.pop(style_state.underline) - style_state.underline = index - - # Store this style and its location in the dictionary - style_state.style_dict[index] = style - - return list(style_state.style_dict.values()) - - -class TextAlignment(Enum): - """Horizontal text alignment.""" - - LEFT = 1 - CENTER = 2 - RIGHT = 3 - - -def align_text( - text: str, - alignment: TextAlignment, - *, - fill_char: str = ' ', - width: Optional[int] = None, - tab_width: int = 4, - truncate: bool = False, -) -> str: - """Align text for display within a given width. Supports characters with display widths greater than 1. - - ANSI style sequences do not count toward the display width. If text has line breaks, then each line is aligned - independently. - - There are convenience wrappers around this function: align_left(), align_center(), and align_right() - - :param text: text to align (can contain multiple lines) - :param alignment: how to align the text - :param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character) - :param width: display width of the aligned text. Defaults to width of the terminal. - :param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will - be converted to one space. - :param truncate: if True, then each line will be shortened to fit within the display width. The truncated - portions are replaced by a '…' character. Defaults to False. - :return: aligned text - :raises TypeError: if fill_char is more than one character (not including ANSI style sequences) - :raises ValueError: if text or fill_char contains an unprintable character - :raises ValueError: if width is less than 1 - """ - import io - import shutil - - from . import ( - ansi, - ) - - if width is None: - # Prior to Python 3.11 this can return 0, so use a fallback if needed. - width = shutil.get_terminal_size().columns or constants.DEFAULT_TERMINAL_WIDTH - - if width < 1: - raise ValueError("width must be at least 1") - - # Convert tabs to spaces - text = text.replace('\t', ' ' * tab_width) - fill_char = fill_char.replace('\t', ' ') - - # Save fill_char with no styles for use later - stripped_fill_char = ansi.strip_style(fill_char) - if len(stripped_fill_char) != 1: - raise TypeError("Fill character must be exactly one character long") - - fill_char_width = ansi.style_aware_wcswidth(fill_char) - if fill_char_width == -1: - raise (ValueError("Fill character is an unprintable character")) - - # Isolate the style chars before and after the fill character. We will use them when building sequences of - # fill characters. Instead of repeating the style characters for each fill character, we'll wrap each sequence. - fill_char_style_begin, fill_char_style_end = fill_char.split(stripped_fill_char) - - lines = text.splitlines() if text else [''] - - text_buf = io.StringIO() - - # ANSI style sequences that may affect subsequent lines will be cancelled by the fill_char's style. - # To avoid this, we save styles which are still in effect so we can restore them when beginning the next line. - # This also allows lines to be used independently and still have their style. TableCreator does this. - previous_styles: list[str] = [] - - for index, line in enumerate(lines): - if index > 0: - text_buf.write('\n') - - if truncate: - line = truncate_line(line, width) # noqa: PLW2901 - - line_width = ansi.style_aware_wcswidth(line) - if line_width == -1: - raise (ValueError("Text to align contains an unprintable character")) - - # Get list of styles in this line - line_styles = list(get_styles_dict(line).values()) - - # Calculate how wide each side of filling needs to be - total_fill_width = 0 if line_width >= width else width - line_width - # Even if the line needs no fill chars, there may be styles sequences to restore - - if alignment == TextAlignment.LEFT: - left_fill_width = 0 - right_fill_width = total_fill_width - elif alignment == TextAlignment.CENTER: - left_fill_width = total_fill_width // 2 - right_fill_width = total_fill_width - left_fill_width - else: - left_fill_width = total_fill_width - right_fill_width = 0 - - # Determine how many fill characters are needed to cover the width - left_fill = (left_fill_width // fill_char_width) * stripped_fill_char - right_fill = (right_fill_width // fill_char_width) * stripped_fill_char - - # In cases where the fill character display width didn't divide evenly into - # the gap being filled, pad the remainder with space. - left_fill += ' ' * (left_fill_width - ansi.style_aware_wcswidth(left_fill)) - right_fill += ' ' * (right_fill_width - ansi.style_aware_wcswidth(right_fill)) - - # Don't allow styles in fill characters and text to affect one another - if fill_char_style_begin or fill_char_style_end or previous_styles or line_styles: - if left_fill: - left_fill = ansi.TextStyle.RESET_ALL + fill_char_style_begin + left_fill + fill_char_style_end - left_fill += ansi.TextStyle.RESET_ALL - - if right_fill: - right_fill = ansi.TextStyle.RESET_ALL + fill_char_style_begin + right_fill + fill_char_style_end - right_fill += ansi.TextStyle.RESET_ALL - - # Write the line and restore styles from previous lines which are still in effect - text_buf.write(left_fill + ''.join(previous_styles) + line + right_fill) - - # Update list of styles that are still in effect for the next line - previous_styles.extend(line_styles) - previous_styles = _remove_overridden_styles(previous_styles) - - return text_buf.getvalue() - - -def align_left( - text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4, truncate: bool = False -) -> str: - """Left align text for display within a given width. Supports characters with display widths greater than 1. - - ANSI style sequences do not count toward the display width. If text has line breaks, then each line is aligned - independently. - - :param text: text to left align (can contain multiple lines) - :param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character) - :param width: display width of the aligned text. Defaults to width of the terminal. - :param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will - be converted to one space. - :param truncate: if True, then text will be shortened to fit within the display width. The truncated portion is - replaced by a '…' character. Defaults to False. - :return: left-aligned text - :raises TypeError: if fill_char is more than one character (not including ANSI style sequences) - :raises ValueError: if text or fill_char contains an unprintable character - :raises ValueError: if width is less than 1 - """ - return align_text(text, TextAlignment.LEFT, fill_char=fill_char, width=width, tab_width=tab_width, truncate=truncate) - - -def align_center( - text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4, truncate: bool = False -) -> str: - """Center text for display within a given width. Supports characters with display widths greater than 1. - - ANSI style sequences do not count toward the display width. If text has line breaks, then each line is aligned - independently. - - :param text: text to center (can contain multiple lines) - :param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character) - :param width: display width of the aligned text. Defaults to width of the terminal. - :param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will - be converted to one space. - :param truncate: if True, then text will be shortened to fit within the display width. The truncated portion is - replaced by a '…' character. Defaults to False. - :return: centered text - :raises TypeError: if fill_char is more than one character (not including ANSI style sequences) - :raises ValueError: if text or fill_char contains an unprintable character - :raises ValueError: if width is less than 1 - """ - return align_text(text, TextAlignment.CENTER, fill_char=fill_char, width=width, tab_width=tab_width, truncate=truncate) - - -def align_right( - text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4, truncate: bool = False -) -> str: - """Right align text for display within a given width. Supports characters with display widths greater than 1. - - ANSI style sequences do not count toward the display width. If text has line breaks, then each line is aligned - independently. - - :param text: text to right align (can contain multiple lines) - :param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character) - :param width: display width of the aligned text. Defaults to width of the terminal. - :param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will - be converted to one space. - :param truncate: if True, then text will be shortened to fit within the display width. The truncated portion is - replaced by a '…' character. Defaults to False. - :return: right-aligned text - :raises TypeError: if fill_char is more than one character (not including ANSI style sequences) - :raises ValueError: if text or fill_char contains an unprintable character - :raises ValueError: if width is less than 1 - """ - return align_text(text, TextAlignment.RIGHT, fill_char=fill_char, width=width, tab_width=tab_width, truncate=truncate) - - -def truncate_line(line: str, max_width: int, *, tab_width: int = 4) -> str: - """Truncate a single line to fit within a given display width. - - Any portion of the string that is truncated is replaced by a '…' character. Supports characters with display widths greater - than 1. ANSI style sequences do not count toward the display width. - - If there are ANSI style sequences in the string after where truncation occurs, this function will append them - to the returned string. - - This is done to prevent issues caused in cases like: truncate_line(Fg.BLUE + hello + Fg.RESET, 3) - In this case, "hello" would be truncated before Fg.RESET resets the color from blue. Appending the remaining style - sequences makes sure the style is in the same state had the entire string been printed. align_text() relies on this - behavior when preserving style over multiple lines. - - :param line: text to truncate - :param max_width: the maximum display width the resulting string is allowed to have - :param tab_width: any tabs in the text will be replaced with this many spaces - :return: line that has a display width less than or equal to width - :raises ValueError: if text contains an unprintable character like a newline - :raises ValueError: if max_width is less than 1 - """ - import io - - from . import ( - ansi, - ) - - # Handle tabs - line = line.replace('\t', ' ' * tab_width) - - if ansi.style_aware_wcswidth(line) == -1: - raise (ValueError("text contains an unprintable character")) - - if max_width < 1: - raise ValueError("max_width must be at least 1") - - if ansi.style_aware_wcswidth(line) <= max_width: - return line - - # Find all style sequences in the line - styles_dict = get_styles_dict(line) - - # Add characters one by one and preserve all style sequences - done = False - index = 0 - total_width = 0 - truncated_buf = io.StringIO() - - while not done: - # Check if a style sequence is at this index. These don't count toward display width. - if index in styles_dict: - truncated_buf.write(styles_dict[index]) - style_len = len(styles_dict[index]) - styles_dict.pop(index) - index += style_len - continue - - char = line[index] - char_width = ansi.style_aware_wcswidth(char) - - # This char will make the text too wide, add the ellipsis instead - if char_width + total_width >= max_width: - char = constants.HORIZONTAL_ELLIPSIS - char_width = ansi.style_aware_wcswidth(char) - done = True - - total_width += char_width - truncated_buf.write(char) - index += 1 - - # Filter out overridden styles from the remaining ones - remaining_styles = _remove_overridden_styles(list(styles_dict.values())) - - # Append the remaining styles to the truncated text - truncated_buf.write(''.join(remaining_styles)) - - return truncated_buf.getvalue() - - -def get_styles_dict(text: str) -> dict[int, str]: - """Return an OrderedDict containing all ANSI style sequences found in a string. - - The structure of the dictionary is: - key: index where sequences begins - value: ANSI style sequence found at index in text - - Keys are in ascending order - - :param text: text to search for style sequences - """ - from . import ( - ansi, - ) - - start = 0 - styles = collections.OrderedDict() - - while True: - match = ansi.ANSI_STYLE_RE.search(text, start) - if match is None: - break - styles[match.start()] = match.group() - start += len(match.group()) - - return styles - - -def categorize(func: Union[Callable[..., Any], Iterable[Callable[..., Any]]], category: str) -> None: +def categorize(func: Callable[..., Any] | Iterable[Callable[..., Any]], category: str) -> None: """Categorize a function. The help command output will group the passed function under the @@ -1123,12 +706,12 @@ def do_echo(self, arglist): for item in func: setattr(item, constants.CMD_ATTR_HELP_CATEGORY, category) elif inspect.ismethod(func): - setattr(func.__func__, constants.CMD_ATTR_HELP_CATEGORY, category) # type: ignore[attr-defined] + setattr(func.__func__, constants.CMD_ATTR_HELP_CATEGORY, category) else: setattr(func, constants.CMD_ATTR_HELP_CATEGORY, category) -def get_defining_class(meth: Callable[..., Any]) -> Optional[type[Any]]: +def get_defining_class(meth: Callable[..., Any]) -> type[Any] | None: """Attempt to resolve the class that defined a method. Inspired by implementation published here: @@ -1142,7 +725,7 @@ def get_defining_class(meth: Callable[..., Any]) -> Optional[type[Any]]: if inspect.ismethod(meth) or ( inspect.isbuiltin(meth) and hasattr(meth, '__self__') and hasattr(meth.__self__, '__class__') ): - for cls in inspect.getmro(meth.__self__.__class__): # type: ignore[attr-defined] + for cls in inspect.getmro(meth.__self__.__class__): if meth.__name__ in cls.__dict__: return cls meth = getattr(meth, '__func__', meth) # fallback to __qualname__ parsing @@ -1225,8 +808,8 @@ def similarity_function(s1: str, s2: str) -> float: def suggest_similar( - requested_command: str, options: Iterable[str], similarity_function_to_use: Optional[Callable[[str, str], float]] = None -) -> Optional[str]: + requested_command: str, options: Iterable[str], similarity_function_to_use: Callable[[str, str], float] | None = None +) -> str | None: """Given a requested command and an iterable of possible options returns the most similar (if any is similar). :param requested_command: The command entered by the user @@ -1247,24 +830,21 @@ def suggest_similar( def get_types(func_or_method: Callable[..., Any]) -> tuple[dict[str, Any], Any]: - """Use typing.get_type_hints() to extract type hints for parameters and return value. - - This exists because the inspect module doesn't have a safe way of doing this that works - both with and without importing annotations from __future__ until Python 3.10. + """Use inspect.get_annotations() to extract type hints for parameters and return value. - TODO: Once cmd2 only supports Python 3.10+, change to use inspect.get_annotations(eval_str=True) + This is a thin convenience wrapper around inspect.get_annotations() that treats the return value + annotation separately. :param func_or_method: Function or method to return the type hints for - :return tuple with first element being dictionary mapping param names to type hints - and second element being return type hint, unspecified, returns None + :return: tuple with first element being dictionary mapping param names to type hints + and second element being the return type hint or None if there is no return value type hint + :raises ValueError: if the `func_or_method` argument is not a valid object to pass to `inspect.get_annotations` """ try: - type_hints = get_type_hints(func_or_method) # Get dictionary of type hints + type_hints = inspect.get_annotations(func_or_method, eval_str=True) # Get dictionary of type hints except TypeError as exc: raise ValueError("Argument passed to get_types should be a function or method") from exc ret_ann = type_hints.pop('return', None) # Pop off the return annotation if it exists if inspect.ismethod(func_or_method): type_hints.pop('self', None) # Pop off `self` hint for methods - if ret_ann is type(None): - ret_ann = None # Simplify logic to just return None instead of NoneType return type_hints, ret_ann diff --git a/docs/api/ansi.md b/docs/api/ansi.md deleted file mode 100644 index 754861d50..000000000 --- a/docs/api/ansi.md +++ /dev/null @@ -1,3 +0,0 @@ -# cmd2.ansi - -::: cmd2.ansi diff --git a/docs/api/clipboard.md b/docs/api/clipboard.md new file mode 100644 index 000000000..b3f9a2bf9 --- /dev/null +++ b/docs/api/clipboard.md @@ -0,0 +1,3 @@ +# cmd2.clipboard + +::: cmd2.clipboard diff --git a/docs/api/colors.md b/docs/api/colors.md new file mode 100644 index 000000000..cb37aece6 --- /dev/null +++ b/docs/api/colors.md @@ -0,0 +1,3 @@ +# cmd2.colors + +::: cmd2.colors diff --git a/docs/api/index.md b/docs/api/index.md index 291bcbccd..c52dca6f8 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -12,10 +12,10 @@ incremented according to the [Semantic Version Specification](https://semver.org ## Modules - [cmd2.Cmd](./cmd.md) - functions and attributes of the main class in this library -- [cmd2.ansi](./ansi.md) - convenience classes and functions for generating ANSI escape sequences to - style text in the terminal - [cmd2.argparse_completer](./argparse_completer.md) - classes for `argparse`-based tab completion - [cmd2.argparse_custom](./argparse_custom.md) - classes and functions for extending `argparse` +- [cmd2.clipboard](./clipboard.md) - functions to copy from and paste to the clipboard/pastebuffer +- [cmd2.colors](./colors.md) - StrEnum of all color names supported by the Rich library - [cmd2.command_definition](./command_definition.md) - supports the definition of commands in separate classes to be composed into cmd2.Cmd - [cmd2.constants](./constants.md) - just like it says on the tin @@ -26,5 +26,11 @@ incremented according to the [Semantic Version Specification](https://semver.org - [cmd2.plugin](./plugin.md) - data classes for hook methods - [cmd2.py_bridge](./py_bridge.md) - classes for bridging calls from the embedded python environment to the host app -- [cmd2.table_creator](./table_creator.md) - table creation module +- [cmd2.rich_utils](./rich_utils.md) - common utilities to support Rich in cmd2 applications +- [cmd2.rl_utils](./rl_utils.md) - imports the proper Readline for the platform and provides utility + functions for it +- [cmd2.string_utils](./string_utils.md) - string utility functions +- [cmd2.styles](./styles.md) - cmd2-specific Rich styles and a StrEnum of their corresponding names +- [cmd2.terminal_utils](./terminal_utils.md) - support for terminal control escape sequences +- [cmd2.transcript](./transcript.md) - functions and classes for running and validating transcripts - [cmd2.utils](./utils.md) - various utility classes and functions diff --git a/docs/api/rich_utils.md b/docs/api/rich_utils.md new file mode 100644 index 000000000..e339843d0 --- /dev/null +++ b/docs/api/rich_utils.md @@ -0,0 +1,3 @@ +# cmd2.rich_utils + +::: cmd2.rich_utils diff --git a/docs/api/rl_utils.md b/docs/api/rl_utils.md new file mode 100644 index 000000000..52beb31ba --- /dev/null +++ b/docs/api/rl_utils.md @@ -0,0 +1,3 @@ +# cmd2.rl_utils + +::: cmd2.rl_utils diff --git a/docs/api/string_utils.md b/docs/api/string_utils.md new file mode 100644 index 000000000..5717608b1 --- /dev/null +++ b/docs/api/string_utils.md @@ -0,0 +1,3 @@ +# cmd2.string_utils + +::: cmd2.string_utils diff --git a/docs/api/styles.md b/docs/api/styles.md new file mode 100644 index 000000000..4f10ccb12 --- /dev/null +++ b/docs/api/styles.md @@ -0,0 +1,3 @@ +# cmd2.styles + +::: cmd2.styles diff --git a/docs/api/table_creator.md b/docs/api/table_creator.md deleted file mode 100644 index 2d3887fcf..000000000 --- a/docs/api/table_creator.md +++ /dev/null @@ -1,3 +0,0 @@ -# cmd2.table_creator - -::: cmd2.table_creator diff --git a/docs/api/terminal_utils.md b/docs/api/terminal_utils.md new file mode 100644 index 000000000..919f36dd5 --- /dev/null +++ b/docs/api/terminal_utils.md @@ -0,0 +1,3 @@ +# cmd2.terminal_utils + +::: cmd2.terminal_utils diff --git a/docs/api/transcript.md b/docs/api/transcript.md new file mode 100644 index 000000000..bde72d371 --- /dev/null +++ b/docs/api/transcript.md @@ -0,0 +1,3 @@ +# cmd2.transcript + +::: cmd2.transcript diff --git a/docs/api/utils.md b/docs/api/utils.md index 93bbbf5b1..20a92e6b6 100644 --- a/docs/api/utils.md +++ b/docs/api/utils.md @@ -1,57 +1,3 @@ # cmd2.utils -## Settings - -::: cmd2.utils.Settable - -## Quote Handling - -::: cmd2.utils.is_quoted - -::: cmd2.utils.quote_string - -::: cmd2.utils.quote_string_if_needed - -::: cmd2.utils.strip_quotes - -## IO Handling - -::: cmd2.utils.StdSim - -::: cmd2.utils.ByteBuf - -::: cmd2.utils.ProcReader - -## Tab Completion - -::: cmd2.utils.CompletionMode - -::: cmd2.utils.CustomCompletionSettings - -## Text Alignment - -::: cmd2.utils.TextAlignment - -::: cmd2.utils.align_text - -::: cmd2.utils.align_left - -::: cmd2.utils.align_right - -::: cmd2.utils.align_center - -::: cmd2.utils.truncate_line - -## Miscellaneous - -::: cmd2.utils.to_bool - -::: cmd2.utils.categorize - -::: cmd2.utils.remove_duplicates - -::: cmd2.utils.alphabetical_sort - -::: cmd2.utils.natural_sort - -::: cmd2.utils.suggest_similar +::: cmd2.utils diff --git a/docs/examples/first_app.md b/docs/examples/getting_started.md similarity index 97% rename from docs/examples/first_app.md rename to docs/examples/getting_started.md index 86efd70ff..0ab7289eb 100644 --- a/docs/examples/first_app.md +++ b/docs/examples/getting_started.md @@ -1,6 +1,6 @@ -# First Application +# Getting Started -Here's a quick walkthrough of a simple application which demonstrates 8 features of `cmd2`: +Here's a quick walkthrough of a simple application which demonstrates 10 features of `cmd2`: - [Settings](../features/settings.md) - [Commands](../features/commands.md) @@ -14,17 +14,17 @@ Here's a quick walkthrough of a simple application which demonstrates 8 features 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 +!!! example "getting_started.py" ```py {% - include "../../examples/first_app.py" + include "../../examples/getting_started.py" %} ``` ## Basic Application -First we need to create a new `cmd2` application. Create a new file `first_app.py` with the +First we need to create a new `cmd2` application. Create a new file `getting_started.py` with the following contents: ```py @@ -47,7 +47,7 @@ We have a new class `FirstApp` which is a subclass of [cmd2.Cmd][]. When we tell file like this: ```shell -$ python first_app.py +$ python getting_started.py ``` it creates an instance of our class, and calls the `cmd2.Cmd.cmdloop` method. This method accepts @@ -77,7 +77,7 @@ In that initializer, the first thing to do is to make sure we initialize `cmd2`. run the script, and enter the `set` command to see the settings, like this: ```shell -$ python first_app.py +$ python getting_started.py (Cmd) set ``` @@ -88,8 +88,8 @@ you will see our `maxrepeats` setting show up with it's default value of `3`. 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 piglatin. We will also use some built in methods for -[generating output](../features/generating_output.md). Add this code to `first_app.py`, so that the -`speak_parser` attribute and the `do_speak()` method are part of the `CmdLineApp()` class: +[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 `CmdLineApp()` class: ```py speak_parser = cmd2.Cmd2ArgumentParser() diff --git a/docs/examples/index.md b/docs/examples/index.md index 23001e973..6aad5a595 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -2,7 +2,7 @@ -- [First Application](first_app.md) +- [Getting Started](getting_started.md) - [Alternate Event Loops](alternate_event_loops.md) - [List of cmd2 examples](examples.md) diff --git a/docs/features/argument_processing.md b/docs/features/argument_processing.md index a8dd62ba0..33db6723f 100644 --- a/docs/features/argument_processing.md +++ b/docs/features/argument_processing.md @@ -14,10 +14,10 @@ following for you: These features are all provided by the `@with_argparser` decorator which is importable from `cmd2`. -See the either the [argprint](https://github.com/python-cmd2/cmd2/blob/main/examples/arg_print.py) -or [decorator](https://github.com/python-cmd2/cmd2/blob/main/examples/decorator_example.py) example -to learn more about how to use the various `cmd2` argument processing decorators in your `cmd2` -applications. +See the +[argparse_example](https://github.com/python-cmd2/cmd2/blob/main/examples/argparse_example.py) +example to learn more about how to use the various `cmd2` argument processing decorators in your +`cmd2` applications. `cmd2` provides the following [decorators](../api/decorators.md) for assisting with parsing arguments passed to commands: @@ -63,7 +63,7 @@ def do_speak(self, opts) !!! note - The `@with_argparser` decorator sets the `prog` variable in the argument parser based on the name of the method it is decorating. This will override anything you specify in `prog` variable when creating the argument parser. + `cmd2` sets the `prog` variable in the argument parser based on the name of the method it is decorating. This will override anything you specify in `prog` variable when creating the argument parser. ## Help Messages @@ -286,8 +286,9 @@ argparse sub-parsers. You may add multiple layers of subcommands for your command. `cmd2` will automatically traverse and tab complete subcommands for all commands using argparse. -See the [subcommands](https://github.com/python-cmd2/cmd2/blob/main/examples/subcommands.py) example -to learn more about how to use subcommands in your `cmd2` application. +See the +[argparse_example](https://github.com/python-cmd2/cmd2/blob/main/examples/argparse_example.py) +example to learn more about how to use subcommands in your `cmd2` application. ## Argparse Extensions diff --git a/docs/features/builtin_commands.md b/docs/features/builtin_commands.md index ed0e24796..33e27aa20 100644 --- a/docs/features/builtin_commands.md +++ b/docs/features/builtin_commands.md @@ -86,7 +86,7 @@ always_show_hint False Display tab completion h debug True Show full traceback on exception echo False Echo command issued into output editor vi Program used by 'edit' -feedback_to_output False Include nonessentials in '|', '>' results +feedback_to_output False Include nonessentials in '|' and '>' results max_completion_items 50 Maximum number of CompletionItems to display during tab completion quiet False Don't print nonessential feedback diff --git a/docs/features/commands.md b/docs/features/commands.md index 5497ce44c..06f3877be 100644 --- a/docs/features/commands.md +++ b/docs/features/commands.md @@ -61,7 +61,7 @@ backwards compatibility. - quoted arguments - output redirection and piping - multi-line commands -- shortcut, macro, and alias expansion +- shortcut, alias, and macro expansion In addition to parsing all of these elements from the user input, `cmd2` also has code to make all of these items work; it's almost transparent to you and to the commands you write in your own diff --git a/docs/features/completion.md b/docs/features/completion.md index 47ba9d07e..3d08bf87a 100644 --- a/docs/features/completion.md +++ b/docs/features/completion.md @@ -96,8 +96,7 @@ Tab completion of argument values can be configured by using one of three parame - `completer` See the [arg_decorators](https://github.com/python-cmd2/cmd2/blob/main/examples/arg_decorators.py) -or [colors](https://github.com/python-cmd2/cmd2/blob/main/examples/colors.py) example for a -demonstration of how to use the `choices` parameter. See the +example for a demonstration of how to use the `choices` parameter. See the [argparse_completion](https://github.com/python-cmd2/cmd2/blob/main/examples/argparse_completion.py) example for a demonstration of how to use the `choices_provider` parameter. See the [arg_decorators](https://github.com/python-cmd2/cmd2/blob/main/examples/arg_decorators.py) or diff --git a/docs/features/generating_output.md b/docs/features/generating_output.md index 4892dd7e6..beed0c1c0 100644 --- a/docs/features/generating_output.md +++ b/docs/features/generating_output.md @@ -43,12 +43,11 @@ def do_echo(self, args): ## Error Messages When an error occurs in your program, you can display it on `sys.stderr` by calling the -`.cmd2.Cmd.perror` method. By default this method applies `cmd2.ansi.style_error` to the output. +`.cmd2.Cmd.perror` method. By default this method applies `Cmd2Style.ERROR` to the output. ## Warning Messages -`cmd2.Cmd.pwarning` is just like `cmd2.Cmd.perror` but applies `cmd2.ansi.style_warning` to the -output. +`cmd2.Cmd.pwarning` is just like `cmd2.Cmd.perror` but applies `Cmd2Style.WARNING` to the output. ## Feedback @@ -85,7 +84,13 @@ You can add your own [ANSI escape sequences](https://en.wikipedia.org/wiki/ANSI_ to your output which tell the terminal to change the foreground and background colors. `cmd2` provides a number of convenience functions and classes for adding color and other styles to -text. These are all documented in [cmd2.ansi][]. +text. These are all based on [rich](https://github.com/Textualize/rich) and are documented in the +following sectins: + +- [cmd2.colors][] +- [cmd2.rich_utils][] +- [cmd2.string_utils][] +- [cmd2.terminal_utils][] After adding the desired escape sequences to your output, you should use one of these methods to present the output to the user: @@ -121,21 +126,6 @@ you can pad it appropriately with spaces. However, there are categories of Unico occupy 2 cells, and other that occupy 0. To further complicate matters, you might have included ANSI escape sequences in the output to generate colors on the terminal. -The `cmd2.ansi.style_aware_wcswidth` function solves both of these problems. Pass it a string, and +The `cmd2.string_utils.str_width` function solves both of these problems. Pass it a string, and regardless of which Unicode characters and ANSI text style escape sequences it contains, it will tell you how many characters on the screen that string will consume when printed. - -## Pretty Printing Data Structures - -The `cmd2.Cmd.ppretty` method is similar to the Python -[pprint](https://docs.python.org/3/library/pprint.html) function from the standard `pprint` module. -`cmd2.Cmd.pprint` adds the same conveniences as `cmd2.Cmd.poutput`. - -This method provides a capability to “pretty-print” arbitrary Python data structures in a form which -can be used as input to the interpreter and is easy for humans to read. - -The formatted representation keeps objects on a single line if it can, and breaks them onto multiple -lines if they don’t fit within the allowed width, adjustable by the width parameter defaulting to 80 -characters. - -Dictionaries are sorted by key before the display is computed. diff --git a/docs/features/help.md b/docs/features/help.md index 56a47b3bc..816acc11c 100644 --- a/docs/features/help.md +++ b/docs/features/help.md @@ -137,51 +137,55 @@ categories with per-command Help Messages: Documented commands (use 'help -v' for verbose/'help ' for details): Application Management - ================================================================================ - deploy Deploy command - expire Expire command - findleakers Find Leakers command - list List command - redeploy Redeploy command - restart usage: restart [-h] {now,later,sometime,whenever} - sessions Sessions command - start Start command - stop Stop command - undeploy Undeploy command + ====================================================================================================== + deploy Deploy command. + expire Expire command. + findleakers Find Leakers command. + list List command. + redeploy Redeploy command. + restart Restart command. + sessions Sessions command. + start Start command. + stop Stop command. + undeploy Undeploy command. + + Command Management + ====================================================================================================== + disable_commands Disable the Application Management commands. + enable_commands Enable the Application Management commands. Connecting - ================================================================================ - connect Connect command - which Which command + ====================================================================================================== + connect Connect command. + which Which command. Server Information - ================================================================================ - resources Resources command - serverinfo Server Info command - sslconnectorciphers SSL Connector Ciphers command is an example of a command that contains - multiple lines of help information for the user. Each line of help in a - contiguous set of lines will be printed and aligned in the verbose output - provided with 'help --verbose' - status Status command - thread_dump Thread Dump command - vminfo VM Info command + ====================================================================================================== + resources Resources command. + serverinfo Server Info command. + sslconnectorciphers SSL Connector Ciphers command is an example of a command that contains + multiple lines of help information for the user. Each line of help in a + contiguous set of lines will be printed and aligned in the verbose output + provided with 'help --verbose'. + status Status command. + thread_dump Thread Dump command. + vminfo VM Info command. Other - ================================================================================ - alias Manage aliases - config Config command - edit Run a text editor and optionally open a file with it - help List available commands or provide detailed help for a specific command - history View, run, edit, save, or clear previously entered commands - macro Manage macros - py Invoke Python command or shell - quit Exits this application - run_pyscript Runs a python script file inside the console - run_script Runs commands in script file that is encoded as either ASCII or UTF-8 text - set Set a settable parameter or show current settings of parameters - shell Execute a command as if at the OS prompt - shortcuts List available shortcuts - version Version command + ====================================================================================================== + alias Manage aliases. + config Config command. + edit Run a text editor and optionally open a file with it. + help List available commands or provide detailed help for a specific command. + history View, run, edit, save, or clear previously entered commands. + macro Manage macros. + quit Exit this application. + run_pyscript Run Python script within this application's environment. + run_script Run text script. + set Set a settable parameter or show current settings of parameters. + shell Execute a command as if at the OS prompt. + shortcuts List available shortcuts. + version Version command. When called with the `-v` flag for verbose help, the one-line description for each command is provided by the first line of the docstring for that command's associated `do_*` method. diff --git a/docs/features/history.md b/docs/features/history.md index 59ecf5f19..fdc7c9b46 100644 --- a/docs/features/history.md +++ b/docs/features/history.md @@ -212,7 +212,7 @@ By default, the `history` command shows exactly what we typed: 1 alias create ls shell ls -aF 2 ls -d h* -There are two ways to modify that display so you can see what aliases and macros were expanded to. +There are two ways to modify the display so you can see what aliases and macros were expanded to. The first is to use `-x` or `--expanded`. These options show the expanded command instead of the entered command: diff --git a/docs/features/initialization.md b/docs/features/initialization.md index 279238f0e..5fe6e008d 100644 --- a/docs/features/initialization.md +++ b/docs/features/initialization.md @@ -2,81 +2,13 @@ Here is a basic example `cmd2` application which demonstrates many capabilities which you may wish to utilize while initializing the app: -```py - #!/usr/bin/env python3 - # coding=utf-8 - """A simple example cmd2 application demonstrating the following: - 1) Colorizing/stylizing output - 2) Using multiline commands - 3) Persistent history - 4) How to run an initialization script at startup - 5) How to group and categorize commands when displaying them in help - 6) Opting-in to using the ipy command to run an IPython shell - 7) Allowing access to your application in py and ipy - 8) Displaying an intro banner upon starting your application - 9) Using a custom prompt - 10) How to make custom attributes settable at runtime - """ - import cmd2 - from cmd2 import ( - Bg, - Fg, - style, - ) +!!! example "examples/getting_started.py" - - class BasicApp(cmd2.Cmd): - CUSTOM_CATEGORY = 'My Custom Commands' - - def __init__(self): - super().__init__( - multiline_commands=['echo'], - persistent_history_file='cmd2_history.dat', - startup_script='scripts/startup.txt', - include_ipy=True, - ) - - # Prints an intro banner once upon application startup - self.intro = style('Welcome to cmd2!', fg=Fg.RED, bg=Bg.WHITE, bold=True) - - # Show this as the prompt when asking for input - self.prompt = 'myapp> ' - - # Used as prompt for multiline commands after the first line - self.continuation_prompt = '... ' - - # Allow access to your application in py and ipy via self - self.self_in_py = True - - # Set the default category name - self.default_category = 'cmd2 Built-in Commands' - - # Color to output text in with echo command - self.foreground_color = Fg.CYAN.name.lower() - - # Make echo_fg settable at runtime - fg_colors = [c.name.lower() for c in Fg] - self.add_settable( - cmd2.Settable('foreground_color', str, 'Foreground color to use with echo command', self, - choices=fg_colors) - ) - - @cmd2.with_category(CUSTOM_CATEGORY) - def do_intro(self, _): - """Display the intro banner""" - self.poutput(self.intro) - - @cmd2.with_category(CUSTOM_CATEGORY) - def do_echo(self, arg): - """Example of a multiline command""" - fg_color = Fg[self.foreground_color.upper()] - self.poutput(style(arg, fg=fg_color)) - - - if __name__ == '__main__': - app = BasicApp() - app.cmdloop() -``` + ```py + {% + include "../../examples/getting_started.py" + %} + ``` ## Cmd class initializer @@ -92,7 +24,7 @@ The `cmd2.Cmd` class provides a large number of public instance attributes which ### Public instance attributes -Here are instance attributes of `cmd2.Cmd` which developers might wish override: +Here are instance attributes of `cmd2.Cmd` which developers might wish to override: - **always_show_hint**: if `True`, display tab completion hint even when completion suggestions print (Default: `False`) - **broken_pipe_warning**: if non-empty, this string will be displayed if a broken pipe error occurs diff --git a/docs/features/os.md b/docs/features/os.md index d1da31bf5..313b988bb 100644 --- a/docs/features/os.md +++ b/docs/features/os.md @@ -69,23 +69,23 @@ user to enter commands, which are then executed by your program. You may want to execute commands in your program without prompting the user for any input. There are several ways you might accomplish this task. The easiest one is to pipe commands and their arguments into your program via standard input. You don't need to do anything to your program in order to use -this technique. Here's a demonstration using the `examples/example.py` included in the source code -of `cmd2`: +this technique. Here's a demonstration using the `examples/transcript_example.py` included in the +source code of `cmd2`: - $ echo "speak -p some words" | python examples/example.py + $ echo "speak -p some words" | python examples/transcript_example.py omesay ordsway Using this same approach you could create a text file containing the commands you would like to run, one command per line in the file. Say your file was called `somecmds.txt`. To run the commands in the text file using your `cmd2` program (from a Windows command prompt): - c:\cmd2> type somecmds.txt | python.exe examples/example.py + c:\cmd2> type somecmds.txt | python.exe examples/transcript_example.py omesay ordsway By default, `cmd2` programs also look for commands pass as arguments from the operating system shell, and execute those commands before entering the command loop: - $ python examples/example.py help + $ python examples/transcript_example.py help Documented commands (use 'help -v' for verbose/'help ' for details): =========================================================================== @@ -99,8 +99,8 @@ example, you might have a command inside your `cmd2` program which itself accept maybe even option strings. Say you wanted to run the `speak` command from the operating system shell, but have it say it in pig latin: - $ python example/example.py speak -p hello there - python example.py speak -p hello there + $ python example/transcript_example.py speak -p hello there + python transcript_example.py speak -p hello there usage: speak [-h] [-p] [-s] [-r REPEAT] words [words ...] speak: error: the following arguments are required: words *** Unknown syntax: -p @@ -122,7 +122,7 @@ Check the source code of this example, especially the `main()` function, to see Alternatively you can simply wrap the command plus arguments in quotes (either single or double quotes): - $ python example/example.py "speak -p hello there" + $ python example/transcript_example.py "speak -p hello there" ellohay heretay (Cmd) @@ -148,6 +148,6 @@ quits while returning an exit code: Here is another example using `quit`: - $ python example/example.py "speak -p hello there" quit + $ python example/transcript_example.py "speak -p hello there" quit ellohay heretay $ diff --git a/docs/features/prompt.md b/docs/features/prompt.md index 0ae8b1790..ad385fbcd 100644 --- a/docs/features/prompt.md +++ b/docs/features/prompt.md @@ -6,8 +6,8 @@ This prompt can be configured by setting the `cmd2.Cmd.prompt` instance attribute. This contains the string which should be printed as a prompt for user input. See the -[Pirate](https://github.com/python-cmd2/cmd2/blob/main/examples/pirate.py#L39) example for the -simple use case of statically setting the prompt. +[getting_started](https://github.com/python-cmd2/cmd2/blob/main/examples/getting_started.py) example +for the simple use case of statically setting the prompt. ## Continuation Prompt @@ -15,16 +15,16 @@ When a user types a [Multiline Command](./multiline_commands.md) it may span mor input. The prompt for the first line of input is specified by the `cmd2.Cmd.prompt` instance attribute. The prompt for subsequent lines of input is defined by the `cmd2.Cmd.continuation_prompt` attribute.See the -[Initialization](https://github.com/python-cmd2/cmd2/blob/main/examples/initialization.py#L42) -example for a demonstration of customizing the continuation prompt. +[getting_started](https://github.com/python-cmd2/cmd2/blob/main/examples/getting_started.py) example +for a demonstration of customizing the continuation prompt. ## Updating the prompt If you wish to update the prompt between commands, you can do so using one of the [Application Lifecycle Hooks](./hooks.md#application-lifecycle-hooks) such as a [Postcommand hook](./hooks.md#postcommand-hooks). See -[PythonScripting](https://github.com/python-cmd2/cmd2/blob/main/examples/python_scripting.py#L38-L55) -for an example of dynamically updating the prompt. +[PythonScripting](https://github.com/python-cmd2/cmd2/blob/main/examples/python_scripting.py) for an +example of dynamically updating the prompt. ## Asynchronous Feedback diff --git a/docs/features/shortcuts_aliases_macros.md b/docs/features/shortcuts_aliases_macros.md index 9c87ec445..b286bc485 100644 --- a/docs/features/shortcuts_aliases_macros.md +++ b/docs/features/shortcuts_aliases_macros.md @@ -78,8 +78,11 @@ Similar to aliases, pipes and redirectors need to be quoted in the definition of macro create lc !cat "{1}" "|" less -To use the literal string `{1}` in your command, escape it this way: `{{1}}`. Because macros do not -resolve until after hitting ``, tab completion will only complete paths while typing a macro. +To use the literal string `{1}` in your command, escape it this way: `{{1}}`. + +Since macros don't resolve until after you press ``, their arguments tab complete as paths. +You can change this default behavior by overriding `Cmd.macro_arg_complete()` to implement custom +tab completion for macro arguments. For more details run: `help macro create` diff --git a/docs/features/startup_commands.md b/docs/features/startup_commands.md index b695c43dd..1bd563abc 100644 --- a/docs/features/startup_commands.md +++ b/docs/features/startup_commands.md @@ -16,7 +16,7 @@ program. `cmd2` interprets each argument as a separate command, so you should en in quotation marks if it is more than a one-word command. You can use either single or double quotes for this purpose. - $ python examples/example.py "say hello" "say Gracie" quit + $ python examples/transcript_example.py "say hello" "say Gracie" quit hello Gracie @@ -47,8 +47,8 @@ class StartupApp(cmd2.Cmd): ``` This text file should contain a [Command Script](./scripting.md#command-scripts). See the -[AliasStartup](https://github.com/python-cmd2/cmd2/blob/main/examples/alias_startup.py) example for -a demonstration. +[initialization](https://github.com/python-cmd2/cmd2/blob/main/examples/initialization.py) example +for a demonstration. You can silence a startup script's output by setting `silence_startup_script` to True: diff --git a/docs/features/table_creation.md b/docs/features/table_creation.md index a41300a5a..cd0a7278d 100644 --- a/docs/features/table_creation.md +++ b/docs/features/table_creation.md @@ -1,33 +1,9 @@ # Table Creation -`cmd2` provides a table creation class called `cmd2.table_creator.TableCreator`. This class handles -ANSI style sequences and characters with display widths greater than 1 when performing width -calculations. It was designed with the ability to build tables one row at a time. This helps when -you have large data sets that you don't want to hold in memory or when you receive portions of the -data set incrementally. +As of version 3, `cmd2` no longer includes code for table creation. -`TableCreator` has one public method: `cmd2.table_creator.TableCreator.generate_row()`. +This is because `cmd2` now has a dependency on [rich](https://github.com/Textualize/rich) which has +excellent support for this feature. -This function and the `cmd2.table_creator.Column` class provide all features needed to build tables -with headers, borders, colors, horizontal and vertical alignment, and wrapped text. However, it's -generally easier to inherit from this class and implement a more granular API rather than use -`TableCreator` directly. - -The following table classes build upon `TableCreator` and are provided in the -[cmd2.table_creater](../api/table_creator.md) module. They can be used as is or as examples for how -to build your own table classes. - -`cmd2.table_creator.SimpleTable` - Implementation of TableCreator which generates a borderless table -with an optional divider row after the header. This class can be used to create the whole table at -once or one row at a time. - -`cmd2.table_creator.BorderedTable` - Implementation of TableCreator which generates a table with -borders around the table and between rows. Borders between columns can also be toggled. This class -can be used to create the whole table at once or one row at a time. - -`cmd2.table_creator.AlternatingTable` - Implementation of BorderedTable which uses background colors -to distinguish between rows instead of row border lines. This class can be used to create the whole -table at once or one row at a time. - -See the [table_creation](https://github.com/python-cmd2/cmd2/blob/main/examples/table_creation.py) -example to see these classes in use +Please see rich's docummentation on [Tables](https://rich.readthedocs.io/en/latest/tables.html) for +more information. diff --git a/docs/features/transcripts.md b/docs/features/transcripts.md index 037fc5dda..1368b13e3 100644 --- a/docs/features/transcripts.md +++ b/docs/features/transcripts.md @@ -40,7 +40,7 @@ testing as your `cmd2` application changes. ## Creating Manually -Here's a transcript created from `python examples/example.py`: +Here's a transcript created from `python examples/transcript_example.py`: ```text (Cmd) say -r 3 Goodnight, Gracie @@ -155,7 +155,7 @@ Once you have created a transcript, it's easy to have your application play it b output. From within the `examples/` directory: ```text -$ python example.py --test transcript_regex.txt +$ python transcript_example.py --test transcript_regex.txt . ---------------------------------------------------------------------- Ran 1 test in 0.013s diff --git a/docs/index.md b/docs/index.md index bca8cc526..cbbdb7dfb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -25,13 +25,13 @@ app = App() app.cmdloop() ``` -## Getting Started + {% include-markdown "./overview/index.md" %} -## Migrating from cmd + {% include-markdown "./migrating/index.md" diff --git a/docs/migrating/next_steps.md b/docs/migrating/next_steps.md index 7d56e2f4b..892e05c78 100644 --- a/docs/migrating/next_steps.md +++ b/docs/migrating/next_steps.md @@ -41,5 +41,5 @@ to `cmd2.Cmd.poutput`, `cmd2.Cmd.perror`, and `cmd2.Cmd.pfeedback`. These method of the built in [Settings](../features/settings.md) to allow the user to view or suppress feedback (i.e. progress or status output). They also properly handle ansi colored output according to user preference. Speaking of colored output, you can use any color library you want, or use the included -`cmd2.ansi.style` function. These and other related topics are covered in +`cmd2.string_utils.stylize` function. These and other related topics are covered in [Generating Output](../features/generating_output.md). diff --git a/docs/overview/index.md b/docs/overview/index.md index 8038b9c1a..a8cb8ee21 100644 --- a/docs/overview/index.md +++ b/docs/overview/index.md @@ -11,8 +11,8 @@ if this library is a good fit for your needs. - [Installation Instructions](installation.md) - how to install `cmd2` and associated optional dependencies -- [First Application](../examples/first_app.md) - a sample application showing 8 key features of - `cmd2` +- [Getting Started Application](../examples/getting_started.md) - a sample application showing many + key features of `cmd2` - [Integrate cmd2 Into Your Project](integrating.md) - adding `cmd2` to your project - [Alternatives](alternatives.md) - other python packages that might meet your needs - [Resources](resources.md) - related links and other materials diff --git a/docs/overview/installation.md b/docs/overview/installation.md index 0f94f06b8..e3c12d60d 100644 --- a/docs/overview/installation.md +++ b/docs/overview/installation.md @@ -1,6 +1,6 @@ # Installation Instructions -`cmd2` works on Linux, macOS, and Windows. It requires Python 3.9 or higher, +`cmd2` works on Linux, macOS, and Windows. It requires Python 10 or higher, [pip](https://pypi.org/project/pip), and [setuptools](https://pypi.org/project/setuptools). If you've got all that, then you can just: @@ -18,7 +18,7 @@ $ pip install cmd2 ## Prerequisites -If you have Python 3 >=3.9 installed from [python.org](https://www.python.org), you will already +If you have Python 3 >=3.10 installed from [python.org](https://www.python.org), you will already have [pip](https://pypi.org/project/pip) and [setuptools](https://pypi.org/project/setuptools), but may need to upgrade to the latest versions: diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index f680db57c..000000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -mkdocs-include-markdown-plugin -mkdocs-macros-plugin -mkdocs-material -pyperclip -setuptools -setuptools-scm -wcwidth diff --git a/examples/README.md b/examples/README.md index e8d5cf514..c46968cb0 100644 --- a/examples/README.md +++ b/examples/README.md @@ -11,34 +11,27 @@ application, if you are looking for that then see Here is the list of examples in alphabetical order by filename along with a brief description of each: -- [alias_startup.py](https://github.com/python-cmd2/cmd2/blob/main/examples/alias_startup.py) - - Demonstrates how to add custom command aliases and how to run an initialization script at - startup -- [arg_decorators.py](https://github.com/python-cmd2/cmd2/blob/main/examples/arg_decorators.py) - - Demonstrates how to use the `cmd2.with_argparser` decorator to specify command arguments using - [argparse](https://docs.python.org/3/library/argparse.html) -- [arg_print.py](https://github.com/python-cmd2/cmd2/blob/main/examples/arg_print.py) - - Demonstrates how arguments and options get parsed and passed to commands and shows how - shortcuts work - [argparse_completion.py](https://github.com/python-cmd2/cmd2/blob/main/examples/argparse_completion.py) - Shows how to integrate tab-completion with argparse-based commands +- [argparse_example.py](https://github.com/python-cmd2/cmd2/blob/main/examples/argparse_example.py) + - Comprehensive example demonstrating various aspects of using + [argparse](https://docs.python.org/3/library/argparse.html) for command argument processing + via the `cmd2.with_argparser` decorator - [async_printing.py](https://github.com/python-cmd2/cmd2/blob/main/examples/async_printing.py) - Shows how to asynchronously print alerts, update the prompt in realtime, and change the window title -- [basic.py](https://github.com/python-cmd2/cmd2/blob/main/examples/basic.py) - - Shows how to add a command, add help for it, and create persistent command history for your - application - [basic_completion.py](https://github.com/python-cmd2/cmd2/blob/main/examples/basic_completion.py) - Show how to enable custom tab completion by assigning a completer function to `do_*` commands - [cmd2_as_argument.py](https://github.com/python-cmd2/cmd2/blob/main/examples/cmd_as_argument.py) - Demonstrates how to accept and parse command-line arguments when invoking a cmd2 application -- [colors.py](https://github.com/python-cmd2/cmd2/blob/main/examples/colors.py) - - Show various ways of using colorized output within a cmd2 application +- [color.py](https://github.com/python-cmd2/cmd2/blob/main/examples/color.py) + - Show the numerous colors available to use in your cmd2 applications +- [command_sets.py](https://github.com/python-cmd2/cmd2/blob/main/examples/command_sets.py) + - Example that demonstrates the `CommandSet` features for modularizing commands and demonstrates + all main capabilities including basic CommandSets, dynamic loading an unloading, using + subcommands, etc. - [custom_parser.py](https://github.com/python-cmd2/cmd2/blob/main/examples/custom_parser.py) - - Demonstrates how to create your own customer `Cmd2ArgumentParser`; used by the - `override_parser.py` example -- [decorator_example.py](https://github.com/python-cmd2/cmd2/blob/main/examples/decorator_example.py) - - Shows how to use cmd2's various argparse decorators to processes command-line arguments + - Demonstrates how to create your own custom `Cmd2ArgumentParser` - [default_categories.py](https://github.com/python-cmd2/cmd2/blob/main/examples/default_categories.py) - Demonstrates usage of `@with_default_category` decorator to group and categorize commands and `CommandSet` use @@ -50,13 +43,10 @@ each: - [event_loops.py](https://github.com/python-cmd2/cmd2/blob/main/examples/event_loops.py) - Shows how to integrate a `cmd2` application with an external event loop which isn't managed by `cmd2` -- [example.py](https://github.com/python-cmd2/cmd2/blob/main/examples/example.py) - - This example is intended to demonstrate `cmd2's` build-in transcript testing capability - [exit_code.py](https://github.com/python-cmd2/cmd2/blob/main/examples/exit_code.py) - Show how to emit a non-zero exit code from your `cmd2` application when it exits -- [first_app.py](https://github.com/python-cmd2/cmd2/blob/main/examples/first_app.py) - - Short application that demonstrates 8 key features: Settings, Commands, Argument Parsing, - Generating Output, Help, Shortcuts, Multiple Commands, and History +- [getting_started.py](https://github.com/python-cmd2/cmd2/blob/main/examples/getting_started.py) + - Short application that demonstrates many key features of cmd2 - [hello_cmd2.py](https://github.com/python-cmd2/cmd2/blob/main/examples/hello_cmd2.py) - Completely bare-bones `cmd2` application suitable for rapid testing and debugging of `cmd2` itself @@ -65,28 +55,15 @@ each: command - [hooks.py](https://github.com/python-cmd2/cmd2/blob/main/examples/hooks.py) - Shows how to use various `cmd2` application lifecycle hooks -- [initialization.py](https://github.com/python-cmd2/cmd2/blob/main/examples/initialization.py) - - Shows how to colorize output, use multiline command, add persistent history, and more - [migrating.py](https://github.com/python-cmd2/cmd2/blob/main/examples/migrating.py) - A simple `cmd` application that you can migrate to `cmd2` by changing one line -- [modular_commands_basic.py](https://github.com/python-cmd2/cmd2/blob/main/examples/modular_commands_basic.py) - - Demonstrates based `CommandSet` usage -- [modular_commands_dynamic.py](https://github.com/python-cmd2/cmd2/blob/main/examples/modular_commands_dynamic.py) - - Demonstrates dynamic `CommandSet` loading and unloading -- [modular_commands_main.py](https://github.com/python-cmd2/cmd2/blob/main/examples/modular_commands_main.py) +- [modular_commands.py](https://github.com/python-cmd2/cmd2/blob/main/examples/modular_commands.py) - Complex example demonstrating a variety of methods to load `CommandSets` using a mix of command decorators -- [modular_subcommands.py](https://github.com/python-cmd2/cmd2/blob/main/examples/modular_subcommands.py) - - Shows how to dynamically add and remove subcommands at runtime using `CommandSets` -- [override-parser.py](https://github.com/python-cmd2/cmd2/blob/main/examples/override_parser.py) - - Shows how to override cmd2's default `Cmd2ArgumentParser` with your own customer parser class - [paged_output.py](https://github.com/python-cmd2/cmd2/blob/main/examples/paged_output.py) - Shows how to use output pagination within `cmd2` apps via the `ppaged` method - [persistent_history.py](https://github.com/python-cmd2/cmd2/blob/main/examples/persistent_history.py) - Shows how to enable persistent history in your `cmd2` application -- [pirate.py](https://github.com/python-cmd2/cmd2/blob/main/examples/pirate.py) - - Demonstrates many features including colorized output, multiline commands, shorcuts, - defaulting to shell, etc. - [pretty_print.py](https://github.com/python-cmd2/cmd2/blob/main/examples/pretty_print.py) - Demonstrates use of cmd2.Cmd.ppretty() for pretty-printing arbitrary Python data structures like dictionaries. @@ -102,13 +79,15 @@ each: - [remove_settable.py](https://github.com/python-cmd2/cmd2/blob/main/examples/remove_settable.py) - Shows how to remove any of the built-in cmd2 `Settables` you do not want in your cmd2 application -- [subcommands.py](https://github.com/python-cmd2/cmd2/blob/main/examples/subcommands.py) - - Shows how to use `argparse` to easily support sub-commands within your cmd2 commands -- [table_creation.py](https://github.com/python-cmd2/cmd2/blob/main/examples/table_creation.py) - - Contains various examples of using cmd2's table creation capabilities +- [rich_tables.py](https://github.com/python-cmd2/cmd2/blob/main/examples/rich_tables.py) + - Example of using Rich Tables within a cmd2 application for displaying tabular data +- [rich_theme.py](https://github.com/python-cmd2/cmd2/blob/main/examples/rich_theme.py) + - Demonstrates how to create a custom theme for a cmd2 application - [tmux_launch.sh](https://github.com/python-cmd2/cmd2/blob/main/examples/tmux_launch.sh) - Shell script that launches two applications using tmux in different windows/tabs - [tmux_split.sh](https://github.com/python-cmd2/cmd2/blob/main/examples/tmux_split.sh) - Shell script that launches two applications using tmux in a split pane view +- [transcript_example.py](https://github.com/python-cmd2/cmd2/blob/main/examples/transcript_example.py) + - This example is intended to demonstrate `cmd2's` build-in transcript testing capability - [unicode_commands.py](https://github.com/python-cmd2/cmd2/blob/main/examples/unicode_commands.py) - Shows that cmd2 supports unicode everywhere, including within command names diff --git a/examples/alias_startup.py b/examples/alias_startup.py deleted file mode 100755 index f6e401a0c..000000000 --- a/examples/alias_startup.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python -"""A simple example demonstrating the following: -1) How to add custom command aliases using the alias command -2) How to run an initialization script at startup. -""" - -import os - -import cmd2 - - -class AliasAndStartup(cmd2.Cmd): - """Example cmd2 application where we create commands that just print the arguments they are called with.""" - - def __init__(self) -> None: - alias_script = os.path.join(os.path.dirname(__file__), '.cmd2rc') - super().__init__(startup_script=alias_script) - - def do_nothing(self, args) -> None: - """This command does nothing and produces no output.""" - - -if __name__ == '__main__': - import sys - - app = AliasAndStartup() - sys.exit(app.cmdloop()) diff --git a/examples/arg_decorators.py b/examples/arg_decorators.py deleted file mode 100755 index 5fe262d4c..000000000 --- a/examples/arg_decorators.py +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env python3 -"""An example demonstrating how use one of cmd2's argument parsing decorators.""" - -import argparse -import os - -import cmd2 - - -class ArgparsingApp(cmd2.Cmd): - def __init__(self) -> None: - super().__init__(include_ipy=True) - self.intro = 'cmd2 has awesome decorators to make it easy to use Argparse to parse command arguments' - - # do_fsize parser - fsize_parser = cmd2.Cmd2ArgumentParser(description='Obtain the size of a file') - fsize_parser.add_argument('-c', '--comma', action='store_true', help='add comma for thousands separator') - fsize_parser.add_argument('-u', '--unit', choices=['MB', 'KB'], help='unit to display size in') - fsize_parser.add_argument('file_path', help='path of file', completer=cmd2.Cmd.path_complete) - - @cmd2.with_argparser(fsize_parser) - def do_fsize(self, args: argparse.Namespace) -> None: - """Obtain the size of a file.""" - expanded_path = os.path.expanduser(args.file_path) - - try: - size = os.path.getsize(expanded_path) - except OSError as ex: - self.perror(f"Error retrieving size: {ex}") - return - - if args.unit == 'KB': - size /= 1024 - elif args.unit == 'MB': - size /= 1024 * 1024 - else: - args.unit = 'bytes' - size = round(size, 2) - - if args.comma: - size = f'{size:,}' - self.poutput(f'{size} {args.unit}') - - # do_pow parser - pow_parser = cmd2.Cmd2ArgumentParser() - pow_parser.add_argument('base', type=int) - pow_parser.add_argument('exponent', type=int, choices=range(-5, 6)) - - @cmd2.with_argparser(pow_parser) - def do_pow(self, args: argparse.Namespace) -> None: - """Raise an integer to a small integer exponent, either positive or negative. - - :param args: argparse arguments - """ - self.poutput(f'{args.base} ** {args.exponent} == {args.base**args.exponent}') - - -if __name__ == '__main__': - app = ArgparsingApp() - app.cmdloop() diff --git a/examples/arg_print.py b/examples/arg_print.py deleted file mode 100755 index 506e92250..000000000 --- a/examples/arg_print.py +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env python -"""A simple example demonstrating the following: - 1) How arguments and options get parsed and passed to commands - 2) How to change what syntax gets parsed as a comment and stripped from the arguments. - -This is intended to serve as a live demonstration so that developers can -experiment with and understand how command and argument parsing work. - -It also serves as an example of how to create shortcuts. -""" - -import cmd2 - - -class ArgumentAndOptionPrinter(cmd2.Cmd): - """Example cmd2 application where we create commands that just print the arguments they are called with.""" - - def __init__(self) -> None: - # Create command shortcuts which are typically 1 character abbreviations which can be used in place of a command - shortcuts = dict(cmd2.DEFAULT_SHORTCUTS) - shortcuts.update({'$': 'aprint', '%': 'oprint'}) - super().__init__(shortcuts=shortcuts) - - def do_aprint(self, statement) -> None: - """Print the argument string this basic command is called with.""" - self.poutput(f'aprint was called with argument: {statement!r}') - self.poutput(f'statement.raw = {statement.raw!r}') - self.poutput(f'statement.argv = {statement.argv!r}') - self.poutput(f'statement.command = {statement.command!r}') - - @cmd2.with_argument_list - def do_lprint(self, arglist) -> None: - """Print the argument list this basic command is called with.""" - self.poutput(f'lprint was called with the following list of arguments: {arglist!r}') - - @cmd2.with_argument_list(preserve_quotes=True) - def do_rprint(self, arglist) -> None: - """Print the argument list this basic command is called with (with quotes preserved).""" - self.poutput(f'rprint was called with the following list of arguments: {arglist!r}') - - oprint_parser = cmd2.Cmd2ArgumentParser() - oprint_parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay') - oprint_parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE') - oprint_parser.add_argument('-r', '--repeat', type=int, help='output [n] times') - oprint_parser.add_argument('words', nargs='+', help='words to print') - - @cmd2.with_argparser(oprint_parser) - def do_oprint(self, args) -> None: - """Print the options and argument list this options command was called with.""" - self.poutput(f'oprint was called with the following\n\toptions: {args!r}') - - pprint_parser = cmd2.Cmd2ArgumentParser() - pprint_parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay') - pprint_parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE') - pprint_parser.add_argument('-r', '--repeat', type=int, help='output [n] times') - - @cmd2.with_argparser(pprint_parser, with_unknown_args=True) - def do_pprint(self, args, unknown) -> None: - """Print the options and argument list this options command was called with.""" - self.poutput(f'oprint was called with the following\n\toptions: {args!r}\n\targuments: {unknown}') - - -if __name__ == '__main__': - import sys - - app = ArgumentAndOptionPrinter() - sys.exit(app.cmdloop()) diff --git a/examples/argparse_completion.py b/examples/argparse_completion.py index 43cad367b..8d2c3dca1 100755 --- a/examples/argparse_completion.py +++ b/examples/argparse_completion.py @@ -3,12 +3,18 @@ import argparse +import rich.box +from rich.style import Style +from rich.table import Table +from rich.text import Text + from cmd2 import ( Cmd, Cmd2ArgumentParser, + Cmd2Style, + Color, CompletionError, CompletionItem, - ansi, with_argparser, ) @@ -17,8 +23,8 @@ class ArgparseCompletion(Cmd): - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) + def __init__(self) -> None: + super().__init__(include_ipy=True) self.sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball'] def choices_provider(self) -> list[str]: @@ -38,10 +44,28 @@ def choices_completion_error(self) -> list[str]: def choices_completion_item(self) -> list[CompletionItem]: """Return CompletionItem instead of strings. These give more context to what's being tab completed.""" - fancy_item = "These things can\ncontain newlines and\n" - fancy_item += ansi.style("styled text!!", fg=ansi.Fg.LIGHT_YELLOW, underline=True) - items = {1: "My item", 2: "Another item", 3: "Yet another item", 4: fancy_item} - return [CompletionItem(item_id, description) for item_id, description in items.items()] + fancy_item = Text.assemble( + "These things can\ncontain newlines and\n", + Text("styled text!!", style=Style(color=Color.BRIGHT_YELLOW, underline=True)), + ) + + table_item = Table( + "Left Column", + "Right Column", + box=rich.box.ROUNDED, + border_style=Cmd2Style.TABLE_BORDER, + ) + table_item.add_row("Yes, it's true.", "CompletionItems can") + table_item.add_row("even display description", "data in tables!") + + items = { + 1: "My item", + 2: "Another item", + 3: "Yet another item", + 4: fancy_item, + 5: table_item, + } + return [CompletionItem(item_id, [description]) for item_id, description in items.items()] def choices_arg_tokens(self, arg_tokens: dict[str, list[str]]) -> list[str]: """If a choices or completer function/method takes a value called arg_tokens, then it will be @@ -86,7 +110,7 @@ def choices_arg_tokens(self, arg_tokens: dict[str, list[str]]) -> list[str]: '--completion_item', choices_provider=choices_completion_item, metavar="ITEM_ID", - descriptive_header="Description", + descriptive_headers=["Description"], help="demonstrate use of CompletionItems", ) diff --git a/examples/argparse_example.py b/examples/argparse_example.py new file mode 100755 index 000000000..564f4be92 --- /dev/null +++ b/examples/argparse_example.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""A comprehensive example demonstrating various aspects of using `argparse` for command argument processing. + +Demonstrates basic usage of the `cmd2.with_argparser` decorator for passing a `cmd2.Cmd2ArgumentParser` to a `do_*` command +method. The `fsize` and `pow` commands demonstrate various different types of arguments, actions, choices, and completers that +can be used. + +The `print_args` and `print_unknown` commands display how argparse arguments are passed to commands in the cases that unknown +arguments are not captured and are captured, respectively. + +The `base` and `alternate` commands show an easy way for a single command to have many subcommands, each of which take +different arguments and provides separate contextual help. + +Lastly, this example shows how you can also use `argparse` to parse command-line arguments when launching a cmd2 application. +""" + +import argparse +import os + +import cmd2 +from cmd2.string_utils import stylize + +# Command categories +ARGPARSE_USAGE = 'Argparse Basic Usage' +ARGPARSE_PRINTING = 'Argparse Printing' +ARGPARSE_SUBCOMMANDS = 'Argparse Subcommands' + + +class ArgparsingApp(cmd2.Cmd): + def __init__(self, color: str) -> None: + """Cmd2 application for demonstrating the use of argparse for command argument parsing.""" + super().__init__(include_ipy=True) + self.intro = stylize( + 'cmd2 has awesome decorators to make it easy to use Argparse to parse command arguments', style=color + ) + + ## ------ Basic examples of using argparse for command argument parsing ----- + + # do_fsize parser + fsize_parser = cmd2.Cmd2ArgumentParser(description='Obtain the size of a file') + fsize_parser.add_argument('-c', '--comma', action='store_true', help='add comma for thousands separator') + fsize_parser.add_argument('-u', '--unit', choices=['MB', 'KB'], help='unit to display size in') + fsize_parser.add_argument('file_path', help='path of file', completer=cmd2.Cmd.path_complete) + + @cmd2.with_argparser(fsize_parser) + @cmd2.with_category(ARGPARSE_USAGE) + def do_fsize(self, args: argparse.Namespace) -> None: + """Obtain the size of a file.""" + expanded_path = os.path.expanduser(args.file_path) + + try: + size = os.path.getsize(expanded_path) + except OSError as ex: + self.perror(f"Error retrieving size: {ex}") + return + + if args.unit == 'KB': + size //= 1024 + elif args.unit == 'MB': + size //= 1024 * 1024 + else: + args.unit = 'bytes' + size = round(size, 2) + + size_str = f'{size:,}' if args.comma else f'{size}' + self.poutput(f'{size_str} {args.unit}') + + # do_pow parser + pow_parser = cmd2.Cmd2ArgumentParser() + pow_parser.add_argument('base', type=int) + pow_parser.add_argument('exponent', type=int, choices=range(-5, 6)) + + @cmd2.with_argparser(pow_parser) + @cmd2.with_category(ARGPARSE_USAGE) + def do_pow(self, args: argparse.Namespace) -> None: + """Raise an integer to a small integer exponent, either positive or negative. + + :param args: argparse arguments + """ + self.poutput(f'{args.base} ** {args.exponent} == {args.base**args.exponent}') + + ## ------ Examples displaying how argparse arguments are passed to commands by printing them out ----- + + argprint_parser = cmd2.Cmd2ArgumentParser() + argprint_parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay') + argprint_parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE') + argprint_parser.add_argument('-r', '--repeat', type=int, help='output [n] times') + argprint_parser.add_argument('words', nargs='+', help='words to print') + + @cmd2.with_argparser(argprint_parser) + @cmd2.with_category(ARGPARSE_PRINTING) + def do_print_args(self, args: argparse.Namespace) -> None: + """Print the arpgarse argument list this command was called with.""" + self.poutput(f'print_args was called with the following\n\targuments: {args!r}') + + unknownprint_parser = cmd2.Cmd2ArgumentParser() + unknownprint_parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay') + unknownprint_parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE') + unknownprint_parser.add_argument('-r', '--repeat', type=int, help='output [n] times') + + @cmd2.with_argparser(unknownprint_parser, with_unknown_args=True) + @cmd2.with_category(ARGPARSE_PRINTING) + def do_print_unknown(self, args: argparse.Namespace, unknown: list[str]) -> None: + """Print the arpgarse argument list this command was called with, including unknown arguments.""" + self.poutput(f'print_unknown was called with the following arguments\n\tknown: {args!r}\n\tunknown: {unknown}') + + ## ------ Examples demonstrating how to use argparse subcommands ----- + + # create the top-level parser for the base command + calculate_parser = cmd2.Cmd2ArgumentParser(description="Perform simple mathematical calculations.") + calculate_subparsers = calculate_parser.add_subparsers(title='operation', help='Available operations', required=True) + + # create the parser for the "add" subcommand + add_description = "Add two numbers" + add_parser = cmd2.Cmd2ArgumentParser("add", description=add_description) + add_parser.add_argument('num1', type=int, help='The first number') + add_parser.add_argument('num2', type=int, help='The second number') + + # create the parser for the "add" subcommand + subtract_description = "Subtract two numbers" + subtract_parser = cmd2.Cmd2ArgumentParser("subtract", description=subtract_description) + subtract_parser.add_argument('num1', type=int, help='The first number') + subtract_parser.add_argument('num2', type=int, help='The second number') + + # subcommand functions for the calculate command + @cmd2.as_subcommand_to('calculate', 'add', add_parser, help=add_description.lower()) + def add(self, args: argparse.Namespace) -> None: + """add subcommand of calculate command.""" + result = args.num1 + args.num2 + self.poutput(f"{args.num1} + {args.num2} = {result}") + + @cmd2.as_subcommand_to('calculate', 'subtract', subtract_parser, help=subtract_description.lower()) + def subtract(self, args: argparse.Namespace) -> None: + """subtract subcommand of calculate command.""" + result = args.num1 - args.num2 + self.poutput(f"{args.num1} - {args.num2} = {result}") + + @cmd2.with_argparser(calculate_parser) + @cmd2.with_category(ARGPARSE_SUBCOMMANDS) + def do_calculate(self, args: argparse.Namespace) -> None: + """Calculate a simple mathematical operation on two integers.""" + handler = args.cmd2_handler.get() + handler(args) + + +if __name__ == '__main__': + import sys + + from cmd2.colors import Color + + # You can do your custom Argparse parsing here to meet your application's needs + parser = cmd2.Cmd2ArgumentParser(description='Process the arguments however you like.') + + # Add an argument which we will pass to the app to change some behavior + parser.add_argument( + '-c', + '--color', + choices=[Color.RED, Color.ORANGE1, Color.YELLOW, Color.GREEN, Color.BLUE, Color.PURPLE, Color.VIOLET, Color.WHITE], + help='Color of intro text', + ) + + # Parse the arguments + args, unknown_args = parser.parse_known_args() + + color = Color.WHITE + if args.color: + color = args.color + + # Perform surgery on sys.argv to remove the arguments which have already been processed by argparse + sys.argv = sys.argv[:1] + unknown_args + + # Instantiate your cmd2 application + app = ArgparsingApp(color) + + # And run your cmd2 application + sys.exit(app.cmdloop()) diff --git a/examples/async_printing.py b/examples/async_printing.py index 5655a62ff..f1eac85d4 100755 --- a/examples/async_printing.py +++ b/examples/async_printing.py @@ -9,8 +9,8 @@ import cmd2 from cmd2 import ( - Fg, - style, + Color, + stylize, ) ALERTS = [ @@ -139,20 +139,20 @@ def _generate_colored_prompt(self) -> str: """ rand_num = random.randint(1, 20) - status_color = Fg.RESET + status_color = Color.DEFAULT if rand_num == 1: - status_color = Fg.LIGHT_RED + status_color = Color.BRIGHT_RED elif rand_num == 2: - status_color = Fg.LIGHT_YELLOW + status_color = Color.BRIGHT_YELLOW elif rand_num == 3: - status_color = Fg.CYAN + status_color = Color.CYAN elif rand_num == 4: - status_color = Fg.LIGHT_GREEN + status_color = Color.BRIGHT_GREEN elif rand_num == 5: - status_color = Fg.LIGHT_BLUE + status_color = Color.BRIGHT_BLUE - return style(self.visible_prompt, fg=status_color) + return stylize(self.visible_prompt, style=status_color) def _alerter_thread_func(self) -> None: """Prints alerts and updates the prompt any time the prompt is showing.""" diff --git a/examples/basic.py b/examples/basic.py deleted file mode 100755 index 20ebe20a5..000000000 --- a/examples/basic.py +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env python3 -"""A simple example demonstrating the following: -1) How to add a command -2) How to add help for that command -3) Persistent history -4) How to run an initialization script at startup -5) How to add custom command aliases using the alias command -6) Shell-like capabilities. -""" - -import cmd2 -from cmd2 import ( - Bg, - Fg, - style, -) - - -class BasicApp(cmd2.Cmd): - CUSTOM_CATEGORY = 'My Custom Commands' - - def __init__(self) -> None: - super().__init__( - multiline_commands=['echo'], - persistent_history_file='cmd2_history.dat', - startup_script='scripts/startup.txt', - include_ipy=True, - ) - - self.intro = style('Welcome to PyOhio 2019 and cmd2!', fg=Fg.RED, bg=Bg.WHITE, bold=True) + ' 😀' - - # Allow access to your application in py and ipy via self - self.self_in_py = True - - # Set the default category name - self.default_category = 'cmd2 Built-in Commands' - - @cmd2.with_category(CUSTOM_CATEGORY) - def do_intro(self, _) -> None: - """Display the intro banner.""" - self.poutput(self.intro) - - @cmd2.with_category(CUSTOM_CATEGORY) - def do_echo(self, arg) -> None: - """Example of a multiline command.""" - self.poutput(arg) - - -if __name__ == '__main__': - app = BasicApp() - app.cmdloop() diff --git a/examples/cmd_as_argument.py b/examples/cmd_as_argument.py index dd265074c..b9db4acd5 100755 --- a/examples/cmd_as_argument.py +++ b/examples/cmd_as_argument.py @@ -1,7 +1,7 @@ #!/usr/bin/env python """A sample application for cmd2. -This example is very similar to example.py, but had additional +This example is very similar to transcript_example.py, but had additional code in main() that shows how to accept a command from the command line at invocation: diff --git a/examples/color.py b/examples/color.py new file mode 100755 index 000000000..e6e2cf26b --- /dev/null +++ b/examples/color.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python +"""A sample application for cmd2. Demonstrating colors available in the cmd2.colors.Color enum. + +Execute the taste_the_rainbow command to see the colors available. +""" + +import argparse + +from rich.style import Style +from rich.text import Text + +import cmd2 +from cmd2 import Color + + +class CmdLineApp(cmd2.Cmd): + """Example cmd2 application demonstrating colorized output.""" + + def __init__(self) -> None: + # Set include_ipy to True to enable the "ipy" command which runs an interactive IPython shell + super().__init__(include_ipy=True) + self.intro = 'Run the taste_the_rainbow command to see all of the colors available to you in cmd2.' + + rainbow_parser = cmd2.Cmd2ArgumentParser() + rainbow_parser.add_argument('-b', '--background', action='store_true', help='show background colors as well') + rainbow_parser.add_argument('-p', '--paged', action='store_true', help='display output using a pager') + + @cmd2.with_argparser(rainbow_parser) + def do_taste_the_rainbow(self, args: argparse.Namespace) -> None: + """Show all of the colors available within cmd2's Color StrEnum class.""" + + def create_style(color: Color) -> Style: + """Create a foreground or background color Style.""" + if args.background: + return Style(bgcolor=color) + return Style(color=color) + + styled_names = [Text(color.name, style=create_style(color)) for color in Color] + output = Text("\n").join(styled_names) + + if args.paged: + self.ppaged(output) + else: + self.poutput(output) + + +if __name__ == '__main__': + import sys + + c = CmdLineApp() + sys.exit(c.cmdloop()) diff --git a/examples/colors.py b/examples/colors.py deleted file mode 100755 index fad3c9586..000000000 --- a/examples/colors.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env python -"""A sample application for cmd2. Demonstrating colorized output. - -Experiment with the command line options on the `speak` command to see how -different output colors ca - -The allow_style setting has three possible values: - -Never - poutput(), pfeedback(), and ppaged() strip all ANSI style sequences - which instruct the terminal to colorize output - -Terminal - (the default value) poutput(), pfeedback(), and ppaged() do not strip any - ANSI style sequences when the output is a terminal, but if the output is - a pipe or a file the style sequences are stripped. If you want colorized - output, add ANSI style sequences using cmd2's internal ansi module. - -Always - poutput(), pfeedback(), and ppaged() never strip ANSI style sequences, - regardless of the output destination -""" - -import cmd2 -from cmd2 import ( - Bg, - Fg, - ansi, -) - -fg_choices = [c.name.lower() for c in Fg] -bg_choices = [c.name.lower() for c in Bg] - - -class CmdLineApp(cmd2.Cmd): - """Example cmd2 application demonstrating colorized output.""" - - def __init__(self) -> None: - # Set include_ipy to True to enable the "ipy" command which runs an interactive IPython shell - super().__init__(include_ipy=True) - - self.maxrepeats = 3 - # Make maxrepeats settable at runtime - self.add_settable(cmd2.Settable('maxrepeats', int, 'max repetitions for speak command', self)) - - # Should ANSI color output be allowed - self.allow_style = ansi.AllowStyle.TERMINAL - - 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('-f', '--fg', choices=fg_choices, help='foreground color to apply to output') - speak_parser.add_argument('-b', '--bg', choices=bg_choices, help='background color to apply to output') - speak_parser.add_argument('-l', '--bold', action='store_true', help='bold the output') - speak_parser.add_argument('-u', '--underline', action='store_true', help='underline the output') - speak_parser.add_argument('words', nargs='+', help='words to say') - - @cmd2.with_argparser(speak_parser) - def do_speak(self, args) -> None: - """Repeats what you tell me to.""" - words = [] - for word in args.words: - if args.piglatin: - word = f'{word[1:]}{word[0]}ay' - if args.shout: - word = word.upper() - words.append(word) - - repetitions = args.repeat or 1 - - fg_color = Fg[args.fg.upper()] if args.fg else None - bg_color = Bg[args.bg.upper()] if args.bg else None - output_str = ansi.style(' '.join(words), fg=fg_color, bg=bg_color, bold=args.bold, underline=args.underline) - - for _ in range(min(repetitions, self.maxrepeats)): - # .poutput handles newlines, and accommodates output redirection too - self.poutput(output_str) - - def do_timetravel(self, _) -> None: - """A command which always generates an error message, to demonstrate custom error colors.""" - self.perror('Mr. Fusion failed to start. Could not energize flux capacitor.') - - -if __name__ == '__main__': - import sys - - c = CmdLineApp() - sys.exit(c.cmdloop()) diff --git a/examples/command_sets.py b/examples/command_sets.py new file mode 100755 index 000000000..ed51c6f4b --- /dev/null +++ b/examples/command_sets.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Example revolving around the CommandSet feature for modularizing commands. + +It attempts to cover basic usage as well as more complex usage including dynamic loading and unloading of CommandSets, using +CommandSets to add subcommands, as well as how to categorize command in CommandSets. Here we have kept the implementation for +most commands trivial because the intent is to focus on the CommandSet feature set. + +The `AutoLoadCommandSet` is a basic command set which is loaded automatically at application startup and stays loaded until +application exit. Ths is the simplest case of simply modularizing command definitions to different classes and/or files. + +The `LoadableFruits` and `LoadableVegetables` CommandSets are dynamically loadable and un-loadable at runtime using the `load` +and `unload` commands. This demonstrates the ability to load and unload CommandSets based on application state. Each of these +also loads a subcommand of the `cut` command. +""" + +import argparse + +import cmd2 +from cmd2 import ( + CommandSet, + with_argparser, + with_category, + with_default_category, +) + +COMMANDSET_BASIC = "Basic CommandSet" +COMMANDSET_DYNAMIC = "Dynamic CommandSet" +COMMANDSET_LOAD_UNLOAD = "Loading and Unloading CommandSets" +COMMANDSET_SUBCOMMAND = "Subcommands with CommandSet" + + +@with_default_category(COMMANDSET_BASIC) +class AutoLoadCommandSet(CommandSet): + def __init__(self) -> None: + """CommandSet class for auto-loading commands at startup.""" + super().__init__() + + def do_hello(self, _: cmd2.Statement) -> None: + """Print hello.""" + self._cmd.poutput('Hello') + + def do_world(self, _: cmd2.Statement) -> None: + """Print World.""" + self._cmd.poutput('World') + + +@with_default_category(COMMANDSET_DYNAMIC) +class LoadableFruits(CommandSet): + def __init__(self) -> None: + """CommandSet class for dynamically loading commands related to fruits.""" + super().__init__() + + def do_apple(self, _: cmd2.Statement) -> None: + """Print Apple.""" + self._cmd.poutput('Apple') + + def do_banana(self, _: cmd2.Statement) -> None: + """Print Banana.""" + self._cmd.poutput('Banana') + + banana_description = "Cut a banana" + banana_parser = cmd2.Cmd2ArgumentParser(description=banana_description) + banana_parser.add_argument('direction', choices=['discs', 'lengthwise']) + + @cmd2.as_subcommand_to('cut', 'banana', banana_parser, help=banana_description.lower()) + def cut_banana(self, ns: argparse.Namespace) -> None: + """Cut banana.""" + self._cmd.poutput('cutting banana: ' + ns.direction) + + +@with_default_category(COMMANDSET_DYNAMIC) +class LoadableVegetables(CommandSet): + def __init__(self) -> None: + """CommandSet class for dynamically loading commands related to vegetables.""" + super().__init__() + + def do_arugula(self, _: cmd2.Statement) -> None: + "Print Arguula." + self._cmd.poutput('Arugula') + + def do_bokchoy(self, _: cmd2.Statement) -> None: + """Print Bok Choy.""" + self._cmd.poutput('Bok Choy') + + bokchoy_description = "Cut some bokchoy" + bokchoy_parser = cmd2.Cmd2ArgumentParser(description=bokchoy_description) + bokchoy_parser.add_argument('style', choices=['quartered', 'diced']) + + @cmd2.as_subcommand_to('cut', 'bokchoy', bokchoy_parser, help=bokchoy_description.lower()) + def cut_bokchoy(self, ns: argparse.Namespace) -> None: + """Cut bokchoy.""" + self._cmd.poutput('Bok Choy: ' + ns.style) + + +class CommandSetApp(cmd2.Cmd): + """CommandSets are automatically loaded. Nothing needs to be done.""" + + def __init__(self) -> None: + """Cmd2 application for demonstrating the CommandSet features.""" + # This prevents all CommandSets from auto-loading, which is necessary if you don't want some to load at startup + super().__init__(auto_load_commands=False) + + self.register_command_set(AutoLoadCommandSet()) + + # Store the dyanmic CommandSet classes for ease of loading and unloading + self._fruits = LoadableFruits() + self._vegetables = LoadableVegetables() + + self.intro = 'The CommandSet feature allows defining commands in multiple files and the dynamic load/unload at runtime' + + load_parser = cmd2.Cmd2ArgumentParser() + load_parser.add_argument('cmds', choices=['fruits', 'vegetables']) + + @with_argparser(load_parser) + @with_category(COMMANDSET_LOAD_UNLOAD) + def do_load(self, ns: argparse.Namespace) -> None: + """Load a CommandSet at runtime.""" + if ns.cmds == 'fruits': + try: + self.register_command_set(self._fruits) + self.poutput('Fruits loaded') + except ValueError: + self.poutput('Fruits already loaded') + + if ns.cmds == 'vegetables': + try: + self.register_command_set(self._vegetables) + self.poutput('Vegetables loaded') + except ValueError: + self.poutput('Vegetables already loaded') + + @with_argparser(load_parser) + @with_category(COMMANDSET_LOAD_UNLOAD) + def do_unload(self, ns: argparse.Namespace) -> None: + """Unload a CommandSet at runtime.""" + if ns.cmds == 'fruits': + self.unregister_command_set(self._fruits) + self.poutput('Fruits unloaded') + + if ns.cmds == 'vegetables': + self.unregister_command_set(self._vegetables) + self.poutput('Vegetables unloaded') + + cut_parser = cmd2.Cmd2ArgumentParser() + cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut') + + @with_argparser(cut_parser) + @with_category(COMMANDSET_SUBCOMMAND) + def do_cut(self, ns: argparse.Namespace) -> None: + """Intended to be used with dyanmically loaded subcommands specifically.""" + handler = ns.cmd2_handler.get() + if handler is not None: + handler(ns) + else: + # No subcommand was provided, so call help + self.poutput('This command does nothing without sub-parsers registered') + self.do_help('cut') + + +if __name__ == '__main__': + app = CommandSetApp() + app.cmdloop() diff --git a/examples/custom_parser.py b/examples/custom_parser.py index a79a65b85..70a279e8a 100644 --- a/examples/custom_parser.py +++ b/examples/custom_parser.py @@ -1,22 +1,29 @@ -"""Defines the CustomParser used with override_parser.py example.""" +""" +The standard parser used by cmd2 built-in commands is Cmd2ArgumentParser. +The following code shows how to override it with your own parser class. +""" import sys +from typing import NoReturn from cmd2 import ( Cmd2ArgumentParser, - ansi, + cmd2, set_default_argument_parser_type, + styles, + stylize, ) -# First define the parser +# Since built-in commands rely on customizations made in Cmd2ArgumentParser, +# your custom parser class should inherit from Cmd2ArgumentParser. class CustomParser(Cmd2ArgumentParser): - """Overrides error class.""" + """Overrides error method.""" - def __init__(self, *args, **kwargs) -> None: + def __init__(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def] super().__init__(*args, **kwargs) - def error(self, message: str) -> None: + def error(self, message: str) -> NoReturn: """Custom override that applies custom formatting to the error message.""" lines = message.split('\n') formatted_message = '' @@ -28,10 +35,21 @@ def error(self, message: str) -> None: self.print_usage(sys.stderr) - # Format errors with style_warning() - formatted_message = ansi.style_warning(formatted_message) + # Format errors with warning style + formatted_message = stylize( + formatted_message, + style=styles.WARNING, + ) self.exit(2, f'{formatted_message}\n\n') -# Now set the default parser for a cmd2 app -set_default_argument_parser_type(CustomParser) +if __name__ == '__main__': + import sys + + # Set the default parser type before instantiating app. + set_default_argument_parser_type(CustomParser) + + app = cmd2.Cmd(include_ipy=True, persistent_history_file='cmd2_history.dat') + app.self_in_py = True # Enable access to "self" within the py command + app.debug = True # Show traceback if/when an exception occurs + sys.exit(app.cmdloop()) diff --git a/examples/decorator_example.py b/examples/decorator_example.py deleted file mode 100755 index 736c729e7..000000000 --- a/examples/decorator_example.py +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env python -"""A sample application showing how to use cmd2's argparse decorators to -process command line arguments for your application. - -Thanks to cmd2's built-in transcript testing capability, it also -serves as a test suite when used with the exampleSession.txt transcript. - -Running `python decorator_example.py -t exampleSession.txt` will run -all the commands in the transcript against decorator_example.py, -verifying that the output produced matches the transcript. -""" - -import argparse - -import cmd2 - - -class CmdLineApp(cmd2.Cmd): - """Example cmd2 application.""" - - def __init__(self, ip_addr=None, port=None, transcript_files=None) -> None: - shortcuts = dict(cmd2.DEFAULT_SHORTCUTS) - shortcuts.update({'&': 'speak'}) - super().__init__(transcript_files=transcript_files, multiline_commands=['orate'], shortcuts=shortcuts) - - self.maxrepeats = 3 - # Make maxrepeats settable at runtime - self.add_settable(cmd2.Settable('maxrepeats', int, 'max repetitions for speak command', self)) - - # Example of args set from the command-line (but they aren't being used here) - self._ip = ip_addr - self._port = port - - # Setting this true makes it run a shell command if a cmd2/cmd command doesn't exist - # self.default_to_shell = True # noqa: ERA001 - - 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: argparse.Namespace) -> None: - """Repeats what you tell me to.""" - words = [] - for word in args.words: - if args.piglatin: - word = f'{word[1:]}{word[0]}ay' - if args.shout: - word = word.upper() - words.append(word) - repetitions = args.repeat or 1 - for _ in range(min(repetitions, self.maxrepeats)): - self.poutput(' '.join(words)) - - do_say = do_speak # now "say" is a synonym for "speak" - do_orate = do_speak # another synonym, but this one takes multi-line input - - tag_parser = cmd2.Cmd2ArgumentParser() - tag_parser.add_argument('tag', help='tag') - tag_parser.add_argument('content', nargs='+', help='content to surround with tag') - - @cmd2.with_argparser(tag_parser) - def do_tag(self, args: argparse.Namespace) -> None: - """Create an html tag.""" - # The Namespace always includes the Statement object created when parsing the command line - statement = args.cmd2_statement.get() - - self.poutput(f"The command line you ran was: {statement.command_and_args}") - self.poutput("It generated this tag:") - self.poutput('<{0}>{1}'.format(args.tag, ' '.join(args.content))) - - @cmd2.with_argument_list - def do_tagg(self, arglist: list[str]) -> None: - """Version of creating an html tag using arglist instead of argparser.""" - if len(arglist) >= 2: - tag = arglist[0] - content = arglist[1:] - self.poutput('<{0}>{1}'.format(tag, ' '.join(content))) - else: - self.perror("tagg requires at least 2 arguments") - - -if __name__ == '__main__': - import sys - - # You can do your custom Argparse parsing here to meet your application's needs - parser = cmd2.Cmd2ArgumentParser(description='Process the arguments however you like.') - - # Add a few arguments which aren't really used, but just to get the gist - parser.add_argument('-p', '--port', type=int, help='TCP port') - parser.add_argument('-i', '--ip', type=str, help='IPv4 address') - - # Add an argument which enables transcript testing - args, unknown_args = parser.parse_known_args() - - port = None - if args.port: - port = args.port - - ip_addr = None - if args.ip: - ip_addr = args.ip - - # Perform surgery on sys.argv to remove the arguments which have already been processed by argparse - sys.argv = sys.argv[:1] + unknown_args - - # Instantiate your cmd2 application - c = CmdLineApp() - - # And run your cmd2 application - sys.exit(c.cmdloop()) diff --git a/examples/first_app.py b/examples/first_app.py deleted file mode 100755 index c82768a37..000000000 --- a/examples/first_app.py +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env python -"""A simple application using cmd2 which demonstrates 8 key features: - -* Settings -* Commands -* Argument Parsing -* Generating Output -* Help -* Shortcuts -* Multiline Commands -* History -""" - -import cmd2 - - -class FirstApp(cmd2.Cmd): - """A simple cmd2 application.""" - - def __init__(self) -> None: - shortcuts = cmd2.DEFAULT_SHORTCUTS - shortcuts.update({'&': 'speak'}) - super().__init__(multiline_commands=['orate'], shortcuts=shortcuts) - - # Make maxrepeats settable at runtime - self.maxrepeats = 3 - 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') - - @cmd2.with_argparser(speak_parser) - def do_speak(self, args) -> None: - """Repeats what you tell me to.""" - words = [] - for word in args.words: - if args.piglatin: - word = f'{word[1:]}{word[0]}ay' - 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 - - -if __name__ == '__main__': - import sys - - c = FirstApp() - sys.exit(c.cmdloop()) diff --git a/examples/initialization.py b/examples/getting_started.py similarity index 51% rename from examples/initialization.py rename to examples/getting_started.py index 22de3ff20..43e9a904f 100755 --- a/examples/initialization.py +++ b/examples/getting_started.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 -"""A simple example cmd2 application demonstrating the following: +"""A simple example cmd2 application demonstrating many common features. + +Features demonstrated include all of the following: 1) Colorizing/stylizing output 2) Using multiline commands 3) Persistent history @@ -10,29 +12,49 @@ 8) Displaying an intro banner upon starting your application 9) Using a custom prompt 10) How to make custom attributes settable at runtime. +11) Shortcuts for commands """ +import pathlib + +from rich.style import Style + import cmd2 from cmd2 import ( - Bg, - Fg, - style, + Color, + stylize, ) class BasicApp(cmd2.Cmd): + """Cmd2 application to demonstrate many common features.""" + CUSTOM_CATEGORY = 'My Custom Commands' def __init__(self) -> None: + """Initialize the cmd2 application.""" + # Startup script that defines a couple aliases for running shell commands + alias_script = pathlib.Path(__file__).absolute().parent / '.cmd2rc' + + # Create a shortcut for one of our commands + shortcuts = cmd2.DEFAULT_SHORTCUTS + shortcuts.update({'&': 'intro'}) super().__init__( + include_ipy=True, multiline_commands=['echo'], persistent_history_file='cmd2_history.dat', - startup_script='scripts/startup.txt', - include_ipy=True, + shortcuts=shortcuts, + startup_script=str(alias_script), ) # Prints an intro banner once upon application startup - self.intro = style('Welcome to cmd2!', fg=Fg.RED, bg=Bg.WHITE, bold=True) + self.intro = ( + stylize( + 'Welcome to cmd2!', + style=Style(color=Color.GREEN1, bgcolor=Color.GRAY0, bold=True), + ) + + ' Note the full Unicode support: 😇 💩' + ) # Show this as the prompt when asking for input self.prompt = 'myapp> ' @@ -47,24 +69,34 @@ def __init__(self) -> None: self.default_category = 'cmd2 Built-in Commands' # Color to output text in with echo command - self.foreground_color = Fg.CYAN.name.lower() + self.foreground_color = Color.CYAN.value # Make echo_fg settable at runtime - fg_colors = [c.name.lower() for c in Fg] + fg_colors = [c.value for c in Color] self.add_settable( - cmd2.Settable('foreground_color', str, 'Foreground color to use with echo command', self, choices=fg_colors) + cmd2.Settable( + 'foreground_color', + str, + 'Foreground color to use with echo command', + self, + choices=fg_colors, + ) ) @cmd2.with_category(CUSTOM_CATEGORY) - def do_intro(self, _) -> None: + def do_intro(self, _: cmd2.Statement) -> None: """Display the intro banner.""" self.poutput(self.intro) @cmd2.with_category(CUSTOM_CATEGORY) - def do_echo(self, arg) -> None: - """Example of a multiline command.""" - fg_color = Fg[self.foreground_color.upper()] - self.poutput(style(arg, fg=fg_color)) + def do_echo(self, arg: cmd2.Statement) -> None: + """Multiline command.""" + self.poutput( + stylize( + arg, + style=Style(color=self.foreground_color), + ) + ) if __name__ == '__main__': diff --git a/examples/help_categories.py b/examples/help_categories.py index 7a9b4acab..7a1872509 100755 --- a/examples/help_categories.py +++ b/examples/help_categories.py @@ -7,10 +7,7 @@ import functools import cmd2 -from cmd2 import ( - COMMAND_NAME, - argparse_custom, -) +from cmd2 import COMMAND_NAME def my_decorator(f): @@ -35,6 +32,9 @@ class HelpCategories(cmd2.Cmd): def __init__(self) -> None: super().__init__() + # Set the default category for uncategorized commands + self.default_category = 'Other' + def do_connect(self, _) -> None: """Connect command.""" self.poutput('Connect') @@ -55,8 +55,9 @@ def do_deploy(self, _) -> None: """Deploy command.""" self.poutput('Deploy') - start_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( - description='Start', epilog='my_decorator runs even with argparse errors' + start_parser = cmd2.Cmd2ArgumentParser( + description='Start', + epilog='my_decorator runs even with argparse errors', ) start_parser.add_argument('when', choices=START_TIMES, help='Specify when to start') @@ -74,8 +75,9 @@ def do_redeploy(self, _) -> None: """Redeploy command.""" self.poutput('Redeploy') - restart_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( - description='Restart', epilog='my_decorator does not run when argparse errors' + restart_parser = cmd2.Cmd2ArgumentParser( + description='Restart', + epilog='my_decorator does not run when argparse errors', ) restart_parser.add_argument('when', choices=START_TIMES, help='Specify when to restart') diff --git a/examples/modular_commands_main.py b/examples/modular_commands.py similarity index 86% rename from examples/modular_commands_main.py rename to examples/modular_commands.py index f03ea38d6..582d1605c 100755 --- a/examples/modular_commands_main.py +++ b/examples/modular_commands.py @@ -1,11 +1,11 @@ #!/usr/bin/env python -"""A complex example demonstrating a variety of methods to load CommandSets using a mix of command decorators -with examples of how to integrate tab completion with argparse-based commands. +"""A complex example demonstrating a variety of methods to load CommandSets using a mix of command decorators. + +Includes examples of how to integrate tab completion with argparse-based commands. """ import argparse from collections.abc import Iterable -from typing import Optional from modular_commands.commandset_basic import ( # noqa: F401 BasicCompletionCommandSet, @@ -26,7 +26,8 @@ class WithCommandSets(Cmd): - def __init__(self, command_sets: Optional[Iterable[CommandSet]] = None) -> None: + def __init__(self, command_sets: Iterable[CommandSet] | None = None) -> None: + """Cmd2 application to demonstrate a variety of methods for loading CommandSets.""" super().__init__(command_sets=command_sets) self.sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball'] @@ -55,7 +56,7 @@ def choices_provider(self) -> list[str]: @with_argparser(example_parser) def do_example(self, _: argparse.Namespace) -> None: - """The example command.""" + """An example command.""" self.poutput("I do nothing") diff --git a/examples/modular_commands_basic.py b/examples/modular_commands_basic.py deleted file mode 100755 index c681a389a..000000000 --- a/examples/modular_commands_basic.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python3 -"""Simple example demonstrating basic CommandSet usage.""" - -import cmd2 -from cmd2 import ( - CommandSet, - with_default_category, -) - - -@with_default_category('My Category') -class AutoLoadCommandSet(CommandSet): - def __init__(self) -> None: - super().__init__() - - def do_hello(self, _: cmd2.Statement) -> None: - self._cmd.poutput('Hello') - - def do_world(self, _: cmd2.Statement) -> None: - self._cmd.poutput('World') - - -class ExampleApp(cmd2.Cmd): - """CommandSets are automatically loaded. Nothing needs to be done.""" - - def __init__(self) -> None: - super().__init__() - - def do_something(self, _arg) -> None: - self.poutput('this is the something command') - - -if __name__ == '__main__': - app = ExampleApp() - app.cmdloop() diff --git a/examples/modular_commands_dynamic.py b/examples/modular_commands_dynamic.py deleted file mode 100755 index 163c9dc8a..000000000 --- a/examples/modular_commands_dynamic.py +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env python3 -"""Simple example demonstrating dynamic CommandSet loading and unloading. - -There are 2 CommandSets defined. ExampleApp sets the `auto_load_commands` flag to false. - -The `load` and `unload` commands will load and unload the CommandSets. The available commands will change depending -on which CommandSets are loaded -""" - -import argparse - -import cmd2 -from cmd2 import ( - CommandSet, - with_argparser, - with_category, - with_default_category, -) - - -@with_default_category('Fruits') -class LoadableFruits(CommandSet): - def __init__(self) -> None: - super().__init__() - - def do_apple(self, _: cmd2.Statement) -> None: - self._cmd.poutput('Apple') - - def do_banana(self, _: cmd2.Statement) -> None: - self._cmd.poutput('Banana') - - -@with_default_category('Vegetables') -class LoadableVegetables(CommandSet): - def __init__(self) -> None: - super().__init__() - - def do_arugula(self, _: cmd2.Statement) -> None: - self._cmd.poutput('Arugula') - - def do_bokchoy(self, _: cmd2.Statement) -> None: - self._cmd.poutput('Bok Choy') - - -class ExampleApp(cmd2.Cmd): - """CommandSets are loaded via the `load` and `unload` commands.""" - - def __init__(self, *args, **kwargs) -> None: - # gotta have this or neither the plugin or cmd2 will initialize - super().__init__(*args, auto_load_commands=False, **kwargs) - - self._fruits = LoadableFruits() - self._vegetables = LoadableVegetables() - - load_parser = cmd2.Cmd2ArgumentParser() - load_parser.add_argument('cmds', choices=['fruits', 'vegetables']) - - @with_argparser(load_parser) - @with_category('Command Loading') - def do_load(self, ns: argparse.Namespace) -> None: - if ns.cmds == 'fruits': - try: - self.register_command_set(self._fruits) - self.poutput('Fruits loaded') - except ValueError: - self.poutput('Fruits already loaded') - - if ns.cmds == 'vegetables': - try: - self.register_command_set(self._vegetables) - self.poutput('Vegetables loaded') - except ValueError: - self.poutput('Vegetables already loaded') - - @with_argparser(load_parser) - def do_unload(self, ns: argparse.Namespace) -> None: - if ns.cmds == 'fruits': - self.unregister_command_set(self._fruits) - self.poutput('Fruits unloaded') - - if ns.cmds == 'vegetables': - self.unregister_command_set(self._vegetables) - self.poutput('Vegetables unloaded') - - -if __name__ == '__main__': - app = ExampleApp() - app.cmdloop() diff --git a/examples/modular_subcommands.py b/examples/modular_subcommands.py deleted file mode 100755 index f1dbd024c..000000000 --- a/examples/modular_subcommands.py +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env python3 -"""A simple example demonstrating modular subcommand loading through CommandSets. - -In this example, there are loadable CommandSets defined. Each CommandSet has 1 subcommand defined that will be -attached to the 'cut' command. - -The cut command is implemented with the `do_cut` function that has been tagged as an argparse command. - -The `load` and `unload` command will load and unload the CommandSets. The available top level commands as well as -subcommands to the `cut` command will change depending on which CommandSets are loaded. -""" - -import argparse - -import cmd2 -from cmd2 import ( - CommandSet, - with_argparser, - with_category, - with_default_category, -) - - -@with_default_category('Fruits') -class LoadableFruits(CommandSet): - def __init__(self) -> None: - super().__init__() - - def do_apple(self, _: cmd2.Statement) -> None: - self._cmd.poutput('Apple') - - banana_description = "Cut a banana" - banana_parser = cmd2.Cmd2ArgumentParser(description=banana_description) - banana_parser.add_argument('direction', choices=['discs', 'lengthwise']) - - @cmd2.as_subcommand_to('cut', 'banana', banana_parser, help=banana_description.lower()) - def cut_banana(self, ns: argparse.Namespace) -> None: - """Cut banana.""" - self._cmd.poutput('cutting banana: ' + ns.direction) - - -@with_default_category('Vegetables') -class LoadableVegetables(CommandSet): - def __init__(self) -> None: - super().__init__() - - def do_arugula(self, _: cmd2.Statement) -> None: - self._cmd.poutput('Arugula') - - bokchoy_description = "Cut some bokchoy" - bokchoy_parser = cmd2.Cmd2ArgumentParser(description=bokchoy_description) - bokchoy_parser.add_argument('style', choices=['quartered', 'diced']) - - @cmd2.as_subcommand_to('cut', 'bokchoy', bokchoy_parser, help=bokchoy_description.lower()) - def cut_bokchoy(self, _: argparse.Namespace) -> None: - self._cmd.poutput('Bok Choy') - - -class ExampleApp(cmd2.Cmd): - """CommandSets are automatically loaded. Nothing needs to be done.""" - - def __init__(self, *args, **kwargs) -> None: - # gotta have this or neither the plugin or cmd2 will initialize - super().__init__(*args, auto_load_commands=False, **kwargs) - - self._fruits = LoadableFruits() - self._vegetables = LoadableVegetables() - - load_parser = cmd2.Cmd2ArgumentParser() - load_parser.add_argument('cmds', choices=['fruits', 'vegetables']) - - @with_argparser(load_parser) - @with_category('Command Loading') - def do_load(self, ns: argparse.Namespace) -> None: - if ns.cmds == 'fruits': - try: - self.register_command_set(self._fruits) - self.poutput('Fruits loaded') - except ValueError: - self.poutput('Fruits already loaded') - - if ns.cmds == 'vegetables': - try: - self.register_command_set(self._vegetables) - self.poutput('Vegetables loaded') - except ValueError: - self.poutput('Vegetables already loaded') - - @with_argparser(load_parser) - def do_unload(self, ns: argparse.Namespace) -> None: - if ns.cmds == 'fruits': - self.unregister_command_set(self._fruits) - self.poutput('Fruits unloaded') - - if ns.cmds == 'vegetables': - self.unregister_command_set(self._vegetables) - self.poutput('Vegetables unloaded') - - cut_parser = cmd2.Cmd2ArgumentParser() - cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut') - - @with_argparser(cut_parser) - def do_cut(self, ns: argparse.Namespace) -> None: - # Call handler for whatever subcommand was selected - handler = ns.cmd2_handler.get() - if handler is not None: - handler(ns) - else: - # No subcommand was provided, so call help - self.poutput('This command does nothing without sub-parsers registered') - self.do_help('cut') - - -if __name__ == '__main__': - app = ExampleApp() - app.cmdloop() diff --git a/examples/override_parser.py b/examples/override_parser.py deleted file mode 100755 index 2d4a0f9ca..000000000 --- a/examples/override_parser.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python -"""The standard parser used by cmd2 built-in commands is Cmd2ArgumentParser. -The following code shows how to override it with your own parser class. -""" - -# First set a value called argparse.cmd2_parser_module with the module that defines the custom parser. -# See the code for custom_parser.py. It simply defines a parser and calls cmd2.set_default_argument_parser_type() -# with the custom parser's type. -import argparse - -argparse.cmd2_parser_module = 'custom_parser' - -# Next import from cmd2. It will import your module just before the cmd2.Cmd class file is imported -# and therefore override the parser class it uses on its commands. -from cmd2 import cmd2 # noqa: E402 - -if __name__ == '__main__': - import sys - - app = cmd2.Cmd(include_ipy=True, persistent_history_file='cmd2_history.dat') - app.self_in_py = True # Enable access to "self" within the py command - app.debug = True # Show traceback if/when an exception occurs - sys.exit(app.cmdloop()) diff --git a/examples/pirate.py b/examples/pirate.py deleted file mode 100755 index b15dae4f6..000000000 --- a/examples/pirate.py +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env python -"""This example is adapted from the pirate8.py example created by Catherine Devlin and -presented as part of her PyCon 2010 talk. - -It demonstrates many features of cmd2. -""" - -import cmd2 -from cmd2 import ( - Fg, -) -from cmd2.constants import ( - MULTILINE_TERMINATOR, -) - -color_choices = [c.name.lower() for c in Fg] - - -class Pirate(cmd2.Cmd): - """A piratical example cmd2 application involving looting and drinking.""" - - def __init__(self) -> None: - """Initialize the base class as well as this one.""" - shortcuts = dict(cmd2.DEFAULT_SHORTCUTS) - shortcuts.update({'~': 'sing'}) - super().__init__(multiline_commands=['sing'], terminators=[MULTILINE_TERMINATOR, '...'], shortcuts=shortcuts) - - self.default_to_shell = True - self.songcolor = 'blue' - - # Make songcolor settable at runtime - self.add_settable(cmd2.Settable('songcolor', str, 'Color to ``sing``', self, choices=color_choices)) - - # prompts and defaults - self.gold = 0 - self.initial_gold = self.gold - self.prompt = 'arrr> ' - - def precmd(self, line): - """Runs just before a command line is parsed, but after the prompt is presented.""" - self.initial_gold = self.gold - return line - - def postcmd(self, stop, _line): - """Runs right before a command is about to return.""" - if self.gold != self.initial_gold: - self.poutput(f'Now we gots {self.gold} doubloons') - if self.gold < 0: - self.poutput("Off to debtorrr's prison.") - self.exit_code = 1 - stop = True - return stop - - def do_loot(self, _arg) -> None: - """Seize booty from a passing ship.""" - self.gold += 1 - - def do_drink(self, arg) -> None: - """Drown your sorrrows in rrrum. - - drink [n] - drink [n] barrel[s] o' rum. - """ - try: - self.gold -= int(arg) - except ValueError: - if arg: - self.poutput(f'''What's "{arg}"? I'll take rrrum.''') - self.gold -= 1 - - def do_quit(self, _arg) -> bool: - """Quit the application gracefully.""" - self.poutput("Quiterrr!") - return True - - def do_sing(self, arg) -> None: - """Sing a colorful song.""" - self.poutput(cmd2.ansi.style(arg, fg=Fg[self.songcolor.upper()])) - - yo_parser = cmd2.Cmd2ArgumentParser() - yo_parser.add_argument('--ho', type=int, default=2, help="How often to chant 'ho'") - yo_parser.add_argument('-c', '--commas', action='store_true', help='Intersperse commas') - yo_parser.add_argument('beverage', help='beverage to drink with the chant') - - @cmd2.with_argparser(yo_parser) - def do_yo(self, args) -> None: - """Compose a yo-ho-ho type chant with flexible options.""" - chant = ['yo'] + ['ho'] * args.ho - separator = ', ' if args.commas else ' ' - chant = separator.join(chant) - self.poutput(f'{chant} and a bottle of {args.beverage}') - - -if __name__ == '__main__': - import sys - - # Create an instance of the Pirate derived class and enter the REPL with cmdloop(). - pirate = Pirate() - sys_exit_code = pirate.cmdloop() - print(f'Exiting with code: {sys_exit_code!r}') - sys.exit(sys_exit_code) diff --git a/examples/python_scripting.py b/examples/python_scripting.py index 393e31fdd..0e5c6fc61 100755 --- a/examples/python_scripting.py +++ b/examples/python_scripting.py @@ -20,10 +20,14 @@ example for one way in which this can be done. """ +import argparse import os import cmd2 -from cmd2 import ansi +from cmd2 import ( + Color, + stylize, +) class CmdLineApp(cmd2.Cmd): @@ -38,7 +42,7 @@ def __init__(self) -> None: def _set_prompt(self) -> None: """Set prompt so it displays the current working directory.""" self.cwd = os.getcwd() - self.prompt = ansi.style(f'{self.cwd} $ ', fg=ansi.Fg.CYAN) + self.prompt = stylize(f'{self.cwd} $ ', style=Color.CYAN) def postcmd(self, stop: bool, _line: str) -> bool: """Hook method executed just after a command dispatch is finished. @@ -52,7 +56,7 @@ def postcmd(self, stop: bool, _line: str) -> bool: return stop @cmd2.with_argument_list - def do_cd(self, arglist) -> None: + def do_cd(self, arglist: list[str]) -> None: """Change directory. Usage: cd . @@ -88,7 +92,7 @@ def do_cd(self, arglist) -> None: self.last_result = data # Enable tab completion for cd command - def complete_cd(self, text, line, begidx, endidx): + def complete_cd(self, text: str, line: str, begidx: int, endidx: int) -> list[str]: # Tab complete only directories return self.path_complete(text, line, begidx, endidx, path_filter=os.path.isdir) @@ -96,7 +100,7 @@ def complete_cd(self, text, line, begidx, endidx): 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) -> None: + def do_dir(self, _args: argparse.Namespace, unknown: list[str]) -> None: """List contents of current directory.""" # No arguments for this command if unknown: diff --git a/examples/rich_tables.py b/examples/rich_tables.py new file mode 100755 index 000000000..0d4a0900b --- /dev/null +++ b/examples/rich_tables.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""An example of using Rich Tables within a cmd2 application for displaying tabular data. + +While you can use any Python library for displaying tabular data within a cmd2 application, +we recommend using rich since that is built into cmd2. + +Data comes from World Population Review: https://worldpopulationreview.com/ +and https://en.wikipedia.org/wiki/List_of_countries_by_GDP_(nominal) +""" + +from rich.table import Table + +import cmd2 +from cmd2.colors import Color + +CITY_HEADERS = ['Flag', 'City', 'Country', '2025 Population'] +CITY_DATA = [ + ["🇯🇵", "Tokyo (東京)", "Japan", 37_036_200], + ["🇮🇳", "Delhi", "India", 34_665_600], + ["🇨🇳", "Shanghai (上海)", "China", 30_482_100], + ["🇧🇩", "Dhaka", "Bangladesh", 24_652_900], + ["🇪🇬", "Cairo (القاهرة)", "Egypt", 23_074_200], + ["🇪🇬", "São Paulo", "Brazil", 22_990_000], + ["🇲🇽", "Mexico City", "Mexico", 22_752_400], + ["🇨🇳", "Beijing (北京)", "China", 22_596_500], + ["🇮🇳", "Mumbai", "India", 22_089_000], + ["🇯🇵", "Osaka (大阪)", "Japan", 18_921_600], +] +CITY_TITLE = "10 Largest Cities by Population 2025" +CITY_CAPTION = "Data from https://worldpopulationreview.com/" + +COUNTRY_HEADERS = [ + 'Flag', + 'Country', + '2025 Population', + 'Area (M km^2)', + 'Population Density (/km^2)', + 'GDP (million US$)', + 'GDP per capita (US$)', +] +COUNTRY_DATA = [ + ["🇮🇳", "India", 1_463_870_000, 3.3, 492, 4_187_017, 2_878], + ["🇨🇳", "China (中国)", 1_416_100_000, 9.7, 150, 19_231_705, 13_687], + ["🇺🇸", "United States", 347_276_000, 9.4, 38, 30_507_217, 89_105], + ["🇮🇩", "Indonesia", 285_721_000, 1.9, 152, 1_429_743, 5_027], + ["🇵🇰", "Pakistan", 255_220_000, 0.9, 331, 373_072, 1_484], + ["🇳🇬", "Nigeria", 237_528_000, 0.9, 261, 188_271, 807], + ["🇧🇷", "Brazil", 212_812_000, 8.5, 25, 2_125_958, 9_964], + ["🇧🇩", "Bangladesh", 175_687_000, 0.1, 1_350, 467_218, 2_689], + ["🇷🇺", "Russia (россия)", 143_997_000, 17.1, 9, 2_076_396, 14_258], + ["🇪🇹", "Ethiopia (እትዮጵያ)", 135_472_000, 1.1, 120, 117_457, 1_066], +] +COUNTRY_TITLE = "10 Largest Countries by Population 2025" +COUNTRY_CAPTION = "Data from https://worldpopulationreview.com/ and Wikipedia" + + +class TableApp(cmd2.Cmd): + """Cmd2 application to demonstrate displaying tabular data using rich.""" + + TABLE_CATEGORY = 'Table Commands' + + def __init__(self) -> None: + """Initialize the cmd2 application.""" + super().__init__() + + # Prints an intro banner once upon application startup + self.intro = 'Are you curious which countries and cities on Earth have the largest populations?' + + # Set the default category name + self.default_category = 'cmd2 Built-in Commands' + + @cmd2.with_category(TABLE_CATEGORY) + def do_cities(self, _: cmd2.Statement) -> None: + """Display the cities with the largest population.""" + table = Table(title=CITY_TITLE, caption=CITY_CAPTION) + + for header in CITY_HEADERS: + table.add_column(header) + + for row in CITY_DATA: + # Convert integers or floats to strings, since rich tables can not render int/float + str_row = [f"{item:,}" if isinstance(item, int) else str(item) for item in row] + table.add_row(*str_row) + + self.poutput(table) + + @cmd2.with_category(TABLE_CATEGORY) + def do_countries(self, _: cmd2.Statement) -> None: + """Display the countries with the largest population.""" + table = Table(title=COUNTRY_TITLE, caption=COUNTRY_CAPTION) + + for header in COUNTRY_HEADERS: + justify = "right" + header_style = None + style = None + match header: + case population if "2025 Population" in population: + header_style = Color.BRIGHT_BLUE + style = Color.BLUE + case density if "Density" in density: + header_style = Color.BRIGHT_RED + style = Color.RED + case percap if "per capita" in percap: + header_style = Color.BRIGHT_GREEN + style = Color.GREEN + case flag if 'Flag' in flag: + justify = "center" + case country if 'Country' in country: + justify = "left" + + table.add_column(header, justify=justify, header_style=header_style, style=style) + + for row in COUNTRY_DATA: + # Convert integers or floats to strings, since rich tables can not render int/float + str_row = [f"{item:,}" if isinstance(item, int) else str(item) for item in row] + table.add_row(*str_row) + + self.poutput(table) + + +if __name__ == '__main__': + app = TableApp() + app.cmdloop() diff --git a/examples/rich_theme.py b/examples/rich_theme.py new file mode 100755 index 000000000..67914e33f --- /dev/null +++ b/examples/rich_theme.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""A simple example of setting a custom theme for a cmd2 application.""" + +from rich.style import Style + +import cmd2 +import cmd2.rich_utils as ru +from cmd2 import Cmd2Style, Color + + +class ThemedApp(cmd2.Cmd): + """A simple cmd2 application with a custom theme.""" + + def __init__(self, *args, **kwargs): + """Initialize the application.""" + super().__init__(*args, **kwargs) + self.intro = "This is a themed application. Try the 'theme_show' command." + + # Set text which prints right before all of the help tables are listed. + self.doc_leader = "Welcome to this glorious help ..." + + # Create a custom theme + # Colors can come from the cmd2.color.Color StrEnum class, be RGB hex values, or + # be any of the rich standard colors: https://rich.readthedocs.io/en/stable/appendix/colors.html + custom_theme = { + Cmd2Style.SUCCESS: Style(color=Color.GREEN1, bgcolor=Color.GRAY30), # Use color from cmd2 Color class + Cmd2Style.WARNING: Style(color=Color.ORANGE1), + Cmd2Style.ERROR: Style(color=Color.PINK1), + Cmd2Style.HELP_HEADER: Style(color=Color.CYAN, bgcolor="#44475a"), + Cmd2Style.HELP_LEADER: Style(color="#f8f8f2", bgcolor="#282a36"), # use RGB hex colors + Cmd2Style.TABLE_BORDER: Style(color="turquoise2"), # use a rich standard color + "traceback.exc_type": Style(color=Color.RED, bgcolor=Color.LIGHT_YELLOW3, bold=True), + "argparse.args": Style(color=Color.AQUAMARINE3, underline=True), + } + ru.set_theme(custom_theme) + + @cmd2.with_category("Theme Commands") + def do_theme_show(self, _: cmd2.Statement): + """Showcases the custom theme by printing messages with different styles.""" + self.poutput("This is a basic output message.") + self.psuccess("This is a success message.") + self.pwarning("This is a warning message.") + self.perror("This is an error message.") + self.pexcept(ValueError("This is a dummy ValueError exception.")) + + +if __name__ == "__main__": + import sys + + app = ThemedApp() + sys.exit(app.cmdloop()) diff --git a/examples/subcommands.py b/examples/subcommands.py deleted file mode 100755 index b2768cffe..000000000 --- a/examples/subcommands.py +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env python3 -"""A simple example demonstrating how to use Argparse to support subcommands. - -This example shows an easy way for a single command to have many subcommands, each of which takes different arguments -and provides separate contextual help. -""" - -import cmd2 - -sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball'] - -# create the top-level parser for the base command -base_parser = cmd2.Cmd2ArgumentParser() -base_subparsers = base_parser.add_subparsers(title='subcommands', help='subcommand help') - -# create the parser for the "foo" subcommand -parser_foo = base_subparsers.add_parser('foo', help='foo help') -parser_foo.add_argument('-x', type=int, default=1, help='integer') -parser_foo.add_argument('y', type=float, help='float') -parser_foo.add_argument('input_file', type=str, help='Input File') - -# create the parser for the "bar" subcommand -parser_bar = base_subparsers.add_parser('bar', help='bar help') - -bar_subparsers = parser_bar.add_subparsers(title='layer3', help='help for 3rd layer of commands') -parser_bar.add_argument('z', help='string') - -bar_subparsers.add_parser('apple', help='apple help') -bar_subparsers.add_parser('artichoke', help='artichoke help') -bar_subparsers.add_parser('cranberries', help='cranberries help') - -# create the parser for the "sport" subcommand -parser_sport = base_subparsers.add_parser('sport', help='sport help') -sport_arg = parser_sport.add_argument('sport', help='Enter name of a sport', choices=sport_item_strs) - - -# create the top-level parser for the alternate command -# The alternate command doesn't provide its own help flag -base2_parser = cmd2.Cmd2ArgumentParser(add_help=False) -base2_subparsers = base2_parser.add_subparsers(title='subcommands', help='subcommand help') - -# create the parser for the "foo" subcommand -parser_foo2 = base2_subparsers.add_parser('foo', help='foo help') -parser_foo2.add_argument('-x', type=int, default=1, help='integer') -parser_foo2.add_argument('y', type=float, help='float') -parser_foo2.add_argument('input_file', type=str, help='Input File') - -# create the parser for the "bar" subcommand -parser_bar2 = base2_subparsers.add_parser('bar', help='bar help') - -bar2_subparsers = parser_bar2.add_subparsers(title='layer3', help='help for 3rd layer of commands') -parser_bar2.add_argument('z', help='string') - -bar2_subparsers.add_parser('apple', help='apple help') -bar2_subparsers.add_parser('artichoke', help='artichoke help') -bar2_subparsers.add_parser('cranberries', help='cranberries help') - -# create the parser for the "sport" subcommand -parser_sport2 = base2_subparsers.add_parser('sport', help='sport help') -sport2_arg = parser_sport2.add_argument('sport', help='Enter name of a sport', choices=sport_item_strs) - - -class SubcommandsExample(cmd2.Cmd): - """Example cmd2 application where we a base command which has a couple subcommands - and the "sport" subcommand has tab completion enabled. - """ - - def __init__(self) -> None: - super().__init__() - - # subcommand functions for the base command - def base_foo(self, args) -> None: - """Foo subcommand of base command.""" - self.poutput(args.x * args.y) - - def base_bar(self, args) -> None: - """Bar subcommand of base command.""" - self.poutput(f'(({args.z}))') - - def base_sport(self, args) -> None: - """Sport subcommand of base command.""" - self.poutput(f'Sport is {args.sport}') - - # Set handler functions for the subcommands - parser_foo.set_defaults(func=base_foo) - parser_bar.set_defaults(func=base_bar) - parser_sport.set_defaults(func=base_sport) - - @cmd2.with_argparser(base_parser) - def do_base(self, args) -> None: - """Base command help.""" - func = getattr(args, 'func', None) - if func is not None: - # Call whatever subcommand function was selected - func(self, args) - else: - # No subcommand was provided, so call help - self.do_help('base') - - @cmd2.with_argparser(base2_parser) - def do_alternate(self, args) -> None: - """Alternate command help.""" - func = getattr(args, 'func', None) - if func is not None: - # Call whatever subcommand function was selected - func(self, args) - else: - # No subcommand was provided, so call help - self.do_help('alternate') - - -if __name__ == '__main__': - import sys - - app = SubcommandsExample() - sys.exit(app.cmdloop()) diff --git a/examples/table_creation.py b/examples/table_creation.py deleted file mode 100755 index 00a45d292..000000000 --- a/examples/table_creation.py +++ /dev/null @@ -1,274 +0,0 @@ -#!/usr/bin/env python -"""Examples of using the cmd2 table creation API.""" - -import functools -import sys -from typing import Any - -from cmd2 import ( - EightBitBg, - EightBitFg, - Fg, - ansi, -) -from cmd2.table_creator import ( - AlternatingTable, - BorderedTable, - Column, - HorizontalAlignment, - SimpleTable, -) - -# Text styles used in the tables -bold_yellow = functools.partial(ansi.style, fg=Fg.LIGHT_YELLOW, bold=True) -blue = functools.partial(ansi.style, fg=Fg.LIGHT_BLUE) -green = functools.partial(ansi.style, fg=Fg.GREEN) - - -class DollarFormatter: - """Example class to show that any object type can be passed as data to TableCreator and converted to a string.""" - - def __init__(self, val: float) -> None: - self.val = val - - def __str__(self) -> str: - """Returns the value in dollar currency form (e.g. $100.22).""" - return f"${self.val:,.2f}" - - -class Relative: - """Class used for example data.""" - - def __init__(self, name: str, relationship: str) -> None: - self.name = name - self.relationship = relationship - - -class Book: - """Class used for example data.""" - - def __init__(self, title: str, year_published: str) -> None: - self.title = title - self.year_published = year_published - - -class Author: - """Class used for example data.""" - - def __init__(self, name: str, birthday: str, place_of_birth: str) -> None: - self.name = name - self.birthday = birthday - self.place_of_birth = place_of_birth - self.books: list[Book] = [] - self.relatives: list[Relative] = [] - - -def ansi_print(text) -> None: - """Wraps style_aware_write so style can be stripped if needed.""" - ansi.style_aware_write(sys.stdout, text + '\n\n') - - -def basic_tables() -> None: - """Demonstrates basic examples of the table classes.""" - # Table data which demonstrates handling of wrapping and text styles - data_list: list[list[Any]] = [] - data_list.append(["Billy Smith", "123 Sesame St.\nFake Town, USA 33445", DollarFormatter(100333.03)]) - data_list.append( - [ - "William Longfellow Marmaduke III", - "984 Really Long Street Name Which Will Wrap Nicely\nApt 22G\nPensacola, FL 32501", - DollarFormatter(55135.22), - ] - ) - data_list.append( - [ - "James " + blue("Bluestone"), - bold_yellow("This address has line feeds,\ntext styles, and wrapping. ") - + blue("Style is preserved across lines."), - DollarFormatter(300876.10), - ] - ) - data_list.append(["John Jones", "9235 Highway 32\n" + green("Greenville") + ", SC 29604", DollarFormatter(82987.71)]) - - # Table Columns (width does not account for any borders or padding which may be added) - columns: list[Column] = [] - columns.append(Column("Name", width=20)) - columns.append(Column("Address", width=38)) - columns.append( - Column("Income", width=14, header_horiz_align=HorizontalAlignment.RIGHT, data_horiz_align=HorizontalAlignment.RIGHT) - ) - - st = SimpleTable(columns) - table = st.generate_table(data_list) - ansi_print(table) - - bt = BorderedTable(columns) - table = bt.generate_table(data_list) - ansi_print(table) - - at = AlternatingTable(columns) - table = at.generate_table(data_list) - ansi_print(table) - - -def nested_tables() -> None: - """Demonstrates how to nest tables with styles which conflict with the parent table by setting style_data_text to False. - It also demonstrates coloring various aspects of tables. - """ - # Create data for this example - author_data: list[Author] = [] - author_1 = Author("Frank Herbert", "10/08/1920", "Tacoma, Washington") - author_1.books.append(Book("Dune", "1965")) - author_1.books.append(Book("Dune Messiah", "1969")) - author_1.books.append(Book("Children of Dune", "1976")) - author_1.books.append(Book("God Emperor of Dune", "1981")) - author_1.books.append(Book("Heretics of Dune", "1984")) - author_1.books.append(Book("Chapterhouse: Dune", "1985")) - author_1.relatives.append(Relative("Flora Lillian Parkinson", "First Wife")) - author_1.relatives.append(Relative("Beverly Ann Stuart", "Second Wife")) - author_1.relatives.append(Relative("Theresa Diane Shackelford", "Third Wife")) - author_1.relatives.append(Relative("Penelope Herbert", "Daughter")) - author_1.relatives.append(Relative("Brian Patrick Herbert", "Son")) - author_1.relatives.append(Relative("Bruce Calvin Herbert", "Son")) - - author_2 = Author("Jane Austen", "12/16/1775", "Steventon, Hampshire, England") - author_2.books.append(Book("Sense and Sensibility", "1811")) - author_2.books.append(Book("Pride and Prejudice", "1813")) - author_2.books.append(Book("Mansfield Park ", "1814")) - author_2.books.append(Book("Emma", "1815")) - author_2.books.append(Book("Northanger Abbey", "1818")) - author_2.books.append(Book("Persuasion", "1818")) - author_2.books.append(Book("Lady Susan", "1871")) - author_2.relatives.append(Relative("James Austen", "Brother")) - author_2.relatives.append(Relative("George Austen", "Brother")) - author_2.relatives.append(Relative("Edward Austen", "Brother")) - author_2.relatives.append(Relative("Henry Thomas Austen", "Brother")) - author_2.relatives.append(Relative("Cassandra Elizabeth Austen", "Sister")) - author_2.relatives.append(Relative("Francis William Austen", "Brother")) - author_2.relatives.append(Relative("Charles John Austen", "Brother")) - - author_data.append(author_1) - author_data.append(author_2) - - # Define table which presents Author data fields vertically with no header. - # This will be nested in the parent table's first column. - author_columns: list[Column] = [] - author_columns.append(Column("", width=14)) - author_columns.append(Column("", width=20)) - - # The text labels in this table will be bold text. They will also be aligned by the table code. - # When styled text is aligned, a TextStyle.RESET_ALL sequence is inserted between the aligned text - # and the fill characters. Therefore, the Author table will contain TextStyle.RESET_ALL sequences, - # which would interfere with the background color applied by the parent table. To account for this, - # we will manually color the Author tables to match the background colors of the parent AlternatingTable's - # rows and set style_data_text to False in the Author column. - odd_author_tbl = SimpleTable(author_columns, data_bg=EightBitBg.GRAY_0) - even_author_tbl = SimpleTable(author_columns, data_bg=EightBitBg.GRAY_15) - - # Define AlternatingTable for books checked out by people in the first table. - # This will be nested in the parent table's second column. - books_columns: list[Column] = [] - books_columns.append(Column(ansi.style("Title", bold=True), width=25)) - books_columns.append( - Column( - ansi.style("Published", bold=True), - width=9, - header_horiz_align=HorizontalAlignment.RIGHT, - data_horiz_align=HorizontalAlignment.RIGHT, - ) - ) - - books_tbl = AlternatingTable( - books_columns, - column_borders=False, - border_fg=EightBitFg.GRAY_15, - header_bg=EightBitBg.GRAY_0, - odd_bg=EightBitBg.GRAY_0, - even_bg=EightBitBg.GRAY_15, - ) - - # Define BorderedTable for relatives of the author - # This will be nested in the parent table's third column. - relative_columns: list[Column] = [] - relative_columns.append(Column(ansi.style("Name", bold=True), width=25)) - relative_columns.append(Column(ansi.style("Relationship", bold=True), width=12)) - - # Since the header labels are bold, we have the same issue as the Author table. Therefore, we will manually - # color Relatives tables to match the background colors of the parent AlternatingTable's rows and set style_data_text - # to False in the Relatives column. - odd_relatives_tbl = BorderedTable( - relative_columns, - border_fg=EightBitFg.GRAY_15, - border_bg=EightBitBg.GRAY_0, - header_bg=EightBitBg.GRAY_0, - data_bg=EightBitBg.GRAY_0, - ) - - even_relatives_tbl = BorderedTable( - relative_columns, - border_fg=EightBitFg.GRAY_0, - border_bg=EightBitBg.GRAY_15, - header_bg=EightBitBg.GRAY_15, - data_bg=EightBitBg.GRAY_15, - ) - - # Define parent AlternatingTable which contains Author and Book tables - parent_tbl_columns: list[Column] = [] - - # All of the nested tables already have background colors. Set style_data_text - # to False so the parent AlternatingTable does not apply background color to them. - parent_tbl_columns.append( - Column(ansi.style("Author", bold=True), width=odd_author_tbl.total_width(), style_data_text=False) - ) - parent_tbl_columns.append(Column(ansi.style("Books", bold=True), width=books_tbl.total_width(), style_data_text=False)) - parent_tbl_columns.append( - Column(ansi.style("Relatives", bold=True), width=odd_relatives_tbl.total_width(), style_data_text=False) - ) - - parent_tbl = AlternatingTable( - parent_tbl_columns, - column_borders=False, - border_fg=EightBitFg.GRAY_93, - header_bg=EightBitBg.GRAY_0, - odd_bg=EightBitBg.GRAY_0, - even_bg=EightBitBg.GRAY_15, - ) - - # Construct the tables - parent_table_data: list[list[Any]] = [] - for row, author in enumerate(author_data, start=1): - # First build the author table and color it based on row number - author_tbl = even_author_tbl if row % 2 == 0 else odd_author_tbl - - # This table has three rows and two columns - table_data = [ - [ansi.style("Name", bold=True), author.name], - [ansi.style("Birthday", bold=True), author.birthday], - [ansi.style("Place of Birth", bold=True), author.place_of_birth], - ] - - # Build the author table string - author_tbl_str = author_tbl.generate_table(table_data, include_header=False, row_spacing=0) - - # Now build this author's book table - table_data = [[book.title, book.year_published] for book in author.books] - book_tbl_str = books_tbl.generate_table(table_data) - - # Lastly build the relatives table and color it based on row number - relatives_tbl = even_relatives_tbl if row % 2 == 0 else odd_relatives_tbl - table_data = [[relative.name, relative.relationship] for relative in author.relatives] - relatives_tbl_str = relatives_tbl.generate_table(table_data) - - # Add these tables to the parent table's data - parent_table_data.append(['\n' + author_tbl_str, '\n' + book_tbl_str + '\n\n', '\n' + relatives_tbl_str + '\n\n']) - - # Build the parent table - top_table_str = parent_tbl.generate_table(parent_table_data) - ansi_print(top_table_str) - - -if __name__ == '__main__': - # Default to terminal mode so redirecting to a file won't include the ANSI style sequences - ansi.allow_style = ansi.AllowStyle.TERMINAL - basic_tables() - nested_tables() diff --git a/examples/example.py b/examples/transcript_example.py similarity index 91% rename from examples/example.py rename to examples/transcript_example.py index 20918152e..06b06c2d7 100755 --- a/examples/example.py +++ b/examples/transcript_example.py @@ -2,10 +2,10 @@ """A sample application for cmd2. Thanks to cmd2's built-in transcript testing capability, it also serves as a -test suite for example.py when used with the transcript_regex.txt transcript. +test suite for transcript_example.py when used with the transcript_regex.txt transcript. -Running `python example.py -t transcript_regex.txt` will run all the commands in -the transcript against example.py, verifying that the output produced matches +Running `python transcript_example.py -t transcript_regex.txt` will run all the commands in +the transcript against transcript_example.py, verifying that the output produced matches the transcript. """ diff --git a/examples/transcripts/exampleSession.txt b/examples/transcripts/exampleSession.txt index 85b985d31..84ff1e3f6 100644 --- a/examples/transcripts/exampleSession.txt +++ b/examples/transcripts/exampleSession.txt @@ -1,4 +1,4 @@ -# Run this transcript with "python decorator_example.py -t exampleSession.txt" +# Run this transcript with "python transcript_example.py -t exampleSession.txt" # Anything between two forward slashes, /, is interpreted as a regular expression (regex). # The regex for editor will match whatever program you use. # regexes on prompts just make the trailing space obvious diff --git a/examples/transcripts/transcript_regex.txt b/examples/transcripts/transcript_regex.txt index 3065aae52..1eef14276 100644 --- a/examples/transcripts/transcript_regex.txt +++ b/examples/transcripts/transcript_regex.txt @@ -1,4 +1,4 @@ -# Run this transcript with "python example.py -t transcript_regex.txt" +# Run this transcript with "python transcript_example.py -t transcript_regex.txt" # Anything between two forward slashes, /, is interpreted as a regular expression (regex). # The regex for editor will match whatever program you use. # regexes on prompts just make the trailing space obvious diff --git a/mkdocs.yml b/mkdocs.yml index 77a3d3d79..df42da4a6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -189,7 +189,7 @@ nav: - features/transcripts.md - Examples: - examples/index.md - - examples/first_app.md + - examples/getting_started.md - examples/alternate_event_loops.md - examples/examples.md - Plugins: @@ -200,9 +200,10 @@ nav: - API Reference: - api/index.md - api/cmd.md - - api/ansi.md - api/argparse_completer.md - api/argparse_custom.md + - api/clipboard.md + - api/colors.md - api/command_definition.md - api/constants.md - api/decorators.md @@ -211,7 +212,12 @@ nav: - api/parsing.md - api/plugin.md - api/py_bridge.md - - api/table_creator.md + - api/rich_utils.md + - api/rl_utils.md + - api/string_utils.md + - api/styles.md + - api/terminal_utils.md + - api/transcript.md - api/utils.md - Meta: - doc_conventions.md diff --git a/package.json b/package.json index 6d5accd87..f3aa74f77 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "devDependencies": { - "prettier": "^3.5.3", - "prettier-plugin-toml": "^2.0.5" + "prettier": "^3.6.2", + "prettier-plugin-toml": "^2.0.6" } } diff --git a/plugins/ext_test/build-pyenvs.sh b/plugins/ext_test/build-pyenvs.sh index 4b515bbf5..9ee27578b 100644 --- a/plugins/ext_test/build-pyenvs.sh +++ b/plugins/ext_test/build-pyenvs.sh @@ -8,7 +8,7 @@ # version numbers are: major.minor.patch # # this script will delete and recreate existing virtualenvs named -# cmd2-3.9, etc. It will also create a .python-version +# cmd2-3.14, etc. It will also create a .python-version # # Prerequisites: # - *nix-ish environment like macOS or Linux @@ -23,7 +23,7 @@ # virtualenvs will be added to '.python-version'. Feel free to modify # this list, but note that this script intentionally won't install # dev, rc, or beta python releases -declare -a pythons=("3.9", "3.10", "3.11", "3.12", "3.13") +declare -a pythons=("3.10", "3.11", "3.12", "3.13", "3.14") # function to find the latest patch of a minor version of python function find_latest_version { diff --git a/plugins/ext_test/cmd2_ext_test/cmd2_ext_test.py b/plugins/ext_test/cmd2_ext_test/cmd2_ext_test.py index 1cb45f603..843d609f2 100644 --- a/plugins/ext_test/cmd2_ext_test/cmd2_ext_test.py +++ b/plugins/ext_test/cmd2_ext_test/cmd2_ext_test.py @@ -2,7 +2,6 @@ from typing import ( TYPE_CHECKING, - Optional, ) import cmd2 @@ -29,7 +28,7 @@ def __init__(self, *args, **kwargs): # code placed here runs after cmd2 initializes self._pybridge = cmd2.py_bridge.PyBridge(self) - def app_cmd(self, command: str, echo: Optional[bool] = None) -> cmd2.CommandResult: + def app_cmd(self, command: str, echo: bool | None = None) -> cmd2.CommandResult: """ Run the application command diff --git a/plugins/ext_test/noxfile.py b/plugins/ext_test/noxfile.py index d8aa344bf..9a29eaabd 100644 --- a/plugins/ext_test/noxfile.py +++ b/plugins/ext_test/noxfile.py @@ -1,7 +1,7 @@ import nox -@nox.session(python=['3.9', '3.10', '3.11', '3.12', '3.13']) +@nox.session(python=['3.10', '3.11', '3.12', '3.13', '3.14']) def tests(session): session.install('invoke', './[test]') session.run('invoke', 'pytest', '--junit', '--no-pty') diff --git a/plugins/ext_test/pyproject.toml b/plugins/ext_test/pyproject.toml index 715301a9b..f46d152bd 100644 --- a/plugins/ext_test/pyproject.toml +++ b/plugins/ext_test/pyproject.toml @@ -6,11 +6,11 @@ disallow_incomplete_defs = true disallow_untyped_calls = true disallow_untyped_defs = true exclude = [ - "^examples/", # examples directory + "^examples/", # examples directory "^noxfile\\.py$", # nox config file - "setup\\.py$", # any files named setup.py - "^tasks\\.py$", # tasks.py invoke config file - "^tests/", # tests directory + "setup\\.py$", # any files named setup.py + "^tasks\\.py$", # tasks.py invoke config file + "^tests/", # tests directory ] show_column_numbers = true show_error_codes = true @@ -82,7 +82,7 @@ select = [ # "EM", # flake8-errmsg # "ERA", # eradicate # "EXE", # flake8-executable - "F", # Pyflakes + "F", # Pyflakes "FA", # flake8-future-annotations # "FBT", # flake8-boolean-trap "G", # flake8-logging-format @@ -93,7 +93,7 @@ select = [ # "ISC", # flake8-implicit-str-concat # "N", # pep8-naming "NPY", # NumPy-specific rules - "PD", # pandas-vet + "PD", # pandas-vet # "PGH", # pygrep-hooks # "PIE", # flake8-pie # "PL", # Pylint @@ -119,21 +119,21 @@ select = [ ] ignore = [ # `ruff rule S101` for a description of that rule - "B904", # Within an `except` clause, raise exceptions with `raise ... from err` -- FIX ME - "B905", # `zip()` without an explicit `strict=` parameter -- FIX ME - "E501", # Line too long - "EM101", # Exception must not use a string literal, assign to variable first - "EXE001", # Shebang is present but file is not executable -- DO NOT FIX - "G004", # Logging statement uses f-string + "B904", # Within an `except` clause, raise exceptions with `raise ... from err` -- FIX ME + "B905", # `zip()` without an explicit `strict=` parameter -- FIX ME + "E501", # Line too long + "EM101", # Exception must not use a string literal, assign to variable first + "EXE001", # Shebang is present but file is not executable -- DO NOT FIX + "G004", # Logging statement uses f-string "PLC1901", # `{}` can be simplified to `{}` as an empty string is falsey - "PLW060", # Using global for `{name}` but no assignment is done -- DO NOT FIX + "PLW060", # Using global for `{name}` but no assignment is done -- DO NOT FIX "PLW2901", # PLW2901: Redefined loop variable -- FIX ME - "PT011", # `pytest.raises(Exception)` is too broad, set the `match` parameter or use a more specific exception - "PT018", # Assertion should be broken down into multiple parts - "S101", # Use of `assert` detected -- DO NOT FIX - "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes -- FIX ME - "SLF001", # Private member accessed: `_Iterator` -- FIX ME - "UP038", # Use `X | Y` in `{}` call instead of `(X, Y)` -- DO NOT FIX + "PT011", # `pytest.raises(Exception)` is too broad, set the `match` parameter or use a more specific exception + "PT018", # Assertion should be broken down into multiple parts + "S101", # Use of `assert` detected -- DO NOT FIX + "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes -- FIX ME + "SLF001", # Private member accessed: `_Iterator` -- FIX ME + "UP038", # Use `X | Y` in `{}` call instead of `(X, Y)` -- DO NOT FIX ] # Allow fix for all enabled rules (when `--fix`) is provided. @@ -145,19 +145,6 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" mccabe.max-complexity = 49 -per-file-ignores."cmd2/__init__.py" = [ - "E402", # Module level import not at top of file - "F401", # Unused import -] - -per-file-ignores."docs/conf.py" = [ - "F401", # Unused import -] - -per-file-ignores."examples/override_parser.py" = [ - "E402", # Module level import not at top of file -] - per-file-ignores."examples/scripts/*.py" = [ "F821", # Undefined name `app` ] diff --git a/plugins/ext_test/setup.py b/plugins/ext_test/setup.py index e3b387767..244b85cf6 100644 --- a/plugins/ext_test/setup.py +++ b/plugins/ext_test/setup.py @@ -24,7 +24,7 @@ license='MIT', package_data=PACKAGE_DATA, packages=['cmd2_ext_test'], - python_requires='>=3.9', + python_requires='>=3.10', install_requires=['cmd2 >= 2, <3'], setup_requires=['setuptools >= 42', 'setuptools_scm >= 3.4'], classifiers=[ @@ -34,7 +34,6 @@ 'Topic :: Software Development :: Libraries :: Python Modules', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', diff --git a/plugins/template/README.md b/plugins/template/README.md index 11fe26909..4ade388f9 100644 --- a/plugins/template/README.md +++ b/plugins/template/README.md @@ -215,28 +215,24 @@ If you prefer to create these virtualenvs by hand, do the following: ``` $ cd cmd2_abbrev -$ pyenv install 3.8.5 -$ pyenv virtualenv -p python3.8 3.8.5 cmd2-3.8 -$ pyenv install 3.9.0 -$ pyenv virtualenv -p python3.9 3.9.0 cmd2-3.9 +$ pyenv install 3.14.0 +$ pyenv virtualenv -p python3.14 3.14.0 cmd2-3.14 ``` Now set pyenv to make both of those available at the same time: ``` -$ pyenv local cmd2-3.8 cmd2-3.9 +$ pyenv local cmd2-3.14 ``` Whether you ran the script, or did it by hand, you now have isolated virtualenvs for each of the major python versions. This table shows various python commands, the version of python which will be executed, and the virtualenv it will utilize. -| Command | python | virtualenv | -| ----------- | ------ | ---------- | -| `python3.8` | 3.8.5 | cmd2-3.8 | -| `python3.9` | 3.9.0 | cmd2-3.9 | -| `pip3.8` | 3.8.5 | cmd2-3.8 | -| `pip3.9` | 3.9.0 | cmd2-3.9 | +| Command | python | virtualenv | +| ------------ | ------ | ---------- | +| `python3.14` | 3.14.0 | cmd2-3.14 | +| `pip3.14` | 3.14.0 | cmd2-3.14 | ## Install Dependencies @@ -249,10 +245,10 @@ $ pip install -e .[dev] This command also installs `cmd2-myplugin` "in-place", so the package points to the source code instead of copying files to the python `site-packages` folder. -All the dependencies now have been installed in the `cmd2-3.9` virtualenv. If you want to work in +All the dependencies now have been installed in the `cmd2-3.14` virtualenv. If you want to work in other virtualenvs, you'll need to manually select it, and install again:: -$ pyenv shell cmd2-3.4 $ pip install -e .[dev] +$ pyenv shell cmd2-3.14 $ pip install -e .[dev] Now that you have your python environments created, you need to install the package in place, along with all the other development dependencies: @@ -268,8 +264,8 @@ the `tests` directory. ### Use nox to run unit tests in multiple versions of python -The included `noxfile.py` is setup to run the unit tests in python 3.8, 3.9 3.10, 3.11, and 3.12 You -can run your unit tests in all of these versions of python by: +The included `noxfile.py` is setup to run the unit tests in 3.10, 3.11, 3.12, 3.13, and 3.14 You can +run your unit tests in all of these versions of python by: ``` $ nox diff --git a/plugins/template/build-pyenvs.sh b/plugins/template/build-pyenvs.sh index fd0b505b0..9ee27578b 100644 --- a/plugins/template/build-pyenvs.sh +++ b/plugins/template/build-pyenvs.sh @@ -8,7 +8,7 @@ # version numbers are: major.minor.patch # # this script will delete and recreate existing virtualenvs named -# cmd2-3.9, etc. It will also create a .python-version +# cmd2-3.14, etc. It will also create a .python-version # # Prerequisites: # - *nix-ish environment like macOS or Linux @@ -23,7 +23,7 @@ # virtualenvs will be added to '.python-version'. Feel free to modify # this list, but note that this script intentionally won't install # dev, rc, or beta python releases -declare -a pythons=("3.9" "3.10" "3.11", "3.12", "3.13") +declare -a pythons=("3.10", "3.11", "3.12", "3.13", "3.14") # function to find the latest patch of a minor version of python function find_latest_version { diff --git a/plugins/template/noxfile.py b/plugins/template/noxfile.py index cac9f9177..d37ed1384 100644 --- a/plugins/template/noxfile.py +++ b/plugins/template/noxfile.py @@ -1,7 +1,7 @@ import nox -@nox.session(python=['3.9', '3.10', '3.11', '3.12', '3.13']) +@nox.session(python=['3.10', '3.11', '3.12', '3.13', '3.14']) def tests(session) -> None: session.install('invoke', './[test]') session.run('invoke', 'pytest', '--junit', '--no-pty') diff --git a/plugins/template/setup.py b/plugins/template/setup.py index 3eed7f283..cc4f63315 100644 --- a/plugins/template/setup.py +++ b/plugins/template/setup.py @@ -20,7 +20,7 @@ url='https://github.com/python-cmd2/cmd2-plugin-template', license='MIT', packages=['cmd2_myplugin'], - python_requires='>=3.9', + python_requires='>=3.10', install_requires=['cmd2 >= 2, <3'], setup_requires=['setuptools_scm'], classifiers=[ @@ -30,7 +30,6 @@ 'Topic :: Software Development :: Libraries :: Python Modules', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', diff --git a/pyproject.toml b/pyproject.toml index 4794983f1..c25b3497f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,18 +8,17 @@ dynamic = ["version"] description = "cmd2 - quickly build feature-rich and user-friendly interactive command line applications in Python" authors = [{ name = "cmd2 Contributors" }] readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" keywords = ["CLI", "cmd", "command", "interactive", "prompt", "Python"] -license = { file = "LICENSE" } +license = "MIT" +license-files = ["LICENSE"] classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Console", "Operating System :: OS Independent", "Intended Audience :: Developers", "Intended Audience :: System Administrators", - "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -29,11 +28,12 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ + "backports.strenum; python_version == '3.10'", "gnureadline>=8; platform_system == 'Darwin'", "pyperclip>=1.8", "pyreadline3>=3.4; platform_system == 'Windows'", + "rich>=14.1.0", "rich-argparse>=1.7.1", - "wcwidth>=0.2.10", ] [dependency-groups] @@ -149,7 +149,7 @@ exclude = [ # Same as Black. line-length = 127 indent-width = 4 -target-version = "py39" # Minimum supported version of Python +target-version = "py310" # Minimum supported version of Python output-format = "full" [tool.ruff.lint] @@ -225,7 +225,6 @@ select = [ ignore = [ # `uv run ruff rule E501` for a description of that rule "ANN401", # Dynamically typed expressions (typing.Any) are disallowed (would be good to enable this later) - "B905", # zip() without an explicit strict= parameter (strict added in Python 3.10+) "COM812", # Conflicts with ruff format (see https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules) "COM819", # Conflicts with ruff format "D203", # 1 blank line required before class docstring (conflicts with D211) @@ -243,7 +242,6 @@ ignore = [ "Q003", # Conflicts with ruff format "TC006", # Add quotes to type expression in typing.cast() (not needed except for forward references) "TRY003", # Avoid specifying long messages outside the exception class (force custom exceptions for everything) - "UP007", # Use X | Y for type annotations (requires Python 3.10+) "UP017", # Use datetime.UTC alias (requires Python 3.11+) "UP038", # Use X | Y in {} call instead of (X, Y) - deprecated due to poor performance (requires Python 3.10+) "W191", # Conflicts with ruff format @@ -259,9 +257,6 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" mccabe.max-complexity = 49 [tool.ruff.lint.per-file-ignores] -# Module level import not at top of file and unused import -"cmd2/__init__.py" = ["E402", "F401"] - # Do not call setattr with constant attribute value "cmd2/argparse_custom.py" = ["B010"] diff --git a/tasks.py b/tasks.py index f6b9d7ffc..92917dbab 100644 --- a/tasks.py +++ b/tasks.py @@ -12,7 +12,6 @@ import re import shutil import sys -from typing import Union import invoke from invoke.context import Context @@ -26,7 +25,7 @@ # shared function -def rmrf(items: Union[str, list[str], set[str]], verbose: bool = True) -> None: +def rmrf(items: str | list[str] | set[str], verbose: bool = True) -> None: """Silently remove a list of directories or files.""" if isinstance(items, str): items = [items] diff --git a/tests/conftest.py b/tests/conftest.py index b9c64375b..72e902602 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,32 +2,29 @@ import argparse import sys -from contextlib import ( - redirect_stderr, - redirect_stdout, -) +from collections.abc import Callable +from contextlib import redirect_stderr from typing import ( - Optional, - Union, -) -from unittest import ( - mock, + ParamSpec, + TextIO, + TypeVar, + cast, ) +from unittest import mock import pytest import cmd2 -from cmd2.rl_utils import ( - readline, -) -from cmd2.utils import ( - StdSim, -) +from cmd2 import rich_utils as ru +from cmd2.rl_utils import readline +from cmd2.utils import StdSim +# For type hinting decorators +P = ParamSpec('P') +T = TypeVar('T') -def verify_help_text( - cmd2_app: cmd2.Cmd, help_output: Union[str, list[str]], verbose_strings: Optional[list[str]] = None -) -> None: + +def verify_help_text(cmd2_app: cmd2.Cmd, help_output: str | list[str], verbose_strings: list[str] | None = None) -> None: """This function verifies that all expected commands are present in the help text. :param cmd2_app: instance of cmd2.Cmd @@ -44,42 +41,6 @@ def verify_help_text( assert verbose_string in help_text -# Help text for the history command -HELP_HISTORY = """Usage: history [-h] [-r | -e | -o FILE | -t TRANSCRIPT_FILE | -c] [-s] [-x] - [-v] [-a] - [arg] - -View, run, edit, save, or clear previously entered commands - -positional arguments: - arg empty all history items - a one history item by number - a..b, a:b, a:, ..b items by indices (inclusive) - string items containing string - /regex/ items matching regular expression - -optional arguments: - -h, --help show this help message and exit - -r, --run run selected history items - -e, --edit edit and then run selected history items - -o, --output_file FILE - output commands to a script file, implies -s - -t, --transcript TRANSCRIPT_FILE - create a transcript file by re-running the commands, - implies both -r and -s - -c, --clear clear all history - -formatting: - -s, --script output commands in script format, i.e. without command - numbers - -x, --expanded output fully parsed commands with any aliases and - macros expanded, instead of typed commands - -v, --verbose display history and include expanded commands if they - differ from the typed command - -a, --all display all commands, including ones persisted from - previous sessions -""" - # Output from the shortcuts command with default built-in shortcuts SHORTCUTS_TXT = """Shortcuts for other commands: !: shell @@ -88,27 +49,8 @@ def verify_help_text( @@: _relative_run_script """ -# Output from the set command -SET_TXT = ( - "Name Value Description \n" - "====================================================================================================================\n" - "allow_style Terminal Allow ANSI text style sequences in output (valid values: \n" - " Always, Never, Terminal) \n" - "always_show_hint False Display tab completion hint even when completion suggestions\n" - " print \n" - "debug False Show full traceback on exception \n" - "echo False Echo command issued into output \n" - "editor vim Program used by 'edit' \n" - "feedback_to_output False Include nonessentials in '|', '>' results \n" - "max_completion_items 50 Maximum number of CompletionItems to display during tab \n" - " completion \n" - "quiet False Don't print nonessential feedback \n" - "scripts_add_to_history True Scripts and pyscripts add commands to history \n" - "timing False Report execution times \n" -) - -def normalize(block): +def normalize(block: str) -> list[str]: """Normalize a block of text to perform comparison. Strip newlines from the very beginning and very end Then split into separate lines and strip trailing whitespace @@ -119,24 +61,28 @@ def normalize(block): return [line.rstrip() for line in block.splitlines()] -def run_cmd(app, cmd): +def run_cmd(app: cmd2.Cmd, cmd: str) -> tuple[list[str], list[str]]: """Clear out and err StdSim buffers, run the command, and return out and err""" - saved_sysout = sys.stdout - sys.stdout = app.stdout + + # Only capture sys.stdout if it's the same stream as self.stdout + stdouts_match = app.stdout == sys.stdout # This will be used to capture app.stdout and sys.stdout - copy_cmd_stdout = StdSim(app.stdout) + copy_cmd_stdout = StdSim(cast(TextIO, app.stdout)) # This will be used to capture sys.stderr copy_stderr = StdSim(sys.stderr) try: - app.stdout = copy_cmd_stdout - with redirect_stdout(copy_cmd_stdout), redirect_stderr(copy_stderr): + app.stdout = cast(TextIO, copy_cmd_stdout) + if stdouts_match: + sys.stdout = app.stdout + with redirect_stderr(cast(TextIO, copy_stderr)): app.onecmd_plus_hooks(cmd) finally: - app.stdout = copy_cmd_stdout.inner_stream - sys.stdout = saved_sysout + app.stdout = cast(TextIO, copy_cmd_stdout.inner_stream) + if stdouts_match: + sys.stdout = app.stdout out = copy_cmd_stdout.getvalue() err = copy_stderr.getvalue() @@ -144,15 +90,36 @@ def run_cmd(app, cmd): @pytest.fixture -def base_app(): +def base_app() -> cmd2.Cmd: return cmd2.Cmd(include_py=True, include_ipy=True) +def with_ansi_style(style: ru.AllowStyle) -> Callable[[Callable[P, T]], Callable[P, T]]: + """Decorator which sets ru.ALLOW_STYLE before a function runs and restores it when it's done.""" + + def arg_decorator(func: Callable[P, T]) -> Callable[P, T]: + import functools + + @functools.wraps(func) + def cmd_wrapper(*args: P.args, **kwargs: P.kwargs) -> T: + old = ru.ALLOW_STYLE + ru.ALLOW_STYLE = style + try: + retval = func(*args, **kwargs) + finally: + ru.ALLOW_STYLE = old + return retval + + return cmd_wrapper + + return arg_decorator + + # These are odd file names for testing quoting of them odd_file_names = ['nothingweird', 'has spaces', '"is_double_quoted"', "'is_single_quoted'"] -def complete_tester(text: str, line: str, begidx: int, endidx: int, app) -> Optional[str]: +def complete_tester(text: str, line: str, begidx: int, endidx: int, app: cmd2.Cmd) -> str | None: """This is a convenience function to test cmd2.complete() since in a unit test environment there is no actual console readline is monitoring. Therefore we use mock to provide readline data @@ -168,13 +135,13 @@ def complete_tester(text: str, line: str, begidx: int, endidx: int, app) -> Opti These matches also have been sorted by complete() """ - def get_line(): + def get_line() -> str: return line - def get_begidx(): + def get_begidx() -> int: return begidx - def get_endidx(): + def get_endidx() -> int: return endidx # Run the readline tab completion function with readline mocks in place diff --git a/tests/pyscript/stdout_capture.py b/tests/pyscript/stdout_capture.py index 5cc0cf3a4..7cc6641c6 100644 --- a/tests/pyscript/stdout_capture.py +++ b/tests/pyscript/stdout_capture.py @@ -1,25 +1,4 @@ -# This script demonstrates when output of a command finalization hook is captured by a pyscript app() call -import sys - -# The unit test framework passes in the string being printed by the command finalization hook -hook_output = sys.argv[1] - -# Run a help command which results in 1 call to onecmd_plus_hooks -res = app('help') - -# hook_output will not be captured because there are no nested calls to onecmd_plus_hooks -if hook_output not in res.stdout: - print("PASSED") -else: - print("FAILED") - -# Run the last command in the history -res = app('history -r -1') - -# All output of the history command will be captured. This includes all output of the commands -# started in do_history() using onecmd_plus_hooks(), including any output in those commands' hooks. -# Therefore we expect the hook_output to show up this time. -if hook_output in res.stdout: - print("PASSED") -else: - print("FAILED") +# This script demonstrates that cmd2 can capture sys.stdout and self.stdout when both point to the same stream. +# Set base_app.self_in_py to True before running this script. +print("print") +self.poutput("poutput") diff --git a/tests/test_ansi.py b/tests/test_ansi.py deleted file mode 100644 index 841190724..000000000 --- a/tests/test_ansi.py +++ /dev/null @@ -1,298 +0,0 @@ -"""Unit testing for cmd2/ansi.py module""" - -import pytest - -from cmd2 import ( - ansi, -) - -HELLO_WORLD = 'Hello, world!' - - -def test_strip_style() -> None: - base_str = HELLO_WORLD - ansi_str = ansi.style(base_str, fg=ansi.Fg.GREEN) - assert base_str != ansi_str - assert base_str == ansi.strip_style(ansi_str) - - -def test_style_aware_wcswidth() -> None: - base_str = HELLO_WORLD - ansi_str = ansi.style(base_str, fg=ansi.Fg.GREEN) - assert ansi.style_aware_wcswidth(HELLO_WORLD) == ansi.style_aware_wcswidth(ansi_str) - - assert ansi.style_aware_wcswidth('i have a tab\t') == -1 - assert ansi.style_aware_wcswidth('i have a newline\n') == -1 - - -def test_widest_line() -> None: - text = ansi.style('i have\n3 lines\nThis is the longest one', fg=ansi.Fg.GREEN) - assert ansi.widest_line(text) == ansi.style_aware_wcswidth("This is the longest one") - - text = "I'm just one line" - assert ansi.widest_line(text) == ansi.style_aware_wcswidth(text) - - assert ansi.widest_line('i have a tab\t') == -1 - - -def test_style_none() -> None: - base_str = HELLO_WORLD - ansi_str = base_str - assert ansi.style(base_str) == ansi_str - - -@pytest.mark.parametrize('fg_color', [ansi.Fg.BLUE, ansi.EightBitFg.AQUAMARINE_1A, ansi.RgbFg(0, 2, 4)]) -def test_style_fg(fg_color) -> None: - base_str = HELLO_WORLD - ansi_str = fg_color + base_str + ansi.Fg.RESET - assert ansi.style(base_str, fg=fg_color) == ansi_str - - -@pytest.mark.parametrize('bg_color', [ansi.Bg.BLUE, ansi.EightBitBg.AQUAMARINE_1A, ansi.RgbBg(0, 2, 4)]) -def test_style_bg(bg_color) -> None: - base_str = HELLO_WORLD - ansi_str = bg_color + base_str + ansi.Bg.RESET - assert ansi.style(base_str, bg=bg_color) == ansi_str - - -def test_style_invalid_types() -> None: - # Use a BgColor with fg - with pytest.raises(TypeError): - ansi.style('test', fg=ansi.Bg.BLUE) - - # Use a FgColor with bg - with pytest.raises(TypeError): - ansi.style('test', bg=ansi.Fg.BLUE) - - -def test_style_bold() -> None: - base_str = HELLO_WORLD - ansi_str = ansi.TextStyle.INTENSITY_BOLD + base_str + ansi.TextStyle.INTENSITY_NORMAL - assert ansi.style(base_str, bold=True) == ansi_str - - -def test_style_dim() -> None: - base_str = HELLO_WORLD - ansi_str = ansi.TextStyle.INTENSITY_DIM + base_str + ansi.TextStyle.INTENSITY_NORMAL - assert ansi.style(base_str, dim=True) == ansi_str - - -def test_style_italic() -> None: - base_str = HELLO_WORLD - ansi_str = ansi.TextStyle.ITALIC_ENABLE + base_str + ansi.TextStyle.ITALIC_DISABLE - assert ansi.style(base_str, italic=True) == ansi_str - - -def test_style_overline() -> None: - base_str = HELLO_WORLD - ansi_str = ansi.TextStyle.OVERLINE_ENABLE + base_str + ansi.TextStyle.OVERLINE_DISABLE - assert ansi.style(base_str, overline=True) == ansi_str - - -def test_style_strikethrough() -> None: - base_str = HELLO_WORLD - ansi_str = ansi.TextStyle.STRIKETHROUGH_ENABLE + base_str + ansi.TextStyle.STRIKETHROUGH_DISABLE - assert ansi.style(base_str, strikethrough=True) == ansi_str - - -def test_style_underline() -> None: - base_str = HELLO_WORLD - ansi_str = ansi.TextStyle.UNDERLINE_ENABLE + base_str + ansi.TextStyle.UNDERLINE_DISABLE - assert ansi.style(base_str, underline=True) == ansi_str - - -def test_style_multi() -> None: - base_str = HELLO_WORLD - fg_color = ansi.Fg.LIGHT_BLUE - bg_color = ansi.Bg.LIGHT_GRAY - ansi_str = ( - fg_color - + bg_color - + ansi.TextStyle.INTENSITY_BOLD - + ansi.TextStyle.INTENSITY_DIM - + ansi.TextStyle.ITALIC_ENABLE - + ansi.TextStyle.OVERLINE_ENABLE - + ansi.TextStyle.STRIKETHROUGH_ENABLE - + ansi.TextStyle.UNDERLINE_ENABLE - + base_str - + ansi.Fg.RESET - + ansi.Bg.RESET - + ansi.TextStyle.INTENSITY_NORMAL - + ansi.TextStyle.INTENSITY_NORMAL - + ansi.TextStyle.ITALIC_DISABLE - + ansi.TextStyle.OVERLINE_DISABLE - + ansi.TextStyle.STRIKETHROUGH_DISABLE - + ansi.TextStyle.UNDERLINE_DISABLE - ) - assert ( - ansi.style( - base_str, - fg=fg_color, - bg=bg_color, - bold=True, - dim=True, - italic=True, - overline=True, - strikethrough=True, - underline=True, - ) - == ansi_str - ) - - -def test_set_title() -> None: - title = HELLO_WORLD - assert ansi.set_title(title) == ansi.OSC + '2;' + title + ansi.BEL - - -@pytest.mark.parametrize( - ('cols', 'prompt', 'line', 'cursor', 'msg', 'expected'), - [ - ( - 127, - '(Cmd) ', - 'help his', - 12, - ansi.style('Hello World!', fg=ansi.Fg.MAGENTA), - '\x1b[2K\r\x1b[35mHello World!\x1b[39m', - ), - (127, '\n(Cmd) ', 'help ', 5, 'foo', '\x1b[2K\x1b[1A\x1b[2K\rfoo'), - ( - 10, - '(Cmd) ', - 'help history of the american republic', - 4, - 'boo', - '\x1b[3B\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\rboo', - ), - ], -) -def test_async_alert_str(cols, prompt, line, cursor, msg, expected) -> None: - alert_str = ansi.async_alert_str(terminal_columns=cols, prompt=prompt, line=line, cursor_offset=cursor, alert_msg=msg) - assert alert_str == expected - - -def test_clear_screen() -> None: - clear_type = 2 - assert ansi.clear_screen(clear_type) == f"{ansi.CSI}{clear_type}J" - - clear_type = -1 - expected_err = "clear_type must in an integer from 0 to 3" - with pytest.raises(ValueError, match=expected_err): - ansi.clear_screen(clear_type) - - clear_type = 4 - with pytest.raises(ValueError, match=expected_err): - ansi.clear_screen(clear_type) - - -def test_clear_line() -> None: - clear_type = 2 - assert ansi.clear_line(clear_type) == f"{ansi.CSI}{clear_type}K" - - clear_type = -1 - expected_err = "clear_type must in an integer from 0 to 2" - with pytest.raises(ValueError, match=expected_err): - ansi.clear_line(clear_type) - - clear_type = 3 - with pytest.raises(ValueError, match=expected_err): - ansi.clear_line(clear_type) - - -def test_cursor() -> None: - count = 1 - assert ansi.Cursor.UP(count) == f"{ansi.CSI}{count}A" - assert ansi.Cursor.DOWN(count) == f"{ansi.CSI}{count}B" - assert ansi.Cursor.FORWARD(count) == f"{ansi.CSI}{count}C" - assert ansi.Cursor.BACK(count) == f"{ansi.CSI}{count}D" - - x = 4 - y = 5 - assert ansi.Cursor.SET_POS(x, y) == f"{ansi.CSI}{y};{x}H" - - -@pytest.mark.parametrize( - 'ansi_sequence', - [ - ansi.Fg.MAGENTA, - ansi.Bg.LIGHT_GRAY, - ansi.EightBitBg.CHARTREUSE_2A, - ansi.EightBitBg.MEDIUM_PURPLE, - ansi.RgbFg(0, 5, 22), - ansi.RgbBg(100, 150, 222), - ansi.TextStyle.OVERLINE_ENABLE, - ], -) -def test_sequence_str_building(ansi_sequence) -> None: - """This tests __add__(), __radd__(), and __str__() methods for AnsiSequences""" - assert ansi_sequence + ansi_sequence == str(ansi_sequence) + str(ansi_sequence) - - -@pytest.mark.parametrize( - ('r', 'g', 'b', 'valid'), - [ - (0, 0, 0, True), - (255, 255, 255, True), - (-1, 0, 0, False), - (256, 255, 255, False), - (0, -1, 0, False), - (255, 256, 255, False), - (0, 0, -1, False), - (255, 255, 256, False), - ], -) -def test_rgb_bounds(r, g, b, valid) -> None: - if valid: - ansi.RgbFg(r, g, b) - ansi.RgbBg(r, g, b) - else: - expected_err = "RGB values must be integers in the range of 0 to 255" - with pytest.raises(ValueError, match=expected_err): - ansi.RgbFg(r, g, b) - with pytest.raises(ValueError, match=expected_err): - ansi.RgbBg(r, g, b) - - -def test_std_color_re() -> None: - """Test regular expressions for matching standard foreground and background colors""" - for color in ansi.Fg: - assert ansi.STD_FG_RE.match(str(color)) - assert not ansi.STD_BG_RE.match(str(color)) - for color in ansi.Bg: - assert ansi.STD_BG_RE.match(str(color)) - assert not ansi.STD_FG_RE.match(str(color)) - - # Test an invalid color code - assert not ansi.STD_FG_RE.match(f'{ansi.CSI}38m') - assert not ansi.STD_BG_RE.match(f'{ansi.CSI}48m') - - -def test_eight_bit_color_re() -> None: - """Test regular expressions for matching eight-bit foreground and background colors""" - for color in ansi.EightBitFg: - assert ansi.EIGHT_BIT_FG_RE.match(str(color)) - assert not ansi.EIGHT_BIT_BG_RE.match(str(color)) - for color in ansi.EightBitBg: - assert ansi.EIGHT_BIT_BG_RE.match(str(color)) - assert not ansi.EIGHT_BIT_FG_RE.match(str(color)) - - # Test invalid eight-bit value (256) - assert not ansi.EIGHT_BIT_FG_RE.match(f'{ansi.CSI}38;5;256m') - assert not ansi.EIGHT_BIT_BG_RE.match(f'{ansi.CSI}48;5;256m') - - -def test_rgb_color_re() -> None: - """Test regular expressions for matching RGB foreground and background colors""" - for i in range(256): - fg_color = ansi.RgbFg(i, i, i) - assert ansi.RGB_FG_RE.match(str(fg_color)) - assert not ansi.RGB_BG_RE.match(str(fg_color)) - - bg_color = ansi.RgbBg(i, i, i) - assert ansi.RGB_BG_RE.match(str(bg_color)) - assert not ansi.RGB_FG_RE.match(str(bg_color)) - - # Test invalid RGB value (256) - assert not ansi.RGB_FG_RE.match(f'{ansi.CSI}38;2;256;256;256m') - assert not ansi.RGB_BG_RE.match(f'{ansi.CSI}48;2;256;256;256m') diff --git a/tests/test_argparse.py b/tests/test_argparse.py index ff387ecc3..dd567434f 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -1,7 +1,6 @@ """Cmd2 testing for argument parsing""" import argparse -from typing import Optional import pytest @@ -32,9 +31,8 @@ def _say_parser_builder() -> cmd2.Cmd2ArgumentParser: return say_parser @cmd2.with_argparser(_say_parser_builder) - def do_say(self, args, *, keyword_arg: Optional[str] = None) -> None: - """Repeat what you - tell me to. + def do_say(self, args, *, keyword_arg: str | None = None) -> None: + """Repeat what you tell me to. :param args: argparse namespace :param keyword_arg: Optional keyword arguments @@ -71,7 +69,7 @@ def do_test_argparse_ns(self, args) -> None: self.stdout.write(f'{args.custom_stuff}') @cmd2.with_argument_list - def do_arglist(self, arglist, *, keyword_arg: Optional[str] = None) -> None: + def do_arglist(self, arglist, *, keyword_arg: str | None = None) -> None: if isinstance(arglist, list): self.stdout.write('True') else: @@ -93,7 +91,7 @@ def _speak_parser_builder(cls) -> cmd2.Cmd2ArgumentParser: return known_parser @cmd2.with_argparser(_speak_parser_builder, with_unknown_args=True) - def do_speak(self, args, extra, *, keyword_arg: Optional[str] = None) -> None: + def do_speak(self, args, extra, *, keyword_arg: str | None = None) -> None: """Repeat what you tell me to.""" words = [] for word in extra: @@ -212,8 +210,7 @@ def test_argparse_help_docstring(argparse_app) -> None: out, err = run_cmd(argparse_app, 'help say') assert out[0].startswith('Usage: say') assert out[1] == '' - assert out[2] == 'Repeat what you' - assert out[3] == 'tell me to.' + assert out[2] == 'Repeat what you tell me to.' for line in out: assert not line.startswith(':') @@ -292,7 +289,7 @@ def base_helpless(self, args) -> None: parser_bar.set_defaults(func=base_bar) # create the parser for the "helpless" subcommand - # This subcommand has aliases and no help text. It exists to prevent changes to _set_parser_prog() which + # This subcommand has aliases and no help text. It exists to prevent changes to set_parser_prog() which # use an approach which relies on action._choices_actions list. See comment in that function for more # details. parser_helpless = base_subparsers.add_parser('helpless', aliases=['helpless_1', 'helpless_2']) @@ -362,39 +359,39 @@ def test_subcommand_help(subcommand_app) -> None: out, err = run_cmd(subcommand_app, 'help base foo') assert out[0].startswith('Usage: base foo') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' # bar has aliases (usage should never show alias name) out, err = run_cmd(subcommand_app, 'help base bar') assert out[0].startswith('Usage: base bar') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' out, err = run_cmd(subcommand_app, 'help base bar_1') assert out[0].startswith('Usage: base bar') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' out, err = run_cmd(subcommand_app, 'help base bar_2') assert out[0].startswith('Usage: base bar') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' # helpless has aliases and no help text (usage should never show alias name) out, err = run_cmd(subcommand_app, 'help base helpless') assert out[0].startswith('Usage: base helpless') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' out, err = run_cmd(subcommand_app, 'help base helpless_1') assert out[0].startswith('Usage: base helpless') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' out, err = run_cmd(subcommand_app, 'help base helpless_2') assert out[0].startswith('Usage: base helpless') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' def test_subcommand_invalid_help(subcommand_app) -> None: @@ -403,7 +400,7 @@ def test_subcommand_invalid_help(subcommand_app) -> None: def test_add_another_subcommand(subcommand_app) -> None: - """This tests makes sure _set_parser_prog() sets _prog_prefix on every _SubParsersAction so that all future calls + """This tests makes sure set_parser_prog() sets _prog_prefix on every _SubParsersAction so that all future calls to add_parser() write the correct prog value to the parser being added. """ base_parser = subcommand_app._command_parsers.get(subcommand_app.do_base) @@ -428,7 +425,7 @@ def test_subcmd_decorator(subcommand_app) -> None: # Test subcommand that has no help option out, err = run_cmd(subcommand_app, 'test_subcmd_decorator helpless_subcmd') - assert "'subcommand': 'helpless_subcmd'" in out[0] + assert "'subcommand': 'helpless_subcmd'" in out[1] out, err = run_cmd(subcommand_app, 'help test_subcmd_decorator helpless_subcmd') assert out[0] == 'Usage: test_subcmd_decorator helpless_subcmd' diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index f6561321a..e4cdab795 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -5,8 +5,10 @@ from typing import cast import pytest +from rich.text import Text import cmd2 +import cmd2.string_utils as su from cmd2 import ( Cmd2ArgumentParser, CompletionError, @@ -15,15 +17,13 @@ argparse_custom, with_argparser, ) -from cmd2.utils import ( - StdSim, - align_right, -) +from cmd2 import rich_utils as ru from .conftest import ( complete_tester, normalize, run_cmd, + with_ansi_style, ) # Data and functions for testing standalone choice_provider and completer @@ -105,17 +105,26 @@ def do_pos_and_flag(self, args: argparse.Namespace) -> None: ############################################################################################################ STR_METAVAR = "HEADLESS" TUPLE_METAVAR = ('arg1', 'others') - CUSTOM_DESC_HEADER = "Custom Header" + CUSTOM_DESC_HEADERS = ("Custom Headers",) # tuples (for sake of immutability) used in our tests (there is a mix of sorted and unsorted on purpose) non_negative_num_choices = (1, 2, 3, 0.5, 22) num_choices = (-1, 1, -2, 2.5, 0, -12) static_choices_list = ('static', 'choices', 'stop', 'here') choices_from_provider = ('choices', 'provider', 'probably', 'improved') - completion_item_choices = (CompletionItem('choice_1', 'A description'), CompletionItem('choice_2', 'Another description')) + completion_item_choices = ( + CompletionItem('choice_1', ['Description 1']), + # Make this the longest description so we can test display width. + CompletionItem('choice_2', [su.stylize("String with style", style=cmd2.Color.BLUE)]), + CompletionItem('choice_3', [Text("Text with style", style=cmd2.Color.RED)]), + ) # This tests that CompletionItems created with numerical values are sorted as numbers. - num_completion_items = (CompletionItem(5, "Five"), CompletionItem(1.5, "One.Five"), CompletionItem(2, "Five")) + num_completion_items = ( + CompletionItem(5, ["Five"]), + CompletionItem(1.5, ["One.Five"]), + CompletionItem(2, ["Five"]), + ) def choices_provider(self) -> tuple[str]: """Method that provides choices""" @@ -126,7 +135,7 @@ def completion_item_method(self) -> list[CompletionItem]: items = [] for i in range(10): main_str = f'main_str{i}' - items.append(CompletionItem(main_str, description='blah blah')) + items.append(CompletionItem(main_str, ['blah blah'])) return items choices_parser = Cmd2ArgumentParser() @@ -140,7 +149,7 @@ def completion_item_method(self) -> list[CompletionItem]: "--desc_header", help='this arg has a descriptive header', choices_provider=completion_item_method, - descriptive_header=CUSTOM_DESC_HEADER, + descriptive_headers=CUSTOM_DESC_HEADERS, ) choices_parser.add_argument( "--no_header", @@ -334,9 +343,7 @@ def do_standalone(self, args: argparse.Namespace) -> None: @pytest.fixture def ac_app(): - app = ArgparseCompleterTester() - app.stdout = StdSim(app.stdout) - return app + return ArgparseCompleterTester() @pytest.mark.parametrize('command', ['music', 'music create', 'music create rock', 'music create jazz']) @@ -707,6 +714,7 @@ def test_autocomp_blank_token(ac_app) -> None: assert sorted(completions) == sorted(ArgparseCompleterTester.completions_for_pos_2) +@with_ansi_style(ru.AllowStyle.ALWAYS) def test_completion_items(ac_app) -> None: # First test CompletionItems created from strings text = '' @@ -719,16 +727,20 @@ def test_completion_items(ac_app) -> None: assert len(ac_app.completion_matches) == len(ac_app.completion_item_choices) assert len(ac_app.display_matches) == len(ac_app.completion_item_choices) - # Look for both the value and description in the hint table - line_found = False - for line in ac_app.formatted_completions.splitlines(): - # Since the CompletionItems were created from strings, the left-most column is left-aligned. - # Therefore choice_1 will begin the line. - if line.startswith('choice_1') and 'A description' in line: - line_found = True - break + lines = ac_app.formatted_completions.splitlines() + + # Since the CompletionItems were created from strings, the left-most column is left-aligned. + # Therefore choice_1 will begin the line (with 1 space for padding). + assert lines[2].startswith(' choice_1') + assert lines[2].strip().endswith('Description 1') + + # Verify that the styled string was converted to a Rich Text object so that + # Rich could correctly calculate its display width. Since it was the longest + # description in the table, we should only see one space of padding after it. + assert lines[3].endswith("\x1b[34mString with style\x1b[0m ") - assert line_found + # Verify that the styled Rich Text also rendered. + assert lines[4].endswith("\x1b[31mText with style \x1b[0m ") # Now test CompletionItems created from numbers text = '' @@ -741,17 +753,12 @@ def test_completion_items(ac_app) -> None: assert len(ac_app.completion_matches) == len(ac_app.num_completion_items) assert len(ac_app.display_matches) == len(ac_app.num_completion_items) - # Look for both the value and description in the hint table - line_found = False - aligned_val = align_right('1.5', width=cmd2.ansi.style_aware_wcswidth('num_completion_items')) - for line in ac_app.formatted_completions.splitlines(): - # Since the CompletionItems were created from numbers, the left-most column is right-aligned. - # Therefore 1.5 will be right-aligned in a field as wide as the arg ("num_completion_items"). - if line.startswith(aligned_val) and 'One.Five' in line: - line_found = True - break + lines = ac_app.formatted_completions.splitlines() - assert line_found + # Since the CompletionItems were created from numbers, the left-most column is right-aligned. + # Therefore 1.5 will be right-aligned. + assert lines[2].startswith(" 1.5") + assert lines[2].strip().endswith('One.Five') @pytest.mark.parametrize( @@ -953,9 +960,9 @@ def test_completion_items_arg_header(ac_app) -> None: assert ac_app.TUPLE_METAVAR[1].upper() in normalize(ac_app.formatted_completions)[0] -def test_completion_items_descriptive_header(ac_app) -> None: +def test_completion_items_descriptive_headers(ac_app) -> None: from cmd2.argparse_completer import ( - DEFAULT_DESCRIPTIVE_HEADER, + DEFAULT_DESCRIPTIVE_HEADERS, ) # This argument provided a descriptive header @@ -965,16 +972,16 @@ def test_completion_items_descriptive_header(ac_app) -> None: begidx = endidx - len(text) complete_tester(text, line, begidx, endidx, ac_app) - assert ac_app.CUSTOM_DESC_HEADER in normalize(ac_app.formatted_completions)[0] + assert ac_app.CUSTOM_DESC_HEADERS[0] in normalize(ac_app.formatted_completions)[0] - # This argument did not provide a descriptive header, so it should be DEFAULT_DESCRIPTIVE_HEADER + # This argument did not provide a descriptive header, so it should be DEFAULT_DESCRIPTIVE_HEADERS text = '' line = f'choices --no_header {text}' endidx = len(line) begidx = endidx - len(text) complete_tester(text, line, begidx, endidx, ac_app) - assert DEFAULT_DESCRIPTIVE_HEADER in normalize(ac_app.formatted_completions)[0] + assert DEFAULT_DESCRIPTIVE_HEADERS[0] in normalize(ac_app.formatted_completions)[0] @pytest.mark.parametrize( diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index bd79910e3..2472ab743 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -237,30 +237,7 @@ def test_apcustom_required_options() -> None: # Make sure a 'required arguments' section shows when a flag is marked required parser = Cmd2ArgumentParser() parser.add_argument('--required_flag', required=True) - assert 'required arguments' in parser.format_help() - - -def test_override_parser() -> None: - """Test overriding argparse_custom.DEFAULT_ARGUMENT_PARSER""" - import importlib - - from cmd2 import ( - argparse_custom, - ) - - # The standard parser is Cmd2ArgumentParser - assert Cmd2ArgumentParser == argparse_custom.DEFAULT_ARGUMENT_PARSER - - # Set our parser module and force a reload of cmd2 so it loads the module - argparse.cmd2_parser_module = 'examples.custom_parser' - importlib.reload(cmd2) - - # Verify DEFAULT_ARGUMENT_PARSER is now our CustomParser - from examples.custom_parser import ( - CustomParser, - ) - - assert CustomParser == argparse_custom.DEFAULT_ARGUMENT_PARSER + assert 'Required Arguments' in parser.format_help() def test_apcustom_metavar_tuple() -> None: diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 8e23b7abd..2586da8c7 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -10,57 +10,41 @@ InteractiveConsole, ) from typing import NoReturn -from unittest import ( - mock, -) +from unittest import mock import pytest +from rich.text import Text import cmd2 from cmd2 import ( COMMAND_NAME, - ansi, + Cmd2Style, + Color, + RichPrintKwargs, clipboard, constants, exceptions, plugin, + stylize, utils, ) -from cmd2.rl_utils import ( - readline, # This ensures gnureadline is used in macOS tests -) +from cmd2 import rich_utils as ru +from cmd2 import string_utils as su + +# This ensures gnureadline is used in macOS tests +from cmd2.rl_utils import readline # type: ignore[atrr-defined] from .conftest import ( - HELP_HISTORY, - SET_TXT, SHORTCUTS_TXT, complete_tester, normalize, odd_file_names, run_cmd, verify_help_text, + with_ansi_style, ) -def with_ansi_style(style): - def arg_decorator(func): - import functools - - @functools.wraps(func) - def cmd_wrapper(*args, **kwargs): - old = ansi.allow_style - ansi.allow_style = style - try: - retval = func(*args, **kwargs) - finally: - ansi.allow_style = old - return retval - - return cmd_wrapper - - return arg_decorator - - def create_outsim_app(): c = cmd2.Cmd() c.stdout = utils.StdSim(c.stdout) @@ -151,15 +135,25 @@ def test_command_starts_with_shortcut() -> None: def test_base_set(base_app) -> None: - # force editor to be 'vim' so test is repeatable across platforms - base_app.editor = 'vim' + # Make sure all settables appear in output. out, err = run_cmd(base_app, 'set') - expected = normalize(SET_TXT) - assert out == expected + settables = sorted(base_app.settables.keys()) + + # The settables will appear in order in the table. + # Go line-by-line until all settables are found. + for line in out: + if not settables: + break + if line.lstrip().startswith(settables[0]): + settables.pop(0) + + # This will be empty if we found all settables in the output. + assert not settables + # Make sure all settables appear in last_result. assert len(base_app.last_result) == len(base_app.settables) for param in base_app.last_result: - assert base_app.last_result[param] == base_app.settables[param].get_value() + assert base_app.last_result[param] == base_app.settables[param].value def test_set(base_app) -> None: @@ -173,15 +167,14 @@ def test_set(base_app) -> None: assert out == expected assert base_app.last_result is True + line_found = False out, err = run_cmd(base_app, 'set quiet') - expected = normalize( - """ -Name Value Description -=================================================================================================== -quiet True Don't print nonessential feedback -""" - ) - assert out == expected + for line in out: + if "quiet" in line and "True" in line and "False" not in line: + line_found = True + break + + assert line_found assert len(base_app.last_result) == 1 assert base_app.last_result['quiet'] is True @@ -222,32 +215,27 @@ def test_set_no_settables(base_app) -> None: @pytest.mark.parametrize( ('new_val', 'is_valid', 'expected'), [ - (ansi.AllowStyle.NEVER, True, ansi.AllowStyle.NEVER), - ('neVeR', True, ansi.AllowStyle.NEVER), - (ansi.AllowStyle.TERMINAL, True, ansi.AllowStyle.TERMINAL), - ('TeRMInal', True, ansi.AllowStyle.TERMINAL), - (ansi.AllowStyle.ALWAYS, True, ansi.AllowStyle.ALWAYS), - ('AlWaYs', True, ansi.AllowStyle.ALWAYS), - ('invalid', False, ansi.AllowStyle.TERMINAL), + (ru.AllowStyle.NEVER, True, ru.AllowStyle.NEVER), + ('neVeR', True, ru.AllowStyle.NEVER), + (ru.AllowStyle.TERMINAL, True, ru.AllowStyle.TERMINAL), + ('TeRMInal', True, ru.AllowStyle.TERMINAL), + (ru.AllowStyle.ALWAYS, True, ru.AllowStyle.ALWAYS), + ('AlWaYs', True, ru.AllowStyle.ALWAYS), + ('invalid', False, ru.AllowStyle.TERMINAL), ], ) +@with_ansi_style(ru.AllowStyle.TERMINAL) def test_set_allow_style(base_app, new_val, is_valid, expected) -> None: - # Initialize allow_style for this test - ansi.allow_style = ansi.AllowStyle.TERMINAL - - # Use the set command to alter it + # Use the set command to alter allow_style out, err = run_cmd(base_app, f'set allow_style {new_val}') assert base_app.last_result is is_valid # Verify the results - assert ansi.allow_style == expected + assert expected == ru.ALLOW_STYLE if is_valid: assert not err assert out - # Reset allow_style to its default since it's an application-wide setting that can affect other unit tests - ansi.allow_style = ansi.AllowStyle.TERMINAL - def test_set_with_choices(base_app) -> None: """Test choices validation of Settables""" @@ -565,8 +553,8 @@ def test_relative_run_script_with_odd_file_names(base_app, file_name, monkeypatc run_script_mock = mock.MagicMock(name='do_run_script') monkeypatch.setattr("cmd2.Cmd.do_run_script", run_script_mock) - run_cmd(base_app, f"_relative_run_script {utils.quote_string(file_name)}") - run_script_mock.assert_called_once_with(utils.quote_string(file_name)) + run_cmd(base_app, f"_relative_run_script {su.quote(file_name)}") + run_script_mock.assert_called_once_with(su.quote(file_name)) def test_relative_run_script_requires_an_argument(base_app) -> None: @@ -626,38 +614,85 @@ def do_passthrough(self, _) -> NoReturn: base_app.onecmd_plus_hooks('passthrough') -def test_output_redirection(base_app) -> None: +class RedirectionApp(cmd2.Cmd): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + def do_print_output(self, _: str) -> None: + """Print output to sys.stdout and self.stdout..""" + print("print") + self.poutput("poutput") + + def do_print_feedback(self, _: str) -> None: + """Call pfeedback.""" + self.pfeedback("feedback") + + +@pytest.fixture +def redirection_app(): + return RedirectionApp() + + +def test_output_redirection(redirection_app) -> None: fd, filename = tempfile.mkstemp(prefix='cmd2_test', suffix='.txt') os.close(fd) try: # Verify that writing to a file works - run_cmd(base_app, f'help > {filename}') + run_cmd(redirection_app, f'print_output > {filename}') + with open(filename) as f: + lines = f.read().splitlines() + assert lines[0] == "print" + assert lines[1] == "poutput" + + # Verify that appending to a file also works + run_cmd(redirection_app, f'print_output >> {filename}') + with open(filename) as f: + lines = f.read().splitlines() + assert lines[0] == "print" + assert lines[1] == "poutput" + assert lines[2] == "print" + assert lines[3] == "poutput" + finally: + os.remove(filename) + + +def test_output_redirection_custom_stdout(redirection_app) -> None: + """sys.stdout should not redirect if it's different than self.stdout.""" + fd, filename = tempfile.mkstemp(prefix='cmd2_test', suffix='.txt') + os.close(fd) + + redirection_app.stdout = io.StringIO() + try: + # Verify that we only see output written to self.stdout + run_cmd(redirection_app, f'print_output > {filename}') with open(filename) as f: - content = f.read() - verify_help_text(base_app, content) + lines = f.read().splitlines() + assert "print" not in lines + assert lines[0] == "poutput" # Verify that appending to a file also works - run_cmd(base_app, f'help history >> {filename}') + run_cmd(redirection_app, f'print_output >> {filename}') with open(filename) as f: - appended_content = f.read() - assert appended_content.startswith(content) - assert len(appended_content) > len(content) + lines = f.read().splitlines() + assert "print" not in lines + assert lines[0] == "poutput" + assert lines[1] == "poutput" finally: os.remove(filename) -def test_output_redirection_to_nonexistent_directory(base_app) -> None: +def test_output_redirection_to_nonexistent_directory(redirection_app) -> None: filename = '~/fakedir/this_does_not_exist.txt' - out, err = run_cmd(base_app, f'help > {filename}') + out, err = run_cmd(redirection_app, f'print_output > {filename}') assert 'Failed to redirect' in err[0] - out, err = run_cmd(base_app, f'help >> {filename}') + out, err = run_cmd(redirection_app, f'print_output >> {filename}') assert 'Failed to redirect' in err[0] -def test_output_redirection_to_too_long_filename(base_app) -> None: +def test_output_redirection_to_too_long_filename(redirection_app) -> None: filename = ( '~/sdkfhksdjfhkjdshfkjsdhfkjsdhfkjdshfkjdshfkjshdfkhdsfkjhewfuihewiufhweiufhiweufhiuewhiuewhfiuwehfia' 'ewhfiuewhfiuewhfiuewhiuewhfiuewhfiuewfhiuwehewiufhewiuhfiweuhfiuwehfiuewfhiuwehiuewfhiuewhiewuhfiueh' @@ -666,93 +701,86 @@ def test_output_redirection_to_too_long_filename(base_app) -> None: 'whfieuwfhieufhiuewhfeiuwfhiuefhueiwhfw' ) - out, err = run_cmd(base_app, f'help > {filename}') + out, err = run_cmd(redirection_app, f'print_output > {filename}') assert 'Failed to redirect' in err[0] - out, err = run_cmd(base_app, f'help >> {filename}') + out, err = run_cmd(redirection_app, f'print_output >> {filename}') assert 'Failed to redirect' in err[0] -def test_feedback_to_output_true(base_app) -> None: - base_app.feedback_to_output = True - base_app.timing = True +def test_feedback_to_output_true(redirection_app) -> None: + redirection_app.feedback_to_output = True f, filename = tempfile.mkstemp(prefix='cmd2_test', suffix='.txt') os.close(f) try: - run_cmd(base_app, f'help > {filename}') + run_cmd(redirection_app, f'print_feedback > {filename}') with open(filename) as f: - content = f.readlines() - assert content[-1].startswith('Elapsed: ') + content = f.read().splitlines() + assert "feedback" in content finally: os.remove(filename) -def test_feedback_to_output_false(base_app) -> None: - base_app.feedback_to_output = False - base_app.timing = True +def test_feedback_to_output_false(redirection_app) -> None: + redirection_app.feedback_to_output = False f, filename = tempfile.mkstemp(prefix='feedback_to_output', suffix='.txt') os.close(f) try: - out, err = run_cmd(base_app, f'help > {filename}') + out, err = run_cmd(redirection_app, f'print_feedback > {filename}') with open(filename) as f: - content = f.readlines() - assert not content[-1].startswith('Elapsed: ') - assert err[0].startswith('Elapsed') + content = f.read().splitlines() + assert not content + assert "feedback" in err finally: os.remove(filename) -def test_disallow_redirection(base_app) -> None: +def test_disallow_redirection(redirection_app) -> None: # Set allow_redirection to False - base_app.allow_redirection = False + redirection_app.allow_redirection = False filename = 'test_allow_redirect.txt' # Verify output wasn't redirected - out, err = run_cmd(base_app, f'help > {filename}') - verify_help_text(base_app, out) + out, err = run_cmd(redirection_app, f'print_output > {filename}') + assert "print" in out + assert "poutput" in out # Verify that no file got created assert not os.path.exists(filename) -def test_pipe_to_shell(base_app) -> None: - if sys.platform == "win32": - # Windows - command = 'help | sort' - else: - # Mac and Linux - # Get help on help and pipe it's output to the input of the word count shell command - command = 'help help | wc' +def test_pipe_to_shell(redirection_app) -> None: + out, err = run_cmd(redirection_app, "print_output | sort") + assert "print" in out + assert "poutput" in out + assert not err - out, err = run_cmd(base_app, command) - assert out + +def test_pipe_to_shell_custom_stdout(redirection_app) -> None: + """sys.stdout should not redirect if it's different than self.stdout.""" + redirection_app.stdout = io.StringIO() + out, err = run_cmd(redirection_app, "print_output | sort") + assert "print" not in out + assert "poutput" in out assert not err -def test_pipe_to_shell_and_redirect(base_app) -> None: +def test_pipe_to_shell_and_redirect(redirection_app) -> None: filename = 'out.txt' - if sys.platform == "win32": - # Windows - command = f'help | sort > {filename}' - else: - # Mac and Linux - # Get help on help and pipe it's output to the input of the word count shell command - command = f'help help | wc > {filename}' - - out, err = run_cmd(base_app, command) + out, err = run_cmd(redirection_app, f"print_output | sort > {filename}") assert not out assert not err assert os.path.exists(filename) os.remove(filename) -def test_pipe_to_shell_error(base_app) -> None: +def test_pipe_to_shell_error(redirection_app) -> None: # Try to pipe command output to a shell command that doesn't exist in order to produce an error - out, err = run_cmd(base_app, 'help | foobarbaz.this_does_not_exist') + out, err = run_cmd(redirection_app, 'print_output | foobarbaz.this_does_not_exist') assert not out assert "Pipe process exited with code" in err[0] @@ -772,26 +800,48 @@ def test_pipe_to_shell_error(base_app) -> None: @pytest.mark.skipif(not can_paste, reason="Pyperclip could not find a copy/paste mechanism for your system") -def test_send_to_paste_buffer(base_app) -> None: +def test_send_to_paste_buffer(redirection_app) -> None: # Test writing to the PasteBuffer/Clipboard - run_cmd(base_app, 'help >') - paste_contents = cmd2.cmd2.get_paste_buffer() - verify_help_text(base_app, paste_contents) + run_cmd(redirection_app, 'print_output >') + lines = cmd2.cmd2.get_paste_buffer().splitlines() + assert lines[0] == "print" + assert lines[1] == "poutput" # Test appending to the PasteBuffer/Clipboard - run_cmd(base_app, 'help history >>') - appended_contents = cmd2.cmd2.get_paste_buffer() - assert appended_contents.startswith(paste_contents) - assert len(appended_contents) > len(paste_contents) + run_cmd(redirection_app, 'print_output >>') + lines = cmd2.cmd2.get_paste_buffer().splitlines() + assert lines[0] == "print" + assert lines[1] == "poutput" + assert lines[2] == "print" + assert lines[3] == "poutput" + + +@pytest.mark.skipif(not can_paste, reason="Pyperclip could not find a copy/paste mechanism for your system") +def test_send_to_paste_buffer_custom_stdout(redirection_app) -> None: + """sys.stdout should not redirect if it's different than self.stdout.""" + redirection_app.stdout = io.StringIO() + # Verify that we only see output written to self.stdout + run_cmd(redirection_app, 'print_output >') + lines = cmd2.cmd2.get_paste_buffer().splitlines() + assert "print" not in lines + assert lines[0] == "poutput" -def test_get_paste_buffer_exception(base_app, mocker, capsys) -> None: + # Test appending to the PasteBuffer/Clipboard + run_cmd(redirection_app, 'print_output >>') + lines = cmd2.cmd2.get_paste_buffer().splitlines() + assert "print" not in lines + assert lines[0] == "poutput" + assert lines[1] == "poutput" + + +def test_get_paste_buffer_exception(redirection_app, mocker, capsys) -> None: # Force get_paste_buffer to throw an exception pastemock = mocker.patch('pyperclip.paste') pastemock.side_effect = ValueError('foo') # Redirect command output to the clipboard - base_app.onecmd_plus_hooks('help > ') + redirection_app.onecmd_plus_hooks('print_output > ') # Make sure we got the exception output out, err = capsys.readouterr() @@ -801,8 +851,8 @@ def test_get_paste_buffer_exception(base_app, mocker, capsys) -> None: assert 'foo' in err -def test_allow_clipboard_initializer(base_app) -> None: - assert base_app.allow_clipboard is True +def test_allow_clipboard_initializer(redirection_app) -> None: + assert redirection_app.allow_clipboard is True noclipcmd = cmd2.Cmd(allow_clipboard=False) assert noclipcmd.allow_clipboard is False @@ -834,29 +884,14 @@ def test_base_timing(base_app) -> None: assert err[0].startswith('Elapsed: 0:00:00.0') -def _expected_no_editor_error(): - expected_exception = 'OSError' - # If PyPy, expect a different exception than with Python 3 - if hasattr(sys, "pypy_translation_info"): - expected_exception = 'EnvironmentError' - - return normalize( - f""" -EXCEPTION of type '{expected_exception}' occurred with message: Please use 'set editor' to specify your text editing program of choice. -To enable full traceback, run the following command: 'set debug true' -""" - ) - - def test_base_debug(base_app) -> None: # Purposely set the editor to None base_app.editor = None # Make sure we get an exception, but cmd2 handles it out, err = run_cmd(base_app, 'edit') - - expected = _expected_no_editor_error() - assert err == expected + assert "ValueError: Please use 'set editor'" in err[0] + assert "To enable full traceback" in err[3] # Set debug true out, err = run_cmd(base_app, 'set debug True') @@ -870,7 +905,7 @@ def test_base_debug(base_app) -> None: # Verify that we now see the exception traceback out, err = run_cmd(base_app, 'edit') - assert err[0].startswith('Traceback (most recent call last):') + assert 'Traceback (most recent call last)' in err[0] def test_debug_not_settable(base_app) -> None: @@ -878,11 +913,20 @@ def test_debug_not_settable(base_app) -> None: base_app.debug = False base_app.remove_settable('debug') - # Cause an exception - out, err = run_cmd(base_app, 'bad "quote') + # Cause an exception by setting editor to None and running edit + base_app.editor = None + out, err = run_cmd(base_app, 'edit') # Since debug is unsettable, the user will not be given the option to enable a full traceback - assert err == ['Invalid syntax: No closing quotation'] + assert err == ["ValueError: Please use 'set editor' to specify your text editing program of", 'choice.'] + + +def test_blank_exception(mocker, base_app): + mocker.patch("cmd2.Cmd.do_help", side_effect=Exception) + out, err = run_cmd(base_app, 'help') + + # When an exception has no message, the first error line is just its type. + assert err[0] == "Exception" def test_remove_settable_keyerror(base_app) -> None: @@ -915,9 +959,9 @@ def test_edit_file_with_odd_file_names(base_app, file_name, monkeypatch) -> None monkeypatch.setattr("cmd2.Cmd.do_shell", shell_mock) base_app.editor = 'fooedit' - file_name = utils.quote_string('nothingweird.py') - run_cmd(base_app, f"edit {utils.quote_string(file_name)}") - shell_mock.assert_called_once_with(f'"fooedit" {utils.quote_string(file_name)}') + file_name = su.quote('nothingweird.py') + run_cmd(base_app, f"edit {su.quote(file_name)}") + shell_mock.assert_called_once_with(f'"fooedit" {su.quote(file_name)}') def test_edit_file_with_spaces(base_app, request, monkeypatch) -> None: @@ -1024,16 +1068,70 @@ def test_cmdloop_without_rawinput() -> None: assert out == expected -@pytest.mark.skipif(sys.platform.startswith('win'), reason="stty sane only run on Linux/Mac") -def test_stty_sane(base_app, monkeypatch) -> None: - """Make sure stty sane is run on Linux/Mac after each command if stdin is a terminal""" - with mock.patch('sys.stdin.isatty', mock.MagicMock(name='isatty', return_value=True)): - # Mock out the subprocess.Popen call so we don't actually run stty sane - m = mock.MagicMock(name='Popen') - monkeypatch.setattr("subprocess.Popen", m) +def test_cmdfinalizations_runs(base_app, monkeypatch) -> None: + """Make sure _run_cmdfinalization_hooks is run after each command.""" + with ( + mock.patch('sys.stdin.isatty', mock.MagicMock(name='isatty', return_value=True)), + mock.patch('sys.stdin.fileno', mock.MagicMock(name='fileno', return_value=0)), + ): + monkeypatch.setattr(base_app.stdin, "fileno", lambda: 0) + monkeypatch.setattr(base_app.stdin, "isatty", lambda: True) + + cmd_fin = mock.MagicMock(name='cmdfinalization') + monkeypatch.setattr("cmd2.Cmd._run_cmdfinalization_hooks", cmd_fin) base_app.onecmd_plus_hooks('help') - m.assert_called_once_with(['stty', 'sane']) + cmd_fin.assert_called_once() + + +@pytest.mark.skipif(sys.platform.startswith('win'), reason="termios is not available on Windows") +@pytest.mark.parametrize( + ('is_tty', 'settings_set', 'raised_exception', 'should_call'), + [ + (True, True, None, True), + (True, True, 'termios_error', True), + (True, True, 'unsupported_operation', True), + (False, True, None, False), + (True, False, None, False), + ], +) +def test_restore_termios_settings(base_app, monkeypatch, is_tty, settings_set, raised_exception, should_call): + """Test that terminal settings are restored after a command and that errors are suppressed.""" + import io + import termios # Mock termios since it's imported within the method + + termios_mock = mock.MagicMock() + # The error attribute needs to be the actual exception for isinstance checks + termios_mock.error = termios.error + monkeypatch.setitem(sys.modules, 'termios', termios_mock) + + # Set the exception to be raised by tcsetattr + if raised_exception == 'termios_error': + termios_mock.tcsetattr.side_effect = termios.error("test termios error") + elif raised_exception == 'unsupported_operation': + termios_mock.tcsetattr.side_effect = io.UnsupportedOperation("test io error") + + # Set initial termios settings so the logic will run + if settings_set: + termios_settings = ["dummy settings"] + base_app._initial_termios_settings = termios_settings + else: + base_app._initial_termios_settings = None + termios_settings = None # for the assert + + # Mock stdin to make it look like a TTY + monkeypatch.setattr(base_app.stdin, "isatty", lambda: is_tty) + monkeypatch.setattr(base_app.stdin, "fileno", lambda: 0) + + # Run a command to trigger _run_cmdfinalization_hooks + # This should not raise an exception + base_app.onecmd_plus_hooks('help') + + # Verify that tcsetattr was called with the correct arguments + if should_call: + termios_mock.tcsetattr.assert_called_once_with(0, termios_mock.TCSANOW, termios_settings) + else: + termios_mock.tcsetattr.assert_not_called() def test_sigint_handler(base_app) -> None: @@ -1149,8 +1247,7 @@ def test_escaping_prompt() -> None: assert rl_escape_prompt(prompt) == prompt # This prompt has color which needs to be escaped - color = ansi.Fg.CYAN - prompt = ansi.style('InColor', fg=color) + prompt = stylize('InColor', style=Color.CYAN) escape_start = "\x01" escape_end = "\x02" @@ -1160,8 +1257,10 @@ def test_escaping_prompt() -> None: # PyReadline on Windows doesn't need to escape invisible characters assert escaped_prompt == prompt else: - assert escaped_prompt.startswith(escape_start + color + escape_end) - assert escaped_prompt.endswith(escape_start + ansi.Fg.RESET + escape_end) + cyan = "\x1b[36m" + reset_all = "\x1b[0m" + assert escaped_prompt.startswith(escape_start + cyan + escape_end) + assert escaped_prompt.endswith(escape_start + reset_all + escape_end) assert rl_unescape_prompt(escaped_prompt) == prompt @@ -1171,6 +1270,10 @@ class HelpApp(cmd2.Cmd): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) + self.doc_leader = "I now present you with a list of help topics." + self.doc_header = "My very custom doc header." + self.misc_header = "Various topics found here." + self.undoc_header = "Why did no one document these?" def do_squat(self, arg) -> None: """This docstring help will never be shown because the help_squat method overrides it.""" @@ -1192,6 +1295,10 @@ def do_multiline_docstr(self, arg) -> None: tabs """ + def help_physics(self): + """A miscellaneous help topic.""" + self.poutput("Here is some help on physics.") + parser_cmd_parser = cmd2.Cmd2ArgumentParser(description="This is the description.") @cmd2.with_argparser(parser_cmd_parser) @@ -1204,6 +1311,18 @@ def help_app(): return HelpApp() +def test_help_headers(capsys) -> None: + help_app = HelpApp() + help_app.onecmd_plus_hooks('help') + out, err = capsys.readouterr() + + assert help_app.doc_leader in out + assert help_app.doc_header in out + assert help_app.misc_header in out + assert help_app.undoc_header in out + assert help_app.last_result is True + + def test_custom_command_help(help_app) -> None: out, err = run_cmd(help_app, 'help squat') expected = normalize('This command does diddly squat...') @@ -1214,6 +1333,7 @@ def test_custom_command_help(help_app) -> None: def test_custom_help_menu(help_app) -> None: out, err = run_cmd(help_app, 'help') verify_help_text(help_app, out) + assert help_app.last_result is True def test_help_undocumented(help_app) -> None: @@ -1236,14 +1356,67 @@ def test_help_multiline_docstring(help_app) -> None: assert help_app.last_result is True +def test_miscellaneous_help_topic(help_app) -> None: + out, err = run_cmd(help_app, 'help physics') + expected = normalize("Here is some help on physics.") + assert out == expected + assert help_app.last_result is True + + def test_help_verbose_uses_parser_description(help_app: HelpApp) -> None: out, err = run_cmd(help_app, 'help --verbose') - verify_help_text(help_app, out, verbose_strings=[help_app.parser_cmd_parser.description]) + expected_verbose = utils.strip_doc_annotations(help_app.do_parser_cmd.__doc__) + verify_help_text(help_app, out, verbose_strings=[expected_verbose]) + + +def test_help_verbose_with_fake_command(capsys) -> None: + """Verify that only actual command functions appear in verbose output.""" + help_app = HelpApp() + + cmds = ["alias", "fake_command"] + help_app._print_documented_command_topics(help_app.doc_header, cmds, verbose=True) + out, err = capsys.readouterr() + assert cmds[0] in out + assert cmds[1] not in out + + +def test_render_columns_no_strs(help_app: HelpApp) -> None: + no_strs = [] + result = help_app.render_columns(no_strs) + assert result == "" + + +def test_render_columns_one_str(help_app: HelpApp) -> None: + one_str = ["one_string"] + result = help_app.render_columns(one_str) + assert result == "one_string" + + +def test_render_columns_too_wide(help_app: HelpApp) -> None: + commands = ["kind_of_long_string", "a_slightly_longer_string"] + result = help_app.render_columns(commands, display_width=10) + + expected = "kind_of_long_string \na_slightly_longer_string" + assert result == expected + + +def test_columnize(capsys: pytest.CaptureFixture[str]) -> None: + help_app = HelpApp() + items = ["one", "two"] + help_app.columnize(items) + out, err = capsys.readouterr() + + # poutput() adds a newline at the end. + expected = "one two\n" + assert out == expected class HelpCategoriesApp(cmd2.Cmd): """Class for testing custom help_* methods which override docstring help.""" + SOME_CATEGORY = "Some Category" + CUSTOM_CATEGORY = "Custom Category" + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) @@ -1252,10 +1425,11 @@ def do_diddly(self, arg) -> None: """This command does diddly""" # This command will be in the "Some Category" section of the help menu even though it has no docstring - @cmd2.with_category("Some Category") + @cmd2.with_category(SOME_CATEGORY) def do_cat_nodoc(self, arg) -> None: pass + # This command will show in the category labeled with self.default_category def do_squat(self, arg) -> None: """This docstring help will never be shown because the help_squat method overrides it.""" @@ -1265,7 +1439,7 @@ def help_squat(self) -> None: def do_edit(self, arg) -> None: """This overrides the edit command and does nothing.""" - cmd2.categorize((do_squat, do_edit), 'Custom Category') + cmd2.categorize((do_squat, do_edit), CUSTOM_CATEGORY) # This command will be in the "undocumented" section of the help menu def do_undoc(self, arg) -> None: @@ -1282,12 +1456,22 @@ def test_help_cat_base(helpcat_app) -> None: assert helpcat_app.last_result is True verify_help_text(helpcat_app, out) + help_text = ''.join(out) + assert helpcat_app.CUSTOM_CATEGORY in help_text + assert helpcat_app.SOME_CATEGORY in help_text + assert helpcat_app.default_category in help_text + def test_help_cat_verbose(helpcat_app) -> None: out, err = run_cmd(helpcat_app, 'help --verbose') assert helpcat_app.last_result is True verify_help_text(helpcat_app, out) + help_text = ''.join(out) + assert helpcat_app.CUSTOM_CATEGORY in help_text + assert helpcat_app.SOME_CATEGORY in help_text + assert helpcat_app.default_category in help_text + class SelectApp(cmd2.Cmd): def do_eat(self, arg) -> None: @@ -1554,7 +1738,7 @@ def test_help_with_no_docstring(capsys) -> None: out == """Usage: greet [-h] [-s] -optional arguments: +Optional Arguments: -h, --help show this help message and exit -s, --shout N00B EMULATION MODE @@ -1801,7 +1985,7 @@ def test_echo(capsys) -> None: app.runcmds_plus_hooks(commands) out, err = capsys.readouterr() - assert out.startswith(f'{app.prompt}{commands[0]}\n' + HELP_HISTORY.split()[0]) + assert out.startswith(f'{app.prompt}{commands[0]}\nUsage: history') def test_read_input_rawinput_true(capsys, monkeypatch) -> None: @@ -1962,46 +2146,142 @@ def test_poutput_none(outsim_app) -> None: assert out == expected -def test_ppretty_dict(outsim_app) -> None: - data = { - "name": "John Doe", - "age": 30, - "address": {"street": "123 Main St", "city": "Anytown", "state": "CA"}, - "hobbies": ["reading", "hiking", "coding"], - } - outsim_app.ppretty(data) +@with_ansi_style(ru.AllowStyle.ALWAYS) +@pytest.mark.parametrize( + # Test a Rich Text and a string. + ('styled_msg', 'expected'), + [ + (Text("A Text object", style="cyan"), "\x1b[36mA Text object\x1b[0m\n"), + (su.stylize("A str object", style="blue"), "\x1b[34mA str object\x1b[0m\n"), + ], +) +def test_poutput_ansi_always(styled_msg, expected, outsim_app) -> None: + outsim_app.poutput(styled_msg) out = outsim_app.stdout.getvalue() - expected = """ -{ 'address': {'city': 'Anytown', 'state': 'CA', 'street': '123 Main St'}, - 'age': 30, - 'hobbies': ['reading', 'hiking', 'coding'], - 'name': 'John Doe'} -""" - assert out == expected.lstrip() + assert out == expected -@with_ansi_style(ansi.AllowStyle.ALWAYS) -def test_poutput_ansi_always(outsim_app) -> None: - msg = 'Hello World' - colored_msg = ansi.style(msg, fg=ansi.Fg.CYAN) - outsim_app.poutput(colored_msg) +@with_ansi_style(ru.AllowStyle.NEVER) +@pytest.mark.parametrize( + # Test a Rich Text and a string. + ('styled_msg', 'expected'), + [ + (Text("A Text object", style="cyan"), "A Text object\n"), + (su.stylize("A str object", style="blue"), "A str object\n"), + ], +) +def test_poutput_ansi_never(styled_msg, expected, outsim_app) -> None: + outsim_app.poutput(styled_msg) out = outsim_app.stdout.getvalue() - expected = colored_msg + '\n' - assert colored_msg != msg assert out == expected -@with_ansi_style(ansi.AllowStyle.NEVER) -def test_poutput_ansi_never(outsim_app) -> None: - msg = 'Hello World' - colored_msg = ansi.style(msg, fg=ansi.Fg.CYAN) +@with_ansi_style(ru.AllowStyle.TERMINAL) +def test_poutput_ansi_terminal(outsim_app) -> None: + """Test that AllowStyle.TERMINAL strips style when redirecting.""" + msg = 'testing...' + colored_msg = Text(msg, style="cyan") + outsim_app._redirecting = True outsim_app.poutput(colored_msg) out = outsim_app.stdout.getvalue() expected = msg + '\n' - assert colored_msg != msg assert out == expected +@with_ansi_style(ru.AllowStyle.ALWAYS) +def test_poutput_highlight(outsim_app): + outsim_app.poutput("My IP Address is 192.168.1.100.", highlight=True) + out = outsim_app.stdout.getvalue() + assert out == "My IP Address is \x1b[1;92m192.168.1.100\x1b[0m.\n" + + +@with_ansi_style(ru.AllowStyle.ALWAYS) +def test_poutput_markup(outsim_app): + outsim_app.poutput("The leaves are [green]green[/green].", markup=True) + out = outsim_app.stdout.getvalue() + assert out == "The leaves are \x1b[32mgreen\x1b[0m.\n" + + +@with_ansi_style(ru.AllowStyle.ALWAYS) +def test_poutput_emoji(outsim_app): + outsim_app.poutput("Look at the emoji :1234:.", emoji=True) + out = outsim_app.stdout.getvalue() + assert out == "Look at the emoji 🔢.\n" + + +@with_ansi_style(ru.AllowStyle.ALWAYS) +def test_poutput_justify_and_width(outsim_app): + rich_print_kwargs = RichPrintKwargs(justify="right", width=10) + + # Use a styled-string when justifying to check if its display width is correct. + outsim_app.poutput( + su.stylize("Hello", style="blue"), + rich_print_kwargs=rich_print_kwargs, + ) + out = outsim_app.stdout.getvalue() + assert out == " \x1b[34mHello\x1b[0m\n" + + +@with_ansi_style(ru.AllowStyle.ALWAYS) +def test_poutput_no_wrap_and_overflow(outsim_app): + rich_print_kwargs = RichPrintKwargs(no_wrap=True, overflow="ellipsis", width=10) + + outsim_app.poutput( + "This is longer than width.", + soft_wrap=False, + rich_print_kwargs=rich_print_kwargs, + ) + out = outsim_app.stdout.getvalue() + assert out.startswith("This is l…\n") + + +@with_ansi_style(ru.AllowStyle.ALWAYS) +def test_poutput_pretty_print(outsim_app): + """Test that cmd2 passes objects through so they can be pretty-printed when highlighting is enabled.""" + dictionary = {1: 'hello', 2: 'person', 3: 'who', 4: 'codes'} + + outsim_app.poutput(dictionary, highlight=True) + out = outsim_app.stdout.getvalue() + assert out.startswith("\x1b[1m{\x1b[0m\x1b[1;36m1\x1b[0m: \x1b[32m'hello'\x1b[0m") + + +@with_ansi_style(ru.AllowStyle.ALWAYS) +def test_poutput_all_keyword_args(outsim_app): + """Test that all fields in RichPrintKwargs are recognized by Rich's Console.print().""" + rich_print_kwargs = RichPrintKwargs( + justify="center", + overflow="ellipsis", + no_wrap=True, + width=40, + height=50, + crop=False, + new_line_start=True, + ) + + outsim_app.poutput( + "My string", + rich_print_kwargs=rich_print_kwargs, + ) + + # Verify that something printed which means Console.print() didn't + # raise a TypeError for an unexpected keyword argument. + out = outsim_app.stdout.getvalue() + assert "My string" in out + + +def test_broken_pipe_error(outsim_app, monkeypatch, capsys): + write_mock = mock.MagicMock() + write_mock.side_effect = BrokenPipeError + monkeypatch.setattr("cmd2.utils.StdSim.write", write_mock) + + outsim_app.broken_pipe_warning = "The pipe broke" + outsim_app.poutput("My test string") + + out, err = capsys.readouterr() + assert not out + assert outsim_app.broken_pipe_warning in err + + # These are invalid names for aliases and macros invalid_command_name = [ '""', # Blank name @@ -2026,7 +2306,7 @@ def test_get_alias_completion_items(base_app) -> None: for cur_res in results: assert cur_res in base_app.aliases # Strip trailing spaces from table output - assert cur_res.description.rstrip() == base_app.aliases[cur_res] + assert cur_res.descriptive_data[0].rstrip() == base_app.aliases[cur_res] def test_get_macro_completion_items(base_app) -> None: @@ -2039,7 +2319,7 @@ def test_get_macro_completion_items(base_app) -> None: for cur_res in results: assert cur_res in base_app.macros # Strip trailing spaces from table output - assert cur_res.description.rstrip() == base_app.macros[cur_res].value + assert cur_res.descriptive_data[0].rstrip() == base_app.macros[cur_res].value def test_get_settable_completion_items(base_app) -> None: @@ -2052,12 +2332,12 @@ def test_get_settable_completion_items(base_app) -> None: # These CompletionItem descriptions are a two column table (Settable Value and Settable Description) # First check if the description text starts with the value - str_value = str(cur_settable.get_value()) - assert cur_res.description.startswith(str_value) + str_value = str(cur_settable.value) + assert cur_res.descriptive_data[0].startswith(str_value) # The second column is likely to have wrapped long text. So we will just examine the # first couple characters to look for the Settable's description. - assert cur_settable.description[0:10] in cur_res.description + assert cur_settable.description[0:10] in cur_res.descriptive_data[1] def test_alias_no_subcommand(base_app) -> None: @@ -2418,79 +2698,76 @@ def test_nonexistent_macro(base_app) -> None: assert exception is not None -@with_ansi_style(ansi.AllowStyle.ALWAYS) +@with_ansi_style(ru.AllowStyle.ALWAYS) def test_perror_style(base_app, capsys) -> None: msg = 'testing...' - end = '\n' base_app.perror(msg) out, err = capsys.readouterr() - assert err == ansi.style_error(msg) + end + assert err == "\x1b[91mtesting...\x1b[0m\n" -@with_ansi_style(ansi.AllowStyle.ALWAYS) +@with_ansi_style(ru.AllowStyle.ALWAYS) def test_perror_no_style(base_app, capsys) -> None: msg = 'testing...' end = '\n' - base_app.perror(msg, apply_style=False) + base_app.perror(msg, style=None) out, err = capsys.readouterr() assert err == msg + end -@with_ansi_style(ansi.AllowStyle.ALWAYS) +@with_ansi_style(ru.AllowStyle.ALWAYS) def test_pexcept_style(base_app, capsys) -> None: msg = Exception('testing...') base_app.pexcept(msg) out, err = capsys.readouterr() - assert err.startswith(ansi.style_error("EXCEPTION of type 'Exception' occurred with message: testing...")) + expected = su.stylize("Exception: ", style="traceback.exc_type") + assert err.startswith(expected) -@with_ansi_style(ansi.AllowStyle.ALWAYS) +@with_ansi_style(ru.AllowStyle.NEVER) def test_pexcept_no_style(base_app, capsys) -> None: msg = Exception('testing...') - base_app.pexcept(msg, apply_style=False) + base_app.pexcept(msg) out, err = capsys.readouterr() - assert err.startswith("EXCEPTION of type 'Exception' occurred with message: testing...") + assert err.startswith("Exception: testing...") -@with_ansi_style(ansi.AllowStyle.ALWAYS) -def test_pexcept_not_exception(base_app, capsys) -> None: - # Pass in a msg that is not an Exception object - msg = False +@pytest.mark.parametrize('chop', [True, False]) +def test_ppaged_with_pager(outsim_app, monkeypatch, chop) -> None: + """Force ppaged() to run the pager by mocking an actual terminal state.""" - base_app.pexcept(msg) - out, err = capsys.readouterr() - assert err.startswith(ansi.style_error(msg)) + # Make it look like we're in a terminal + stdin_mock = mock.MagicMock() + stdin_mock.isatty.return_value = True + monkeypatch.setattr(outsim_app, "stdin", stdin_mock) + stdout_mock = mock.MagicMock() + stdout_mock.isatty.return_value = True + monkeypatch.setattr(outsim_app, "stdout", stdout_mock) -def test_ppaged(outsim_app) -> None: - msg = 'testing...' - end = '\n' - outsim_app.ppaged(msg) - out = outsim_app.stdout.getvalue() - assert out == msg + end + if not sys.platform.startswith('win') and os.environ.get("TERM") is None: + monkeypatch.setenv('TERM', 'simulated') + # This will force ppaged to call Popen to run a pager + popen_mock = mock.MagicMock(name='Popen') + monkeypatch.setattr("subprocess.Popen", popen_mock) + outsim_app.ppaged("Test", chop=chop) -@with_ansi_style(ansi.AllowStyle.TERMINAL) -def test_ppaged_strips_ansi_when_redirecting(outsim_app) -> None: - msg = 'testing...' - end = '\n' - outsim_app._redirecting = True - outsim_app.ppaged(ansi.style(msg, fg=ansi.Fg.RED)) - out = outsim_app.stdout.getvalue() - assert out == msg + end + # Verify the correct pager was run + expected_cmd = outsim_app.pager_chop if chop else outsim_app.pager + assert len(popen_mock.call_args_list) == 1 + assert expected_cmd == popen_mock.call_args_list[0].args[0] -@with_ansi_style(ansi.AllowStyle.ALWAYS) -def test_ppaged_strips_ansi_when_redirecting_if_always(outsim_app) -> None: +def test_ppaged_no_pager(outsim_app) -> None: + """Since we're not in a fully-functional terminal, ppaged() will just call poutput().""" msg = 'testing...' end = '\n' - outsim_app._redirecting = True - colored_msg = ansi.style(msg, fg=ansi.Fg.RED) - outsim_app.ppaged(colored_msg) + outsim_app.ppaged(msg) out = outsim_app.stdout.getvalue() - assert out == colored_msg + end + assert out == msg + end # we override cmd.parseline() so we always get consistent @@ -2672,12 +2949,12 @@ def do_echo(self, args) -> None: self.perror(args) def do_echo_error(self, args) -> None: - self.poutput(ansi.style(args, fg=ansi.Fg.RED)) + self.poutput(args, style=Cmd2Style.ERROR) # perror uses colors by default self.perror(args) -@with_ansi_style(ansi.AllowStyle.ALWAYS) +@with_ansi_style(ru.AllowStyle.ALWAYS) def test_ansi_pouterr_always_tty(mocker, capsys) -> None: app = AnsiApp() mocker.patch.object(app.stdout, 'isatty', return_value=True) @@ -2700,7 +2977,7 @@ def test_ansi_pouterr_always_tty(mocker, capsys) -> None: assert 'oopsie' in err -@with_ansi_style(ansi.AllowStyle.ALWAYS) +@with_ansi_style(ru.AllowStyle.ALWAYS) def test_ansi_pouterr_always_notty(mocker, capsys) -> None: app = AnsiApp() mocker.patch.object(app.stdout, 'isatty', return_value=False) @@ -2723,7 +3000,7 @@ def test_ansi_pouterr_always_notty(mocker, capsys) -> None: assert 'oopsie' in err -@with_ansi_style(ansi.AllowStyle.TERMINAL) +@with_ansi_style(ru.AllowStyle.TERMINAL) def test_ansi_terminal_tty(mocker, capsys) -> None: app = AnsiApp() mocker.patch.object(app.stdout, 'isatty', return_value=True) @@ -2745,7 +3022,7 @@ def test_ansi_terminal_tty(mocker, capsys) -> None: assert 'oopsie' in err -@with_ansi_style(ansi.AllowStyle.TERMINAL) +@with_ansi_style(ru.AllowStyle.TERMINAL) def test_ansi_terminal_notty(mocker, capsys) -> None: app = AnsiApp() mocker.patch.object(app.stdout, 'isatty', return_value=False) @@ -2760,7 +3037,7 @@ def test_ansi_terminal_notty(mocker, capsys) -> None: assert out == err == 'oopsie\n' -@with_ansi_style(ansi.AllowStyle.NEVER) +@with_ansi_style(ru.AllowStyle.NEVER) def test_ansi_never_tty(mocker, capsys) -> None: app = AnsiApp() mocker.patch.object(app.stdout, 'isatty', return_value=True) @@ -2775,7 +3052,7 @@ def test_ansi_never_tty(mocker, capsys) -> None: assert out == err == 'oopsie\n' -@with_ansi_style(ansi.AllowStyle.NEVER) +@with_ansi_style(ru.AllowStyle.NEVER) def test_ansi_never_notty(mocker, capsys) -> None: app = AnsiApp() mocker.patch.object(app.stdout, 'isatty', return_value=False) @@ -2988,7 +3265,7 @@ def test_startup_script_with_odd_file_names(startup_script) -> None: app = cmd2.Cmd(allow_cli_args=False, startup_script=startup_script) assert len(app._startup_commands) == 1 - assert app._startup_commands[0] == f"run_script {utils.quote_string(os.path.abspath(startup_script))}" + assert app._startup_commands[0] == f"run_script {su.quote(os.path.abspath(startup_script))}" # Restore os.path.exists os.path.exists = saved_exists @@ -3000,15 +3277,6 @@ def test_transcripts_at_init() -> None: assert app._transcript_files == transcript_files -def test_columnize_too_wide(outsim_app) -> None: - """Test calling columnize with output that wider than display_width""" - str_list = ["way too wide", "much wider than the first"] - outsim_app.columnize(str_list, display_width=5) - - expected = "\n".join(str_list) + "\n" - assert outsim_app.stdout.getvalue() == expected - - def test_command_parser_retrieval(outsim_app: cmd2.Cmd) -> None: # Pass something that isn't a method not_a_method = "just a string" diff --git a/tests/test_completion.py b/tests/test_completion.py index 1d9e92563..f98e3b967 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -8,19 +8,12 @@ import os import sys from typing import NoReturn -from unittest import ( - mock, -) +from unittest import mock import pytest import cmd2 -from cmd2 import ( - utils, -) -from examples.subcommands import ( - SubcommandsExample, -) +from cmd2 import utils from .conftest import ( complete_tester, @@ -28,6 +21,107 @@ run_cmd, ) + +class SubcommandsExample(cmd2.Cmd): + """Example cmd2 application where we a base command which has a couple subcommands + and the "sport" subcommand has tab completion enabled. + """ + + sport_item_strs = ('Bat', 'Basket', 'Basketball', 'Football', 'Space Ball') + + # create the top-level parser for the base command + base_parser = cmd2.Cmd2ArgumentParser() + base_subparsers = base_parser.add_subparsers(title='subcommands', help='subcommand help') + + # create the parser for the "foo" subcommand + parser_foo = base_subparsers.add_parser('foo', help='foo help') + parser_foo.add_argument('-x', type=int, default=1, help='integer') + parser_foo.add_argument('y', type=float, help='float') + parser_foo.add_argument('input_file', type=str, help='Input File') + + # create the parser for the "bar" subcommand + parser_bar = base_subparsers.add_parser('bar', help='bar help') + + bar_subparsers = parser_bar.add_subparsers(title='layer3', help='help for 3rd layer of commands') + parser_bar.add_argument('z', help='string') + + bar_subparsers.add_parser('apple', help='apple help') + bar_subparsers.add_parser('artichoke', help='artichoke help') + bar_subparsers.add_parser('cranberries', help='cranberries help') + + # create the parser for the "sport" subcommand + parser_sport = base_subparsers.add_parser('sport', help='sport help') + sport_arg = parser_sport.add_argument('sport', help='Enter name of a sport', choices=sport_item_strs) + + # create the top-level parser for the alternate command + # The alternate command doesn't provide its own help flag + base2_parser = cmd2.Cmd2ArgumentParser(add_help=False) + base2_subparsers = base2_parser.add_subparsers(title='subcommands', help='subcommand help') + + # create the parser for the "foo" subcommand + parser_foo2 = base2_subparsers.add_parser('foo', help='foo help') + parser_foo2.add_argument('-x', type=int, default=1, help='integer') + parser_foo2.add_argument('y', type=float, help='float') + parser_foo2.add_argument('input_file', type=str, help='Input File') + + # create the parser for the "bar" subcommand + parser_bar2 = base2_subparsers.add_parser('bar', help='bar help') + + bar2_subparsers = parser_bar2.add_subparsers(title='layer3', help='help for 3rd layer of commands') + parser_bar2.add_argument('z', help='string') + + bar2_subparsers.add_parser('apple', help='apple help') + bar2_subparsers.add_parser('artichoke', help='artichoke help') + bar2_subparsers.add_parser('cranberries', help='cranberries help') + + # create the parser for the "sport" subcommand + parser_sport2 = base2_subparsers.add_parser('sport', help='sport help') + sport2_arg = parser_sport2.add_argument('sport', help='Enter name of a sport', choices=sport_item_strs) + + def __init__(self) -> None: + super().__init__() + + # subcommand functions for the base command + def base_foo(self, args) -> None: + """Foo subcommand of base command.""" + self.poutput(args.x * args.y) + + def base_bar(self, args) -> None: + """Bar subcommand of base command.""" + self.poutput(f'(({args.z}))') + + def base_sport(self, args) -> None: + """Sport subcommand of base command.""" + self.poutput(f'Sport is {args.sport}') + + # Set handler functions for the subcommands + parser_foo.set_defaults(func=base_foo) + parser_bar.set_defaults(func=base_bar) + parser_sport.set_defaults(func=base_sport) + + @cmd2.with_argparser(base_parser) + def do_base(self, args) -> None: + """Base command help.""" + func = getattr(args, 'func', None) + if func is not None: + # Call whatever subcommand function was selected + func(self, args) + else: + # No subcommand was provided, so call help + self.do_help('base') + + @cmd2.with_argparser(base2_parser) + def do_alternate(self, args) -> None: + """Alternate command help.""" + func = getattr(args, 'func', None) + if func is not None: + # Call whatever subcommand function was selected + func(self, args) + else: + # No subcommand was provided, so call help + self.do_help('alternate') + + # List of strings used with completion functions food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato', 'Cheese "Pizza"'] sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball'] @@ -280,7 +374,7 @@ def test_set_allow_style_completion(cmd2_app) -> None: endidx = len(line) begidx = endidx - len(text) - expected = [val.name.lower() for val in cmd2.ansi.AllowStyle] + expected = [val.name.lower() for val in cmd2.rich_utils.AllowStyle] first_match = complete_tester(text, line, begidx, endidx, cmd2_app) assert first_match diff --git a/tests/test_history.py b/tests/test_history.py index 7b2a3a7c6..9e698f648 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -12,7 +12,6 @@ import cmd2 from .conftest import ( - HELP_HISTORY, normalize, run_cmd, ) @@ -770,9 +769,7 @@ def test_history_verbose_with_other_options(base_app) -> None: options_to_test = ['-r', '-e', '-o file', '-t file', '-c', '-x'] for opt in options_to_test: out, err = run_cmd(base_app, 'history -v ' + opt) - assert 4 <= len(out) <= 5 - assert out[0] == '-v cannot be used with any other options' - assert out[1].startswith('Usage:') + assert '-v cannot be used with any other options' in out assert base_app.last_result is False @@ -798,9 +795,7 @@ def test_history_script_with_invalid_options(base_app) -> None: options_to_test = ['-r', '-e', '-o file', '-t file', '-c'] for opt in options_to_test: out, err = run_cmd(base_app, 'history -s ' + opt) - assert 4 <= len(out) <= 5 - assert out[0] == '-s and -x cannot be used with -c, -r, -e, -o, or -t' - assert out[1].startswith('Usage:') + assert '-s and -x cannot be used with -c, -r, -e, -o, or -t' in out assert base_app.last_result is False @@ -818,9 +813,7 @@ def test_history_expanded_with_invalid_options(base_app) -> None: options_to_test = ['-r', '-e', '-o file', '-t file', '-c'] for opt in options_to_test: out, err = run_cmd(base_app, 'history -x ' + opt) - assert 4 <= len(out) <= 5 - assert out[0] == '-s and -x cannot be used with -c, -r, -e, -o, or -t' - assert out[1].startswith('Usage:') + assert '-s and -x cannot be used with -c, -r, -e, -o, or -t' in out assert base_app.last_result is False @@ -846,11 +839,6 @@ def test_history_script_expanded(base_app) -> None: verify_hi_last_result(base_app, 2) -def test_base_help_history(base_app) -> None: - out, err = run_cmd(base_app, 'help history') - assert out == normalize(HELP_HISTORY) - - def test_exclude_from_history(base_app) -> None: # Run history command run_cmd(base_app, 'history') diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 711868cad..b7af37145 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -8,8 +8,8 @@ from cmd2 import ( constants, exceptions, - utils, ) +from cmd2 import string_utils as su from cmd2.parsing import ( Statement, StatementParser, @@ -140,7 +140,7 @@ def test_parse_single_word(parser, line) -> None: statement = parser.parse(line) assert statement.command == line assert statement == '' - assert statement.argv == [utils.strip_quotes(line)] + assert statement.argv == [su.strip_quotes(line)] assert not statement.arg_list assert statement.args == statement assert statement.raw == line diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py new file mode 100644 index 000000000..5f6a07981 --- /dev/null +++ b/tests/test_rich_utils.py @@ -0,0 +1,277 @@ +"""Unit testing for cmd2/rich_utils.py module""" + +import pytest +import rich.box +from rich.console import Console +from rich.segment import Segment +from rich.style import Style +from rich.table import Table +from rich.text import Text + +from cmd2 import ( + Cmd2Style, + Color, +) +from cmd2 import rich_utils as ru + + +def test_cmd2_base_console() -> None: + # Test the keyword arguments which are not allowed. + with pytest.raises(TypeError) as excinfo: + ru.Cmd2BaseConsole(force_terminal=True) + assert 'force_terminal' in str(excinfo.value) + + with pytest.raises(TypeError) as excinfo: + ru.Cmd2BaseConsole(force_interactive=True) + assert 'force_interactive' in str(excinfo.value) + + with pytest.raises(TypeError) as excinfo: + ru.Cmd2BaseConsole(theme=None) + assert 'theme' in str(excinfo.value) + + +def test_indented_text() -> None: + console = Console(width=20) + + # With an indention of 10, text will be evenly split across two lines. + text = "A" * 20 + level = 10 + indented_text = ru.indent(text, level) + + with console.capture() as capture: + console.print(indented_text) + result = capture.get().splitlines() + + padding = " " * level + expected_line = padding + ("A" * 10) + assert result[0] == expected_line + assert result[1] == expected_line + + +def test_indented_table() -> None: + console = Console() + + level = 2 + table = Table("Column", box=rich.box.ASCII) + table.add_row("Some Data") + indented_table = ru.indent(table, level) + + with console.capture() as capture: + console.print(indented_table) + result = capture.get().splitlines() + + padding = " " * level + assert result[0].startswith(padding + "+-----------+") + assert result[1].startswith(padding + "| Column |") + assert result[2].startswith(padding + "|-----------|") + assert result[3].startswith(padding + "| Some Data |") + assert result[4].startswith(padding + "+-----------+") + + +@pytest.mark.parametrize( + ('rich_text', 'string'), + [ + (Text("Hello"), "Hello"), + (Text("Hello\n"), "Hello\n"), + (Text("Hello", style="blue"), "\x1b[34mHello\x1b[0m"), + ], +) +def test_rich_text_to_string(rich_text: Text, string: str) -> None: + assert ru.rich_text_to_string(rich_text) == string + + +def test_set_theme() -> None: + # Save a cmd2, rich-argparse, and rich-specific style. + cmd2_style_key = Cmd2Style.ERROR + argparse_style_key = "argparse.args" + rich_style_key = "inspect.attr" + + orig_cmd2_style = ru.APP_THEME.styles[cmd2_style_key] + orig_argparse_style = ru.APP_THEME.styles[argparse_style_key] + orig_rich_style = ru.APP_THEME.styles[rich_style_key] + + # Overwrite these styles by setting a new theme. + theme = { + cmd2_style_key: Style(color=Color.CYAN), + argparse_style_key: Style(color=Color.AQUAMARINE3, underline=True), + rich_style_key: Style(color=Color.DARK_GOLDENROD, bold=True), + } + ru.set_theme(theme) + + # Verify theme styles have changed to our custom values. + assert ru.APP_THEME.styles[cmd2_style_key] != orig_cmd2_style + assert ru.APP_THEME.styles[cmd2_style_key] == theme[cmd2_style_key] + + assert ru.APP_THEME.styles[argparse_style_key] != orig_argparse_style + assert ru.APP_THEME.styles[argparse_style_key] == theme[argparse_style_key] + + assert ru.APP_THEME.styles[rich_style_key] != orig_rich_style + assert ru.APP_THEME.styles[rich_style_key] == theme[rich_style_key] + + +def test_from_ansi_wrapper() -> None: + # Check if we are still patching Text.from_ansi(). If this check fails, then Rich + # has fixed the bug. Therefore, we can remove this test function and ru._from_ansi_wrapper. + assert Text.from_ansi.__func__ is ru._from_ansi_wrapper.__func__ # type: ignore[attr-defined] + + # Line breaks recognized by str.splitlines(). + # Source: https://docs.python.org/3/library/stdtypes.html#str.splitlines + line_breaks = { + "\n", # Line Feed + "\r", # Carriage Return + "\r\n", # Carriage Return + Line Feed + "\v", # Vertical Tab + "\f", # Form Feed + "\x1c", # File Separator + "\x1d", # Group Separator + "\x1e", # Record Separator + "\x85", # Next Line (NEL) + "\u2028", # Line Separator + "\u2029", # Paragraph Separator + } + + # Test all line breaks + for lb in line_breaks: + input_string = f"Text{lb}" + expected_output = input_string.replace(lb, "\n") + assert Text.from_ansi(input_string).plain == expected_output + + # Test string without trailing line break + input_string = "No trailing\nline break" + assert Text.from_ansi(input_string).plain == input_string + + # Test empty string + input_string = "" + assert Text.from_ansi(input_string).plain == input_string + + +@pytest.mark.parametrize( + # Print with style and verify that everything but newline characters have style. + ('objects', 'expected', 'sep', 'end'), + [ + # Print nothing + ((), "\n", " ", "\n"), + # Empty string + (("",), "\n", " ", "\n"), + # Multple empty strings + (("", ""), '\x1b[34;47m \x1b[0m\n', " ", "\n"), + # Basic string + ( + ("str_1",), + "\x1b[34;47mstr_1\x1b[0m\n", + " ", + "\n", + ), + # String which ends with newline + ( + ("str_1\n",), + "\x1b[34;47mstr_1\x1b[0m\n\n", + " ", + "\n", + ), + # String which ends with multiple newlines + ( + ("str_1\n\n",), + "\x1b[34;47mstr_1\x1b[0m\n\n\n", + " ", + "\n", + ), + # Mutiple lines + ( + ("str_1\nstr_2",), + "\x1b[34;47mstr_1\x1b[0m\n\x1b[34;47mstr_2\x1b[0m\n", + " ", + "\n", + ), + # Multiple strings + ( + ("str_1", "str_2"), + "\x1b[34;47mstr_1 str_2\x1b[0m\n", + " ", + "\n", + ), + # Multiple strings with newline between them. + ( + ("str_1\n", "str_2"), + "\x1b[34;47mstr_1\x1b[0m\n\x1b[34;47m str_2\x1b[0m\n", + " ", + "\n", + ), + # Multiple strings and non-space value for sep + ( + ("str_1", "str_2"), + "\x1b[34;47mstr_1(sep)str_2\x1b[0m\n", + "(sep)", + "\n", + ), + # Multiple strings and sep is a newline + ( + ("str_1", "str_2"), + "\x1b[34;47mstr_1\x1b[0m\n\x1b[34;47mstr_2\x1b[0m\n", + "\n", + "\n", + ), + # Multiple strings and sep has newlines + ( + ("str_1", "str_2"), + "\x1b[34;47mstr_1(sep1)\x1b[0m\n\x1b[34;47m(sep2)str_2\x1b[0m\n", + "(sep1)\n(sep2)", + "\n", + ), + # Non-newline value for end. + ( + ("str_1", "str_2"), + "\x1b[34;47mstr_1(sep1)\x1b[0m\n\x1b[34;47m(sep2)str_2\x1b[0m\x1b[34;47m(end)\x1b[0m", + "(sep1)\n(sep2)", + "(end)", + ), + # end has newlines. + ( + ("str_1", "str_2"), + "\x1b[34;47mstr_1(sep1)\x1b[0m\n\x1b[34;47m(sep2)str_2\x1b[0m\x1b[34;47m(end1)\x1b[0m\n\x1b[34;47m(end2)\x1b[0m", + "(sep1)\n(sep2)", + "(end1)\n(end2)", + ), + # Empty sep and end values + ( + ("str_1", "str_2"), + "\x1b[34;47mstr_1str_2\x1b[0m", + "", + "", + ), + ], +) +def test_apply_style_wrapper(objects: tuple[str], expected: str, sep: str, end: str) -> None: + # Check if we are still patching Segment.apply_style(). If this check fails, then Rich + # has fixed the bug. Therefore, we can remove this test function and ru._apply_style_wrapper. + assert Segment.apply_style.__func__ is ru._apply_style_wrapper.__func__ # type: ignore[attr-defined] + + console = Console(force_terminal=True) + + try: + # Since our patch was meant to fix behavior seen when soft wrapping, + # we will first test in that condition. + with console.capture() as capture: + console.print(*objects, sep=sep, end=end, style="blue on white", soft_wrap=True) + result = capture.get() + assert result == expected + + # Now print with soft wrapping disabled. Since none of our input strings are long enough + # to auto wrap, the results should be the same as our soft-wrapping output. + with console.capture() as capture: + console.print(*objects, sep=sep, end=end, style="blue on white", soft_wrap=False) + result = capture.get() + assert result == expected + + # Now remove our patch and disable soft wrapping. This will prove that our patch produces + # the same result as unpatched Rich + Segment.apply_style = ru._orig_segment_apply_style # type: ignore[assignment] + + with console.capture() as capture: + console.print(*objects, sep=sep, end=end, style="blue on white", soft_wrap=False) + result = capture.get() + assert result == expected + + finally: + # Restore the patch + Segment.apply_style = ru._apply_style_wrapper # type: ignore[assignment] diff --git a/tests/test_run_pyscript.py b/tests/test_run_pyscript.py index a64f77ba9..0d21379ab 100644 --- a/tests/test_run_pyscript.py +++ b/tests/test_run_pyscript.py @@ -8,24 +8,13 @@ import pytest -from cmd2 import ( - plugin, - utils, -) +from cmd2.string_utils import quote from .conftest import ( odd_file_names, run_cmd, ) -HOOK_OUTPUT = "TEST_OUTPUT" - - -def cmdfinalization_hook(data: plugin.CommandFinalizationData) -> plugin.CommandFinalizationData: - """A cmdfinalization_hook hook which requests application exit""" - print(HOOK_OUTPUT) - return data - def test_run_pyscript(base_app, request) -> None: test_dir = os.path.dirname(request.module.__file__) @@ -74,7 +63,7 @@ def test_run_pyscript_with_odd_file_names(base_app, python_script) -> None: input_mock = mock.MagicMock(name='input', return_value='1') builtins.input = input_mock - out, err = run_cmd(base_app, f"run_pyscript {utils.quote_string(python_script)}") + out, err = run_cmd(base_app, f"run_pyscript {quote(python_script)}") err = ''.join(err) assert f"Error reading script file '{python_script}'" in err assert base_app.last_result is False @@ -133,14 +122,29 @@ def test_run_pyscript_dir(base_app, request) -> None: assert out[0] == "['cmd_echo']" -def test_run_pyscript_stdout_capture(base_app, request) -> None: - base_app.register_cmdfinalization_hook(cmdfinalization_hook) +def test_run_pyscript_capture(base_app, request) -> None: + base_app.self_in_py = True test_dir = os.path.dirname(request.module.__file__) python_script = os.path.join(test_dir, 'pyscript', 'stdout_capture.py') - out, err = run_cmd(base_app, f'run_pyscript {python_script} {HOOK_OUTPUT}') + out, err = run_cmd(base_app, f'run_pyscript {python_script}') - assert out[0] == "PASSED" - assert out[1] == "PASSED" + assert out[0] == "print" + assert out[1] == "poutput" + + +def test_run_pyscript_capture_custom_stdout(base_app, request) -> None: + """sys.stdout will not be captured if it's different than self.stdout.""" + import io + + base_app.stdout = io.StringIO() + + base_app.self_in_py = True + test_dir = os.path.dirname(request.module.__file__) + python_script = os.path.join(test_dir, 'pyscript', 'stdout_capture.py') + out, err = run_cmd(base_app, f'run_pyscript {python_script}') + + assert "print" not in out + assert out[0] == "poutput" def test_run_pyscript_stop(base_app, request) -> None: diff --git a/tests/test_string_utils.py b/tests/test_string_utils.py new file mode 100644 index 000000000..7e1aa5f78 --- /dev/null +++ b/tests/test_string_utils.py @@ -0,0 +1,215 @@ +"""Unit testing for cmd2/string_utils.py module""" + +from rich.style import Style + +from cmd2 import Color +from cmd2 import rich_utils as ru +from cmd2 import string_utils as su + +HELLO_WORLD = 'Hello, world!' + + +def test_align_blank() -> None: + text = '' + character = '-' + width = 5 + aligned = su.align(text, "left", width=width, character=character) + assert aligned == character * width + + +def test_align_wider_than_width() -> None: + text = 'long text field' + character = '-' + width = 8 + aligned = su.align(text, "left", width=width, character=character) + assert aligned == text[:width] + + +def test_align_term_width() -> None: + text = 'foo' + character = ' ' + + term_width = ru.console_width() + expected_padding = (term_width - su.str_width(text)) * character + + aligned = su.align(text, "left", character=character) + assert aligned == text + expected_padding + + +def test_align_left() -> None: + text = 'foo' + character = '-' + width = 5 + aligned = su.align_left(text, width=width, character=character) + assert aligned == text + character * 2 + + +def test_align_left_wide_text() -> None: + text = '苹' + character = '-' + width = 4 + aligned = su.align_left(text, width=width, character=character) + assert aligned == text + character * 2 + + +def test_align_left_with_style() -> None: + character = '-' + + styled_text = su.stylize('table', style=Color.BRIGHT_BLUE) + width = 8 + + aligned = su.align_left(styled_text, width=width, character=character) + assert aligned == styled_text + character * 3 + + +def test_align_center() -> None: + text = 'foo' + character = '-' + width = 5 + aligned = su.align_center(text, width=width, character=character) + assert aligned == character + text + character + + +def test_align_center_wide_text() -> None: + text = '苹' + character = '-' + width = 4 + aligned = su.align_center(text, width=width, character=character) + assert aligned == character + text + character + + +def test_align_center_with_style() -> None: + character = '-' + + styled_text = su.stylize('table', style=Color.BRIGHT_BLUE) + width = 8 + + aligned = su.align_center(styled_text, width=width, character=character) + assert aligned == character + styled_text + character * 2 + + +def test_align_right() -> None: + text = 'foo' + character = '-' + width = 5 + aligned = su.align_right(text, width=width, character=character) + assert aligned == character * 2 + text + + +def test_align_right_wide_text() -> None: + text = '苹' + character = '-' + width = 4 + aligned = su.align_right(text, width=width, character=character) + assert aligned == character * 2 + text + + +def test_align_right_with_style() -> None: + character = '-' + + styled_text = su.stylize('table', style=Color.BRIGHT_BLUE) + width = 8 + + aligned = su.align_right(styled_text, width=width, character=character) + assert aligned == character * 3 + styled_text + + +def test_stylize() -> None: + # Test string with no existing style + style = Style(color=Color.GREEN, bgcolor=Color.BLUE, bold=True, underline=True) + styled_str = su.stylize(HELLO_WORLD, style=style) + assert styled_str == "\x1b[1;4;32;44mHello, world!\x1b[0m" + + # Add style to already-styled string + updated_style = Style.combine([style, Style(strike=True)]) + restyled_string = su.stylize(styled_str, style=updated_style) + assert restyled_string == "\x1b[1;4;9;32;44mHello, world!\x1b[0m" + + +def test_strip_style() -> None: + base_str = HELLO_WORLD + styled_str = su.stylize(base_str, style=Color.GREEN) + assert base_str != styled_str + assert base_str == su.strip_style(styled_str) + + +def test_str_width() -> None: + # Include a full-width character + base_str = HELLO_WORLD + "深" + styled_str = su.stylize(base_str, style=Color.GREEN) + expected_width = len(HELLO_WORLD) + 2 + assert su.str_width(base_str) == su.str_width(styled_str) == expected_width + + +def test_is_quoted_short() -> None: + my_str = '' + assert not su.is_quoted(my_str) + your_str = '"' + assert not su.is_quoted(your_str) + + +def test_is_quoted_yes() -> None: + my_str = '"This is a test"' + assert su.is_quoted(my_str) + your_str = "'of the emergengy broadcast system'" + assert su.is_quoted(your_str) + + +def test_is_quoted_no() -> None: + my_str = '"This is a test' + assert not su.is_quoted(my_str) + your_str = "of the emergengy broadcast system'" + assert not su.is_quoted(your_str) + simple_str = "hello world" + assert not su.is_quoted(simple_str) + + +def test_quote() -> None: + my_str = "Hello World" + assert su.quote(my_str) == '"' + my_str + '"' + + my_str = "'Hello World'" + assert su.quote(my_str) == '"' + my_str + '"' + + my_str = '"Hello World"' + assert su.quote(my_str) == "'" + my_str + "'" + + +def test_quote_if_needed_yes() -> None: + my_str = "Hello World" + assert su.quote_if_needed(my_str) == '"' + my_str + '"' + your_str = '"foo" bar' + assert su.quote_if_needed(your_str) == "'" + your_str + "'" + + +def test_quote_if_needed_no() -> None: + my_str = "HelloWorld" + assert su.quote_if_needed(my_str) == my_str + your_str = "'Hello World'" + assert su.quote_if_needed(your_str) == your_str + + +def test_strip_quotes_no_quotes() -> None: + base_str = HELLO_WORLD + stripped = su.strip_quotes(base_str) + assert base_str == stripped + + +def test_strip_quotes_with_quotes() -> None: + base_str = '"' + HELLO_WORLD + '"' + stripped = su.strip_quotes(base_str) + assert stripped == HELLO_WORLD + + +def test_unicode_normalization() -> None: + s1 = 'café' + s2 = 'cafe\u0301' + assert s1 != s2 + assert su.norm_fold(s1) == su.norm_fold(s2) + + +def test_unicode_casefold() -> None: + micro = 'µ' + micro_cf = micro.casefold() + assert micro != micro_cf + assert su.norm_fold(micro) == su.norm_fold(micro_cf) diff --git a/tests/test_table_creator.py b/tests/test_table_creator.py deleted file mode 100644 index caf19b7eb..000000000 --- a/tests/test_table_creator.py +++ /dev/null @@ -1,725 +0,0 @@ -"""Unit testing for cmd2/table_creator.py module""" - -import pytest - -from cmd2 import ( - Bg, - Fg, - TextStyle, - ansi, -) -from cmd2.table_creator import ( - AlternatingTable, - BorderedTable, - Column, - HorizontalAlignment, - SimpleTable, - TableCreator, - VerticalAlignment, -) - -# Turn off black formatting for entire file so multiline strings -# can be visually aligned to match the tables being tested. -# fmt: off - - -def test_column_creation() -> None: - # Width less than 1 - with pytest.raises(ValueError, match="Column width cannot be less than 1"): - Column("Column 1", width=0) - - # Width specified - c = Column("header", width=20) - assert c.width == 20 - - # max_data_lines less than 1 - with pytest.raises(ValueError, match="Max data lines cannot be less than 1"): - Column("Column 1", max_data_lines=0) - - # No width specified, blank label - c = Column("") - assert c.width < 0 - tc = TableCreator([c]) - assert tc.cols[0].width == 1 - - # No width specified, label isn't blank but has no width - c = Column(ansi.style('', fg=Fg.GREEN)) - assert c.width < 0 - tc = TableCreator([c]) - assert tc.cols[0].width == 1 - - # No width specified, label has width - c = Column("a line") - assert c.width < 0 - tc = TableCreator([c]) - assert tc.cols[0].width == ansi.style_aware_wcswidth("a line") - - # No width specified, label has width and multiple lines - c = Column("short\nreally long") - assert c.width < 0 - tc = TableCreator([c]) - assert tc.cols[0].width == ansi.style_aware_wcswidth("really long") - - # No width specified, label has tabs - c = Column("line\twith\ttabs") - assert c.width < 0 - tc = TableCreator([c]) - assert tc.cols[0].width == ansi.style_aware_wcswidth("line with tabs") - - # Add basic tests for style_header_text and style_data_text to make sure these members don't get removed. - c = Column("Column 1") - assert c.style_header_text is True - assert c.style_data_text is True - - c = Column("Column 1", style_header_text=False) - assert c.style_header_text is False - assert c.style_data_text is True - - c = Column("Column 1", style_data_text=False) - assert c.style_header_text is True - assert c.style_data_text is False - - -def test_column_alignment() -> None: - column_1 = Column( - "Col 1", - width=10, - header_horiz_align=HorizontalAlignment.LEFT, - header_vert_align=VerticalAlignment.TOP, - data_horiz_align=HorizontalAlignment.RIGHT, - data_vert_align=VerticalAlignment.BOTTOM, - ) - column_2 = Column( - "Col 2", - width=10, - header_horiz_align=HorizontalAlignment.RIGHT, - header_vert_align=VerticalAlignment.BOTTOM, - data_horiz_align=HorizontalAlignment.CENTER, - data_vert_align=VerticalAlignment.MIDDLE, - ) - column_3 = Column( - "Col 3", - width=10, - header_horiz_align=HorizontalAlignment.CENTER, - header_vert_align=VerticalAlignment.MIDDLE, - data_horiz_align=HorizontalAlignment.LEFT, - data_vert_align=VerticalAlignment.TOP, - ) - column_4 = Column("Three\nline\nheader", width=10) - - columns = [column_1, column_2, column_3, column_4] - tc = TableCreator(columns) - - # Check defaults - assert column_4.header_horiz_align == HorizontalAlignment.LEFT - assert column_4.header_vert_align == VerticalAlignment.BOTTOM - assert column_4.data_horiz_align == HorizontalAlignment.LEFT - assert column_4.data_vert_align == VerticalAlignment.TOP - - # Create a header row - row_data = [col.header for col in columns] - header = tc.generate_row(row_data=row_data, is_header=True) - assert header == ( - 'Col 1 Three \n' - ' Col 3 line \n' - ' Col 2 header ' - ) - - # Create a data row - row_data = ["Val 1", "Val 2", "Val 3", "Three\nline\ndata"] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ( - ' Val 3 Three \n' - ' Val 2 line \n' - ' Val 1 data ' - ) - - -def test_blank_last_line() -> None: - """This tests that an empty line is inserted when the last data line is blank""" - column_1 = Column("Col 1", width=10) - tc = TableCreator([column_1]) - - row_data = ['my line\n\n'] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ('my line \n' - ' ') - - row_data = ['\n'] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ' ' - - row_data = [''] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ' ' - - -def test_wrap_text() -> None: - column_1 = Column("Col 1", width=10) - tc = TableCreator([column_1]) - - # Test normal wrapping - row_data = ['Some text to wrap\nA new line that will wrap\nNot wrap\n 1 2 3'] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ('Some text \n' - 'to wrap \n' - 'A new line\n' - 'that will \n' - 'wrap \n' - 'Not wrap \n' - ' 1 2 3 ') - - # Test preserving a multiple space sequence across a line break - row_data = ['First last one'] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ('First \n' - ' last one ') - - -def test_wrap_text_max_lines() -> None: - column_1 = Column("Col 1", width=10, max_data_lines=2) - tc = TableCreator([column_1]) - - # Test not needing to truncate the final line - row_data = ['First line last line'] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ('First line\n' - 'last line ') - - # Test having to truncate the last word because it's too long for the final line - row_data = ['First line last lineextratext'] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ('First line\n' - 'last line…') - - # Test having to truncate the last word because it fits the final line but there is more text not being included - row_data = ['First line thistxtfit extra'] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ('First line\n' - 'thistxtfi…') - - # Test having to truncate the last word because it fits the final line but there are more lines not being included - row_data = ['First line thistxtfit\nextra'] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ('First line\n' - 'thistxtfi…') - - # Test having space left on the final line and adding an ellipsis because there are more lines not being included - row_data = ['First line last line\nextra line'] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ('First line\n' - 'last line…') - - -def test_wrap_long_word() -> None: - # Make sure words wider than column start on own line and wrap - column_1 = Column("LongColumnName", width=10) - column_2 = Column("Col 2", width=10) - - columns = [column_1, column_2] - tc = TableCreator(columns) - - # Test header row - row_data = [col.header for col in columns] - header = tc.generate_row(row_data, is_header=True) - assert header == ('LongColumn \n' - 'Name Col 2 ') - - # Test data row - row_data = [] - - # Long word should start on the first line (style should not affect width) - row_data.append(ansi.style("LongerThan10", fg=Fg.GREEN)) - - # Long word should start on the second line - row_data.append("Word LongerThan10") - - row = tc.generate_row(row_data=row_data, is_header=False) - expected = ( - TextStyle.RESET_ALL - + Fg.GREEN - + "LongerThan" - + TextStyle.RESET_ALL - + " Word \n" - + TextStyle.RESET_ALL - + Fg.GREEN - + "10" - + Fg.RESET - + TextStyle.RESET_ALL - + ' ' - + TextStyle.RESET_ALL - + ' LongerThan\n' - ' 10 ' - ) - assert row == expected - - -def test_wrap_long_word_max_data_lines() -> None: - column_1 = Column("Col 1", width=10, max_data_lines=2) - column_2 = Column("Col 2", width=10, max_data_lines=2) - column_3 = Column("Col 3", width=10, max_data_lines=2) - column_4 = Column("Col 4", width=10, max_data_lines=1) - - columns = [column_1, column_2, column_3, column_4] - tc = TableCreator(columns) - - row_data = [] - - # This long word will exactly fit the last line and it's the final word in the text. No ellipsis should appear. - row_data.append("LongerThan10FitsLast") - - # This long word will exactly fit the last line but it's not the final word in the text. - # Make sure ellipsis word's final character. - row_data.append("LongerThan10FitsLast\nMore lines") - - # This long word will run over the last line. Make sure it is truncated. - row_data.append("LongerThan10RunsOverLast") - - # This long word will start on the final line after another word. Therefore it won't wrap but will instead be truncated. - row_data.append("A LongerThan10RunsOverLast") - - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ('LongerThan LongerThan LongerThan A LongerT…\n' - '10FitsLast 10FitsLas… 10RunsOve… ') - - -def test_wrap_long_char_wider_than_max_width() -> None: - """This tests case where a character is wider than max_width. This can happen if max_width - is 1 and the text contains wide characters (e.g. East Asian). Replace it with an ellipsis. - """ - column_1 = Column("Col 1", width=1) - tc = TableCreator([column_1]) - row = tc.generate_row(row_data=['深'], is_header=False) - assert row == '…' - - -def test_generate_row_exceptions() -> None: - column_1 = Column("Col 1") - tc = TableCreator([column_1]) - row_data = ['fake'] - - # fill_char too long - with pytest.raises(TypeError) as excinfo: - tc.generate_row(row_data=row_data, is_header=False, fill_char='too long') - assert "Fill character must be exactly one character long" in str(excinfo.value) - - # Unprintable characters - for arg in ['fill_char', 'pre_line', 'inter_cell', 'post_line']: - kwargs = {arg: '\n'} - with pytest.raises(ValueError, match=f"{arg} contains an unprintable character"): - tc.generate_row(row_data=row_data, is_header=False, **kwargs) - - # Data with too many columns - row_data = ['Data 1', 'Extra Column'] - with pytest.raises(ValueError, match="Length of row_data must match length of cols"): - tc.generate_row(row_data=row_data, is_header=False) - - -def test_tabs() -> None: - column_1 = Column("Col\t1", width=20) - column_2 = Column("Col 2") - columns = [column_1, column_2] - tc = TableCreator(columns, tab_width=2) - - row_data = [col.header for col in columns] - row = tc.generate_row(row_data, is_header=True, fill_char='\t', pre_line='\t', inter_cell='\t', post_line='\t') - assert row == ' Col 1 Col 2 ' - - with pytest.raises(ValueError, match="Tab width cannot be less than 1" ): - TableCreator([column_1, column_2], tab_width=0) - - -def test_simple_table_creation() -> None: - column_1 = Column("Col 1", width=16) - column_2 = Column("Col 2", width=16) - - row_data = [] - row_data.append(["Col 1 Row 1", "Col 2 Row 1"]) - row_data.append(["Col 1 Row 2", "Col 2 Row 2"]) - - # Default options - st = SimpleTable([column_1, column_2]) - table = st.generate_table(row_data) - - assert table == ( - 'Col 1 Col 2 \n' - '----------------------------------\n' - 'Col 1 Row 1 Col 2 Row 1 \n' - ' \n' - 'Col 1 Row 2 Col 2 Row 2 ' - ) - - # Custom column spacing - st = SimpleTable([column_1, column_2], column_spacing=5) - table = st.generate_table(row_data) - - assert table == ( - 'Col 1 Col 2 \n' - '-------------------------------------\n' - 'Col 1 Row 1 Col 2 Row 1 \n' - ' \n' - 'Col 1 Row 2 Col 2 Row 2 ' - ) - - # Custom divider - st = SimpleTable([column_1, column_2], divider_char='─') - table = st.generate_table(row_data) - - assert table == ( - 'Col 1 Col 2 \n' - '──────────────────────────────────\n' - 'Col 1 Row 1 Col 2 Row 1 \n' - ' \n' - 'Col 1 Row 2 Col 2 Row 2 ' - ) - - # No divider - st = SimpleTable([column_1, column_2], divider_char=None) - no_divider_1 = st.generate_table(row_data) - - st = SimpleTable([column_1, column_2], divider_char='') - no_divider_2 = st.generate_table(row_data) - - assert no_divider_1 == no_divider_2 == ( - 'Col 1 Col 2 \n' - 'Col 1 Row 1 Col 2 Row 1 \n' - ' \n' - 'Col 1 Row 2 Col 2 Row 2 ' - ) - - # No row spacing - st = SimpleTable([column_1, column_2]) - table = st.generate_table(row_data, row_spacing=0) - assert table == ( - 'Col 1 Col 2 \n' - '----------------------------------\n' - 'Col 1 Row 1 Col 2 Row 1 \n' - 'Col 1 Row 2 Col 2 Row 2 ' - ) - - # No header - st = SimpleTable([column_1, column_2]) - table = st.generate_table(row_data, include_header=False) - - assert table == ('Col 1 Row 1 Col 2 Row 1 \n' - ' \n' - 'Col 1 Row 2 Col 2 Row 2 ') - - # Wide custom divider (divider needs no padding) - st = SimpleTable([column_1, column_2], divider_char='深') - table = st.generate_table(row_data) - - assert table == ( - 'Col 1 Col 2 \n' - '深深深深深深深深深深深深深深深深深\n' - 'Col 1 Row 1 Col 2 Row 1 \n' - ' \n' - 'Col 1 Row 2 Col 2 Row 2 ' - ) - - # Wide custom divider (divider needs padding) - st = SimpleTable([column_1, Column("Col 2", width=17)], - divider_char='深') - table = st.generate_table(row_data) - - assert table == ( - 'Col 1 Col 2 \n' - '深深深深深深深深深深深深深深深深深 \n' - 'Col 1 Row 1 Col 2 Row 1 \n' - ' \n' - 'Col 1 Row 2 Col 2 Row 2 ' - ) - - # Invalid column spacing - with pytest.raises(ValueError, match="Column spacing cannot be less than 0"): - SimpleTable([column_1, column_2], column_spacing=-1) - - # Invalid divider character - with pytest.raises(TypeError, match="Divider character must be exactly one character long"): - SimpleTable([column_1, column_2], divider_char='too long') - - with pytest.raises(ValueError, match="Divider character is an unprintable character"): - SimpleTable([column_1, column_2], divider_char='\n') - - # Invalid row spacing - st = SimpleTable([column_1, column_2]) - with pytest.raises(ValueError, match="Row spacing cannot be less than 0"): - st.generate_table(row_data, row_spacing=-1) - - # Test header and data colors - st = SimpleTable([column_1, column_2], divider_char=None, header_bg=Bg.GREEN, data_bg=Bg.LIGHT_BLUE) - table = st.generate_table(row_data) - assert table == ( - '\x1b[0m\x1b[42mCol 1\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42mCol 2\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\n' - '\x1b[0m\x1b[104mCol 1 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 2 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\n' - '\x1b[0m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\n' - '\x1b[0m\x1b[104mCol 1 Row 2\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 2 Row 2\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m' - ) - - # Make sure SimpleTable respects style_header_text and style_data_text flags. - # Don't apply parent table's background colors to header or data text in second column. - st = SimpleTable([column_1, Column("Col 2", width=16, style_header_text=False, style_data_text=False)], - divider_char=None, header_bg=Bg.GREEN, data_bg=Bg.LIGHT_BLUE) - table = st.generate_table(row_data) - assert table == ( - '\x1b[0m\x1b[42mCol 1\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0mCol 2\x1b[0m\x1b[42m \x1b[49m\x1b[0m\n' - '\x1b[0m\x1b[104mCol 1 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0mCol 2 Row 1\x1b[0m\x1b[104m \x1b[49m\x1b[0m\n' - '\x1b[0m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\n' - '\x1b[0m\x1b[104mCol 1 Row 2\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0mCol 2 Row 2\x1b[0m\x1b[104m \x1b[49m\x1b[0m' - ) - - -def test_simple_table_width() -> None: - # Base width - for num_cols in range(1, 10): - assert SimpleTable.base_width(num_cols) == (num_cols - 1) * 2 - - # Invalid num_cols value - with pytest.raises(ValueError, match="Column count cannot be less than 1"): - SimpleTable.base_width(0) - - # Total width - column_1 = Column("Col 1", width=16) - column_2 = Column("Col 2", width=16) - - row_data = [] - row_data.append(["Col 1 Row 1", "Col 2 Row 1"]) - row_data.append(["Col 1 Row 2", "Col 2 Row 2"]) - - st = SimpleTable([column_1, column_2]) - assert st.total_width() == 34 - - -def test_simple_generate_data_row_exceptions() -> None: - column_1 = Column("Col 1") - tc = SimpleTable([column_1]) - - # Data with too many columns - row_data = ['Data 1', 'Extra Column'] - with pytest.raises(ValueError, match="Length of row_data must match length of cols"): - tc.generate_data_row(row_data=row_data) - - -def test_bordered_table_creation() -> None: - column_1 = Column("Col 1", width=15) - column_2 = Column("Col 2", width=15) - - row_data = [] - row_data.append(["Col 1 Row 1", "Col 2 Row 1"]) - row_data.append(["Col 1 Row 2", "Col 2 Row 2"]) - - # Default options - bt = BorderedTable([column_1, column_2]) - table = bt.generate_table(row_data) - assert table == ( - '╔═════════════════╤═════════════════╗\n' - '║ Col 1 │ Col 2 ║\n' - '╠═════════════════╪═════════════════╣\n' - '║ Col 1 Row 1 │ Col 2 Row 1 ║\n' - '╟─────────────────┼─────────────────╢\n' - '║ Col 1 Row 2 │ Col 2 Row 2 ║\n' - '╚═════════════════╧═════════════════╝' - ) - - # No column borders - bt = BorderedTable([column_1, column_2], column_borders=False) - table = bt.generate_table(row_data) - assert table == ( - '╔══════════════════════════════════╗\n' - '║ Col 1 Col 2 ║\n' - '╠══════════════════════════════════╣\n' - '║ Col 1 Row 1 Col 2 Row 1 ║\n' - '╟──────────────────────────────────╢\n' - '║ Col 1 Row 2 Col 2 Row 2 ║\n' - '╚══════════════════════════════════╝' - ) - - # No header - bt = BorderedTable([column_1, column_2]) - table = bt.generate_table(row_data, include_header=False) - assert table == ( - '╔═════════════════╤═════════════════╗\n' - '║ Col 1 Row 1 │ Col 2 Row 1 ║\n' - '╟─────────────────┼─────────────────╢\n' - '║ Col 1 Row 2 │ Col 2 Row 2 ║\n' - '╚═════════════════╧═════════════════╝' - ) - - # Non-default padding - bt = BorderedTable([column_1, column_2], padding=2) - table = bt.generate_table(row_data) - assert table == ( - '╔═══════════════════╤═══════════════════╗\n' - '║ Col 1 │ Col 2 ║\n' - '╠═══════════════════╪═══════════════════╣\n' - '║ Col 1 Row 1 │ Col 2 Row 1 ║\n' - '╟───────────────────┼───────────────────╢\n' - '║ Col 1 Row 2 │ Col 2 Row 2 ║\n' - '╚═══════════════════╧═══════════════════╝' - ) - - # Invalid padding - with pytest.raises(ValueError, match="Padding cannot be less than 0"): - BorderedTable([column_1, column_2], padding=-1) - - # Test border, header, and data colors - bt = BorderedTable([column_1, column_2], border_fg=Fg.LIGHT_YELLOW, border_bg=Bg.WHITE, - header_bg=Bg.GREEN, data_bg=Bg.LIGHT_BLUE) - table = bt.generate_table(row_data) - assert table == ( - '\x1b[93m\x1b[107m╔═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╤═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╗\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\x1b[42m \x1b[49m\x1b[0m\x1b[42mCol 1\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[93m\x1b[107m│\x1b[39m\x1b[49m\x1b[42m \x1b[49m\x1b[0m\x1b[42mCol 2\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m╠═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╪═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╣\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 1 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[93m\x1b[107m│\x1b[39m\x1b[49m\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 2 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m╟─\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m───────────────\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m─┼─\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m───────────────\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m─╢\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 1 Row 2\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[93m\x1b[107m│\x1b[39m\x1b[49m\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 2 Row 2\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m╚═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╧═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╝\x1b[39m\x1b[49m' - ) - - # Make sure BorderedTable respects style_header_text and style_data_text flags. - # Don't apply parent table's background colors to header or data text in second column. - bt = BorderedTable([column_1, Column("Col 2", width=15, style_header_text=False, style_data_text=False)], - header_bg=Bg.GREEN, data_bg=Bg.LIGHT_BLUE) - table = bt.generate_table(row_data) - assert table == ( - '╔═════════════════╤═════════════════╗\n' - '║\x1b[42m \x1b[49m\x1b[0m\x1b[42mCol 1\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m│\x1b[42m \x1b[49m\x1b[0mCol 2\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m║\n' - '╠═════════════════╪═════════════════╣\n' - '║\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 1 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m│\x1b[104m \x1b[49m\x1b[0mCol 2 Row 1\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m║\n' - '╟─────────────────┼─────────────────╢\n' - '║\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 1 Row 2\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m│\x1b[104m \x1b[49m\x1b[0mCol 2 Row 2\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m║\n' - '╚═════════════════╧═════════════════╝' - ) - - -def test_bordered_table_width() -> None: - # Default behavior (column_borders=True, padding=1) - assert BorderedTable.base_width(1) == 4 - assert BorderedTable.base_width(2) == 7 - assert BorderedTable.base_width(3) == 10 - - # No column borders - assert BorderedTable.base_width(1, column_borders=False) == 4 - assert BorderedTable.base_width(2, column_borders=False) == 6 - assert BorderedTable.base_width(3, column_borders=False) == 8 - - # No padding - assert BorderedTable.base_width(1, padding=0) == 2 - assert BorderedTable.base_width(2, padding=0) == 3 - assert BorderedTable.base_width(3, padding=0) == 4 - - # Extra padding - assert BorderedTable.base_width(1, padding=3) == 8 - assert BorderedTable.base_width(2, padding=3) == 15 - assert BorderedTable.base_width(3, padding=3) == 22 - - # Invalid num_cols value - with pytest.raises(ValueError, match="Column count cannot be less than 1"): - BorderedTable.base_width(0) - - # Total width - column_1 = Column("Col 1", width=15) - column_2 = Column("Col 2", width=15) - - row_data = [] - row_data.append(["Col 1 Row 1", "Col 2 Row 1"]) - row_data.append(["Col 1 Row 2", "Col 2 Row 2"]) - - bt = BorderedTable([column_1, column_2]) - assert bt.total_width() == 37 - - -def test_bordered_generate_data_row_exceptions() -> None: - column_1 = Column("Col 1") - tc = BorderedTable([column_1]) - - # Data with too many columns - row_data = ['Data 1', 'Extra Column'] - with pytest.raises(ValueError, match="Length of row_data must match length of cols"): - tc.generate_data_row(row_data=row_data) - - -def test_alternating_table_creation() -> None: - column_1 = Column("Col 1", width=15) - column_2 = Column("Col 2", width=15) - - row_data = [] - row_data.append(["Col 1 Row 1", "Col 2 Row 1"]) - row_data.append(["Col 1 Row 2", "Col 2 Row 2"]) - - # Default options - at = AlternatingTable([column_1, column_2]) - table = at.generate_table(row_data) - assert table == ( - '╔═════════════════╤═════════════════╗\n' - '║ Col 1 │ Col 2 ║\n' - '╠═════════════════╪═════════════════╣\n' - '║ Col 1 Row 1 │ Col 2 Row 1 ║\n' - '║\x1b[100m \x1b[49m\x1b[0m\x1b[100mCol 1 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[0m\x1b[100m \x1b[49m│\x1b[100m \x1b[49m\x1b[0m\x1b[100mCol 2 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[0m\x1b[100m \x1b[49m║\n' - '╚═════════════════╧═════════════════╝' - ) - - # No column borders - at = AlternatingTable([column_1, column_2], column_borders=False) - table = at.generate_table(row_data) - assert table == ( - '╔══════════════════════════════════╗\n' - '║ Col 1 Col 2 ║\n' - '╠══════════════════════════════════╣\n' - '║ Col 1 Row 1 Col 2 Row 1 ║\n' - '║\x1b[100m \x1b[49m\x1b[0m\x1b[100mCol 1 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[0m\x1b[100mCol 2 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[0m\x1b[100m \x1b[49m║\n' - '╚══════════════════════════════════╝' - ) - - # No header - at = AlternatingTable([column_1, column_2]) - table = at.generate_table(row_data, include_header=False) - assert table == ( - '╔═════════════════╤═════════════════╗\n' - '║ Col 1 Row 1 │ Col 2 Row 1 ║\n' - '║\x1b[100m \x1b[49m\x1b[0m\x1b[100mCol 1 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[0m\x1b[100m \x1b[49m│\x1b[100m \x1b[49m\x1b[0m\x1b[100mCol 2 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[0m\x1b[100m \x1b[49m║\n' - '╚═════════════════╧═════════════════╝' - ) - - # Non-default padding - at = AlternatingTable([column_1, column_2], padding=2) - table = at.generate_table(row_data) - assert table == ( - '╔═══════════════════╤═══════════════════╗\n' - '║ Col 1 │ Col 2 ║\n' - '╠═══════════════════╪═══════════════════╣\n' - '║ Col 1 Row 1 │ Col 2 Row 1 ║\n' - '║\x1b[100m \x1b[49m\x1b[0m\x1b[100mCol 1 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[0m\x1b[100m \x1b[49m│\x1b[100m \x1b[49m\x1b[0m\x1b[100mCol 2 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[0m\x1b[100m \x1b[49m║\n' - '╚═══════════════════╧═══════════════════╝' - ) - - # Invalid padding - with pytest.raises(ValueError, match="Padding cannot be less than 0"): - AlternatingTable([column_1, column_2], padding=-1) - - # Test border, header, and data colors - at = AlternatingTable([column_1, column_2], border_fg=Fg.LIGHT_YELLOW, border_bg=Bg.WHITE, - header_bg=Bg.GREEN, odd_bg=Bg.LIGHT_BLUE, even_bg=Bg.LIGHT_RED) - table = at.generate_table(row_data) - assert table == ( - '\x1b[93m\x1b[107m╔═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╤═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╗\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\x1b[42m \x1b[49m\x1b[0m\x1b[42mCol 1\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[93m\x1b[107m│\x1b[39m\x1b[49m\x1b[42m \x1b[49m\x1b[0m\x1b[42mCol 2\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m╠═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╪═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╣\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 1 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[93m\x1b[107m│\x1b[39m\x1b[49m\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 2 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\x1b[101m \x1b[49m\x1b[0m\x1b[101mCol 1 Row 2\x1b[49m\x1b[0m\x1b[101m \x1b[49m\x1b[0m\x1b[101m \x1b[49m\x1b[93m\x1b[107m│\x1b[39m\x1b[49m\x1b[101m \x1b[49m\x1b[0m\x1b[101mCol 2 Row 2\x1b[49m\x1b[0m\x1b[101m \x1b[49m\x1b[0m\x1b[101m \x1b[49m\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m╚═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╧═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╝\x1b[39m\x1b[49m' - ) - - # Make sure AlternatingTable respects style_header_text and style_data_text flags. - # Don't apply parent table's background colors to header or data text in second column. - at = AlternatingTable([column_1, Column("Col 2", width=15, style_header_text=False, style_data_text=False)], - header_bg=Bg.GREEN, odd_bg=Bg.LIGHT_BLUE, even_bg=Bg.LIGHT_RED) - table = at.generate_table(row_data) - assert table == ( - '╔═════════════════╤═════════════════╗\n' - '║\x1b[42m \x1b[49m\x1b[0m\x1b[42mCol 1\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m│\x1b[42m \x1b[49m\x1b[0mCol 2\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m║\n' - '╠═════════════════╪═════════════════╣\n' - '║\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 1 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m│\x1b[104m \x1b[49m\x1b[0mCol 2 Row 1\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m║\n' - '║\x1b[101m \x1b[49m\x1b[0m\x1b[101mCol 1 Row 2\x1b[49m\x1b[0m\x1b[101m \x1b[49m\x1b[0m\x1b[101m \x1b[49m│\x1b[101m \x1b[49m\x1b[0mCol 2 Row 2\x1b[0m\x1b[101m \x1b[49m\x1b[0m\x1b[101m \x1b[49m║\n' - '╚═════════════════╧═════════════════╝' - ) diff --git a/tests/test_terminal_utils.py b/tests/test_terminal_utils.py new file mode 100644 index 000000000..c7d8a22f3 --- /dev/null +++ b/tests/test_terminal_utils.py @@ -0,0 +1,81 @@ +"""Unit testing for cmd2/terminal_utils.py module""" + +import pytest + +from cmd2 import ( + Color, +) +from cmd2 import string_utils as su +from cmd2 import terminal_utils as tu + + +def test_set_title() -> None: + title = "Hello, world!" + assert tu.set_title_str(title) == tu.OSC + '2;' + title + tu.BEL + + +@pytest.mark.parametrize( + ('cols', 'prompt', 'line', 'cursor', 'msg', 'expected'), + [ + ( + 127, + '(Cmd) ', + 'help his', + 12, + su.stylize('Hello World!', style=Color.MAGENTA), + '\x1b[2K\r\x1b[35mHello World!\x1b[0m', + ), + (127, '\n(Cmd) ', 'help ', 5, 'foo', '\x1b[2K\x1b[1A\x1b[2K\rfoo'), + ( + 10, + '(Cmd) ', + 'help history of the american republic', + 4, + 'boo', + '\x1b[3B\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\rboo', + ), + ], +) +def test_async_alert_str(cols, prompt, line, cursor, msg, expected) -> None: + alert_str = tu.async_alert_str(terminal_columns=cols, prompt=prompt, line=line, cursor_offset=cursor, alert_msg=msg) + assert alert_str == expected + + +def test_clear_screen() -> None: + clear_type = 2 + assert tu.clear_screen_str(clear_type) == f"{tu.CSI}{clear_type}J" + + clear_type = -1 + expected_err = "clear_type must in an integer from 0 to 3" + with pytest.raises(ValueError, match=expected_err): + tu.clear_screen_str(clear_type) + + clear_type = 4 + with pytest.raises(ValueError, match=expected_err): + tu.clear_screen_str(clear_type) + + +def test_clear_line() -> None: + clear_type = 2 + assert tu.clear_line_str(clear_type) == f"{tu.CSI}{clear_type}K" + + clear_type = -1 + expected_err = "clear_type must in an integer from 0 to 2" + with pytest.raises(ValueError, match=expected_err): + tu.clear_line_str(clear_type) + + clear_type = 3 + with pytest.raises(ValueError, match=expected_err): + tu.clear_line_str(clear_type) + + +def test_cursor() -> None: + count = 1 + assert tu.Cursor.UP(count) == f"{tu.CSI}{count}A" + assert tu.Cursor.DOWN(count) == f"{tu.CSI}{count}B" + assert tu.Cursor.FORWARD(count) == f"{tu.CSI}{count}C" + assert tu.Cursor.BACK(count) == f"{tu.CSI}{count}D" + + x = 4 + y = 5 + assert tu.Cursor.SET_POS(x, y) == f"{tu.CSI}{y};{x}H" diff --git a/tests/test_transcript.py b/tests/test_transcript.py index 0739c0c7b..8a654ecd5 100644 --- a/tests/test_transcript.py +++ b/tests/test_transcript.py @@ -115,7 +115,6 @@ def test_commands_at_invocation() -> None: ('multiline_regex.txt', False), ('no_output.txt', False), ('no_output_last.txt', False), - ('regex_set.txt', False), ('singleslash.txt', False), ('slashes_escaped.txt', False), ('slashslash.txt', False), diff --git a/tests/test_utils.py b/tests/test_utils.py index 334b13007..a5a83ba13 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -11,29 +11,10 @@ import pytest import cmd2.utils as cu -from cmd2 import ( - ansi, - constants, -) -from cmd2.constants import ( - HORIZONTAL_ELLIPSIS, -) HELLO_WORLD = 'Hello, world!' -def test_strip_quotes_no_quotes() -> None: - base_str = HELLO_WORLD - stripped = cu.strip_quotes(base_str) - assert base_str == stripped - - -def test_strip_quotes_with_quotes() -> None: - base_str = '"' + HELLO_WORLD + '"' - stripped = cu.strip_quotes(base_str) - assert stripped == HELLO_WORLD - - def test_remove_duplicates_no_duplicates() -> None: no_dups = [5, 4, 3, 2, 1] assert cu.remove_duplicates(no_dups) == no_dups @@ -44,20 +25,6 @@ def test_remove_duplicates_with_duplicates() -> None: assert cu.remove_duplicates(duplicates) == [1, 2, 3, 9, 7, 8] -def test_unicode_normalization() -> None: - s1 = 'café' - s2 = 'cafe\u0301' - assert s1 != s2 - assert cu.norm_fold(s1) == cu.norm_fold(s2) - - -def test_unicode_casefold() -> None: - micro = 'µ' - micro_cf = micro.casefold() - assert micro != micro_cf - assert cu.norm_fold(micro) == cu.norm_fold(micro_cf) - - def test_alphabetical_sort() -> None: my_list = ['café', 'µ', 'A', 'micro', 'unity', 'cafeteria'] assert cu.alphabetical_sort(my_list) == ['A', 'cafeteria', 'café', 'micro', 'unity', 'µ'] @@ -92,54 +59,6 @@ def test_natural_sort() -> None: assert cu.natural_sort(my_list) == ['a1', 'A2', 'a3', 'A11', 'a22'] -def test_is_quoted_short() -> None: - my_str = '' - assert not cu.is_quoted(my_str) - your_str = '"' - assert not cu.is_quoted(your_str) - - -def test_is_quoted_yes() -> None: - my_str = '"This is a test"' - assert cu.is_quoted(my_str) - your_str = "'of the emergengy broadcast system'" - assert cu.is_quoted(your_str) - - -def test_is_quoted_no() -> None: - my_str = '"This is a test' - assert not cu.is_quoted(my_str) - your_str = "of the emergengy broadcast system'" - assert not cu.is_quoted(your_str) - simple_str = "hello world" - assert not cu.is_quoted(simple_str) - - -def test_quote_string() -> None: - my_str = "Hello World" - assert cu.quote_string(my_str) == '"' + my_str + '"' - - my_str = "'Hello World'" - assert cu.quote_string(my_str) == '"' + my_str + '"' - - my_str = '"Hello World"' - assert cu.quote_string(my_str) == "'" + my_str + "'" - - -def test_quote_string_if_needed_yes() -> None: - my_str = "Hello World" - assert cu.quote_string_if_needed(my_str) == '"' + my_str + '"' - your_str = '"foo" bar' - assert cu.quote_string_if_needed(your_str) == "'" + your_str + "'" - - -def test_quote_string_if_needed_no() -> None: - my_str = "HelloWorld" - assert cu.quote_string_if_needed(my_str) == my_str - your_str = "'Hello World'" - assert cu.quote_string_if_needed(your_str) == your_str - - @pytest.fixture def stdout_sim(): return cu.StdSim(sys.stdout, echo=True) @@ -329,484 +248,6 @@ def test_context_flag_exit_err(context_flag) -> None: context_flag.__exit__() -def test_remove_overridden_styles() -> None: - from cmd2 import ( - Bg, - EightBitBg, - EightBitFg, - Fg, - RgbBg, - RgbFg, - TextStyle, - ) - - def make_strs(styles_list: list[ansi.AnsiSequence]) -> list[str]: - return [str(s) for s in styles_list] - - # Test Reset All - styles_to_parse = make_strs([Fg.BLUE, TextStyle.UNDERLINE_DISABLE, TextStyle.INTENSITY_DIM, TextStyle.RESET_ALL]) - expected = make_strs([TextStyle.RESET_ALL]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - styles_to_parse = make_strs([Fg.BLUE, TextStyle.UNDERLINE_DISABLE, TextStyle.INTENSITY_DIM, TextStyle.ALT_RESET_ALL]) - expected = make_strs([TextStyle.ALT_RESET_ALL]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - # Test colors - styles_to_parse = make_strs([Fg.BLUE, Fg.RED, Fg.GREEN, Bg.BLUE, Bg.RED, Bg.GREEN]) - expected = make_strs([Fg.GREEN, Bg.GREEN]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - styles_to_parse = make_strs([EightBitFg.BLUE, EightBitFg.RED, EightBitBg.BLUE, EightBitBg.RED]) - expected = make_strs([EightBitFg.RED, EightBitBg.RED]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - styles_to_parse = make_strs([RgbFg(0, 3, 4), RgbFg(5, 6, 7), RgbBg(8, 9, 10), RgbBg(11, 12, 13)]) - expected = make_strs([RgbFg(5, 6, 7), RgbBg(11, 12, 13)]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - # Test text styles - styles_to_parse = make_strs([TextStyle.INTENSITY_DIM, TextStyle.INTENSITY_NORMAL, TextStyle.ITALIC_ENABLE]) - expected = make_strs([TextStyle.INTENSITY_NORMAL, TextStyle.ITALIC_ENABLE]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - styles_to_parse = make_strs([TextStyle.INTENSITY_DIM, TextStyle.ITALIC_ENABLE, TextStyle.ITALIC_DISABLE]) - expected = make_strs([TextStyle.INTENSITY_DIM, TextStyle.ITALIC_DISABLE]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - styles_to_parse = make_strs([TextStyle.INTENSITY_BOLD, TextStyle.OVERLINE_DISABLE, TextStyle.OVERLINE_ENABLE]) - expected = make_strs([TextStyle.INTENSITY_BOLD, TextStyle.OVERLINE_ENABLE]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - styles_to_parse = make_strs([TextStyle.OVERLINE_DISABLE, TextStyle.STRIKETHROUGH_DISABLE, TextStyle.STRIKETHROUGH_ENABLE]) - expected = make_strs([TextStyle.OVERLINE_DISABLE, TextStyle.STRIKETHROUGH_ENABLE]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - styles_to_parse = make_strs([TextStyle.STRIKETHROUGH_DISABLE, TextStyle.UNDERLINE_DISABLE, TextStyle.UNDERLINE_ENABLE]) - expected = make_strs([TextStyle.STRIKETHROUGH_DISABLE, TextStyle.UNDERLINE_ENABLE]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - styles_to_parse = make_strs([TextStyle.UNDERLINE_DISABLE]) - expected = make_strs([TextStyle.UNDERLINE_DISABLE]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - # Test unrecognized styles - slow_blink = ansi.CSI + str(5) - rapid_blink = ansi.CSI + str(6) - styles_to_parse = [slow_blink, rapid_blink] - expected = styles_to_parse - assert cu._remove_overridden_styles(styles_to_parse) == expected - - -def test_truncate_line() -> None: - line = 'long' - max_width = 3 - truncated = cu.truncate_line(line, max_width) - assert truncated == 'lo' + HORIZONTAL_ELLIPSIS - - -def test_truncate_line_already_fits() -> None: - line = 'long' - max_width = 4 - truncated = cu.truncate_line(line, max_width) - assert truncated == line - - -def test_truncate_line_with_newline() -> None: - line = 'fo\no' - max_width = 2 - with pytest.raises(ValueError, match="text contains an unprintable character"): - cu.truncate_line(line, max_width) - - -def test_truncate_line_width_is_too_small() -> None: - line = 'foo' - max_width = 0 - with pytest.raises(ValueError, match="max_width must be at least 1"): - cu.truncate_line(line, max_width) - - -def test_truncate_line_wide_text() -> None: - line = '苹苹other' - max_width = 6 - truncated = cu.truncate_line(line, max_width) - assert truncated == '苹苹o' + HORIZONTAL_ELLIPSIS - - -def test_truncate_line_split_wide_text() -> None: - """Test when truncation results in a string which is shorter than max_width""" - line = '1苹2苹' - max_width = 3 - truncated = cu.truncate_line(line, max_width) - assert truncated == '1' + HORIZONTAL_ELLIPSIS - - -def test_truncate_line_tabs() -> None: - line = 'has\ttab' - max_width = 9 - truncated = cu.truncate_line(line, max_width) - assert truncated == 'has t' + HORIZONTAL_ELLIPSIS - - -def test_truncate_with_style() -> None: - from cmd2 import ( - Fg, - TextStyle, - ) - - before_text = Fg.BLUE + TextStyle.UNDERLINE_ENABLE - after_text = Fg.RESET + TextStyle.UNDERLINE_DISABLE + TextStyle.ITALIC_ENABLE + TextStyle.ITALIC_DISABLE - - # This is what the styles after the truncated text should look like since they will be - # filtered by _remove_overridden_styles. - filtered_after_text = Fg.RESET + TextStyle.UNDERLINE_DISABLE + TextStyle.ITALIC_DISABLE - - # Style only before truncated text - line = before_text + 'long' - max_width = 3 - truncated = cu.truncate_line(line, max_width) - assert truncated == before_text + 'lo' + HORIZONTAL_ELLIPSIS - - # Style before and after truncated text - line = before_text + 'long' + after_text - max_width = 3 - truncated = cu.truncate_line(line, max_width) - assert truncated == before_text + 'lo' + HORIZONTAL_ELLIPSIS + filtered_after_text - - # Style only after truncated text - line = 'long' + after_text - max_width = 3 - truncated = cu.truncate_line(line, max_width) - assert truncated == 'lo' + HORIZONTAL_ELLIPSIS + filtered_after_text - - -def test_align_text_fill_char_is_tab() -> None: - text = 'foo' - fill_char = '\t' - width = 5 - aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width) - assert aligned == text + ' ' - - -def test_align_text_with_style() -> None: - from cmd2 import ( - Fg, - TextStyle, - style, - ) - - fill_char = '-' - styled_fill_char = style(fill_char, fg=Fg.LIGHT_YELLOW) - - # Single line with only left fill - text = style('line1', fg=Fg.LIGHT_BLUE) - width = 8 - - aligned = cu.align_text(text, cu.TextAlignment.RIGHT, fill_char=styled_fill_char, width=width) - - left_fill = TextStyle.RESET_ALL + Fg.LIGHT_YELLOW + (fill_char * 3) + Fg.RESET + TextStyle.RESET_ALL - right_fill = TextStyle.RESET_ALL - line_1_text = Fg.LIGHT_BLUE + 'line1' + Fg.RESET - - assert aligned == (left_fill + line_1_text + right_fill) - - # Single line with only right fill - text = style('line1', fg=Fg.LIGHT_BLUE) - width = 8 - - aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=styled_fill_char, width=width) - - left_fill = TextStyle.RESET_ALL - right_fill = TextStyle.RESET_ALL + Fg.LIGHT_YELLOW + (fill_char * 3) + Fg.RESET + TextStyle.RESET_ALL - line_1_text = Fg.LIGHT_BLUE + 'line1' + Fg.RESET - - assert aligned == (left_fill + line_1_text + right_fill) - - # Multiple lines to show that style is preserved across all lines. Also has left and right fill. - text = style('line1\nline2', fg=Fg.LIGHT_BLUE) - width = 9 - - aligned = cu.align_text(text, cu.TextAlignment.CENTER, fill_char=styled_fill_char, width=width) - - left_fill = TextStyle.RESET_ALL + Fg.LIGHT_YELLOW + (fill_char * 2) + Fg.RESET + TextStyle.RESET_ALL - right_fill = TextStyle.RESET_ALL + Fg.LIGHT_YELLOW + (fill_char * 2) + Fg.RESET + TextStyle.RESET_ALL - line_1_text = Fg.LIGHT_BLUE + 'line1' - line_2_text = Fg.LIGHT_BLUE + 'line2' + Fg.RESET - - assert aligned == (left_fill + line_1_text + right_fill + '\n' + left_fill + line_2_text + right_fill) - - -def test_align_text_width_is_too_small() -> None: - text = 'foo' - fill_char = '-' - width = 0 - with pytest.raises(ValueError, match="width must be at least 1"): - cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width) - - -def test_align_text_fill_char_is_too_long() -> None: - text = 'foo' - fill_char = 'fill' - width = 5 - with pytest.raises(TypeError): - cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width) - - -def test_align_text_fill_char_is_newline() -> None: - text = 'foo' - fill_char = '\n' - width = 5 - with pytest.raises(ValueError, match="Fill character is an unprintable character"): - cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width) - - -def test_align_text_has_tabs() -> None: - text = '\t\tfoo' - fill_char = '-' - width = 10 - aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width, tab_width=2) - assert aligned == ' ' + 'foo' + '---' - - -def test_align_text_blank() -> None: - text = '' - fill_char = '-' - width = 5 - aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width) - assert aligned == fill_char * width - - -def test_align_text_wider_than_width() -> None: - text = 'long text field' - fill_char = '-' - width = 8 - aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width) - assert aligned == text - - -def test_align_text_wider_than_width_truncate() -> None: - text = 'long text field' - fill_char = '-' - width = 8 - aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width, truncate=True) - assert aligned == 'long te' + HORIZONTAL_ELLIPSIS - - -def test_align_text_wider_than_width_truncate_add_fill() -> None: - """Test when truncation results in a string which is shorter than width and align_text adds filler""" - text = '1苹2苹' - fill_char = '-' - width = 3 - aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width, truncate=True) - assert aligned == '1' + HORIZONTAL_ELLIPSIS + fill_char - - -def test_align_text_has_unprintable() -> None: - text = 'foo\x02' - fill_char = '-' - width = 5 - with pytest.raises(ValueError, match="Text to align contains an unprintable character"): - cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width) - - -def test_align_text_term_width() -> None: - import shutil - - text = 'foo' - fill_char = ' ' - - # Prior to Python 3.11 this can return 0, so use a fallback, so - # use the same fallback that cu.align_text() does if needed. - term_width = shutil.get_terminal_size().columns or constants.DEFAULT_TERMINAL_WIDTH - expected_fill = (term_width - ansi.style_aware_wcswidth(text)) * fill_char - - aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char) - assert aligned == text + expected_fill - - -def test_align_left() -> None: - text = 'foo' - fill_char = '-' - width = 5 - aligned = cu.align_left(text, fill_char=fill_char, width=width) - assert aligned == text + fill_char + fill_char - - -def test_align_left_multiline() -> None: - # Without style - text = "foo\nshoes" - fill_char = '-' - width = 7 - aligned = cu.align_left(text, fill_char=fill_char, width=width) - assert aligned == 'foo----\nshoes--' - - # With style - reset_all = str(ansi.TextStyle.RESET_ALL) - blue = str(ansi.Fg.BLUE) - red = str(ansi.Fg.RED) - green = str(ansi.Fg.GREEN) - fg_reset = str(ansi.Fg.RESET) - - text = f"{blue}foo{red}moo\nshoes{fg_reset}" - fill_char = f"{green}-{fg_reset}" - width = 7 - aligned = cu.align_left(text, fill_char=fill_char, width=width) - - expected = f"{reset_all}{blue}foo{red}moo{reset_all}{green}-{fg_reset}{reset_all}\n" - expected += f"{reset_all}{red}shoes{fg_reset}{reset_all}{green}--{fg_reset}{reset_all}" - assert aligned == expected - - -def test_align_left_wide_text() -> None: - text = '苹' - fill_char = '-' - width = 4 - aligned = cu.align_left(text, fill_char=fill_char, width=width) - assert aligned == text + fill_char + fill_char - - -def test_align_left_wide_fill() -> None: - text = 'foo' - fill_char = '苹' - width = 5 - aligned = cu.align_left(text, fill_char=fill_char, width=width) - assert aligned == text + fill_char - - -def test_align_left_wide_fill_needs_padding() -> None: - """Test when fill_char's display width does not divide evenly into gap""" - text = 'foo' - fill_char = '苹' - width = 6 - aligned = cu.align_left(text, fill_char=fill_char, width=width) - assert aligned == text + fill_char + ' ' - - -def test_align_center() -> None: - text = 'foo' - fill_char = '-' - width = 5 - aligned = cu.align_center(text, fill_char=fill_char, width=width) - assert aligned == fill_char + text + fill_char - - -def test_align_center_multiline() -> None: - # Without style - text = "foo\nshoes" - fill_char = '-' - width = 7 - aligned = cu.align_center(text, fill_char=fill_char, width=width) - assert aligned == '--foo--\n-shoes-' - - # With style - reset_all = str(ansi.TextStyle.RESET_ALL) - blue = str(ansi.Fg.BLUE) - red = str(ansi.Fg.RED) - green = str(ansi.Fg.GREEN) - fg_reset = str(ansi.Fg.RESET) - - text = f"{blue}foo{red}moo\nshoes{fg_reset}" - fill_char = f"{green}-{fg_reset}" - width = 10 - aligned = cu.align_center(text, fill_char=fill_char, width=width) - - expected = f"{reset_all}{green}--{fg_reset}{reset_all}{blue}foo{red}moo{reset_all}{green}--{fg_reset}{reset_all}\n" - expected += f"{reset_all}{green}--{fg_reset}{reset_all}{red}shoes{fg_reset}{reset_all}{green}---{fg_reset}{reset_all}" - assert aligned == expected - - -def test_align_center_wide_text() -> None: - text = '苹' - fill_char = '-' - width = 4 - aligned = cu.align_center(text, fill_char=fill_char, width=width) - assert aligned == fill_char + text + fill_char - - -def test_align_center_wide_fill() -> None: - text = 'foo' - fill_char = '苹' - width = 7 - aligned = cu.align_center(text, fill_char=fill_char, width=width) - assert aligned == fill_char + text + fill_char - - -def test_align_center_wide_fill_needs_right_padding() -> None: - """Test when fill_char's display width does not divide evenly into right gap""" - text = 'foo' - fill_char = '苹' - width = 8 - aligned = cu.align_center(text, fill_char=fill_char, width=width) - assert aligned == fill_char + text + fill_char + ' ' - - -def test_align_center_wide_fill_needs_left_and_right_padding() -> None: - """Test when fill_char's display width does not divide evenly into either gap""" - text = 'foo' - fill_char = '苹' - width = 9 - aligned = cu.align_center(text, fill_char=fill_char, width=width) - assert aligned == fill_char + ' ' + text + fill_char + ' ' - - -def test_align_right() -> None: - text = 'foo' - fill_char = '-' - width = 5 - aligned = cu.align_right(text, fill_char=fill_char, width=width) - assert aligned == fill_char + fill_char + text - - -def test_align_right_multiline() -> None: - # Without style - text = "foo\nshoes" - fill_char = '-' - width = 7 - aligned = cu.align_right(text, fill_char=fill_char, width=width) - assert aligned == '----foo\n--shoes' - - # With style - reset_all = str(ansi.TextStyle.RESET_ALL) - blue = str(ansi.Fg.BLUE) - red = str(ansi.Fg.RED) - green = str(ansi.Fg.GREEN) - fg_reset = str(ansi.Fg.RESET) - - text = f"{blue}foo{red}moo\nshoes{fg_reset}" - fill_char = f"{green}-{fg_reset}" - width = 7 - aligned = cu.align_right(text, fill_char=fill_char, width=width) - - expected = f"{reset_all}{green}-{fg_reset}{reset_all}{blue}foo{red}moo{reset_all}\n" - expected += f"{reset_all}{green}--{fg_reset}{reset_all}{red}shoes{fg_reset}{reset_all}" - assert aligned == expected - - -def test_align_right_wide_text() -> None: - text = '苹' - fill_char = '-' - width = 4 - aligned = cu.align_right(text, fill_char=fill_char, width=width) - assert aligned == fill_char + fill_char + text - - -def test_align_right_wide_fill() -> None: - text = 'foo' - fill_char = '苹' - width = 5 - aligned = cu.align_right(text, fill_char=fill_char, width=width) - assert aligned == fill_char + text - - -def test_align_right_wide_fill_needs_padding() -> None: - """Test when fill_char's display width does not divide evenly into gap""" - text = 'foo' - fill_char = '苹' - width = 6 - aligned = cu.align_right(text, fill_char=fill_char, width=width) - assert aligned == fill_char + ' ' + text - - def test_to_bool_str_true() -> None: assert cu.to_bool('true') assert cu.to_bool('True') diff --git a/tests/transcripts/from_cmdloop.txt b/tests/transcripts/from_cmdloop.txt index f1c68d813..da5363831 100644 --- a/tests/transcripts/from_cmdloop.txt +++ b/tests/transcripts/from_cmdloop.txt @@ -6,7 +6,7 @@ Usage: speak [-h] [-p] [-s] [-r REPEAT]/ */ Repeats what you tell me to./ */ -optional arguments:/ */ +Optional Arguments:/ */ -h, --help show this help message and exit/ */ -p, --piglatin atinLay/ */ -s, --shout N00B EMULATION MODE/ */ diff --git a/tests/transcripts/regex_set.txt b/tests/transcripts/regex_set.txt deleted file mode 100644 index 4d40ce0f6..000000000 --- a/tests/transcripts/regex_set.txt +++ /dev/null @@ -1,28 +0,0 @@ -# Run this transcript with "python example.py -t transcript_regex.txt" -# The regex for colors shows all possible settings for colors -# The regex for editor will match whatever program you use. -# Regexes on prompts just make the trailing space obvious - -(Cmd) set allow_style Terminal -allow_style - was: '/.*/' -now: 'Terminal' -(Cmd) set editor vim -editor - was: '/.*/' -now: 'vim' -(Cmd) set -Name Value Description/ +/ -==================================================================================================================== -allow_style Terminal Allow ANSI text style sequences in output (valid values:/ +/ - Always, Never, Terminal)/ +/ -always_show_hint False Display tab completion hint even when completion suggestions - print/ +/ -debug False Show full traceback on exception/ +/ -echo False Echo command issued into output/ +/ -editor vim Program used by 'edit'/ +/ -feedback_to_output False Include nonessentials in '|', '>' results/ +/ -max_completion_items 50 Maximum number of CompletionItems to display during tab/ +/ - completion/ +/ -maxrepeats 3 Max number of `--repeat`s allowed/ +/ -quiet False Don't print nonessential feedback/ +/ -scripts_add_to_history True Scripts and pyscripts add commands to history/ +/ -timing False Report execution times/ +/ diff --git a/tests_isolated/test_commandset/conftest.py b/tests_isolated/test_commandset/conftest.py index 171f4a29f..ec476bbfc 100644 --- a/tests_isolated/test_commandset/conftest.py +++ b/tests_isolated/test_commandset/conftest.py @@ -5,10 +5,6 @@ redirect_stderr, redirect_stdout, ) -from typing import ( - Optional, - Union, -) from unittest import ( mock, ) @@ -27,9 +23,7 @@ ) -def verify_help_text( - cmd2_app: cmd2.Cmd, help_output: Union[str, list[str]], verbose_strings: Optional[list[str]] = None -) -> None: +def verify_help_text(cmd2_app: cmd2.Cmd, help_output: str | list[str], verbose_strings: list[str] | None = None) -> None: """This function verifies that all expected commands are present in the help text. :param cmd2_app: instance of cmd2.Cmd @@ -46,42 +40,6 @@ def verify_help_text( assert verbose_string in help_text -# Help text for the history command -HELP_HISTORY = """Usage: history [-h] [-r | -e | -o FILE | -t TRANSCRIPT_FILE | -c] [-s] [-x] - [-v] [-a] - [arg] - -View, run, edit, save, or clear previously entered commands - -positional arguments: - arg empty all history items - a one history item by number - a..b, a:b, a:, ..b items by indices (inclusive) - string items containing string - /regex/ items matching regular expression - -optional arguments: - -h, --help show this help message and exit - -r, --run run selected history items - -e, --edit edit and then run selected history items - -o, --output_file FILE - output commands to a script file, implies -s - -t, --transcript TRANSCRIPT_FILE - output commands and results to a transcript file, - implies -s - -c, --clear clear all history - -formatting: - -s, --script output commands in script format, i.e. without command - numbers - -x, --expanded output fully parsed commands with any aliases and - macros expanded, instead of typed commands - -v, --verbose display history and include expanded commands if they - differ from the typed command - -a, --all display all commands, including ones persisted from - previous sessions -""" - # Output from the shortcuts command with default built-in shortcuts SHORTCUTS_TXT = """Shortcuts for other commands: !: shell @@ -135,7 +93,7 @@ def base_app(): odd_file_names = ['nothingweird', 'has spaces', '"is_double_quoted"', "'is_single_quoted'"] -def complete_tester(text: str, line: str, begidx: int, endidx: int, app) -> Optional[str]: +def complete_tester(text: str, line: str, begidx: int, endidx: int, app) -> str | None: """This is a convenience function to test cmd2.complete() since in a unit test environment there is no actual console readline is monitoring. Therefore we use mock to provide readline data diff --git a/tests_isolated/test_commandset/test_argparse_subcommands.py b/tests_isolated/test_commandset/test_argparse_subcommands.py index 5f4645d57..a95c57777 100644 --- a/tests_isolated/test_commandset/test_argparse_subcommands.py +++ b/tests_isolated/test_commandset/test_argparse_subcommands.py @@ -45,7 +45,7 @@ def base_helpless(self, args) -> None: parser_bar.set_defaults(func=base_bar) # create the parser for the "helpless" subcommand - # This subcommand has aliases and no help text. It exists to prevent changes to _set_parser_prog() which + # This subcommand has aliases and no help text. It exists to prevent changes to set_parser_prog() which # use an approach which relies on action._choices_actions list. See comment in that function for more # details. parser_helpless = base_subparsers.add_parser('helpless', aliases=['helpless_1', 'helpless_2']) @@ -93,39 +93,39 @@ def test_subcommand_help(subcommand_app) -> None: out, err = run_cmd(subcommand_app, 'help base foo') assert out[0].startswith('Usage: base foo') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' # bar has aliases (usage should never show alias name) out, err = run_cmd(subcommand_app, 'help base bar') assert out[0].startswith('Usage: base bar') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' out, err = run_cmd(subcommand_app, 'help base bar_1') assert out[0].startswith('Usage: base bar') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' out, err = run_cmd(subcommand_app, 'help base bar_2') assert out[0].startswith('Usage: base bar') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' # helpless has aliases and no help text (usage should never show alias name) out, err = run_cmd(subcommand_app, 'help base helpless') assert out[0].startswith('Usage: base helpless') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' out, err = run_cmd(subcommand_app, 'help base helpless_1') assert out[0].startswith('Usage: base helpless') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' out, err = run_cmd(subcommand_app, 'help base helpless_2') assert out[0].startswith('Usage: base helpless') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' def test_subcommand_invalid_help(subcommand_app) -> None: