diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 812c7153..514ac365 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -46,20 +46,21 @@ The tables below list all prerequisites along with the minimum required version #### Prerequisites to run cmd2 applications -| Prerequisite | Minimum Version | -| --------------------------------------------------- | --------------- | -| [python](https://www.python.org/downloads/) | `3.8` | -| [pyperclip](https://github.com/asweigart/pyperclip) | `1.8.2` | -| [wcwidth](https://pypi.python.org/pypi/wcwidth) | `0.2.12` | +| Prerequisite | Minimum Version | Purpose | +| -------------------------------------------------------- | --------------- | -------------------------------------- | +| [python](https://www.python.org/downloads/) | `3.9` | Python programming language | +| [pyperclip](https://github.com/asweigart/pyperclip) | `1.8.2` | Cross-platform clipboard functions | +| [rich-argparse](https://pypi.org/project/rich-argparse/) | `1.6.0` | Rich help formatters for argparse | +| [wcwidth](https://pypi.python.org/pypi/wcwidth) | `0.2.12` | Measure the displayed width of unicode | #### Additional prerequisites to build and publish cmd2 -| Prerequisite | Minimum Version | -| -------------------------------------------------------- | --------------- | -| [build](https://pypi.org/project/build/) | `1.2.2` | -| [setuptools](https://pypi.org/project/setuptools/) | `72.1.0` | -| [setuptools-scm](https://github.com/pypa/setuptools-scm) | `8.0.4` | -| [twine](https://github.com/pypa/twine) | `5.1.1` | +| Prerequisite | Minimum Version | Purpose | +| -------------------------------------------------------- | --------------- | ----------------------------------- | +| [build](https://pypi.org/project/build/) | `1.2.2` | Python build frontend | +| [setuptools](https://pypi.org/project/setuptools/) | `72.1.0` | Python package management | +| [setuptools-scm](https://github.com/pypa/setuptools-scm) | `8.0.4` | Manage your versions by scm tag s | +| [twine](https://github.com/pypa/twine) | `5.1.1` | Utilities for interacting with PyPI | #### Additional prerequisites for developing cmd2 @@ -94,7 +95,7 @@ on all platforms (Windows, Mac, and Linux). You can install `uv` using instructi You can then install multiple versions of Python using `uv` like so: ```sh -uv python install 3.10 3.11 3.12 3.13 +uv python install 3.9 3.10 3.11 3.12 3.13 ``` ### Forking the project diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5ab971b8..9f81a7a0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,7 +10,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 # https://github.com/actions/checkout diff --git a/CHANGELOG.md b/CHANGELOG.md index 56d0a33d..ea4209fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## 3.0.0 (TBD) + +- Breaking Changes + - `cmd2` 3.x supports Python 3.9+ (removed support for Python 3.8) + - Removed macros +- Enhancements + - Simplified the process to set a custom parser for `cmd2's` built-in commands. + See [custom_parser.py](https://github.com/python-cmd2/cmd2/blob/master/examples/custom_parser.py) + example for more details. + - Integrated `rich-argparse` with `Cmd2HelpFormatter`. + - Added `RawDescriptionCmd2HelpFormatter`, `RawTextCmd2HelpFormatter`, `ArgumentDefaultsCmd2HelpFormatter`, + and `MetavarTypeCmd2HelpFormatter` and they all use `rich-argparse`. + ## 2.5.11 (January 25, 2025) - Bug Fixes @@ -7,7 +20,7 @@ - Bug Fixes - Fixed docstring style for MkDocs API documentation so parameters are displayed properly. - + ## 2.5.9 (January 17, 2025) - Bug Fixes diff --git a/Pipfile b/Pipfile index b0ba2c0c..58e86480 100644 --- a/Pipfile +++ b/Pipfile @@ -5,6 +5,8 @@ verify_ssl = true [packages] pyperclip = "*" +rich = "*" +rich-argparse = "*" setuptools = "*" wcwidth = "*" diff --git a/README.md b/README.md index d34b9259..9a7def90 100755 --- a/README.md +++ b/README.md @@ -57,9 +57,9 @@ Deep extensive tab completion and help text generation based on the argparse lib -cmd2 creates the second pillar of 'ease of transition to automation' through alias/macro creation, command line argument parsing and execution of cmd2 scripting. +cmd2 creates the second pillar of 'ease of transition to automation' through alias creation, command line argument parsing and execution of cmd2 scripting. -- Flexible alias and macro creation for quick abstraction of commands. +- Flexible alias creation for quick abstraction of commands. - Text file scripting of your application with `run_script` (`@`) and `_relative_run_script` (`@@`) - Powerful and flexible built-in Python scripting of your application using the `run_pyscript` command - Transcripts for use with built-in regression can be automatically generated from `history -t` or `run_script -t` @@ -72,7 +72,7 @@ On all operating systems, the latest stable version of `cmd2` can be installed u pip install -U cmd2 ``` -cmd2 works with Python 3.8+ on Windows, macOS, and Linux. It is pure Python code with few 3rd-party dependencies. +cmd2 works with Python 3.9+ on Windows, macOS, and Linux. It is pure Python code with few 3rd-party dependencies. For information on other installation options, see [Installation Instructions](https://cmd2.readthedocs.io/en/latest/overview/installation.html) in the cmd2 diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 8f1f030e..4b9f65ea 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -3,8 +3,6 @@ # flake8: noqa F401 """This simply imports certain things for backwards compatibility.""" -import sys - import importlib.metadata as importlib_metadata try: @@ -13,19 +11,19 @@ # package is not installed pass -from typing import List - +from . import plugin from .ansi import ( - Cursor, Bg, - Fg, + Cursor, EightBitBg, EightBitFg, + Fg, RgbBg, RgbFg, TextStyle, style, ) +from .argparse_completer import set_default_ap_completer_type from .argparse_custom import ( Cmd2ArgumentParser, Cmd2AttributeWrapper, @@ -33,23 +31,21 @@ 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. -import argparse - -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 .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 with_argument_list, with_argparser, with_category, as_subcommand_to +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, @@ -57,13 +53,16 @@ PassThroughException, SkipPostcommandHooks, ) -from . import plugin from .parsing import Statement from .py_bridge import CommandResult -from .utils import categorize, CompletionMode, CustomCompletionSettings, Settable - +from .utils import ( + CompletionMode, + CustomCompletionSettings, + Settable, + categorize, +) -__all__: List[str] = [ +__all__: list[str] = [ 'COMMAND_NAME', 'DEFAULT_SHORTCUTS', # ANSI Exports @@ -81,8 +80,8 @@ 'Cmd2AttributeWrapper', 'CompletionItem', 'register_argparse_argument_parameter', - 'set_default_argument_parser_type', 'set_default_ap_completer_type', + 'set_default_argument_parser_type', # Cmd2 'Cmd', 'CommandResult', @@ -98,6 +97,7 @@ 'Cmd2ArgparseError', 'CommandSetRegistrationError', 'CompletionError', + 'PassThroughException', 'SkipPostcommandHooks', # modules 'plugin', diff --git a/cmd2/ansi.py b/cmd2/ansi.py index 8242957a..4399f2de 100644 --- a/cmd2/ansi.py +++ b/cmd2/ansi.py @@ -12,12 +12,11 @@ from typing import ( IO, Any, - List, Optional, cast, ) -from wcwidth import ( # type: ignore[import] +from wcwidth import ( # type: ignore[import-untyped] wcswidth, ) @@ -992,11 +991,11 @@ def style( :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 add style + additions: list[AnsiSequence] = [] - # List of strings that remove style - removals: List[AnsiSequence] = [] + # list of strings that remove style + removals: list[AnsiSequence] = [] # Process the style settings if fg is not None: diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 8dd543c2..3a0f3190 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -14,8 +14,6 @@ ) from typing import ( TYPE_CHECKING, - Dict, - List, Optional, Type, Union, @@ -172,7 +170,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: Optional[dict[str, list[str]]] = None ) -> None: """ Create an ArgparseCompleter @@ -213,8 +211,8 @@ 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 - ) -> List[str]: + self, text: str, line: str, begidx: int, endidx: int, tokens: list[str], *, cmd_set: Optional[CommandSet] = None + ) -> list[str]: """ Complete text using argparse metadata @@ -245,13 +243,13 @@ def complete( flag_arg_state: Optional[_ArgumentState] = None # Non-reusable flags that we've parsed - matched_flags: List[str] = [] + matched_flags: list[str] = [] # Keeps track of arguments we've seen and any tokens they consumed - consumed_arg_values: Dict[str, List[str]] = dict() # dict(arg_name -> List[tokens]) + consumed_arg_values: dict[str, list[str]] = dict() # dict(arg_name -> l[tokens]) # Completed mutually exclusive groups - completed_mutex_groups: Dict[argparse._MutuallyExclusiveGroup, argparse.Action] = dict() + completed_mutex_groups: dict[argparse._MutuallyExclusiveGroup, argparse.Action] = dict() def consume_argument(arg_state: _ArgumentState) -> None: """Consuming token as an argument""" @@ -507,7 +505,7 @@ def update_mutex_groups(arg_action: argparse.Action) -> None: return completion_results - def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, matched_flags: List[str]) -> List[str]: + def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, matched_flags: list[str]) -> list[str]: """Tab completion routine for a parsers unused flags""" # Build a list of flags that can be tab completed @@ -524,7 +522,7 @@ def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, matche matches = self._cmd2_app.basic_complete(text, line, begidx, endidx, match_against) # Build a dictionary linking actions with their matched flag names - matched_actions: Dict[argparse.Action, List[str]] = dict() + matched_actions: dict[argparse.Action, list[str]] = dict() for flag in matches: action = self._flag_to_action[flag] matched_actions.setdefault(action, []) @@ -541,14 +539,14 @@ 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: Union[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): - return cast(List[str], completions) + return cast(list[str], completions) - completion_items = cast(List[CompletionItem], completions) + completion_items = cast(list[CompletionItem], completions) # Check if the data being completed have a numerical type all_nums = all(isinstance(c.orig_value, numbers.Number) for c in completion_items) @@ -557,7 +555,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: @@ -616,9 +614,9 @@ def _format_completions(self, arg_state: _ArgumentState, completions: Union[List self._cmd2_app.formatted_completions = hint_table.generate_table(table_data, row_spacing=0) # Return sorted list of completions - return cast(List[str], completions) + return cast(list[str], completions) - def complete_subcommand_help(self, text: str, line: str, begidx: int, endidx: int, tokens: List[str]) -> List[str]: + def complete_subcommand_help(self, text: str, line: str, begidx: int, endidx: int, tokens: list[str]) -> list[str]: """ Supports cmd2's help command in the completion of subcommand names :param text: the string prefix we are attempting to match (all matches must begin with it) @@ -626,7 +624,7 @@ def complete_subcommand_help(self, text: str, line: str, begidx: int, endidx: in :param begidx: the beginning index of the prefix text :param endidx: the ending index of the prefix text :param tokens: arguments passed to command/subcommand - :return: List of subcommand completions + :return: list of subcommand completions """ # 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. @@ -645,7 +643,7 @@ def complete_subcommand_help(self, text: str, line: str, begidx: int, endidx: in break return [] - def format_help(self, tokens: List[str]) -> str: + def format_help(self, tokens: list[str]) -> str: """ Supports cmd2's help command in the retrieval of help text :param tokens: arguments passed to help command @@ -672,17 +670,17 @@ def _complete_arg( begidx: int, endidx: int, arg_state: _ArgumentState, - consumed_arg_values: Dict[str, List[str]], + consumed_arg_values: dict[str, list[str]], *, cmd_set: Optional[CommandSet] = None, - ) -> List[str]: + ) -> list[str]: """ Tab completion routine for an argparse argument :return: list of completions :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: Union[list[str], ChoicesCallable] if arg_state.action.choices is not None: arg_choices = list(arg_state.action.choices) if not arg_choices: @@ -739,17 +737,17 @@ def _complete_arg( # Otherwise use basic_complete on the choices else: # Check if the choices come from a function - completion_items: List[str] = [] + completion_items: list[str] = [] if isinstance(arg_choices, ChoicesCallable): 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 68249d85..bd5ff0dd 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -7,7 +7,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 @@ -238,21 +238,29 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens) TYPE_CHECKING, Any, Callable, - Dict, Iterable, - List, NoReturn, Optional, Protocol, Sequence, - Set, - Tuple, Type, Union, cast, runtime_checkable, ) +from rich.console import ( + Group, + RenderableType, +) +from rich_argparse import ( + ArgumentDefaultsRichHelpFormatter, + MetavarTypeRichHelpFormatter, + RawDescriptionRichHelpFormatter, + RawTextRichHelpFormatter, + RichHelpFormatter, +) + from . import ( ansi, constants, @@ -325,7 +333,7 @@ class ChoicesProviderFuncBase(Protocol): Function that returns a list of choices in support of tab completion """ - def __call__(self) -> List[str]: ... # pragma: no cover + def __call__(self) -> list[str]: ... # pragma: no cover @runtime_checkable @@ -334,7 +342,7 @@ class ChoicesProviderFuncWithTokens(Protocol): Function that returns a list of choices in support of tab completion and accepts a dictionary of prior arguments. """ - def __call__(self, *, arg_tokens: Dict[str, List[str]] = {}) -> List[str]: ... # pragma: no cover + def __call__(self, *, arg_tokens: dict[str, list[str]] = {}) -> list[str]: ... # pragma: no cover ChoicesProviderFunc = Union[ChoicesProviderFuncBase, ChoicesProviderFuncWithTokens] @@ -352,7 +360,7 @@ def __call__( line: str, begidx: int, endidx: int, - ) -> List[str]: ... # pragma: no cover + ) -> list[str]: ... # pragma: no cover @runtime_checkable @@ -369,8 +377,8 @@ def __call__( begidx: int, endidx: int, *, - arg_tokens: Dict[str, List[str]] = {}, - ) -> List[str]: ... # pragma: no cover + arg_tokens: dict[str, list[str]] = {}, + ) -> list[str]: ... # pragma: no cover CompleterFunc = Union[CompleterFuncBase, CompleterFuncWithTokens] @@ -572,7 +580,7 @@ def _action_set_descriptive_header(self: argparse.Action, descriptive_header: Op ############################################################################################################ # 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) -> Optional[tuple[int, Union[int, float]]]: """ Get the nargs_range attribute of an argparse Action. @@ -583,13 +591,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(Optional[tuple[int, Union[int, float]]], 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: Optional[tuple[int, Union[int, float]]]) -> None: """ Set the nargs_range attribute of an argparse Action. @@ -647,7 +655,7 @@ def _action_set_suppress_tab_hint(self: argparse.Action, suppress_tab_hint: bool # Allow developers to add custom action attributes ############################################################################################################ -CUSTOM_ACTION_ATTRIBS: Set[str] = set() +CUSTOM_ACTION_ATTRIBS: set[str] = set() _CUSTOM_ATTRIB_PFX = '_attr_' @@ -720,7 +728,7 @@ 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, + nargs: Union[int, str, tuple[int], tuple[int, int], tuple[int, float], None] = None, choices_provider: Optional[ChoicesProviderFunc] = None, completer: Optional[CompleterFunc] = None, suppress_tab_hint: bool = False, @@ -771,7 +779,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: Union[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 @@ -781,11 +789,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') @@ -793,7 +801,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: @@ -821,7 +829,7 @@ def _add_argument_wrapper( kwargs['nargs'] = nargs_adjusted # Extract registered custom keyword arguments - custom_attribs: Dict[str, Any] = {} + custom_attribs: dict[str, Any] = {} for keyword, value in kwargs.items(): if keyword in CUSTOM_ACTION_ATTRIBS: custom_attribs[keyword] = value @@ -832,7 +840,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] @@ -868,7 +876,7 @@ def _get_nargs_pattern_wrapper(self: argparse.ArgumentParser, action: argparse.A if nargs_range[1] == constants.INFINITY: range_max = '' else: - range_max = nargs_range[1] # type: ignore[assignment] + range_max = nargs_range[1] nargs_pattern = f'(-*A{{{nargs_range[0]},{range_max}}}-*)' @@ -1028,9 +1036,14 @@ def _SubParsersAction_remove_parser(self: argparse._SubParsersAction, name: str) ############################################################################################################ -class Cmd2HelpFormatter(argparse.RawTextHelpFormatter): +class Cmd2HelpFormatter(RichHelpFormatter): """Custom help formatter to configure ordering of help text""" + # Render markup in usage, help, description, and epilog text. + RichHelpFormatter.usage_markup = True + RichHelpFormatter.help_markup = True + RichHelpFormatter.text_markup = True + def _format_usage( self, usage: Optional[str], @@ -1093,9 +1106,9 @@ def _format_usage( # End cmd2 customization # helper for wrapping lines - def get_lines(parts: List[str], indent: str, prefix: Optional[str] = None) -> List[str]: - lines: List[str] = [] - line: List[str] = [] + def get_lines(parts: list[str], indent: str, prefix: Optional[str] = None) -> list[str]: + lines: list[str] = [] + line: list[str] = [] if prefix is not None: line_len = len(prefix) - 1 else: @@ -1157,7 +1170,7 @@ def _format_action_invocation(self, action: argparse.Action) -> str: return metavar else: - parts: List[str] = [] + parts: list[str] = [] # if the Optional doesn't take a value, format is: # -s, --long @@ -1178,8 +1191,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: Union[str, tuple[str, ...]], + ) -> Union[str, tuple[str, ...]]: """Custom method to determine what to use as the metavar value of an action""" if action.metavar is not None: result = action.metavar @@ -1195,11 +1208,11 @@ def _determine_metavar( def _metavar_formatter( self, action: argparse.Action, - default_metavar: Union[str, Tuple[str, ...]], - ) -> Callable[[int], Tuple[str, ...]]: + default_metavar: Union[str, tuple[str, ...]], + ) -> Callable[[int], tuple[str, ...]]: metavar = self._determine_metavar(action, default_metavar) - def format(tuple_size: int) -> Tuple[str, ...]: + def format(tuple_size: int) -> tuple[str, ...]: if isinstance(metavar, tuple): return metavar else: @@ -1207,7 +1220,7 @@ def format(tuple_size: int) -> Tuple[str, ...]: return format - def _format_args(self, action: argparse.Action, default_metavar: Union[str, Tuple[str, ...]]) -> str: + def _format_args(self, action: argparse.Action, default_metavar: Union[str, tuple[str, ...]]) -> str: """Customized to handle ranged nargs and make other output less verbose""" metavar = self._determine_metavar(action, default_metavar) metavar_formatter = self._metavar_formatter(action, default_metavar) @@ -1235,6 +1248,84 @@ 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'). + """ + + +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: + """ + :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: + """Custom rendering logic.""" + import rich + + formatter = self.formatter_creator() + + styled_title = rich.text.Text( + type(formatter).group_name_formatter(f"{self.title}:"), + style=formatter.styles["argparse.groups"], + ) + + # Left pad the text like an argparse argument group does + left_padding = formatter._indent_increment + + text_table = rich.table.Table( + box=None, + show_header=False, + padding=(0, 0, 0, left_padding), + ) + text_table.add_row(self.text) + return Group(styled_title, text_table) + + class Cmd2ArgumentParser(argparse.ArgumentParser): """Custom ArgumentParser class that improves error and help output""" @@ -1242,10 +1333,10 @@ def __init__( self, prog: Optional[str] = None, usage: Optional[str] = None, - description: Optional[str] = None, - epilog: Optional[str] = None, + description: Optional[RenderableType] = None, + epilog: Optional[RenderableType] = 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, @@ -1265,10 +1356,10 @@ def __init__( super(Cmd2ArgumentParser, self).__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, @@ -1277,6 +1368,10 @@ def __init__( allow_abbrev=allow_abbrev, ) + # Recast to assist type checkers since in a Cmd2HelpFormatter, these can be Rich renderables. + self.description: Optional[RenderableType] = self.description # type: ignore[assignment] + self.epilog: Optional[RenderableType] = 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 @@ -1307,12 +1402,16 @@ def error(self, message: str) -> NoReturn: formatted_message = ansi.style_error(formatted_message) self.exit(2, f'{formatted_message}\n\n') + def _get_formatter(self) -> Cmd2HelpFormatter: + """Copy of _get_formatter() with a different return type to assist type checkers.""" + return cast(Cmd2HelpFormatter, super()._get_formatter()) + def format_help(self) -> str: """Copy of format_help() from argparse.ArgumentParser with tweaks to separately display required parameters""" 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) @@ -1368,6 +1467,10 @@ def _print_message(self, message: str, file: Optional[IO[str]] = None) -> 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: """ @@ -1388,14 +1491,21 @@ 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[argparse.ArgumentParser]) -> None: +def set_default_argument_parser_type(parser_type: Type[Cmd2ArgumentParser]) -> None: """ - Set the default ArgumentParser class for a cmd2 app. 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. + 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. + + This should be called prior to instantiating your CLI object. + + See examples/custom_parser.py. """ global DEFAULT_ARGUMENT_PARSER DEFAULT_ARGUMENT_PARSER = parser_type diff --git a/cmd2/clipboard.py b/cmd2/clipboard.py index 454e3484..376b0015 100644 --- a/cmd2/clipboard.py +++ b/cmd2/clipboard.py @@ -5,7 +5,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 7e34b7dc..123b7705 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -37,7 +37,6 @@ import inspect import os import pydoc -import re import sys import tempfile import threading @@ -60,14 +59,10 @@ TYPE_CHECKING, Any, Callable, - Dict, Iterable, - List, Mapping, Optional, - Set, TextIO, - Tuple, Type, TypeVar, Union, @@ -84,6 +79,7 @@ ) from .argparse_custom import ( ChoicesProviderFunc, + Cmd2ArgumentParser, CompleterFunc, CompletionItem, ) @@ -122,8 +118,6 @@ single_line_format, ) from .parsing import ( - Macro, - MacroArg, Statement, StatementParser, shlex_split, @@ -201,7 +195,7 @@ class _SavedCmd2Env: def __init__(self) -> None: self.readline_settings = _SavedReadlineSettings() self.readline_module: Optional[ModuleType] = None - self.history: List[str] = [] + self.history: list[str] = [] self.sys_stdout: Optional[TextIO] = None self.sys_stdin: Optional[TextIO] = None @@ -230,7 +224,7 @@ def __init__(self, cmd: 'Cmd') -> None: # Keyed by the fully qualified method names. This is more reliable than # the methods themselves, since wrapping a method will change its address. - self._parsers: Dict[str, argparse.ArgumentParser] = {} + self._parsers: dict[str, argparse.ArgumentParser] = {} @staticmethod def _fully_qualified_name(command_method: CommandFunc) -> str: @@ -279,8 +273,8 @@ def get(self, command_method: CommandFunc) -> Optional[argparse.ArgumentParser]: _set_parser_prog(parser, 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 @@ -325,11 +319,11 @@ def __init__( include_py: bool = False, include_ipy: bool = False, allow_cli_args: bool = True, - transcript_files: Optional[List[str]] = None, + transcript_files: Optional[list[str]] = None, allow_redirection: bool = True, - multiline_commands: Optional[List[str]] = None, - terminators: Optional[List[str]] = None, - shortcuts: Optional[Dict[str, str]] = None, + multiline_commands: Optional[list[str]] = None, + terminators: Optional[list[str]] = None, + shortcuts: Optional[dict[str, str]] = None, command_sets: Optional[Iterable[CommandSet]] = None, auto_load_commands: bool = True, allow_clipboard: bool = True, @@ -419,12 +413,12 @@ def __init__( self.max_completion_items = 50 # A dictionary mapping settable names to their Settable instance - self._settables: Dict[str, Settable] = dict() + self._settables: dict[str, Settable] = dict() self._always_prefix_settables: bool = False # CommandSet containers - self._installed_command_sets: Set[CommandSet] = set() - self._cmd_to_command_sets: Dict[str, CommandSet] = {} + self._installed_command_sets: set[CommandSet] = set() + self._cmd_to_command_sets: dict[str, CommandSet] = {} self.build_settables() @@ -445,17 +439,14 @@ def __init__( # Commands to exclude from the history command self.exclude_from_history = ['eof', 'history'] - # Dictionary of macro names and their values - self.macros: Dict[str, Macro] = dict() - # Keeps track of typed command history in the Python shell - self._py_history: List[str] = [] + self._py_history: list[str] = [] # The name by which Python environments refer to the PyBridge to call app commands self.py_bridge_name = 'app' # Defines app-specific variables/functions available in Python shells and pyscripts - self.py_locals: Dict[str, Any] = dict() + self.py_locals: dict[str, Any] = dict() # True if running inside a Python shell or pyscript, False otherwise self._in_py = False @@ -468,7 +459,7 @@ def __init__( self.last_result: Any = None # Used by run_script command to store current script dir as a LIFO queue to support _relative_run_script command - self._script_dir: List[str] = [] + self._script_dir: list[str] = [] # Context manager used to protect critical sections in the main thread from stopping due to a KeyboardInterrupt self.sigint_protection = utils.ContextFlag() @@ -493,13 +484,13 @@ def __init__( self.help_error = "No help on {}" # The error that prints when a non-existent command is run - self.default_error = "{} is not a recognized command, alias, or macro." + self.default_error = "{} is not a recognized command or alias." # If non-empty, this string will be displayed if a broken pipe error occurs self.broken_pipe_warning = '' # Commands that will run at the beginning of the command loop - self._startup_commands: List[str] = [] + self._startup_commands: list[str] = [] # If a startup script is provided and exists, then execute it in the startup commands if startup_script: @@ -511,7 +502,7 @@ def __init__( 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: Optional[list[str]] = None # Check for command line args if allow_cli_args: @@ -554,7 +545,7 @@ def __init__( # Commands that have been disabled from use. This is to support commands that are only available # during specific states of the application. This dictionary's keys are the command names and its # values are DisabledCommand objects. - self.disabled_commands: Dict[str, DisabledCommand] = dict() + self.disabled_commands: dict[str, DisabledCommand] = dict() # If any command has been categorized, then all other commands that haven't been categorized # will display under this section in the help output. @@ -564,7 +555,7 @@ def __init__( # If natural sorting is preferred, then set this to NATURAL_SORT_KEY. # cmd2 uses this key for sorting: # command and category names - # alias, macro, settable, and shortcut names + # alias, settable, and shortcut names # tab completion results when self.matches_sorted is False self.default_sort_key = Cmd.ALPHABETICAL_SORT_KEY @@ -592,7 +583,7 @@ def __init__( self.formatted_completions = '' # Used by complete() for readline tab completion - self.completion_matches: List[str] = [] + self.completion_matches: list[str] = [] # Use this list if you need to display tab completion suggestions that are different than the actual text # of the matches. For instance, if you are completing strings that contain a common delimiter and you only @@ -600,7 +591,7 @@ def __init__( # still must be returned from your completer function. For an example, look at path_complete() which # uses this to show only the basename of paths as the suggestions. delimiter_complete() also populates # this list. These are ignored if self.formatted_completions is populated. - self.display_matches: List[str] = [] + self.display_matches: list[str] = [] # Used by functions like path_complete() and delimiter_complete() to properly # quote matches that are completed in a delimited fashion @@ -642,7 +633,7 @@ def __init__( # the current command being executed self.current_command: Optional[Statement] = None - def find_commandsets(self, commandset_type: Type[CommandSet], *, subclass_match: bool = False) -> List[CommandSet]: + def find_commandsets(self, commandset_type: Type[CommandSet], *, subclass_match: bool = False) -> list[CommandSet]: """ Find all CommandSets that match the provided CommandSet type. By default, locates a CommandSet that is an exact type match but may optionally return all CommandSets that @@ -671,7 +662,7 @@ def _autoload_commands(self) -> None: all_commandset_defs = CommandSet.__subclasses__() existing_commandset_types = [type(command_set) for command_set in self._installed_command_sets] - def load_commandset_by_type(commandset_types: List[Type[CommandSet]]) -> None: + def load_commandset_by_type(commandset_types: list[Type[CommandSet]]) -> None: for cmdset_type in commandset_types: # check if the type has sub-classes. We will only auto-load leaf class types. subclasses = cmdset_type.__subclasses__() @@ -715,7 +706,7 @@ def register_command_set(self, cmdset: CommandSet) -> None: cmdset.on_register(self) methods = cast( - List[Tuple[str, Callable[..., Any]]], + list[tuple[str, Callable[..., Any]]], inspect.getmembers( cmdset, predicate=lambda meth: isinstance(meth, Callable) # type: ignore[arg-type] @@ -825,11 +816,6 @@ def _install_command_function(self, command_func_name: str, command_method: Comm self.pwarning(f"Deleting alias '{command}' because it shares its name with a new command") del self.aliases[command] - # Check if command shares a name with a macro - if command in self.macros: - self.pwarning(f"Deleting macro '{command}' because it shares its name with a new command") - del self.macros[command] - setattr(self, command_func_name, command_method) def _install_completer_function(self, cmd_name: str, cmd_completer: CompleterFunc) -> None: @@ -857,7 +843,7 @@ def unregister_command_set(self, cmdset: CommandSet) -> None: cmdset.on_unregister() self._unregister_subcommands(cmdset) - methods: List[Tuple[str, Callable[..., Any]]] = inspect.getmembers( + methods: list[tuple[str, Callable[..., Any]]] = inspect.getmembers( cmdset, predicate=lambda meth: isinstance(meth, Callable) # type: ignore[arg-type] and hasattr(meth, '__name__') @@ -903,7 +889,7 @@ def check_parser_uninstallable(parser: argparse.ArgumentParser) -> None: check_parser_uninstallable(subparser) break - methods: List[Tuple[str, Callable[..., Any]]] = inspect.getmembers( + methods: list[tuple[str, Callable[..., Any]]] = inspect.getmembers( cmdset, predicate=lambda meth: isinstance(meth, Callable) # type: ignore[arg-type] and hasattr(meth, '__name__') @@ -966,7 +952,7 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: f"Could not find argparser for command '{command_name}' needed by subcommand: {str(method)}" ) - def find_subcommand(action: argparse.ArgumentParser, subcmd_names: List[str]) -> argparse.ArgumentParser: + def find_subcommand(action: argparse.ArgumentParser, subcmd_names: list[str]) -> argparse.ArgumentParser: if not subcmd_names: return action cur_subcmd = subcmd_names.pop(0) @@ -1072,7 +1058,7 @@ def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: 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 @@ -1144,7 +1130,7 @@ def remove_settable(self, name: str) -> None: def build_settables(self) -> None: """Create the dictionary of user-settable parameters""" - def get_allow_style_choices(cli_self: Cmd) -> List[str]: + def get_allow_style_choices(cli_self: Cmd) -> list[str]: """Used to tab complete allow_style values""" return [val.name.lower() for val in ansi.AllowStyle] @@ -1387,7 +1373,7 @@ def _reset_completion_defaults(self) -> None: elif rl_type == RlType.PYREADLINE: readline.rl.mode._display_completions = self._display_matches_pyreadline - def tokens_for_completion(self, line: str, begidx: int, endidx: int) -> Tuple[List[str], List[str]]: + def tokens_for_completion(self, line: str, begidx: int, endidx: int) -> tuple[list[str], list[str]]: """Used by tab completion functions to get all tokens through the one being completed. :param line: the current input line with leading whitespace removed @@ -1458,7 +1444,7 @@ def basic_complete( begidx: int, endidx: int, match_against: Iterable[str], - ) -> List[str]: + ) -> list[str]: """ Basic tab completion function that matches against a list of strings without considering line contents or cursor position. The args required by this function are defined in the header of Python's cmd.py. @@ -1480,7 +1466,7 @@ def delimiter_complete( endidx: int, match_against: Iterable[str], delimiter: str, - ) -> List[str]: + ) -> list[str]: """ Performs tab completion against a list but each match is split on a delimiter and only the portion of the match being tab completed is shown as the completion suggestions. @@ -1546,10 +1532,10 @@ def flag_based_complete( line: str, begidx: int, endidx: int, - flag_dict: Dict[str, Union[Iterable[str], CompleterFunc]], + flag_dict: dict[str, Union[Iterable[str], CompleterFunc]], *, all_else: Union[None, Iterable[str], CompleterFunc] = None, - ) -> List[str]: + ) -> list[str]: """Tab completes based on a particular flag preceding the token being completed. :param text: the string prefix we are attempting to match (all matches must begin with it) @@ -1598,7 +1584,7 @@ def index_based_complete( index_dict: Mapping[int, Union[Iterable[str], CompleterFunc]], *, all_else: Optional[Union[Iterable[str], CompleterFunc]] = None, - ) -> List[str]: + ) -> list[str]: """Tab completes based on a fixed position in the input string. :param text: the string prefix we are attempting to match (all matches must begin with it) @@ -1643,7 +1629,7 @@ def index_based_complete( def path_complete( self, text: str, line: str, begidx: int, endidx: int, *, path_filter: Optional[Callable[[str], bool]] = None - ) -> List[str]: + ) -> list[str]: """Performs completion of local file system paths :param text: the string prefix we are attempting to match (all matches must begin with it) @@ -1657,7 +1643,7 @@ def path_complete( """ # Used to complete ~ and ~user strings - def complete_users() -> List[str]: + def complete_users() -> list[str]: users = [] # Windows lacks the pwd module so we can't get a list of users. @@ -1784,7 +1770,7 @@ def complete_users() -> List[str]: return matches - def shell_cmd_complete(self, text: str, line: str, begidx: int, endidx: int, *, complete_blank: bool = False) -> List[str]: + def shell_cmd_complete(self, text: str, line: str, begidx: int, endidx: int, *, complete_blank: bool = False) -> list[str]: """Performs completion of executables either in a user's path or a given path :param text: the string prefix we are attempting to match (all matches must begin with it) @@ -1809,7 +1795,7 @@ def shell_cmd_complete(self, text: str, line: str, begidx: int, endidx: int, *, text, line, begidx, endidx, path_filter=lambda path: os.path.isdir(path) or os.access(path, os.X_OK) ) - def _redirect_complete(self, text: str, line: str, begidx: int, endidx: int, compfunc: CompleterFunc) -> List[str]: + def _redirect_complete(self, text: str, line: str, begidx: int, endidx: int, compfunc: CompleterFunc) -> list[str]: """Called by complete() as the first tab completion function for all commands It determines if it should tab complete for redirection (|, >, >>) or use the completer function for the current command @@ -1890,7 +1876,7 @@ def _redirect_complete(self, text: str, line: str, begidx: int, endidx: int, com return compfunc(text, line, begidx, endidx) @staticmethod - def _pad_matches_to_display(matches_to_display: List[str]) -> Tuple[List[str], int]: # pragma: no cover + def _pad_matches_to_display(matches_to_display: list[str]) -> tuple[list[str], int]: # pragma: no cover """Adds padding to the matches being displayed as tab completion suggestions. The default padding of readline/pyreadine is small and not visually appealing especially if matches have spaces. It appears very squished together. @@ -1912,7 +1898,7 @@ def _pad_matches_to_display(matches_to_display: List[str]) -> Tuple[List[str], i return [cur_match + padding for cur_match in matches_to_display], len(padding) def _display_matches_gnu_readline( - self, substitution: str, matches: List[str], longest_match_length: int + self, substitution: str, matches: list[str], longest_match_length: int ) -> None: # pragma: no cover """Prints a match list using GNU readline's rl_display_match_list() @@ -1960,7 +1946,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[Optional[bytes]], (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,7 +1959,7 @@ def _display_matches_gnu_readline( # Redraw prompt and input line rl_force_redisplay() - def _display_matches_pyreadline(self, matches: List[str]) -> None: # pragma: no cover + def _display_matches_pyreadline(self, matches: list[str]) -> None: # pragma: no cover """Prints a match list using pyreadline3's _display_completions() :param matches: the tab completion matches to display @@ -2074,12 +2060,8 @@ def _perform_completion( # Determine the completer function to use for the command's argument if custom_settings is None: - # Check if a macro was entered - if command in self.macros: - completer_func = self.path_complete - # Check if a command was entered - elif command in self.get_all_commands(): + if command in self.get_all_commands(): # Get the completer function for this command func_attr = getattr(self, constants.COMPLETER_FUNC_PREFIX + command, None) @@ -2103,15 +2085,15 @@ def _perform_completion( completer.complete, tokens=raw_tokens[1:] if preserve_quotes else tokens[1:], cmd_set=cmd_set ) else: - completer_func = self.completedefault # type: ignore[assignment] + completer_func = self.completedefault - # Not a recognized macro or command + # Not a recognized command else: # Check if this command should be run as a shell command if self.default_to_shell and command in utils.get_exes_in_path(command): completer_func = self.path_complete else: - completer_func = self.completedefault # type: ignore[assignment] + completer_func = self.completedefault # Otherwise we are completing the command token or performing custom completion else: @@ -2268,8 +2250,8 @@ def complete( # type: ignore[override] parser.add_argument( 'command', metavar="COMMAND", - help="command, alias, or macro name", - choices=self._get_commands_aliases_and_macros_for_completion(), + help="command or alias name", + choices=self._get_commands_and_aliases_for_completion(), ) custom_settings = utils.CustomCompletionSettings(parser) @@ -2320,15 +2302,15 @@ def in_pyscript(self) -> bool: return self._in_py @property - def aliases(self) -> Dict[str, str]: + def aliases(self) -> dict[str, str]: """Read-only property to access the aliases stored in the StatementParser""" return self.statement_parser.aliases - def get_names(self) -> List[str]: + def get_names(self) -> list[str]: """Return an alphabetized list of names comprising the attributes of the cmd2 class instance.""" return dir(self) - def get_all_commands(self) -> List[str]: + def get_all_commands(self) -> list[str]: """Return a list of all commands""" return [ name[len(constants.COMMAND_FUNC_PREFIX) :] @@ -2336,7 +2318,7 @@ def get_all_commands(self) -> List[str]: if name.startswith(constants.COMMAND_FUNC_PREFIX) and callable(getattr(self, name)) ] - def get_visible_commands(self) -> List[str]: + def get_visible_commands(self) -> list[str]: """Return a list of commands that have not been hidden or disabled""" return [ command @@ -2347,9 +2329,9 @@ def get_visible_commands(self) -> List[str]: # 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]: + def _get_alias_completion_items(self) -> list[CompletionItem]: """Return list of alias names and values as CompletionItems""" - results: List[CompletionItem] = [] + results: list[CompletionItem] = [] for cur_key in self.aliases: row_data = [self.aliases[cur_key]] @@ -2357,25 +2339,12 @@ def _get_alias_completion_items(self) -> List[CompletionItem]: 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))) - - 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]: + def _get_settable_completion_items(self) -> list[CompletionItem]: """Return list of Settable names, values, and descriptions as CompletionItems""" - results: List[CompletionItem] = [] + results: list[CompletionItem] = [] for cur_key in self.settables: row_data = [self.settables[cur_key].get_value(), self.settables[cur_key].description] @@ -2383,14 +2352,13 @@ def _get_settable_completion_items(self) -> List[CompletionItem]: return results - def _get_commands_aliases_and_macros_for_completion(self) -> List[str]: - """Return a list of visible commands, aliases, and macros for tab completion""" + def _get_commands_and_aliases_for_completion(self) -> list[str]: + """Return a list of visible commands and aliases for tab completion""" visible_commands = set(self.get_visible_commands()) alias_names = set(self.aliases) - macro_names = set(self.macros) - return list(visible_commands | alias_names | macro_names) + return list(visible_commands | alias_names) - def get_help_topics(self) -> List[str]: + def get_help_topics(self) -> list[str]: """Return a list of help topics""" all_topics = [ name[len(constants.HELP_FUNC_PREFIX) :] @@ -2490,7 +2458,7 @@ def postloop(self) -> None: """ pass - def parseline(self, line: str) -> Tuple[str, str, str]: + def parseline(self, line: str) -> tuple[str, str, str]: """Parse the line into a command name and a string containing the arguments. NOTE: This is an override of a parent class method. It is only used by other parent class methods. @@ -2534,7 +2502,7 @@ def onecmd_plus_hooks( try: # Convert the line into a Statement - statement = self._input_line_to_statement(line, orig_rl_history_length=orig_rl_history_length) + statement = self._complete_statement(line, orig_rl_history_length=orig_rl_history_length) # call the postparsing hooks postparsing_data = plugin.PostparsingData(False, statement) @@ -2653,7 +2621,7 @@ def _run_cmdfinalization_hooks(self, stop: bool, statement: Optional[Statement]) def runcmds_plus_hooks( self, - cmds: Union[List[HistoryItem], List[str]], + cmds: Union[list[HistoryItem], list[str]], *, add_to_history: bool = True, stop_on_keyboard_interrupt: bool = False, @@ -2779,101 +2747,6 @@ 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: - """ - 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 - :param orig_rl_history_length: Optional length of the readline history before the current command was typed. - This is used to assist in combining multiline readline history entries and is only - populated by cmd2. Defaults to None. - :return: parsed command line as a Statement - :raises Cmd2ShlexError: if a shlex error occurs (e.g. No closing quotation) - :raises EmptyStatement: when the resulting Statement is blank - """ - used_macros = [] - orig_line = None - - # Continue until all macros are resolved - while True: - # Make sure all input has been read and convert it to a Statement - statement = self._complete_statement(line, orig_rl_history_length=orig_rl_history_length) - - # If this is the first loop iteration, save the original line and stop - # combining multiline history entries in the remaining iterations. - if orig_line is None: - orig_line = statement.raw - orig_rl_history_length = None - - # Check if this command matches a macro and wasn't already processed to avoid an infinite loop - if statement.command in self.macros.keys() and statement.command not in used_macros: - used_macros.append(statement.command) - resolve_result = self._resolve_macro(statement) - if resolve_result is None: - raise EmptyStatement - line = resolve_result - else: - break - - # This will be true when a macro was used - if orig_line != statement.raw: - # Build a Statement that contains the resolved macro line - # but the originally typed line for its raw member. - statement = Statement( - statement.args, - raw=orig_line, - command=statement.command, - arg_list=statement.arg_list, - multiline_command=statement.multiline_command, - terminator=statement.terminator, - suffix=statement.suffix, - pipe_to=statement.pipe_to, - output=statement.output, - output_to=statement.output_to, - ) - return statement - - def _resolve_macro(self, statement: Statement) -> Optional[str]: - """ - Resolve a macro and return the resulting string - - :param statement: the parsed statement from the command line - :return: the resolved macro or None on error - """ - if statement.command not in self.macros.keys(): - raise KeyError(f"{statement.command} is not a macro") - - macro = self.macros[statement.command] - - # Make sure enough arguments were passed in - if len(statement.arg_list) < macro.minimum_arg_count: - plural = '' if macro.minimum_arg_count == 1 else 's' - self.perror(f"The macro '{statement.command}' expects at least {macro.minimum_arg_count} argument{plural}") - return None - - # Resolve the arguments in reverse and read their values from statement.argv since those - # are unquoted. Macro args should have been quoted when the macro was created. - resolved = macro.value - reverse_arg_list = sorted(macro.arg_list, key=lambda ma: ma.start_index, reverse=True) - - for macro_arg in reverse_arg_list: - if macro_arg.is_escaped: - to_replace = '{{' + macro_arg.number_str + '}}' - replacement = '{' + macro_arg.number_str + '}' - else: - to_replace = '{' + macro_arg.number_str + '}' - replacement = statement.argv[int(macro_arg.number_str)] - - parts = resolved.rsplit(to_replace, maxsplit=1) - resolved = parts[0] + replacement + parts[1] - - # Append extra arguments and use statement.arg_list since these arguments need their quotes preserved - for stmt_arg in statement.arg_list[macro.minimum_arg_count :]: - resolved += ' ' + stmt_arg - - # Restore any terminator, suffix, redirection, etc. - return resolved + statement.post_command - def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: """Set up a command's output redirection for >, >>, and |. @@ -2907,7 +2780,7 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: # Create pipe process in a separate group to isolate our signals from it. If a Ctrl-C event occurs, # our sigint handler will forward it only to the most recent pipe process. This makes sure pipe # processes close in the right order (most recent first). - kwargs: Dict[str, Any] = dict() + kwargs: dict[str, Any] = dict() if sys.platform == 'win32': kwargs['creationflags'] = subprocess.CREATE_NEW_PROCESS_GROUP else: @@ -2919,11 +2792,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] + proc = subprocess.Popen( 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, ) @@ -2943,7 +2816,7 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: new_stdout.close() raise RedirectionError(f'Pipe process exited with code {proc.returncode} before command could run') else: - 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 @@ -3049,7 +2922,7 @@ def onecmd(self, statement: Union[Statement, str], *, add_to_history: bool = Tru """ # For backwards compatibility with cmd, allow a str to be passed in if not isinstance(statement, Statement): - statement = self._input_line_to_statement(statement) + statement = self._complete_statement(statement) func = self.cmd_func(statement.command) if func: @@ -3098,7 +2971,7 @@ def read_input( self, prompt: str, *, - history: Optional[List[str]] = None, + history: Optional[list[str]] = None, completion_mode: utils.CompletionMode = utils.CompletionMode.NONE, preserve_quotes: bool = False, choices: Optional[Iterable[Any]] = None, @@ -3139,7 +3012,7 @@ def read_input( """ readline_configured = False saved_completer: Optional[CompleterFunc] = None - saved_history: Optional[List[str]] = None + saved_history: Optional[list[str]] = None def configure_readline() -> None: """Configure readline tab completion and history""" @@ -3174,7 +3047,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, ) @@ -3380,50 +3253,67 @@ 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 = "Manage aliases\n\nAn 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.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""" + """Manage aliases.""" # Call handler for whatever subcommand was selected handler = args.cmd2_handler.get() 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" - ) + @staticmethod + def _build_alias_create_parser() -> Cmd2ArgumentParser: + from rich.console import Group - 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 - ) + alias_create_description = "Create or overwrite an alias." + + alias_create_notes = ( + "If you want to use redirection, pipes, or terminators in the value of the alias, then quote them.\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_examples = ( + "alias create ls !ls -lF\n" + "alias create show_log !cat \"log file.txt\"\n" + "alias create save_results print_results \">\" out.txt" + ) + + alias_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_create_description) - @as_subcommand_to('alias', 'create', alias_create_parser, help=alias_create_description.lower()) + # Create epilog + notes_group = alias_create_parser.create_text_group("Notes", alias_create_notes) + examples_group = alias_create_parser.create_text_group("Examples", alias_create_examples) + alias_create_parser.epilog = Group(notes_group, "\n", examples_group) + + 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=Cmd._get_commands_and_aliases_for_completion, + ) + alias_create_parser.add_argument( + 'command_args', + nargs=argparse.REMAINDER, + help='arguments to pass to command', + completer=Cmd.path_complete, + ) + + return alias_create_parser + + @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""" + """Create or overwrite an alias.""" self.last_result = False # Validate the alias name @@ -3436,10 +3326,6 @@ def _alias_create(self, args: argparse.Namespace) -> None: self.perror("Alias cannot have the same name as a command") return - if args.name in self.macros: - self.perror("Alias cannot have the same name as a macro") - return - # Unquote redirection and terminator tokens tokens_to_unquote = constants.REDIRECTION_TOKENS tokens_to_unquote.extend(self.statement_parser.terminators) @@ -3458,22 +3344,25 @@ 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" - - 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(), - ) + @staticmethod + def _build_alias_delete_parser() -> 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=Cmd._get_alias_completion_items, + descriptive_header=Cmd._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""" + """Delete aliases.""" self.last_result = True if args.all: @@ -3491,27 +3380,25 @@ 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." - ) + @staticmethod + def _build_alias_list_parser() -> Cmd2ArgumentParser: + alias_list_description = "List specified aliases or all aliases if no arguments are given." + + 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=Cmd._get_alias_completion_items, + descriptive_header=Cmd._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=_get_alias_completion_items, - descriptive_header=_alias_completion_table.generate_header(), - ) + 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] + """List some or all aliases as 'alias create' commands.""" + self.last_result = {} # dict[alias_name, alias_value] tokens_to_quote = constants.REDIRECTION_TOKENS tokens_to_quote.extend(self.statement_parser.terminators) @@ -3521,7 +3408,7 @@ def _alias_list(self, args: argparse.Namespace) -> None: else: to_list = sorted(self.aliases, key=self.default_sort_key) - not_found: List[str] = [] + not_found: list[str] = [] for name in to_list: if name not in self.aliases: not_found.append(name) @@ -3543,243 +3430,7 @@ def _alias_list(self, args: argparse.Namespace) -> None: for name in not_found: self.perror(f"Alias '{name}' not found") - ############################################################# - # Parsers and functions for macro command and subcommands - ############################################################# - - # 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) - - # Preserve quotes since we are passing strings to other commands - @with_argparser(macro_parser, preserve_quotes=True) - def do_macro(self, args: argparse.Namespace) -> None: - """Manage macros""" - # Call handler for whatever subcommand was selected - handler = args.cmd2_handler.get() - 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." - ) - - 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 - ) - - @as_subcommand_to('macro', 'create', macro_create_parser, help=macro_create_help) - def _macro_create(self, args: argparse.Namespace) -> None: - """Create or overwrite a macro""" - self.last_result = False - - # Validate the macro name - valid, errmsg = self.statement_parser.is_valid_command(args.name) - if not valid: - self.perror(f"Invalid macro name: {errmsg}") - return - - if args.name in self.get_all_commands(): - self.perror("Macro cannot have the same name as a command") - return - - if args.name in self.aliases: - self.perror("Macro cannot have the same name as an alias") - return - - # Unquote redirection and terminator tokens - tokens_to_unquote = constants.REDIRECTION_TOKENS - tokens_to_unquote.extend(self.statement_parser.terminators) - utils.unquote_specific_tokens(args.command_args, tokens_to_unquote) - - # Build the macro value string - value = args.command - if args.command_args: - value += ' ' + ' '.join(args.command_args) - - # Find all normal arguments - arg_list = [] - normal_matches = re.finditer(MacroArg.macro_normal_arg_pattern, value) - max_arg_num = 0 - arg_nums = set() - - while True: - try: - cur_match = normal_matches.__next__() - - # Get the number string between the braces - cur_num_str = re.findall(MacroArg.digit_pattern, cur_match.group())[0] - cur_num = int(cur_num_str) - if cur_num < 1: - self.perror("Argument numbers must be greater than 0") - return - - arg_nums.add(cur_num) - if cur_num > max_arg_num: - max_arg_num = cur_num - - arg_list.append(MacroArg(start_index=cur_match.start(), number_str=cur_num_str, is_escaped=False)) - - except StopIteration: - break - - # Make sure the argument numbers are continuous - if len(arg_nums) != max_arg_num: - self.perror(f"Not all numbers between 1 and {max_arg_num} are present in the argument placeholders") - return - - # Find all escaped arguments - escaped_matches = re.finditer(MacroArg.macro_escaped_arg_pattern, value) - - while True: - try: - cur_match = escaped_matches.__next__() - - # Get the number string between the braces - cur_num_str = re.findall(MacroArg.digit_pattern, cur_match.group())[0] - - arg_list.append(MacroArg(start_index=cur_match.start(), number_str=cur_num_str, is_escaped=True)) - except StopIteration: - break - - # Set the macro - result = "overwritten" if args.name in self.macros else "created" - self.poutput(f"Macro '{args.name}' {result}") - - self.macros[args.name] = Macro(name=args.name, value=value, minimum_arg_count=max_arg_num, arg_list=arg_list) - 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(), - ) - - @as_subcommand_to('macro', 'delete', macro_delete_parser, help=macro_delete_help) - def _macro_delete(self, args: argparse.Namespace) -> None: - """Delete macros""" - self.last_result = True - - if args.all: - self.macros.clear() - self.poutput("All macros deleted") - elif not args.names: - self.perror("Either --all or macro name(s) must be specified") - self.last_result = False - else: - for cur_name in utils.remove_duplicates(args.names): - if cur_name in self.macros: - del self.macros[cur_name] - self.poutput(f"Macro '{cur_name}' deleted") - else: - self.perror(f"Macro '{cur_name}' does not exist") - - # 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_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_list_description) - macro_list_parser.add_argument( - 'names', - nargs=argparse.ZERO_OR_MORE, - help='macro(s) to list', - choices_provider=_get_macro_completion_items, - descriptive_header=_macro_completion_table.generate_header(), - ) - - @as_subcommand_to('macro', 'list', macro_list_parser, help=macro_list_help) - def _macro_list(self, args: argparse.Namespace) -> None: - """List some or all macros as 'macro create' commands""" - self.last_result = {} # Dict[macro_name, macro_value] - - tokens_to_quote = constants.REDIRECTION_TOKENS - tokens_to_quote.extend(self.statement_parser.terminators) - - if args.names: - to_list = utils.remove_duplicates(args.names) - else: - to_list = sorted(self.macros, key=self.default_sort_key) - - not_found: List[str] = [] - for name in to_list: - if name not in self.macros: - not_found.append(name) - continue - - # Quote redirection and terminator tokens for the 'macro create' command - tokens = shlex_split(self.macros[name].value) - command = tokens[0] - command_args = tokens[1:] - utils.quote_specific_tokens(command_args, tokens_to_quote) - - val = command - if command_args: - val += ' ' + ' '.join(command_args) - - self.poutput(f"macro create {name} {val}") - self.last_result[name] = val - - for name in not_found: - self.perror(f"Macro '{name}' not found") - - def complete_help_command(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + def complete_help_command(self, text: str, line: str, begidx: int, endidx: int) -> list[str]: """Completes the command argument of help""" # Complete token against topics and visible commands @@ -3789,8 +3440,8 @@ def complete_help_command(self, text: str, line: str, begidx: int, endidx: int) return self.basic_complete(text, line, begidx, endidx, strs_to_match) def complete_help_subcommands( - self, text: str, line: str, begidx: int, endidx: int, arg_tokens: Dict[str, List[str]] - ) -> List[str]: + self, text: str, line: str, begidx: int, endidx: int, arg_tokens: dict[str, list[str]] + ) -> list[str]: """Completes the subcommands argument of help""" # Make sure we have a command whose subcommands we will complete @@ -3805,26 +3456,38 @@ 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 - ) + @staticmethod + def _build_help_parser() -> 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=Cmd.complete_help_command, + ) + help_parser.add_argument( + 'subcommands', + nargs=argparse.REMAINDER, + help="subcommand(s) to retrieve help for", + completer=Cmd.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""" + """List available commands or provide detailed help for a specific command.""" self.last_result = True if not args.command or args.verbose: @@ -3859,7 +3522,7 @@ def do_help(self, args: argparse.Namespace) -> None: self.perror(err_msg, apply_style=False) self.last_result = False - def print_topics(self, header: str, cmds: Optional[List[str]], cmdlen: int, maxcol: int) -> None: + def print_topics(self, header: str, cmds: Optional[list[str]], cmdlen: int, maxcol: int) -> None: """ 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 @@ -3877,7 +3540,7 @@ def print_topics(self, header: str, cmds: Optional[List[str]], cmdlen: int, maxc self.columnize(cmds, maxcol - 1) self.poutput() - def columnize(self, str_list: Optional[List[str]], display_width: int = 80) -> None: + 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. Override of cmd's columnize() to handle strings with ANSI style sequences and wide characters @@ -3953,15 +3616,15 @@ def _help_menu(self, verbose: bool = False) -> None: self.print_topics(self.misc_header, help_topics, 15, 80) self.print_topics(self.undoc_header, cmds_undoc, 15, 80) - def _build_command_info(self) -> Tuple[Dict[str, List[str]], List[str], List[str], List[str]]: + 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]] = {} + 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 @@ -3984,7 +3647,7 @@ def _build_command_info(self) -> Tuple[Dict[str, List[str]], List[str], List[str 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: + def _print_topics(self, header: str, cmds: list[str], verbose: bool) -> None: """Customized version of print_topics that can switch between verbose or traditional output""" import io @@ -4018,12 +3681,8 @@ def _print_topics(self, header: str, cmds: List[str], verbose: bool) -> None: 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: + if command in topics: help_func = getattr(self, constants.HELP_FUNC_PREFIX + command) result = io.StringIO() @@ -4052,22 +3711,30 @@ def _print_topics(self, header: str, cmds: List[str], verbose: bool) -> None: self.poutput(table_str_buf.getvalue()) - 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""" + """List available shortcuts.""" # Sort the shortcut tuples by name sorted_shortcuts = sorted(self.statement_parser.shortcuts, key=lambda x: self.default_sort_key(x[0])) result = "\n".join('{}: {}'.format(sc[0], sc[1]) for sc in sorted_shortcuts) 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) + return eof_parser + + @with_argparser(_build_eof_parser) def do_eof(self, _: argparse.Namespace) -> Optional[bool]: """ Called when Ctrl-D is pressed and calls quit with no arguments. @@ -4078,16 +3745,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) + @with_argparser(_build_quit_parser) def do_quit(self, _: argparse.Namespace) -> Optional[bool]: - """Exit this application""" + """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: Union[str, list[str], list[tuple[Any, Optional[str]]]], prompt: str = 'Your choice? ') -> Any: """Presents a numbered menu to the user. Modeled after the bash shell's SELECT. Returns the item chosen. @@ -4098,12 +3767,12 @@ def select(self, opts: Union[str, List[str], List[Tuple[Any, Optional[str]]]], p | a list of tuples -> interpreted as (value, text), so 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: Union[list[str], list[tuple[Any, Optional[str]]]] if isinstance(opts, str): - local_opts = cast(List[Tuple[Any, Optional[str]]], list(zip(opts.split(), opts.split()))) + local_opts = cast(list[tuple[Any, Optional[str]]], list(zip(opts.split(), opts.split()))) else: local_opts = opts - fulloptions: List[Tuple[Any, Optional[str]]] = [] + fulloptions: list[tuple[Any, Optional[str]]] = [] for opt in local_opts: if isinstance(opt, str): fulloptions.append((opt, opt)) @@ -4137,8 +3806,8 @@ def select(self, opts: Union[str, List[str], List[Tuple[Any, Optional[str]]]], p self.poutput(f"'{response}' isn't a valid choice. Pick a number between 1 and {len(fulloptions)}:") def complete_set_value( - self, text: str, line: str, begidx: int, endidx: int, arg_tokens: Dict[str, List[str]] - ) -> List[str]: + self, text: str, line: str, begidx: int, endidx: int, arg_tokens: dict[str, list[str]] + ) -> list[str]: """Completes the value argument of set""" param = arg_tokens['param'][0] try: @@ -4147,7 +3816,7 @@ def complete_set_value( raise CompletionError(param + " is not a settable parameter") # 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 = Cmd._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. @@ -4156,7 +3825,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, ) @@ -4167,30 +3836,45 @@ 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(), - ) + @staticmethod + def _build_base_set_parser() -> 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 = "Set a settable parameter or show current settings of parameters." + 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=Cmd._get_settable_completion_items, + descriptive_header=Cmd._settable_completion_table.generate_header(), + ) - # 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 - ) + base_set_parser_notes = ( + "Call without arguments for a list of all settable parameters with their values.\n" + "\n" + "Call with just param to view that parameter's value." + ) + base_set_parser.epilog = base_set_parser.create_text_group("Notes", base_set_parser_notes) + + return base_set_parser + + @staticmethod + def _build_set_parser() -> Cmd2ArgumentParser: + # Create the parser for the set command + set_parser = Cmd._build_base_set_parser() + set_parser.add_argument( + 'value', + nargs=argparse.OPTIONAL, + help='new value for settable', + completer=Cmd.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 @@ -4229,7 +3913,7 @@ def do_set(self, args: argparse.Namespace) -> None: 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] = [ + cols: list[Column] = [ Column(name_label, width=max_name_width), Column('Value', width=30), Column('Description', width=60), @@ -4239,7 +3923,7 @@ def do_set(self, args: argparse.Namespace) -> None: self.poutput(table.generate_header()) # Build the table and populate self.last_result - self.last_result = {} # Dict[settable_name, settable_value] + self.last_result = {} # dict[settable_name, settable_value] for param in sorted(to_show, key=self.default_sort_key): settable = self.settables[param] @@ -4247,20 +3931,24 @@ def do_set(self, args: argparse.Namespace) -> None: 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 - ) + @staticmethod + def _build_shell_parser() -> 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=Cmd.shell_cmd_complete) + shell_parser.add_argument( + 'command_args', nargs=argparse.REMAINDER, help='arguments to pass to command', completer=Cmd.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""" + """Execute a command as if at the OS prompt.""" import signal import subprocess - kwargs: Dict[str, Any] = dict() + kwargs: dict[str, Any] = dict() # Set OS-specific parameters if sys.platform.startswith('win'): @@ -4292,15 +3980,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] + proc = subprocess.Popen( 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 @@ -4392,10 +4080,10 @@ def _set_up_py_shell_env(self, interp: InteractiveConsole) -> _SavedCmd2Env: self._reset_py_display() cmd2_env.sys_stdout = sys.stdout - sys.stdout = self.stdout # type: ignore[assignment] + sys.stdout = self.stdout cmd2_env.sys_stdin = sys.stdin - sys.stdin = self.stdin # type: ignore[assignment] + sys.stdin = self.stdin return cmd2_env @@ -4405,8 +4093,8 @@ 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] + sys.stdout = cmd2_env.sys_stdout + sys.stdin = cmd2_env.sys_stdin # Set up readline for cmd2 if rl_type != RlType.NONE: @@ -4552,27 +4240,36 @@ 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) + @with_argparser(_build_py_parser) def do_py(self, _: argparse.Namespace) -> Optional[bool]: """ - Run an interactive Python shell + Run an interactive Python shell. + :return: True if running of commands should stop """ # self.last_resort 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 - ) + @staticmethod + def _build_run_pyscript_parser() -> 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=Cmd.path_complete) + run_pyscript_parser.add_argument( + 'script_arguments', nargs=argparse.REMAINDER, help='arguments to pass to script', completer=Cmd.path_complete + ) + + return run_pyscript_parser - @with_argparser(run_pyscript_parser) + @with_argparser(_build_run_pyscript_parser) def do_run_pyscript(self, args: argparse.Namespace) -> Optional[bool]: """ - Run a Python script file inside the console + Run Python script within this application's environment. :return: True if running of commands should stop """ @@ -4604,12 +4301,14 @@ 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) + @with_argparser(_build_ipython_parser) def do_ipy(self, _: argparse.Namespace) -> Optional[bool]: # pragma: no cover """ - Enter an interactive IPython shell + Run an interactive IPython shell. :return: True if running of commands should stop """ @@ -4679,57 +4378,75 @@ 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" + @staticmethod + def _build_history_parser() -> Cmd2ArgumentParser: + from .argparse_custom import RawTextCmd2HelpFormatter - 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='output commands and results to a transcript file,\nimplies -s', - completer=path_complete, - ) - history_action_group.add_argument('-c', '--clear', action='store_true', help='clear all history') + history_description = "View, run, edit, save, or clear previously entered commands." - 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_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( + description=history_description, formatter_class=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 --script', + completer=Cmd.path_complete, + ) + history_action_group.add_argument( + '-t', + '--transcript', + metavar='TRANSCRIPT_FILE', + help='output commands and results to a transcript file,\nimplies --script', + completer=Cmd.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\nnumbers', + ) + history_format_group.add_argument( + '-x', + '--expanded', + action='store_true', + help='output fully parsed commands with aliases and shortcuts\nexpanded', + ) + 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) - @with_argparser(history_parser) + return history_parser + + @with_argparser(_build_history_parser) def do_history(self, args: argparse.Namespace) -> Optional[bool]: """ - View, run, edit, save, or clear previously entered commands + View, run, edit, save, or clear previously entered commands. :return: True if running of commands should stop """ @@ -4739,13 +4456,11 @@ def do_history(self, args: argparse.Namespace) -> Optional[bool]: if args.verbose: 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: @@ -4820,7 +4535,7 @@ def do_history(self, args: argparse.Namespace) -> Optional[bool]: self.last_result = history return None - def _get_history(self, args: argparse.Namespace) -> 'OrderedDict[int, HistoryItem]': + def _get_history(self, args: argparse.Namespace) -> OrderedDict[int, HistoryItem]: """If an argument was supplied, then retrieve partial contents of the history; otherwise retrieve entire history. This function returns a dictionary with history items keyed by their 1-based index in ascending order. @@ -4898,11 +4613,11 @@ def _initialize_history(self, hist_file: str) -> None: try: import lzma as decompress_lib - decompress_exceptions: Tuple[type[Exception]] = (decompress_lib.LZMAError,) + decompress_exceptions: tuple[type[Exception]] = (decompress_lib.LZMAError,) except ModuleNotFoundError: # pragma: no cover import bz2 as decompress_lib # type: ignore[no-redef] - decompress_exceptions: Tuple[type[Exception]] = (OSError, ValueError) # type: ignore[no-redef] + decompress_exceptions: tuple[type[Exception]] = (OSError, ValueError) # type: ignore[no-redef] try: history_json = decompress_lib.decompress(compressed_bytes).decode(encoding='utf-8') @@ -4959,7 +4674,7 @@ def _persist_history(self) -> None: def _generate_transcript( self, - history: Union[List[HistoryItem], List[str]], + history: Union[list[HistoryItem], list[str]], transcript_file: str, *, add_to_history: bool = True, @@ -5055,22 +4770,29 @@ 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)" - ) + @staticmethod + def _build_edit_parser() -> Cmd2ArgumentParser: + from rich.markdown import Markdown - 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_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", + Markdown("To set a new editor, run: `set editor `"), + ) - @with_argparser(edit_parser) + edit_parser.add_argument( + 'file_path', + nargs=argparse.OPTIONAL, + help="optional path to a file to open in editor", + completer=Cmd.path_complete, + ) + return 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""" + """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) @@ -5099,29 +4821,39 @@ def _current_script_dir(self) -> Optional[str]: else: 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" - ) + @staticmethod + def _build_base_run_script_parser() -> Cmd2ArgumentParser: + from rich.table import Table - 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_description = Table( + box=None, + show_header=False, + padding=(0, 0), + ) + run_script_description.add_row("Run text script.") + run_script_description.add_row() + run_script_description.add_row("Scripts contain one command, written as it would be typed in the console, per line.") + + return argparse_custom.DEFAULT_ARGUMENT_PARSER(description=run_script_description) + + @staticmethod + def _build_run_script_parser() -> Cmd2ArgumentParser: + run_script_parser = Cmd._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=Cmd.path_complete, + ) + run_script_parser.add_argument('script_path', help="path to the script file", completer=Cmd.path_complete) + + return run_script_parser - @with_argparser(run_script_parser) + @with_argparser(_build_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. + """ + Run text script. :return: True if running of commands should stop """ @@ -5182,35 +4914,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." - ) + @staticmethod + def _build_relative_run_script_parser() -> Cmd2ArgumentParser: + from rich.table import Table + + relative_run_script_parser = Cmd._build_base_run_script_parser() + relative_run_script_parser.description = cast(Table, relative_run_script_parser.description) + relative_run_script_parser.description.add_row() + relative_run_script_parser.description.add_row( + "If this is run from within a script, script_path will be interpreted relative to that 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') + relative_run_script_parser.add_argument('script_path', help="path to the script file") + + return relative_run_script_parser - @with_argparser(relative_run_script_parser) + @with_argparser(_build_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 :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)) - def _run_transcript_tests(self, transcript_paths: List[str]) -> None: + def _run_transcript_tests(self, transcript_paths: list[str]) -> None: """Runs transcript tests for provided file(s). This is called when either -t is provided on the command line or the transcript_files argument is provided @@ -5599,12 +5337,12 @@ def cmdloop(self, intro: Optional[str] = None) -> int: # type: ignore[override] ### def _initialize_plugin_system(self) -> None: """Initialize the plugin system""" - self._preloop_hooks: List[Callable[[], None]] = [] - self._postloop_hooks: List[Callable[[], None]] = [] - self._postparsing_hooks: List[Callable[[plugin.PostparsingData], plugin.PostparsingData]] = [] - self._precmd_hooks: List[Callable[[plugin.PrecommandData], plugin.PrecommandData]] = [] - self._postcmd_hooks: List[Callable[[plugin.PostcommandData], plugin.PostcommandData]] = [] - self._cmdfinalization_hooks: List[Callable[[plugin.CommandFinalizationData], plugin.CommandFinalizationData]] = [] + self._preloop_hooks: list[Callable[[], None]] = [] + self._postloop_hooks: list[Callable[[], None]] = [] + self._postparsing_hooks: list[Callable[[plugin.PostparsingData], plugin.PostparsingData]] = [] + self._precmd_hooks: list[Callable[[plugin.PrecommandData], plugin.PrecommandData]] = [] + self._postcmd_hooks: list[Callable[[plugin.PostcommandData], plugin.PostcommandData]] = [] + self._cmdfinalization_hooks: list[Callable[[plugin.CommandFinalizationData], plugin.CommandFinalizationData]] = [] @classmethod def _validate_callable_param_count(cls, func: Callable[..., Any], count: int) -> None: @@ -5738,7 +5476,7 @@ def _resolve_func_self( else: # Search all registered CommandSets func_self = None - candidate_sets: List[CommandSet] = [] + candidate_sets: list[CommandSet] = [] for installed_cmd_set in self._installed_command_sets: if type(installed_cmd_set) == func_class: # noqa: E721 # Case 2: CommandSet is an exact type match for the function's CommandSet diff --git a/cmd2/command_definition.py b/cmd2/command_definition.py index 5bac8ef3..1762d909 100644 --- a/cmd2/command_definition.py +++ b/cmd2/command_definition.py @@ -6,7 +6,6 @@ from typing import ( TYPE_CHECKING, Callable, - Dict, Mapping, Optional, Type, @@ -100,7 +99,7 @@ def __init__(self) -> None: # accessed by child classes using the self._cmd property. self.__cmd_internal: Optional[cmd2.Cmd] = None - self._settables: Dict[str, Settable] = {} + self._settables: dict[str, Settable] = {} self._settable_prefix = self.__class__.__name__ @property diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 343beaa2..03a7ad50 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -6,11 +6,8 @@ TYPE_CHECKING, Any, Callable, - Dict, - List, Optional, Sequence, - Tuple, Type, TypeVar, Union, @@ -79,7 +76,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', Union[Statement, str]]: """ Helper function for cmd2 decorators to 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. @@ -103,7 +100,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: Union[Sequence[Any]], search_arg: Any, *replace_arg: Any) -> list[Any]: """ Helper function for cmd2 decorators to swap the Statement parameter with one or more decorator-specific parameters @@ -120,13 +117,13 @@ 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]] -#: Function signature for a command function that accepts a pre-processed argument list from user input +ArgListCommandFuncOptionalBoolReturn = Callable[[CommandParent, list[str]], Optional[bool]] +#: Function signature for an Command Function that accepts a pre-processed argument list from user input #: and returns a boolean -ArgListCommandFuncBoolReturn = Callable[[CommandParent, List[str]], bool] -#: Function signature for a command function that accepts a pre-processed argument list from user input +ArgListCommandFuncBoolReturn = Callable[[CommandParent, list[str]], bool] +#: Function signature for an Command Function that accepts a pre-processed argument list from user input #: and returns Nothing -ArgListCommandFuncNoneReturn = Callable[[CommandParent, List[str]], None] +ArgListCommandFuncNoneReturn = Callable[[CommandParent, list[str]], None] #: Aggregate of all accepted function signatures for command functions that accept a pre-processed argument list ArgListCommandFunc = Union[ @@ -187,7 +184,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__ @@ -209,7 +206,7 @@ def _set_parser_prog(parser: argparse.ArgumentParser, prog: str) -> None: """ # Set the prog value for this parser parser.prog = prog - req_args: List[str] = [] + req_args: list[str] = [] # Set the prog value for the parser's subcommands for action in parser._actions: @@ -252,17 +249,17 @@ def _set_parser_prog(parser: argparse.ArgumentParser, prog: str) -> None: #: 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]] +ArgparseCommandFuncWithUnknownArgsOptionalBoolReturn = Callable[[CommandParent, argparse.Namespace, list[str]], Optional[bool]] #: Function signatures for command functions that use an argparse.ArgumentParser to process user input #: and return a boolean ArgparseCommandFuncBoolReturn = Callable[[CommandParent, argparse.Namespace], bool] -ArgparseCommandFuncWithUnknownArgsBoolReturn = Callable[[CommandParent, argparse.Namespace, List[str]], bool] +ArgparseCommandFuncWithUnknownArgsBoolReturn = Callable[[CommandParent, argparse.Namespace, list[str]], bool] #: Function signatures for command functions that use an argparse.ArgumentParser to process user input #: and return nothing ArgparseCommandFuncNoneReturn = Callable[[CommandParent, argparse.Namespace], None] -ArgparseCommandFuncWithUnknownArgsNoneReturn = Callable[[CommandParent, argparse.Namespace, List[str]], None] +ArgparseCommandFuncWithUnknownArgsNoneReturn = Callable[[CommandParent, argparse.Namespace, list[str]], None] #: Aggregate of all accepted function signatures for an argparse command function ArgparseCommandFunc = Union[ @@ -344,7 +341,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]) -> Optional[bool]: """ Command function wrapper which translates command line into argparse Namespace and calls actual command function @@ -377,7 +374,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: Union[tuple[argparse.Namespace], tuple[argparse.Namespace, list[str]]] if with_unknown_args: new_args = arg_parser.parse_known_args(parsed_arglist, namespace) else: @@ -399,7 +396,7 @@ 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) :] @@ -422,7 +419,7 @@ def as_subcommand_to( ], *, help: Optional[str] = None, - aliases: Optional[List[str]] = None, + aliases: Optional[list[str]] = None, ) -> Callable[[ArgparseCommandFunc[CommandParent]], ArgparseCommandFunc[CommandParent]]: """ Tag this method as a subcommand to an existing argparse decorated command. @@ -444,7 +441,7 @@ def arg_decorator(func: ArgparseCommandFunc[CommandParent]) -> ArgparseCommandFu setattr(func, constants.SUBCMD_ATTR_NAME, subcommand) # Keyword arguments for subparsers.add_parser() - add_parser_kwargs: Dict[str, Any] = dict() + add_parser_kwargs: dict[str, Any] = dict() if help is not None: add_parser_kwargs['help'] = help if aliases: diff --git a/cmd2/history.py b/cmd2/history.py index 289615c8..dbd5ba0f 100644 --- a/cmd2/history.py +++ b/cmd2/history.py @@ -14,9 +14,7 @@ from typing import ( Any, Callable, - Dict, Iterable, - List, Optional, Union, overload, @@ -132,12 +130,12 @@ def pr(self, idx: int, script: bool = False, expanded: bool = False, verbose: bo return ret_str - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Utility method to convert this HistoryItem into a dictionary for use in persistent JSON history files""" return {HistoryItem._statement_field: self.statement.to_dict()} @staticmethod - def from_dict(source_dict: Dict[str, Any]) -> 'HistoryItem': + def from_dict(source_dict: dict[str, Any]) -> 'HistoryItem': """ Utility method to restore a HistoryItem from a dictionary @@ -149,7 +147,7 @@ def from_dict(source_dict: Dict[str, Any]) -> 'HistoryItem': return HistoryItem(Statement.from_dict(statement_dict)) -class History(List[HistoryItem]): +class History(list[HistoryItem]): """A list of [HistoryItem][cmd2.history.HistoryItem] objects with additional methods for searching and managing the list. @@ -242,7 +240,7 @@ def get(self, index: int) -> HistoryItem: # spanpattern = re.compile(r'^\s*(?P-?[1-9]\d*)?(?P:|(\.{2,}))(?P-?[1-9]\d*)?\s*$') - def span(self, span: str, include_persisted: bool = False) -> 'OrderedDict[int, HistoryItem]': + def span(self, span: str, include_persisted: bool = False) -> OrderedDict[int, HistoryItem]: """Return a slice of the History list :param span: string containing an index or a slice @@ -291,7 +289,7 @@ def span(self, span: str, include_persisted: bool = False) -> 'OrderedDict[int, return self._build_result_dictionary(start, end) - def str_search(self, search: str, include_persisted: bool = False) -> 'OrderedDict[int, HistoryItem]': + def str_search(self, search: str, include_persisted: bool = False) -> OrderedDict[int, HistoryItem]: """Find history items which contain a given string :param search: the string to search for @@ -310,7 +308,7 @@ def isin(history_item: HistoryItem) -> bool: start = 0 if include_persisted else self.session_start_index return self._build_result_dictionary(start, len(self), isin) - def regex_search(self, regex: str, include_persisted: bool = False) -> 'OrderedDict[int, HistoryItem]': + def regex_search(self, regex: str, include_persisted: bool = False) -> OrderedDict[int, HistoryItem]: """Find history items which match a given regular expression :param regex: the regular expression to search for. @@ -346,7 +344,7 @@ def truncate(self, max_length: int) -> None: def _build_result_dictionary( self, start: int, end: int, filter_func: Optional[Callable[[HistoryItem], bool]] = None - ) -> 'OrderedDict[int, HistoryItem]': + ) -> OrderedDict[int, HistoryItem]: """ Build history search results :param start: start index to search from diff --git a/cmd2/parsing.py b/cmd2/parsing.py index c42ac22b..827f440f 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -10,11 +10,8 @@ ) from typing import ( Any, - Dict, Iterable, - List, Optional, - Tuple, Union, ) @@ -27,7 +24,7 @@ ) -def shlex_split(str_to_split: str) -> List[str]: +def shlex_split(str_to_split: str) -> list[str]: """ A wrapper around shlex.split() that uses cmd2's preferred arguments. This allows other classes to easily call split() the same way StatementParser does. @@ -39,57 +36,7 @@ def shlex_split(str_to_split: str) -> List[str]: @dataclass(frozen=True) -class MacroArg: - """ - Information used to replace or unescape arguments in a macro value when the macro is resolved - Normal argument syntax: {5} - Escaped argument syntax: {{5}} - """ - - # The starting index of this argument in the macro value - start_index: int - - # The number string that appears between the braces - # This is a string instead of an int because we support unicode digits and must be able - # to reproduce this string later - number_str: str - - # Tells if this argument is escaped and therefore needs to be unescaped - is_escaped: bool - - # Pattern used to find normal argument - # Digits surrounded by exactly 1 brace on a side and 1 or more braces on the opposite side - # Match strings like: {5}, {{{{{4}, {2}}}}} - macro_normal_arg_pattern = re.compile(r'(? str: return self.command_and_args + self.post_command @property - def argv(self) -> List[str]: + def argv(self) -> list[str]: """a list of arguments a-la ``sys.argv``. - The first element of the list is the command after shortcut and macro - expansion. Subsequent elements of the list contain any additional - arguments, with quotes removed, just like bash would. This is very - useful if you are going to use ``argparse.parse_args()``. + The first element of the list is the command after shortcut expansion. + Subsequent elements of the list contain any additional arguments, + with quotes removed, just like bash would. This is very useful if + you are going to use ``argparse.parse_args()``. If you want to strip quotes from the input, you can use ``argv[1:]``. """ @@ -225,12 +172,12 @@ def argv(self) -> List[str]: return rtn - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Utility method to convert this Statement into a dictionary for use in persistent JSON history files""" return self.__dict__.copy() @staticmethod - def from_dict(source_dict: Dict[str, Any]) -> 'Statement': + def from_dict(source_dict: dict[str, Any]) -> 'Statement': """ Utility method to restore a Statement from a dictionary @@ -258,8 +205,8 @@ 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, + aliases: Optional[dict[str, str]] = None, + shortcuts: Optional[dict[str, str]] = None, ) -> None: """Initialize an instance of StatementParser. @@ -271,13 +218,13 @@ def __init__( :param aliases: dictionary containing aliases :param shortcuts: dictionary containing shortcuts """ - self.terminators: Tuple[str, ...] + self.terminators: tuple[str, ...] if terminators is None: self.terminators = (constants.MULTILINE_TERMINATOR,) else: self.terminators = tuple(terminators) - self.multiline_commands: Tuple[str, ...] = tuple(multiline_commands) if multiline_commands is not None else () - self.aliases: Dict[str, str] = aliases if aliases is not None else {} + self.multiline_commands: tuple[str, ...] = tuple(multiline_commands) if multiline_commands is not None else () + self.aliases: dict[str, str] = aliases if aliases is not None else {} if shortcuts is None: shortcuts = constants.DEFAULT_SHORTCUTS @@ -318,7 +265,7 @@ def __init__( expr = rf'\A\s*(\S*?)({second_group})' self._command_pattern = re.compile(expr) - def is_valid_command(self, word: str, *, is_subcommand: bool = False) -> Tuple[bool, str]: + def is_valid_command(self, word: str, *, is_subcommand: bool = False) -> tuple[bool, str]: """Determine whether a word is a valid name for a command. Commands cannot include redirection characters, whitespace, @@ -369,7 +316,7 @@ def is_valid_command(self, word: str, *, is_subcommand: bool = False) -> Tuple[b errmsg = '' return valid, errmsg - def tokenize(self, line: str) -> List[str]: + def tokenize(self, line: str) -> list[str]: """ Lex a string into a list of tokens. Shortcuts and aliases are expanded and comments are removed. @@ -607,7 +554,7 @@ def parse_command_only(self, rawinput: str) -> Statement: def get_command_arg_list( self, command_name: str, to_parse: Union[Statement, str], preserve_quotes: bool - ) -> Tuple[Statement, List[str]]: + ) -> tuple[Statement, list[str]]: """ Convenience method used by the argument parsing decorators. @@ -676,7 +623,7 @@ def _expand(self, line: str) -> str: return line @staticmethod - def _command_and_args(tokens: List[str]) -> Tuple[str, str]: + def _command_and_args(tokens: list[str]) -> tuple[str, str]: """Given a list of tokens, return a tuple of the command and the args as a string. """ @@ -691,7 +638,7 @@ def _command_and_args(tokens: List[str]) -> Tuple[str, str]: return command, args - def split_on_punctuation(self, tokens: List[str]) -> List[str]: + def split_on_punctuation(self, tokens: list[str]) -> list[str]: """Further splits tokens from a command line using punctuation characters. Punctuation characters are treated as word breaks when they are in @@ -701,7 +648,7 @@ def split_on_punctuation(self, tokens: List[str]) -> List[str]: :param tokens: the tokens as parsed by shlex :return: a new list of tokens, further split using punctuation """ - punctuation: List[str] = [] + punctuation: list[str] = [] punctuation.extend(self.terminators) punctuation.extend(constants.REDIRECTION_CHARS) diff --git a/cmd2/py_bridge.py b/cmd2/py_bridge.py index 2955cfa3..a8262ef3 100644 --- a/cmd2/py_bridge.py +++ b/cmd2/py_bridge.py @@ -13,7 +13,6 @@ IO, TYPE_CHECKING, Any, - List, NamedTuple, Optional, TextIO, @@ -99,9 +98,9 @@ def __init__(self, cmd2_app: 'cmd2.Cmd', *, add_to_history: bool = True) -> None # Tells if any of the commands run via __call__ returned True for stop self.stop = False - def __dir__(self) -> List[str]: + def __dir__(self) -> list[str]: """Return a custom set of attribute names""" - attributes: List[str] = [] + attributes: list[str] = [] attributes.insert(0, 'cmd_echo') return attributes diff --git a/cmd2/table_creator.py b/cmd2/table_creator.py index 285253c8..27d27cc1 100644 --- a/cmd2/table_creator.py +++ b/cmd2/table_creator.py @@ -17,14 +17,12 @@ from typing import ( Any, Deque, - List, Optional, Sequence, - Tuple, Union, ) -from wcwidth import ( # type: ignore[import] +from wcwidth import ( # type: ignore[import-untyped] wcwidth, ) @@ -156,7 +154,7 @@ def __init__(self, cols: Sequence[Column], *, tab_width: int = 4) -> None: col.width = max(1, ansi.widest_line(col.header)) @staticmethod - def _wrap_long_word(word: str, max_width: int, max_lines: Union[int, float], is_last_word: bool) -> Tuple[str, int, int]: + def _wrap_long_word(word: str, max_width: int, max_lines: Union[int, float], is_last_word: bool) -> tuple[str, int, int]: """ Used by _wrap_text() to wrap a long word over multiple lines @@ -164,7 +162,7 @@ def _wrap_long_word(word: str, max_width: int, max_lines: Union[int, float], is_ :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) + :return: tuple(wrapped text, lines used, display width of last line) """ styles_dict = utils.get_styles_dict(word) wrapped_buf = io.StringIO() @@ -382,7 +380,7 @@ def add_word(word_to_add: str, is_last_word: bool) -> None: return wrapped_buf.getvalue() - def _generate_cell_lines(self, cell_data: Any, is_header: bool, col: Column, fill_char: str) -> Tuple[Deque[str], int]: + 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 @@ -392,7 +390,7 @@ def _generate_cell_lines(self, cell_data: Any, is_header: bool, col: Column, fil :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) + :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) @@ -654,7 +652,7 @@ def generate_header(self) -> str: 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] = [] + to_display: list[Any] = [] for col in self.cols: if col.style_header_text: to_display.append(self.apply_header_bg(col.header)) @@ -694,7 +692,7 @@ def generate_data_row(self, row_data: Sequence[Any]) -> str: 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] = [] + 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])) @@ -949,7 +947,7 @@ def generate_header(self) -> str: 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] = [] + to_display: list[Any] = [] for col in self.cols: if col.style_header_text: to_display.append(self.apply_header_bg(col.header)) @@ -993,7 +991,7 @@ def generate_data_row(self, row_data: Sequence[Any]) -> str: 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] = [] + 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])) diff --git a/cmd2/transcript.py b/cmd2/transcript.py index f4781fd9..47c76878 100644 --- a/cmd2/transcript.py +++ b/cmd2/transcript.py @@ -15,10 +15,8 @@ class is used in cmd2.py::run_transcript_tests() from typing import ( TYPE_CHECKING, Iterator, - List, Optional, TextIO, - Tuple, cast, ) @@ -66,7 +64,7 @@ def runTest(self) -> None: # was testall def _fetchTranscripts(self) -> None: self.transcripts = {} - testfiles = cast(List[str], getattr(self.cmdapp, 'testfiles', [])) + testfiles = cast(list[str], getattr(self.cmdapp, 'testfiles', [])) for fname in testfiles: tfile = open(fname) self.transcripts[fname] = iter(tfile.readlines()) @@ -184,7 +182,7 @@ def _transform_transcript_expected(self, s: str) -> str: return regex @staticmethod - def _escaped_find(regex: str, s: str, start: int, in_regex: bool) -> Tuple[str, int, int]: + def _escaped_find(regex: str, s: str, start: int, in_regex: bool) -> tuple[str, int, int]: """Find the next slash in {s} after {start} that is not preceded by a backslash. If we find an escaped slash, add everything up to and including it to regex, diff --git a/cmd2/utils.py b/cmd2/utils.py index 2ad86417..804d5221 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -23,9 +23,7 @@ TYPE_CHECKING, Any, Callable, - Dict, Iterable, - List, Optional, TextIO, Type, @@ -160,7 +158,7 @@ def __init__( """ if val_type is bool: - def get_bool_choices(_) -> List[str]: # type: ignore[no-untyped-def] + def get_bool_choices(_) -> list[str]: # type: ignore[no-untyped-def] """Used to tab complete lowercase boolean values""" return ['true', 'false'] @@ -231,7 +229,7 @@ def is_text_file(file_path: str) -> bool: return valid_text_file -def remove_duplicates(list_to_prune: List[_T]) -> List[_T]: +def remove_duplicates(list_to_prune: list[_T]) -> list[_T]: """Removes duplicates from a list while preserving order of the items. :param list_to_prune: the list being pruned of duplicates @@ -253,7 +251,7 @@ def norm_fold(astr: str) -> str: return unicodedata.normalize('NFC', astr).casefold() -def alphabetical_sort(list_to_sort: Iterable[str]) -> List[str]: +def alphabetical_sort(list_to_sort: Iterable[str]) -> list[str]: """Sorts a list of strings alphabetically. For example: ['a1', 'A11', 'A2', 'a22', 'a3'] @@ -280,7 +278,7 @@ def try_int_or_force_to_lower_case(input_str: str) -> Union[int, str]: return norm_fold(input_str) -def natural_keys(input_str: str) -> List[Union[int, str]]: +def natural_keys(input_str: str) -> list[Union[int, str]]: """ Converts a string into a list of integers and strings to support natural sorting (see natural_sort). @@ -291,7 +289,7 @@ def natural_keys(input_str: str) -> List[Union[int, str]]: return [try_int_or_force_to_lower_case(substr) for substr in re.split(r'(\d+)', input_str)] -def natural_sort(list_to_sort: Iterable[str]) -> List[str]: +def natural_sort(list_to_sort: Iterable[str]) -> list[str]: """ Sorts a list of strings case insensitively as well as numerically. @@ -307,7 +305,7 @@ def natural_sort(list_to_sort: Iterable[str]) -> List[str]: return sorted(list_to_sort, key=natural_keys) -def quote_specific_tokens(tokens: List[str], tokens_to_quote: List[str]) -> None: +def quote_specific_tokens(tokens: list[str], tokens_to_quote: list[str]) -> None: """ Quote specific tokens in a list @@ -319,7 +317,7 @@ def quote_specific_tokens(tokens: List[str], tokens_to_quote: List[str]) -> None tokens[i] = quote_string(token) -def unquote_specific_tokens(tokens: List[str], tokens_to_unquote: List[str]) -> None: +def unquote_specific_tokens(tokens: list[str], tokens_to_unquote: list[str]) -> None: """ Unquote specific tokens in a list @@ -353,7 +351,7 @@ def expand_user(token: str) -> str: return token -def expand_user_in_tokens(tokens: List[str]) -> None: +def expand_user_in_tokens(tokens: list[str]) -> None: """ Call expand_user() on all tokens in a list of strings :param tokens: tokens to expand @@ -395,7 +393,7 @@ def find_editor() -> Optional[str]: return editor -def files_from_glob_pattern(pattern: str, access: int = os.F_OK) -> List[str]: +def files_from_glob_pattern(pattern: str, access: int = os.F_OK) -> list[str]: """Return a list of file paths based on a glob pattern. Only files are returned, not directories, and optionally only files for which the user has a specified access to. @@ -407,7 +405,7 @@ def files_from_glob_pattern(pattern: str, access: int = os.F_OK) -> List[str]: return [f for f in glob.glob(pattern) if os.path.isfile(f) and os.access(f, access)] -def files_from_glob_patterns(patterns: List[str], access: int = os.F_OK) -> List[str]: +def files_from_glob_patterns(patterns: list[str], access: int = os.F_OK) -> list[str]: """Return a list of file paths based on a list of glob patterns. Only files are returned, not directories, and optionally only files for which the user has a specified access to. @@ -423,7 +421,7 @@ def files_from_glob_patterns(patterns: List[str], access: int = os.F_OK) -> List return files -def get_exes_in_path(starts_with: str) -> List[str]: +def get_exes_in_path(starts_with: str) -> list[str]: """Returns names of executables in a user's path :param starts_with: what the exes should start with. leave blank for all exes in path. @@ -744,7 +742,7 @@ def __init__( self.saved_redirecting = saved_redirecting -def _remove_overridden_styles(styles_to_parse: List[str]) -> List[str]: +def _remove_overridden_styles(styles_to_parse: list[str]) -> list[str]: """ Utility function for align_text() / truncate_line() which filters a style list down to only those which would still be in effect if all were processed in order. @@ -765,7 +763,7 @@ class StyleState: def __init__(self) -> None: # Contains styles still in effect, keyed by their index in styles_to_parse - self.style_dict: Dict[int, str] = dict() + self.style_dict: dict[int, str] = dict() # Indexes into style_dict self.reset_all: Optional[int] = None @@ -903,7 +901,7 @@ def align_text( # 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] = [] + previous_styles: list[str] = [] for index, line in enumerate(lines): if index > 0: @@ -1114,7 +1112,7 @@ def truncate_line(line: str, max_width: int, *, tab_width: int = 4) -> str: return truncated_buf.getvalue() -def get_styles_dict(text: str) -> Dict[int, str]: +def get_styles_dict(text: str) -> dict[int, str]: """ Return an OrderedDict containing all ANSI style sequences found in a string @@ -1170,7 +1168,7 @@ def do_echo(self, arglist): setattr(item, constants.CMD_ATTR_HELP_CATEGORY, category) else: if 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) @@ -1190,7 +1188,7 @@ def get_defining_class(meth: Callable[..., Any]) -> Optional[Type[Any]]: if inspect.ismethod(meth) or ( inspect.isbuiltin(meth) and getattr(meth, '__self__') is not None and getattr(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 diff --git a/examples/argparse_completion.py b/examples/argparse_completion.py index e0ad101b..b1efe4e5 100755 --- a/examples/argparse_completion.py +++ b/examples/argparse_completion.py @@ -5,10 +5,6 @@ """ import argparse -from typing import ( - Dict, - List, -) from cmd2 import ( Cmd, @@ -28,11 +24,11 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball'] - def choices_provider(self) -> List[str]: + def choices_provider(self) -> list[str]: """A choices provider is useful when the choice list is based on instance data of your application""" return self.sport_item_strs - def choices_completion_error(self) -> List[str]: + def choices_completion_error(self) -> list[str]: """ CompletionErrors can be raised if an error occurs while tab completing. @@ -44,14 +40,14 @@ def choices_completion_error(self) -> List[str]: return self.sport_item_strs raise CompletionError("debug must be true") - def choices_completion_item(self) -> List[CompletionItem]: + 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()] - def choices_arg_tokens(self, arg_tokens: Dict[str, List[str]]) -> List[str]: + 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 passed a dictionary that maps the command line tokens up through the one being completed diff --git a/examples/async_printing.py b/examples/async_printing.py index e94ee89a..221c52e7 100755 --- a/examples/async_printing.py +++ b/examples/async_printing.py @@ -8,9 +8,6 @@ import random import threading import time -from typing import ( - List, -) import cmd2 from cmd2 import ( @@ -89,7 +86,7 @@ def do_stop_alerts(self, _): else: print("The alert thread is already stopped") - def _get_alerts(self) -> List[str]: + def _get_alerts(self) -> list[str]: """ Reports alerts :return: the list of alerts diff --git a/examples/basic_completion.py b/examples/basic_completion.py index c713f2b0..ba32d67b 100755 --- a/examples/basic_completion.py +++ b/examples/basic_completion.py @@ -14,13 +14,10 @@ """ import functools -from typing import ( - List, -) import cmd2 -# List of strings used with completion functions +# list of strings used with completion functions food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato'] sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball'] @@ -46,7 +43,7 @@ def do_flag_based(self, statement: cmd2.Statement): """ self.poutput("Args: {}".format(statement.args)) - def complete_flag_based(self, text, line, begidx, endidx) -> List[str]: + def complete_flag_based(self, text, line, begidx, endidx) -> list[str]: """Completion function for do_flag_based""" flag_dict = { # Tab complete food items after -f and --food flags in command line @@ -66,7 +63,7 @@ def do_index_based(self, statement: cmd2.Statement): """Tab completes first 3 arguments using index_based_complete""" self.poutput("Args: {}".format(statement.args)) - def complete_index_based(self, text, line, begidx, endidx) -> List[str]: + def complete_index_based(self, text, line, begidx, endidx) -> list[str]: """Completion function for do_index_based""" index_dict = { 1: food_item_strs, # Tab complete food items at index 1 in command line @@ -87,7 +84,7 @@ def do_raise_error(self, statement: cmd2.Statement): """Demonstrates effect of raising CompletionError""" self.poutput("Args: {}".format(statement.args)) - def complete_raise_error(self, text, line, begidx, endidx) -> List[str]: + def complete_raise_error(self, text, line, begidx, endidx) -> list[str]: """ CompletionErrors can be raised if an error occurs while tab completing. diff --git a/examples/custom_parser.py b/examples/custom_parser.py index 94df3b05..419c0a54 100644 --- a/examples/custom_parser.py +++ b/examples/custom_parser.py @@ -1,25 +1,29 @@ # coding=utf-8 """ -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, ) -# 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') linum = 0 @@ -38,5 +42,13 @@ def error(self, message: str) -> None: self.exit(2, '{}\n\n'.format(formatted_message)) -# 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 index ea8fd3b5..2a5449dc 100755 --- a/examples/decorator_example.py +++ b/examples/decorator_example.py @@ -12,9 +12,6 @@ """ import argparse -from typing import ( - List, -) import cmd2 @@ -76,7 +73,7 @@ def do_tag(self, args: argparse.Namespace): self.poutput('<{0}>{1}'.format(args.tag, ' '.join(args.content))) @cmd2.with_argument_list - def do_tagg(self, arglist: List[str]): + def do_tagg(self, arglist: list[str]): """version of creating an html tag using arglist instead of argparser""" if len(arglist) >= 2: tag = arglist[0] diff --git a/examples/exit_code.py b/examples/exit_code.py index d8e538ce..12d73176 100755 --- a/examples/exit_code.py +++ b/examples/exit_code.py @@ -2,10 +2,6 @@ # coding=utf-8 """A simple example demonstrating the following how to emit a non-zero exit code in your cmd2 application.""" -from typing import ( - List, -) - import cmd2 @@ -16,7 +12,7 @@ def __init__(self): super().__init__() @cmd2.with_argument_list - def do_exit(self, arg_list: List[str]) -> bool: + def do_exit(self, arg_list: list[str]) -> bool: """Exit the application with an optional exit code. Usage: exit [exit_code] diff --git a/examples/help_categories.py b/examples/help_categories.py index 5c349422..bb5a1d43 100755 --- a/examples/help_categories.py +++ b/examples/help_categories.py @@ -11,7 +11,6 @@ import cmd2 from cmd2 import ( COMMAND_NAME, - argparse_custom, ) @@ -37,6 +36,9 @@ class HelpCategories(cmd2.Cmd): def __init__(self): super().__init__() + # Set the default category for uncategorized commands + self.default_category = 'Other' + def do_connect(self, _): """Connect command""" self.poutput('Connect') @@ -57,8 +59,9 @@ def do_deploy(self, _): """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') @@ -76,8 +79,9 @@ def do_redeploy(self, _): """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/hooks.py b/examples/hooks.py index 97b90739..6c1001e5 100755 --- a/examples/hooks.py +++ b/examples/hooks.py @@ -10,9 +10,6 @@ """ import re -from typing import ( - List, -) import cmd2 @@ -102,7 +99,7 @@ def proof_hook(self, data: cmd2.plugin.PostcommandData) -> cmd2.plugin.Postcomma return data @cmd2.with_argument_list - def do_list(self, arglist: List[str]) -> None: + def do_list(self, arglist: list[str]) -> None: """Generate a list of 10 numbers.""" if arglist: first = arglist[0] diff --git a/examples/modular_commands/commandset_basic.py b/examples/modular_commands/commandset_basic.py index 8587b98d..59a08de0 100644 --- a/examples/modular_commands/commandset_basic.py +++ b/examples/modular_commands/commandset_basic.py @@ -3,10 +3,6 @@ A simple example demonstrating a loadable command set """ -from typing import ( - List, -) - from cmd2 import ( CommandSet, CompletionError, @@ -18,7 +14,7 @@ @with_default_category('Basic Completion') class BasicCompletionCommandSet(CommandSet): - # List of strings used with completion functions + # list of strings used with completion functions food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato'] sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball'] @@ -39,7 +35,7 @@ def do_flag_based(self, statement: Statement) -> None: """ self._cmd.poutput("Args: {}".format(statement.args)) - def complete_flag_based(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + def complete_flag_based(self, text: str, line: str, begidx: int, endidx: int) -> list[str]: """Completion function for do_flag_based""" flag_dict = { # Tab complete food items after -f and --food flags in command line @@ -59,7 +55,7 @@ def do_index_based(self, statement: Statement) -> None: """Tab completes first 3 arguments using index_based_complete""" self._cmd.poutput("Args: {}".format(statement.args)) - def complete_index_based(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + def complete_index_based(self, text: str, line: str, begidx: int, endidx: int) -> list[str]: """Completion function for do_index_based""" index_dict = { 1: self.food_item_strs, # Tab complete food items at index 1 in command line @@ -73,14 +69,14 @@ def do_delimiter_complete(self, statement: Statement) -> None: """Tab completes files from a list using delimiter_complete""" self._cmd.poutput("Args: {}".format(statement.args)) - def complete_delimiter_complete(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + def complete_delimiter_complete(self, text: str, line: str, begidx: int, endidx: int) -> list[str]: return self._cmd.delimiter_complete(text, line, begidx, endidx, match_against=self.file_strs, delimiter='/') def do_raise_error(self, statement: Statement) -> None: """Demonstrates effect of raising CompletionError""" self._cmd.poutput("Args: {}".format(statement.args)) - def complete_raise_error(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + def complete_raise_error(self, text: str, line: str, begidx: int, endidx: int) -> list[str]: """ CompletionErrors can be raised if an error occurs while tab completing. diff --git a/examples/modular_commands/commandset_complex.py b/examples/modular_commands/commandset_complex.py index 7ab84ac3..e89884d7 100644 --- a/examples/modular_commands/commandset_complex.py +++ b/examples/modular_commands/commandset_complex.py @@ -5,9 +5,6 @@ """ import argparse -from typing import ( - List, -) import cmd2 @@ -25,7 +22,7 @@ def do_banana(self, statement: cmd2.Statement): cranberry_parser.add_argument('arg1', choices=['lemonade', 'juice', 'sauce']) @cmd2.with_argparser(cranberry_parser, with_unknown_args=True) - def do_cranberry(self, ns: argparse.Namespace, unknown: List[str]): + def do_cranberry(self, ns: argparse.Namespace, unknown: list[str]): self._cmd.poutput('Cranberry {}!!'.format(ns.arg1)) if unknown and len(unknown): self._cmd.poutput('Unknown: ' + ', '.join(['{}'] * len(unknown)).format(*unknown)) @@ -36,12 +33,12 @@ def help_cranberry(self): @cmd2.with_argument_list @cmd2.with_category('Also Alone') - def do_durian(self, args: List[str]): + def do_durian(self, args: list[str]): """Durian Command""" self._cmd.poutput('{} Arguments: '.format(len(args))) self._cmd.poutput(', '.join(['{}'] * len(args)).format(*args)) - def complete_durian(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + def complete_durian(self, text: str, line: str, begidx: int, endidx: int) -> list[str]: return self._cmd.basic_complete(text, line, begidx, endidx, ['stinks', 'smells', 'disgusting']) elderberry_parser = cmd2.Cmd2ArgumentParser() diff --git a/examples/modular_commands_main.py b/examples/modular_commands_main.py index e544b3db..773e042b 100755 --- a/examples/modular_commands_main.py +++ b/examples/modular_commands_main.py @@ -8,7 +8,6 @@ import argparse from typing import ( Iterable, - List, Optional, ) @@ -35,7 +34,7 @@ def __init__(self, command_sets: Optional[Iterable[CommandSet]] = None): super().__init__(command_sets=command_sets) self.sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball'] - def choices_provider(self) -> List[str]: + def choices_provider(self) -> list[str]: """A choices provider is useful when the choice list is based on instance data of your application""" return self.sport_item_strs diff --git a/examples/override_parser.py b/examples/override_parser.py deleted file mode 100755 index 0ae279e7..00000000 --- a/examples/override_parser.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python -# coding=utf-8 -# flake8: noqa F402 -""" -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, -) - -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/paged_output.py b/examples/paged_output.py index 0f7173b2..4f4f8a5a 100755 --- a/examples/paged_output.py +++ b/examples/paged_output.py @@ -3,9 +3,6 @@ """A simple example demonstrating the using paged output via the ppaged() method.""" import os -from typing import ( - List, -) import cmd2 @@ -27,7 +24,7 @@ def page_file(self, file_path: str, chop: bool = False): self.pexcept('Error reading {!r}: {}'.format(filename, ex)) @cmd2.with_argument_list - def do_page_wrap(self, args: List[str]): + def do_page_wrap(self, args: list[str]): """Read in a text file and display its output in a pager, wrapping long lines if they don't fit. Usage: page_wrap @@ -40,7 +37,7 @@ def do_page_wrap(self, args: List[str]): complete_page_wrap = cmd2.Cmd.path_complete @cmd2.with_argument_list - def do_page_truncate(self, args: List[str]): + def do_page_truncate(self, args: list[str]): """Read in a text file and display its output in a pager, truncating long lines if they don't fit. Truncated lines can still be accessed by scrolling to the right using the arrow keys. diff --git a/examples/read_input.py b/examples/read_input.py index bfc43380..87f4719f 100755 --- a/examples/read_input.py +++ b/examples/read_input.py @@ -4,10 +4,6 @@ A simple example demonstrating the various ways to call cmd2.Cmd.read_input() for input history and tab completion """ -from typing import ( - List, -) - import cmd2 EXAMPLE_COMMANDS = "Example Commands" @@ -64,7 +60,7 @@ def do_custom_choices(self, _) -> None: else: self.custom_history.append(input_str) - def choices_provider(self) -> List[str]: + def choices_provider(self) -> list[str]: """Example choices provider function""" return ["from_provider_1", "from_provider_2", "from_provider_3"] diff --git a/examples/scripts/save_help_text.py b/examples/scripts/save_help_text.py index b8ba9624..3590dd32 100644 --- a/examples/scripts/save_help_text.py +++ b/examples/scripts/save_help_text.py @@ -8,15 +8,12 @@ import argparse import os import sys -from typing import ( - List, - TextIO, -) +from typing import TextIO ASTERISKS = "********************************************************" -def get_sub_commands(parser: argparse.ArgumentParser) -> List[str]: +def get_sub_commands(parser: argparse.ArgumentParser) -> list[str]: """Get a list of subcommands for an ArgumentParser""" sub_cmds = [] diff --git a/examples/table_creation.py b/examples/table_creation.py index 0849a0b2..3cffa45f 100755 --- a/examples/table_creation.py +++ b/examples/table_creation.py @@ -4,10 +4,7 @@ import functools import sys -from typing import ( - Any, - List, -) +from typing import Any from cmd2 import ( EightBitBg, @@ -63,8 +60,8 @@ 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] = [] + self.books: list[Book] = [] + self.relatives: list[Relative] = [] def ansi_print(text): @@ -76,7 +73,7 @@ def basic_tables(): """Demonstrates basic examples of the table classes""" # Table data which demonstrates handling of wrapping and text styles - data_list: List[List[Any]] = list() + data_list: list[list[Any]] = list() data_list.append(["Billy Smith", "123 Sesame St.\nFake Town, USA 33445", DollarFormatter(100333.03)]) data_list.append( [ @@ -96,7 +93,7 @@ def basic_tables(): 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] = list() + columns: list[Column] = list() columns.append(Column("Name", width=20)) columns.append(Column("Address", width=38)) columns.append( @@ -123,7 +120,7 @@ def nested_tables(): """ # Create data for this example - author_data: List[Author] = [] + 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")) @@ -159,7 +156,7 @@ def nested_tables(): # 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] = list() + author_columns: list[Column] = list() author_columns.append(Column("", width=14)) author_columns.append(Column("", width=20)) @@ -174,7 +171,7 @@ def nested_tables(): # 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] = list() + books_columns: list[Column] = list() books_columns.append(Column(ansi.style("Title", bold=True), width=25)) books_columns.append( Column( @@ -196,7 +193,7 @@ def nested_tables(): # Define BorderedTable for relatives of the author # This will be nested in the parent table's third column. - relative_columns: List[Column] = list() + relative_columns: list[Column] = list() relative_columns.append(Column(ansi.style("Name", bold=True), width=25)) relative_columns.append(Column(ansi.style("Relationship", bold=True), width=12)) @@ -220,7 +217,7 @@ def nested_tables(): ) # Define parent AlternatingTable which contains Author and Book tables - parent_tbl_columns: List[Column] = list() + parent_tbl_columns: list[Column] = list() # 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. @@ -242,7 +239,7 @@ def nested_tables(): ) # Construct the tables - parent_table_data: List[List[Any]] = [] + 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 diff --git a/plugins/ext_test/build-pyenvs.sh b/plugins/ext_test/build-pyenvs.sh index 20f5c8d4..4b515bbf 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.8, etc. It will also create a .python-version +# cmd2-3.9, 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.8" "3.9", "3.10", "3.11", "3.12") +declare -a pythons=("3.9", "3.10", "3.11", "3.12", "3.13") # function to find the latest patch of a minor version of python function find_latest_version { diff --git a/plugins/ext_test/noxfile.py b/plugins/ext_test/noxfile.py index 25a95067..d8aa344b 100644 --- a/plugins/ext_test/noxfile.py +++ b/plugins/ext_test/noxfile.py @@ -1,7 +1,7 @@ import nox -@nox.session(python=['3.8', '3.9', '3.10', '3.11', '3.12']) +@nox.session(python=['3.9', '3.10', '3.11', '3.12', '3.13']) 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 715301a9..5dbbd826 100644 --- a/plugins/ext_test/pyproject.toml +++ b/plugins/ext_test/pyproject.toml @@ -145,19 +145,10 @@ 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 45110413..a6487e54 100644 --- a/plugins/ext_test/setup.py +++ b/plugins/ext_test/setup.py @@ -33,7 +33,7 @@ license='MIT', package_data=PACKAGE_DATA, packages=['cmd2_ext_test'], - python_requires='>=3.8', + python_requires='>=3.9', install_requires=['cmd2 >= 2, <3'], setup_requires=['setuptools >= 42', 'setuptools_scm >= 3.4'], classifiers=[ @@ -43,11 +43,11 @@ 'Topic :: Software Development :: Libraries :: Python Modules', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', ], # dependencies for development and testing # $ pip install -e .[dev] diff --git a/plugins/template/README.md b/plugins/template/README.md index 775a9a81..9981987f 100644 --- a/plugins/template/README.md +++ b/plugins/template/README.md @@ -224,16 +224,16 @@ 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.12 +$ pyenv virtualenv -p python3.12 3.12 cmd2-3.12 +$ pyenv install 3.13 +$ pyenv virtualenv -p python3.13 3.13 cmd2-3.13 ``` 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.13 cmd2-3.13 ``` Whether you ran the script, or did it by hand, you now have isolated virtualenvs @@ -241,12 +241,12 @@ 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.12` | 3.12 | cmd2-3.12 | +| `python3.13` | 3.13 | cmd2-3.13 | +| `pip3.12` | 3.12 | cmd2-3.12 | +| `pip3.13` | 3.13 | cmd2-3.13 | ## Install Dependencies @@ -259,11 +259,11 @@ $ 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.8` +All the dependencies now have been installed in the `cmd2-3.13` virtualenv. If you want to work in other virtualenvs, you'll need to manually select it, and install again:: -$ pyenv shell cmd2-3.4 +$ pyenv shell cmd2-3.13 $ pip install -e .[dev] Now that you have your python environments created, you need to install the @@ -280,8 +280,8 @@ unit tests found in 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 +The included `noxfile.py` is setup to run the unit tests in python 3.9 +3.10, 3.11, 3.12, and 3.13 You can run your unit tests in all of these versions of python by: ``` diff --git a/plugins/template/build-pyenvs.sh b/plugins/template/build-pyenvs.sh index 4a6e1578..4b515bbf 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.8, etc. It will also create a .python-version +# cmd2-3.9, 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.8" "3.9" "3.10" "3.11", "3.12") +declare -a pythons=("3.9", "3.10", "3.11", "3.12", "3.13") # 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 25a95067..d8aa344b 100644 --- a/plugins/template/noxfile.py +++ b/plugins/template/noxfile.py @@ -1,7 +1,7 @@ import nox -@nox.session(python=['3.8', '3.9', '3.10', '3.11', '3.12']) +@nox.session(python=['3.9', '3.10', '3.11', '3.12', '3.13']) def tests(session): session.install('invoke', './[test]') session.run('invoke', 'pytest', '--junit', '--no-pty') diff --git a/plugins/template/setup.py b/plugins/template/setup.py index 7e872cd8..60028c11 100644 --- a/plugins/template/setup.py +++ b/plugins/template/setup.py @@ -24,7 +24,7 @@ url='https://github.com/python-cmd2/cmd2-plugin-template', license='MIT', packages=['cmd2_myplugin'], - python_requires='>=3.8', + python_requires='>=3.9', install_requires=['cmd2 >= 2, <3'], setup_requires=['setuptools_scm'], classifiers=[ @@ -34,11 +34,11 @@ 'Topic :: Software Development :: Libraries :: Python Modules', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', ], # dependencies for development and testing # $ pip install -e .[dev] diff --git a/pyproject.toml b/pyproject.toml index 68007ead..ebb13ace 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,8 +8,15 @@ 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.8" -keywords = ["CLI", "cmd", "command", "interactive", "prompt", "Python"] +requires-python = ">=3.9" +keywords = [ + "CLI", + "cmd", + "command", + "interactive", + "prompt", + "Python", +] license = { file = "LICENSE" } classifiers = [ "Development Status :: 5 - Production/Stable", @@ -19,7 +26,6 @@ classifiers = [ "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -31,6 +37,7 @@ dependencies = [ "gnureadline>=8; platform_system == 'Darwin'", "pyperclip>=1.8", "pyreadline3>=3.4; platform_system == 'Windows'", + "rich-argparse>=1.4", "wcwidth>=0.2.10", ] @@ -176,7 +183,7 @@ select = [ "FA", # flake8-future-annotations # "FBT", # flake8-boolean-trap "G", # flake8-logging-format - # "I", # isort + "I", # isort "ICN", # flake8-import-conventions # "INP", # flake8-no-pep420 "INT", # flake8-gettext @@ -235,19 +242,10 @@ 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/tests/conftest.py b/tests/conftest.py index 644ae7cc..aae8a3bd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,6 @@ redirect_stdout, ) from typing import ( - List, Optional, Union, ) @@ -32,7 +31,7 @@ def verify_help_text( - cmd2_app: cmd2.Cmd, help_output: Union[str, List[str]], verbose_strings: Optional[List[str]] = None + cmd2_app: cmd2.Cmd, help_output: Union[str, list[str]], verbose_strings: Optional[list[str]] = None ) -> None: """This function verifies that all expected commands are present in the help text. @@ -53,36 +52,36 @@ def verify_help_text( assert verbose_string in help_text -# Help text for the history command +# Help text for the history command (Generated when terminal width is 80) 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 +View, run, edit, save, or clear previously entered commands. -positional arguments: +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: +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 + output commands to a script file, implies --script -t, --transcript TRANSCRIPT_FILE output commands and results to a transcript file, - implies -s + implies --script -c, --clear clear all history -formatting: +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 + -x, --expanded output fully parsed commands with aliases and shortcuts + expanded -v, --verbose display history and include expanded commands if they differ from the typed command -a, --all display all commands, including ones persisted from @@ -195,7 +194,7 @@ def get_endidx(): return app.complete(text, 0) -def find_subcommand(action: argparse.ArgumentParser, subcmd_names: List[str]) -> argparse.ArgumentParser: +def find_subcommand(action: argparse.ArgumentParser, subcmd_names: list[str]) -> argparse.ArgumentParser: if not subcmd_names: return action cur_subcmd = subcmd_names.pop(0) diff --git a/tests/test_argparse.py b/tests/test_argparse.py index f800c84a..61e126c0 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -40,8 +40,7 @@ def _say_parser_builder() -> cmd2.Cmd2ArgumentParser: @cmd2.with_argparser(_say_parser_builder) def do_say(self, args, *, keyword_arg: Optional[str] = None): """ - Repeat what you - tell me to. + Repeat what you tell me to. :param args: argparse namespace :param keyword_arg: Optional keyword arguments @@ -218,8 +217,7 @@ def test_argparse_help_docstring(argparse_app): 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(':') @@ -362,40 +360,26 @@ def test_subcommand_help(subcommand_app): # foo has no aliases out, err = run_cmd(subcommand_app, 'help base foo') assert out[0].startswith('Usage: base foo') - assert out[1] == '' - 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:' 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:' 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:' # 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:' 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:' 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:' def test_subcommand_invalid_help(subcommand_app): diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 1f9178f8..e06b53a8 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -6,11 +6,7 @@ import argparse import numbers -from typing import ( - Dict, - List, - cast, -) +from typing import cast import pytest @@ -39,11 +35,11 @@ standalone_completions = ['standalone', 'completer'] -def standalone_choice_provider(cli: cmd2.Cmd) -> List[str]: +def standalone_choice_provider(cli: cmd2.Cmd) -> list[str]: return standalone_choices -def standalone_completer(cli: cmd2.Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]: +def standalone_completer(cli: cmd2.Cmd, text: str, line: str, begidx: int, endidx: int) -> list[str]: return cli.basic_complete(text, line, begidx, endidx, standalone_completions) @@ -125,11 +121,11 @@ def do_pos_and_flag(self, args: argparse.Namespace) -> None: # 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")] - def choices_provider(self) -> List[str]: + def choices_provider(self) -> list[str]: """Method that provides choices""" return self.choices_from_provider - def completion_item_method(self) -> List[CompletionItem]: + def completion_item_method(self) -> list[CompletionItem]: """Choices method that returns CompletionItems""" items = [] for i in range(0, 10): @@ -191,13 +187,13 @@ def do_choices(self, args: argparse.Namespace) -> None: completions_for_pos_1 = ['completions', 'positional_1', 'probably', 'missed', 'spot'] completions_for_pos_2 = ['completions', 'positional_2', 'probably', 'missed', 'me'] - def flag_completer(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + def flag_completer(self, text: str, line: str, begidx: int, endidx: int) -> list[str]: return self.basic_complete(text, line, begidx, endidx, self.completions_for_flag) - def pos_1_completer(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + def pos_1_completer(self, text: str, line: str, begidx: int, endidx: int) -> list[str]: return self.basic_complete(text, line, begidx, endidx, self.completions_for_pos_1) - def pos_2_completer(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + def pos_2_completer(self, text: str, line: str, begidx: int, endidx: int) -> list[str]: return self.basic_complete(text, line, begidx, endidx, self.completions_for_pos_2) completer_parser = Cmd2ArgumentParser() @@ -265,11 +261,11 @@ def do_hint(self, args: argparse.Namespace) -> None: ############################################################################################################ # Begin code related to CompletionError ############################################################################################################ - def completer_raise_error(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + def completer_raise_error(self, text: str, line: str, begidx: int, endidx: int) -> list[str]: """Raises CompletionError""" raise CompletionError('completer broke something') - def choice_raise_error(self) -> List[str]: + def choice_raise_error(self) -> list[str]: """Raises CompletionError""" raise CompletionError('choice broke something') @@ -284,13 +280,13 @@ def do_raise_completion_error(self, args: argparse.Namespace) -> None: ############################################################################################################ # Begin code related to receiving arg_tokens ############################################################################################################ - def choices_takes_arg_tokens(self, arg_tokens: Dict[str, List[str]]) -> List[str]: + def choices_takes_arg_tokens(self, arg_tokens: dict[str, list[str]]) -> list[str]: """Choices function that receives arg_tokens from ArgparseCompleter""" return [arg_tokens['parent_arg'][0], arg_tokens['subcommand'][0]] def completer_takes_arg_tokens( - self, text: str, line: str, begidx: int, endidx: int, arg_tokens: Dict[str, List[str]] - ) -> List[str]: + self, text: str, line: str, begidx: int, endidx: int, arg_tokens: dict[str, list[str]] + ) -> list[str]: """Completer function that receives arg_tokens from ArgparseCompleter""" match_against = [arg_tokens['parent_arg'][0], arg_tokens['subcommand'][0]] return self.basic_complete(text, line, begidx, endidx, match_against) @@ -1204,7 +1200,7 @@ def test_complete_standalone(ac_app, flag, completions): # Custom ArgparseCompleter-based class class CustomCompleter(argparse_completer.ArgparseCompleter): - def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, matched_flags: List[str]) -> List[str]: + def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, matched_flags: list[str]) -> list[str]: """Override so flags with 'complete_when_ready' set to True will complete only when app is ready""" # Find flags which should not be completed and place them in matched_flags diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index a3f85558..3a5b14aa 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -240,30 +240,7 @@ def test_apcustom_required_options(): # 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(): - """Test overriding argparse_custom.DEFAULT_ARGUMENT_PARSER""" - import importlib - - from cmd2 import ( - argparse_custom, - ) - - # The standard parser is Cmd2ArgumentParser - assert argparse_custom.DEFAULT_ARGUMENT_PARSER == Cmd2ArgumentParser - - # 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 argparse_custom.DEFAULT_ARGUMENT_PARSER == CustomParser + assert 'Required Arguments' in parser.format_help() def test_apcustom_metavar_tuple(): diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 721be0a2..6092c54e 100755 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1256,9 +1256,10 @@ def test_help_multiline_docstring(help_app): assert help_app.last_result is True -def test_help_verbose_uses_parser_description(help_app: HelpApp): +def test_help_verbose_uses_parser_docstring(help_app: HelpApp): 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]) class HelpCategoriesApp(cmd2.Cmd): @@ -1579,7 +1580,7 @@ def test_help_with_no_docstring(capsys): out == """Usage: greet [-h] [-s] -optional arguments: +Optional Arguments: -h, --help show this help message and exit -s, --shout N00B EMULATION MODE @@ -1642,8 +1643,8 @@ def test_multiline_complete_statement_with_unclosed_quotes(multiline_app): assert statement.terminator == ';' -def test_multiline_input_line_to_statement(multiline_app): - # Verify _input_line_to_statement saves the fully entered input line for multiline commands +def test_multiline_complete_statement(multiline_app): + # Verify _complete_statement saves the fully entered input line for multiline commands # Mock out the input call so we don't actually wait for a user's response # on stdin when it looks for more input @@ -1651,7 +1652,7 @@ def test_multiline_input_line_to_statement(multiline_app): builtins.input = m line = 'orate hi' - statement = multiline_app._input_line_to_statement(line) + statement = multiline_app._complete_statement(line) assert statement.raw == 'orate hi\nperson\n' assert statement == 'hi person' assert statement.command == 'orate' @@ -2010,8 +2011,7 @@ def test_poutput_ansi_never(outsim_app): assert out == expected -# These are invalid names for aliases and macros -invalid_command_name = [ +invalid_alias_names = [ '""', # Blank name constants.COMMENT_CHAR, '!no_shortcut', @@ -2037,19 +2037,6 @@ def test_get_alias_completion_items(base_app): assert cur_res.description.rstrip() == base_app.aliases[cur_res] -def test_get_macro_completion_items(base_app): - run_cmd(base_app, 'macro create foo !echo foo') - run_cmd(base_app, 'macro create bar !echo bar') - - results = base_app._get_macro_completion_items() - assert len(results) == len(base_app.macros) - - 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 - - def test_get_settable_completion_items(base_app): results = base_app._get_settable_completion_items() assert len(results) == len(base_app.settables) @@ -2125,7 +2112,7 @@ def test_alias_create_with_quoted_tokens(base_app): assert base_app.last_result[alias_name] == alias_command -@pytest.mark.parametrize('alias_name', invalid_command_name) +@pytest.mark.parametrize('alias_name', invalid_alias_names) def test_alias_create_invalid_name(base_app, alias_name, capsys): out, err = run_cmd(base_app, 'alias create {} help'.format(alias_name)) assert "Invalid alias name" in err[0] @@ -2138,14 +2125,6 @@ def test_alias_create_with_command_name(base_app): assert base_app.last_result is False -def test_alias_create_with_macro_name(base_app): - macro = "my_macro" - run_cmd(base_app, 'macro create {} help'.format(macro)) - out, err = run_cmd(base_app, 'alias create {} help'.format(macro)) - assert "Alias cannot have the same name as a macro" in err[0] - assert base_app.last_result is False - - def test_alias_that_resolves_into_comment(base_app): # Create the alias out, err = run_cmd(base_app, 'alias create fake ' + constants.COMMENT_CHAR + ' blah blah') @@ -2204,228 +2183,6 @@ def test_multiple_aliases(base_app): verify_help_text(base_app, out) -def test_macro_no_subcommand(base_app): - out, err = run_cmd(base_app, 'macro') - assert "Usage: macro [-h]" in err[0] - assert "Error: the following arguments are required: SUBCOMMAND" in err[1] - - -def test_macro_create(base_app): - # Create the macro - out, err = run_cmd(base_app, 'macro create fake run_pyscript') - assert out == normalize("Macro 'fake' created") - assert base_app.last_result is True - - # Use the macro - out, err = run_cmd(base_app, 'fake') - assert "the following arguments are required: script_path" in err[1] - - # See a list of macros - out, err = run_cmd(base_app, 'macro list') - assert out == normalize('macro create fake run_pyscript') - assert len(base_app.last_result) == len(base_app.macros) - assert base_app.last_result['fake'] == "run_pyscript" - - # Look up the new macro - out, err = run_cmd(base_app, 'macro list fake') - assert out == normalize('macro create fake run_pyscript') - assert len(base_app.last_result) == 1 - assert base_app.last_result['fake'] == "run_pyscript" - - # Overwrite macro - out, err = run_cmd(base_app, 'macro create fake help') - assert out == normalize("Macro 'fake' overwritten") - assert base_app.last_result is True - - # Look up the updated macro - out, err = run_cmd(base_app, 'macro list fake') - assert out == normalize('macro create fake help') - assert len(base_app.last_result) == 1 - assert base_app.last_result['fake'] == "help" - - -def test_macro_create_with_quoted_tokens(base_app): - """Demonstrate that quotes in macro value will be preserved""" - macro_name = "fake" - macro_command = 'help ">" "out file.txt" ";"' - create_command = f"macro create {macro_name} {macro_command}" - - # Create the macro - out, err = run_cmd(base_app, create_command) - assert out == normalize("Macro 'fake' created") - - # Look up the new macro and verify all quotes are preserved - out, err = run_cmd(base_app, 'macro list fake') - assert out == normalize(create_command) - assert len(base_app.last_result) == 1 - assert base_app.last_result[macro_name] == macro_command - - -@pytest.mark.parametrize('macro_name', invalid_command_name) -def test_macro_create_invalid_name(base_app, macro_name): - out, err = run_cmd(base_app, 'macro create {} help'.format(macro_name)) - assert "Invalid macro name" in err[0] - assert base_app.last_result is False - - -def test_macro_create_with_command_name(base_app): - out, err = run_cmd(base_app, 'macro create help stuff') - assert "Macro cannot have the same name as a command" in err[0] - assert base_app.last_result is False - - -def test_macro_create_with_alias_name(base_app): - macro = "my_macro" - run_cmd(base_app, 'alias create {} help'.format(macro)) - out, err = run_cmd(base_app, 'macro create {} help'.format(macro)) - assert "Macro cannot have the same name as an alias" in err[0] - assert base_app.last_result is False - - -def test_macro_create_with_args(base_app): - # Create the macro - out, err = run_cmd(base_app, 'macro create fake {1} {2}') - assert out == normalize("Macro 'fake' created") - - # Run the macro - out, err = run_cmd(base_app, 'fake help -v') - verify_help_text(base_app, out) - - -def test_macro_create_with_escaped_args(base_app): - # Create the macro - out, err = run_cmd(base_app, 'macro create fake help {{1}}') - assert out == normalize("Macro 'fake' created") - - # Run the macro - out, err = run_cmd(base_app, 'fake') - assert err[0].startswith('No help on {1}') - - -def test_macro_usage_with_missing_args(base_app): - # Create the macro - out, err = run_cmd(base_app, 'macro create fake help {1} {2}') - assert out == normalize("Macro 'fake' created") - - # Run the macro - out, err = run_cmd(base_app, 'fake arg1') - assert "expects at least 2 arguments" in err[0] - - -def test_macro_usage_with_exta_args(base_app): - # Create the macro - out, err = run_cmd(base_app, 'macro create fake help {1}') - assert out == normalize("Macro 'fake' created") - - # Run the macro - out, err = run_cmd(base_app, 'fake alias create') - assert "Usage: alias create" in out[0] - - -def test_macro_create_with_missing_arg_nums(base_app): - # Create the macro - out, err = run_cmd(base_app, 'macro create fake help {1} {3}') - assert "Not all numbers between 1 and 3" in err[0] - assert base_app.last_result is False - - -def test_macro_create_with_invalid_arg_num(base_app): - # Create the macro - out, err = run_cmd(base_app, 'macro create fake help {1} {-1} {0}') - assert "Argument numbers must be greater than 0" in err[0] - assert base_app.last_result is False - - -def test_macro_create_with_unicode_numbered_arg(base_app): - # Create the macro expecting 1 argument - out, err = run_cmd(base_app, 'macro create fake help {\N{ARABIC-INDIC DIGIT ONE}}') - assert out == normalize("Macro 'fake' created") - - # Run the macro - out, err = run_cmd(base_app, 'fake') - assert "expects at least 1 argument" in err[0] - - -def test_macro_create_with_missing_unicode_arg_nums(base_app): - out, err = run_cmd(base_app, 'macro create fake help {1} {\N{ARABIC-INDIC DIGIT THREE}}') - assert "Not all numbers between 1 and 3" in err[0] - assert base_app.last_result is False - - -def test_macro_that_resolves_into_comment(base_app): - # Create the macro - out, err = run_cmd(base_app, 'macro create fake {1} blah blah') - assert out == normalize("Macro 'fake' created") - - # Use the macro - out, err = run_cmd(base_app, 'fake ' + constants.COMMENT_CHAR) - assert not out - assert not err - - -def test_macro_list_invalid_macro(base_app): - # Look up invalid macro - out, err = run_cmd(base_app, 'macro list invalid') - assert "Macro 'invalid' not found" in err[0] - assert base_app.last_result == {} - - -def test_macro_delete(base_app): - # Create an macro - run_cmd(base_app, 'macro create fake run_pyscript') - - # Delete the macro - out, err = run_cmd(base_app, 'macro delete fake') - assert out == normalize("Macro 'fake' deleted") - assert base_app.last_result is True - - -def test_macro_delete_all(base_app): - out, err = run_cmd(base_app, 'macro delete --all') - assert out == normalize("All macros deleted") - assert base_app.last_result is True - - -def test_macro_delete_non_existing(base_app): - out, err = run_cmd(base_app, 'macro delete fake') - assert "Macro 'fake' does not exist" in err[0] - assert base_app.last_result is True - - -def test_macro_delete_no_name(base_app): - out, err = run_cmd(base_app, 'macro delete') - assert "Either --all or macro name(s)" in err[0] - assert base_app.last_result is False - - -def test_multiple_macros(base_app): - macro1 = 'h1' - macro2 = 'h2' - run_cmd(base_app, 'macro create {} help'.format(macro1)) - run_cmd(base_app, 'macro create {} help -v'.format(macro2)) - out, err = run_cmd(base_app, macro1) - verify_help_text(base_app, out) - - out2, err2 = run_cmd(base_app, macro2) - verify_help_text(base_app, out2) - assert len(out2) > len(out) - - -def test_nonexistent_macro(base_app): - from cmd2.parsing import ( - StatementParser, - ) - - exception = None - - try: - base_app._resolve_macro(StatementParser().parse('fake')) - except KeyError as e: - exception = e - - assert exception is not None - - @with_ansi_style(ansi.AllowStyle.ALWAYS) def test_perror_style(base_app, capsys): msg = 'testing...' @@ -2565,7 +2322,6 @@ def test_get_all_commands(base_app): 'help', 'history', 'ipy', - 'macro', 'py', 'quit', 'run_pyscript', diff --git a/tests/test_completion.py b/tests/test_completion.py index cd2a2af0..ec731a72 100755 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -26,8 +26,6 @@ from .conftest import ( complete_tester, - normalize, - run_cmd, ) # List of strings used with completion functions @@ -186,25 +184,6 @@ def test_complete_exception(cmd2_app, capsys): assert "IndexError" in err -def test_complete_macro(base_app, request): - # Create the macro - out, err = run_cmd(base_app, 'macro create fake run_pyscript {1}') - assert out == normalize("Macro 'fake' created") - - # Macros do path completion - test_dir = os.path.dirname(request.module.__file__) - - text = os.path.join(test_dir, 's') - line = 'fake {}'.format(text) - - endidx = len(line) - begidx = endidx - len(text) - - expected = [text + 'cript.py', text + 'cript.txt', text + 'cripts' + os.path.sep] - first_match = complete_tester(text, line, begidx, endidx, base_app) - assert first_match is not None and base_app.completion_matches == expected - - def test_default_sort_key(cmd2_app): text = '' line = 'test_sort_key {}'.format(text) diff --git a/tests/test_history.py b/tests/test_history.py index 1a3bd744..f2e12277 100755 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -772,9 +772,7 @@ def test_history_verbose_with_other_options(base_app): 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 len(out) == 4 - 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 @@ -800,9 +798,7 @@ def test_history_script_with_invalid_options(base_app): 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 len(out) == 4 - 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 @@ -820,9 +816,7 @@ def test_history_expanded_with_invalid_options(base_app): 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 len(out) == 4 - 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 diff --git a/tests/test_parsing.py b/tests/test_parsing.py index e3d42d7c..ed5a00f9 100755 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -1034,111 +1034,3 @@ def test_is_valid_command_valid(parser): valid, errmsg = parser.is_valid_command('!subcmd', is_subcommand=True) assert valid assert not errmsg - - -def test_macro_normal_arg_pattern(): - # This pattern matches digits surrounded by exactly 1 brace on a side and 1 or more braces on the opposite side - from cmd2.parsing import ( - MacroArg, - ) - - pattern = MacroArg.macro_normal_arg_pattern - - # Valid strings - matches = pattern.findall('{5}') - assert matches == ['{5}'] - - matches = pattern.findall('{233}') - assert matches == ['{233}'] - - matches = pattern.findall('{{{{{4}') - assert matches == ['{4}'] - - matches = pattern.findall('{2}}}}}') - assert matches == ['{2}'] - - matches = pattern.findall('{3}{4}{5}') - assert matches == ['{3}', '{4}', '{5}'] - - matches = pattern.findall('{3} {4} {5}') - assert matches == ['{3}', '{4}', '{5}'] - - matches = pattern.findall('{3} {{{4} {5}}}}') - assert matches == ['{3}', '{4}', '{5}'] - - matches = pattern.findall('{3} text {4} stuff {5}}}}') - assert matches == ['{3}', '{4}', '{5}'] - - # Unicode digit - matches = pattern.findall('{\N{ARABIC-INDIC DIGIT ONE}}') - assert matches == ['{\N{ARABIC-INDIC DIGIT ONE}}'] - - # Invalid strings - matches = pattern.findall('5') - assert not matches - - matches = pattern.findall('{5') - assert not matches - - matches = pattern.findall('5}') - assert not matches - - matches = pattern.findall('{{5}}') - assert not matches - - matches = pattern.findall('{5text}') - assert not matches - - -def test_macro_escaped_arg_pattern(): - # This pattern matches digits surrounded by 2 or more braces on both sides - from cmd2.parsing import ( - MacroArg, - ) - - pattern = MacroArg.macro_escaped_arg_pattern - - # Valid strings - matches = pattern.findall('{{5}}') - assert matches == ['{{5}}'] - - matches = pattern.findall('{{233}}') - assert matches == ['{{233}}'] - - matches = pattern.findall('{{{{{4}}') - assert matches == ['{{4}}'] - - matches = pattern.findall('{{2}}}}}') - assert matches == ['{{2}}'] - - matches = pattern.findall('{{3}}{{4}}{{5}}') - assert matches == ['{{3}}', '{{4}}', '{{5}}'] - - matches = pattern.findall('{{3}} {{4}} {{5}}') - assert matches == ['{{3}}', '{{4}}', '{{5}}'] - - matches = pattern.findall('{{3}} {{{4}} {{5}}}}') - assert matches == ['{{3}}', '{{4}}', '{{5}}'] - - matches = pattern.findall('{{3}} text {{4}} stuff {{5}}}}') - assert matches == ['{{3}}', '{{4}}', '{{5}}'] - - # Unicode digit - matches = pattern.findall('{{\N{ARABIC-INDIC DIGIT ONE}}}') - assert matches == ['{{\N{ARABIC-INDIC DIGIT ONE}}}'] - - # Invalid strings - matches = pattern.findall('5') - assert not matches - - matches = pattern.findall('{{5') - assert not matches - - matches = pattern.findall('5}}') - assert not matches - - matches = pattern.findall('{5}') - assert not matches - - matches = pattern.findall('{{5text}}') - assert not matches diff --git a/tests/test_transcript.py b/tests/test_transcript.py index 986221ff..3567c55c 100644 --- a/tests/test_transcript.py +++ b/tests/test_transcript.py @@ -48,7 +48,9 @@ def __init__(self, *args, **kwargs): 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") + + # Escape open bracket since help text can contain rich markup + speak_parser.add_argument('-r', '--repeat', type=int, help=r"output \[n] times") @cmd2.with_argparser(speak_parser, with_unknown_args=True) def do_speak(self, opts, arg): diff --git a/tests/test_utils.py b/tests/test_utils.py index a173f7f4..665261ab 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -336,10 +336,6 @@ def test_context_flag_exit_err(context_flag): def test_remove_overridden_styles(): - from typing import ( - List, - ) - from cmd2 import ( Bg, EightBitBg, @@ -350,7 +346,7 @@ def test_remove_overridden_styles(): TextStyle, ) - def make_strs(styles_list: List[ansi.AnsiSequence]) -> List[str]: + def make_strs(styles_list: list[ansi.AnsiSequence]) -> list[str]: return [str(s) for s in styles_list] # Test Reset All diff --git a/tests/transcripts/from_cmdloop.txt b/tests/transcripts/from_cmdloop.txt index f1c68d81..da536383 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_isolated/test_commandset/conftest.py b/tests_isolated/test_commandset/conftest.py index c8c6d34b..423523b5 100644 --- a/tests_isolated/test_commandset/conftest.py +++ b/tests_isolated/test_commandset/conftest.py @@ -9,7 +9,6 @@ redirect_stdout, ) from typing import ( - List, Optional, Union, ) @@ -34,7 +33,7 @@ def verify_help_text( - cmd2_app: cmd2.Cmd, help_output: Union[str, List[str]], verbose_strings: Optional[List[str]] = None + cmd2_app: cmd2.Cmd, help_output: Union[str, list[str]], verbose_strings: Optional[list[str]] = None ) -> None: """This function verifies that all expected commands are present in the help text. @@ -83,8 +82,7 @@ def verify_help_text( 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 + -x, --expanded output fully parsed commands with aliases and shortcuts expanded -v, --verbose display history and include expanded commands if they differ from the typed command -a, --all display all commands, including ones persisted from diff --git a/tests_isolated/test_commandset/test_argparse_subcommands.py b/tests_isolated/test_commandset/test_argparse_subcommands.py index 3a9ab595..35cd99d6 100644 --- a/tests_isolated/test_commandset/test_argparse_subcommands.py +++ b/tests_isolated/test_commandset/test_argparse_subcommands.py @@ -98,39 +98,39 @@ def test_subcommand_help(subcommand_app): 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): diff --git a/tests_isolated/test_commandset/test_commandset.py b/tests_isolated/test_commandset/test_commandset.py index 89c982f3..434e5d6a 100644 --- a/tests_isolated/test_commandset/test_commandset.py +++ b/tests_isolated/test_commandset/test_commandset.py @@ -6,9 +6,6 @@ import argparse import signal -from typing import ( - List, -) import pytest @@ -61,7 +58,7 @@ def do_banana(self, statement: cmd2.Statement): cranberry_parser.add_argument('arg1', choices=['lemonade', 'juice', 'sauce']) @cmd2.with_argparser(cranberry_parser, with_unknown_args=True) - def do_cranberry(self, ns: argparse.Namespace, unknown: List[str]): + def do_cranberry(self, ns: argparse.Namespace, unknown: list[str]): self._cmd.poutput('Cranberry {}!!'.format(ns.arg1)) if unknown and len(unknown): self._cmd.poutput('Unknown: ' + ', '.join(['{}'] * len(unknown)).format(*unknown)) @@ -72,13 +69,13 @@ def help_cranberry(self): @cmd2.with_argument_list @cmd2.with_category('Also Alone') - def do_durian(self, args: List[str]): + def do_durian(self, args: list[str]): """Durian Command""" self._cmd.poutput('{} Arguments: '.format(len(args))) self._cmd.poutput(', '.join(['{}'] * len(args)).format(*args)) self._cmd.last_result = {'args': args} - def complete_durian(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + def complete_durian(self, text: str, line: str, begidx: int, endidx: int) -> list[str]: return self._cmd.basic_complete(text, line, begidx, endidx, ['stinks', 'smells', 'disgusting']) elderberry_parser = cmd2.Cmd2ArgumentParser() @@ -350,17 +347,17 @@ def test_load_commandset_errors(command_sets_manual, capsys): delattr(command_sets_manual, 'do_durian') - # pre-create intentionally conflicting macro and alias names - command_sets_manual.app_cmd('macro create apple run_pyscript') + # pre-create intentionally conflicting aliases + command_sets_manual.app_cmd('alias create apple run_pyscript') command_sets_manual.app_cmd('alias create banana run_pyscript') # now install a command set and verify the commands are now present command_sets_manual.register_command_set(cmd_set) out, err = capsys.readouterr() - # verify aliases and macros are deleted with warning if they conflict with a command + # verify aliases are deleted with warning if they conflict with a command + assert "Deleting alias 'apple'" in err assert "Deleting alias 'banana'" in err - assert "Deleting macro 'apple'" in err # verify command functions which don't start with "do_" raise an exception with pytest.raises(CommandSetRegistrationError): @@ -504,7 +501,7 @@ def __init__(self, dummy): def do_arugula(self, _: cmd2.Statement): self._cmd.poutput('Arugula') - def complete_style_arg(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + def complete_style_arg(self, text: str, line: str, begidx: int, endidx: int) -> list[str]: return ['quartered', 'diced'] bokchoy_parser = cmd2.Cmd2ArgumentParser() @@ -742,7 +739,7 @@ def cut_banana(self, ns: argparse.Namespace): """Cut banana""" self.poutput('cutting banana: ' + ns.direction) - def complete_style_arg(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + def complete_style_arg(self, text: str, line: str, begidx: int, endidx: int) -> list[str]: return ['quartered', 'diced'] bokchoy_parser = cmd2.Cmd2ArgumentParser() @@ -797,7 +794,7 @@ def __init__(self, dummy): """dummy variable prevents this from being autoloaded in other tests""" super(SupportFuncProvider, self).__init__() - def complete_states(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + def complete_states(self, text: str, line: str, begidx: int, endidx: int) -> list[str]: assert self is complete_states_expected_self return self._cmd.basic_complete(text, line, begidx, endidx, self.states)