From 0937472edb10c7cb26e18923cd4105d9d4c1b1c0 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Mon, 30 Jun 2025 14:18:39 -0400 Subject: [PATCH 01/89] Add note to README about how 2.x is feature complete (#1455) * Add note to README about how 2.x is feature complete * Try to replace symbol with emoji --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 6e4c1444..5271480f 100755 --- a/README.md +++ b/README.md @@ -25,6 +25,10 @@ applications. It provides a simple API which is an extension of Python's built-i of cmd to make your life easier and eliminates much of the boilerplate code which would be necessary when using cmd. +> :warning: **cmd2 is now "feature complete" for the `2.x` branch and is actively working on the +> 3.0.0 release on the `main` branch. New features will only be addressed in 3.x moving forwards. If +> need be, we will still fix bugs in 2.x.** + ## The developers toolbox ![system schema](https://raw.githubusercontent.com/python-cmd2/cmd2/main/.github/images/graph.drawio.png) From acb824b90c5d6adec5907d7a076cdbbb3372fc4e Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 4 Jul 2025 15:19:44 -0400 Subject: [PATCH 02/89] Removed macros. --- CHANGELOG.md | 5 + README.md | 6 +- cmd2/cmd2.py | 387 +----------------- cmd2/parsing.py | 58 +-- docs/examples/first_app.md | 9 +- docs/features/builtin_commands.md | 12 +- docs/features/commands.md | 2 +- docs/features/help.md | 93 +++-- docs/features/history.md | 10 +- docs/features/index.md | 2 +- docs/features/initialization.md | 3 +- docs/features/os.md | 8 +- ...aliases_macros.md => shortcuts_aliases.md} | 40 +- docs/migrating/incompatibilities.md | 2 +- docs/migrating/why.md | 4 +- examples/help_categories.py | 3 + mkdocs.yml | 2 +- tests/conftest.py | 3 +- tests/test_cmd2.py | 255 +----------- tests/test_completion.py | 22 - tests/test_parsing.py | 108 ----- tests_isolated/test_commandset/conftest.py | 3 +- .../test_commandset/test_commandset.py | 8 +- 23 files changed, 114 insertions(+), 931 deletions(-) rename docs/features/{shortcuts_aliases_macros.md => shortcuts_aliases.md} (56%) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3e06fa4..78ec8717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 3.0.0 (TBD) + +- Breaking Change + - Removed macros + ## 2.7.0 (June 30, 2025) - Enhancements diff --git a/README.md b/README.md index 5271480f..221257ab 100755 --- a/README.md +++ b/README.md @@ -69,10 +69,10 @@ first pillar of 'ease of command discovery'. The following is a list of features -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 diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 898aad07..d0de8782 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -38,7 +38,6 @@ import os import pprint import pydoc -import re import sys import tempfile import threading @@ -116,8 +115,6 @@ single_line_format, ) from .parsing import ( - Macro, - MacroArg, Statement, StatementParser, shlex_split, @@ -431,9 +428,6 @@ 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] = {} - # Keeps track of typed command history in the Python shell self._py_history: list[str] = [] @@ -479,7 +473,7 @@ 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 = '' @@ -550,7 +544,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: Callable[[str], str] = Cmd.ALPHABETICAL_SORT_KEY @@ -823,11 +817,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: @@ -2060,12 +2049,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) @@ -2091,8 +2076,7 @@ def _perform_completion( else: completer_func = self.completedefault # type: ignore[assignment] - # Not a recognized macro or command - # Check if this command should be run as a shell command + # Not a recognized command. Check if it should be run as a shell command. elif self.default_to_shell and command in utils.get_exes_in_path(command): completer_func = self.path_complete else: @@ -2250,8 +2234,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) @@ -2339,19 +2323,6 @@ 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) @@ -2365,12 +2336,11 @@ 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]: """Return a list of help topics.""" @@ -2509,7 +2479,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) @@ -2753,99 +2723,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 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: - 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 |. @@ -3014,7 +2891,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: @@ -3340,8 +3217,7 @@ 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 = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_description) alias_parser.add_subparsers(metavar='SUBCOMMAND', required=True) # Preserve quotes since we are passing strings to other commands @@ -3374,7 +3250,7 @@ def do_alias(self, args: argparse.Namespace) -> None: ) 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 + 'command', help='what the alias resolves to', choices_provider=_get_commands_and_aliases_for_completion ) alias_create_parser.add_argument( 'command_args', nargs=argparse.REMAINDER, help='arguments to pass to command', completer=path_complete @@ -3395,10 +3271,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) @@ -3499,237 +3371,6 @@ 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() - - try: - while True: - 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) - max_arg_num = max(max_arg_num, cur_num) - - arg_list.append(MacroArg(start_index=cur_match.start(), number_str=cur_num_str, is_escaped=False)) - except StopIteration: - pass - - # 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) - - try: - while True: - 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: - pass - - # 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) - - to_list = utils.remove_duplicates(args.names) if args.names else 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]: """Completes the command argument of help.""" # Complete token against topics and visible commands @@ -4651,7 +4292,7 @@ def do_ipy(self, _: argparse.Namespace) -> Optional[bool]: # pragma: no cover '-x', '--expanded', action='store_true', - help='output fully parsed commands with any aliases and\nmacros expanded, instead of typed commands', + help='output fully parsed commands with aliases and shortcuts expanded', ) history_format_group.add_argument( '-v', diff --git a/cmd2/parsing.py b/cmd2/parsing.py index e12f799c..e488aad1 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -35,56 +35,6 @@ def shlex_split(str_to_split: str) -> list[str]: return shlex.split(str_to_split, comments=False, posix=False) -@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: 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:]``. """ diff --git a/docs/examples/first_app.md b/docs/examples/first_app.md index 86efd70f..64e1c1c0 100644 --- a/docs/examples/first_app.md +++ b/docs/examples/first_app.md @@ -7,7 +7,7 @@ Here's a quick walkthrough of a simple application which demonstrates 8 features - [Argument Processing](../features/argument_processing.md) - [Generating Output](../features/generating_output.md) - [Help](../features/help.md) -- [Shortcuts](../features/shortcuts_aliases_macros.md#shortcuts) +- [Shortcuts](../features/shortcuts_aliases.md#shortcuts) - [Multiline Commands](../features/multiline_commands.md) - [History](../features/history.md) @@ -166,10 +166,9 @@ With those few lines of code, we created a [command](../features/commands.md), u ## Shortcuts `cmd2` has several capabilities to simplify repetitive user input: -[Shortcuts, Aliases, and Macros](../features/shortcuts_aliases_macros.md). Let's add a shortcut to -our application. Shortcuts are character strings that can be used instead of a command name. For -example, `cmd2` has support for a shortcut `!` which runs the `shell` command. So instead of typing -this: +[Shortcuts and Aliases](../features/shortcuts_aliases.md). Let's add a shortcut to our application. +Shortcuts are character strings that can be used instead of a command name. For example, `cmd2` has +support for a shortcut `!` which runs the `shell` command. So instead of typing this: ```shell (Cmd) shell ls -al diff --git a/docs/features/builtin_commands.md b/docs/features/builtin_commands.md index ed0e2479..42822a53 100644 --- a/docs/features/builtin_commands.md +++ b/docs/features/builtin_commands.md @@ -9,7 +9,7 @@ to be part of the application. ### alias This command manages aliases via subcommands `create`, `delete`, and `list`. See -[Aliases](shortcuts_aliases_macros.md#aliases) for more information. +[Aliases](shortcuts_aliases.md#aliases) for more information. ### edit @@ -38,12 +38,6 @@ history. See [History](history.md) for more information. This optional opt-in command enters an interactive IPython shell. See [IPython (optional)](./embedded_python_shells.md#ipython-optional) for more information. -### macro - -This command manages macros via subcommands `create`, `delete`, and `list`. A macro is similar to an -alias, but it can contain argument placeholders. See [Macros](./shortcuts_aliases_macros.md#macros) -for more information. - ### py This command invokes a Python command or shell. See @@ -114,8 +108,8 @@ Execute a command as if at the operating system shell prompt: ### shortcuts -This command lists available shortcuts. See [Shortcuts](./shortcuts_aliases_macros.md#shortcuts) for -more information. +This command lists available shortcuts. See [Shortcuts](./shortcuts_aliases.md#shortcuts) for more +information. ## Remove Builtin Commands diff --git a/docs/features/commands.md b/docs/features/commands.md index 5497ce44..2693add3 100644 --- a/docs/features/commands.md +++ b/docs/features/commands.md @@ -61,7 +61,7 @@ backwards compatibility. - quoted arguments - output redirection and piping - multi-line commands -- shortcut, macro, and alias expansion +- shortcut and alias expansion In addition to parsing all of these elements from the user input, `cmd2` also has code to make all of these items work; it's almost transparent to you and to the commands you write in your own diff --git a/docs/features/help.md b/docs/features/help.md index 56a47b3b..aa2e9d70 100644 --- a/docs/features/help.md +++ b/docs/features/help.md @@ -14,8 +14,8 @@ command. The `help` command by itself displays a list of the commands available: Documented commands (use 'help -v' for verbose/'help ' for details): =========================================================================== -alias help ipy py run_pyscript set shortcuts -edit history macro quit run_script shell +alias help ipy quit run_script shell +edit history py run_pyscript set shortcuts ``` The `help` command can also be used to provide detailed help for a specific command: @@ -53,8 +53,8 @@ By default, the `help` command displays: Documented commands (use 'help -v' for verbose/'help ' for details): =========================================================================== - alias help ipy py run_pyscript set shortcuts - edit history macro quit run_script shell + alias help ipy quit run_script shell + edit history py run_pyscript set shortcuts If you have a large number of commands, you can optionally group your commands into categories. Here's the output from the example `help_categories.py`: @@ -80,8 +80,8 @@ Here's the output from the example `help_categories.py`: Other ===== - alias edit history py run_pyscript set shortcuts - config help macro quit run_script shell version + alias edit history run_pyscript set shortcuts + config help quit run_script shell version There are 2 methods of specifying command categories, using the `@with_category` decorator or with the `categorize()` function. Once a single command category is detected, the help output switches to @@ -137,51 +137,54 @@ categories with per-command Help Messages: Documented commands (use 'help -v' for verbose/'help ' for details): Application Management - ================================================================================ - deploy Deploy command - expire Expire command - findleakers Find Leakers command - list List command - redeploy Redeploy command - restart usage: restart [-h] {now,later,sometime,whenever} - sessions Sessions command - start Start command - stop Stop command - undeploy Undeploy command + ====================================================================================================== + deploy Deploy command. + expire Expire command. + findleakers Find Leakers command. + list List command. + redeploy Redeploy command. + restart Restart + sessions Sessions command. + start Start + stop Stop command. + undeploy Undeploy command. + + Command Management + ====================================================================================================== + disable_commands Disable the Application Management commands. + enable_commands Enable the Application Management commands. Connecting - ================================================================================ - connect Connect command - which Which command + ====================================================================================================== + connect Connect command. + which Which command. Server Information - ================================================================================ - resources Resources command - serverinfo Server Info command - sslconnectorciphers SSL Connector Ciphers command is an example of a command that contains - multiple lines of help information for the user. Each line of help in a - contiguous set of lines will be printed and aligned in the verbose output - provided with 'help --verbose' - status Status command - thread_dump Thread Dump command - vminfo VM Info command + ====================================================================================================== + resources Resources command. + serverinfo Server Info command. + sslconnectorciphers SSL Connector Ciphers command is an example of a command that contains + multiple lines of help information for the user. Each line of help in a + contiguous set of lines will be printed and aligned in the verbose output + provided with 'help --verbose'. + status Status command. + thread_dump Thread Dump command. + vminfo VM Info command. Other - ================================================================================ - alias Manage aliases - config Config command - edit Run a text editor and optionally open a file with it - help List available commands or provide detailed help for a specific command - history View, run, edit, save, or clear previously entered commands - macro Manage macros - py Invoke Python command or shell - quit Exits this application - run_pyscript Runs a python script file inside the console - run_script Runs commands in script file that is encoded as either ASCII or UTF-8 text - set Set a settable parameter or show current settings of parameters - shell Execute a command as if at the OS prompt - shortcuts List available shortcuts - version Version command + ====================================================================================================== + alias Manage aliases + config Config command. + edit Run a text editor and optionally open a file with it + help List available commands or provide detailed help for a specific command + history View, run, edit, save, or clear previously entered commands + quit Exit this application + run_pyscript Run a Python script file inside the console + run_script Run commands in script file that is encoded as either ASCII or UTF-8 text + set Set a settable parameter or show current settings of parameters. + shell Execute a command as if at the OS prompt + shortcuts List available shortcuts + version Version command. When called with the `-v` flag for verbose help, the one-line description for each command is provided by the first line of the docstring for that command's associated `do_*` method. diff --git a/docs/features/history.md b/docs/features/history.md index 59ecf5f1..bc98dcf4 100644 --- a/docs/features/history.md +++ b/docs/features/history.md @@ -198,8 +198,8 @@ without line numbers, so you can copy them to the clipboard: (Cmd) history -s 1:3 -`cmd2` supports both aliases and macros, which allow you to substitute a short, more convenient -input string with a longer replacement string. Say we create an alias like this, and then use it: +`cmd2` supports aliases which allow you to substitute a short, more convenient input string with a +longer replacement string. Say we create an alias like this, and then use it: (Cmd) alias create ls shell ls -aF Alias 'ls' created @@ -212,7 +212,7 @@ By default, the `history` command shows exactly what we typed: 1 alias create ls shell ls -aF 2 ls -d h* -There are two ways to modify that display so you can see what aliases and macros were expanded to. +There are two ways to modify the display so you can see what aliases and shortcuts were expanded to. The first is to use `-x` or `--expanded`. These options show the expanded command instead of the entered command: @@ -229,5 +229,5 @@ option: 2x shell ls -aF -d h* If the entered command had no expansion, it is displayed as usual. However, if there is some change -as the result of expanding macros and aliases, then the entered command is displayed with the -number, and the expanded command is displayed with the number followed by an `x`. +as the result of expanding aliases, then the entered command is displayed with the number, and the +expanded command is displayed with the number followed by an `x`. diff --git a/docs/features/index.md b/docs/features/index.md index 13f99715..13ea9afe 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -24,7 +24,7 @@ - [Output Redirection and Pipes](redirection.md) - [Scripting](scripting.md) - [Settings](settings.md) -- [Shortcuts, Aliases, and Macros](shortcuts_aliases_macros.md) +- [Shortcuts and Aliases](shortcuts_aliases.md) - [Startup Commands](startup_commands.md) - [Table Creation](table_creation.md) - [Transcripts](transcripts.md) diff --git a/docs/features/initialization.md b/docs/features/initialization.md index 279238f0..85735b87 100644 --- a/docs/features/initialization.md +++ b/docs/features/initialization.md @@ -92,7 +92,7 @@ The `cmd2.Cmd` class provides a large number of public instance attributes which ### Public instance attributes -Here are instance attributes of `cmd2.Cmd` which developers might wish override: +Here are instance attributes of `cmd2.Cmd` which developers might wish to override: - **always_show_hint**: if `True`, display tab completion hint even when completion suggestions print (Default: `False`) - **broken_pipe_warning**: if non-empty, this string will be displayed if a broken pipe error occurs @@ -112,7 +112,6 @@ Here are instance attributes of `cmd2.Cmd` which developers might wish override: - **help_error**: the error that prints when no help information can be found - **hidden_commands**: commands to exclude from the help menu and tab completion - **last_result**: stores results from the last command run to enable usage of results in a Python script or interactive console. Built-in commands don't make use of this. It is purely there for user-defined commands and convenience. -- **macros**: dictionary of macro names and their values - **max_completion_items**: max number of CompletionItems to display during tab completion (Default: 50) - **pager**: sets the pager command used by the `Cmd.ppaged()` method for displaying wrapped output using a pager - **pager_chop**: sets the pager command used by the `Cmd.ppaged()` method for displaying chopped/truncated output using a pager diff --git a/docs/features/os.md b/docs/features/os.md index d1da31bf..83ffe6de 100644 --- a/docs/features/os.md +++ b/docs/features/os.md @@ -10,8 +10,8 @@ See [Output Redirection and Pipes](./redirection.md#output-redirection-and-pipes (Cmd) shell ls -al -If you use the default [Shortcuts](./shortcuts_aliases_macros.md#shortcuts) defined in `cmd2` you'll -get a `!` shortcut for `shell`, which allows you to type: +If you use the default [Shortcuts](./shortcuts_aliases.md#shortcuts) defined in `cmd2` you'll get a +`!` shortcut for `shell`, which allows you to type: (Cmd) !ls -al @@ -89,8 +89,8 @@ shell, and execute those commands before entering the command loop: Documented commands (use 'help -v' for verbose/'help ' for details): =========================================================================== - alias help macro orate quit run_script set shortcuts - edit history mumble py run_pyscript say shell speak + alias help ipy quit run_script shell + edit history py run_pyscript set shortcuts (Cmd) diff --git a/docs/features/shortcuts_aliases_macros.md b/docs/features/shortcuts_aliases.md similarity index 56% rename from docs/features/shortcuts_aliases_macros.md rename to docs/features/shortcuts_aliases.md index 9c87ec44..17642ace 100644 --- a/docs/features/shortcuts_aliases_macros.md +++ b/docs/features/shortcuts_aliases.md @@ -1,4 +1,4 @@ -# Shortcuts, Aliases, and Macros +# Shortcuts and Aliases ## Shortcuts @@ -26,7 +26,7 @@ class App(Cmd): Shortcuts need to be created by updating the `shortcuts` dictionary attribute prior to calling the `cmd2.Cmd` super class `__init__()` method. Moreover, that super class init method needs to be called after updating the `shortcuts` attribute This warning applies in general to many other attributes which are not settable at runtime. -Note: Command, alias, and macro names cannot start with a shortcut +Note: Command and alias names cannot start with a shortcut ## Aliases @@ -57,38 +57,4 @@ Use `alias delete` to remove aliases For more details run: `help alias delete` -Note: Aliases cannot have the same name as a command or macro - -## Macros - -`cmd2` provides a feature that is similar to aliases called macros. The major difference between -macros and aliases is that macros can contain argument placeholders. Arguments are expressed when -creating a macro using {#} notation where {1} means the first argument. - -The following creates a macro called my[macro]{#macro} that expects two arguments: - - macro create my[macro]{#macro} make[dinner]{#dinner} -meat {1} -veggie {2} - -When the macro is called, the provided arguments are resolved and the assembled command is run. For -example: - - my[macro]{#macro} beef broccoli ---> make[dinner]{#dinner} -meat beef -veggie broccoli - -Similar to aliases, pipes and redirectors need to be quoted in the definition of a macro: - - macro create lc !cat "{1}" "|" less - -To use the literal string `{1}` in your command, escape it this way: `{{1}}`. Because macros do not -resolve until after hitting ``, tab completion will only complete paths while typing a macro. - -For more details run: `help macro create` - -The macro command has `list` and `delete` subcommands that function identically to the alias -subcommands of the same name. Like aliases, macros can be created via a `cmd2` startup script to -preserve them across application sessions. - -For more details on listing macros run: `help macro list` - -For more details on deleting macros run: `help macro delete` - -Note: Macros cannot have the same name as a command or alias +Note: Aliases cannot have the same name as a command diff --git a/docs/migrating/incompatibilities.md b/docs/migrating/incompatibilities.md index 030959d1..1b5e3f99 100644 --- a/docs/migrating/incompatibilities.md +++ b/docs/migrating/incompatibilities.md @@ -28,7 +28,7 @@ and arguments on whitespace. We opted for this breaking change because while characters in command names while simultaneously using `identchars` functionality can be somewhat painful. Requiring white space to delimit arguments also ensures reliable operation of many other useful `cmd2` features, including [Tab Completion](../features/completion.md) and -[Shortcuts, Aliases, and Macros](../features/shortcuts_aliases_macros.md). +[Shortcuts and Aliases](../features/shortcuts_aliases.md). If you really need this functionality in your app, you can add it back in by writing a [Postparsing Hook](../features/hooks.md#postparsing-hooks). diff --git a/docs/migrating/why.md b/docs/migrating/why.md index 060ef0c0..44fbe2a8 100644 --- a/docs/migrating/why.md +++ b/docs/migrating/why.md @@ -37,8 +37,8 @@ and capabilities, without you having to do anything: Before you do, you might consider the various ways `cmd2` has of [Generatoring Output](../features/generating_output.md). - Users can load script files, which contain a series of commands to be executed. -- Users can create [Shortcuts, Aliases, and Macros](../features/shortcuts_aliases_macros.md) to - reduce the typing required for repetitive commands. +- Users can create [Shortcuts and Aliases](../features/shortcuts_aliases.md) to reduce the typing + required for repetitive commands. - Embedded python shell allows a user to execute python code from within your `cmd2` app. How meta. - [Clipboard Integration](../features/clipboard.md) allows you to save command output to the operating system clipboard. diff --git a/examples/help_categories.py b/examples/help_categories.py index 7a9b4aca..8b213c74 100755 --- a/examples/help_categories.py +++ b/examples/help_categories.py @@ -35,6 +35,9 @@ class HelpCategories(cmd2.Cmd): def __init__(self) -> None: super().__init__() + # Set the default category for uncategorized commands + self.default_category = 'Other' + def do_connect(self, _) -> None: """Connect command.""" self.poutput('Connect') diff --git a/mkdocs.yml b/mkdocs.yml index 77a3d3d7..a8090308 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -183,7 +183,7 @@ nav: - features/redirection.md - features/scripting.md - features/settings.md - - features/shortcuts_aliases_macros.md + - features/shortcuts_aliases.md - features/startup_commands.md - features/table_creation.md - features/transcripts.md diff --git a/tests/conftest.py b/tests/conftest.py index b9c64375..0f745047 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -72,8 +72,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/test_cmd2.py b/tests/test_cmd2.py index 8e23b7ab..ee666784 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1616,8 +1616,8 @@ def test_multiline_complete_statement_with_unclosed_quotes(multiline_app) -> Non assert statement.terminator == ';' -def test_multiline_input_line_to_statement(multiline_app) -> None: - # Verify _input_line_to_statement saves the fully entered input line for multiline commands +def test_multiline_complete_statement(multiline_app) -> None: + # 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 @@ -1625,7 +1625,7 @@ def test_multiline_input_line_to_statement(multiline_app) -> None: 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' @@ -2002,8 +2002,7 @@ def test_poutput_ansi_never(outsim_app) -> None: assert out == expected -# These are invalid names for aliases and macros -invalid_command_name = [ +invalid_alias_names = [ '""', # Blank name constants.COMMENT_CHAR, '!no_shortcut', @@ -2029,19 +2028,6 @@ def test_get_alias_completion_items(base_app) -> None: assert cur_res.description.rstrip() == base_app.aliases[cur_res] -def test_get_macro_completion_items(base_app) -> None: - 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) -> None: results = base_app._get_settable_completion_items() assert len(results) == len(base_app.settables) @@ -2117,7 +2103,7 @@ def test_alias_create_with_quoted_tokens(base_app) -> None: 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) -> None: out, err = run_cmd(base_app, f'alias create {alias_name} help') assert "Invalid alias name" in err[0] @@ -2130,14 +2116,6 @@ def test_alias_create_with_command_name(base_app) -> None: assert base_app.last_result is False -def test_alias_create_with_macro_name(base_app) -> None: - macro = "my_macro" - run_cmd(base_app, f'macro create {macro} help') - out, err = run_cmd(base_app, f'alias create {macro} help') - 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) -> None: # Create the alias out, err = run_cmd(base_app, 'alias create fake ' + constants.COMMENT_CHAR + ' blah blah') @@ -2196,228 +2174,6 @@ def test_multiple_aliases(base_app) -> None: verify_help_text(base_app, out) -def test_macro_no_subcommand(base_app) -> None: - 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) -> None: - # 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) -> None: - """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) -> None: - out, err = run_cmd(base_app, f'macro create {macro_name} help') - assert "Invalid macro name" in err[0] - assert base_app.last_result is False - - -def test_macro_create_with_command_name(base_app) -> None: - 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) -> None: - macro = "my_macro" - run_cmd(base_app, f'alias create {macro} help') - out, err = run_cmd(base_app, f'macro create {macro} help') - 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) -> None: - # 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) -> None: - # 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) -> None: - # 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) -> None: - # 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) -> None: - # 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) -> None: - # 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) -> None: - # 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) -> None: - 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) -> None: - # 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) -> None: - # 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) -> None: - # 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) -> None: - 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) -> None: - 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) -> None: - 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) -> None: - macro1 = 'h1' - macro2 = 'h2' - run_cmd(base_app, f'macro create {macro1} help') - run_cmd(base_app, f'macro create {macro2} help -v') - 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) -> None: - 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) -> None: msg = 'testing...' @@ -2557,7 +2313,6 @@ def test_get_all_commands(base_app) -> None: 'help', 'history', 'ipy', - 'macro', 'py', 'quit', 'run_pyscript', diff --git a/tests/test_completion.py b/tests/test_completion.py index 1d9e9256..2361c222 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -24,8 +24,6 @@ from .conftest import ( complete_tester, - normalize, - run_cmd, ) # List of strings used with completion functions @@ -182,26 +180,6 @@ def test_complete_exception(cmd2_app, capsys) -> None: assert "IndexError" in err -def test_complete_macro(base_app, request) -> None: - # 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 = f'fake {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 - assert base_app.completion_matches == expected - - def test_default_sort_key(cmd2_app) -> None: text = '' line = f'test_sort_key {text}' diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 711868ca..969d00d7 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -1039,111 +1039,3 @@ def test_is_valid_command_valid(parser) -> None: valid, errmsg = parser.is_valid_command('!subcmd', is_subcommand=True) assert valid assert not errmsg - - -def test_macro_normal_arg_pattern() -> None: - # 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() -> None: - # 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_isolated/test_commandset/conftest.py b/tests_isolated/test_commandset/conftest.py index 171f4a29..6a3d66ab 100644 --- a/tests_isolated/test_commandset/conftest.py +++ b/tests_isolated/test_commandset/conftest.py @@ -74,8 +74,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_commandset.py b/tests_isolated/test_commandset/test_commandset.py index 7498e145..9ea4eb3b 100644 --- a/tests_isolated/test_commandset/test_commandset.py +++ b/tests_isolated/test_commandset/test_commandset.py @@ -343,17 +343,17 @@ def test_load_commandset_errors(command_sets_manual, capsys) -> None: 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 aliases with names which conflict with commands + 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): From 3bb47c471f639483e24898b3554b3f38ed8f330b Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 5 Jul 2025 01:39:36 -0400 Subject: [PATCH 03/89] All built-in commands now use a function to create their argument parser. This simplifies the process for overriding cmd2's default parser class. --- CHANGELOG.md | 8 +- cmd2/__init__.py | 42 +-- cmd2/argparse_custom.py | 17 +- cmd2/cmd2.py | 490 +++++++++++++++++++------------- examples/custom_parser.py | 28 +- examples/help_categories.py | 15 +- examples/override_parser.py | 23 -- plugins/ext_test/pyproject.toml | 9 - pyproject.toml | 3 - tests/test_argparse_custom.py | 23 -- tests/test_history.py | 12 +- 11 files changed, 366 insertions(+), 304 deletions(-) delete mode 100755 examples/override_parser.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 78ec8717..3f957308 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,14 @@ ## 3.0.0 (TBD) -- Breaking Change +- Breaking Changes + - 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/main/examples/custom_parser.py) + example for more details. + ## 2.7.0 (June 30, 2025) - Enhancements diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 09962e79..b6b56682 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -1,13 +1,12 @@ """Import certain things for backwards compatibility.""" -import argparse import contextlib import importlib.metadata as importlib_metadata -import sys with contextlib.suppress(importlib_metadata.PackageNotFoundError): __version__ = importlib_metadata.version(__name__) +from . import plugin from .ansi import ( Bg, Cursor, @@ -19,6 +18,7 @@ TextStyle, style, ) +from .argparse_completer import set_default_ap_completer_type from .argparse_custom import ( Cmd2ArgumentParser, Cmd2AttributeWrapper, @@ -26,21 +26,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. -cmd2_parser_module = getattr(argparse, 'cmd2_parser_module', None) -if cmd2_parser_module is not None: - import importlib - - importlib.import_module(cmd2_parser_module) - -from . import plugin -from .argparse_completer import set_default_ap_completer_type from .cmd2 import Cmd -from .command_definition import CommandSet, with_default_category -from .constants import COMMAND_NAME, DEFAULT_SHORTCUTS -from .decorators import as_subcommand_to, with_argparser, with_argument_list, with_category +from .command_definition import ( + CommandSet, + with_default_category, +) +from .constants import ( + COMMAND_NAME, + DEFAULT_SHORTCUTS, +) +from .decorators import ( + as_subcommand_to, + with_argparser, + with_argument_list, + with_category, +) from .exceptions import ( Cmd2ArgparseError, CommandSetRegistrationError, @@ -50,7 +50,12 @@ ) from .parsing import Statement from .py_bridge import CommandResult -from .utils import CompletionMode, CustomCompletionSettings, Settable, categorize +from .utils import ( + CompletionMode, + CustomCompletionSettings, + Settable, + categorize, +) __all__: list[str] = [ # noqa: RUF022 'COMMAND_NAME', @@ -70,8 +75,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', @@ -87,6 +92,7 @@ 'Cmd2ArgparseError', 'CommandSetRegistrationError', 'CompletionError', + 'PassThroughException', 'SkipPostcommandHooks', # modules 'plugin', diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index d67ccbe8..b20b155f 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -1378,15 +1378,20 @@ def set(self, new_val: Any) -> None: self.__attribute = new_val -# The default ArgumentParser class for a cmd2 app -DEFAULT_ARGUMENT_PARSER: type[argparse.ArgumentParser] = Cmd2ArgumentParser +# Parser type used by cmd2's built-in commands. +# Set it using cmd2.set_default_argument_parser_type(). +DEFAULT_ARGUMENT_PARSER: type[Cmd2ArgumentParser] = Cmd2ArgumentParser -def set_default_argument_parser_type(parser_type: type[argparse.ArgumentParser]) -> None: - """Set the default ArgumentParser class for a cmd2 app. +def set_default_argument_parser_type(parser_type: type[Cmd2ArgumentParser]) -> None: + """Set the default ArgumentParser class for cmd2's built-in commands. - 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. + 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 # noqa: PLW0603 DEFAULT_ARGUMENT_PARSER = parser_type diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index d0de8782..bccfb888 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -77,6 +77,7 @@ ) from .argparse_custom import ( ChoicesProviderFunc, + Cmd2ArgumentParser, CompleterFunc, CompletionItem, ) @@ -3216,12 +3217,16 @@ 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_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_description) - 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.""" # Call handler for whatever subcommand was selected @@ -3231,32 +3236,42 @@ def do_alias(self, args: argparse.Namespace) -> None: # alias -> create alias_create_description = "Create or overwrite an alias" - alias_create_epilog = ( - "Notes:\n" - " If you want to use redirection, pipes, or terminators in the value of the\n" - " alias, then quote them.\n" - "\n" - " Since aliases are resolved during parsing, tab completion will function as\n" - " it would for the actual command the alias resolves to.\n" - "\n" - "Examples:\n" - " alias create ls !ls -lF\n" - " alias create show_log !cat \"log file.txt\"\n" - " alias create save_results print_results \">\" out.txt\n" - ) + @classmethod + def _build_alias_create_parser(cls) -> Cmd2ArgumentParser: + alias_create_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" + ) - 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_and_aliases_for_completion - ) - alias_create_parser.add_argument( - 'command_args', nargs=argparse.REMAINDER, help='arguments to pass to command', completer=path_complete - ) + alias_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( + description=cls.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=cls._get_commands_and_aliases_for_completion, + ) + alias_create_parser.add_argument( + 'command_args', + nargs=argparse.REMAINDER, + help='arguments to pass to command', + completer=cls.path_complete, + ) + + return alias_create_parser - @as_subcommand_to('alias', 'create', alias_create_parser, help=alias_create_description.lower()) + @as_subcommand_to('alias', 'create', _build_alias_create_parser, help=alias_create_description.lower()) def _alias_create(self, args: argparse.Namespace) -> None: """Create or overwrite an alias.""" self.last_result = False @@ -3289,20 +3304,23 @@ def _alias_create(self, args: argparse.Namespace) -> None: self.last_result = True # alias -> delete - alias_delete_help = "delete aliases" - alias_delete_description = "Delete specified aliases or all aliases if --all is used" - - 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(), - ) + @classmethod + def _build_alias_delete_parser(cls) -> Cmd2ArgumentParser: + alias_delete_description = "Delete specified aliases or all aliases if --all is used" + + alias_delete_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_delete_description) + alias_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all aliases") + alias_delete_parser.add_argument( + 'names', + nargs=argparse.ZERO_OR_MORE, + help='alias(es) to delete', + choices_provider=cls._get_alias_completion_items, + descriptive_header=cls._alias_completion_table.generate_header(), + ) + + return alias_delete_parser - @as_subcommand_to('alias', 'delete', alias_delete_parser, help=alias_delete_help) + @as_subcommand_to('alias', 'delete', _build_alias_delete_parser, help="delete aliases") def _alias_delete(self, args: argparse.Namespace) -> None: """Delete aliases.""" self.last_result = True @@ -3322,24 +3340,27 @@ def _alias_delete(self, args: argparse.Namespace) -> None: self.perror(f"Alias '{cur_name}' does not exist") # alias -> list - alias_list_help = "list aliases" - alias_list_description = ( - "List specified aliases in a reusable form that can be saved to a startup\n" - "script to preserve aliases across sessions\n" - "\n" - "Without arguments, all aliases will be listed." - ) + @classmethod + def _build_alias_list_parser(cls) -> Cmd2ArgumentParser: + alias_list_description = ( + "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." + ) - alias_list_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_list_description) - alias_list_parser.add_argument( - 'names', - nargs=argparse.ZERO_OR_MORE, - help='alias(es) to list', - choices_provider=_get_alias_completion_items, - descriptive_header=_alias_completion_table.generate_header(), - ) + alias_list_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_list_description) + alias_list_parser.add_argument( + 'names', + nargs=argparse.ZERO_OR_MORE, + help='alias(es) to list', + choices_provider=cls._get_alias_completion_items, + descriptive_header=cls._alias_completion_table.generate_header(), + ) - @as_subcommand_to('alias', 'list', alias_list_parser, help=alias_list_help) + return alias_list_parser + + @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] @@ -3395,24 +3416,36 @@ 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 - ) + @classmethod + def _build_help_parser(cls) -> Cmd2ArgumentParser: + help_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( + description="List available commands or provide detailed help for a specific command" + ) + help_parser.add_argument( + '-v', + '--verbose', + action='store_true', + help="print a list of all commands with descriptions of each", + ) + help_parser.add_argument( + 'command', + nargs=argparse.OPTIONAL, + help="command to retrieve help for", + completer=cls.complete_help_command, + ) + help_parser.add_argument( + 'subcommands', + nargs=argparse.REMAINDER, + help="subcommand(s) to retrieve help for", + completer=cls.complete_help_subcommands, + ) + return help_parser # Get rid of cmd's complete_help() functions so ArgparseCompleter will complete the help command if getattr(cmd.Cmd, 'complete_help', None) is not None: delattr(cmd.Cmd, 'complete_help') - @with_argparser(help_parser) + @with_argparser(_build_help_parser) def do_help(self, args: argparse.Namespace) -> None: """List available commands or provide detailed help for a specific command.""" self.last_result = True @@ -3640,9 +3673,11 @@ 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.""" # Sort the shortcut tuples by name @@ -3651,11 +3686,14 @@ def do_shortcuts(self, _: argparse.Namespace) -> None: self.poutput(f"Shortcuts for other commands:\n{result}") self.last_result = True - eof_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( - description="Called when Ctrl-D is pressed", epilog=INTERNAL_COMMAND_EPILOG - ) + @classmethod + def _build_eof_parser(cls) -> Cmd2ArgumentParser: + return argparse_custom.DEFAULT_ARGUMENT_PARSER( + description="Called when Ctrl-D is pressed", + epilog=cls.INTERNAL_COMMAND_EPILOG, + ) - @with_argparser(eof_parser) + @with_argparser(_build_eof_parser) def do_eof(self, _: argparse.Namespace) -> Optional[bool]: """Quit with no arguments, called when Ctrl-D is pressed. @@ -3666,9 +3704,11 @@ 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.""" # Return True to stop the command loop @@ -3726,6 +3766,26 @@ def select(self, opts: Union[str, list[str], list[tuple[Any, Optional[str]]]], p except (ValueError, IndexError): self.poutput(f"'{response}' isn't a valid choice. Pick a number between 1 and {len(fulloptions)}:") + @classmethod + def _build_base_set_parser(cls) -> Cmd2ArgumentParser: + # When tab completing value, we recreate the set command parser with a value argument specific to + # the settable being edited. To make this easier, define a base parser with all the common elements. + set_description = ( + "Set a settable parameter or show current settings of parameters\n\n" + "Call without arguments for a list of all settable parameters with their values.\n" + "Call with just param to view that parameter's value." + ) + base_set_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=set_description) + base_set_parser.add_argument( + 'param', + nargs=argparse.OPTIONAL, + help='parameter to set or view', + choices_provider=cls._get_settable_completion_items, + descriptive_header=cls._settable_completion_table.generate_header(), + ) + + return base_set_parser + def complete_set_value( self, text: str, line: str, begidx: int, endidx: int, arg_tokens: dict[str, list[str]] ) -> list[str]: @@ -3737,7 +3797,7 @@ def complete_set_value( raise CompletionError(param + " is not a settable parameter") from exc # Create a parser with a value field based on this settable - settable_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(parents=[Cmd.set_parser_parent]) + settable_parser = self._build_base_set_parser() # Settables with choices list the values of those choices instead of the arg name # in help text and this shows in tab completion hints. Set metavar to avoid this. @@ -3757,30 +3817,22 @@ def complete_set_value( _, raw_tokens = self.tokens_for_completion(line, begidx, endidx) return completer.complete(text, line, begidx, endidx, raw_tokens[1:]) - # When tab completing value, we recreate the set command parser with a value argument specific to - # the settable being edited. To make this easier, define a parent parser with all the common elements. - set_description = ( - "Set a settable parameter or show current settings of parameters\n" - "Call without arguments for a list of all settable parameters with their values.\n" - "Call with just param to view that parameter's value." - ) - set_parser_parent = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=set_description, add_help=False) - set_parser_parent.add_argument( - 'param', - nargs=argparse.OPTIONAL, - help='parameter to set or view', - choices_provider=_get_settable_completion_items, - descriptive_header=_settable_completion_table.generate_header(), - ) + @classmethod + def _build_set_parser(cls) -> Cmd2ArgumentParser: + # Create the parser for the set command + set_parser = cls._build_base_set_parser() + set_parser.add_argument( + 'value', + nargs=argparse.OPTIONAL, + help='new value for settable', + completer=cls.complete_set_value, + suppress_tab_hint=True, + ) - # Create the parser for the set command - set_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(parents=[set_parser_parent]) - set_parser.add_argument( - 'value', nargs=argparse.OPTIONAL, help='new value for settable', completer=complete_set_value, suppress_tab_hint=True - ) + return set_parser # Preserve quotes so users can pass in quoted empty strings and flags (e.g. -h) as the value - @with_argparser(set_parser, preserve_quotes=True) + @with_argparser(_build_set_parser, preserve_quotes=True) def do_set(self, args: argparse.Namespace) -> None: """Set a settable parameter or show current settings of parameters.""" self.last_result = False @@ -3837,14 +3889,18 @@ 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 - ) + @classmethod + def _build_shell_parser(cls) -> Cmd2ArgumentParser: + shell_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Execute a command as if at the OS prompt") + shell_parser.add_argument('command', help='the command to run', completer=cls.shell_cmd_complete) + shell_parser.add_argument( + 'command_args', nargs=argparse.REMAINDER, help='arguments to pass to command', completer=cls.path_complete + ) + + return shell_parser # Preserve quotes since we are passing these strings to the shell - @with_argparser(shell_parser, preserve_quotes=True) + @with_argparser(_build_shell_parser, preserve_quotes=True) def do_shell(self, args: argparse.Namespace) -> None: """Execute a command as if at the OS prompt.""" import signal @@ -4141,9 +4197,11 @@ 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. @@ -4152,13 +4210,19 @@ def do_py(self, _: argparse.Namespace) -> Optional[bool]: # self.last_result will be set by _run_python() return self._run_python() - run_pyscript_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run a Python script file inside the console") - run_pyscript_parser.add_argument('script_path', help='path to the script file', completer=path_complete) - run_pyscript_parser.add_argument( - 'script_arguments', nargs=argparse.REMAINDER, help='arguments to pass to script', completer=path_complete - ) + @classmethod + def _build_run_pyscript_parser(cls) -> Cmd2ArgumentParser: + run_pyscript_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( + description="Run a Python script file inside the console" + ) + run_pyscript_parser.add_argument('script_path', help='path to the script file', completer=cls.path_complete) + run_pyscript_parser.add_argument( + 'script_arguments', nargs=argparse.REMAINDER, help='arguments to pass to script', completer=cls.path_complete + ) + + return run_pyscript_parser - @with_argparser(run_pyscript_parser) + @with_argparser(_build_run_pyscript_parser) def do_run_pyscript(self, args: argparse.Namespace) -> Optional[bool]: """Run a Python script file inside the console. @@ -4192,9 +4256,11 @@ 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. @@ -4266,54 +4332,68 @@ 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" - - history_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=history_description) - history_action_group = history_parser.add_mutually_exclusive_group() - history_action_group.add_argument('-r', '--run', action='store_true', help='run selected history items') - history_action_group.add_argument('-e', '--edit', action='store_true', help='edit and then run selected history items') - history_action_group.add_argument( - '-o', '--output_file', metavar='FILE', help='output commands to a script file, implies -s', completer=path_complete - ) - history_action_group.add_argument( - '-t', - '--transcript', - metavar='TRANSCRIPT_FILE', - help='create a transcript file by re-running the commands,\nimplies both -r and -s', - completer=path_complete, - ) - history_action_group.add_argument('-c', '--clear', action='store_true', help='clear all history') + @classmethod + def _build_history_parser(cls) -> Cmd2ArgumentParser: + history_description = "View, run, edit, save, or clear previously entered commands" + + history_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=history_description) + history_action_group = history_parser.add_mutually_exclusive_group() + history_action_group.add_argument('-r', '--run', action='store_true', help='run selected history items') + history_action_group.add_argument('-e', '--edit', action='store_true', help='edit and then run selected history items') + history_action_group.add_argument( + '-o', + '--output_file', + metavar='FILE', + help='output commands to a script file, implies -s', + completer=cls.path_complete, + ) + history_action_group.add_argument( + '-t', + '--transcript', + metavar='TRANSCRIPT_FILE', + help='create a transcript file by re-running the commands,\nimplies both -r and -s', + completer=cls.path_complete, + ) + history_action_group.add_argument('-c', '--clear', action='store_true', help='clear all history') + + history_format_group = history_parser.add_argument_group(title='formatting') + history_format_group.add_argument( + '-s', + '--script', + action='store_true', + help='output commands in script format, i.e. without command\nnumbers', + ) + history_format_group.add_argument( + '-x', + '--expanded', + action='store_true', + help='output fully parsed commands with aliases and shortcuts expanded', + ) + 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_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 expanded', - ) - history_format_group.add_argument( - '-v', - '--verbose', - action='store_true', - help='display history and include expanded commands if they\ndiffer from the typed command', - ) - history_format_group.add_argument( - '-a', '--all', action='store_true', help='display all commands, including ones persisted from\nprevious sessions' - ) + history_arg_help = ( + "empty all history items\n" + "a one history item by number\n" + "a..b, a:b, a:, ..b items by indices (inclusive)\n" + "string items containing string\n" + "/regex/ items matching regular expression" + ) + history_parser.add_argument('arg', nargs=argparse.OPTIONAL, help=history_arg_help) - history_arg_help = ( - "empty all history items\n" - "a one history item by number\n" - "a..b, a:b, a:, ..b items by indices (inclusive)\n" - "string items containing string\n" - "/regex/ items matching regular expression" - ) - history_parser.add_argument('arg', nargs=argparse.OPTIONAL, help=history_arg_help) + return history_parser - @with_argparser(history_parser) + @with_argparser(_build_history_parser) def do_history(self, args: argparse.Namespace) -> Optional[bool]: """View, run, edit, save, or clear previously entered commands. @@ -4325,13 +4405,11 @@ def do_history(self, args: argparse.Namespace) -> Optional[bool]: if args.verbose: # noqa: SIM102 if args.clear or args.edit or args.output_file or args.run or args.transcript or args.expanded or args.script: self.poutput("-v cannot be used with any other options") - self.poutput(self.history_parser.format_usage()) return None # -s and -x can only be used if none of these options are present: [-c -r -e -o -t] if (args.script or args.expanded) and (args.clear or args.edit or args.output_file or args.run or args.transcript): self.poutput("-s and -x cannot be used with -c, -r, -e, -o, or -t") - self.poutput(self.history_parser.format_usage()) return None if args.clear: @@ -4638,20 +4716,26 @@ def _generate_transcript( self.pfeedback(f"{commands_run} {plural} saved to transcript file '{transcript_path}'") self.last_result = True - edit_description = ( - "Run a text editor and optionally open a file with it\n" - "\n" - "The editor used is determined by a settable parameter. To set it:\n" - "\n" - " set editor (program-name)" - ) + @classmethod + def _build_edit_parser(cls) -> Cmd2ArgumentParser: + edit_description = ( + "Run a text editor and optionally open a file with it\n" + "\n" + "The editor used is determined by a settable parameter. To set it:\n" + "\n" + " set editor (program-name)" + ) - edit_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=edit_description) - edit_parser.add_argument( - 'file_path', nargs=argparse.OPTIONAL, help="optional path to a file to open in editor", completer=path_complete - ) + edit_parser = 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=cls.path_complete, + ) + return edit_parser - @with_argparser(edit_parser) + @with_argparser(_build_edit_parser) def do_edit(self, args: argparse.Namespace) -> None: """Run a text editor and optionally open a file with it.""" # self.last_result will be set by do_shell() which is called by run_editor() @@ -4689,17 +4773,25 @@ def _current_script_dir(self) -> Optional[str]: "the output of the script commands to a transcript for testing purposes.\n" ) - 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) + @classmethod + def _build_run_script_parser(cls) -> Cmd2ArgumentParser: + run_script_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=cls.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=cls.path_complete, + ) + run_script_parser.add_argument( + 'script_path', + help="path to the script file", + completer=cls.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. @@ -4762,21 +4854,25 @@ def do_run_script(self, args: argparse.Namespace) -> Optional[bool]: self._script_dir.pop() return None - relative_run_script_description = run_script_description - relative_run_script_description += ( - "\n\n" - "If this is called from within an already-running script, the filename will be\n" - "interpreted relative to the already-running script's directory." - ) + @classmethod + def _build_relative_run_script_parser(cls) -> Cmd2ArgumentParser: + relative_run_script_description = cls.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." + ) - relative_run_script_epilog = "Notes:\n This command is intended to only be used within text file scripts." + relative_run_script_epilog = "Notes:\n This command is intended to only be used within text file scripts." - 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 = argparse_custom.DEFAULT_ARGUMENT_PARSER( + description=relative_run_script_description, epilog=relative_run_script_epilog + ) + relative_run_script_parser.add_argument('file_path', help='a file path pointing to a script') + + return relative_run_script_parser - @with_argparser(relative_run_script_parser) + @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. diff --git a/examples/custom_parser.py b/examples/custom_parser.py index a79a65b8..d4c33116 100644 --- a/examples/custom_parser.py +++ b/examples/custom_parser.py @@ -1,22 +1,28 @@ -"""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') formatted_message = '' @@ -33,5 +39,13 @@ def error(self, message: str) -> None: self.exit(2, f'{formatted_message}\n\n') -# Now set the default parser for a cmd2 app -set_default_argument_parser_type(CustomParser) +if __name__ == '__main__': + import sys + + # Set the default parser type before instantiating app. + set_default_argument_parser_type(CustomParser) + + app = cmd2.Cmd(include_ipy=True, persistent_history_file='cmd2_history.dat') + app.self_in_py = True # Enable access to "self" within the py command + app.debug = True # Show traceback if/when an exception occurs + sys.exit(app.cmdloop()) diff --git a/examples/help_categories.py b/examples/help_categories.py index 8b213c74..7a187250 100755 --- a/examples/help_categories.py +++ b/examples/help_categories.py @@ -7,10 +7,7 @@ import functools import cmd2 -from cmd2 import ( - COMMAND_NAME, - argparse_custom, -) +from cmd2 import COMMAND_NAME def my_decorator(f): @@ -58,8 +55,9 @@ def do_deploy(self, _) -> None: """Deploy command.""" self.poutput('Deploy') - start_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( - description='Start', epilog='my_decorator runs even with argparse errors' + start_parser = cmd2.Cmd2ArgumentParser( + description='Start', + epilog='my_decorator runs even with argparse errors', ) start_parser.add_argument('when', choices=START_TIMES, help='Specify when to start') @@ -77,8 +75,9 @@ def do_redeploy(self, _) -> None: """Redeploy command.""" self.poutput('Redeploy') - restart_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( - description='Restart', epilog='my_decorator does not run when argparse errors' + restart_parser = cmd2.Cmd2ArgumentParser( + description='Restart', + epilog='my_decorator does not run when argparse errors', ) restart_parser.add_argument('when', choices=START_TIMES, help='Specify when to restart') diff --git a/examples/override_parser.py b/examples/override_parser.py deleted file mode 100755 index 2d4a0f9c..00000000 --- a/examples/override_parser.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python -"""The standard parser used by cmd2 built-in commands is Cmd2ArgumentParser. -The following code shows how to override it with your own parser class. -""" - -# First set a value called argparse.cmd2_parser_module with the module that defines the custom parser. -# See the code for custom_parser.py. It simply defines a parser and calls cmd2.set_default_argument_parser_type() -# with the custom parser's type. -import argparse - -argparse.cmd2_parser_module = 'custom_parser' - -# Next import from cmd2. It will import your module just before the cmd2.Cmd class file is imported -# and therefore override the parser class it uses on its commands. -from cmd2 import cmd2 # noqa: E402 - -if __name__ == '__main__': - import sys - - app = cmd2.Cmd(include_ipy=True, persistent_history_file='cmd2_history.dat') - app.self_in_py = True # Enable access to "self" within the py command - app.debug = True # Show traceback if/when an exception occurs - sys.exit(app.cmdloop()) diff --git a/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/pyproject.toml b/pyproject.toml index 4794983f..9f3990d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -259,9 +259,6 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" mccabe.max-complexity = 49 [tool.ruff.lint.per-file-ignores] -# Module level import not at top of file and unused import -"cmd2/__init__.py" = ["E402", "F401"] - # Do not call setattr with constant attribute value "cmd2/argparse_custom.py" = ["B010"] diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index bd79910e..eedd0d3e 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -240,29 +240,6 @@ def test_apcustom_required_options() -> None: assert 'required arguments' in parser.format_help() -def test_override_parser() -> None: - """Test overriding argparse_custom.DEFAULT_ARGUMENT_PARSER""" - import importlib - - from cmd2 import ( - argparse_custom, - ) - - # The standard parser is Cmd2ArgumentParser - assert Cmd2ArgumentParser == argparse_custom.DEFAULT_ARGUMENT_PARSER - - # Set our parser module and force a reload of cmd2 so it loads the module - argparse.cmd2_parser_module = 'examples.custom_parser' - importlib.reload(cmd2) - - # Verify DEFAULT_ARGUMENT_PARSER is now our CustomParser - from examples.custom_parser import ( - CustomParser, - ) - - assert CustomParser == argparse_custom.DEFAULT_ARGUMENT_PARSER - - def test_apcustom_metavar_tuple() -> None: # Test the case when a tuple metavar is used with nargs an integer > 1 parser = Cmd2ArgumentParser() diff --git a/tests/test_history.py b/tests/test_history.py index 7b2a3a7c..703966c2 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -770,9 +770,7 @@ def test_history_verbose_with_other_options(base_app) -> None: options_to_test = ['-r', '-e', '-o file', '-t file', '-c', '-x'] for opt in options_to_test: out, err = run_cmd(base_app, 'history -v ' + opt) - assert 4 <= len(out) <= 5 - assert out[0] == '-v cannot be used with any other options' - assert out[1].startswith('Usage:') + assert '-v cannot be used with any other options' in out assert base_app.last_result is False @@ -798,9 +796,7 @@ def test_history_script_with_invalid_options(base_app) -> None: options_to_test = ['-r', '-e', '-o file', '-t file', '-c'] for opt in options_to_test: out, err = run_cmd(base_app, 'history -s ' + opt) - assert 4 <= len(out) <= 5 - assert out[0] == '-s and -x cannot be used with -c, -r, -e, -o, or -t' - assert out[1].startswith('Usage:') + assert '-s and -x cannot be used with -c, -r, -e, -o, or -t' in out assert base_app.last_result is False @@ -818,9 +814,7 @@ def test_history_expanded_with_invalid_options(base_app) -> None: options_to_test = ['-r', '-e', '-o file', '-t file', '-c'] for opt in options_to_test: out, err = run_cmd(base_app, 'history -x ' + opt) - assert 4 <= len(out) <= 5 - assert out[0] == '-s and -x cannot be used with -c, -r, -e, -o, or -t' - assert out[1].startswith('Usage:') + assert '-s and -x cannot be used with -c, -r, -e, -o, or -t' in out assert base_app.last_result is False From 8cbf1fbe2371519a0f4349de3c1bddd26e5a7da7 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sat, 5 Jul 2025 07:45:18 -0400 Subject: [PATCH 04/89] Replace references to override_parser.py example with ones to custom_parser.py Also: - Delete unused chunk from plugin pyproject.toml - Upgrade version of ruff used by pre-commit --- .pre-commit-config.yaml | 2 +- CHANGELOG.md | 2 +- cmd2/argparse_custom.py | 2 +- examples/README.md | 5 +--- plugins/ext_test/pyproject.toml | 42 +++++++++++++++------------------ 5 files changed, 23 insertions(+), 30 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f2ac5356..ea28343f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.12.1" + rev: "v0.12.2" hooks: - id: ruff-format args: [--config=pyproject.toml] diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f957308..50f8125d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -699,7 +699,7 @@ - Added `read_input()` function that is used to read from stdin. Unlike the Python built-in `input()`, it also has an argument to disable tab completion while input is being entered. - Added capability to override the argument parser class used by cmd2 built-in commands. See - override_parser.py example for more details. + custom_parser.py example for more details. - Added `end` argument to `pfeedback()` to be consistent with the other print functions like `poutput()`. - Added `apply_style` to `pwarning()`. diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index b20b155f..79d17a8c 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -6,7 +6,7 @@ parser that inherits from it. This will give a consistent look-and-feel between the help/error output of built-in cmd2 commands and the app-specific commands. If you wish to override the parser used by cmd2's built-in commands, see -override_parser.py example. +custom_parser.py example. Since the new capabilities are added by patching at the argparse API level, they are available whether or not Cmd2ArgumentParser is used. However, the help diff --git a/examples/README.md b/examples/README.md index e8d5cf51..d8307558 100644 --- a/examples/README.md +++ b/examples/README.md @@ -35,8 +35,7 @@ each: - [colors.py](https://github.com/python-cmd2/cmd2/blob/main/examples/colors.py) - Show various ways of using colorized output within a cmd2 application - [custom_parser.py](https://github.com/python-cmd2/cmd2/blob/main/examples/custom_parser.py) - - Demonstrates how to create your own customer `Cmd2ArgumentParser`; used by the - `override_parser.py` example + - Demonstrates how to create your own customer `Cmd2ArgumentParser` - [decorator_example.py](https://github.com/python-cmd2/cmd2/blob/main/examples/decorator_example.py) - Shows how to use cmd2's various argparse decorators to processes command-line arguments - [default_categories.py](https://github.com/python-cmd2/cmd2/blob/main/examples/default_categories.py) @@ -78,8 +77,6 @@ each: command decorators - [modular_subcommands.py](https://github.com/python-cmd2/cmd2/blob/main/examples/modular_subcommands.py) - Shows how to dynamically add and remove subcommands at runtime using `CommandSets` -- [override-parser.py](https://github.com/python-cmd2/cmd2/blob/main/examples/override_parser.py) - - Shows how to override cmd2's default `Cmd2ArgumentParser` with your own customer parser class - [paged_output.py](https://github.com/python-cmd2/cmd2/blob/main/examples/paged_output.py) - Shows how to use output pagination within `cmd2` apps via the `ppaged` method - [persistent_history.py](https://github.com/python-cmd2/cmd2/blob/main/examples/persistent_history.py) diff --git a/plugins/ext_test/pyproject.toml b/plugins/ext_test/pyproject.toml index 5dbbd826..f46d152b 100644 --- a/plugins/ext_test/pyproject.toml +++ b/plugins/ext_test/pyproject.toml @@ -6,11 +6,11 @@ disallow_incomplete_defs = true disallow_untyped_calls = true disallow_untyped_defs = true exclude = [ - "^examples/", # examples directory + "^examples/", # examples directory "^noxfile\\.py$", # nox config file - "setup\\.py$", # any files named setup.py - "^tasks\\.py$", # tasks.py invoke config file - "^tests/", # tests directory + "setup\\.py$", # any files named setup.py + "^tasks\\.py$", # tasks.py invoke config file + "^tests/", # tests directory ] show_column_numbers = true show_error_codes = true @@ -82,7 +82,7 @@ select = [ # "EM", # flake8-errmsg # "ERA", # eradicate # "EXE", # flake8-executable - "F", # Pyflakes + "F", # Pyflakes "FA", # flake8-future-annotations # "FBT", # flake8-boolean-trap "G", # flake8-logging-format @@ -93,7 +93,7 @@ select = [ # "ISC", # flake8-implicit-str-concat # "N", # pep8-naming "NPY", # NumPy-specific rules - "PD", # pandas-vet + "PD", # pandas-vet # "PGH", # pygrep-hooks # "PIE", # flake8-pie # "PL", # Pylint @@ -119,21 +119,21 @@ select = [ ] ignore = [ # `ruff rule S101` for a description of that rule - "B904", # Within an `except` clause, raise exceptions with `raise ... from err` -- FIX ME - "B905", # `zip()` without an explicit `strict=` parameter -- FIX ME - "E501", # Line too long - "EM101", # Exception must not use a string literal, assign to variable first - "EXE001", # Shebang is present but file is not executable -- DO NOT FIX - "G004", # Logging statement uses f-string + "B904", # Within an `except` clause, raise exceptions with `raise ... from err` -- FIX ME + "B905", # `zip()` without an explicit `strict=` parameter -- FIX ME + "E501", # Line too long + "EM101", # Exception must not use a string literal, assign to variable first + "EXE001", # Shebang is present but file is not executable -- DO NOT FIX + "G004", # Logging statement uses f-string "PLC1901", # `{}` can be simplified to `{}` as an empty string is falsey - "PLW060", # Using global for `{name}` but no assignment is done -- DO NOT FIX + "PLW060", # Using global for `{name}` but no assignment is done -- DO NOT FIX "PLW2901", # PLW2901: Redefined loop variable -- FIX ME - "PT011", # `pytest.raises(Exception)` is too broad, set the `match` parameter or use a more specific exception - "PT018", # Assertion should be broken down into multiple parts - "S101", # Use of `assert` detected -- DO NOT FIX - "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes -- FIX ME - "SLF001", # Private member accessed: `_Iterator` -- FIX ME - "UP038", # Use `X | Y` in `{}` call instead of `(X, Y)` -- DO NOT FIX + "PT011", # `pytest.raises(Exception)` is too broad, set the `match` parameter or use a more specific exception + "PT018", # Assertion should be broken down into multiple parts + "S101", # Use of `assert` detected -- DO NOT FIX + "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes -- FIX ME + "SLF001", # Private member accessed: `_Iterator` -- FIX ME + "UP038", # Use `X | Y` in `{}` call instead of `(X, Y)` -- DO NOT FIX ] # Allow fix for all enabled rules (when `--fix`) is provided. @@ -145,10 +145,6 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" mccabe.max-complexity = 49 -per-file-ignores."docs/conf.py" = [ - "F401", # Unused import -] - per-file-ignores."examples/scripts/*.py" = [ "F821", # Undefined name `app` ] From bf9750b0cdf08c093fa2717957f315d1ac6b254c Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 5 Jul 2025 08:44:25 -0400 Subject: [PATCH 05/89] Fixed spelling and corrected CHANGELOG entry. --- CHANGELOG.md | 2 +- examples/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50f8125d..3f957308 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -699,7 +699,7 @@ - Added `read_input()` function that is used to read from stdin. Unlike the Python built-in `input()`, it also has an argument to disable tab completion while input is being entered. - Added capability to override the argument parser class used by cmd2 built-in commands. See - custom_parser.py example for more details. + override_parser.py example for more details. - Added `end` argument to `pfeedback()` to be consistent with the other print functions like `poutput()`. - Added `apply_style` to `pwarning()`. diff --git a/examples/README.md b/examples/README.md index d8307558..67bd4278 100644 --- a/examples/README.md +++ b/examples/README.md @@ -35,7 +35,7 @@ each: - [colors.py](https://github.com/python-cmd2/cmd2/blob/main/examples/colors.py) - Show various ways of using colorized output within a cmd2 application - [custom_parser.py](https://github.com/python-cmd2/cmd2/blob/main/examples/custom_parser.py) - - Demonstrates how to create your own customer `Cmd2ArgumentParser` + - Demonstrates how to create your own custom `Cmd2ArgumentParser` - [decorator_example.py](https://github.com/python-cmd2/cmd2/blob/main/examples/decorator_example.py) - Shows how to use cmd2's various argparse decorators to processes command-line arguments - [default_categories.py](https://github.com/python-cmd2/cmd2/blob/main/examples/default_categories.py) From 01e84ceaf4f2918ea0fe9f85af8ca41761f117e0 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sat, 5 Jul 2025 09:07:42 -0400 Subject: [PATCH 06/89] Updated .github/CODEOWNERS to reflect current project structure and recent ownership (#1459) --- .github/CODEOWNERS | 72 ++++++++++++++++++++++++---------------------- 1 file changed, 37 insertions(+), 35 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 252703bb..4f1fd9c5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -22,55 +22,57 @@ # You can also use email addresses if you prefer. #docs/* docs@example.com +# GitHub stuff +.github/* @tleonhardt + # cmd2 code -cmd2/__init__.py @tleonhardt @kotfu +cmd2/__init__.py @kmvanbrunt @tleonhardt cmd2/ansi.py @kmvanbrunt @tleonhardt cmd2/argparse_*.py @kmvanbrunt @anselor cmd2/clipboard.py @tleonhardt -cmd2/cmd2.py @tleonhardt @kmvanbrunt @kotfu +cmd2/cmd2.py @tleonhardt @kmvanbrunt cmd2/command_definition.py @anselor -cmd2/constants.py @kotfu -cmd2/decorators.py @kotfu @kmvanbrunt @anselor +cmd2/constants.py @tleonhardt @kmvanbrunt +cmd2/decorators.py @kmvanbrunt @anselor cmd2/exceptions.py @kmvanbrunt @anselor -cmd2/history.py @kotfu @tleonhardt -cmd2/parsing.py @kotfu @kmvanbrunt -cmd2/plugin.py @kotfu +cmd2/history.py @tleonhardt +cmd2/parsing.py @kmvanbrunt +cmd2/plugin.py @anselor cmd2/py_bridge.py @kmvanbrunt cmd2/rl_utils.py @kmvanbrunt cmd2/table_creator.py @kmvanbrunt -cmd2/transcript.py @kotfu -cmd2/utils.py @tleonhardt @kotfu @kmvanbrunt +cmd2/transcript.py @tleonhardt +cmd2/utils.py @tleonhardt @kmvanbrunt # Documentation -docs/* @tleonhardt @kotfu +docs/* @tleonhardt # Examples -examples/async_printing.py @kmvanbrunt -examples/environment.py @kotfu -examples/tab_*.py @kmvanbrunt -examples/modular_*.py @anselor -examples/modular_commands/* @anselor - -plugins/template/* @kotfu -plugins/ext_test/* @anselor +examples/modular* @anselor +examples/*.py @kmvanbrunt @tleonhardt -# Unit Tests -tests/pyscript/* @kmvanbrunt -tests/transcripts/* @kotfu -tests/__init__.py @kotfu -tests/conftest.py @kotfu @tleonhardt -tests/test_argparse.py @kotfu -tests/test_argparse_*.py @kmvanbrunt -tests/test_comp*.py @kmvanbrunt -tests/test_pars*.py @kotfu -tests/test_run_pyscript.py @kmvanbrunt -tests/test_transcript.py @kotfu +# Plugins +plugins/* @anselor -tests_isolated/test_commandset/* @anselor +# Unit and Integration Tests +tests/* @kmvanbrunt @tleonhardt +tests_isolated/* @anselor # Top-level project stuff -setup.py @tleonhardt @kotfu -tasks.py @kotfu - -# GitHub stuff -.github/* @tleonhardt +.coveragerc @tleonhardt +.gitignore @tleonhardt @kmvanbrunt +.pre-commit-config.yaml @tleonhardt +.prettierignore @tleonhardt +.prettierrc @tleonhardt +.readthedocs.yaml @tleonhardt +CHANGELOG.md @kmvanbrunt @tleonhardt +cmd2.png @kmvanbrunt @tleonhardt +codecov.yml @tleonhardt +LICENSE @kmvanbrunt @tleonhardt +Makefile @tleonhardt +MANIFEST.in @tleonhardt +mkdocs.yml @tleonhardt +package.json @tleonhardt +pyproject.toml @tleonhardt @kmvanbrunt +README.md @kmvanbrunt @tleonhardt +tasks.py @tleonhardt From 71c4130e787d8e191debabb049fb70c3644b5482 Mon Sep 17 00:00:00 2001 From: pdalloz <4847497+pdalloz@users.noreply.github.com> Date: Thu, 10 Jul 2025 16:49:21 +0200 Subject: [PATCH 07/89] Add Pobshell to the "Projects using cmd2" table in README (#1461) * Update README.md Add Pobshell project to table of "Projects using cmd2" * Shorten Pobshell description as requested in PR review * Fix dumb mistake in Pobshell addition to README projects section I apologise --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 221257ab..2841d53c 100755 --- a/README.md +++ b/README.md @@ -161,6 +161,7 @@ reproduce the bug. At a minimum, please state the following: | Application Name | Description | Organization or Author | | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| [Pobshell](https://github.com/pdalloz/pobshell) | A Bash‑like shell for live Python objects: `cd`, `ls`, `cat`, `find` and _CLI piping_ for object code, str values & more | [Peter Dalloz](https://www.linkedin.com/in/pdalloz) | | [CephFS Shell](https://github.com/ceph/ceph) | The Ceph File System, or CephFS, is a POSIX-compliant file system built on top of Ceph’s distributed object store | [ceph](https://ceph.com/) | | [garak](https://github.com/NVIDIA/garak) | LLM vulnerability scanner that checks if an LLM can be made to fail in a way we don't want | [NVIDIA](https://github.com/NVIDIA) | | [medusa](https://github.com/Ch0pin/medusa) | Binary instrumentation framework that that automates processes for the dynamic analysis of Android and iOS Applications | [Ch0pin](https://github.com/Ch0pin) | From beed0d16ab9ee718e4117cb95b78d360a5f482ef Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 12 Jul 2025 12:07:41 -0400 Subject: [PATCH 08/89] No longer setting parser's prog value in with_argparser() since it gets set in Cmd._build_parser(). --- CHANGELOG.md | 3 +++ cmd2/decorators.py | 8 -------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f957308..87056cee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ - Breaking Changes - Removed macros + - No longer setting parser's `prog` value in `with_argparser()` since it gets set in + `Cmd._build_parser()`. This code had previously been restored to support backward + compatibility in `cmd2` 2.0 family. - Enhancements - Simplified the process to set a custom parser for `cmd2's` built-in commands. See diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 21870cdc..61742ad3 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -393,14 +393,6 @@ def cmd_wrapper(*args: Any, **kwargs: dict[str, Any]) -> Optional[bool]: command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX) :] - if isinstance(parser, argparse.ArgumentParser): - # Set parser's prog value for backward compatibility within the cmd2 2.0 family. - # This will be removed in cmd2 3.0 since we never reference this parser object's prog value. - # Since it's possible for the same parser object to be passed into multiple with_argparser() - # calls, we only set prog on the deep copies of this parser based on the specific do_xxxx - # instance method they are associated with. - _set_parser_prog(parser, command_name) - # Set some custom attributes for this command setattr(cmd_wrapper, constants.CMD_ATTR_ARGPARSER, parser) setattr(cmd_wrapper, constants.CMD_ATTR_PRESERVE_QUOTES, preserve_quotes) From ee2c9bbb15d40b7437cfde2f7461bc6d54744ea8 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 8 Jul 2025 00:57:08 -0400 Subject: [PATCH 09/89] Added rich_utils module which is the foundation code for supporting Rich in the rest of cmd2. --- cmd2/rich_utils.py | 127 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 cmd2/rich_utils.py diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py new file mode 100644 index 00000000..c5d3264b --- /dev/null +++ b/cmd2/rich_utils.py @@ -0,0 +1,127 @@ +"""Provides common utilities to support Rich in cmd2 applications.""" + +from collections.abc import Mapping +from enum import Enum +from typing import ( + IO, + Any, + Optional, +) + +from rich.console import Console +from rich.style import ( + Style, + StyleType, +) +from rich.theme import Theme +from rich_argparse import RichHelpFormatter + + +class AllowStyle(Enum): + """Values for ``cmd2.rich_utils.allow_style``.""" + + ALWAYS = 'Always' # Always output ANSI style sequences + NEVER = 'Never' # Remove ANSI style sequences from all output + TERMINAL = 'Terminal' # Remove ANSI style sequences if the output is not going to the terminal + + def __str__(self) -> str: + """Return value instead of enum name for printing in cmd2's set command.""" + return str(self.value) + + def __repr__(self) -> str: + """Return quoted value instead of enum description for printing in cmd2's set command.""" + return repr(self.value) + + +# Controls when ANSI style sequences are allowed in output +allow_style = AllowStyle.TERMINAL + +# Default styles for cmd2 +DEFAULT_CMD2_STYLES: dict[str, StyleType] = { + "cmd2.success": Style(color="green"), + "cmd2.warning": Style(color="bright_yellow"), + "cmd2.error": Style(color="bright_red"), + "cmd2.help_header": Style(color="bright_green", bold=True), +} + +# Include default styles from RichHelpFormatter +DEFAULT_CMD2_STYLES.update(RichHelpFormatter.styles.copy()) + + +class Cmd2Theme(Theme): + """Rich theme class used by Cmd2Console.""" + + def __init__(self, styles: Optional[Mapping[str, StyleType]] = None, inherit: bool = True) -> None: + """Cmd2Theme initializer. + + :param styles: optional mapping of style names on to styles. + Defaults to None for a theme with no styles. + :param inherit: Inherit default styles. Defaults to True. + """ + cmd2_styles = DEFAULT_CMD2_STYLES.copy() if inherit else {} + if styles is not None: + cmd2_styles.update(styles) + + super().__init__(cmd2_styles, inherit=inherit) + + +# Current Rich theme used by Cmd2Console +THEME: Cmd2Theme = Cmd2Theme() + + +def set_theme(new_theme: Cmd2Theme) -> None: + """Set the Rich theme used by Cmd2Console and rich-argparse. + + :param new_theme: new theme to use. + """ + global THEME # noqa: PLW0603 + THEME = new_theme + + # Make sure the new theme has all style names included in a Cmd2Theme. + missing_names = Cmd2Theme().styles.keys() - THEME.styles.keys() + for name in missing_names: + THEME.styles[name] = Style() + + # Update rich-argparse styles + for name in RichHelpFormatter.styles.keys() & THEME.styles.keys(): + RichHelpFormatter.styles[name] = THEME.styles[name] + + +class Cmd2Console(Console): + """Rich console with characteristics appropriate for cmd2 applications.""" + + def __init__(self, file: IO[str]) -> None: + """Cmd2Console initializer. + + :param file: a file object where the console should write to + """ + kwargs: dict[str, Any] = {} + if allow_style == AllowStyle.ALWAYS: + kwargs["force_terminal"] = True + + # Turn off interactive mode if dest is not actually a terminal which supports it + tmp_console = Console(file=file) + kwargs["force_interactive"] = tmp_console.is_interactive + elif allow_style == AllowStyle.NEVER: + kwargs["force_terminal"] = False + + # Turn off automatic markup, emoji, and highlight rendering at the console level. + # You can still enable these in Console.print() calls. + super().__init__( + file=file, + tab_size=4, + markup=False, + emoji=False, + highlight=False, + theme=THEME, + **kwargs, + ) + + def on_broken_pipe(self) -> None: + """Override which raises BrokenPipeError instead of SystemExit.""" + import contextlib + + with contextlib.suppress(SystemExit): + super().on_broken_pipe() + + raise BrokenPipeError From 353cf1464ca99a0f02c302dfbc7200cac20f912f Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 12 Jul 2025 19:51:09 -0400 Subject: [PATCH 10/89] Integrated more of rich-argparse into cmd2. Upgraded several parsers for built-in commands to use rich. --- cmd2/__init__.py | 6 +- cmd2/ansi.py | 39 +-- cmd2/argparse_custom.py | 162 ++++++++++-- cmd2/cmd2.py | 242 ++++++++++-------- docs/features/argument_processing.md | 2 +- docs/features/help.md | 18 +- examples/table_creation.py | 3 +- tests/conftest.py | 22 +- tests/test_argparse.py | 20 +- tests/test_argparse_custom.py | 2 +- tests/test_cmd2.py | 62 ++--- tests/test_completion.py | 2 +- tests/transcripts/from_cmdloop.txt | 2 +- .../test_argparse_subcommands.py | 14 +- 14 files changed, 353 insertions(+), 243 deletions(-) diff --git a/cmd2/__init__.py b/cmd2/__init__.py index b6b56682..618c0472 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -6,7 +6,10 @@ with contextlib.suppress(importlib_metadata.PackageNotFoundError): __version__ = importlib_metadata.version(__name__) -from . import plugin +from . import ( + plugin, + rich_utils, +) from .ansi import ( Bg, Cursor, @@ -96,6 +99,7 @@ 'SkipPostcommandHooks', # modules 'plugin', + 'rich_utils', # Utilities 'categorize', 'CompletionMode', diff --git a/cmd2/ansi.py b/cmd2/ansi.py index cca02018..929d77bd 100644 --- a/cmd2/ansi.py +++ b/cmd2/ansi.py @@ -19,6 +19,8 @@ wcswidth, ) +from . import rich_utils + ####################################################### # Common ANSI escape sequence constants ####################################################### @@ -28,38 +30,6 @@ BEL = '\a' -class AllowStyle(Enum): - """Values for ``cmd2.ansi.allow_style``.""" - - ALWAYS = 'Always' # Always output ANSI style sequences - NEVER = 'Never' # Remove ANSI style sequences from all output - TERMINAL = 'Terminal' # Remove ANSI style sequences if the output is not going to the terminal - - def __str__(self) -> str: - """Return value instead of enum name for printing in cmd2's set command.""" - return str(self.value) - - def __repr__(self) -> str: - """Return quoted value instead of enum description for printing in cmd2's set command.""" - return repr(self.value) - - -# Controls when ANSI style sequences are allowed in output -allow_style = AllowStyle.TERMINAL -"""When using outside of a cmd2 app, set this variable to one of: - -- ``AllowStyle.ALWAYS`` - always output ANSI style sequences -- ``AllowStyle.NEVER`` - remove ANSI style sequences from all output -- ``AllowStyle.TERMINAL`` - remove ANSI style sequences if the output is not going to the terminal - -to control how ANSI style sequences are handled by ``style_aware_write()``. - -``style_aware_write()`` is called by cmd2 methods like ``poutput()``, ``perror()``, -``pwarning()``, etc. - -The default is ``AllowStyle.TERMINAL``. -""" - # Regular expression to match ANSI style sequence ANSI_STYLE_RE = re.compile(rf'{ESC}\[[^m]*m') @@ -133,8 +103,11 @@ def style_aware_write(fileobj: IO[str], msg: str) -> None: :param fileobj: the file object being written to :param msg: the string being written """ - if allow_style == AllowStyle.NEVER or (allow_style == AllowStyle.TERMINAL and not fileobj.isatty()): + if rich_utils.allow_style == rich_utils.AllowStyle.NEVER or ( + rich_utils.allow_style == rich_utils.AllowStyle.TERMINAL and not fileobj.isatty() + ): msg = strip_style(msg) + fileobj.write(msg) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 79d17a8c..41f06354 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -236,7 +236,6 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens) ) from gettext import gettext from typing import ( - IO, TYPE_CHECKING, Any, ClassVar, @@ -248,11 +247,23 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens) runtime_checkable, ) -from rich_argparse import RawTextRichHelpFormatter +from rich.console import ( + Group, + RenderableType, +) +from rich.table import Column, Table +from rich.text import Text +from rich_argparse import ( + ArgumentDefaultsRichHelpFormatter, + MetavarTypeRichHelpFormatter, + RawDescriptionRichHelpFormatter, + RawTextRichHelpFormatter, + RichHelpFormatter, +) from . import ( - ansi, constants, + rich_utils, ) if TYPE_CHECKING: # pragma: no cover @@ -759,11 +770,11 @@ def _add_argument_wrapper( # Validate nargs tuple if ( len(nargs) != 2 - or not isinstance(nargs[0], int) # type: ignore[unreachable] - or not (isinstance(nargs[1], int) or nargs[1] == constants.INFINITY) # type: ignore[misc] + or not isinstance(nargs[0], int) + or not (isinstance(nargs[1], int) or nargs[1] == constants.INFINITY) ): raise ValueError('Ranged values for nargs must be a tuple of 1 or 2 integers') - if nargs[0] >= nargs[1]: # type: ignore[misc] + if nargs[0] >= nargs[1]: raise ValueError('Invalid nargs range. The first value must be less than the second') if nargs[0] < 0: raise ValueError('Negative numbers are invalid for nargs range') @@ -771,7 +782,7 @@ def _add_argument_wrapper( # Save the nargs tuple as our range setting nargs_range = nargs range_min = nargs_range[0] - range_max = nargs_range[1] # type: ignore[misc] + range_max = nargs_range[1] # Convert nargs into a format argparse recognizes if range_min == 0: @@ -807,7 +818,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] @@ -996,13 +1007,9 @@ def _SubParsersAction_remove_parser(self: argparse._SubParsersAction, name: str) ############################################################################################################ -class Cmd2HelpFormatter(RawTextRichHelpFormatter): +class Cmd2HelpFormatter(RichHelpFormatter): """Custom help formatter to configure ordering of help text.""" - # rich-argparse formats all group names with str.title(). - # Override their formatter to do nothing. - group_name_formatter: ClassVar[Callable[[str], str]] = str - # Disable automatic highlighting in the help text. highlights: ClassVar[list[str]] = [] @@ -1015,6 +1022,22 @@ class Cmd2HelpFormatter(RawTextRichHelpFormatter): help_markup: ClassVar[bool] = False text_markup: ClassVar[bool] = False + def __init__( + self, + prog: str, + indent_increment: int = 2, + max_help_position: int = 24, + width: Optional[int] = None, + *, + console: Optional[rich_utils.Cmd2Console] = None, + **kwargs: Any, + ) -> None: + """Initialize Cmd2HelpFormatter.""" + if console is None: + console = rich_utils.Cmd2Console(sys.stdout) + + super().__init__(prog, indent_increment, max_help_position, width, console=console, **kwargs) + def _format_usage( self, usage: Optional[str], @@ -1207,6 +1230,82 @@ def _format_args(self, action: argparse.Action, default_metavar: Union[str, tupl return super()._format_args(action, default_metavar) # type: ignore[arg-type] +class RawDescriptionCmd2HelpFormatter( + RawDescriptionRichHelpFormatter, + Cmd2HelpFormatter, +): + """Cmd2 help message formatter which retains any formatting in descriptions and epilogs.""" + + +class RawTextCmd2HelpFormatter( + RawTextRichHelpFormatter, + Cmd2HelpFormatter, +): + """Cmd2 help message formatter which retains formatting of all help text.""" + + +class ArgumentDefaultsCmd2HelpFormatter( + ArgumentDefaultsRichHelpFormatter, + Cmd2HelpFormatter, +): + """Cmd2 help message formatter which adds default values to argument help.""" + + +class MetavarTypeCmd2HelpFormatter( + MetavarTypeRichHelpFormatter, + Cmd2HelpFormatter, +): + """Cmd2 help message formatter which uses the argument 'type' as the default + metavar value (instead of the argument 'dest'). + """ # noqa: D205 + + +class TextGroup: + """A block of text which is formatted like an argparse argument group, including a title. + + Title: + Here is the first row of text. + Here is yet another row of text. + """ + + def __init__( + self, + title: str, + text: RenderableType, + formatter_creator: Callable[[], Cmd2HelpFormatter], + ) -> None: + """TextGroup initializer. + + :param title: the group's title + :param text: the group's text (string or object that may be rendered by Rich) + :param formatter_creator: callable which returns a Cmd2HelpFormatter instance + """ + self.title = title + self.text = text + self.formatter_creator = formatter_creator + + def __rich__(self) -> Group: + """Perform custom rendering.""" + formatter = self.formatter_creator() + + styled_title = 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 = Table( + Column(overflow="fold"), + 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.""" @@ -1214,10 +1313,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, @@ -1247,8 +1346,8 @@ def __init__( super().__init__( prog=prog, usage=usage, - description=description, - epilog=epilog, + description=description, # type: ignore[arg-type] + epilog=epilog, # type: ignore[arg-type] parents=parents if parents else [], formatter_class=formatter_class, # type: ignore[arg-type] prefix_chars=prefix_chars, @@ -1261,6 +1360,10 @@ def __init__( **kwargs, # added in Python 3.14 ) + # Recast to assist type checkers since these can be Rich renderables in a Cmd2HelpFormatter. + self.description: 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[type-arg] @@ -1290,8 +1393,18 @@ def error(self, message: str) -> NoReturn: formatted_message += '\n ' + line self.print_usage(sys.stderr) - formatted_message = ansi.style_error(formatted_message) - self.exit(2, f'{formatted_message}\n\n') + + # Add error style to message + console = self._get_formatter().console + with console.capture() as capture: + console.print(formatted_message, style="cmd2.error", crop=False) + formatted_message = f"{capture.get()}" + + self.exit(2, f'{formatted_message}\n') + + def _get_formatter(self) -> Cmd2HelpFormatter: + """Override _get_formatter with customizations for Cmd2HelpFormatter.""" + return cast(Cmd2HelpFormatter, super()._get_formatter()) def format_help(self) -> str: """Return a string containing a help message, including the program usage and information about the arguments. @@ -1350,12 +1463,9 @@ def format_help(self) -> str: # determine help from format above return formatter.format_help() + '\n' - def _print_message(self, message: str, file: Optional[IO[str]] = None) -> None: # type: ignore[override] - # Override _print_message to use style_aware_write() since we use ANSI escape characters to support color - if message: - if file is None: - file = sys.stderr - ansi.style_aware_write(file, message) + def create_text_group(self, title: str, text: RenderableType) -> TextGroup: + """Create a TextGroup using this parser's formatter creator.""" + return TextGroup(title, text, self._get_formatter) class Cmd2AttributeWrapper: diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index bccfb888..3e316d80 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -67,12 +67,15 @@ cast, ) +from rich.console import Group + from . import ( ansi, argparse_completer, argparse_custom, constants, plugin, + rich_utils, utils, ) from .argparse_custom import ( @@ -261,8 +264,8 @@ def get(self, command_method: CommandFunc) -> Optional[argparse.ArgumentParser]: parser = self._cmd._build_parser(parent, parser_builder, command) # If the description has not been set, then use the method docstring if one exists - if parser.description is None and hasattr(command_method, '__wrapped__') and command_method.__wrapped__.__doc__: - parser.description = strip_doc_annotations(command_method.__wrapped__.__doc__) + if parser.description is None and command_method.__doc__: + parser.description = strip_doc_annotations(command_method.__doc__) self._parsers[full_method_name] = parser @@ -286,10 +289,6 @@ class Cmd(cmd.Cmd): DEFAULT_EDITOR = utils.find_editor() - INTERNAL_COMMAND_EPILOG = ( - "Notes:\n This command is for internal use and is not intended to be called from the\n command line." - ) - # Sorting keys for strings ALPHABETICAL_SORT_KEY = utils.norm_fold NATURAL_SORT_KEY = utils.natural_keys @@ -1116,16 +1115,16 @@ def build_settables(self) -> None: def get_allow_style_choices(_cli_self: Cmd) -> list[str]: """Tab complete allow_style values.""" - return [val.name.lower() for val in ansi.AllowStyle] + return [val.name.lower() for val in rich_utils.AllowStyle] - def allow_style_type(value: str) -> ansi.AllowStyle: - """Convert a string value into an ansi.AllowStyle.""" + def allow_style_type(value: str) -> rich_utils.AllowStyle: + """Convert a string value into an rich_utils.AllowStyle.""" try: - return ansi.AllowStyle[value.upper()] + return rich_utils.AllowStyle[value.upper()] except KeyError as esc: raise ValueError( - f"must be {ansi.AllowStyle.ALWAYS}, {ansi.AllowStyle.NEVER}, or " - f"{ansi.AllowStyle.TERMINAL} (case-insensitive)" + f"must be {rich_utils.AllowStyle.ALWAYS}, {rich_utils.AllowStyle.NEVER}, or " + f"{rich_utils.AllowStyle.TERMINAL} (case-insensitive)" ) from esc self.add_settable( @@ -1133,7 +1132,7 @@ def allow_style_type(value: str) -> ansi.AllowStyle: 'allow_style', allow_style_type, 'Allow ANSI text style sequences in output (valid values: ' - f'{ansi.AllowStyle.ALWAYS}, {ansi.AllowStyle.NEVER}, {ansi.AllowStyle.TERMINAL})', + f'{rich_utils.AllowStyle.ALWAYS}, {rich_utils.AllowStyle.NEVER}, {rich_utils.AllowStyle.TERMINAL})', self, choices_provider=cast(ChoicesProviderFunc, get_allow_style_choices), ) @@ -1156,14 +1155,14 @@ def allow_style_type(value: str) -> ansi.AllowStyle: # ----- Methods related to presenting output to the user ----- @property - def allow_style(self) -> ansi.AllowStyle: + def allow_style(self) -> rich_utils.AllowStyle: """Read-only property needed to support do_set when it reads allow_style.""" - return ansi.allow_style + return rich_utils.allow_style @allow_style.setter - def allow_style(self, new_val: ansi.AllowStyle) -> None: + def allow_style(self, new_val: rich_utils.AllowStyle) -> None: """Setter property needed to support do_set when it updates allow_style.""" - ansi.allow_style = new_val + rich_utils.allow_style = new_val def _completion_supported(self) -> bool: """Return whether tab completion is supported.""" @@ -1312,7 +1311,7 @@ def ppaged(self, msg: Any, *, end: str = '\n', chop: bool = False) -> None: # Also only attempt to use a pager if actually running in a real fully functional terminal. if functional_terminal and not self._redirecting and not self.in_pyscript() and not self.in_script(): final_msg = f"{msg}{end}" - if ansi.allow_style == ansi.AllowStyle.NEVER: + if rich_utils.allow_style == rich_utils.AllowStyle.NEVER: final_msg = ansi.strip_style(final_msg) pager = self.pager @@ -3219,7 +3218,11 @@ def _cmdloop(self) -> None: # Top-level parser for alias @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_description = Group( + "Manage aliases.", + "\n", + "An alias is a command that enables replacement of a word by another string.", + ) alias_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_description) alias_parser.add_subparsers(metavar='SUBCOMMAND', required=True) @@ -3234,28 +3237,38 @@ def do_alias(self, args: argparse.Namespace) -> None: handler(args) # alias -> create - alias_create_description = "Create or overwrite an alias" - @classmethod def _build_alias_create_parser(cls) -> Cmd2ArgumentParser: - 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" + alias_create_description = "Create or overwrite an alias." + alias_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_create_description) + + # Create Notes TextGroup + alias_create_notes = Group( + "If you want to use redirection, pipes, or terminators in the value of the alias, then quote them.", + "\n", + ( + "Since aliases are resolved during parsing, tab completion will function as it would " + "for the actual command the alias resolves to." + ), + ) + notes_group = alias_create_parser.create_text_group("Notes", alias_create_notes) + + # Create Examples TextGroup + alias_create_examples = Group( + "alias create ls !ls -lF", + "alias create show_log !cat \"log file.txt\"", + "alias create save_results print_results \">\" out.txt", ) + examples_group = alias_create_parser.create_text_group("Examples", alias_create_examples) - alias_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( - description=cls.alias_create_description, - epilog=alias_create_epilog, + # Display both Notes and Examples in the epilog + alias_create_parser.epilog = Group( + notes_group, + "\n", + examples_group, ) + + # Add arguments alias_create_parser.add_argument('name', help='name of this alias') alias_create_parser.add_argument( 'command', @@ -3271,7 +3284,7 @@ def _build_alias_create_parser(cls) -> Cmd2ArgumentParser: return alias_create_parser - @as_subcommand_to('alias', 'create', _build_alias_create_parser, help=alias_create_description.lower()) + @as_subcommand_to('alias', 'create', _build_alias_create_parser, help="create or overwrite an alias") def _alias_create(self, args: argparse.Namespace) -> None: """Create or overwrite an alias.""" self.last_result = False @@ -3306,7 +3319,7 @@ def _alias_create(self, args: argparse.Namespace) -> None: # alias -> delete @classmethod def _build_alias_delete_parser(cls) -> Cmd2ArgumentParser: - alias_delete_description = "Delete specified aliases or all aliases if --all is used" + 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") @@ -3342,11 +3355,13 @@ def _alias_delete(self, args: argparse.Namespace) -> None: # alias -> list @classmethod def _build_alias_list_parser(cls) -> Cmd2ArgumentParser: - 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." + alias_list_description = Group( + ( + "List specified aliases in a reusable form that can be saved to a startup " + "script to preserve aliases across sessions." + ), + "\n", + "Without arguments, all aliases will be listed.", ) alias_list_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_list_description) @@ -3419,7 +3434,7 @@ def complete_help_subcommands( @classmethod def _build_help_parser(cls) -> Cmd2ArgumentParser: help_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( - description="List available commands or provide detailed help for a specific command" + description="List available commands or provide detailed help for a specific command." ) help_parser.add_argument( '-v', @@ -3639,12 +3654,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() @@ -3675,7 +3686,7 @@ def _print_topics(self, header: str, cmds: list[str], verbose: bool) -> None: @staticmethod def _build_shortcuts_parser() -> Cmd2ArgumentParser: - return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="List available shortcuts") + return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="List available shortcuts.") @with_argparser(_build_shortcuts_parser) def do_shortcuts(self, _: argparse.Namespace) -> None: @@ -3686,13 +3697,16 @@ def do_shortcuts(self, _: argparse.Namespace) -> None: self.poutput(f"Shortcuts for other commands:\n{result}") self.last_result = True - @classmethod - def _build_eof_parser(cls) -> Cmd2ArgumentParser: - return argparse_custom.DEFAULT_ARGUMENT_PARSER( - description="Called when Ctrl-D is pressed", - epilog=cls.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.", ) + return eof_parser + @with_argparser(_build_eof_parser) def do_eof(self, _: argparse.Namespace) -> Optional[bool]: """Quit with no arguments, called when Ctrl-D is pressed. @@ -3706,7 +3720,7 @@ def do_eof(self, _: argparse.Namespace) -> Optional[bool]: @staticmethod def _build_quit_parser() -> Cmd2ArgumentParser: - return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Exit this application") + return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Exit this application.") @with_argparser(_build_quit_parser) def do_quit(self, _: argparse.Namespace) -> Optional[bool]: @@ -3770,10 +3784,13 @@ def select(self, opts: Union[str, list[str], list[tuple[Any, Optional[str]]]], p def _build_base_set_parser(cls) -> Cmd2ArgumentParser: # When tab completing value, we recreate the set command parser with a value argument specific to # the settable being edited. To make this easier, define a base parser with all the common elements. - set_description = ( - "Set a settable parameter or show current settings of parameters\n\n" - "Call without arguments for a list of all settable parameters with their values.\n" - "Call with just param to view that parameter's value." + set_description = Group( + "Set a settable parameter or show current settings of parameters.", + "\n", + ( + "Call without arguments for a list of all settable parameters with their values. " + "Call with just param to view that parameter's value." + ), ) base_set_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=set_description) base_set_parser.add_argument( @@ -3891,7 +3908,7 @@ def do_set(self, args: argparse.Namespace) -> None: @classmethod def _build_shell_parser(cls) -> Cmd2ArgumentParser: - shell_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Execute a command as if at the OS prompt") + shell_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Execute a command as if at the OS prompt.") shell_parser.add_argument('command', help='the command to run', completer=cls.shell_cmd_complete) shell_parser.add_argument( 'command_args', nargs=argparse.REMAINDER, help='arguments to pass to command', completer=cls.path_complete @@ -4199,7 +4216,7 @@ def py_quit() -> None: @staticmethod def _build_py_parser() -> Cmd2ArgumentParser: - return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive Python shell") + return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive Python shell.") @with_argparser(_build_py_parser) def do_py(self, _: argparse.Namespace) -> Optional[bool]: @@ -4213,7 +4230,7 @@ def do_py(self, _: argparse.Namespace) -> Optional[bool]: @classmethod def _build_run_pyscript_parser(cls) -> Cmd2ArgumentParser: run_pyscript_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( - description="Run a Python script file inside the console" + description="Run Python script within this application's environment." ) run_pyscript_parser.add_argument('script_path', help='path to the script file', completer=cls.path_complete) run_pyscript_parser.add_argument( @@ -4224,7 +4241,7 @@ def _build_run_pyscript_parser(cls) -> Cmd2ArgumentParser: @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 """ @@ -4258,11 +4275,11 @@ def do_run_pyscript(self, args: argparse.Namespace) -> Optional[bool]: @staticmethod def _build_ipython_parser() -> Cmd2ArgumentParser: - return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive IPython shell") + return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive IPython shell.") @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 """ @@ -4334,9 +4351,11 @@ def do_ipy(self, _: argparse.Namespace) -> Optional[bool]: # pragma: no cover @classmethod def _build_history_parser(cls) -> Cmd2ArgumentParser: - history_description = "View, run, edit, save, or clear previously entered commands" + history_description = "View, run, edit, save, or clear previously entered commands." - history_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=history_description) + history_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( + description=history_description, formatter_class=argparse_custom.RawTextCmd2HelpFormatter + ) history_action_group = history_parser.add_mutually_exclusive_group() history_action_group.add_argument('-r', '--run', action='store_true', help='run selected history items') history_action_group.add_argument('-e', '--edit', action='store_true', help='edit and then run selected history items') @@ -4351,7 +4370,7 @@ def _build_history_parser(cls) -> Cmd2ArgumentParser: '-t', '--transcript', metavar='TRANSCRIPT_FILE', - help='create a transcript file by re-running the commands,\nimplies both -r and -s', + help='create a transcript file by re-running the commands, implies both -r and -s', completer=cls.path_complete, ) history_action_group.add_argument('-c', '--clear', action='store_true', help='clear all history') @@ -4361,7 +4380,7 @@ def _build_history_parser(cls) -> Cmd2ArgumentParser: '-s', '--script', action='store_true', - help='output commands in script format, i.e. without command\nnumbers', + help='output commands in script format, i.e. without command numbers', ) history_format_group.add_argument( '-x', @@ -4373,13 +4392,13 @@ def _build_history_parser(cls) -> Cmd2ArgumentParser: '-v', '--verbose', action='store_true', - help='display history and include expanded commands if they\ndiffer from the typed command', + help='display history and include expanded commands if they differ from the typed command', ) history_format_group.add_argument( '-a', '--all', action='store_true', - help='display all commands, including ones persisted from\nprevious sessions', + help='display all commands, including ones persisted from previous sessions', ) history_arg_help = ( @@ -4718,15 +4737,15 @@ def _generate_transcript( @classmethod def _build_edit_parser(cls) -> Cmd2ArgumentParser: - 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)" - ) + from rich.markdown import Markdown + 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 `"), + ) + edit_parser.add_argument( 'file_path', nargs=argparse.OPTIONAL, @@ -4763,19 +4782,26 @@ def _current_script_dir(self) -> Optional[str]: return self._script_dir[-1] return None - run_script_description = ( - "Run commands in script file that is encoded as either ASCII or UTF-8 text\n" - "\n" - "Script should contain one command per line, just like the command would be\n" - "typed in the console.\n" - "\n" - "If the -t/--transcript flag is used, this command instead records\n" - "the output of the script commands to a transcript for testing purposes.\n" - ) + @classmethod + def _build_base_run_script_parser(cls) -> Cmd2ArgumentParser: + run_script_description = Group( + "Run text script.", + "\n", + "Scripts should contain one command per line, entered as you would in the console.", + ) + + run_script_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=run_script_description) + run_script_parser.add_argument( + 'script_path', + help="path to the script file", + completer=cls.path_complete, + ) + + return run_script_parser @classmethod def _build_run_script_parser(cls) -> Cmd2ArgumentParser: - run_script_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=cls.run_script_description) + run_script_parser = cls._build_base_run_script_parser() run_script_parser.add_argument( '-t', '--transcript', @@ -4783,17 +4809,12 @@ def _build_run_script_parser(cls) -> Cmd2ArgumentParser: help='record the output of the script as a transcript file', completer=cls.path_complete, ) - run_script_parser.add_argument( - 'script_path', - help="path to the script file", - completer=cls.path_complete, - ) return 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 """ @@ -4856,31 +4877,36 @@ def do_run_script(self, args: argparse.Namespace) -> Optional[bool]: @classmethod def _build_relative_run_script_parser(cls) -> Cmd2ArgumentParser: - relative_run_script_description = cls.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." + relative_run_script_parser = cls._build_base_run_script_parser() + + # Append to existing description + relative_run_script_parser.description = Group( + cast(Group, relative_run_script_parser.description), + "\n", + ( + "If this is called from within an already-running script, the filename will be " + "interpreted relative to the already-running script's directory." + ), ) - relative_run_script_epilog = "Notes:\n This command is intended to only be used within text file scripts." - - relative_run_script_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( - description=relative_run_script_description, epilog=relative_run_script_epilog + 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.add_argument('file_path', help='a file path pointing to a script') return 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. + """Run text script. + + This command is intended to be used from within a text script. :return: True if running of commands should stop """ - file_path = args.file_path + script_path = args.script_path # NOTE: Relative path is an absolute path, it is just relative to the current script directory - relative_path = os.path.join(self._current_script_dir or '', file_path) + relative_path = os.path.join(self._current_script_dir or '', script_path) # self.last_result will be set by do_run_script() return self.do_run_script(utils.quote_string(relative_path)) diff --git a/docs/features/argument_processing.md b/docs/features/argument_processing.md index a8dd62ba..9750a596 100644 --- a/docs/features/argument_processing.md +++ b/docs/features/argument_processing.md @@ -63,7 +63,7 @@ def do_speak(self, opts) !!! note - The `@with_argparser` decorator sets the `prog` variable in the argument parser based on the name of the method it is decorating. This will override anything you specify in `prog` variable when creating the argument parser. + `cmd2` sets the `prog` variable in the argument parser based on the name of the method it is decorating. This will override anything you specify in `prog` variable when creating the argument parser. ## Help Messages diff --git a/docs/features/help.md b/docs/features/help.md index aa2e9d70..41ce44f4 100644 --- a/docs/features/help.md +++ b/docs/features/help.md @@ -173,17 +173,17 @@ categories with per-command Help Messages: Other ====================================================================================================== - alias Manage aliases + alias Manage aliases. config Config command. - edit Run a text editor and optionally open a file with it - help List available commands or provide detailed help for a specific command - history View, run, edit, save, or clear previously entered commands - quit Exit this application - run_pyscript Run a Python script file inside the console - run_script Run commands in script file that is encoded as either ASCII or UTF-8 text + edit Run a text editor and optionally open a file with it. + help List available commands or provide detailed help for a specific command. + history View, run, edit, save, or clear previously entered commands. + quit Exit this application. + run_pyscript Run Python script within this application's environment. + run_script Run text script. set Set a settable parameter or show current settings of parameters. - shell Execute a command as if at the OS prompt - shortcuts List available shortcuts + shell Execute a command as if at the OS prompt. + shortcuts List available shortcuts. version Version command. When called with the `-v` flag for verbose help, the one-line description for each command is diff --git a/examples/table_creation.py b/examples/table_creation.py index 00a45d29..754fe972 100755 --- a/examples/table_creation.py +++ b/examples/table_creation.py @@ -10,6 +10,7 @@ EightBitFg, Fg, ansi, + rich_utils, ) from cmd2.table_creator import ( AlternatingTable, @@ -269,6 +270,6 @@ def nested_tables() -> None: if __name__ == '__main__': # Default to terminal mode so redirecting to a file won't include the ANSI style sequences - ansi.allow_style = ansi.AllowStyle.TERMINAL + rich_utils.allow_style = rich_utils.AllowStyle.TERMINAL basic_tables() nested_tables() diff --git a/tests/conftest.py b/tests/conftest.py index 0f745047..19aedaac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,39 +44,35 @@ 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 -t, --transcript TRANSCRIPT_FILE - create a transcript file by re-running the commands, - implies both -r and -s + create a transcript file by re-running the commands, implies both -r and -s -c, --clear clear all history -formatting: - -s, --script output commands in script format, i.e. without command - numbers +Formatting: + -s, --script output commands in script format, i.e. without command numbers -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 - previous sessions + -v, --verbose display history and include expanded commands if they differ from the typed command + -a, --all display all commands, including ones persisted from previous sessions """ # Output from the shortcuts command with default built-in shortcuts diff --git a/tests/test_argparse.py b/tests/test_argparse.py index ff387ecc..0ae9e724 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -33,8 +33,7 @@ def _say_parser_builder() -> cmd2.Cmd2ArgumentParser: @cmd2.with_argparser(_say_parser_builder) def do_say(self, args, *, keyword_arg: Optional[str] = None) -> None: - """Repeat what you - tell me to. + """Repeat what you tell me to. :param args: argparse namespace :param keyword_arg: Optional keyword arguments @@ -212,8 +211,7 @@ def test_argparse_help_docstring(argparse_app) -> None: out, err = run_cmd(argparse_app, 'help say') assert out[0].startswith('Usage: say') assert out[1] == '' - assert out[2] == 'Repeat what you' - assert out[3] == 'tell me to.' + assert out[2] == 'Repeat what you tell me to.' for line in out: assert not line.startswith(':') @@ -362,39 +360,39 @@ def test_subcommand_help(subcommand_app) -> None: out, err = run_cmd(subcommand_app, 'help base foo') assert out[0].startswith('Usage: base foo') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' # bar has aliases (usage should never show alias name) out, err = run_cmd(subcommand_app, 'help base bar') assert out[0].startswith('Usage: base bar') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' out, err = run_cmd(subcommand_app, 'help base bar_1') assert out[0].startswith('Usage: base bar') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' out, err = run_cmd(subcommand_app, 'help base bar_2') assert out[0].startswith('Usage: base bar') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' # helpless has aliases and no help text (usage should never show alias name) out, err = run_cmd(subcommand_app, 'help base helpless') assert out[0].startswith('Usage: base helpless') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' out, err = run_cmd(subcommand_app, 'help base helpless_1') assert out[0].startswith('Usage: base helpless') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' out, err = run_cmd(subcommand_app, 'help base helpless_2') assert out[0].startswith('Usage: base helpless') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' def test_subcommand_invalid_help(subcommand_app) -> None: diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index eedd0d3e..2472ab74 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -237,7 +237,7 @@ def test_apcustom_required_options() -> None: # Make sure a 'required arguments' section shows when a flag is marked required parser = Cmd2ArgumentParser() parser.add_argument('--required_flag', required=True) - assert 'required arguments' in parser.format_help() + assert 'Required Arguments' in parser.format_help() def test_apcustom_metavar_tuple() -> None: diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index ee666784..e641f9bd 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -24,6 +24,7 @@ constants, exceptions, plugin, + rich_utils, utils, ) from cmd2.rl_utils import ( @@ -48,12 +49,12 @@ def arg_decorator(func): @functools.wraps(func) def cmd_wrapper(*args, **kwargs): - old = ansi.allow_style - ansi.allow_style = style + old = rich_utils.allow_style + rich_utils.allow_style = style try: retval = func(*args, **kwargs) finally: - ansi.allow_style = old + rich_utils.allow_style = old return retval return cmd_wrapper @@ -222,31 +223,31 @@ def test_set_no_settables(base_app) -> None: @pytest.mark.parametrize( ('new_val', 'is_valid', 'expected'), [ - (ansi.AllowStyle.NEVER, True, ansi.AllowStyle.NEVER), - ('neVeR', True, ansi.AllowStyle.NEVER), - (ansi.AllowStyle.TERMINAL, True, ansi.AllowStyle.TERMINAL), - ('TeRMInal', True, ansi.AllowStyle.TERMINAL), - (ansi.AllowStyle.ALWAYS, True, ansi.AllowStyle.ALWAYS), - ('AlWaYs', True, ansi.AllowStyle.ALWAYS), - ('invalid', False, ansi.AllowStyle.TERMINAL), + (rich_utils.AllowStyle.NEVER, True, rich_utils.AllowStyle.NEVER), + ('neVeR', True, rich_utils.AllowStyle.NEVER), + (rich_utils.AllowStyle.TERMINAL, True, rich_utils.AllowStyle.TERMINAL), + ('TeRMInal', True, rich_utils.AllowStyle.TERMINAL), + (rich_utils.AllowStyle.ALWAYS, True, rich_utils.AllowStyle.ALWAYS), + ('AlWaYs', True, rich_utils.AllowStyle.ALWAYS), + ('invalid', False, rich_utils.AllowStyle.TERMINAL), ], ) def test_set_allow_style(base_app, new_val, is_valid, expected) -> None: # Initialize allow_style for this test - ansi.allow_style = ansi.AllowStyle.TERMINAL + rich_utils.allow_style = rich_utils.AllowStyle.TERMINAL # Use the set command to alter it out, err = run_cmd(base_app, f'set allow_style {new_val}') assert base_app.last_result is is_valid # Verify the results - assert ansi.allow_style == expected + assert rich_utils.allow_style == expected if is_valid: assert not err assert out # Reset allow_style to its default since it's an application-wide setting that can affect other unit tests - ansi.allow_style = ansi.AllowStyle.TERMINAL + rich_utils.allow_style = rich_utils.AllowStyle.TERMINAL def test_set_with_choices(base_app) -> None: @@ -1238,7 +1239,8 @@ def test_help_multiline_docstring(help_app) -> None: def test_help_verbose_uses_parser_description(help_app: HelpApp) -> None: out, err = run_cmd(help_app, 'help --verbose') - verify_help_text(help_app, out, verbose_strings=[help_app.parser_cmd_parser.description]) + expected_verbose = utils.strip_doc_annotations(help_app.do_parser_cmd.__doc__) + verify_help_text(help_app, out, verbose_strings=[expected_verbose]) class HelpCategoriesApp(cmd2.Cmd): @@ -1554,7 +1556,7 @@ def test_help_with_no_docstring(capsys) -> None: out == """Usage: greet [-h] [-s] -optional arguments: +Optional Arguments: -h, --help show this help message and exit -s, --shout N00B EMULATION MODE @@ -1980,7 +1982,7 @@ def test_ppretty_dict(outsim_app) -> None: assert out == expected.lstrip() -@with_ansi_style(ansi.AllowStyle.ALWAYS) +@with_ansi_style(rich_utils.AllowStyle.ALWAYS) def test_poutput_ansi_always(outsim_app) -> None: msg = 'Hello World' colored_msg = ansi.style(msg, fg=ansi.Fg.CYAN) @@ -1991,7 +1993,7 @@ def test_poutput_ansi_always(outsim_app) -> None: assert out == expected -@with_ansi_style(ansi.AllowStyle.NEVER) +@with_ansi_style(rich_utils.AllowStyle.NEVER) def test_poutput_ansi_never(outsim_app) -> None: msg = 'Hello World' colored_msg = ansi.style(msg, fg=ansi.Fg.CYAN) @@ -2174,7 +2176,7 @@ def test_multiple_aliases(base_app) -> None: verify_help_text(base_app, out) -@with_ansi_style(ansi.AllowStyle.ALWAYS) +@with_ansi_style(rich_utils.AllowStyle.ALWAYS) def test_perror_style(base_app, capsys) -> None: msg = 'testing...' end = '\n' @@ -2183,7 +2185,7 @@ def test_perror_style(base_app, capsys) -> None: assert err == ansi.style_error(msg) + end -@with_ansi_style(ansi.AllowStyle.ALWAYS) +@with_ansi_style(rich_utils.AllowStyle.ALWAYS) def test_perror_no_style(base_app, capsys) -> None: msg = 'testing...' end = '\n' @@ -2192,7 +2194,7 @@ def test_perror_no_style(base_app, capsys) -> None: assert err == msg + end -@with_ansi_style(ansi.AllowStyle.ALWAYS) +@with_ansi_style(rich_utils.AllowStyle.ALWAYS) def test_pexcept_style(base_app, capsys) -> None: msg = Exception('testing...') @@ -2201,7 +2203,7 @@ def test_pexcept_style(base_app, capsys) -> None: assert err.startswith(ansi.style_error("EXCEPTION of type 'Exception' occurred with message: testing...")) -@with_ansi_style(ansi.AllowStyle.ALWAYS) +@with_ansi_style(rich_utils.AllowStyle.ALWAYS) def test_pexcept_no_style(base_app, capsys) -> None: msg = Exception('testing...') @@ -2210,7 +2212,7 @@ def test_pexcept_no_style(base_app, capsys) -> None: assert err.startswith("EXCEPTION of type 'Exception' occurred with message: testing...") -@with_ansi_style(ansi.AllowStyle.ALWAYS) +@with_ansi_style(rich_utils.AllowStyle.ALWAYS) def test_pexcept_not_exception(base_app, capsys) -> None: # Pass in a msg that is not an Exception object msg = False @@ -2228,7 +2230,7 @@ def test_ppaged(outsim_app) -> None: assert out == msg + end -@with_ansi_style(ansi.AllowStyle.TERMINAL) +@with_ansi_style(rich_utils.AllowStyle.TERMINAL) def test_ppaged_strips_ansi_when_redirecting(outsim_app) -> None: msg = 'testing...' end = '\n' @@ -2238,7 +2240,7 @@ def test_ppaged_strips_ansi_when_redirecting(outsim_app) -> None: assert out == msg + end -@with_ansi_style(ansi.AllowStyle.ALWAYS) +@with_ansi_style(rich_utils.AllowStyle.ALWAYS) def test_ppaged_strips_ansi_when_redirecting_if_always(outsim_app) -> None: msg = 'testing...' end = '\n' @@ -2432,7 +2434,7 @@ def do_echo_error(self, args) -> None: self.perror(args) -@with_ansi_style(ansi.AllowStyle.ALWAYS) +@with_ansi_style(rich_utils.AllowStyle.ALWAYS) def test_ansi_pouterr_always_tty(mocker, capsys) -> None: app = AnsiApp() mocker.patch.object(app.stdout, 'isatty', return_value=True) @@ -2455,7 +2457,7 @@ def test_ansi_pouterr_always_tty(mocker, capsys) -> None: assert 'oopsie' in err -@with_ansi_style(ansi.AllowStyle.ALWAYS) +@with_ansi_style(rich_utils.AllowStyle.ALWAYS) def test_ansi_pouterr_always_notty(mocker, capsys) -> None: app = AnsiApp() mocker.patch.object(app.stdout, 'isatty', return_value=False) @@ -2478,7 +2480,7 @@ def test_ansi_pouterr_always_notty(mocker, capsys) -> None: assert 'oopsie' in err -@with_ansi_style(ansi.AllowStyle.TERMINAL) +@with_ansi_style(rich_utils.AllowStyle.TERMINAL) def test_ansi_terminal_tty(mocker, capsys) -> None: app = AnsiApp() mocker.patch.object(app.stdout, 'isatty', return_value=True) @@ -2500,7 +2502,7 @@ def test_ansi_terminal_tty(mocker, capsys) -> None: assert 'oopsie' in err -@with_ansi_style(ansi.AllowStyle.TERMINAL) +@with_ansi_style(rich_utils.AllowStyle.TERMINAL) def test_ansi_terminal_notty(mocker, capsys) -> None: app = AnsiApp() mocker.patch.object(app.stdout, 'isatty', return_value=False) @@ -2515,7 +2517,7 @@ def test_ansi_terminal_notty(mocker, capsys) -> None: assert out == err == 'oopsie\n' -@with_ansi_style(ansi.AllowStyle.NEVER) +@with_ansi_style(rich_utils.AllowStyle.NEVER) def test_ansi_never_tty(mocker, capsys) -> None: app = AnsiApp() mocker.patch.object(app.stdout, 'isatty', return_value=True) @@ -2530,7 +2532,7 @@ def test_ansi_never_tty(mocker, capsys) -> None: assert out == err == 'oopsie\n' -@with_ansi_style(ansi.AllowStyle.NEVER) +@with_ansi_style(rich_utils.AllowStyle.NEVER) def test_ansi_never_notty(mocker, capsys) -> None: app = AnsiApp() mocker.patch.object(app.stdout, 'isatty', return_value=False) diff --git a/tests/test_completion.py b/tests/test_completion.py index 2361c222..4d9ad79d 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -258,7 +258,7 @@ def test_set_allow_style_completion(cmd2_app) -> None: endidx = len(line) begidx = endidx - len(text) - expected = [val.name.lower() for val in cmd2.ansi.AllowStyle] + expected = [val.name.lower() for val in cmd2.rich_utils.AllowStyle] first_match = complete_tester(text, line, begidx, endidx, cmd2_app) assert first_match diff --git a/tests/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/test_argparse_subcommands.py b/tests_isolated/test_commandset/test_argparse_subcommands.py index 5f4645d5..ee0b08e7 100644 --- a/tests_isolated/test_commandset/test_argparse_subcommands.py +++ b/tests_isolated/test_commandset/test_argparse_subcommands.py @@ -93,39 +93,39 @@ def test_subcommand_help(subcommand_app) -> None: out, err = run_cmd(subcommand_app, 'help base foo') assert out[0].startswith('Usage: base foo') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' # bar has aliases (usage should never show alias name) out, err = run_cmd(subcommand_app, 'help base bar') assert out[0].startswith('Usage: base bar') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' out, err = run_cmd(subcommand_app, 'help base bar_1') assert out[0].startswith('Usage: base bar') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' out, err = run_cmd(subcommand_app, 'help base bar_2') assert out[0].startswith('Usage: base bar') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' # helpless has aliases and no help text (usage should never show alias name) out, err = run_cmd(subcommand_app, 'help base helpless') assert out[0].startswith('Usage: base helpless') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' out, err = run_cmd(subcommand_app, 'help base helpless_1') assert out[0].startswith('Usage: base helpless') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' out, err = run_cmd(subcommand_app, 'help base helpless_2') assert out[0].startswith('Usage: base helpless') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' def test_subcommand_invalid_help(subcommand_app) -> None: From f79c8dd041132098d2f19195de43b6632c293b5f Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 17 Jul 2025 09:03:06 -0400 Subject: [PATCH 11/89] Restore macros (#1464) * Restored macros. * Added Cmd.macro_arg_complete() to allow custom tab completion for macro arguments. --- CHANGELOG.md | 5 +- README.md | 6 +- cmd2/cmd2.py | 436 +++++++++++++++++- cmd2/parsing.py | 58 ++- docs/examples/first_app.md | 9 +- docs/features/builtin_commands.md | 12 +- docs/features/commands.md | 2 +- docs/features/help.md | 23 +- docs/features/history.md | 10 +- docs/features/index.md | 2 +- docs/features/initialization.md | 1 + docs/features/os.md | 8 +- ...aliases.md => shortcuts_aliases_macros.md} | 43 +- docs/migrating/incompatibilities.md | 2 +- docs/migrating/why.md | 4 +- mkdocs.yml | 2 +- tests/conftest.py | 2 +- tests/test_cmd2.py | 255 +++++++++- tests/test_completion.py | 22 + tests/test_parsing.py | 108 +++++ tests_isolated/test_commandset/conftest.py | 2 +- .../test_commandset/test_commandset.py | 8 +- 22 files changed, 952 insertions(+), 68 deletions(-) rename docs/features/{shortcuts_aliases.md => shortcuts_aliases_macros.md} (55%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87056cee..27cdb5b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,16 +2,19 @@ - Breaking Changes - - Removed macros - No longer setting parser's `prog` value in `with_argparser()` since it gets set in `Cmd._build_parser()`. This code had previously been restored to support backward compatibility in `cmd2` 2.0 family. - 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/main/examples/custom_parser.py) example for more details. + - Added `Cmd.macro_arg_complete()` which tab completes arguments to a macro. Its default + behavior is to perform path completion, but it can be overridden as needed. + ## 2.7.0 (June 30, 2025) - Enhancements diff --git a/README.md b/README.md index 2841d53c..bb412042 100755 --- a/README.md +++ b/README.md @@ -69,10 +69,10 @@ first pillar of 'ease of command discovery'. The following is a list of features -cmd2 creates the second pillar of 'ease of transition to automation' through alias creation, command -line argument parsing and execution of cmd2 scripting. +cmd2 creates the second pillar of 'ease of transition to automation' through alias/macro creation, +command line argument parsing and execution of cmd2 scripting. -- Flexible alias creation for quick abstraction of commands. +- Flexible alias and macro creation for quick abstraction of commands. - Text file scripting of your application with `run_script` (`@`) and `_relative_run_script` (`@@`) - Powerful and flexible built-in Python scripting of your application using the `run_pyscript` command diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 3e316d80..92a788ff 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -38,6 +38,7 @@ import os import pprint import pydoc +import re import sys import tempfile import threading @@ -119,6 +120,8 @@ single_line_format, ) from .parsing import ( + Macro, + MacroArg, Statement, StatementParser, shlex_split, @@ -428,6 +431,9 @@ 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] = {} + # Keeps track of typed command history in the Python shell self._py_history: list[str] = [] @@ -473,7 +479,7 @@ 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 or alias." + self.default_error = "{} is not a recognized command, alias, or macro." # If non-empty, this string will be displayed if a broken pipe error occurs self.broken_pipe_warning = '' @@ -544,7 +550,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, settable, and shortcut names + # alias, macro, settable, and shortcut names # tab completion results when self.matches_sorted is False self.default_sort_key: Callable[[str], str] = Cmd.ALPHABETICAL_SORT_KEY @@ -817,6 +823,11 @@ 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: @@ -2049,8 +2060,12 @@ 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.macro_arg_complete + # Check if a command was entered - if command in self.get_all_commands(): + elif command in self.get_all_commands(): # Get the completer function for this command func_attr = getattr(self, constants.COMPLETER_FUNC_PREFIX + command, None) @@ -2076,7 +2091,8 @@ def _perform_completion( else: completer_func = self.completedefault # type: ignore[assignment] - # Not a recognized command. Check if it should be run as a shell command. + # Not a recognized macro or command + # Check if this command should be run as a shell command elif self.default_to_shell and command in utils.get_exes_in_path(command): completer_func = self.path_complete else: @@ -2234,8 +2250,8 @@ def complete( # type: ignore[override] parser.add_argument( 'command', metavar="COMMAND", - help="command or alias name", - choices=self._get_commands_and_aliases_for_completion(), + help="command, alias, or macro name", + choices=self._get_commands_aliases_and_macros_for_completion(), ) custom_settings = utils.CustomCompletionSettings(parser) @@ -2323,6 +2339,19 @@ 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) @@ -2336,11 +2365,12 @@ def _get_settable_completion_items(self) -> list[CompletionItem]: return results - def _get_commands_and_aliases_for_completion(self) -> list[str]: - """Return a list of visible commands and aliases for tab completion.""" + def _get_commands_aliases_and_macros_for_completion(self) -> list[str]: + """Return a list of visible commands, aliases, and macros for tab completion.""" visible_commands = set(self.get_visible_commands()) alias_names = set(self.aliases) - return list(visible_commands | alias_names) + macro_names = set(self.macros) + return list(visible_commands | alias_names | macro_names) def get_help_topics(self) -> list[str]: """Return a list of help topics.""" @@ -2479,7 +2509,7 @@ def onecmd_plus_hooks( try: # Convert the line into a Statement - statement = self._complete_statement(line, orig_rl_history_length=orig_rl_history_length) + statement = self._input_line_to_statement(line, orig_rl_history_length=orig_rl_history_length) # call the postparsing hooks postparsing_data = plugin.PostparsingData(False, statement) @@ -2723,6 +2753,99 @@ 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 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: + 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 |. @@ -2891,7 +3014,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._complete_statement(statement) + statement = self._input_line_to_statement(statement) func = self.cmd_func(statement.command) if func: @@ -3224,6 +3347,10 @@ def _build_alias_parser() -> Cmd2ArgumentParser: "An alias is a command that enables replacement of a word by another string.", ) alias_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_description) + alias_parser.epilog = alias_parser.create_text_group( + "See Also", + "macro", + ) alias_parser.add_subparsers(metavar='SUBCOMMAND', required=True) return alias_parser @@ -3273,7 +3400,7 @@ def _build_alias_create_parser(cls) -> Cmd2ArgumentParser: alias_create_parser.add_argument( 'command', help='what the alias resolves to', - choices_provider=cls._get_commands_and_aliases_for_completion, + choices_provider=cls._get_commands_aliases_and_macros_for_completion, ) alias_create_parser.add_argument( 'command_args', @@ -3299,6 +3426,10 @@ 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) @@ -3407,6 +3538,285 @@ 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 + ############################################################# + + def macro_arg_complete( + self, + text: str, + line: str, + begidx: int, + endidx: int, + ) -> list[str]: + """Tab completes arguments to a macro. + + Its default behavior is to call path_complete, but you can override this as needed. + + The args required by this function are defined in the header of Python's cmd.py. + + :param text: the string prefix we are attempting to match (all matches must begin with it) + :param line: the current input line with leading whitespace removed + :param begidx: the beginning index of the prefix text + :param endidx: the ending index of the prefix text + :return: a list of possible tab completions + """ + return self.path_complete(text, line, begidx, endidx) + + # Top-level parser for macro + @staticmethod + def _build_macro_parser() -> Cmd2ArgumentParser: + macro_description = Group( + "Manage macros.", + "\n", + "A macro is similar to an alias, but it can contain argument placeholders.", + ) + macro_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_description) + macro_parser.epilog = macro_parser.create_text_group( + "See Also", + "alias", + ) + macro_parser.add_subparsers(metavar='SUBCOMMAND', required=True) + + return macro_parser + + # Preserve quotes since we are passing strings to other commands + @with_argparser(_build_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 + @classmethod + def _build_macro_create_parser(cls) -> Cmd2ArgumentParser: + macro_create_description = Group( + "Create or overwrite a macro.", + "\n", + "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} means the first argument.", + "\n", + "The following creates a macro called my_macro that expects two arguments:", + "\n", + " macro create my_macro make_dinner --meat {1} --veggie {2}", + "\n", + "When the macro is called, the provided arguments are resolved and the assembled command is run. For example:", + "\n", + " my_macro beef broccoli ---> make_dinner --meat beef --veggie broccoli", + ) + macro_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_create_description) + + # Create Notes TextGroup + macro_create_notes = Group( + "To use the literal string {1} in your command, escape it this way: {{1}}.", + "\n", + "Extra arguments passed to a macro are appended to resolved command.", + "\n", + ( + "An argument number can be repeated in a macro. In the following example the " + "first argument will populate both {1} instances." + ), + "\n", + " macro create ft file_taxes -p {1} -q {2} -r {1}", + "\n", + "To quote an argument in the resolved command, quote it during creation.", + "\n", + " macro create backup !cp \"{1}\" \"{1}.orig\"", + "\n", + "If you want to use redirection, pipes, or terminators in the value of the macro, then quote them.", + "\n", + " macro create show_results print_results -type {1} \"|\" less", + "\n", + ( + "Since macros don't resolve until after you press Enter, their arguments tab complete as paths. " + "This default behavior changes if custom tab completion for macro arguments has been implemented." + ), + ) + macro_create_parser.epilog = macro_create_parser.create_text_group("Notes", macro_create_notes) + + # Add arguments + macro_create_parser.add_argument('name', help='name of this macro') + macro_create_parser.add_argument( + 'command', + help='what the macro resolves to', + choices_provider=cls._get_commands_aliases_and_macros_for_completion, + ) + macro_create_parser.add_argument( + 'command_args', + nargs=argparse.REMAINDER, + help='arguments to pass to command', + completer=cls.path_complete, + ) + + return macro_create_parser + + @as_subcommand_to('macro', 'create', _build_macro_create_parser, help="create or overwrite a macro") + def _macro_create(self, args: argparse.Namespace) -> None: + """Create or overwrite a macro.""" + self.last_result = False + + # 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() + + try: + while True: + 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) + max_arg_num = max(max_arg_num, cur_num) + + arg_list.append(MacroArg(start_index=cur_match.start(), number_str=cur_num_str, is_escaped=False)) + except StopIteration: + pass + + # 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) + + try: + while True: + 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: + pass + + # 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 + @classmethod + def _build_macro_delete_parser(cls) -> Cmd2ArgumentParser: + macro_delete_description = "Delete specified macros or all macros if --all is used." + + macro_delete_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_delete_description) + macro_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all macros") + macro_delete_parser.add_argument( + 'names', + nargs=argparse.ZERO_OR_MORE, + help='macro(s) to delete', + choices_provider=cls._get_macro_completion_items, + descriptive_header=cls._macro_completion_table.generate_header(), + ) + + return macro_delete_parser + + @as_subcommand_to('macro', 'delete', _build_macro_delete_parser, help="delete macros") + def _macro_delete(self, args: argparse.Namespace) -> None: + """Delete macros.""" + self.last_result = True + + 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) + + to_list = utils.remove_duplicates(args.names) if args.names else 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]: """Completes the command argument of help.""" # Complete token against topics and visible commands @@ -4386,7 +4796,7 @@ def _build_history_parser(cls) -> Cmd2ArgumentParser: '-x', '--expanded', action='store_true', - help='output fully parsed commands with aliases and shortcuts expanded', + help='output fully parsed commands with shortcuts, aliases, and macros expanded', ) history_format_group.add_argument( '-v', diff --git a/cmd2/parsing.py b/cmd2/parsing.py index e488aad1..e12f799c 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -35,6 +35,56 @@ def shlex_split(str_to_split: str) -> list[str]: return shlex.split(str_to_split, comments=False, posix=False) +@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: def argv(self) -> list[str]: """A list of arguments a-la ``sys.argv``. - 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()``. + 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()``. If you want to strip quotes from the input, you can use ``argv[1:]``. """ diff --git a/docs/examples/first_app.md b/docs/examples/first_app.md index 64e1c1c0..86efd70f 100644 --- a/docs/examples/first_app.md +++ b/docs/examples/first_app.md @@ -7,7 +7,7 @@ Here's a quick walkthrough of a simple application which demonstrates 8 features - [Argument Processing](../features/argument_processing.md) - [Generating Output](../features/generating_output.md) - [Help](../features/help.md) -- [Shortcuts](../features/shortcuts_aliases.md#shortcuts) +- [Shortcuts](../features/shortcuts_aliases_macros.md#shortcuts) - [Multiline Commands](../features/multiline_commands.md) - [History](../features/history.md) @@ -166,9 +166,10 @@ With those few lines of code, we created a [command](../features/commands.md), u ## Shortcuts `cmd2` has several capabilities to simplify repetitive user input: -[Shortcuts and Aliases](../features/shortcuts_aliases.md). Let's add a shortcut to our application. -Shortcuts are character strings that can be used instead of a command name. For example, `cmd2` has -support for a shortcut `!` which runs the `shell` command. So instead of typing this: +[Shortcuts, Aliases, and Macros](../features/shortcuts_aliases_macros.md). Let's add a shortcut to +our application. Shortcuts are character strings that can be used instead of a command name. For +example, `cmd2` has support for a shortcut `!` which runs the `shell` command. So instead of typing +this: ```shell (Cmd) shell ls -al diff --git a/docs/features/builtin_commands.md b/docs/features/builtin_commands.md index 42822a53..ed0e2479 100644 --- a/docs/features/builtin_commands.md +++ b/docs/features/builtin_commands.md @@ -9,7 +9,7 @@ to be part of the application. ### alias This command manages aliases via subcommands `create`, `delete`, and `list`. See -[Aliases](shortcuts_aliases.md#aliases) for more information. +[Aliases](shortcuts_aliases_macros.md#aliases) for more information. ### edit @@ -38,6 +38,12 @@ history. See [History](history.md) for more information. This optional opt-in command enters an interactive IPython shell. See [IPython (optional)](./embedded_python_shells.md#ipython-optional) for more information. +### macro + +This command manages macros via subcommands `create`, `delete`, and `list`. A macro is similar to an +alias, but it can contain argument placeholders. See [Macros](./shortcuts_aliases_macros.md#macros) +for more information. + ### py This command invokes a Python command or shell. See @@ -108,8 +114,8 @@ Execute a command as if at the operating system shell prompt: ### shortcuts -This command lists available shortcuts. See [Shortcuts](./shortcuts_aliases.md#shortcuts) for more -information. +This command lists available shortcuts. See [Shortcuts](./shortcuts_aliases_macros.md#shortcuts) for +more information. ## Remove Builtin Commands diff --git a/docs/features/commands.md b/docs/features/commands.md index 2693add3..06f3877b 100644 --- a/docs/features/commands.md +++ b/docs/features/commands.md @@ -61,7 +61,7 @@ backwards compatibility. - quoted arguments - output redirection and piping - multi-line commands -- shortcut and alias expansion +- shortcut, alias, and macro expansion In addition to parsing all of these elements from the user input, `cmd2` also has code to make all of these items work; it's almost transparent to you and to the commands you write in your own diff --git a/docs/features/help.md b/docs/features/help.md index 41ce44f4..816acc11 100644 --- a/docs/features/help.md +++ b/docs/features/help.md @@ -14,8 +14,8 @@ command. The `help` command by itself displays a list of the commands available: Documented commands (use 'help -v' for verbose/'help ' for details): =========================================================================== -alias help ipy quit run_script shell -edit history py run_pyscript set shortcuts +alias help ipy py run_pyscript set shortcuts +edit history macro quit run_script shell ``` The `help` command can also be used to provide detailed help for a specific command: @@ -53,8 +53,8 @@ By default, the `help` command displays: Documented commands (use 'help -v' for verbose/'help ' for details): =========================================================================== - alias help ipy quit run_script shell - edit history py run_pyscript set shortcuts + alias help ipy py run_pyscript set shortcuts + edit history macro quit run_script shell If you have a large number of commands, you can optionally group your commands into categories. Here's the output from the example `help_categories.py`: @@ -80,8 +80,8 @@ Here's the output from the example `help_categories.py`: Other ===== - alias edit history run_pyscript set shortcuts - config help quit run_script shell version + alias edit history py run_pyscript set shortcuts + config help macro quit run_script shell version There are 2 methods of specifying command categories, using the `@with_category` decorator or with the `categorize()` function. Once a single command category is detected, the help output switches to @@ -143,9 +143,9 @@ categories with per-command Help Messages: findleakers Find Leakers command. list List command. redeploy Redeploy command. - restart Restart + restart Restart command. sessions Sessions command. - start Start + start Start command. stop Stop command. undeploy Undeploy command. @@ -164,9 +164,9 @@ categories with per-command Help Messages: resources Resources command. serverinfo Server Info command. sslconnectorciphers SSL Connector Ciphers command is an example of a command that contains - multiple lines of help information for the user. Each line of help in a - contiguous set of lines will be printed and aligned in the verbose output - provided with 'help --verbose'. + multiple lines of help information for the user. Each line of help in a + contiguous set of lines will be printed and aligned in the verbose output + provided with 'help --verbose'. status Status command. thread_dump Thread Dump command. vminfo VM Info command. @@ -178,6 +178,7 @@ categories with per-command Help Messages: edit Run a text editor and optionally open a file with it. help List available commands or provide detailed help for a specific command. history View, run, edit, save, or clear previously entered commands. + macro Manage macros. quit Exit this application. run_pyscript Run Python script within this application's environment. run_script Run text script. diff --git a/docs/features/history.md b/docs/features/history.md index bc98dcf4..fdc7c9b4 100644 --- a/docs/features/history.md +++ b/docs/features/history.md @@ -198,8 +198,8 @@ without line numbers, so you can copy them to the clipboard: (Cmd) history -s 1:3 -`cmd2` supports aliases which allow you to substitute a short, more convenient input string with a -longer replacement string. Say we create an alias like this, and then use it: +`cmd2` supports both aliases and macros, which allow you to substitute a short, more convenient +input string with a longer replacement string. Say we create an alias like this, and then use it: (Cmd) alias create ls shell ls -aF Alias 'ls' created @@ -212,7 +212,7 @@ By default, the `history` command shows exactly what we typed: 1 alias create ls shell ls -aF 2 ls -d h* -There are two ways to modify the display so you can see what aliases and shortcuts were expanded to. +There are two ways to modify the display so you can see what aliases and macros were expanded to. The first is to use `-x` or `--expanded`. These options show the expanded command instead of the entered command: @@ -229,5 +229,5 @@ option: 2x shell ls -aF -d h* If the entered command had no expansion, it is displayed as usual. However, if there is some change -as the result of expanding aliases, then the entered command is displayed with the number, and the -expanded command is displayed with the number followed by an `x`. +as the result of expanding macros and aliases, then the entered command is displayed with the +number, and the expanded command is displayed with the number followed by an `x`. diff --git a/docs/features/index.md b/docs/features/index.md index 13ea9afe..13f99715 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -24,7 +24,7 @@ - [Output Redirection and Pipes](redirection.md) - [Scripting](scripting.md) - [Settings](settings.md) -- [Shortcuts and Aliases](shortcuts_aliases.md) +- [Shortcuts, Aliases, and Macros](shortcuts_aliases_macros.md) - [Startup Commands](startup_commands.md) - [Table Creation](table_creation.md) - [Transcripts](transcripts.md) diff --git a/docs/features/initialization.md b/docs/features/initialization.md index 85735b87..478a2eb2 100644 --- a/docs/features/initialization.md +++ b/docs/features/initialization.md @@ -112,6 +112,7 @@ Here are instance attributes of `cmd2.Cmd` which developers might wish to overri - **help_error**: the error that prints when no help information can be found - **hidden_commands**: commands to exclude from the help menu and tab completion - **last_result**: stores results from the last command run to enable usage of results in a Python script or interactive console. Built-in commands don't make use of this. It is purely there for user-defined commands and convenience. +- **macros**: dictionary of macro names and their values - **max_completion_items**: max number of CompletionItems to display during tab completion (Default: 50) - **pager**: sets the pager command used by the `Cmd.ppaged()` method for displaying wrapped output using a pager - **pager_chop**: sets the pager command used by the `Cmd.ppaged()` method for displaying chopped/truncated output using a pager diff --git a/docs/features/os.md b/docs/features/os.md index 83ffe6de..d1da31bf 100644 --- a/docs/features/os.md +++ b/docs/features/os.md @@ -10,8 +10,8 @@ See [Output Redirection and Pipes](./redirection.md#output-redirection-and-pipes (Cmd) shell ls -al -If you use the default [Shortcuts](./shortcuts_aliases.md#shortcuts) defined in `cmd2` you'll get a -`!` shortcut for `shell`, which allows you to type: +If you use the default [Shortcuts](./shortcuts_aliases_macros.md#shortcuts) defined in `cmd2` you'll +get a `!` shortcut for `shell`, which allows you to type: (Cmd) !ls -al @@ -89,8 +89,8 @@ shell, and execute those commands before entering the command loop: Documented commands (use 'help -v' for verbose/'help ' for details): =========================================================================== - alias help ipy quit run_script shell - edit history py run_pyscript set shortcuts + alias help macro orate quit run_script set shortcuts + edit history mumble py run_pyscript say shell speak (Cmd) diff --git a/docs/features/shortcuts_aliases.md b/docs/features/shortcuts_aliases_macros.md similarity index 55% rename from docs/features/shortcuts_aliases.md rename to docs/features/shortcuts_aliases_macros.md index 17642ace..b286bc48 100644 --- a/docs/features/shortcuts_aliases.md +++ b/docs/features/shortcuts_aliases_macros.md @@ -1,4 +1,4 @@ -# Shortcuts and Aliases +# Shortcuts, Aliases, and Macros ## Shortcuts @@ -26,7 +26,7 @@ class App(Cmd): Shortcuts need to be created by updating the `shortcuts` dictionary attribute prior to calling the `cmd2.Cmd` super class `__init__()` method. Moreover, that super class init method needs to be called after updating the `shortcuts` attribute This warning applies in general to many other attributes which are not settable at runtime. -Note: Command and alias names cannot start with a shortcut +Note: Command, alias, and macro names cannot start with a shortcut ## Aliases @@ -57,4 +57,41 @@ Use `alias delete` to remove aliases For more details run: `help alias delete` -Note: Aliases cannot have the same name as a command +Note: Aliases cannot have the same name as a command or macro + +## Macros + +`cmd2` provides a feature that is similar to aliases called macros. The major difference between +macros and aliases is that macros can contain argument placeholders. Arguments are expressed when +creating a macro using {#} notation where {1} means the first argument. + +The following creates a macro called my[macro]{#macro} that expects two arguments: + + macro create my[macro]{#macro} make[dinner]{#dinner} -meat {1} -veggie {2} + +When the macro is called, the provided arguments are resolved and the assembled command is run. For +example: + + my[macro]{#macro} beef broccoli ---> make[dinner]{#dinner} -meat beef -veggie broccoli + +Similar to aliases, pipes and redirectors need to be quoted in the definition of a macro: + + macro create lc !cat "{1}" "|" less + +To use the literal string `{1}` in your command, escape it this way: `{{1}}`. + +Since macros don't resolve until after you press ``, their arguments tab complete as paths. +You can change this default behavior by overriding `Cmd.macro_arg_complete()` to implement custom +tab completion for macro arguments. + +For more details run: `help macro create` + +The macro command has `list` and `delete` subcommands that function identically to the alias +subcommands of the same name. Like aliases, macros can be created via a `cmd2` startup script to +preserve them across application sessions. + +For more details on listing macros run: `help macro list` + +For more details on deleting macros run: `help macro delete` + +Note: Macros cannot have the same name as a command or alias diff --git a/docs/migrating/incompatibilities.md b/docs/migrating/incompatibilities.md index 1b5e3f99..030959d1 100644 --- a/docs/migrating/incompatibilities.md +++ b/docs/migrating/incompatibilities.md @@ -28,7 +28,7 @@ and arguments on whitespace. We opted for this breaking change because while characters in command names while simultaneously using `identchars` functionality can be somewhat painful. Requiring white space to delimit arguments also ensures reliable operation of many other useful `cmd2` features, including [Tab Completion](../features/completion.md) and -[Shortcuts and Aliases](../features/shortcuts_aliases.md). +[Shortcuts, Aliases, and Macros](../features/shortcuts_aliases_macros.md). If you really need this functionality in your app, you can add it back in by writing a [Postparsing Hook](../features/hooks.md#postparsing-hooks). diff --git a/docs/migrating/why.md b/docs/migrating/why.md index 44fbe2a8..060ef0c0 100644 --- a/docs/migrating/why.md +++ b/docs/migrating/why.md @@ -37,8 +37,8 @@ and capabilities, without you having to do anything: Before you do, you might consider the various ways `cmd2` has of [Generatoring Output](../features/generating_output.md). - Users can load script files, which contain a series of commands to be executed. -- Users can create [Shortcuts and Aliases](../features/shortcuts_aliases.md) to reduce the typing - required for repetitive commands. +- Users can create [Shortcuts, Aliases, and Macros](../features/shortcuts_aliases_macros.md) to + reduce the typing required for repetitive commands. - Embedded python shell allows a user to execute python code from within your `cmd2` app. How meta. - [Clipboard Integration](../features/clipboard.md) allows you to save command output to the operating system clipboard. diff --git a/mkdocs.yml b/mkdocs.yml index a8090308..77a3d3d7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -183,7 +183,7 @@ nav: - features/redirection.md - features/scripting.md - features/settings.md - - features/shortcuts_aliases.md + - features/shortcuts_aliases_macros.md - features/startup_commands.md - features/table_creation.md - features/transcripts.md diff --git a/tests/conftest.py b/tests/conftest.py index 19aedaac..253d9fcd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -70,7 +70,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 aliases and shortcuts expanded + -x, --expanded output fully parsed commands with shortcuts, aliases, and macros 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 previous sessions """ diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index e641f9bd..fa9ee561 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1618,8 +1618,8 @@ def test_multiline_complete_statement_with_unclosed_quotes(multiline_app) -> Non assert statement.terminator == ';' -def test_multiline_complete_statement(multiline_app) -> None: - # Verify _complete_statement saves the fully entered input line for multiline commands +def test_multiline_input_line_to_statement(multiline_app) -> None: + # Verify _input_line_to_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 @@ -1627,7 +1627,7 @@ def test_multiline_complete_statement(multiline_app) -> None: builtins.input = m line = 'orate hi' - statement = multiline_app._complete_statement(line) + statement = multiline_app._input_line_to_statement(line) assert statement.raw == 'orate hi\nperson\n' assert statement == 'hi person' assert statement.command == 'orate' @@ -2004,7 +2004,8 @@ def test_poutput_ansi_never(outsim_app) -> None: assert out == expected -invalid_alias_names = [ +# These are invalid names for aliases and macros +invalid_command_name = [ '""', # Blank name constants.COMMENT_CHAR, '!no_shortcut', @@ -2030,6 +2031,19 @@ def test_get_alias_completion_items(base_app) -> None: assert cur_res.description.rstrip() == base_app.aliases[cur_res] +def test_get_macro_completion_items(base_app) -> None: + 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) -> None: results = base_app._get_settable_completion_items() assert len(results) == len(base_app.settables) @@ -2105,7 +2119,7 @@ def test_alias_create_with_quoted_tokens(base_app) -> None: assert base_app.last_result[alias_name] == alias_command -@pytest.mark.parametrize('alias_name', invalid_alias_names) +@pytest.mark.parametrize('alias_name', invalid_command_name) def test_alias_create_invalid_name(base_app, alias_name, capsys) -> None: out, err = run_cmd(base_app, f'alias create {alias_name} help') assert "Invalid alias name" in err[0] @@ -2118,6 +2132,14 @@ def test_alias_create_with_command_name(base_app) -> None: assert base_app.last_result is False +def test_alias_create_with_macro_name(base_app) -> None: + macro = "my_macro" + run_cmd(base_app, f'macro create {macro} help') + out, err = run_cmd(base_app, f'alias create {macro} help') + 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) -> None: # Create the alias out, err = run_cmd(base_app, 'alias create fake ' + constants.COMMENT_CHAR + ' blah blah') @@ -2176,6 +2198,228 @@ def test_multiple_aliases(base_app) -> None: verify_help_text(base_app, out) +def test_macro_no_subcommand(base_app) -> None: + 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) -> None: + # 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) -> None: + """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) -> None: + out, err = run_cmd(base_app, f'macro create {macro_name} help') + assert "Invalid macro name" in err[0] + assert base_app.last_result is False + + +def test_macro_create_with_command_name(base_app) -> None: + 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) -> None: + macro = "my_macro" + run_cmd(base_app, f'alias create {macro} help') + out, err = run_cmd(base_app, f'macro create {macro} help') + 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) -> None: + # 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) -> None: + # 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) -> None: + # 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) -> None: + # 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) -> None: + # 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) -> None: + # 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) -> None: + # 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) -> None: + 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) -> None: + # 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) -> None: + # 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) -> None: + # 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) -> None: + 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) -> None: + 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) -> None: + 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) -> None: + macro1 = 'h1' + macro2 = 'h2' + run_cmd(base_app, f'macro create {macro1} help') + run_cmd(base_app, f'macro create {macro2} help -v') + 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) -> None: + 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(rich_utils.AllowStyle.ALWAYS) def test_perror_style(base_app, capsys) -> None: msg = 'testing...' @@ -2315,6 +2559,7 @@ def test_get_all_commands(base_app) -> None: 'help', 'history', 'ipy', + 'macro', 'py', 'quit', 'run_pyscript', diff --git a/tests/test_completion.py b/tests/test_completion.py index 4d9ad79d..702d5bd5 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -24,6 +24,8 @@ from .conftest import ( complete_tester, + normalize, + run_cmd, ) # List of strings used with completion functions @@ -180,6 +182,26 @@ def test_complete_exception(cmd2_app, capsys) -> None: assert "IndexError" in err +def test_complete_macro(base_app, request) -> None: + # 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 = f'fake {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 + assert base_app.completion_matches == expected + + def test_default_sort_key(cmd2_app) -> None: text = '' line = f'test_sort_key {text}' diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 969d00d7..711868ca 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -1039,3 +1039,111 @@ def test_is_valid_command_valid(parser) -> None: valid, errmsg = parser.is_valid_command('!subcmd', is_subcommand=True) assert valid assert not errmsg + + +def test_macro_normal_arg_pattern() -> None: + # 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() -> None: + # 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_isolated/test_commandset/conftest.py b/tests_isolated/test_commandset/conftest.py index 6a3d66ab..fe6af8c3 100644 --- a/tests_isolated/test_commandset/conftest.py +++ b/tests_isolated/test_commandset/conftest.py @@ -74,7 +74,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 aliases and shortcuts expanded + -x, --expanded output fully parsed commands with any shortcuts, aliases, and macros 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_commandset.py b/tests_isolated/test_commandset/test_commandset.py index 9ea4eb3b..7498e145 100644 --- a/tests_isolated/test_commandset/test_commandset.py +++ b/tests_isolated/test_commandset/test_commandset.py @@ -343,17 +343,17 @@ def test_load_commandset_errors(command_sets_manual, capsys) -> None: delattr(command_sets_manual, 'do_durian') - # pre-create aliases with names which conflict with commands - command_sets_manual.app_cmd('alias create apple run_pyscript') + # pre-create intentionally conflicting macro and alias names + command_sets_manual.app_cmd('macro 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 are deleted with warning if they conflict with a command - assert "Deleting alias 'apple'" in err + # verify aliases and macros are deleted with warning if they conflict with a command 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): From c6e6229181f0a90c9d95856e39e9e9836b902f7d Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 17 Jul 2025 22:41:45 -0400 Subject: [PATCH 12/89] Update version of ruff used by pre-commit --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ea28343f..88e67c64 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.12.2" + rev: "v0.12.4" hooks: - id: ruff-format args: [--config=pyproject.toml] From dd46c351c447495069a1d6430026fc4da0ef3323 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 19 Jul 2025 13:50:53 -0400 Subject: [PATCH 13/89] Updated some help text and colors. --- cmd2/cmd2.py | 48 +++++++++++++++++++--------------------------- cmd2/rich_utils.py | 1 + 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 92a788ff..31aa9b1c 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -69,6 +69,7 @@ ) from rich.console import Group +from rich.text import Text from . import ( ansi, @@ -3369,37 +3370,23 @@ def _build_alias_create_parser(cls) -> Cmd2ArgumentParser: alias_create_description = "Create or overwrite an alias." alias_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_create_description) - # Create Notes TextGroup + # Add Notes epilog alias_create_notes = Group( "If you want to use redirection, pipes, or terminators in the value of the alias, then quote them.", "\n", + Text(" alias create save_results print_results \">\" out.txt\n", style="cmd2.example"), ( "Since aliases are resolved during parsing, tab completion will function as it would " "for the actual command the alias resolves to." ), ) - notes_group = alias_create_parser.create_text_group("Notes", alias_create_notes) - - # Create Examples TextGroup - alias_create_examples = Group( - "alias create ls !ls -lF", - "alias create show_log !cat \"log file.txt\"", - "alias create save_results print_results \">\" out.txt", - ) - examples_group = alias_create_parser.create_text_group("Examples", alias_create_examples) - - # Display both Notes and Examples in the epilog - alias_create_parser.epilog = Group( - notes_group, - "\n", - examples_group, - ) + alias_create_parser.epilog = alias_create_parser.create_text_group("Notes", alias_create_notes) # Add arguments alias_create_parser.add_argument('name', help='name of this alias') alias_create_parser.add_argument( 'command', - help='what the alias resolves to', + help='command, alias, or macro to run', choices_provider=cls._get_commands_aliases_and_macros_for_completion, ) alias_create_parser.add_argument( @@ -3600,15 +3587,19 @@ def _build_macro_create_parser(cls) -> Cmd2ArgumentParser: "\n", "The following creates a macro called my_macro that expects two arguments:", "\n", - " macro create my_macro make_dinner --meat {1} --veggie {2}", + Text(" macro create my_macro make_dinner --meat {1} --veggie {2}", style="cmd2.example"), "\n", "When the macro is called, the provided arguments are resolved and the assembled command is run. For example:", "\n", - " my_macro beef broccoli ---> make_dinner --meat beef --veggie broccoli", + Text.assemble( + (" my_macro beef broccoli", "cmd2.example"), + (" ───> ", "bold"), + ("make_dinner --meat beef --veggie broccoli", "cmd2.example"), + ), ) macro_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_create_description) - # Create Notes TextGroup + # Add Notes epilog macro_create_notes = Group( "To use the literal string {1} in your command, escape it this way: {{1}}.", "\n", @@ -3619,15 +3610,15 @@ def _build_macro_create_parser(cls) -> Cmd2ArgumentParser: "first argument will populate both {1} instances." ), "\n", - " macro create ft file_taxes -p {1} -q {2} -r {1}", + Text(" macro create ft file_taxes -p {1} -q {2} -r {1}", style="cmd2.example"), "\n", "To quote an argument in the resolved command, quote it during creation.", "\n", - " macro create backup !cp \"{1}\" \"{1}.orig\"", + Text(" macro create backup !cp \"{1}\" \"{1}.orig\"", style="cmd2.example"), "\n", "If you want to use redirection, pipes, or terminators in the value of the macro, then quote them.", "\n", - " macro create show_results print_results -type {1} \"|\" less", + Text(" macro create show_results print_results -type {1} \"|\" less", style="cmd2.example"), "\n", ( "Since macros don't resolve until after you press Enter, their arguments tab complete as paths. " @@ -3640,7 +3631,7 @@ def _build_macro_create_parser(cls) -> Cmd2ArgumentParser: macro_create_parser.add_argument('name', help='name of this macro') macro_create_parser.add_argument( 'command', - help='what the macro resolves to', + help='command, alias, or macro to run', choices_provider=cls._get_commands_aliases_and_macros_for_completion, ) macro_create_parser.add_argument( @@ -5147,13 +5138,14 @@ def _generate_transcript( @classmethod def _build_edit_parser(cls) -> Cmd2ArgumentParser: - from rich.markdown import Markdown - 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 `"), + Text.assemble( + "To set a new editor, run: ", + ("set editor ", "cmd2.example"), + ), ) edit_parser.add_argument( diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index c5d3264b..eb9b3923 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -42,6 +42,7 @@ def __repr__(self) -> str: "cmd2.warning": Style(color="bright_yellow"), "cmd2.error": Style(color="bright_red"), "cmd2.help_header": Style(color="bright_green", bold=True), + "cmd2.example": Style(color="cyan", bold=True), } # Include default styles from RichHelpFormatter From 3ee5e93e6bd14100cfd0943cf63dbe8da8534225 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 19 Jul 2025 19:05:48 -0400 Subject: [PATCH 14/89] Moved decorators._set_parser_prog() to argparse_custom.set_parser_prog(). --- cmd2/argparse_custom.py | 50 +++++++++++++++++++ cmd2/cmd2.py | 4 +- cmd2/decorators.py | 50 ------------------- tests/test_argparse.py | 4 +- .../test_argparse_subcommands.py | 2 +- 5 files changed, 54 insertions(+), 56 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 41f06354..95a3644b 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -291,6 +291,56 @@ def generate_range_error(range_min: int, range_max: float) -> str: return err_str +def set_parser_prog(parser: argparse.ArgumentParser, prog: str) -> None: + """Recursively set prog attribute of a parser and all of its subparsers. + + Does so that the root command is a command name and not sys.argv[0]. + + :param parser: the parser being edited + :param prog: new value for the parser's prog attribute + """ + # Set the prog value for this parser + parser.prog = prog + req_args: list[str] = [] + + # Set the prog value for the parser's subcommands + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + # Set the _SubParsersAction's _prog_prefix value. That way if its add_parser() method is called later, + # the correct prog value will be set on the parser being added. + action._prog_prefix = parser.prog + + # The keys of action.choices are subcommand names as well as subcommand aliases. The aliases point to the + # same parser as the actual subcommand. We want to avoid placing an alias into a parser's prog value. + # Unfortunately there is nothing about an action.choices entry which tells us it's an alias. In most cases + # we can filter out the aliases by checking the contents of action._choices_actions. This list only contains + # help information and names for the subcommands and not aliases. However, subcommands without help text + # won't show up in that list. Since dictionaries are ordered in Python 3.6 and above and argparse inserts the + # subcommand name into choices dictionary before aliases, we should be OK assuming the first time we see a + # parser, the dictionary key is a subcommand and not alias. + processed_parsers = [] + + # Set the prog value for each subcommand's parser + for subcmd_name, subcmd_parser in action.choices.items(): + # Check if we've already edited this parser + if subcmd_parser in processed_parsers: + continue + + subcmd_prog = parser.prog + if req_args: + subcmd_prog += " " + " ".join(req_args) + subcmd_prog += " " + subcmd_name + set_parser_prog(subcmd_parser, subcmd_prog) + processed_parsers.append(subcmd_parser) + + # We can break since argparse only allows 1 group of subcommands per level + break + + # Need to save required args so they can be prepended to the subcommand usage + if action.required: + req_args.append(action.dest) + + class CompletionItem(str): # noqa: SLOT000 """Completion item with descriptive text attached. diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 31aa9b1c..387b0df3 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -784,9 +784,7 @@ def _build_parser( else: raise TypeError(f"Invalid type for parser_builder: {type(parser_builder)}") - from .decorators import _set_parser_prog - - _set_parser_prog(parser, prog) + argparse_custom.set_parser_prog(parser, prog) return parser diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 61742ad3..246055fa 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -192,56 +192,6 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> Optional[bool]: return arg_decorator -def _set_parser_prog(parser: argparse.ArgumentParser, prog: str) -> None: - """Recursively set prog attribute of a parser and all of its subparsers. - - Does so that the root command is a command name and not sys.argv[0]. - - :param parser: the parser being edited - :param prog: new value for the parser's prog attribute - """ - # Set the prog value for this parser - parser.prog = prog - req_args: list[str] = [] - - # Set the prog value for the parser's subcommands - for action in parser._actions: - if isinstance(action, argparse._SubParsersAction): - # Set the _SubParsersAction's _prog_prefix value. That way if its add_parser() method is called later, - # the correct prog value will be set on the parser being added. - action._prog_prefix = parser.prog - - # The keys of action.choices are subcommand names as well as subcommand aliases. The aliases point to the - # same parser as the actual subcommand. We want to avoid placing an alias into a parser's prog value. - # Unfortunately there is nothing about an action.choices entry which tells us it's an alias. In most cases - # we can filter out the aliases by checking the contents of action._choices_actions. This list only contains - # help information and names for the subcommands and not aliases. However, subcommands without help text - # won't show up in that list. Since dictionaries are ordered in Python 3.6 and above and argparse inserts the - # subcommand name into choices dictionary before aliases, we should be OK assuming the first time we see a - # parser, the dictionary key is a subcommand and not alias. - processed_parsers = [] - - # Set the prog value for each subcommand's parser - for subcmd_name, subcmd_parser in action.choices.items(): - # Check if we've already edited this parser - if subcmd_parser in processed_parsers: - continue - - subcmd_prog = parser.prog - if req_args: - subcmd_prog += " " + " ".join(req_args) - subcmd_prog += " " + subcmd_name - _set_parser_prog(subcmd_parser, subcmd_prog) - processed_parsers.append(subcmd_parser) - - # We can break since argparse only allows 1 group of subcommands per level - break - - # Need to save required args so they can be prepended to the subcommand usage - if action.required: - req_args.append(action.dest) - - #: Function signatures for command functions that use an argparse.ArgumentParser to process user input #: and optionally return a boolean ArgparseCommandFuncOptionalBoolReturn = Callable[[CommandParent, argparse.Namespace], Optional[bool]] diff --git a/tests/test_argparse.py b/tests/test_argparse.py index 0ae9e724..7eb32076 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -290,7 +290,7 @@ def base_helpless(self, args) -> None: parser_bar.set_defaults(func=base_bar) # create the parser for the "helpless" subcommand - # This subcommand has aliases and no help text. It exists to prevent changes to _set_parser_prog() which + # This subcommand has aliases and no help text. It exists to prevent changes to set_parser_prog() which # use an approach which relies on action._choices_actions list. See comment in that function for more # details. parser_helpless = base_subparsers.add_parser('helpless', aliases=['helpless_1', 'helpless_2']) @@ -401,7 +401,7 @@ def test_subcommand_invalid_help(subcommand_app) -> None: def test_add_another_subcommand(subcommand_app) -> None: - """This tests makes sure _set_parser_prog() sets _prog_prefix on every _SubParsersAction so that all future calls + """This tests makes sure set_parser_prog() sets _prog_prefix on every _SubParsersAction so that all future calls to add_parser() write the correct prog value to the parser being added. """ base_parser = subcommand_app._command_parsers.get(subcommand_app.do_base) diff --git a/tests_isolated/test_commandset/test_argparse_subcommands.py b/tests_isolated/test_commandset/test_argparse_subcommands.py index ee0b08e7..a95c5777 100644 --- a/tests_isolated/test_commandset/test_argparse_subcommands.py +++ b/tests_isolated/test_commandset/test_argparse_subcommands.py @@ -45,7 +45,7 @@ def base_helpless(self, args) -> None: parser_bar.set_defaults(func=base_bar) # create the parser for the "helpless" subcommand - # This subcommand has aliases and no help text. It exists to prevent changes to _set_parser_prog() which + # This subcommand has aliases and no help text. It exists to prevent changes to set_parser_prog() which # use an approach which relies on action._choices_actions list. See comment in that function for more # details. parser_helpless = base_subparsers.add_parser('helpless', aliases=['helpless_1', 'helpless_2']) From 076d0e02eddc130236ceb8d82fbfc09133681971 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 19 Jul 2025 20:47:01 -0400 Subject: [PATCH 15/89] Changed do_help() so that argparsers print their own help. --- cmd2/argparse_completer.py | 31 ++++++++++++++++++------------- cmd2/cmd2.py | 4 +--- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 44f64ee1..1b1efce7 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -10,6 +10,7 @@ deque, ) from typing import ( + IO, TYPE_CHECKING, Optional, Union, @@ -624,24 +625,28 @@ def complete_subcommand_help(self, text: str, line: str, begidx: int, endidx: in break return [] - def format_help(self, tokens: list[str]) -> str: - """Supports cmd2's help command in the retrieval of help text. + def print_help(self, tokens: list[str], file: Optional[IO[str]] = None) -> None: + """Supports cmd2's help command in the printing of help text. :param tokens: arguments passed to help command - :return: help text of the command being queried. + :param file: optional file object where the argparse should write help text + If not supplied, argparse will write to sys.stdout. """ - # If our parser has subcommands, we must examine the tokens and check if they are subcommands + # If our parser has subcommands, we must examine the tokens and check if they are subcommands. # If so, we will let the subcommand's parser handle the rest of the tokens via another ArgparseCompleter. - if self._subcommand_action is not None: - for token_index, token in enumerate(tokens): - if token in self._subcommand_action.choices: - parser: argparse.ArgumentParser = self._subcommand_action.choices[token] - completer_type = self._cmd2_app._determine_ap_completer_type(parser) + if tokens and self._subcommand_action is not None: + parser = cast( + Optional[argparse.ArgumentParser], + self._subcommand_action.choices.get(tokens[0]), + ) - completer = completer_type(parser, self._cmd2_app) - return completer.format_help(tokens[token_index + 1 :]) - break - return self._parser.format_help() + if parser: + completer_type = self._cmd2_app._determine_ap_completer_type(parser) + completer = completer_type(parser, self._cmd2_app) + completer.print_help(tokens[1:]) + return + + self._parser.print_help(file=file) def _complete_arg( self, diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 387b0df3..d9bc4abc 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3876,9 +3876,7 @@ def do_help(self, args: argparse.Namespace) -> None: # If the command function uses argparse, then use argparse's help if func is not None and argparser is not None: completer = argparse_completer.DEFAULT_AP_COMPLETER(argparser, self) - - # Set end to blank so the help output matches how it looks when "command -h" is used - self.poutput(completer.format_help(args.subcommands), end='') + completer.print_help(args.subcommands, self.stdout) # If there is a help func delegate to do_help elif help_func is not None: From 1578fb3e226455a2194c6b6bb4654d81f769154d Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 21 Jul 2025 22:46:06 -0400 Subject: [PATCH 16/89] Only redirect sys.stdout when it's the same as self.stdout. --- cmd2/cmd2.py | 38 ++++++++++++++++++++------------------ cmd2/py_bridge.py | 18 ++++++++++++------ cmd2/utils.py | 8 ++++---- 3 files changed, 36 insertions(+), 28 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index d9bc4abc..5337f144 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -13,8 +13,8 @@ Easy transcript-based testing of applications (see examples/example.py) Bash-style ``select`` available -Note that redirection with > and | will only work if `self.poutput()` -is used in place of `print`. +Note, if self.stdout is different than sys.stdout, then redirection with > and | +will only work if `self.poutput()` is used in place of `print`. - Catherine Devlin, Jan 03 2008 - catherinedevlin.blogspot.com @@ -200,8 +200,6 @@ def __init__(self) -> None: self.readline_settings = _SavedReadlineSettings() self.readline_module: Optional[ModuleType] = None self.history: list[str] = [] - self.sys_stdout: Optional[TextIO] = None - self.sys_stdin: Optional[TextIO] = None # Contains data about a disabled command which is used to restore its original functions when the command is enabled @@ -2854,9 +2852,12 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: """ import subprocess + # Only redirect sys.stdout if it's the same as self.stdout + stdouts_match = self.stdout == sys.stdout + # Initialize the redirection saved state redir_saved_state = utils.RedirectionSavedState( - cast(TextIO, self.stdout), sys.stdout, self._cur_pipe_proc_reader, self._redirecting + cast(TextIO, self.stdout), stdouts_match, self._cur_pipe_proc_reader, self._redirecting ) # The ProcReader for this command @@ -2912,7 +2913,10 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: raise RedirectionError(f'Pipe process exited with code {proc.returncode} before command could run') redir_saved_state.redirecting = True # type: ignore[unreachable] cmd_pipe_proc_reader = utils.ProcReader(proc, cast(TextIO, self.stdout), sys.stderr) - sys.stdout = self.stdout = new_stdout + + self.stdout = new_stdout + if stdouts_match: + sys.stdout = self.stdout elif statement.output: if statement.output_to: @@ -2926,7 +2930,10 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: raise RedirectionError('Failed to redirect output') from ex redir_saved_state.redirecting = True - sys.stdout = self.stdout = new_stdout + + self.stdout = new_stdout + if stdouts_match: + sys.stdout = self.stdout else: # Redirecting to a paste buffer @@ -2944,7 +2951,10 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: # create a temporary file to store output new_stdout = cast(TextIO, tempfile.TemporaryFile(mode="w+")) # noqa: SIM115 redir_saved_state.redirecting = True - sys.stdout = self.stdout = new_stdout + + self.stdout = new_stdout + if stdouts_match: + sys.stdout = self.stdout if statement.output == constants.REDIRECTION_APPEND: self.stdout.write(current_paste_buffer) @@ -2974,7 +2984,8 @@ def _restore_output(self, statement: Statement, saved_redir_state: utils.Redirec # Restore the stdout values self.stdout = cast(TextIO, saved_redir_state.saved_self_stdout) - sys.stdout = cast(TextIO, saved_redir_state.saved_sys_stdout) + if saved_redir_state.stdouts_match: + sys.stdout = self.stdout # Check if we need to wait for the process being piped to if self._cur_pipe_proc_reader is not None: @@ -4449,12 +4460,6 @@ def _set_up_py_shell_env(self, interp: InteractiveConsole) -> _SavedCmd2Env: # Set up sys module for the Python console self._reset_py_display() - cmd2_env.sys_stdout = sys.stdout - sys.stdout = self.stdout # type: ignore[assignment] - - cmd2_env.sys_stdin = sys.stdin - sys.stdin = self.stdin # type: ignore[assignment] - return cmd2_env def _restore_cmd2_env(self, cmd2_env: _SavedCmd2Env) -> None: @@ -4462,9 +4467,6 @@ def _restore_cmd2_env(self, cmd2_env: _SavedCmd2Env) -> None: :param cmd2_env: the environment settings to restore """ - sys.stdout = cmd2_env.sys_stdout # type: ignore[assignment] - sys.stdin = cmd2_env.sys_stdin # type: ignore[assignment] - # Set up readline for cmd2 if rl_type != RlType.NONE: # Save py's history diff --git a/cmd2/py_bridge.py b/cmd2/py_bridge.py index 2a147583..bd376bcb 100644 --- a/cmd2/py_bridge.py +++ b/cmd2/py_bridge.py @@ -4,10 +4,7 @@ """ import sys -from contextlib import ( - redirect_stderr, - redirect_stdout, -) +from contextlib import redirect_stderr from typing import ( IO, TYPE_CHECKING, @@ -113,6 +110,8 @@ def __call__(self, command: str, *, echo: Optional[bool] = None) -> CommandResul if echo is None: echo = self.cmd_echo + stdouts_match = self._cmd2_app.stdout == sys.stdout + # This will be used to capture _cmd2_app.stdout and sys.stdout copy_cmd_stdout = StdSim(cast(Union[TextIO, StdSim], self._cmd2_app.stdout), echo=echo) @@ -126,8 +125,12 @@ def __call__(self, command: str, *, echo: Optional[bool] = None) -> CommandResul stop = False try: - self._cmd2_app.stdout = cast(TextIO, copy_cmd_stdout) - with redirect_stdout(cast(IO[str], copy_cmd_stdout)), redirect_stderr(cast(IO[str], copy_stderr)): + with self._cmd2_app.sigint_protection: + self._cmd2_app.stdout = cast(TextIO, copy_cmd_stdout) + if stdouts_match: + sys.stdout = self._cmd2_app.stdout + + with redirect_stderr(cast(IO[str], copy_stderr)): stop = self._cmd2_app.onecmd_plus_hooks( command, add_to_history=self._add_to_history, @@ -136,6 +139,9 @@ def __call__(self, command: str, *, echo: Optional[bool] = None) -> CommandResul finally: with self._cmd2_app.sigint_protection: self._cmd2_app.stdout = cast(IO[str], copy_cmd_stdout.inner_stream) + if stdouts_match: + sys.stdout = self._cmd2_app.stdout + self.stop = stop or self.stop # Save the result diff --git a/cmd2/utils.py b/cmd2/utils.py index 1c3506e6..da01ff39 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -683,23 +683,23 @@ class RedirectionSavedState: def __init__( self, self_stdout: Union[StdSim, TextIO], - sys_stdout: Union[StdSim, TextIO], + stdouts_match: bool, pipe_proc_reader: Optional[ProcReader], saved_redirecting: bool, ) -> None: """RedirectionSavedState initializer. :param self_stdout: saved value of Cmd.stdout - :param sys_stdout: saved value of sys.stdout + :param stdouts_match: True if Cmd.stdout is equal to sys.stdout :param pipe_proc_reader: saved value of Cmd._cur_pipe_proc_reader :param saved_redirecting: saved value of Cmd._redirecting. """ # Tells if command is redirecting self.redirecting = False - # Used to restore values after redirection ends + # Used to restore stdout values after redirection ends self.saved_self_stdout = self_stdout - self.saved_sys_stdout = sys_stdout + self.stdouts_match = stdouts_match # Used to restore values after command ends regardless of whether the command redirected self.saved_pipe_proc_reader = pipe_proc_reader From 37c03c594b5587dd290e381a29dad4902c83279b Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 22 Jul 2025 10:27:53 -0400 Subject: [PATCH 17/89] Added unit tests for when to redirect/capture sys.stdout. --- CHANGELOG.md | 4 + cmd2/py_bridge.py | 1 + tests/conftest.py | 17 +-- tests/pyscript/stdout_capture.py | 29 +---- tests/test_argparse_completer.py | 9 +- tests/test_cmd2.py | 192 ++++++++++++++++++++----------- tests/test_run_pyscript.py | 38 +++--- 7 files changed, 168 insertions(+), 122 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27cdb5b1..09f27ec9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ - Added `Cmd.macro_arg_complete()` which tab completes arguments to a macro. Its default behavior is to perform path completion, but it can be overridden as needed. + - Bug Fixes + - No longer redirecting `sys.stdout` if it's a different stream than `self.stdout`. This + fixes issue where we overwrote an application's `sys.stdout` while redirecting. + ## 2.7.0 (June 30, 2025) - Enhancements diff --git a/cmd2/py_bridge.py b/cmd2/py_bridge.py index bd376bcb..fe340523 100644 --- a/cmd2/py_bridge.py +++ b/cmd2/py_bridge.py @@ -110,6 +110,7 @@ def __call__(self, command: str, *, echo: Optional[bool] = None) -> CommandResul if echo is None: echo = self.cmd_echo + # Only capture sys.stdout if it's the same stream as self.stdout stdouts_match = self._cmd2_app.stdout == sys.stdout # This will be used to capture _cmd2_app.stdout and sys.stdout diff --git a/tests/conftest.py b/tests/conftest.py index 253d9fcd..35bb90e6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,10 +2,7 @@ import argparse import sys -from contextlib import ( - redirect_stderr, - redirect_stdout, -) +from contextlib import redirect_stderr from typing import ( Optional, Union, @@ -116,8 +113,9 @@ def normalize(block): def run_cmd(app, cmd): """Clear out and err StdSim buffers, run the command, and return out and err""" - saved_sysout = sys.stdout - sys.stdout = app.stdout + + # Only capture sys.stdout if it's the same stream as self.stdout + stdouts_match = app.stdout == sys.stdout # This will be used to capture app.stdout and sys.stdout copy_cmd_stdout = StdSim(app.stdout) @@ -127,11 +125,14 @@ def run_cmd(app, cmd): try: app.stdout = copy_cmd_stdout - with redirect_stdout(copy_cmd_stdout), redirect_stderr(copy_stderr): + if stdouts_match: + sys.stdout = app.stdout + with redirect_stderr(copy_stderr): app.onecmd_plus_hooks(cmd) finally: app.stdout = copy_cmd_stdout.inner_stream - sys.stdout = saved_sysout + if stdouts_match: + sys.stdout = app.stdout out = copy_cmd_stdout.getvalue() err = copy_stderr.getvalue() diff --git a/tests/pyscript/stdout_capture.py b/tests/pyscript/stdout_capture.py index 5cc0cf3a..7cc6641c 100644 --- a/tests/pyscript/stdout_capture.py +++ b/tests/pyscript/stdout_capture.py @@ -1,25 +1,4 @@ -# This script demonstrates when output of a command finalization hook is captured by a pyscript app() call -import sys - -# The unit test framework passes in the string being printed by the command finalization hook -hook_output = sys.argv[1] - -# Run a help command which results in 1 call to onecmd_plus_hooks -res = app('help') - -# hook_output will not be captured because there are no nested calls to onecmd_plus_hooks -if hook_output not in res.stdout: - print("PASSED") -else: - print("FAILED") - -# Run the last command in the history -res = app('history -r -1') - -# All output of the history command will be captured. This includes all output of the commands -# started in do_history() using onecmd_plus_hooks(), including any output in those commands' hooks. -# Therefore we expect the hook_output to show up this time. -if hook_output in res.stdout: - print("PASSED") -else: - print("FAILED") +# This script demonstrates that cmd2 can capture sys.stdout and self.stdout when both point to the same stream. +# Set base_app.self_in_py to True before running this script. +print("print") +self.poutput("poutput") diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index f6561321..b6713e87 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -15,10 +15,7 @@ argparse_custom, with_argparser, ) -from cmd2.utils import ( - StdSim, - align_right, -) +from cmd2.utils import align_right from .conftest import ( complete_tester, @@ -334,9 +331,7 @@ def do_standalone(self, args: argparse.Namespace) -> None: @pytest.fixture def ac_app(): - app = ArgparseCompleterTester() - app.stdout = StdSim(app.stdout) - return app + return ArgparseCompleterTester() @pytest.mark.parametrize('command', ['music', 'music create', 'music create rock', 'music create jazz']) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index fa9ee561..75179723 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -627,38 +627,85 @@ def do_passthrough(self, _) -> NoReturn: base_app.onecmd_plus_hooks('passthrough') -def test_output_redirection(base_app) -> None: +class RedirectionApp(cmd2.Cmd): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + def do_print_output(self, _: str) -> None: + """Print output to sys.stdout and self.stdout..""" + print("print") + self.poutput("poutput") + + def do_print_feedback(self, _: str) -> None: + """Call pfeedback.""" + self.pfeedback("feedback") + + +@pytest.fixture +def redirection_app(): + return RedirectionApp() + + +def test_output_redirection(redirection_app) -> None: fd, filename = tempfile.mkstemp(prefix='cmd2_test', suffix='.txt') os.close(fd) try: # Verify that writing to a file works - run_cmd(base_app, f'help > {filename}') + run_cmd(redirection_app, f'print_output > {filename}') + with open(filename) as f: + lines = f.read().splitlines() + assert lines[0] == "print" + assert lines[1] == "poutput" + + # Verify that appending to a file also works + run_cmd(redirection_app, f'print_output >> {filename}') + with open(filename) as f: + lines = f.read().splitlines() + assert lines[0] == "print" + assert lines[1] == "poutput" + assert lines[2] == "print" + assert lines[3] == "poutput" + finally: + os.remove(filename) + + +def test_output_redirection_custom_stdout(redirection_app) -> None: + """sys.stdout should not redirect if it's different than self.stdout.""" + fd, filename = tempfile.mkstemp(prefix='cmd2_test', suffix='.txt') + os.close(fd) + + redirection_app.stdout = io.StringIO() + try: + # Verify that we only see output written to self.stdout + run_cmd(redirection_app, f'print_output > {filename}') with open(filename) as f: - content = f.read() - verify_help_text(base_app, content) + lines = f.read().splitlines() + assert "print" not in lines + assert lines[0] == "poutput" # Verify that appending to a file also works - run_cmd(base_app, f'help history >> {filename}') + run_cmd(redirection_app, f'print_output >> {filename}') with open(filename) as f: - appended_content = f.read() - assert appended_content.startswith(content) - assert len(appended_content) > len(content) + lines = f.read().splitlines() + assert "print" not in lines + assert lines[0] == "poutput" + assert lines[1] == "poutput" finally: os.remove(filename) -def test_output_redirection_to_nonexistent_directory(base_app) -> None: +def test_output_redirection_to_nonexistent_directory(redirection_app) -> None: filename = '~/fakedir/this_does_not_exist.txt' - out, err = run_cmd(base_app, f'help > {filename}') + out, err = run_cmd(redirection_app, f'print_output > {filename}') assert 'Failed to redirect' in err[0] - out, err = run_cmd(base_app, f'help >> {filename}') + out, err = run_cmd(redirection_app, f'print_output >> {filename}') assert 'Failed to redirect' in err[0] -def test_output_redirection_to_too_long_filename(base_app) -> None: +def test_output_redirection_to_too_long_filename(redirection_app) -> None: filename = ( '~/sdkfhksdjfhkjdshfkjsdhfkjsdhfkjdshfkjdshfkjshdfkhdsfkjhewfuihewiufhweiufhiweufhiuewhiuewhfiuwehfia' 'ewhfiuewhfiuewhfiuewhiuewhfiuewhfiuewfhiuwehewiufhewiuhfiweuhfiuwehfiuewfhiuwehiuewfhiuewhiewuhfiueh' @@ -667,93 +714,86 @@ def test_output_redirection_to_too_long_filename(base_app) -> None: 'whfieuwfhieufhiuewhfeiuwfhiuefhueiwhfw' ) - out, err = run_cmd(base_app, f'help > {filename}') + out, err = run_cmd(redirection_app, f'print_output > {filename}') assert 'Failed to redirect' in err[0] - out, err = run_cmd(base_app, f'help >> {filename}') + out, err = run_cmd(redirection_app, f'print_output >> {filename}') assert 'Failed to redirect' in err[0] -def test_feedback_to_output_true(base_app) -> None: - base_app.feedback_to_output = True - base_app.timing = True +def test_feedback_to_output_true(redirection_app) -> None: + redirection_app.feedback_to_output = True f, filename = tempfile.mkstemp(prefix='cmd2_test', suffix='.txt') os.close(f) try: - run_cmd(base_app, f'help > {filename}') + run_cmd(redirection_app, f'print_feedback > {filename}') with open(filename) as f: - content = f.readlines() - assert content[-1].startswith('Elapsed: ') + content = f.read().splitlines() + assert "feedback" in content finally: os.remove(filename) -def test_feedback_to_output_false(base_app) -> None: - base_app.feedback_to_output = False - base_app.timing = True +def test_feedback_to_output_false(redirection_app) -> None: + redirection_app.feedback_to_output = False f, filename = tempfile.mkstemp(prefix='feedback_to_output', suffix='.txt') os.close(f) try: - out, err = run_cmd(base_app, f'help > {filename}') + out, err = run_cmd(redirection_app, f'print_feedback > {filename}') with open(filename) as f: - content = f.readlines() - assert not content[-1].startswith('Elapsed: ') - assert err[0].startswith('Elapsed') + content = f.read().splitlines() + assert not content + assert "feedback" in err finally: os.remove(filename) -def test_disallow_redirection(base_app) -> None: +def test_disallow_redirection(redirection_app) -> None: # Set allow_redirection to False - base_app.allow_redirection = False + redirection_app.allow_redirection = False filename = 'test_allow_redirect.txt' # Verify output wasn't redirected - out, err = run_cmd(base_app, f'help > {filename}') - verify_help_text(base_app, out) + out, err = run_cmd(redirection_app, f'print_output > {filename}') + assert "print" in out + assert "poutput" in out # Verify that no file got created assert not os.path.exists(filename) -def test_pipe_to_shell(base_app) -> None: - if sys.platform == "win32": - # Windows - command = 'help | sort' - else: - # Mac and Linux - # Get help on help and pipe it's output to the input of the word count shell command - command = 'help help | wc' +def test_pipe_to_shell(redirection_app) -> None: + out, err = run_cmd(redirection_app, "print_output | sort") + assert "print" in out + assert "poutput" in out + assert not err + - out, err = run_cmd(base_app, command) - assert out +def test_pipe_to_shell_custom_stdout(redirection_app) -> None: + """sys.stdout should not redirect if it's different than self.stdout.""" + redirection_app.stdout = io.StringIO() + out, err = run_cmd(redirection_app, "print_output | sort") + assert "print" not in out + assert "poutput" in out assert not err -def test_pipe_to_shell_and_redirect(base_app) -> None: +def test_pipe_to_shell_and_redirect(redirection_app) -> None: filename = 'out.txt' - if sys.platform == "win32": - # Windows - command = f'help | sort > {filename}' - else: - # Mac and Linux - # Get help on help and pipe it's output to the input of the word count shell command - command = f'help help | wc > {filename}' - - out, err = run_cmd(base_app, command) + out, err = run_cmd(redirection_app, f"print_output | sort > {filename}") assert not out assert not err assert os.path.exists(filename) os.remove(filename) -def test_pipe_to_shell_error(base_app) -> None: +def test_pipe_to_shell_error(redirection_app) -> None: # Try to pipe command output to a shell command that doesn't exist in order to produce an error - out, err = run_cmd(base_app, 'help | foobarbaz.this_does_not_exist') + out, err = run_cmd(redirection_app, 'print_output | foobarbaz.this_does_not_exist') assert not out assert "Pipe process exited with code" in err[0] @@ -773,26 +813,48 @@ def test_pipe_to_shell_error(base_app) -> None: @pytest.mark.skipif(not can_paste, reason="Pyperclip could not find a copy/paste mechanism for your system") -def test_send_to_paste_buffer(base_app) -> None: +def test_send_to_paste_buffer(redirection_app) -> None: # Test writing to the PasteBuffer/Clipboard - run_cmd(base_app, 'help >') - paste_contents = cmd2.cmd2.get_paste_buffer() - verify_help_text(base_app, paste_contents) + run_cmd(redirection_app, 'print_output >') + lines = cmd2.cmd2.get_paste_buffer().splitlines() + assert lines[0] == "print" + assert lines[1] == "poutput" + + # Test appending to the PasteBuffer/Clipboard + run_cmd(redirection_app, 'print_output >>') + lines = cmd2.cmd2.get_paste_buffer().splitlines() + assert lines[0] == "print" + assert lines[1] == "poutput" + assert lines[2] == "print" + assert lines[3] == "poutput" + + +@pytest.mark.skipif(not can_paste, reason="Pyperclip could not find a copy/paste mechanism for your system") +def test_send_to_paste_buffer_custom_stdout(redirection_app) -> None: + """sys.stdout should not redirect if it's different than self.stdout.""" + redirection_app.stdout = io.StringIO() + + # Verify that we only see output written to self.stdout + run_cmd(redirection_app, 'print_output >') + lines = cmd2.cmd2.get_paste_buffer().splitlines() + assert "print" not in lines + assert lines[0] == "poutput" # Test appending to the PasteBuffer/Clipboard - run_cmd(base_app, 'help history >>') - appended_contents = cmd2.cmd2.get_paste_buffer() - assert appended_contents.startswith(paste_contents) - assert len(appended_contents) > len(paste_contents) + run_cmd(redirection_app, 'print_output >>') + lines = cmd2.cmd2.get_paste_buffer().splitlines() + assert "print" not in lines + assert lines[0] == "poutput" + assert lines[1] == "poutput" -def test_get_paste_buffer_exception(base_app, mocker, capsys) -> None: +def test_get_paste_buffer_exception(redirection_app, mocker, capsys) -> None: # Force get_paste_buffer to throw an exception pastemock = mocker.patch('pyperclip.paste') pastemock.side_effect = ValueError('foo') # Redirect command output to the clipboard - base_app.onecmd_plus_hooks('help > ') + redirection_app.onecmd_plus_hooks('print_output > ') # Make sure we got the exception output out, err = capsys.readouterr() @@ -802,8 +864,8 @@ def test_get_paste_buffer_exception(base_app, mocker, capsys) -> None: assert 'foo' in err -def test_allow_clipboard_initializer(base_app) -> None: - assert base_app.allow_clipboard is True +def test_allow_clipboard_initializer(redirection_app) -> None: + assert redirection_app.allow_clipboard is True noclipcmd = cmd2.Cmd(allow_clipboard=False) assert noclipcmd.allow_clipboard is False diff --git a/tests/test_run_pyscript.py b/tests/test_run_pyscript.py index a64f77ba..78739dc4 100644 --- a/tests/test_run_pyscript.py +++ b/tests/test_run_pyscript.py @@ -8,24 +8,13 @@ import pytest -from cmd2 import ( - plugin, - utils, -) +from cmd2 import utils from .conftest import ( odd_file_names, run_cmd, ) -HOOK_OUTPUT = "TEST_OUTPUT" - - -def cmdfinalization_hook(data: plugin.CommandFinalizationData) -> plugin.CommandFinalizationData: - """A cmdfinalization_hook hook which requests application exit""" - print(HOOK_OUTPUT) - return data - def test_run_pyscript(base_app, request) -> None: test_dir = os.path.dirname(request.module.__file__) @@ -133,14 +122,29 @@ def test_run_pyscript_dir(base_app, request) -> None: assert out[0] == "['cmd_echo']" -def test_run_pyscript_stdout_capture(base_app, request) -> None: - base_app.register_cmdfinalization_hook(cmdfinalization_hook) +def test_run_pyscript_capture(base_app, request) -> None: + base_app.self_in_py = True test_dir = os.path.dirname(request.module.__file__) python_script = os.path.join(test_dir, 'pyscript', 'stdout_capture.py') - out, err = run_cmd(base_app, f'run_pyscript {python_script} {HOOK_OUTPUT}') + out, err = run_cmd(base_app, f'run_pyscript {python_script}') - assert out[0] == "PASSED" - assert out[1] == "PASSED" + assert out[0] == "print" + assert out[1] == "poutput" + + +def test_run_pyscript_capture_custom_stdout(base_app, request) -> None: + """sys.stdout will not be captured if it's different than self.stdout.""" + import io + + base_app.stdout = io.StringIO() + + base_app.self_in_py = True + test_dir = os.path.dirname(request.module.__file__) + python_script = os.path.join(test_dir, 'pyscript', 'stdout_capture.py') + out, err = run_cmd(base_app, f'run_pyscript {python_script}') + + assert "print" not in out + assert out[0] == "poutput" def test_run_pyscript_stop(base_app, request) -> None: From 4f3ee8d99e9e0fa04718f2433ccdc6e6b49d6fda Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sun, 27 Jul 2025 18:41:16 -0400 Subject: [PATCH 18/89] Add mypy type checking for Python 3.14 and use open() instead of codecs.open() due to deprecation in 3.14 (#1470) --- .github/workflows/typecheck.yml | 2 +- cmd2/utils.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index f4bae5ad..45e65d84 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] fail-fast: false defaults: run: diff --git a/cmd2/utils.py b/cmd2/utils.py index da01ff39..fac5f07f 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -183,14 +183,12 @@ def is_text_file(file_path: str) -> bool: :return: True if the file is a non-empty text file, otherwise False :raises OSError: if file can't be read """ - import codecs - expanded_path = os.path.abspath(os.path.expanduser(file_path.strip())) valid_text_file = False # Only need to check for utf-8 compliance since that covers ASCII, too try: - with codecs.open(expanded_path, encoding='utf-8', errors='strict') as f: + with open(expanded_path, encoding='utf-8', errors='strict') as f: # Make sure the file has only utf-8 text and is not empty if sum(1 for _ in f) > 0: valid_text_file = True From 5ac81d4325e14f09a0fcaebac4e3c6d6b2d30eff Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 5 Aug 2025 10:12:16 -0400 Subject: [PATCH 19/89] Enabled all printing methods to support Rich objects. (#1471) --- CHANGELOG.md | 9 +- cmd2/cmd2.py | 475 ++++++++++++++++++++++---------- cmd2/rich_utils.py | 103 ++++++- cmd2/rl_utils.py | 6 +- tests/test_cmd2.py | 115 ++++---- tests/transcripts/regex_set.txt | 30 +- 6 files changed, 519 insertions(+), 219 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09f27ec9..4e97ffaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,12 @@ - Added `Cmd.macro_arg_complete()` which tab completes arguments to a macro. Its default behavior is to perform path completion, but it can be overridden as needed. - - Bug Fixes - - No longer redirecting `sys.stdout` if it's a different stream than `self.stdout`. This - fixes issue where we overwrote an application's `sys.stdout` while redirecting. + - All print methods (`poutput()`, `perror()`, `ppaged()`, etc.) have the ability to print Rich + objects. + +- Bug Fixes + - No longer redirecting `sys.stdout` if it's a different stream than `self.stdout`. This fixes + issue where we overwrote an application's `sys.stdout` while redirecting. ## 2.7.0 (June 30, 2025) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 5337f144..da308ef4 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -24,10 +24,7 @@ # This module has many imports, quite a few of which are only # infrequently utilized. To reduce the initial overhead of # import this module, many of these imports are lazy-loaded -# i.e. we only import the module when we use it -# For example, we don't import the 'traceback' module -# until the pexcept() function is called and the debug -# setting is True +# i.e. we only import the module when we use it. import argparse import cmd import contextlib @@ -36,7 +33,6 @@ import glob import inspect import os -import pprint import pydoc import re import sys @@ -69,6 +65,7 @@ ) from rich.console import Group +from rich.style import StyleType from rich.text import Text from . import ( @@ -127,6 +124,7 @@ StatementParser, shlex_split, ) +from .rich_utils import Cmd2Console, RichPrintKwargs # NOTE: When using gnureadline with Python 3.13, start_ipython needs to be imported before any readline-related stuff with contextlib.suppress(ImportError): @@ -158,7 +156,7 @@ # Set up readline if rl_type == RlType.NONE: # pragma: no cover - sys.stderr.write(ansi.style_warning(rl_warning)) + Cmd2Console(sys.stderr).print(Text(rl_warning, style="cmd2.warning")) else: from .rl_utils import ( # type: ignore[attr-defined] readline, @@ -416,7 +414,7 @@ def __init__( # Use as prompt for multiline commands on the 2nd+ line of input self.continuation_prompt: str = '> ' - # Allow access to your application in embedded Python shells and scripts py via self + # Allow access to your application in embedded Python shells and pyscripts via self self.self_in_py = False # Commands to exclude from the help menu and tab completion @@ -513,7 +511,7 @@ def __init__( elif transcript_files: self._transcript_files = transcript_files - # Set the pager(s) for use with the ppaged() method for displaying output using a pager + # Set the pager(s) for use when displaying output using a pager if sys.platform.startswith('win'): self.pager = self.pager_chop = 'more' else: @@ -937,7 +935,7 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: subcommand_valid, errmsg = self.statement_parser.is_valid_command(subcommand_name, is_subcommand=True) if not subcommand_valid: - raise CommandSetRegistrationError(f'Subcommand {subcommand_name!s} is not valid: {errmsg}') + raise CommandSetRegistrationError(f'Subcommand {subcommand_name} is not valid: {errmsg}') command_tokens = full_command_name.split() command_name = command_tokens[0] @@ -950,11 +948,11 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: command_func = self.cmd_func(command_name) if command_func is None: - raise CommandSetRegistrationError(f"Could not find command '{command_name}' needed by subcommand: {method!s}") + raise CommandSetRegistrationError(f"Could not find command '{command_name}' needed by subcommand: {method}") command_parser = self._command_parsers.get(command_func) if command_parser is None: raise CommandSetRegistrationError( - f"Could not find argparser for command '{command_name}' needed by subcommand: {method!s}" + f"Could not find argparser for command '{command_name}' needed by subcommand: {method}" ) def find_subcommand(action: argparse.ArgumentParser, subcmd_names: list[str]) -> argparse.ArgumentParser: @@ -1044,13 +1042,13 @@ def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: if command_func is None: # pragma: no cover # This really shouldn't be possible since _register_subcommands would prevent this from happening # but keeping in case it does for some strange reason - raise CommandSetRegistrationError(f"Could not find command '{command_name}' needed by subcommand: {method!s}") + raise CommandSetRegistrationError(f"Could not find command '{command_name}' needed by subcommand: {method}") command_parser = self._command_parsers.get(command_func) if command_parser is None: # pragma: no cover # This really shouldn't be possible since _register_subcommands would prevent this from happening # but keeping in case it does for some strange reason raise CommandSetRegistrationError( - f"Could not find argparser for command '{command_name}' needed by subcommand: {method!s}" + f"Could not find argparser for command '{command_name}' needed by subcommand: {method}" ) for action in command_parser._actions: @@ -1129,11 +1127,11 @@ def allow_style_type(value: str) -> rich_utils.AllowStyle: """Convert a string value into an rich_utils.AllowStyle.""" try: return rich_utils.AllowStyle[value.upper()] - except KeyError as esc: + except KeyError as ex: raise ValueError( f"must be {rich_utils.AllowStyle.ALWAYS}, {rich_utils.AllowStyle.NEVER}, or " f"{rich_utils.AllowStyle.TERMINAL} (case-insensitive)" - ) from esc + ) from ex self.add_settable( Settable( @@ -1189,170 +1187,364 @@ def visible_prompt(self) -> str: def print_to( self, - dest: IO[str], - msg: Any, - *, - end: str = '\n', - style: Optional[Callable[[str], str]] = None, + file: IO[str], + *objects: Any, + sep: str = " ", + end: str = "\n", + style: Optional[StyleType] = None, + soft_wrap: Optional[bool] = None, + rich_print_kwargs: Optional[RichPrintKwargs] = None, + **kwargs: Any, # noqa: ARG002 ) -> None: - """Print message to a given file object. - - :param dest: the file object being written to - :param msg: object to print - :param end: string appended after the end of the message, default a newline - :param style: optional style function to format msg with (e.g. ansi.style_success) + """Print objects to a given file stream. + + :param file: file stream being written to + :param objects: objects to print + :param sep: string to write between print data. Defaults to " ". + :param end: string to write at end of print data. Defaults to a newline. + :param style: optional style to apply to output + :param soft_wrap: Enable soft wrap mode. If True, text lines will not be automatically word-wrapped to fit the + terminal width; instead, any text that doesn't fit will run onto the following line(s), + similar to the built-in print() function. Set to False to enable automatic word-wrapping. + If None (the default for this parameter), the output will default to no word-wrapping, as + configured by the Cmd2Console. + :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). + :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this + method and still call `super()` without encountering unexpected keyword argument errors. + These arguments are not passed to Rich's Console.print(). """ - final_msg = style(msg) if style is not None else msg + prepared_objects = rich_utils.prepare_objects_for_rich_print(*objects) + try: - ansi.style_aware_write(dest, f'{final_msg}{end}') + Cmd2Console(file).print( + *prepared_objects, + sep=sep, + end=end, + style=style, + soft_wrap=soft_wrap, + **(rich_print_kwargs if rich_print_kwargs is not None else {}), + ) except BrokenPipeError: # This occurs if a command's output is being piped to another - # process and that process closes before the command is - # finished. If you would like your application to print a + # process which closes the pipe before the command is finished + # writing. If you would like your application to print a # warning message, then set the broken_pipe_warning attribute # to the message you want printed. - if self.broken_pipe_warning: - sys.stderr.write(self.broken_pipe_warning) + if self.broken_pipe_warning and file != sys.stderr: + Cmd2Console(sys.stderr).print(self.broken_pipe_warning) - def poutput(self, msg: Any = '', *, end: str = '\n') -> None: - """Print message to self.stdout and appends a newline by default. - - :param msg: object to print - :param end: string appended after the end of the message, default a newline + def poutput( + self, + *objects: Any, + sep: str = " ", + end: str = "\n", + style: Optional[StyleType] = None, + soft_wrap: Optional[bool] = None, + rich_print_kwargs: Optional[RichPrintKwargs] = None, + **kwargs: Any, # noqa: ARG002 + ) -> None: + """Print objects to self.stdout. + + :param objects: objects to print + :param sep: string to write between print data. Defaults to " ". + :param end: string to write at end of print data. Defaults to a newline. + :param style: optional style to apply to output + :param soft_wrap: Enable soft wrap mode. If True, text lines will not be automatically word-wrapped to fit the + terminal width; instead, any text that doesn't fit will run onto the following line(s), + similar to the built-in print() function. Set to False to enable automatic word-wrapping. + If None (the default for this parameter), the output will default to no word-wrapping, as + configured by the Cmd2Console. + :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). + :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this + method and still call `super()` without encountering unexpected keyword argument errors. + These arguments are not passed to Rich's Console.print(). """ - self.print_to(self.stdout, msg, end=end) - - def perror(self, msg: Any = '', *, end: str = '\n', apply_style: bool = True) -> None: - """Print message to sys.stderr. + self.print_to( + self.stdout, + *objects, + sep=sep, + end=end, + style=style, + soft_wrap=soft_wrap, + rich_print_kwargs=rich_print_kwargs, + ) - :param msg: object to print - :param end: string appended after the end of the message, default a newline - :param apply_style: If True, then ansi.style_error will be applied to the message text. Set to False in cases - where the message text already has the desired style. Defaults to True. + def perror( + self, + *objects: Any, + sep: str = " ", + end: str = "\n", + style: Optional[StyleType] = "cmd2.error", + soft_wrap: Optional[bool] = None, + rich_print_kwargs: Optional[RichPrintKwargs] = None, + **kwargs: Any, # noqa: ARG002 + ) -> None: + """Print objects to sys.stderr. + + :param objects: objects to print + :param sep: string to write between print data. Defaults to " ". + :param end: string to write at end of print data. Defaults to a newline. + :param style: optional style to apply to output. Defaults to cmd2.error. + :param soft_wrap: Enable soft wrap mode. If True, text lines will not be automatically word-wrapped to fit the + terminal width; instead, any text that doesn't fit will run onto the following line(s), + similar to the built-in print() function. Set to False to enable automatic word-wrapping. + If None (the default for this parameter), the output will default to no word-wrapping, as + configured by the Cmd2Console. + :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). + :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this + method and still call `super()` without encountering unexpected keyword argument errors. + These arguments are not passed to Rich's Console.print(). """ - self.print_to(sys.stderr, msg, end=end, style=ansi.style_error if apply_style else None) - - def psuccess(self, msg: Any = '', *, end: str = '\n') -> None: - """Wrap poutput, but applies ansi.style_success by default. + self.print_to( + sys.stderr, + *objects, + sep=sep, + end=end, + style=style, + soft_wrap=soft_wrap, + rich_print_kwargs=rich_print_kwargs, + ) - :param msg: object to print - :param end: string appended after the end of the message, default a newline + def psuccess( + self, + *objects: Any, + sep: str = " ", + end: str = "\n", + soft_wrap: Optional[bool] = None, + rich_print_kwargs: Optional[RichPrintKwargs] = None, + **kwargs: Any, # noqa: ARG002 + ) -> None: + """Wrap poutput, but apply cmd2.success style. + + :param objects: objects to print + :param sep: string to write between print data. Defaults to " ". + :param end: string to write at end of print data. Defaults to a newline. + :param soft_wrap: Enable soft wrap mode. If True, text lines will not be automatically word-wrapped to fit the + terminal width; instead, any text that doesn't fit will run onto the following line(s), + similar to the built-in print() function. Set to False to enable automatic word-wrapping. + If None (the default for this parameter), the output will default to no word-wrapping, as + configured by the Cmd2Console. + :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). + :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this + method and still call `super()` without encountering unexpected keyword argument errors. + These arguments are not passed to Rich's Console.print(). """ - msg = ansi.style_success(msg) - self.poutput(msg, end=end) - - def pwarning(self, msg: Any = '', *, end: str = '\n') -> None: - """Wrap perror, but applies ansi.style_warning by default. + self.poutput( + *objects, + sep=sep, + end=end, + style="cmd2.success", + soft_wrap=soft_wrap, + rich_print_kwargs=rich_print_kwargs, + ) - :param msg: object to print - :param end: string appended after the end of the message, default a newline + def pwarning( + self, + *objects: Any, + sep: str = " ", + end: str = "\n", + soft_wrap: Optional[bool] = None, + rich_print_kwargs: Optional[RichPrintKwargs] = None, + **kwargs: Any, # noqa: ARG002 + ) -> None: + """Wrap perror, but apply cmd2.warning style. + + :param objects: objects to print + :param sep: string to write between print data. Defaults to " ". + :param end: string to write at end of print data. Defaults to a newline. + :param soft_wrap: Enable soft wrap mode. If True, text lines will not be automatically word-wrapped to fit the + terminal width; instead, any text that doesn't fit will run onto the following line(s), + similar to the built-in print() function. Set to False to enable automatic word-wrapping. + If None (the default for this parameter), the output will default to no word-wrapping, as + configured by the Cmd2Console. + :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). + :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this + method and still call `super()` without encountering unexpected keyword argument errors. + These arguments are not passed to Rich's Console.print(). """ - msg = ansi.style_warning(msg) - self.perror(msg, end=end, apply_style=False) - - def pexcept(self, msg: Any, *, end: str = '\n', apply_style: bool = True) -> None: - """Print Exception message to sys.stderr. If debug is true, print exception traceback if one exists. + self.perror( + *objects, + sep=sep, + end=end, + style="cmd2.warning", + soft_wrap=soft_wrap, + rich_print_kwargs=rich_print_kwargs, + ) - :param msg: message or Exception to print - :param end: string appended after the end of the message, default a newline - :param apply_style: If True, then ansi.style_error will be applied to the message text. Set to False in cases - where the message text already has the desired style. Defaults to True. + def pexcept( + self, + exception: BaseException, + end: str = "\n", + rich_print_kwargs: Optional[RichPrintKwargs] = None, + **kwargs: Any, # noqa: ARG002 + ) -> None: + """Print exception to sys.stderr. If debug is true, print exception traceback if one exists. + + :param exception: the exception to print. + :param end: string to write at end of print data. Defaults to a newline. + :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). + :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this + method and still call `super()` without encountering unexpected keyword argument errors. + These arguments are not passed to Rich's Console.print(). """ - if self.debug and sys.exc_info() != (None, None, None): - import traceback - - traceback.print_exc() + final_msg = Text() - if isinstance(msg, Exception): - final_msg = f"EXCEPTION of type '{type(msg).__name__}' occurred with message: {msg}" + if self.debug and sys.exc_info() != (None, None, None): + console = Cmd2Console(sys.stderr) + console.print_exception(word_wrap=True) else: - final_msg = str(msg) - - if apply_style: - final_msg = ansi.style_error(final_msg) + final_msg += f"EXCEPTION of type '{type(exception).__name__}' occurred with message: {exception}" if not self.debug and 'debug' in self.settables: warning = "\nTo enable full traceback, run the following command: 'set debug true'" - final_msg += ansi.style_warning(warning) + final_msg.append(warning, style="cmd2.warning") - self.perror(final_msg, end=end, apply_style=False) + if final_msg: + self.perror( + final_msg, + end=end, + rich_print_kwargs=rich_print_kwargs, + ) - def pfeedback(self, msg: Any, *, end: str = '\n') -> None: - """Print nonessential feedback. Can be silenced with `quiet`. + def pfeedback( + self, + *objects: Any, + sep: str = " ", + end: str = "\n", + style: Optional[StyleType] = None, + soft_wrap: Optional[bool] = None, + rich_print_kwargs: Optional[RichPrintKwargs] = None, + **kwargs: Any, # noqa: ARG002 + ) -> None: + """For printing nonessential feedback. Can be silenced with `quiet`. Inclusion in redirected output is controlled by `feedback_to_output`. - :param msg: object to print - :param end: string appended after the end of the message, default a newline + :param objects: objects to print + :param sep: string to write between print data. Defaults to " ". + :param end: string to write at end of print data. Defaults to a newline. + :param style: optional style to apply to output + :param soft_wrap: Enable soft wrap mode. If True, text lines will not be automatically word-wrapped to fit the + terminal width; instead, any text that doesn't fit will run onto the following line(s), + similar to the built-in print() function. Set to False to enable automatic word-wrapping. + If None (the default for this parameter), the output will default to no word-wrapping, as + configured by the Cmd2Console. + :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). + :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this + method and still call `super()` without encountering unexpected keyword argument errors. + These arguments are not passed to Rich's Console.print(). """ if not self.quiet: if self.feedback_to_output: - self.poutput(msg, end=end) + self.poutput( + *objects, + sep=sep, + end=end, + style=style, + soft_wrap=soft_wrap, + rich_print_kwargs=rich_print_kwargs, + ) else: - self.perror(msg, end=end, apply_style=False) + self.perror( + *objects, + sep=sep, + end=end, + style=style, + soft_wrap=soft_wrap, + rich_print_kwargs=rich_print_kwargs, + ) - def ppaged(self, msg: Any, *, end: str = '\n', chop: bool = False) -> None: + def ppaged( + self, + *objects: Any, + sep: str = " ", + end: str = "\n", + style: Optional[StyleType] = None, + chop: bool = False, + soft_wrap: Optional[bool] = None, + rich_print_kwargs: Optional[RichPrintKwargs] = None, + **kwargs: Any, # noqa: ARG002 + ) -> None: """Print output using a pager if it would go off screen and stdout isn't currently being redirected. Never uses a pager inside a script (Python or text) or when output is being redirected or piped or when stdout or stdin are not a fully functional terminal. - :param msg: object to print - :param end: string appended after the end of the message, default a newline + :param objects: objects to print + :param sep: string to write between print data. Defaults to " ". + :param end: string to write at end of print data. Defaults to a newline. + :param style: optional style to apply to output :param chop: True -> causes lines longer than the screen width to be chopped (truncated) rather than wrapped - truncated text is still accessible by scrolling with the right & left arrow keys - chopping is ideal for displaying wide tabular data as is done in utilities like pgcli False -> causes lines longer than the screen width to wrap to the next line - wrapping is ideal when you want to keep users from having to use horizontal scrolling - - WARNING: On Windows, the text always wraps regardless of what the chop argument is set to + WARNING: On Windows, the text always wraps regardless of what the chop argument is set to + :param soft_wrap: Enable soft wrap mode. If True, text lines will not be automatically word-wrapped to fit the + terminal width; instead, any text that doesn't fit will run onto the following line(s), + similar to the built-in print() function. Set to False to enable automatic word-wrapping. + If None (the default for this parameter), the output will default to no word-wrapping, as + configured by the Cmd2Console. + Note: If chop is True and a pager is used, soft_wrap is automatically set to True. + :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). + :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this + method and still call `super()` without encountering unexpected keyword argument errors. + These arguments are not passed to Rich's Console.print(). """ - # Attempt to detect if we are not running within a fully functional terminal. + # Detect if we are running within a fully functional terminal. # Don't try to use the pager when being run by a continuous integration system like Jenkins + pexpect. - functional_terminal = False + functional_terminal = ( + self.stdin.isatty() + and self.stdout.isatty() + and (sys.platform.startswith('win') or os.environ.get('TERM') is not None) + ) - if self.stdin.isatty() and self.stdout.isatty(): # noqa: SIM102 - if sys.platform.startswith('win') or os.environ.get('TERM') is not None: - functional_terminal = True + # A pager application blocks, so only run one if not redirecting or running a script (either text or Python). + can_block = not (self._redirecting or self.in_pyscript() or self.in_script()) - # Don't attempt to use a pager that can block if redirecting or running a script (either text or Python). - # Also only attempt to use a pager if actually running in a real fully functional terminal. - if functional_terminal and not self._redirecting and not self.in_pyscript() and not self.in_script(): - final_msg = f"{msg}{end}" - if rich_utils.allow_style == rich_utils.AllowStyle.NEVER: - final_msg = ansi.strip_style(final_msg) + # Check if we are outputting to a pager. + if functional_terminal and can_block: + prepared_objects = rich_utils.prepare_objects_for_rich_print(*objects) - pager = self.pager + # Chopping overrides soft_wrap if chop: - pager = self.pager_chop + soft_wrap = True + + # Generate the bytes to send to the pager + console = Cmd2Console(self.stdout) + with console.capture() as capture: + console.print( + *prepared_objects, + sep=sep, + end=end, + style=style, + soft_wrap=soft_wrap, + **(rich_print_kwargs if rich_print_kwargs is not None else {}), + ) + output_bytes = capture.get().encode('utf-8', 'replace') - try: - # Prevent KeyboardInterrupts while in the pager. The pager application will - # still receive the SIGINT since it is in the same process group as us. - with self.sigint_protection: - import subprocess - - pipe_proc = subprocess.Popen(pager, shell=True, stdin=subprocess.PIPE, stdout=self.stdout) # noqa: S602 - pipe_proc.communicate(final_msg.encode('utf-8', 'replace')) - except BrokenPipeError: - # This occurs if a command's output is being piped to another process and that process closes before the - # command is finished. If you would like your application to print a warning message, then set the - # broken_pipe_warning attribute to the message you want printed.` - if self.broken_pipe_warning: - sys.stderr.write(self.broken_pipe_warning) - else: - self.poutput(msg, end=end) + # Prevent KeyboardInterrupts while in the pager. The pager application will + # still receive the SIGINT since it is in the same process group as us. + with self.sigint_protection: + import subprocess - def ppretty(self, data: Any, *, indent: int = 2, width: int = 80, depth: Optional[int] = None, end: str = '\n') -> None: - """Pretty print arbitrary Python data structures to self.stdout and appends a newline by default. + pipe_proc = subprocess.Popen( # noqa: S602 + self.pager_chop if chop else self.pager, + shell=True, + stdin=subprocess.PIPE, + stdout=self.stdout, + ) + pipe_proc.communicate(output_bytes) - :param data: object to print - :param indent: the amount of indentation added for each nesting level - :param width: the desired maximum number of characters per line in the output, a best effort will be made for long data - :param depth: the number of nesting levels which may be printed, if data is too deep, the next level replaced by ... - :param end: string appended after the end of the message, default a newline - """ - self.print_to(self.stdout, pprint.pformat(data, indent, width, depth), end=end) + else: + self.poutput( + *objects, + sep=sep, + end=end, + style=style, + soft_wrap=soft_wrap, + rich_print_kwargs=rich_print_kwargs, + ) # ----- Methods related to tab completion ----- @@ -2278,9 +2470,13 @@ def complete( # type: ignore[override] # Don't print error and redraw the prompt unless the error has length err_str = str(ex) if err_str: - if ex.apply_style: - err_str = ansi.style_error(err_str) - ansi.style_aware_write(sys.stdout, '\n' + err_str + '\n') + self.print_to( + sys.stdout, + Text.assemble( + "\n", + (err_str, "cmd2.error" if ex.apply_style else ""), + ), + ) rl_force_redisplay() return None except Exception as ex: # noqa: BLE001 @@ -2380,13 +2576,17 @@ def get_help_topics(self) -> list[str]: # Filter out hidden and disabled commands return [topic for topic in all_topics if topic not in self.hidden_commands and topic not in self.disabled_commands] - def sigint_handler(self, signum: int, _: Optional[FrameType]) -> None: # noqa: ARG002 + def sigint_handler( + self, + signum: int, # noqa: ARG002, + frame: Optional[FrameType], # noqa: ARG002, + ) -> None: """Signal handler for SIGINTs which typically come from Ctrl-C events. If you need custom SIGINT behavior, then override this method. :param signum: signal number - :param _: the current stack frame or None + :param frame: the current stack frame or None """ if self._cur_pipe_proc_reader is not None: # Pass the SIGINT to the current pipe process @@ -3062,7 +3262,7 @@ def default(self, statement: Statement) -> Optional[bool]: # type: ignore[overr err_msg += f"\n{self.default_suggestion_message.format(suggested_command)}" # Set apply_style to False so styles for default_error and default_suggestion_message are not overridden - self.perror(err_msg, apply_style=False) + self.perror(err_msg, style=None) return None def _suggest_similar_command(self, command: str) -> Optional[str]: @@ -3902,7 +4102,7 @@ def do_help(self, args: argparse.Namespace) -> None: err_msg = self.help_error.format(args.command) # Set apply_style to False so help_error's style is not overridden - self.perror(err_msg, apply_style=False) + self.perror(err_msg, style=None) self.last_result = False def print_topics(self, header: str, cmds: Optional[list[str]], cmdlen: int, maxcol: int) -> None: # noqa: ARG002 @@ -5356,7 +5556,7 @@ class TestMyAppCase(Cmd2TestCase): test_results = runner.run(testcase) execution_time = time.time() - start_time if test_results.wasSuccessful(): - ansi.style_aware_write(sys.stderr, stream.read()) + self.perror(stream.read(), end="", style=None) finish_msg = f' {num_transcripts} transcript{plural} passed in {execution_time:.3f} seconds ' finish_msg = utils.align_center(finish_msg, fill_char='=') self.psuccess(finish_msg) @@ -5613,8 +5813,7 @@ def _report_disabled_command_usage(self, *_args: Any, message_to_print: str, **_ :param message_to_print: the message reporting that the command is disabled :param _kwargs: not used """ - # Set apply_style to False so message_to_print's style is not overridden - self.perror(message_to_print, apply_style=False) + self.perror(message_to_print, style=None) def cmdloop(self, intro: Optional[str] = None) -> int: # type: ignore[override] """Deal with extra features provided by cmd2, this is an outer wrapper around _cmdloop(). diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index eb9b3923..623dc935 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -6,13 +6,22 @@ IO, Any, Optional, + TypedDict, ) -from rich.console import Console +from rich.console import ( + Console, + ConsoleRenderable, + JustifyMethod, + OverflowMethod, + RenderableType, + RichCast, +) from rich.style import ( Style, StyleType, ) +from rich.text import Text from rich.theme import Theme from rich_argparse import RichHelpFormatter @@ -88,6 +97,28 @@ def set_theme(new_theme: Cmd2Theme) -> None: RichHelpFormatter.styles[name] = THEME.styles[name] +class RichPrintKwargs(TypedDict, total=False): + """Keyword arguments that can be passed to rich.console.Console.print() via cmd2's print methods. + + See Rich's Console.print() documentation for full details on these parameters. + https://rich.readthedocs.io/en/stable/reference/console.html#rich.console.Console.print + + Note: All fields are optional (total=False). If a key is not present in the + dictionary, Rich's default behavior for that argument will apply. + """ + + justify: Optional[JustifyMethod] + overflow: Optional[OverflowMethod] + no_wrap: Optional[bool] + markup: Optional[bool] + emoji: Optional[bool] + highlight: Optional[bool] + width: Optional[int] + height: Optional[int] + crop: bool + new_line_start: bool + + class Cmd2Console(Console): """Rich console with characteristics appropriate for cmd2 applications.""" @@ -106,11 +137,16 @@ def __init__(self, file: IO[str]) -> None: elif allow_style == AllowStyle.NEVER: kwargs["force_terminal"] = False - # Turn off automatic markup, emoji, and highlight rendering at the console level. - # You can still enable these in Console.print() calls. + # Configure console defaults to treat output as plain, unstructured text. + # This involves enabling soft wrapping (no automatic word-wrap) and disabling + # Rich's automatic markup, emoji, and highlight processing. + # While these automatic features are off by default, the console fully supports + # rendering explicitly created Rich objects (e.g., Panel, Table). + # Any of these default settings or other print behaviors can be overridden + # in individual Console.print() calls or via cmd2's print methods. super().__init__( file=file, - tab_size=4, + soft_wrap=True, markup=False, emoji=False, highlight=False, @@ -120,9 +156,60 @@ def __init__(self, file: IO[str]) -> None: def on_broken_pipe(self) -> None: """Override which raises BrokenPipeError instead of SystemExit.""" - import contextlib + self.quiet = True + raise BrokenPipeError - with contextlib.suppress(SystemExit): - super().on_broken_pipe() - raise BrokenPipeError +def from_ansi(text: str) -> Text: + r"""Patched version of rich.Text.from_ansi() that handles a discarded newline issue. + + Text.from_ansi() currently removes the ending line break from string. + e.g. "Hello\n" becomes "Hello" + + There is currently a pull request to fix this. + https://github.com/Textualize/rich/pull/3793 + + :param text: a string to convert to a Text object. + :return: the converted string + """ + result = Text.from_ansi(text) + + # If 'text' ends with a line break character, restore the missing newline to 'result'. + # Note: '\r\n' is handled as its last character is '\n'. + # Source: https://docs.python.org/3/library/stdtypes.html#str.splitlines + line_break_chars = { + "\n", # Line Feed + "\r", # Carriage Return + "\v", # Vertical Tab + "\f", # Form Feed + "\x1c", # File Separator + "\x1d", # Group Separator + "\x1e", # Record Separator + "\x85", # Next Line (NEL) + "\u2028", # Line Separator + "\u2029", # Paragraph Separator + } + if text and text[-1] in line_break_chars: + # We use "\n" because Text.from_ansi() converts all line breaks chars into newlines. + result.append("\n") + + return result + + +def prepare_objects_for_rich_print(*objects: Any) -> tuple[RenderableType, ...]: + """Prepare a tuple of objects for printing by Rich's Console.print(). + + Converts any non-Rich objects (i.e., not ConsoleRenderable or RichCast) + into rich.Text objects by stringifying them and processing them with + from_ansi(). This ensures Rich correctly interprets any embedded ANSI + escape sequences. + + :param objects: objects to prepare + :return: a tuple containing the processed objects, where non-Rich objects are + converted to rich.Text. + """ + object_list = list(objects) + for i, obj in enumerate(object_list): + if not isinstance(obj, (ConsoleRenderable, RichCast)): + object_list[i] = from_ansi(str(obj)) + return tuple(object_list) diff --git a/cmd2/rl_utils.py b/cmd2/rl_utils.py index a07479c7..137d447e 100644 --- a/cmd2/rl_utils.py +++ b/cmd2/rl_utils.py @@ -133,7 +133,7 @@ def pyreadline_remove_history_item(pos: int) -> None: readline_lib = ctypes.CDLL(readline.__file__) except (AttributeError, OSError): # pragma: no cover _rl_warn_reason = ( - "this application is running in a non-standard Python environment in\n" + "this application is running in a non-standard Python environment in " "which GNU readline is not loaded dynamically from a shared library file." ) else: @@ -144,10 +144,10 @@ def pyreadline_remove_history_item(pos: int) -> None: if rl_type == RlType.NONE: # pragma: no cover if not _rl_warn_reason: _rl_warn_reason = ( - "no supported version of readline was found. To resolve this, install\n" + "no supported version of readline was found. To resolve this, install " "pyreadline3 on Windows or gnureadline on Linux/Mac." ) - rl_warning = "Readline features including tab completion have been disabled because\n" + _rl_warn_reason + '\n\n' + rl_warning = f"Readline features including tab completion have been disabled because {_rl_warn_reason}\n\n" else: rl_warning = '' diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 75179723..ec6ec91d 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -15,6 +15,7 @@ ) import pytest +from rich.text import Text import cmd2 from cmd2 import ( @@ -933,7 +934,7 @@ def test_base_debug(base_app) -> None: # Verify that we now see the exception traceback out, err = run_cmd(base_app, 'edit') - assert err[0].startswith('Traceback (most recent call last):') + assert 'Traceback (most recent call last)' in err[0] def test_debug_not_settable(base_app) -> None: @@ -2026,46 +2027,50 @@ def test_poutput_none(outsim_app) -> None: assert out == expected -def test_ppretty_dict(outsim_app) -> None: - data = { - "name": "John Doe", - "age": 30, - "address": {"street": "123 Main St", "city": "Anytown", "state": "CA"}, - "hobbies": ["reading", "hiking", "coding"], - } - outsim_app.ppretty(data) - out = outsim_app.stdout.getvalue() - expected = """ -{ 'address': {'city': 'Anytown', 'state': 'CA', 'street': '123 Main St'}, - 'age': 30, - 'hobbies': ['reading', 'hiking', 'coding'], - 'name': 'John Doe'} -""" - assert out == expected.lstrip() - - @with_ansi_style(rich_utils.AllowStyle.ALWAYS) def test_poutput_ansi_always(outsim_app) -> None: msg = 'Hello World' - colored_msg = ansi.style(msg, fg=ansi.Fg.CYAN) + colored_msg = Text(msg, style="cyan") outsim_app.poutput(colored_msg) out = outsim_app.stdout.getvalue() - expected = colored_msg + '\n' - assert colored_msg != msg - assert out == expected + assert out == "\x1b[36mHello World\x1b[0m\n" @with_ansi_style(rich_utils.AllowStyle.NEVER) def test_poutput_ansi_never(outsim_app) -> None: msg = 'Hello World' - colored_msg = ansi.style(msg, fg=ansi.Fg.CYAN) + colored_msg = Text(msg, style="cyan") outsim_app.poutput(colored_msg) out = outsim_app.stdout.getvalue() expected = msg + '\n' - assert colored_msg != msg assert out == expected +@with_ansi_style(rich_utils.AllowStyle.TERMINAL) +def test_poutput_ansi_terminal(outsim_app) -> None: + """Test that AllowStyle.TERMINAL strips style when redirecting.""" + msg = 'testing...' + colored_msg = Text(msg, style="cyan") + outsim_app._redirecting = True + outsim_app.poutput(colored_msg) + out = outsim_app.stdout.getvalue() + expected = msg + '\n' + assert out == expected + + +def test_broken_pipe_error(outsim_app, monkeypatch, capsys): + write_mock = mock.MagicMock() + write_mock.side_effect = BrokenPipeError + monkeypatch.setattr("cmd2.utils.StdSim.write", write_mock) + + outsim_app.broken_pipe_warning = "The pipe broke" + outsim_app.poutput("My test string") + + out, err = capsys.readouterr() + assert not out + assert outsim_app.broken_pipe_warning in err + + # These are invalid names for aliases and macros invalid_command_name = [ '""', # Blank name @@ -2485,17 +2490,16 @@ def test_nonexistent_macro(base_app) -> None: @with_ansi_style(rich_utils.AllowStyle.ALWAYS) def test_perror_style(base_app, capsys) -> None: msg = 'testing...' - end = '\n' base_app.perror(msg) out, err = capsys.readouterr() - assert err == ansi.style_error(msg) + end + assert err == "\x1b[91mtesting...\x1b[0m\x1b[91m\n\x1b[0m" @with_ansi_style(rich_utils.AllowStyle.ALWAYS) def test_perror_no_style(base_app, capsys) -> None: msg = 'testing...' end = '\n' - base_app.perror(msg, apply_style=False) + base_app.perror(msg, style=None) out, err = capsys.readouterr() assert err == msg + end @@ -2506,14 +2510,14 @@ def test_pexcept_style(base_app, capsys) -> None: base_app.pexcept(msg) out, err = capsys.readouterr() - assert err.startswith(ansi.style_error("EXCEPTION of type 'Exception' occurred with message: testing...")) + assert err.startswith("\x1b[91mEXCEPTION of type 'Exception' occurred with message: testing") -@with_ansi_style(rich_utils.AllowStyle.ALWAYS) +@with_ansi_style(rich_utils.AllowStyle.NEVER) def test_pexcept_no_style(base_app, capsys) -> None: msg = Exception('testing...') - base_app.pexcept(msg, apply_style=False) + base_app.pexcept(msg) out, err = capsys.readouterr() assert err.startswith("EXCEPTION of type 'Exception' occurred with message: testing...") @@ -2525,36 +2529,43 @@ def test_pexcept_not_exception(base_app, capsys) -> None: base_app.pexcept(msg) out, err = capsys.readouterr() - assert err.startswith(ansi.style_error(msg)) + assert err.startswith("\x1b[91mEXCEPTION of type 'bool' occurred with message: False") -def test_ppaged(outsim_app) -> None: - msg = 'testing...' - end = '\n' - outsim_app.ppaged(msg) - out = outsim_app.stdout.getvalue() - assert out == msg + end +@pytest.mark.parametrize('chop', [True, False]) +def test_ppaged_with_pager(outsim_app, monkeypatch, chop) -> None: + """Force ppaged() to run the pager by mocking an actual terminal state.""" + # Make it look like we're in a terminal + stdin_mock = mock.MagicMock() + stdin_mock.isatty.return_value = True + monkeypatch.setattr(outsim_app, "stdin", stdin_mock) -@with_ansi_style(rich_utils.AllowStyle.TERMINAL) -def test_ppaged_strips_ansi_when_redirecting(outsim_app) -> None: - msg = 'testing...' - end = '\n' - outsim_app._redirecting = True - outsim_app.ppaged(ansi.style(msg, fg=ansi.Fg.RED)) - out = outsim_app.stdout.getvalue() - assert out == msg + end + stdout_mock = mock.MagicMock() + stdout_mock.isatty.return_value = True + monkeypatch.setattr(outsim_app, "stdout", stdout_mock) + if not sys.platform.startswith('win') and os.environ.get("TERM") is None: + monkeypatch.setenv('TERM', 'simulated') -@with_ansi_style(rich_utils.AllowStyle.ALWAYS) -def test_ppaged_strips_ansi_when_redirecting_if_always(outsim_app) -> None: + # This will force ppaged to call Popen to run a pager + popen_mock = mock.MagicMock(name='Popen') + monkeypatch.setattr("subprocess.Popen", popen_mock) + outsim_app.ppaged("Test", chop=chop) + + # Verify the correct pager was run + expected_cmd = outsim_app.pager_chop if chop else outsim_app.pager + assert len(popen_mock.call_args_list) == 1 + assert expected_cmd == popen_mock.call_args_list[0].args[0] + + +def test_ppaged_no_pager(outsim_app) -> None: + """Since we're not in a fully-functional terminal, ppaged() will just call poutput().""" msg = 'testing...' end = '\n' - outsim_app._redirecting = True - colored_msg = ansi.style(msg, fg=ansi.Fg.RED) - outsim_app.ppaged(colored_msg) + outsim_app.ppaged(msg) out = outsim_app.stdout.getvalue() - assert out == colored_msg + end + assert out == msg + end # we override cmd.parseline() so we always get consistent diff --git a/tests/transcripts/regex_set.txt b/tests/transcripts/regex_set.txt index 4d40ce0f..8344af81 100644 --- a/tests/transcripts/regex_set.txt +++ b/tests/transcripts/regex_set.txt @@ -1,5 +1,5 @@ # Run this transcript with "python example.py -t transcript_regex.txt" -# The regex for colors shows all possible settings for colors +# The regex for allow_style will match any setting for the previous value. # The regex for editor will match whatever program you use. # Regexes on prompts just make the trailing space obvious @@ -10,19 +10,19 @@ now: 'Terminal' editor - was: '/.*/' now: 'vim' (Cmd) set -Name Value Description/ +/ +Name Value Description/ */ ==================================================================================================================== -allow_style Terminal Allow ANSI text style sequences in output (valid values:/ +/ - Always, Never, Terminal)/ +/ +allow_style Terminal Allow ANSI text style sequences in output (valid values:/ */ + Always, Never, Terminal)/ */ always_show_hint False Display tab completion hint even when completion suggestions - print/ +/ -debug False Show full traceback on exception/ +/ -echo False Echo command issued into output/ +/ -editor vim Program used by 'edit'/ +/ -feedback_to_output False Include nonessentials in '|', '>' results/ +/ -max_completion_items 50 Maximum number of CompletionItems to display during tab/ +/ - completion/ +/ -maxrepeats 3 Max number of `--repeat`s allowed/ +/ -quiet False Don't print nonessential feedback/ +/ -scripts_add_to_history True Scripts and pyscripts add commands to history/ +/ -timing False Report execution times/ +/ + print/ */ +debug False Show full traceback on exception/ */ +echo False Echo command issued into output/ */ +editor vim Program used by 'edit'/ */ +feedback_to_output False Include nonessentials in '|', '>' results/ */ +max_completion_items 50 Maximum number of CompletionItems to display during tab/ */ + completion/ */ +maxrepeats 3 Max number of `--repeat`s allowed/ */ +quiet False Don't print nonessential feedback/ */ +scripts_add_to_history True Scripts and pyscripts add commands to history/ */ +timing False Report execution times/ */ From e7f3313c217759e233c558cebb260116aa4f19f4 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Tue, 5 Aug 2025 22:54:51 -0400 Subject: [PATCH 20/89] Drop support for Python 3.9 and require 3.10 minimum (#1473) * Drop support for Python 3.9 and require 3.10 minimum The primary change is that type hints now support the `|` opertor and both `Optional` and `Union` are needed much less frequently from the typing module. Also: - Upgrade versions of ruff and prettier used by pre-commit * Restored some comments. --------- Co-authored-by: Kevin Van Brunt --- .github/CONTRIBUTING.md | 4 +- .github/workflows/tests.yml | 2 +- .github/workflows/typecheck.yml | 2 +- .pre-commit-config.yaml | 6 +- CHANGELOG.md | 8 +- README.md | 2 +- cmd2/ansi.py | 17 +- cmd2/argparse_completer.py | 26 ++- cmd2/argparse_custom.py | 81 ++++--- cmd2/cmd2.py | 200 +++++++++--------- cmd2/command_definition.py | 5 +- cmd2/decorators.py | 83 ++++---- cmd2/history.py | 8 +- cmd2/parsing.py | 12 +- cmd2/plugin.py | 3 +- cmd2/py_bridge.py | 6 +- cmd2/rich_utils.py | 19 +- cmd2/rl_utils.py | 3 +- cmd2/table_creator.py | 27 ++- cmd2/utils.py | 62 +++--- docs/overview/installation.md | 4 +- examples/modular_commands_main.py | 3 +- package.json | 4 +- plugins/ext_test/build-pyenvs.sh | 4 +- .../ext_test/cmd2_ext_test/cmd2_ext_test.py | 3 +- plugins/ext_test/noxfile.py | 2 +- plugins/ext_test/setup.py | 3 +- plugins/template/README.md | 26 +-- plugins/template/build-pyenvs.sh | 4 +- plugins/template/noxfile.py | 2 +- plugins/template/setup.py | 3 +- pyproject.toml | 7 +- tasks.py | 3 +- tests/conftest.py | 10 +- tests/test_argparse.py | 7 +- tests_isolated/test_commandset/conftest.py | 10 +- 36 files changed, 305 insertions(+), 366 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 48a4a2ed..57049b58 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -62,7 +62,7 @@ See the `dependencies` list under the `[project]` heading in [pyproject.toml](.. | Prerequisite | Minimum Version | Purpose | | --------------------------------------------------- | --------------- | -------------------------------------- | -| [python](https://www.python.org/downloads/) | `3.9` | Python programming language | +| [python](https://www.python.org/downloads/) | `3.10` | Python programming language | | [pyperclip](https://github.com/asweigart/pyperclip) | `1.8` | Cross-platform clipboard functions | | [wcwidth](https://pypi.python.org/pypi/wcwidth) | `0.2.10` | Measure the displayed width of unicode | @@ -520,13 +520,11 @@ on how to do it. 4. The title (also called the subject) of your PR should be descriptive of your changes and succinctly indicate what is being fixed - - **Do not add the issue number in the PR title or commit message** - Examples: `Add test cases for Unicode support`; `Correct typo in overview documentation` 5. In the body of your PR include a more detailed summary of the changes you made and why - - If the PR is meant to fix an existing bug/issue, then, at the end of your PR's description, append the keyword `closes` and #xxxx (where xxxx is the issue number). Example: `closes #1337`. This tells GitHub to close the existing issue if the PR is merged. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 51d75f65..e57d883d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.14t"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.14t"] fail-fast: false runs-on: ${{ matrix.os }} diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 45e65d84..89d5b538 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] fail-fast: false defaults: run: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 88e67c64..8891368f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.12.4" + rev: "v0.12.7" hooks: - id: ruff-format args: [--config=pyproject.toml] @@ -21,5 +21,5 @@ repos: hooks: - id: prettier additional_dependencies: - - prettier@3.5.3 - - prettier-plugin-toml@2.0.5 + - prettier@3.6.2 + - prettier-plugin-toml@2.0.6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e97ffaa..f4fd32f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,12 @@ ## 3.0.0 (TBD) - Breaking Changes - + - `cmd2` 3.0 supports Python 3.10+ (removed support for Python 3.9) - No longer setting parser's `prog` value in `with_argparser()` since it gets set in `Cmd._build_parser()`. This code had previously been restored to support backward compatibility in `cmd2` 2.0 family. - 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/main/examples/custom_parser.py) example for more details. @@ -30,7 +29,6 @@ ## 2.6.2 (June 26, 2025) - Enhancements - - Added explicit support for free-threaded versions of Python, starting with version 3.14 - Bug Fixes @@ -1316,12 +1314,10 @@ ## 0.8.5 (April 15, 2018) - Bug Fixes - - Fixed a bug with all argument decorators where the wrapped function wasn't returning a value and thus couldn't cause the cmd2 app to quit - Enhancements - - Added support for verbose help with -v where it lists a brief summary of what each command does - Added support for categorizing commands into groups within the help menu @@ -1353,12 +1349,10 @@ ## 0.8.3 (April 09, 2018) - Bug Fixes - - Fixed `help` command not calling functions for help topics - Fixed not being able to use quoted paths when redirecting with `<` and `>` - Enhancements - - Tab completion has been overhauled and now supports completion of strings with quotes and spaces. - Tab completion will automatically add an opening quote if a string with a space is completed. diff --git a/README.md b/README.md index bb412042..688ed57a 100755 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ On all operating systems, the latest stable version of `cmd2` can be installed u pip install -U cmd2 ``` -cmd2 works with Python 3.9+ on Windows, macOS, and Linux. It is pure Python code with few 3rd-party +cmd2 works with Python 3.10+ on Windows, macOS, and Linux. It is pure Python code with few 3rd-party dependencies. It works with both conventional CPython and free-threaded variants. For information on other installation options, see diff --git a/cmd2/ansi.py b/cmd2/ansi.py index 929d77bd..76c540c8 100644 --- a/cmd2/ansi.py +++ b/cmd2/ansi.py @@ -11,7 +11,6 @@ from typing import ( IO, Any, - Optional, cast, ) @@ -924,14 +923,14 @@ def __str__(self) -> str: def style( value: Any, *, - fg: Optional[FgColor] = None, - bg: Optional[BgColor] = None, - bold: Optional[bool] = None, - dim: Optional[bool] = None, - italic: Optional[bool] = None, - overline: Optional[bool] = None, - strikethrough: Optional[bool] = None, - underline: Optional[bool] = None, + fg: FgColor | None = None, + bg: BgColor | None = None, + bold: bool | None = None, + dim: bool | None = None, + italic: bool | None = None, + overline: bool | None = None, + strikethrough: bool | None = None, + underline: bool | None = None, ) -> str: """Apply ANSI colors and/or styles to a string and return it. diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 1b1efce7..5aeef609 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -12,8 +12,6 @@ from typing import ( IO, TYPE_CHECKING, - Optional, - Union, cast, ) @@ -105,8 +103,8 @@ class _ArgumentState: def __init__(self, arg_action: argparse.Action) -> None: self.action = arg_action - self.min: Union[int, str] - self.max: Union[float, int, str] + self.min: int | str + self.max: float | int | str self.count = 0 self.is_remainder = self.action.nargs == argparse.REMAINDER @@ -141,7 +139,7 @@ def __init__(self, flag_arg_state: _ArgumentState) -> None: :param flag_arg_state: information about the unfinished flag action. """ arg = f'{argparse._get_action_name(flag_arg_state.action)}' - err = f'{generate_range_error(cast(int, flag_arg_state.min), cast(Union[int, float], flag_arg_state.max))}' + err = f'{generate_range_error(cast(int, flag_arg_state.min), cast(int | float, flag_arg_state.max))}' error = f"Error: argument {arg}: {err} ({flag_arg_state.count} entered)" super().__init__(error) @@ -163,7 +161,7 @@ class ArgparseCompleter: """Automatic command line tab completion based on argparse parameters.""" def __init__( - self, parser: argparse.ArgumentParser, cmd2_app: 'Cmd', *, parent_tokens: Optional[dict[str, list[str]]] = None + self, parser: argparse.ArgumentParser, cmd2_app: 'Cmd', *, parent_tokens: dict[str, list[str]] | None = None ) -> None: """Create an ArgparseCompleter. @@ -203,7 +201,7 @@ def __init__( self._subcommand_action = action def complete( - self, text: str, line: str, begidx: int, endidx: int, tokens: list[str], *, cmd_set: Optional[CommandSet] = None + self, text: str, line: str, begidx: int, endidx: int, tokens: list[str], *, cmd_set: CommandSet | None = None ) -> list[str]: """Complete text using argparse metadata. @@ -228,10 +226,10 @@ def complete( skip_remaining_flags = False # _ArgumentState of the current positional - pos_arg_state: Optional[_ArgumentState] = None + pos_arg_state: _ArgumentState | None = None # _ArgumentState of the current flag - flag_arg_state: Optional[_ArgumentState] = None + flag_arg_state: _ArgumentState | None = None # Non-reusable flags that we've parsed matched_flags: list[str] = [] @@ -523,7 +521,7 @@ def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, matche return matches - def _format_completions(self, arg_state: _ArgumentState, completions: Union[list[str], list[CompletionItem]]) -> list[str]: + def _format_completions(self, arg_state: _ArgumentState, completions: list[str] | list[CompletionItem]) -> list[str]: """Format CompletionItems into hint table.""" # Nothing to do if we don't have at least 2 completions which are all CompletionItems if len(completions) < 2 or not all(isinstance(c, CompletionItem) for c in completions): @@ -625,7 +623,7 @@ def complete_subcommand_help(self, text: str, line: str, begidx: int, endidx: in break return [] - def print_help(self, tokens: list[str], file: Optional[IO[str]] = None) -> None: + def print_help(self, tokens: list[str], file: IO[str] | None = None) -> None: """Supports cmd2's help command in the printing of help text. :param tokens: arguments passed to help command @@ -636,7 +634,7 @@ def print_help(self, tokens: list[str], file: Optional[IO[str]] = None) -> None: # If so, we will let the subcommand's parser handle the rest of the tokens via another ArgparseCompleter. if tokens and self._subcommand_action is not None: parser = cast( - Optional[argparse.ArgumentParser], + argparse.ArgumentParser | None, self._subcommand_action.choices.get(tokens[0]), ) @@ -657,7 +655,7 @@ def _complete_arg( arg_state: _ArgumentState, consumed_arg_values: dict[str, list[str]], *, - cmd_set: Optional[CommandSet] = None, + cmd_set: CommandSet | None = None, ) -> list[str]: """Tab completion routine for an argparse argument. @@ -665,7 +663,7 @@ def _complete_arg( :raises CompletionError: if the completer or choices function this calls raises one. """ # Check if the arg provides choices to the user - arg_choices: Union[list[str], ChoicesCallable] + arg_choices: list[str] | ChoicesCallable if arg_state.action.choices is not None: arg_choices = list(arg_state.action.choices) if not arg_choices: diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 95a3644b..df20ceb6 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -240,9 +240,7 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens) Any, ClassVar, NoReturn, - Optional, Protocol, - Union, cast, runtime_checkable, ) @@ -392,7 +390,7 @@ def __call__(self, *, arg_tokens: dict[str, list[str]] = {}) -> list[str]: # pr """Enable instances to be called like functions.""" -ChoicesProviderFunc = Union[ChoicesProviderFuncBase, ChoicesProviderFuncWithTokens] +ChoicesProviderFunc = ChoicesProviderFuncBase | ChoicesProviderFuncWithTokens @runtime_checkable @@ -425,7 +423,7 @@ def __call__( """Enable instances to be called like functions.""" -CompleterFunc = Union[CompleterFuncBase, CompleterFuncWithTokens] +CompleterFunc = CompleterFuncBase | CompleterFuncWithTokens class ChoicesCallable: @@ -437,7 +435,7 @@ class ChoicesCallable: def __init__( self, is_completer: bool, - to_call: Union[CompleterFunc, ChoicesProviderFunc], + to_call: CompleterFunc | ChoicesProviderFunc, ) -> None: """Initialize the ChoiceCallable instance. @@ -498,7 +496,7 @@ def choices_provider(self) -> ChoicesProviderFunc: ############################################################################################################ # Patch argparse.Action with accessors for choice_callable attribute ############################################################################################################ -def _action_get_choices_callable(self: argparse.Action) -> Optional[ChoicesCallable]: +def _action_get_choices_callable(self: argparse.Action) -> ChoicesCallable | None: """Get the choices_callable attribute of an argparse Action. This function is added by cmd2 as a method called ``get_choices_callable()`` to ``argparse.Action`` class. @@ -508,7 +506,7 @@ def _action_get_choices_callable(self: argparse.Action) -> Optional[ChoicesCalla :param self: argparse Action being queried :return: A ChoicesCallable instance or None if attribute does not exist """ - return cast(Optional[ChoicesCallable], getattr(self, ATTR_CHOICES_CALLABLE, None)) + return cast(ChoicesCallable | None, getattr(self, ATTR_CHOICES_CALLABLE, None)) setattr(argparse.Action, 'get_choices_callable', _action_get_choices_callable) @@ -584,7 +582,7 @@ def _action_set_completer( ############################################################################################################ # Patch argparse.Action with accessors for descriptive_header attribute ############################################################################################################ -def _action_get_descriptive_header(self: argparse.Action) -> Optional[str]: +def _action_get_descriptive_header(self: argparse.Action) -> str | None: """Get the descriptive_header attribute of an argparse Action. This function is added by cmd2 as a method called ``get_descriptive_header()`` to ``argparse.Action`` class. @@ -594,13 +592,13 @@ def _action_get_descriptive_header(self: argparse.Action) -> Optional[str]: :param self: argparse Action being queried :return: The value of descriptive_header or None if attribute does not exist """ - return cast(Optional[str], getattr(self, ATTR_DESCRIPTIVE_HEADER, None)) + return cast(str | None, getattr(self, ATTR_DESCRIPTIVE_HEADER, None)) setattr(argparse.Action, 'get_descriptive_header', _action_get_descriptive_header) -def _action_set_descriptive_header(self: argparse.Action, descriptive_header: Optional[str]) -> None: +def _action_set_descriptive_header(self: argparse.Action, descriptive_header: str | None) -> None: """Set the descriptive_header attribute of an argparse Action. This function is added by cmd2 as a method called ``set_descriptive_header()`` to ``argparse.Action`` class. @@ -619,7 +617,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) -> tuple[int, int | float] | None: """Get the nargs_range attribute of an argparse Action. This function is added by cmd2 as a method called ``get_nargs_range()`` to ``argparse.Action`` class. @@ -629,13 +627,13 @@ def _action_get_nargs_range(self: argparse.Action) -> Optional[tuple[int, Union[ :param self: argparse Action being queried :return: The value of nargs_range or None if attribute does not exist """ - return cast(Optional[tuple[int, Union[int, float]]], getattr(self, ATTR_NARGS_RANGE, None)) + return cast(tuple[int, int | float] | None, getattr(self, ATTR_NARGS_RANGE, None)) setattr(argparse.Action, 'get_nargs_range', _action_get_nargs_range) -def _action_set_nargs_range(self: argparse.Action, nargs_range: Optional[tuple[int, Union[int, float]]]) -> None: +def _action_set_nargs_range(self: argparse.Action, nargs_range: tuple[int, int | float] | None) -> None: """Set the nargs_range attribute of an argparse Action. This function is added by cmd2 as a method called ``set_nargs_range()`` to ``argparse.Action`` class. @@ -694,7 +692,7 @@ def _action_set_suppress_tab_hint(self: argparse.Action, suppress_tab_hint: bool _CUSTOM_ATTRIB_PFX = '_attr_' -def register_argparse_argument_parameter(param_name: str, param_type: Optional[type[Any]]) -> None: +def register_argparse_argument_parameter(param_name: str, param_type: type[Any] | None) -> None: """Register a custom argparse argument parameter. The registered name will then be a recognized keyword parameter to the parser's `add_argument()` function. @@ -760,11 +758,11 @@ def _action_set_custom_parameter(self: argparse.Action, value: Any) -> None: def _add_argument_wrapper( self: argparse._ActionsContainer, *args: Any, - nargs: Union[int, str, tuple[int], tuple[int, int], tuple[int, float], None] = None, - choices_provider: Optional[ChoicesProviderFunc] = None, - completer: Optional[CompleterFunc] = None, + nargs: int | str | tuple[int] | tuple[int, int] | tuple[int, float] | None = None, + choices_provider: ChoicesProviderFunc | None = None, + completer: CompleterFunc | None = None, suppress_tab_hint: bool = False, - descriptive_header: Optional[str] = None, + descriptive_header: str | None = None, **kwargs: Any, ) -> argparse.Action: """Wrap ActionsContainer.add_argument() which supports more settings used by cmd2. @@ -810,7 +808,7 @@ def _add_argument_wrapper( nargs_range = None if nargs is not None: - nargs_adjusted: Union[int, str, tuple[int], tuple[int, int], tuple[int, float], None] + nargs_adjusted: int | str | tuple[int] | tuple[int, int] | tuple[int, float] | None # Check if nargs was given as a range if isinstance(nargs, tuple): # Handle 1-item tuple by setting max to INFINITY @@ -951,7 +949,7 @@ def _match_argument_wrapper(self: argparse.ArgumentParser, action: argparse.Acti ATTR_AP_COMPLETER_TYPE = 'ap_completer_type' -def _ArgumentParser_get_ap_completer_type(self: argparse.ArgumentParser) -> Optional[type['ArgparseCompleter']]: # noqa: N802 +def _ArgumentParser_get_ap_completer_type(self: argparse.ArgumentParser) -> type['ArgparseCompleter'] | None: # noqa: N802 """Get the ap_completer_type attribute of an argparse ArgumentParser. This function is added by cmd2 as a method called ``get_ap_completer_type()`` to ``argparse.ArgumentParser`` class. @@ -961,7 +959,7 @@ def _ArgumentParser_get_ap_completer_type(self: argparse.ArgumentParser) -> Opti :param self: ArgumentParser being queried :return: An ArgparseCompleter-based class or None if attribute does not exist """ - return cast(Optional[type['ArgparseCompleter']], getattr(self, ATTR_AP_COMPLETER_TYPE, None)) + return cast(type['ArgparseCompleter'] | None, getattr(self, ATTR_AP_COMPLETER_TYPE, None)) setattr(argparse.ArgumentParser, 'get_ap_completer_type', _ArgumentParser_get_ap_completer_type) @@ -1077,9 +1075,9 @@ def __init__( prog: str, indent_increment: int = 2, max_help_position: int = 24, - width: Optional[int] = None, + width: int | None = None, *, - console: Optional[rich_utils.Cmd2Console] = None, + console: rich_utils.Cmd2Console | None = None, **kwargs: Any, ) -> None: """Initialize Cmd2HelpFormatter.""" @@ -1090,10 +1088,10 @@ def __init__( def _format_usage( self, - usage: Optional[str], + usage: str | None, actions: Iterable[argparse.Action], groups: Iterable[argparse._ArgumentGroup], - prefix: Optional[str] = None, + prefix: str | None = None, ) -> str: if prefix is None: prefix = gettext('Usage: ') @@ -1147,7 +1145,7 @@ def _format_usage( # End cmd2 customization # helper for wrapping lines - def get_lines(parts: list[str], indent: str, prefix: Optional[str] = None) -> list[str]: + def get_lines(parts: list[str], indent: str, prefix: str | None = None) -> list[str]: lines: list[str] = [] line: list[str] = [] line_len = len(prefix) - 1 if prefix is not None else len(indent) - 1 @@ -1227,8 +1225,8 @@ def _format_action_invocation(self, action: argparse.Action) -> str: def _determine_metavar( self, action: argparse.Action, - default_metavar: Union[str, tuple[str, ...]], - ) -> Union[str, tuple[str, ...]]: + default_metavar: str | tuple[str, ...], + ) -> str | tuple[str, ...]: """Determine what to use as the metavar value of an action.""" if action.metavar is not None: result = action.metavar @@ -1244,7 +1242,7 @@ def _determine_metavar( def _metavar_formatter( self, action: argparse.Action, - default_metavar: Union[str, tuple[str, ...]], + default_metavar: str | tuple[str, ...], ) -> Callable[[int], tuple[str, ...]]: metavar = self._determine_metavar(action, default_metavar) @@ -1255,7 +1253,7 @@ def format_tuple(tuple_size: int) -> tuple[str, ...]: return format_tuple - def _format_args(self, action: argparse.Action, default_metavar: Union[str, tuple[str, ...]]) -> str: + def _format_args(self, action: argparse.Action, default_metavar: str | tuple[str, ...]) -> str: """Handle ranged nargs and make other output less verbose.""" metavar = self._determine_metavar(action, default_metavar) metavar_formatter = self._metavar_formatter(action, default_metavar) @@ -1361,15 +1359,15 @@ class Cmd2ArgumentParser(argparse.ArgumentParser): def __init__( self, - prog: Optional[str] = None, - usage: Optional[str] = None, - description: Optional[RenderableType] = None, - epilog: Optional[RenderableType] = None, + prog: str | None = None, + usage: str | None = None, + description: RenderableType | None = None, + epilog: RenderableType | None = None, parents: Sequence[argparse.ArgumentParser] = (), formatter_class: type[Cmd2HelpFormatter] = Cmd2HelpFormatter, prefix_chars: str = '-', - fromfile_prefix_chars: Optional[str] = None, - argument_default: Optional[str] = None, + fromfile_prefix_chars: str | None = None, + argument_default: str | None = None, conflict_handler: str = 'error', add_help: bool = True, allow_abbrev: bool = True, @@ -1377,7 +1375,7 @@ def __init__( suggest_on_error: bool = False, color: bool = False, *, - ap_completer_type: Optional[type['ArgparseCompleter']] = None, + ap_completer_type: type['ArgparseCompleter'] | None = None, ) -> None: """Initialize the Cmd2ArgumentParser instance, a custom ArgumentParser added by cmd2. @@ -1411,8 +1409,8 @@ def __init__( ) # Recast to assist type checkers since these can be Rich renderables in a Cmd2HelpFormatter. - self.description: Optional[RenderableType] = self.description # type: ignore[assignment] - self.epilog: Optional[RenderableType] = self.epilog # type: ignore[assignment] + self.description: RenderableType | None = self.description # type: ignore[assignment] + self.epilog: RenderableType | None = self.epilog # type: ignore[assignment] self.set_ap_completer_type(ap_completer_type) # type: ignore[attr-defined] @@ -1473,10 +1471,7 @@ def format_help(self) -> str: # positionals, optionals and user-defined groups for action_group in self._action_groups: - if sys.version_info >= (3, 10): - default_options_group = action_group.title == 'options' - else: - default_options_group = action_group.title == 'optional arguments' + default_options_group = action_group.title == 'options' if default_options_group: # check if the arguments are required, group accordingly diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index da308ef4..1f9e5531 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -57,7 +57,6 @@ TYPE_CHECKING, Any, ClassVar, - Optional, TextIO, TypeVar, Union, @@ -188,7 +187,7 @@ class _SavedReadlineSettings: def __init__(self) -> None: self.completer = None self.delims = '' - self.basic_quotes: Optional[bytes] = None + self.basic_quotes: bytes | None = None class _SavedCmd2Env: @@ -196,7 +195,7 @@ class _SavedCmd2Env: def __init__(self) -> None: self.readline_settings = _SavedReadlineSettings() - self.readline_module: Optional[ModuleType] = None + self.readline_module: ModuleType | None = None self.history: list[str] = [] @@ -206,7 +205,7 @@ def __init__(self) -> None: if TYPE_CHECKING: # pragma: no cover StaticArgParseBuilder = staticmethod[[], argparse.ArgumentParser] - ClassArgParseBuilder = classmethod[Union['Cmd', CommandSet], [], argparse.ArgumentParser] + ClassArgParseBuilder = classmethod['Cmd' | CommandSet, [], argparse.ArgumentParser] else: StaticArgParseBuilder = staticmethod ClassArgParseBuilder = classmethod @@ -242,7 +241,7 @@ def __contains__(self, command_method: CommandFunc) -> bool: parser = self.get(command_method) return bool(parser) - def get(self, command_method: CommandFunc) -> Optional[argparse.ArgumentParser]: + def get(self, command_method: CommandFunc) -> argparse.ArgumentParser | None: """Return a given method's parser or None if the method is not argparse-based. If the parser does not yet exist, it will be created. @@ -299,8 +298,8 @@ class Cmd(cmd.Cmd): def __init__( self, completekey: str = 'tab', - stdin: Optional[TextIO] = None, - stdout: Optional[TextIO] = None, + stdin: TextIO | None = None, + stdout: TextIO | None = None, *, persistent_history_file: str = '', persistent_history_length: int = 1000, @@ -309,12 +308,12 @@ def __init__( include_py: bool = False, include_ipy: bool = False, allow_cli_args: bool = True, - transcript_files: Optional[list[str]] = None, + transcript_files: list[str] | None = None, allow_redirection: bool = True, - multiline_commands: Optional[list[str]] = None, - terminators: Optional[list[str]] = None, - shortcuts: Optional[dict[str, str]] = None, - command_sets: Optional[Iterable[CommandSet]] = None, + multiline_commands: list[str] | None = None, + terminators: list[str] | None = None, + shortcuts: dict[str, str] | None = None, + command_sets: Iterable[CommandSet] | None = None, auto_load_commands: bool = True, allow_clipboard: bool = True, suggest_similar_command: bool = False, @@ -458,7 +457,7 @@ def __init__( # If the current command created a process to pipe to, then this will be a ProcReader object. # Otherwise it will be None. It's used to know when a pipe process can be killed and/or waited upon. - self._cur_pipe_proc_reader: Optional[utils.ProcReader] = None + self._cur_pipe_proc_reader: utils.ProcReader | None = None # Used to keep track of whether we are redirecting or piping output self._redirecting = False @@ -494,7 +493,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: list[str] | None = None # Check for command line args if allow_cli_args: @@ -623,7 +622,7 @@ def __init__( self.default_suggestion_message = "Did you mean {}?" # the current command being executed - self.current_command: Optional[Statement] = None + self.current_command: Statement | None = None def find_commandsets(self, commandset_type: type[CommandSet], *, subclass_match: bool = False) -> list[CommandSet]: """Find all CommandSets that match the provided CommandSet type. @@ -640,7 +639,7 @@ def find_commandsets(self, commandset_type: type[CommandSet], *, subclass_match: if type(cmdset) == commandset_type or (subclass_match and isinstance(cmdset, commandset_type)) # noqa: E721 ] - def find_commandset_for_command(self, command_name: str) -> Optional[CommandSet]: + def find_commandset_for_command(self, command_name: str) -> CommandSet | None: """Find the CommandSet that registered the command name. :param command_name: command name to search @@ -751,12 +750,10 @@ def register_command_set(self, cmdset: CommandSet) -> None: def _build_parser( self, parent: CommandParent, - parser_builder: Union[ - argparse.ArgumentParser, - Callable[[], argparse.ArgumentParser], - StaticArgParseBuilder, - ClassArgParseBuilder, - ], + parser_builder: argparse.ArgumentParser + | Callable[[], argparse.ArgumentParser] + | StaticArgParseBuilder + | ClassArgParseBuilder, prog: str, ) -> argparse.ArgumentParser: """Build argument parser for a command/subcommand. @@ -1191,9 +1188,9 @@ def print_to( *objects: Any, sep: str = " ", end: str = "\n", - style: Optional[StyleType] = None, - soft_wrap: Optional[bool] = None, - rich_print_kwargs: Optional[RichPrintKwargs] = None, + style: StyleType | None = None, + soft_wrap: bool | None = None, + rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: """Print objects to a given file stream. @@ -1238,9 +1235,9 @@ def poutput( *objects: Any, sep: str = " ", end: str = "\n", - style: Optional[StyleType] = None, - soft_wrap: Optional[bool] = None, - rich_print_kwargs: Optional[RichPrintKwargs] = None, + style: StyleType | None = None, + soft_wrap: bool | None = None, + rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: """Print objects to self.stdout. @@ -1274,9 +1271,9 @@ def perror( *objects: Any, sep: str = " ", end: str = "\n", - style: Optional[StyleType] = "cmd2.error", - soft_wrap: Optional[bool] = None, - rich_print_kwargs: Optional[RichPrintKwargs] = None, + style: StyleType | None = "cmd2.error", + soft_wrap: bool | None = None, + rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: """Print objects to sys.stderr. @@ -1310,8 +1307,8 @@ def psuccess( *objects: Any, sep: str = " ", end: str = "\n", - soft_wrap: Optional[bool] = None, - rich_print_kwargs: Optional[RichPrintKwargs] = None, + soft_wrap: bool | None = None, + rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: """Wrap poutput, but apply cmd2.success style. @@ -1343,8 +1340,8 @@ def pwarning( *objects: Any, sep: str = " ", end: str = "\n", - soft_wrap: Optional[bool] = None, - rich_print_kwargs: Optional[RichPrintKwargs] = None, + soft_wrap: bool | None = None, + rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: """Wrap perror, but apply cmd2.warning style. @@ -1375,7 +1372,7 @@ def pexcept( self, exception: BaseException, end: str = "\n", - rich_print_kwargs: Optional[RichPrintKwargs] = None, + rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: """Print exception to sys.stderr. If debug is true, print exception traceback if one exists. @@ -1411,9 +1408,9 @@ def pfeedback( *objects: Any, sep: str = " ", end: str = "\n", - style: Optional[StyleType] = None, - soft_wrap: Optional[bool] = None, - rich_print_kwargs: Optional[RichPrintKwargs] = None, + style: StyleType | None = None, + soft_wrap: bool | None = None, + rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: """For printing nonessential feedback. Can be silenced with `quiet`. @@ -1459,10 +1456,10 @@ def ppaged( *objects: Any, sep: str = " ", end: str = "\n", - style: Optional[StyleType] = None, + style: StyleType | None = None, chop: bool = False, - soft_wrap: Optional[bool] = None, - rich_print_kwargs: Optional[RichPrintKwargs] = None, + soft_wrap: bool | None = None, + rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: """Print output using a pager if it would go off screen and stdout isn't currently being redirected. @@ -1726,9 +1723,9 @@ def flag_based_complete( line: str, begidx: int, endidx: int, - flag_dict: dict[str, Union[Iterable[str], CompleterFunc]], + flag_dict: dict[str, Iterable[str] | CompleterFunc], *, - all_else: Union[None, Iterable[str], CompleterFunc] = None, + all_else: None | Iterable[str] | CompleterFunc = None, ) -> list[str]: """Tab completes based on a particular flag preceding the token being completed. @@ -1775,9 +1772,9 @@ def index_based_complete( line: str, begidx: int, endidx: int, - index_dict: Mapping[int, Union[Iterable[str], CompleterFunc]], + index_dict: Mapping[int, Iterable[str] | CompleterFunc], *, - all_else: Optional[Union[Iterable[str], CompleterFunc]] = None, + all_else: Iterable[str] | CompleterFunc | None = None, ) -> list[str]: """Tab completes based on a fixed position in the input string. @@ -1805,7 +1802,7 @@ def index_based_complete( index = len(tokens) - 1 # Check if token is at an index in the dictionary - match_against: Optional[Union[Iterable[str], CompleterFunc]] + match_against: Iterable[str] | CompleterFunc | None match_against = index_dict.get(index, all_else) # Perform tab completion using a Iterable @@ -1825,7 +1822,7 @@ def path_complete( begidx: int, # noqa: ARG002 endidx: int, *, - path_filter: Optional[Callable[[str], bool]] = None, + path_filter: Callable[[str], bool] | None = None, ) -> list[str]: """Perform completion of local file system paths. @@ -2139,7 +2136,7 @@ def _display_matches_gnu_readline( # rl_display_match_list() expects matches to be in argv format where # substitution is the first element, followed by the matches, and then a NULL. - strings_array = cast(list[Optional[bytes]], (ctypes.c_char_p * (1 + len(encoded_matches) + 1))()) + strings_array = cast(list[bytes | None], (ctypes.c_char_p * (1 + len(encoded_matches) + 1))()) # Copy in the encoded strings and add a NULL to the end strings_array[0] = encoded_substitution @@ -2189,11 +2186,10 @@ def _determine_ap_completer_type(parser: argparse.ArgumentParser) -> type[argpar """Determine what type of ArgparseCompleter to use on a given parser. If the parser does not have one set, then use argparse_completer.DEFAULT_AP_COMPLETER. - :param parser: the parser to examine :return: type of ArgparseCompleter """ - Completer = Optional[type[argparse_completer.ArgparseCompleter]] # noqa: N806 + Completer = type[argparse_completer.ArgparseCompleter] | None # noqa: N806 completer_type: Completer = parser.get_ap_completer_type() # type: ignore[attr-defined] if completer_type is None: @@ -2201,7 +2197,7 @@ def _determine_ap_completer_type(parser: argparse.ArgumentParser) -> type[argpar return completer_type def _perform_completion( - self, text: str, line: str, begidx: int, endidx: int, custom_settings: Optional[utils.CustomCompletionSettings] = None + self, text: str, line: str, begidx: int, endidx: int, custom_settings: utils.CustomCompletionSettings | None = None ) -> None: """Perform the actual completion, helper function for complete(). @@ -2379,8 +2375,8 @@ def _perform_completion( self.completion_matches[0] += completion_token_quote def complete( # type: ignore[override] - self, text: str, state: int, custom_settings: Optional[utils.CustomCompletionSettings] = None - ) -> Optional[str]: + self, text: str, state: int, custom_settings: utils.CustomCompletionSettings | None = None + ) -> str | None: """Override of cmd's complete method which returns the next possible completion for 'text'. This completer function is called by readline as complete(text, state), for state in 0, 1, 2, …, @@ -2579,7 +2575,7 @@ def get_help_topics(self) -> list[str]: def sigint_handler( self, signum: int, # noqa: ARG002, - frame: Optional[FrameType], # noqa: ARG002, + frame: FrameType | None, # noqa: ARG002, ) -> None: """Signal handler for SIGINTs which typically come from Ctrl-C events. @@ -2602,7 +2598,7 @@ def sigint_handler( if raise_interrupt: self._raise_keyboard_interrupt() - def termination_signal_handler(self, signum: int, _: Optional[FrameType]) -> None: + def termination_signal_handler(self, signum: int, _: FrameType | None) -> None: """Signal handler for SIGHUP and SIGTERM. Only runs on Linux and Mac. SIGHUP - received when terminal window is closed @@ -2622,7 +2618,7 @@ def _raise_keyboard_interrupt(self) -> None: """Raise a KeyboardInterrupt.""" raise KeyboardInterrupt("Got a keyboard interrupt") - def precmd(self, statement: Union[Statement, str]) -> Statement: + def precmd(self, statement: Statement | str) -> Statement: """Ran just before the command is executed by [cmd2.Cmd.onecmd][] and after adding it to history (cmd Hook method). :param statement: subclass of str which also contains the parsed input @@ -2633,7 +2629,7 @@ def precmd(self, statement: Union[Statement, str]) -> Statement: """ return Statement(statement) if not isinstance(statement, Statement) else statement - def postcmd(self, stop: bool, statement: Union[Statement, str]) -> bool: # noqa: ARG002 + def postcmd(self, stop: bool, statement: Statement | str) -> bool: # noqa: ARG002 """Ran just after a command is executed by [cmd2.Cmd.onecmd][] (cmd inherited Hook method). :param stop: return `True` to request the command loop terminate @@ -2682,7 +2678,7 @@ def onecmd_plus_hooks( add_to_history: bool = True, raise_keyboard_interrupt: bool = False, py_bridge_call: bool = False, - orig_rl_history_length: Optional[int] = None, + orig_rl_history_length: int | None = None, ) -> bool: """Top-level function called by cmdloop() to handle parsing a line and running the command and all of its hooks. @@ -2723,7 +2719,7 @@ def onecmd_plus_hooks( # we need to run the finalization hooks raise EmptyStatement # noqa: TRY301 - redir_saved_state: Optional[utils.RedirectionSavedState] = None + redir_saved_state: utils.RedirectionSavedState | None = None try: # Get sigint protection while we set up redirection @@ -2805,7 +2801,7 @@ def onecmd_plus_hooks( return stop - def _run_cmdfinalization_hooks(self, stop: bool, statement: Optional[Statement]) -> bool: + def _run_cmdfinalization_hooks(self, stop: bool, statement: Statement | None) -> bool: """Run the command finalization hooks.""" with self.sigint_protection: if not sys.platform.startswith('win') and self.stdin.isatty(): @@ -2825,7 +2821,7 @@ def _run_cmdfinalization_hooks(self, stop: bool, statement: Optional[Statement]) def runcmds_plus_hooks( self, - cmds: Union[list[HistoryItem], list[str]], + cmds: list[HistoryItem] | list[str], *, add_to_history: bool = True, stop_on_keyboard_interrupt: bool = False, @@ -2860,7 +2856,7 @@ def runcmds_plus_hooks( return False - def _complete_statement(self, line: str, *, orig_rl_history_length: Optional[int] = None) -> Statement: + def _complete_statement(self, line: str, *, orig_rl_history_length: int | None = None) -> Statement: """Keep accepting lines of input until the command is complete. There is some pretty hacky code here to handle some quirks of @@ -2950,7 +2946,7 @@ def combine_rl_history(statement: Statement) -> None: return statement - def _input_line_to_statement(self, line: str, *, orig_rl_history_length: Optional[int] = None) -> Statement: + def _input_line_to_statement(self, line: str, *, orig_rl_history_length: int | None = None) -> Statement: """Parse the user's input line and convert it to a Statement, ensuring that all macros are also resolved. :param line: the line being parsed @@ -3003,7 +2999,7 @@ def _input_line_to_statement(self, line: str, *, orig_rl_history_length: Optiona ) return statement - def _resolve_macro(self, statement: Statement) -> Optional[str]: + def _resolve_macro(self, statement: Statement) -> str | None: """Resolve a macro and return the resulting string. :param statement: the parsed statement from the command line @@ -3061,7 +3057,7 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: ) # The ProcReader for this command - cmd_pipe_proc_reader: Optional[utils.ProcReader] = None + cmd_pipe_proc_reader: utils.ProcReader | None = None if not self.allow_redirection: # Don't return since we set some state variables at the end of the function @@ -3195,7 +3191,7 @@ def _restore_output(self, statement: Statement, saved_redir_state: utils.Redirec self._cur_pipe_proc_reader = saved_redir_state.saved_pipe_proc_reader self._redirecting = saved_redir_state.saved_redirecting - def cmd_func(self, command: str) -> Optional[CommandFunc]: + def cmd_func(self, command: str) -> CommandFunc | None: """Get the function for a command. :param command: the name of the command @@ -3212,7 +3208,7 @@ def cmd_func(self, command: str) -> Optional[CommandFunc]: func = getattr(self, func_name, None) return cast(CommandFunc, func) if callable(func) else None - def onecmd(self, statement: Union[Statement, str], *, add_to_history: bool = True) -> bool: + def onecmd(self, statement: Statement | str, *, add_to_history: bool = True) -> bool: """Execute the actual do_* method for a command. If the command provided doesn't exist, then it executes default() instead. @@ -3247,7 +3243,7 @@ def onecmd(self, statement: Union[Statement, str], *, add_to_history: bool = Tru return stop if stop is not None else False - def default(self, statement: Statement) -> Optional[bool]: # type: ignore[override] + def default(self, statement: Statement) -> bool | None: # type: ignore[override] """Execute when the command given isn't a recognized command implemented by a do_* method. :param statement: Statement object with parsed input @@ -3265,20 +3261,20 @@ def default(self, statement: Statement) -> Optional[bool]: # type: ignore[overr self.perror(err_msg, style=None) return None - def _suggest_similar_command(self, command: str) -> Optional[str]: + def _suggest_similar_command(self, command: str) -> str | None: return suggest_similar(command, self.get_visible_commands()) def read_input( self, prompt: str, *, - history: Optional[list[str]] = None, + history: list[str] | None = None, completion_mode: utils.CompletionMode = utils.CompletionMode.NONE, preserve_quotes: bool = False, - choices: Optional[Iterable[Any]] = None, - choices_provider: Optional[ChoicesProviderFunc] = None, - completer: Optional[CompleterFunc] = None, - parser: Optional[argparse.ArgumentParser] = None, + choices: Iterable[Any] | None = None, + choices_provider: ChoicesProviderFunc | None = None, + completer: CompleterFunc | None = None, + parser: argparse.ArgumentParser | None = None, ) -> str: """Read input from appropriate stdin value. @@ -3312,8 +3308,8 @@ def read_input( :raises Exception: any exceptions raised by input() and stdin.readline() """ readline_configured = False - saved_completer: Optional[CompleterFunc] = None - saved_history: Optional[list[str]] = None + saved_completer: CompleterFunc | None = None + saved_history: list[str] | None = None def configure_readline() -> None: """Configure readline tab completion and history.""" @@ -3332,7 +3328,7 @@ def configure_readline() -> None: # Disable completion if completion_mode == utils.CompletionMode.NONE: - def complete_none(text: str, state: int) -> Optional[str]: # pragma: no cover # noqa: ARG001 + def complete_none(text: str, state: int) -> str | None: # pragma: no cover # noqa: ARG001 return None complete_func = complete_none @@ -4105,7 +4101,7 @@ def do_help(self, args: argparse.Namespace) -> None: self.perror(err_msg, style=None) self.last_result = False - def print_topics(self, header: str, cmds: Optional[list[str]], cmdlen: int, maxcol: int) -> None: # noqa: ARG002 + def print_topics(self, header: str, cmds: list[str] | None, cmdlen: int, maxcol: int) -> None: # noqa: ARG002 """Print groups of commands and topics in columns and an optional header. Override of cmd's print_topics() to handle headers with newlines, ANSI style sequences, and wide characters. @@ -4123,7 +4119,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: list[str] | None, 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. @@ -4260,7 +4256,7 @@ def _print_topics(self, header: str, cmds: list[str], verbose: bool) -> None: if (cmd_func := self.cmd_func(command)) is None: continue - doc: Optional[str] + doc: str | None # Non-argparse commands can have help_functions for their documentation if command in topics: @@ -4316,7 +4312,7 @@ def _build_eof_parser() -> Cmd2ArgumentParser: return eof_parser @with_argparser(_build_eof_parser) - def do_eof(self, _: argparse.Namespace) -> Optional[bool]: + def do_eof(self, _: argparse.Namespace) -> bool | None: """Quit with no arguments, called when Ctrl-D is pressed. This can be overridden if quit should be called differently. @@ -4331,13 +4327,13 @@ def _build_quit_parser() -> Cmd2ArgumentParser: return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Exit this application.") @with_argparser(_build_quit_parser) - def do_quit(self, _: argparse.Namespace) -> Optional[bool]: + def do_quit(self, _: argparse.Namespace) -> bool | None: """Exit this application.""" # Return True to stop the command loop self.last_result = True return True - def select(self, opts: Union[str, list[str], list[tuple[Any, Optional[str]]]], prompt: str = 'Your choice? ') -> Any: + def select(self, opts: str | list[str] | list[tuple[Any, str | None]], prompt: str = 'Your choice? ') -> Any: """Present a numbered menu to the user. Modeled after the bash shell's SELECT. Returns the item chosen. @@ -4350,12 +4346,12 @@ def select(self, opts: Union[str, list[str], list[tuple[Any, Optional[str]]]], p that the return value can differ from the text advertised to the user """ - local_opts: Union[list[str], list[tuple[Any, Optional[str]]]] + local_opts: list[str] | list[tuple[Any, str | None]] if isinstance(opts, str): - local_opts = cast(list[tuple[Any, Optional[str]]], list(zip(opts.split(), opts.split()))) + local_opts = cast(list[tuple[Any, str | None]], list(zip(opts.split(), opts.split(), strict=False))) else: local_opts = opts - fulloptions: list[tuple[Any, Optional[str]]] = [] + fulloptions: list[tuple[Any, str | None]] = [] for opt in local_opts: if isinstance(opt, str): fulloptions.append((opt, opt)) @@ -4695,7 +4691,7 @@ def _restore_cmd2_env(self, cmd2_env: _SavedCmd2Env) -> None: else: sys.modules['readline'] = cmd2_env.readline_module - def _run_python(self, *, pyscript: Optional[str] = None) -> Optional[bool]: + def _run_python(self, *, pyscript: str | None = None) -> bool | None: """Run an interactive Python shell or execute a pyscript file. Called by do_py() and do_run_pyscript(). @@ -4818,7 +4814,7 @@ def _build_py_parser() -> Cmd2ArgumentParser: return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive Python shell.") @with_argparser(_build_py_parser) - def do_py(self, _: argparse.Namespace) -> Optional[bool]: + def do_py(self, _: argparse.Namespace) -> bool | None: """Run an interactive Python shell. :return: True if running of commands should stop. @@ -4839,7 +4835,7 @@ def _build_run_pyscript_parser(cls) -> Cmd2ArgumentParser: return run_pyscript_parser @with_argparser(_build_run_pyscript_parser) - def do_run_pyscript(self, args: argparse.Namespace) -> Optional[bool]: + def do_run_pyscript(self, args: argparse.Namespace) -> bool | None: """Run Python script within this application's environment. :return: True if running of commands should stop @@ -4877,7 +4873,7 @@ def _build_ipython_parser() -> Cmd2ArgumentParser: return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive IPython shell.") @with_argparser(_build_ipython_parser) - def do_ipy(self, _: argparse.Namespace) -> Optional[bool]: # pragma: no cover + def do_ipy(self, _: argparse.Namespace) -> bool | None: # pragma: no cover """Run an interactive IPython shell. :return: True if running of commands should stop @@ -5012,7 +5008,7 @@ def _build_history_parser(cls) -> Cmd2ArgumentParser: return history_parser @with_argparser(_build_history_parser) - def do_history(self, args: argparse.Namespace) -> Optional[bool]: + def do_history(self, args: argparse.Namespace) -> bool | None: """View, run, edit, save, or clear previously entered commands. :return: True if running of commands should stop @@ -5241,7 +5237,7 @@ def _persist_history(self) -> None: def _generate_transcript( self, - history: Union[list[HistoryItem], list[str]], + history: list[HistoryItem] | list[str], transcript_file: str, *, add_to_history: bool = True, @@ -5360,7 +5356,7 @@ def do_edit(self, args: argparse.Namespace) -> None: # self.last_result will be set by do_shell() which is called by run_editor() self.run_editor(args.file_path) - def run_editor(self, file_path: Optional[str] = None) -> None: + def run_editor(self, file_path: str | None = None) -> None: """Run a text editor and optionally open a file with it. :param file_path: optional path of the file to edit. Defaults to None. @@ -5376,7 +5372,7 @@ def run_editor(self, file_path: Optional[str] = None) -> None: self.do_shell(command) @property - def _current_script_dir(self) -> Optional[str]: + def _current_script_dir(self) -> str | None: """Accessor to get the current script directory from the _script_dir LIFO queue.""" if self._script_dir: return self._script_dir[-1] @@ -5413,7 +5409,7 @@ def _build_run_script_parser(cls) -> Cmd2ArgumentParser: return run_script_parser @with_argparser(_build_run_script_parser) - def do_run_script(self, args: argparse.Namespace) -> Optional[bool]: + def do_run_script(self, args: argparse.Namespace) -> bool | None: """Run text script. :return: True if running of commands should stop @@ -5497,7 +5493,7 @@ def _build_relative_run_script_parser(cls) -> Cmd2ArgumentParser: return relative_run_script_parser @with_argparser(_build_relative_run_script_parser) - def do__relative_run_script(self, args: argparse.Namespace) -> Optional[bool]: + def do__relative_run_script(self, args: argparse.Namespace) -> bool | None: """Run text script. This command is intended to be used from within a text script. @@ -5573,7 +5569,7 @@ class TestMyAppCase(Cmd2TestCase): # Return a failure error code to support automated transcript-based testing self.exit_code = 1 - def async_alert(self, alert_msg: str, new_prompt: Optional[str] = None) -> None: # pragma: no cover + def async_alert(self, alert_msg: str, new_prompt: str | None = None) -> None: # pragma: no cover """Display an important message to the user while they are at a command line prompt. To the user it appears as if an alert message is printed above the prompt and their @@ -5815,7 +5811,7 @@ def _report_disabled_command_usage(self, *_args: Any, message_to_print: str, **_ """ self.perror(message_to_print, style=None) - def cmdloop(self, intro: Optional[str] = None) -> int: # type: ignore[override] + def cmdloop(self, intro: str | None = None) -> int: # type: ignore[override] """Deal with extra features provided by cmd2, this is an outer wrapper around _cmdloop(). _cmdloop() provides the main loop equivalent to cmd.cmdloop(). This is a wrapper around that which deals with @@ -6007,7 +6003,7 @@ def _resolve_func_self( self, cmd_support_func: Callable[..., Any], cmd_self: Union[CommandSet, 'Cmd', None], - ) -> Optional[object]: + ) -> object | None: """Attempt to resolve a candidate instance to pass as 'self'. Used for an unbound class method that was used when defining command's argparse object. @@ -6019,7 +6015,7 @@ def _resolve_func_self( :param cmd_self: The `self` associated with the command or subcommand """ # figure out what class the command support function was defined in - func_class: Optional[type[Any]] = get_defining_class(cmd_support_func) + func_class: type[Any] | None = get_defining_class(cmd_support_func) # Was there a defining class identified? If so, is it a sub-class of CommandSet? if func_class is not None and issubclass(func_class, CommandSet): @@ -6030,7 +6026,7 @@ def _resolve_func_self( # 2. Do any of the registered CommandSets in the Cmd2 application exactly match the type? # 3. Is there a registered CommandSet that is is the only matching subclass? - func_self: Optional[Union[CommandSet, Cmd]] + func_self: CommandSet | Cmd | None # check if the command's CommandSet is a sub-class of the support function's defining class if isinstance(cmd_self, func_class): diff --git a/cmd2/command_definition.py b/cmd2/command_definition.py index 860fd5d1..3bf6afc8 100644 --- a/cmd2/command_definition.py +++ b/cmd2/command_definition.py @@ -3,7 +3,6 @@ from collections.abc import Callable, Mapping from typing import ( TYPE_CHECKING, - Optional, TypeVar, ) @@ -23,7 +22,7 @@ #: Callable signature for a basic command function #: Further refinements are needed to define the input parameters -CommandFunc = Callable[..., Optional[bool]] +CommandFunc = Callable[..., bool | None] CommandSetType = TypeVar('CommandSetType', bound=type['CommandSet']) @@ -91,7 +90,7 @@ def __init__(self) -> None: This will be set when the CommandSet is registered and it should be accessed by child classes using the self._cmd property. """ - self.__cmd_internal: Optional[cmd2.Cmd] = None + self.__cmd_internal: cmd2.Cmd | None = None self._settables: dict[str, Settable] = {} self._settable_prefix = self.__class__.__name__ diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 246055fa..cae1b399 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -5,7 +5,6 @@ from typing import ( TYPE_CHECKING, Any, - Optional, TypeVar, Union, ) @@ -62,10 +61,10 @@ def cat_decorator(func: CommandFunc) -> CommandFunc: CommandParent = TypeVar('CommandParent', bound=Union['cmd2.Cmd', CommandSet]) -CommandParentType = TypeVar('CommandParentType', bound=Union[type['cmd2.Cmd'], type[CommandSet]]) +CommandParentType = TypeVar('CommandParentType', bound=type['cmd2.Cmd'] | type[CommandSet]) -RawCommandFuncOptionalBoolReturn = Callable[[CommandParent, Union[Statement, str]], Optional[bool]] +RawCommandFuncOptionalBoolReturn = Callable[[CommandParent, Statement | str], bool | None] ########################## @@ -73,7 +72,7 @@ def cat_decorator(func: CommandFunc) -> CommandFunc: # in cmd2 command functions/callables. As long as the 2-ple of arguments we expect to be there can be # found we can swap out the statement with each decorator's specific parameters ########################## -def _parse_positionals(args: tuple[Any, ...]) -> tuple['cmd2.Cmd', Union[Statement, str]]: +def _parse_positionals(args: tuple[Any, ...]) -> tuple['cmd2.Cmd', Statement | str]: """Inspect the positional arguments until the cmd2.Cmd argument is found. Assumes that we will find cmd2.Cmd followed by the command statement object or string. @@ -98,7 +97,7 @@ def _parse_positionals(args: tuple[Any, ...]) -> tuple['cmd2.Cmd', Union[Stateme raise TypeError('Expected arguments: cmd: cmd2.Cmd, statement: Union[Statement, str] Not found') -def _arg_swap(args: Union[Sequence[Any]], search_arg: Any, *replace_arg: Any) -> list[Any]: +def _arg_swap(args: Sequence[Any], search_arg: Any, *replace_arg: Any) -> list[Any]: """Swap the Statement parameter with one or more decorator-specific parameters. :param args: The original positional arguments @@ -114,7 +113,7 @@ def _arg_swap(args: Union[Sequence[Any]], search_arg: Any, *replace_arg: Any) -> #: Function signature for a command function that accepts a pre-processed argument list from user input #: and optionally returns a boolean -ArgListCommandFuncOptionalBoolReturn = Callable[[CommandParent, list[str]], Optional[bool]] +ArgListCommandFuncOptionalBoolReturn = Callable[[CommandParent, list[str]], bool | None] #: Function signature for a command function that accepts a pre-processed argument list from user input #: and returns a boolean ArgListCommandFuncBoolReturn = Callable[[CommandParent, list[str]], bool] @@ -123,21 +122,21 @@ def _arg_swap(args: Union[Sequence[Any]], search_arg: Any, *replace_arg: Any) -> ArgListCommandFuncNoneReturn = Callable[[CommandParent, list[str]], None] #: Aggregate of all accepted function signatures for command functions that accept a pre-processed argument list -ArgListCommandFunc = Union[ - ArgListCommandFuncOptionalBoolReturn[CommandParent], - ArgListCommandFuncBoolReturn[CommandParent], - ArgListCommandFuncNoneReturn[CommandParent], -] +ArgListCommandFunc = ( + ArgListCommandFuncOptionalBoolReturn[CommandParent] + | ArgListCommandFuncBoolReturn[CommandParent] + | ArgListCommandFuncNoneReturn[CommandParent] +) def with_argument_list( - func_arg: Optional[ArgListCommandFunc[CommandParent]] = None, + func_arg: ArgListCommandFunc[CommandParent] | None = None, *, preserve_quotes: bool = False, -) -> Union[ - RawCommandFuncOptionalBoolReturn[CommandParent], - Callable[[ArgListCommandFunc[CommandParent]], RawCommandFuncOptionalBoolReturn[CommandParent]], -]: +) -> ( + RawCommandFuncOptionalBoolReturn[CommandParent] + | Callable[[ArgListCommandFunc[CommandParent]], RawCommandFuncOptionalBoolReturn[CommandParent]] +): """Decorate a ``do_*`` method to alter the arguments passed to it so it is passed a list[str]. Default passes a string of whatever the user typed. With this decorator, the @@ -169,7 +168,7 @@ def arg_decorator(func: ArgListCommandFunc[CommandParent]) -> RawCommandFuncOpti """ @functools.wraps(func) - def cmd_wrapper(*args: Any, **kwargs: Any) -> Optional[bool]: + def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: """Command function wrapper which translates command line into an argument list and calls actual command function. :param args: All positional arguments to this function. We're expecting there to be: @@ -194,8 +193,8 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> Optional[bool]: #: Function signatures for command functions that use an argparse.ArgumentParser to process user input #: and optionally return a boolean -ArgparseCommandFuncOptionalBoolReturn = Callable[[CommandParent, argparse.Namespace], Optional[bool]] -ArgparseCommandFuncWithUnknownArgsOptionalBoolReturn = Callable[[CommandParent, argparse.Namespace, list[str]], Optional[bool]] +ArgparseCommandFuncOptionalBoolReturn = Callable[[CommandParent, argparse.Namespace], bool | None] +ArgparseCommandFuncWithUnknownArgsOptionalBoolReturn = Callable[[CommandParent, argparse.Namespace, list[str]], bool | None] #: Function signatures for command functions that use an argparse.ArgumentParser to process user input #: and return a boolean @@ -208,30 +207,28 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> Optional[bool]: ArgparseCommandFuncWithUnknownArgsNoneReturn = Callable[[CommandParent, argparse.Namespace, list[str]], None] #: Aggregate of all accepted function signatures for an argparse command function -ArgparseCommandFunc = Union[ - ArgparseCommandFuncOptionalBoolReturn[CommandParent], - ArgparseCommandFuncWithUnknownArgsOptionalBoolReturn[CommandParent], - ArgparseCommandFuncBoolReturn[CommandParent], - ArgparseCommandFuncWithUnknownArgsBoolReturn[CommandParent], - ArgparseCommandFuncNoneReturn[CommandParent], - ArgparseCommandFuncWithUnknownArgsNoneReturn[CommandParent], -] +ArgparseCommandFunc = ( + ArgparseCommandFuncOptionalBoolReturn[CommandParent] + | ArgparseCommandFuncWithUnknownArgsOptionalBoolReturn[CommandParent] + | ArgparseCommandFuncBoolReturn[CommandParent] + | ArgparseCommandFuncWithUnknownArgsBoolReturn[CommandParent] + | ArgparseCommandFuncNoneReturn[CommandParent] + | ArgparseCommandFuncWithUnknownArgsNoneReturn[CommandParent] +) def with_argparser( - parser: Union[ - argparse.ArgumentParser, # existing parser - Callable[[], argparse.ArgumentParser], # function or staticmethod - Callable[[CommandParentType], argparse.ArgumentParser], # Cmd or CommandSet classmethod - ], + parser: argparse.ArgumentParser # existing parser + | Callable[[], argparse.ArgumentParser] # function or staticmethod + | Callable[[CommandParentType], argparse.ArgumentParser], # Cmd or CommandSet classmethod *, - ns_provider: Optional[Callable[..., argparse.Namespace]] = None, + ns_provider: Callable[..., argparse.Namespace] | None = None, preserve_quotes: bool = False, with_unknown_args: bool = False, ) -> Callable[[ArgparseCommandFunc[CommandParent]], RawCommandFuncOptionalBoolReturn[CommandParent]]: """Decorate a ``do_*`` method to populate its ``args`` argument with the given instance of argparse.ArgumentParser. - :param parser: unique instance of ArgumentParser or a callable that returns an ArgumentParser + :param parser: instance of ArgumentParser or a callable that returns an ArgumentParser for this command :param ns_provider: An optional function that accepts a cmd2.Cmd or cmd2.CommandSet object as an argument and returns an argparse.Namespace. This is useful if the Namespace needs to be prepopulated with state data that affects parsing. @@ -286,7 +283,7 @@ def arg_decorator(func: ArgparseCommandFunc[CommandParent]) -> RawCommandFuncOpt """ @functools.wraps(func) - def cmd_wrapper(*args: Any, **kwargs: dict[str, Any]) -> Optional[bool]: + def cmd_wrapper(*args: Any, **kwargs: dict[str, Any]) -> bool | None: """Command function wrapper which translates command line into argparse Namespace and call actual command function. :param args: All positional arguments to this function. We're expecting there to be: @@ -317,7 +314,7 @@ def cmd_wrapper(*args: Any, **kwargs: dict[str, Any]) -> Optional[bool]: namespace = ns_provider(provider_self if provider_self is not None else cmd2_app) try: - new_args: Union[tuple[argparse.Namespace], tuple[argparse.Namespace, list[str]]] + new_args: tuple[argparse.Namespace] | tuple[argparse.Namespace, list[str]] if with_unknown_args: new_args = arg_parser.parse_known_args(parsed_arglist, namespace) else: @@ -355,20 +352,18 @@ def cmd_wrapper(*args: Any, **kwargs: dict[str, Any]) -> Optional[bool]: def as_subcommand_to( command: str, subcommand: str, - parser: Union[ - argparse.ArgumentParser, # existing parser - Callable[[], argparse.ArgumentParser], # function or staticmethod - Callable[[CommandParentType], argparse.ArgumentParser], # Cmd or CommandSet classmethod - ], + parser: argparse.ArgumentParser # existing parser + | Callable[[], argparse.ArgumentParser] # function or staticmethod + | Callable[[CommandParentType], argparse.ArgumentParser], # Cmd or CommandSet classmethod *, - help: Optional[str] = None, # noqa: A002 - aliases: Optional[list[str]] = None, + help: str | None = None, # noqa: A002 + aliases: list[str] | None = None, ) -> Callable[[ArgparseCommandFunc[CommandParent]], ArgparseCommandFunc[CommandParent]]: """Tag this method as a subcommand to an existing argparse decorated command. :param command: Command Name. Space-delimited subcommands may optionally be specified :param subcommand: Subcommand name - :param parser: argparse Parser for this subcommand + :param parser: instance of ArgumentParser or a callable that returns an ArgumentParser for this subcommand :param help: Help message for this subcommand which displays in the list of subcommands of the command we are adding to. This is passed as the help argument to subparsers.add_parser(). :param aliases: Alternative names for this subcommand. This is passed as the alias argument to diff --git a/cmd2/history.py b/cmd2/history.py index 1a8582b6..6124c30c 100644 --- a/cmd2/history.py +++ b/cmd2/history.py @@ -11,8 +11,6 @@ ) from typing import ( Any, - Optional, - Union, overload, ) @@ -164,7 +162,7 @@ def start_session(self) -> None: """Start a new session, thereby setting the next index as the first index in the new session.""" self.session_start_index = len(self) - def _zero_based_index(self, onebased: Union[int, str]) -> int: + def _zero_based_index(self, onebased: int | str) -> int: """Convert a one-based index to a zero-based index.""" result = int(onebased) if result > 0: @@ -177,7 +175,7 @@ def append(self, new: HistoryItem) -> None: ... # pragma: no cover @overload def append(self, new: Statement) -> None: ... # pragma: no cover - def append(self, new: Union[Statement, HistoryItem]) -> None: + def append(self, new: Statement | HistoryItem) -> None: """Append a new statement to the end of the History list. :param new: Statement object which will be composed into a HistoryItem @@ -332,7 +330,7 @@ def truncate(self, max_length: int) -> None: del self[0:last_element] def _build_result_dictionary( - self, start: int, end: int, filter_func: Optional[Callable[[HistoryItem], bool]] = None + self, start: int, end: int, filter_func: Callable[[HistoryItem], bool] | None = None ) -> 'OrderedDict[int, HistoryItem]': """Build history search results. diff --git a/cmd2/parsing.py b/cmd2/parsing.py index e12f799c..75e6fa41 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -9,8 +9,6 @@ ) from typing import ( Any, - Optional, - Union, ) from . import ( @@ -250,10 +248,10 @@ class StatementParser: def __init__( self, - terminators: Optional[Iterable[str]] = None, - multiline_commands: Optional[Iterable[str]] = None, - aliases: Optional[dict[str, str]] = None, - shortcuts: Optional[dict[str, str]] = None, + terminators: Iterable[str] | None = None, + multiline_commands: Iterable[str] | None = None, + aliases: dict[str, str] | None = None, + shortcuts: dict[str, str] | None = None, ) -> None: """Initialize an instance of StatementParser. @@ -585,7 +583,7 @@ def parse_command_only(self, rawinput: str) -> Statement: return Statement(args, raw=rawinput, command=command, multiline_command=multiline_command) def get_command_arg_list( - self, command_name: str, to_parse: Union[Statement, str], preserve_quotes: bool + self, command_name: str, to_parse: Statement | str, preserve_quotes: bool ) -> tuple[Statement, list[str]]: """Retrieve just the arguments being passed to their ``do_*`` methods as a list. diff --git a/cmd2/plugin.py b/cmd2/plugin.py index 92cb80bd..9243d232 100644 --- a/cmd2/plugin.py +++ b/cmd2/plugin.py @@ -3,7 +3,6 @@ from dataclasses import ( dataclass, ) -from typing import Optional from .parsing import ( Statement, @@ -38,4 +37,4 @@ class CommandFinalizationData: """Data class containing information passed to command finalization hook methods.""" stop: bool - statement: Optional[Statement] + statement: Statement | None diff --git a/cmd2/py_bridge.py b/cmd2/py_bridge.py index fe340523..3cd08494 100644 --- a/cmd2/py_bridge.py +++ b/cmd2/py_bridge.py @@ -10,9 +10,7 @@ TYPE_CHECKING, Any, NamedTuple, - Optional, TextIO, - Union, cast, ) @@ -98,7 +96,7 @@ def __dir__(self) -> list[str]: attributes.insert(0, 'cmd_echo') return attributes - def __call__(self, command: str, *, echo: Optional[bool] = None) -> CommandResult: + def __call__(self, command: str, *, echo: bool | None = None) -> CommandResult: """Provide functionality to call application commands by calling PyBridge. ex: app('help') @@ -114,7 +112,7 @@ def __call__(self, command: str, *, echo: Optional[bool] = None) -> CommandResul stdouts_match = self._cmd2_app.stdout == sys.stdout # This will be used to capture _cmd2_app.stdout and sys.stdout - copy_cmd_stdout = StdSim(cast(Union[TextIO, StdSim], self._cmd2_app.stdout), echo=echo) + copy_cmd_stdout = StdSim(cast(TextIO | StdSim, self._cmd2_app.stdout), echo=echo) # Pause the storing of stdout until onecmd_plus_hooks enables it copy_cmd_stdout.pause_storage = True diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 623dc935..44e4ee29 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -5,7 +5,6 @@ from typing import ( IO, Any, - Optional, TypedDict, ) @@ -61,7 +60,7 @@ def __repr__(self) -> str: class Cmd2Theme(Theme): """Rich theme class used by Cmd2Console.""" - def __init__(self, styles: Optional[Mapping[str, StyleType]] = None, inherit: bool = True) -> None: + def __init__(self, styles: Mapping[str, StyleType] | None = None, inherit: bool = True) -> None: """Cmd2Theme initializer. :param styles: optional mapping of style names on to styles. @@ -107,14 +106,14 @@ class RichPrintKwargs(TypedDict, total=False): dictionary, Rich's default behavior for that argument will apply. """ - justify: Optional[JustifyMethod] - overflow: Optional[OverflowMethod] - no_wrap: Optional[bool] - markup: Optional[bool] - emoji: Optional[bool] - highlight: Optional[bool] - width: Optional[int] - height: Optional[int] + justify: JustifyMethod | None + overflow: OverflowMethod | None + no_wrap: bool | None + markup: bool | None + emoji: bool | None + highlight: bool | None + width: int | None + height: int | None crop: bool new_line_start: bool diff --git a/cmd2/rl_utils.py b/cmd2/rl_utils.py index 137d447e..b6ae824c 100644 --- a/cmd2/rl_utils.py +++ b/cmd2/rl_utils.py @@ -5,7 +5,6 @@ from enum import ( Enum, ) -from typing import Union ######################################################################################################################### # NOTE ON LIBEDIT: @@ -191,7 +190,7 @@ def rl_get_prompt() -> str: # pragma: no cover prompt = '' if encoded_prompt is None else encoded_prompt.decode(encoding='utf-8') elif rl_type == RlType.PYREADLINE: - prompt_data: Union[str, bytes] = readline.rl.prompt + prompt_data: str | bytes = readline.rl.prompt prompt = prompt_data.decode(encoding='utf-8') if isinstance(prompt_data, bytes) else prompt_data else: diff --git a/cmd2/table_creator.py b/cmd2/table_creator.py index 35c89e10..df1a722b 100644 --- a/cmd2/table_creator.py +++ b/cmd2/table_creator.py @@ -16,7 +16,6 @@ ) from typing import ( Any, - Optional, ) from wcwidth import ( # type: ignore[import] @@ -57,7 +56,7 @@ def __init__( self, header: str, *, - width: Optional[int] = None, + width: int | None = None, header_horiz_align: HorizontalAlignment = HorizontalAlignment.LEFT, header_vert_align: VerticalAlignment = VerticalAlignment.BOTTOM, style_header_text: bool = True, @@ -543,9 +542,9 @@ def __init__( *, column_spacing: int = 2, tab_width: int = 4, - divider_char: Optional[str] = '-', - header_bg: Optional[ansi.BgColor] = None, - data_bg: Optional[ansi.BgColor] = None, + divider_char: str | None = '-', + header_bg: ansi.BgColor | None = None, + data_bg: ansi.BgColor | None = None, ) -> None: """SimpleTable initializer. @@ -737,10 +736,10 @@ def __init__( tab_width: int = 4, column_borders: bool = True, padding: int = 1, - border_fg: Optional[ansi.FgColor] = None, - border_bg: Optional[ansi.BgColor] = None, - header_bg: Optional[ansi.BgColor] = None, - data_bg: Optional[ansi.BgColor] = None, + border_fg: ansi.FgColor | None = None, + border_bg: ansi.BgColor | None = None, + header_bg: ansi.BgColor | None = None, + data_bg: ansi.BgColor | None = None, ) -> None: """BorderedTable initializer. @@ -1035,11 +1034,11 @@ def __init__( tab_width: int = 4, column_borders: bool = True, padding: int = 1, - border_fg: Optional[ansi.FgColor] = None, - border_bg: Optional[ansi.BgColor] = None, - header_bg: Optional[ansi.BgColor] = None, - odd_bg: Optional[ansi.BgColor] = None, - even_bg: Optional[ansi.BgColor] = ansi.Bg.DARK_GRAY, + border_fg: ansi.FgColor | None = None, + border_bg: ansi.BgColor | None = None, + header_bg: ansi.BgColor | None = None, + odd_bg: ansi.BgColor | None = None, + even_bg: ansi.BgColor | None = ansi.Bg.DARK_GRAY, ) -> None: """AlternatingTable initializer. diff --git a/cmd2/utils.py b/cmd2/utils.py index fac5f07f..4327627b 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -16,7 +16,7 @@ from collections.abc import Callable, Iterable from difflib import SequenceMatcher from enum import Enum -from typing import TYPE_CHECKING, Any, Optional, TextIO, TypeVar, Union, cast, get_type_hints +from typing import TYPE_CHECKING, Any, TextIO, TypeVar, Union, cast, get_type_hints from . import constants from .argparse_custom import ChoicesProviderFunc, CompleterFunc @@ -95,15 +95,15 @@ class Settable: def __init__( self, name: str, - val_type: Union[type[Any], Callable[[Any], Any]], + val_type: type[Any] | Callable[[Any], Any], description: str, settable_object: object, *, - settable_attrib_name: Optional[str] = None, - onchange_cb: Optional[Callable[[str, _T, _T], Any]] = None, - choices: Optional[Iterable[Any]] = None, - choices_provider: Optional[ChoicesProviderFunc] = None, - completer: Optional[CompleterFunc] = None, + settable_attrib_name: str | None = None, + onchange_cb: Callable[[str, _T, _T], Any] | None = None, + choices: Iterable[Any] | None = None, + choices_provider: ChoicesProviderFunc | None = None, + completer: CompleterFunc | None = None, ) -> None: """Settable Initializer. @@ -238,7 +238,7 @@ def alphabetical_sort(list_to_sort: Iterable[str]) -> list[str]: return sorted(list_to_sort, key=norm_fold) -def try_int_or_force_to_lower_case(input_str: str) -> Union[int, str]: +def try_int_or_force_to_lower_case(input_str: str) -> int | str: """Try to convert the passed-in string to an integer. If that fails, it converts it to lower case using norm_fold. :param input_str: string to convert @@ -250,7 +250,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[int | str]: """Convert a string into a list of integers and strings to support natural sorting (see natural_sort). For example: natural_keys('abc123def') -> ['abc', '123', 'def'] @@ -328,7 +328,7 @@ def expand_user_in_tokens(tokens: list[str]) -> None: tokens[index] = expand_user(tokens[index]) -def find_editor() -> Optional[str]: +def find_editor() -> str | None: """Set cmd2.Cmd.DEFAULT_EDITOR. If EDITOR env variable is set, that will be used. Otherwise the function will look for a known editor in directories specified by PATH env variable. @@ -467,7 +467,7 @@ def getbytes(self) -> bytes: """Get the internal contents as bytes.""" return bytes(self.buffer.byte_buf) - def read(self, size: Optional[int] = -1) -> str: + def read(self, size: int | None = -1) -> str: """Read from the internal contents as a str and then clear them out. :param size: Number of bytes to read from the stream @@ -549,7 +549,7 @@ class ProcReader: If neither are pipes, then the process will run normally and no output will be captured. """ - def __init__(self, proc: PopenTextIO, stdout: Union[StdSim, TextIO], stderr: Union[StdSim, TextIO]) -> None: + def __init__(self, proc: PopenTextIO, stdout: StdSim | TextIO, stderr: StdSim | TextIO) -> None: """ProcReader initializer. :param proc: the Popen process being read from @@ -631,7 +631,7 @@ def _reader_thread_func(self, read_stdout: bool) -> None: self._write_bytes(write_stream, available) @staticmethod - def _write_bytes(stream: Union[StdSim, TextIO], to_write: Union[bytes, str]) -> None: + def _write_bytes(stream: StdSim | TextIO, to_write: bytes | str) -> None: """Write bytes to a stream. :param stream: the stream being written to @@ -680,9 +680,9 @@ class RedirectionSavedState: def __init__( self, - self_stdout: Union[StdSim, TextIO], + self_stdout: StdSim | TextIO, stdouts_match: bool, - pipe_proc_reader: Optional[ProcReader], + pipe_proc_reader: ProcReader | None, saved_redirecting: bool, ) -> None: """RedirectionSavedState initializer. @@ -728,14 +728,14 @@ def __init__(self) -> None: self.style_dict: dict[int, str] = {} # Indexes into style_dict - self.reset_all: Optional[int] = None - self.fg: Optional[int] = None - self.bg: Optional[int] = None - self.intensity: Optional[int] = None - self.italic: Optional[int] = None - self.overline: Optional[int] = None - self.strikethrough: Optional[int] = None - self.underline: Optional[int] = None + self.reset_all: int | None = None + self.fg: int | None = None + self.bg: int | None = None + self.intensity: int | None = None + self.italic: int | None = None + self.overline: int | None = None + self.strikethrough: int | None = None + self.underline: int | None = None # Read the previous styles in order and keep track of their states style_state = StyleState() @@ -798,7 +798,7 @@ def align_text( alignment: TextAlignment, *, fill_char: str = ' ', - width: Optional[int] = None, + width: int | None = None, tab_width: int = 4, truncate: bool = False, ) -> str: @@ -920,7 +920,7 @@ def align_text( def align_left( - text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4, truncate: bool = False + text: str, *, fill_char: str = ' ', width: int | None = None, tab_width: int = 4, truncate: bool = False ) -> str: """Left align text for display within a given width. Supports characters with display widths greater than 1. @@ -943,7 +943,7 @@ def align_left( def align_center( - text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4, truncate: bool = False + text: str, *, fill_char: str = ' ', width: int | None = None, tab_width: int = 4, truncate: bool = False ) -> str: """Center text for display within a given width. Supports characters with display widths greater than 1. @@ -966,7 +966,7 @@ def align_center( def align_right( - text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4, truncate: bool = False + text: str, *, fill_char: str = ' ', width: int | None = None, tab_width: int = 4, truncate: bool = False ) -> str: """Right align text for display within a given width. Supports characters with display widths greater than 1. @@ -1095,7 +1095,7 @@ def get_styles_dict(text: str) -> dict[int, str]: return styles -def categorize(func: Union[Callable[..., Any], Iterable[Callable[..., Any]]], category: str) -> None: +def categorize(func: Callable[..., Any] | Iterable[Callable[..., Any]], category: str) -> None: """Categorize a function. The help command output will group the passed function under the @@ -1126,7 +1126,7 @@ def do_echo(self, arglist): setattr(func, constants.CMD_ATTR_HELP_CATEGORY, category) -def get_defining_class(meth: Callable[..., Any]) -> Optional[type[Any]]: +def get_defining_class(meth: Callable[..., Any]) -> type[Any] | None: """Attempt to resolve the class that defined a method. Inspired by implementation published here: @@ -1223,8 +1223,8 @@ def similarity_function(s1: str, s2: str) -> float: def suggest_similar( - requested_command: str, options: Iterable[str], similarity_function_to_use: Optional[Callable[[str, str], float]] = None -) -> Optional[str]: + requested_command: str, options: Iterable[str], similarity_function_to_use: Callable[[str, str], float] | None = None +) -> str | None: """Given a requested command and an iterable of possible options returns the most similar (if any is similar). :param requested_command: The command entered by the user diff --git a/docs/overview/installation.md b/docs/overview/installation.md index 0f94f06b..e3c12d60 100644 --- a/docs/overview/installation.md +++ b/docs/overview/installation.md @@ -1,6 +1,6 @@ # Installation Instructions -`cmd2` works on Linux, macOS, and Windows. It requires Python 3.9 or higher, +`cmd2` works on Linux, macOS, and Windows. It requires Python 10 or higher, [pip](https://pypi.org/project/pip), and [setuptools](https://pypi.org/project/setuptools). If you've got all that, then you can just: @@ -18,7 +18,7 @@ $ pip install cmd2 ## Prerequisites -If you have Python 3 >=3.9 installed from [python.org](https://www.python.org), you will already +If you have Python 3 >=3.10 installed from [python.org](https://www.python.org), you will already have [pip](https://pypi.org/project/pip) and [setuptools](https://pypi.org/project/setuptools), but may need to upgrade to the latest versions: diff --git a/examples/modular_commands_main.py b/examples/modular_commands_main.py index f03ea38d..2fba205e 100755 --- a/examples/modular_commands_main.py +++ b/examples/modular_commands_main.py @@ -5,7 +5,6 @@ import argparse from collections.abc import Iterable -from typing import Optional from modular_commands.commandset_basic import ( # noqa: F401 BasicCompletionCommandSet, @@ -26,7 +25,7 @@ class WithCommandSets(Cmd): - def __init__(self, command_sets: Optional[Iterable[CommandSet]] = None) -> None: + def __init__(self, command_sets: Iterable[CommandSet] | None = None) -> None: super().__init__(command_sets=command_sets) self.sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball'] diff --git a/package.json b/package.json index 6d5accd8..f3aa74f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "devDependencies": { - "prettier": "^3.5.3", - "prettier-plugin-toml": "^2.0.5" + "prettier": "^3.6.2", + "prettier-plugin-toml": "^2.0.6" } } diff --git a/plugins/ext_test/build-pyenvs.sh b/plugins/ext_test/build-pyenvs.sh index 4b515bbf..9ee27578 100644 --- a/plugins/ext_test/build-pyenvs.sh +++ b/plugins/ext_test/build-pyenvs.sh @@ -8,7 +8,7 @@ # version numbers are: major.minor.patch # # this script will delete and recreate existing virtualenvs named -# cmd2-3.9, etc. It will also create a .python-version +# cmd2-3.14, etc. It will also create a .python-version # # Prerequisites: # - *nix-ish environment like macOS or Linux @@ -23,7 +23,7 @@ # virtualenvs will be added to '.python-version'. Feel free to modify # this list, but note that this script intentionally won't install # dev, rc, or beta python releases -declare -a pythons=("3.9", "3.10", "3.11", "3.12", "3.13") +declare -a pythons=("3.10", "3.11", "3.12", "3.13", "3.14") # function to find the latest patch of a minor version of python function find_latest_version { diff --git a/plugins/ext_test/cmd2_ext_test/cmd2_ext_test.py b/plugins/ext_test/cmd2_ext_test/cmd2_ext_test.py index 1cb45f60..843d609f 100644 --- a/plugins/ext_test/cmd2_ext_test/cmd2_ext_test.py +++ b/plugins/ext_test/cmd2_ext_test/cmd2_ext_test.py @@ -2,7 +2,6 @@ from typing import ( TYPE_CHECKING, - Optional, ) import cmd2 @@ -29,7 +28,7 @@ def __init__(self, *args, **kwargs): # code placed here runs after cmd2 initializes self._pybridge = cmd2.py_bridge.PyBridge(self) - def app_cmd(self, command: str, echo: Optional[bool] = None) -> cmd2.CommandResult: + def app_cmd(self, command: str, echo: bool | None = None) -> cmd2.CommandResult: """ Run the application command diff --git a/plugins/ext_test/noxfile.py b/plugins/ext_test/noxfile.py index d8aa344b..9a29eaab 100644 --- a/plugins/ext_test/noxfile.py +++ b/plugins/ext_test/noxfile.py @@ -1,7 +1,7 @@ import nox -@nox.session(python=['3.9', '3.10', '3.11', '3.12', '3.13']) +@nox.session(python=['3.10', '3.11', '3.12', '3.13', '3.14']) def tests(session): session.install('invoke', './[test]') session.run('invoke', 'pytest', '--junit', '--no-pty') diff --git a/plugins/ext_test/setup.py b/plugins/ext_test/setup.py index e3b38776..244b85cf 100644 --- a/plugins/ext_test/setup.py +++ b/plugins/ext_test/setup.py @@ -24,7 +24,7 @@ license='MIT', package_data=PACKAGE_DATA, packages=['cmd2_ext_test'], - python_requires='>=3.9', + python_requires='>=3.10', install_requires=['cmd2 >= 2, <3'], setup_requires=['setuptools >= 42', 'setuptools_scm >= 3.4'], classifiers=[ @@ -34,7 +34,6 @@ 'Topic :: Software Development :: Libraries :: Python Modules', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', diff --git a/plugins/template/README.md b/plugins/template/README.md index 11fe2690..4ade388f 100644 --- a/plugins/template/README.md +++ b/plugins/template/README.md @@ -215,28 +215,24 @@ If you prefer to create these virtualenvs by hand, do the following: ``` $ cd cmd2_abbrev -$ pyenv install 3.8.5 -$ pyenv virtualenv -p python3.8 3.8.5 cmd2-3.8 -$ pyenv install 3.9.0 -$ pyenv virtualenv -p python3.9 3.9.0 cmd2-3.9 +$ pyenv install 3.14.0 +$ pyenv virtualenv -p python3.14 3.14.0 cmd2-3.14 ``` Now set pyenv to make both of those available at the same time: ``` -$ pyenv local cmd2-3.8 cmd2-3.9 +$ pyenv local cmd2-3.14 ``` Whether you ran the script, or did it by hand, you now have isolated virtualenvs for each of the major python versions. This table shows various python commands, the version of python which will be executed, and the virtualenv it will utilize. -| Command | python | virtualenv | -| ----------- | ------ | ---------- | -| `python3.8` | 3.8.5 | cmd2-3.8 | -| `python3.9` | 3.9.0 | cmd2-3.9 | -| `pip3.8` | 3.8.5 | cmd2-3.8 | -| `pip3.9` | 3.9.0 | cmd2-3.9 | +| Command | python | virtualenv | +| ------------ | ------ | ---------- | +| `python3.14` | 3.14.0 | cmd2-3.14 | +| `pip3.14` | 3.14.0 | cmd2-3.14 | ## Install Dependencies @@ -249,10 +245,10 @@ $ pip install -e .[dev] This command also installs `cmd2-myplugin` "in-place", so the package points to the source code instead of copying files to the python `site-packages` folder. -All the dependencies now have been installed in the `cmd2-3.9` virtualenv. If you want to work in +All the dependencies now have been installed in the `cmd2-3.14` virtualenv. If you want to work in other virtualenvs, you'll need to manually select it, and install again:: -$ pyenv shell cmd2-3.4 $ pip install -e .[dev] +$ pyenv shell cmd2-3.14 $ pip install -e .[dev] Now that you have your python environments created, you need to install the package in place, along with all the other development dependencies: @@ -268,8 +264,8 @@ the `tests` directory. ### Use nox to run unit tests in multiple versions of python -The included `noxfile.py` is setup to run the unit tests in python 3.8, 3.9 3.10, 3.11, and 3.12 You -can run your unit tests in all of these versions of python by: +The included `noxfile.py` is setup to run the unit tests in 3.10, 3.11, 3.12, 3.13, and 3.14 You can +run your unit tests in all of these versions of python by: ``` $ nox diff --git a/plugins/template/build-pyenvs.sh b/plugins/template/build-pyenvs.sh index fd0b505b..9ee27578 100644 --- a/plugins/template/build-pyenvs.sh +++ b/plugins/template/build-pyenvs.sh @@ -8,7 +8,7 @@ # version numbers are: major.minor.patch # # this script will delete and recreate existing virtualenvs named -# cmd2-3.9, etc. It will also create a .python-version +# cmd2-3.14, etc. It will also create a .python-version # # Prerequisites: # - *nix-ish environment like macOS or Linux @@ -23,7 +23,7 @@ # virtualenvs will be added to '.python-version'. Feel free to modify # this list, but note that this script intentionally won't install # dev, rc, or beta python releases -declare -a pythons=("3.9" "3.10" "3.11", "3.12", "3.13") +declare -a pythons=("3.10", "3.11", "3.12", "3.13", "3.14") # function to find the latest patch of a minor version of python function find_latest_version { diff --git a/plugins/template/noxfile.py b/plugins/template/noxfile.py index cac9f917..d37ed138 100644 --- a/plugins/template/noxfile.py +++ b/plugins/template/noxfile.py @@ -1,7 +1,7 @@ import nox -@nox.session(python=['3.9', '3.10', '3.11', '3.12', '3.13']) +@nox.session(python=['3.10', '3.11', '3.12', '3.13', '3.14']) def tests(session) -> None: session.install('invoke', './[test]') session.run('invoke', 'pytest', '--junit', '--no-pty') diff --git a/plugins/template/setup.py b/plugins/template/setup.py index 3eed7f28..cc4f6331 100644 --- a/plugins/template/setup.py +++ b/plugins/template/setup.py @@ -20,7 +20,7 @@ url='https://github.com/python-cmd2/cmd2-plugin-template', license='MIT', packages=['cmd2_myplugin'], - python_requires='>=3.9', + python_requires='>=3.10', install_requires=['cmd2 >= 2, <3'], setup_requires=['setuptools_scm'], classifiers=[ @@ -30,7 +30,6 @@ 'Topic :: Software Development :: Libraries :: Python Modules', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', diff --git a/pyproject.toml b/pyproject.toml index 9f3990d0..cce63b8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ dynamic = ["version"] description = "cmd2 - quickly build feature-rich and user-friendly interactive command line applications in Python" authors = [{ name = "cmd2 Contributors" }] readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" keywords = ["CLI", "cmd", "command", "interactive", "prompt", "Python"] license = { file = "LICENSE" } classifiers = [ @@ -19,7 +19,6 @@ classifiers = [ "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -149,7 +148,7 @@ exclude = [ # Same as Black. line-length = 127 indent-width = 4 -target-version = "py39" # Minimum supported version of Python +target-version = "py310" # Minimum supported version of Python output-format = "full" [tool.ruff.lint] @@ -225,7 +224,6 @@ select = [ ignore = [ # `uv run ruff rule E501` for a description of that rule "ANN401", # Dynamically typed expressions (typing.Any) are disallowed (would be good to enable this later) - "B905", # zip() without an explicit strict= parameter (strict added in Python 3.10+) "COM812", # Conflicts with ruff format (see https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules) "COM819", # Conflicts with ruff format "D203", # 1 blank line required before class docstring (conflicts with D211) @@ -243,7 +241,6 @@ ignore = [ "Q003", # Conflicts with ruff format "TC006", # Add quotes to type expression in typing.cast() (not needed except for forward references) "TRY003", # Avoid specifying long messages outside the exception class (force custom exceptions for everything) - "UP007", # Use X | Y for type annotations (requires Python 3.10+) "UP017", # Use datetime.UTC alias (requires Python 3.11+) "UP038", # Use X | Y in {} call instead of (X, Y) - deprecated due to poor performance (requires Python 3.10+) "W191", # Conflicts with ruff format diff --git a/tasks.py b/tasks.py index f6b9d7ff..92917dba 100644 --- a/tasks.py +++ b/tasks.py @@ -12,7 +12,6 @@ import re import shutil import sys -from typing import Union import invoke from invoke.context import Context @@ -26,7 +25,7 @@ # shared function -def rmrf(items: Union[str, list[str], set[str]], verbose: bool = True) -> None: +def rmrf(items: str | list[str] | set[str], verbose: bool = True) -> None: """Silently remove a list of directories or files.""" if isinstance(items, str): items = [items] diff --git a/tests/conftest.py b/tests/conftest.py index 35bb90e6..df5159a3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,10 +3,6 @@ import argparse import sys from contextlib import redirect_stderr -from typing import ( - Optional, - Union, -) from unittest import ( mock, ) @@ -22,9 +18,7 @@ ) -def verify_help_text( - cmd2_app: cmd2.Cmd, help_output: Union[str, list[str]], verbose_strings: Optional[list[str]] = None -) -> None: +def verify_help_text(cmd2_app: cmd2.Cmd, help_output: str | list[str], verbose_strings: list[str] | None = None) -> None: """This function verifies that all expected commands are present in the help text. :param cmd2_app: instance of cmd2.Cmd @@ -148,7 +142,7 @@ def base_app(): odd_file_names = ['nothingweird', 'has spaces', '"is_double_quoted"', "'is_single_quoted'"] -def complete_tester(text: str, line: str, begidx: int, endidx: int, app) -> Optional[str]: +def complete_tester(text: str, line: str, begidx: int, endidx: int, app) -> str | None: """This is a convenience function to test cmd2.complete() since in a unit test environment there is no actual console readline is monitoring. Therefore we use mock to provide readline data diff --git a/tests/test_argparse.py b/tests/test_argparse.py index 7eb32076..afcae62e 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -1,7 +1,6 @@ """Cmd2 testing for argument parsing""" import argparse -from typing import Optional import pytest @@ -32,7 +31,7 @@ def _say_parser_builder() -> cmd2.Cmd2ArgumentParser: return say_parser @cmd2.with_argparser(_say_parser_builder) - def do_say(self, args, *, keyword_arg: Optional[str] = None) -> None: + def do_say(self, args, *, keyword_arg: str | None = None) -> None: """Repeat what you tell me to. :param args: argparse namespace @@ -70,7 +69,7 @@ def do_test_argparse_ns(self, args) -> None: self.stdout.write(f'{args.custom_stuff}') @cmd2.with_argument_list - def do_arglist(self, arglist, *, keyword_arg: Optional[str] = None) -> None: + def do_arglist(self, arglist, *, keyword_arg: str | None = None) -> None: if isinstance(arglist, list): self.stdout.write('True') else: @@ -92,7 +91,7 @@ def _speak_parser_builder(cls) -> cmd2.Cmd2ArgumentParser: return known_parser @cmd2.with_argparser(_speak_parser_builder, with_unknown_args=True) - def do_speak(self, args, extra, *, keyword_arg: Optional[str] = None) -> None: + def do_speak(self, args, extra, *, keyword_arg: str | None = None) -> None: """Repeat what you tell me to.""" words = [] for word in extra: diff --git a/tests_isolated/test_commandset/conftest.py b/tests_isolated/test_commandset/conftest.py index fe6af8c3..c2bdf81f 100644 --- a/tests_isolated/test_commandset/conftest.py +++ b/tests_isolated/test_commandset/conftest.py @@ -5,10 +5,6 @@ redirect_stderr, redirect_stdout, ) -from typing import ( - Optional, - Union, -) from unittest import ( mock, ) @@ -27,9 +23,7 @@ ) -def verify_help_text( - cmd2_app: cmd2.Cmd, help_output: Union[str, list[str]], verbose_strings: Optional[list[str]] = None -) -> None: +def verify_help_text(cmd2_app: cmd2.Cmd, help_output: str | list[str], verbose_strings: list[str] | None = None) -> None: """This function verifies that all expected commands are present in the help text. :param cmd2_app: instance of cmd2.Cmd @@ -134,7 +128,7 @@ def base_app(): odd_file_names = ['nothingweird', 'has spaces', '"is_double_quoted"', "'is_single_quoted'"] -def complete_tester(text: str, line: str, begidx: int, endidx: int, app) -> Optional[str]: +def complete_tester(text: str, line: str, begidx: int, endidx: int, app) -> str | None: """This is a convenience function to test cmd2.complete() since in a unit test environment there is no actual console readline is monitoring. Therefore we use mock to provide readline data From a7129c5e2499c45ce6b96d02a8a1b1898b783723 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Aug 2025 10:33:39 -0400 Subject: [PATCH 21/89] Bump actions/checkout from 4 to 5 (#1474) Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/docs.yml | 2 +- .github/workflows/quality.yml | 2 +- .github/workflows/tests.yml | 2 +- .github/workflows/typecheck.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 976b5754..820a47b9 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -32,7 +32,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: # We must fetch at least the immediate parents so that if this is # a pull request then we can checkout the head. diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e2137618..5a0f7739 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 # Needed for setuptools_scm to work correctly diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index e0475282..7626b0cf 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 # Needed for setuptools_scm to work correctly diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e57d883d..6d8e0a7e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,7 +21,7 @@ jobs: shell: bash steps: - name: Check out - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 # Needed for setuptools_scm to work correctly - name: Install uv diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 89d5b538..79d60c31 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -22,7 +22,7 @@ jobs: shell: bash steps: - name: Check out - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 # Needed for setuptools_scm to work correctly From 8e0a1a2d1bf5883aaf6152932224c85bba6fbd96 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 13 Aug 2025 13:23:32 -0400 Subject: [PATCH 22/89] All tables in cmd2 are now rich-based. (#1475) * All tables in cmd2 are now rich-based, including those used in tab completion. * Added a StrEnum for all cmd2 text styles. * Force UTF-8 Unicode encoding when running tests both locally and in GitHub Actions * Fixed issue where colors were being stripped by pyreadline3. --------- Co-authored-by: Todd Leonhardt --- .github/workflows/tests.yml | 5 +- Makefile | 4 +- cmd2/argparse_completer.py | 85 +++++----- cmd2/argparse_custom.py | 144 ++++++++++------- cmd2/cmd2.py | 176 ++++++++++----------- cmd2/rich_utils.py | 53 +++++-- docs/features/builtin_commands.md | 2 +- examples/argparse_completion.py | 11 +- pyproject.toml | 1 + tests/conftest.py | 50 ------ tests/test_argparse_completer.py | 33 ++-- tests/test_cmd2.py | 36 +++-- tests/test_history.py | 6 - tests/transcripts/regex_set.txt | 35 ++-- tests_isolated/test_commandset/conftest.py | 35 ---- 15 files changed, 332 insertions(+), 344 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6d8e0a7e..e97ccf4d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -36,11 +36,12 @@ jobs: run: uv sync --all-extras --dev - name: Run tests - run: uv run python -m pytest --cov --cov-config=pyproject.toml --cov-report=xml tests + run: uv run python -Xutf8 -m pytest --cov --cov-config=pyproject.toml --cov-report=xml tests - name: Run isolated tests run: - uv run python -m pytest --cov --cov-config=pyproject.toml --cov-report=xml tests_isolated + uv run python -Xutf8 -m pytest --cov --cov-config=pyproject.toml --cov-report=xml + tests_isolated - name: Upload test results to Codecov if: ${{ !cancelled() }} diff --git a/Makefile b/Makefile index 9c851c14..4f6a7daf 100644 --- a/Makefile +++ b/Makefile @@ -33,8 +33,8 @@ typecheck: ## Perform type checking .PHONY: test test: ## Test the code with pytest. @echo "🚀 Testing code: Running pytest" - @uv run python -m pytest --cov --cov-config=pyproject.toml --cov-report=xml tests - @uv run python -m pytest --cov --cov-config=pyproject.toml --cov-report=xml tests_isolated + @uv run python -Xutf8 -m pytest --cov --cov-config=pyproject.toml --cov-report=xml tests + @uv run python -Xutf8 -m pytest --cov --cov-config=pyproject.toml --cov-report=xml tests_isolated .PHONY: docs-test docs-test: ## Test if documentation can be built without warnings or errors diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 5aeef609..2418d255 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -6,28 +6,34 @@ import argparse import inspect import numbers +import sys from collections import ( deque, ) +from collections.abc import Sequence from typing import ( IO, TYPE_CHECKING, cast, ) -from .ansi import ( - style_aware_wcswidth, - widest_line, -) from .constants import ( INFINITY, ) +from .rich_utils import ( + Cmd2Console, + Cmd2Style, +) if TYPE_CHECKING: # pragma: no cover from .cmd2 import ( Cmd, ) + +from rich.box import SIMPLE_HEAD +from rich.table import Column, Table + from .argparse_custom import ( ChoicesCallable, ChoicesProviderFuncWithTokens, @@ -40,14 +46,9 @@ from .exceptions import ( CompletionError, ) -from .table_creator import ( - Column, - HorizontalAlignment, - SimpleTable, -) -# If no descriptive header is supplied, then this will be used instead -DEFAULT_DESCRIPTIVE_HEADER = 'Description' +# If no descriptive headers are supplied, then this will be used instead +DEFAULT_DESCRIPTIVE_HEADERS: Sequence[str | Column] = ('Description',) # Name of the choice/completer function argument that, if present, will be passed a dictionary of # command line tokens up through the token being completed mapped to their argparse destination name. @@ -546,8 +547,6 @@ def _format_completions(self, arg_state: _ArgumentState, completions: list[str] # Check if there are too many CompletionItems to display as a table if len(completions) <= self._cmd2_app.max_completion_items: - four_spaces = 4 * ' ' - # If a metavar was defined, use that instead of the dest field destination = arg_state.action.metavar if arg_state.action.metavar else arg_state.action.dest @@ -560,39 +559,45 @@ def _format_completions(self, arg_state: _ArgumentState, completions: list[str] tuple_index = min(len(destination) - 1, arg_state.count) destination = destination[tuple_index] - desc_header = arg_state.action.get_descriptive_header() # type: ignore[attr-defined] - if desc_header is None: - desc_header = DEFAULT_DESCRIPTIVE_HEADER - - # Replace tabs with 4 spaces so we can calculate width - desc_header = desc_header.replace('\t', four_spaces) + desc_headers = cast(Sequence[str | Column] | None, arg_state.action.get_descriptive_headers()) # type: ignore[attr-defined] + if desc_headers is None: + desc_headers = DEFAULT_DESCRIPTIVE_HEADERS - # Calculate needed widths for the token and description columns of the table - token_width = style_aware_wcswidth(destination) - desc_width = widest_line(desc_header) - - for item in completion_items: - token_width = max(style_aware_wcswidth(item), token_width) - - # Replace tabs with 4 spaces so we can calculate width - item.description = item.description.replace('\t', four_spaces) - desc_width = max(widest_line(item.description), desc_width) - - cols = [] - dest_alignment = HorizontalAlignment.RIGHT if all_nums else HorizontalAlignment.LEFT - cols.append( + # Build all headers for the hint table + headers: list[Column] = [] + headers.append( Column( destination.upper(), - width=token_width, - header_horiz_align=dest_alignment, - data_horiz_align=dest_alignment, + justify="right" if all_nums else "left", + no_wrap=True, ) ) - cols.append(Column(desc_header, width=desc_width)) + for desc_header in desc_headers: + header = ( + desc_header + if isinstance(desc_header, Column) + else Column( + desc_header, + overflow="fold", + ) + ) + headers.append(header) + + # Build the hint table + hint_table = Table( + *headers, + box=SIMPLE_HEAD, + show_edge=False, + border_style=Cmd2Style.RULE_LINE, + ) + for item in completion_items: + hint_table.add_row(item, *item.descriptive_data) - hint_table = SimpleTable(cols, divider_char=self._cmd2_app.ruler) - table_data = [[item, item.description] for item in completion_items] - self._cmd2_app.formatted_completions = hint_table.generate_table(table_data, row_spacing=0) + # Generate the hint table string + console = Cmd2Console(sys.stdout) + with console.capture() as capture: + console.print(hint_table) + self._cmd2_app.formatted_completions = capture.get() # Return sorted list of completions return cast(list[str], completions) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index df20ceb6..caa4aac5 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -122,38 +122,25 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens) numbers isn't very helpful to a user without context. Returning a list of CompletionItems instead of a regular string for completion results will signal the ArgparseCompleter to output the completion results in a table of completion -tokens with descriptions instead of just a table of tokens:: +tokens with descriptive data instead of just a table of tokens:: Instead of this: 1 2 3 The user sees this: - ITEM_ID Item Name - ============================ - 1 My item - 2 Another item - 3 Yet another item + ITEM_ID Description + ──────────────────────────── + 1 My item + 2 Another item + 3 Yet another item The left-most column is the actual value being tab completed and its header is that value's name. The right column header is defined using the -descriptive_header parameter of add_argument(). The right column values come -from the CompletionItem.description value. - -Example:: - - token = 1 - token_description = "My Item" - completion_item = CompletionItem(token, token_description) - -Since descriptive_header and CompletionItem.description are just strings, you -can format them in such a way to have multiple columns:: - - ITEM_ID Item Name Checked Out Due Date - ========================================================== - 1 My item True 02/02/2022 - 2 Another item False - 3 Yet another item False +``descriptive_headers`` parameter of add_argument(), which is a list of header +names that defaults to ["Description"]. The right column values come from the +``CompletionItem.descriptive_data`` member, which is a list with the same number +of items as columns defined in descriptive_headers. To use CompletionItems, just return them from your choices_provider or completer functions. They can also be used as argparse choices. When a @@ -162,12 +149,59 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens) argparse so that when evaluating choices, input is compared to CompletionItem.orig_value instead of the CompletionItem instance. -To avoid printing a ton of information to the screen at once when a user +Example:: + + Add an argument and define its descriptive_headers. + + parser.add_argument( + add_argument( + "item_id", + type=int, + choices_provider=get_items, + descriptive_headers=["Item Name", "Checked Out", "Due Date"], + ) + + Implement the choices_provider to return CompletionItems. + + def get_items(self) -> list[CompletionItems]: + \"\"\"choices_provider which returns CompletionItems\"\"\" + + # CompletionItem's second argument is descriptive_data. + # Its item count should match that of descriptive_headers. + return [ + CompletionItem(1, ["My item", True, "02/02/2022"]), + CompletionItem(2, ["Another item", False, ""]), + CompletionItem(3, ["Yet another item", False, ""]), + ] + + This is what the user will see during tab completion. + + ITEM_ID Item Name Checked Out Due Date + ─────────────────────────────────────────────────────── + 1 My item True 02/02/2022 + 2 Another item False + 3 Yet another item False + +``descriptive_headers`` can be strings or ``Rich.table.Columns`` for more +control over things like alignment. + +- If a header is a string, it will render as a left-aligned column with its +overflow behavior set to "fold". This means a long string will wrap within its +cell, creating as many new lines as required to fit. + +- If a header is a ``Column``, it defaults to "ellipsis" overflow behavior. +This means a long string which exceeds the width of its column will be +truncated with an ellipsis at the end. You can override this and other settings +when you create the ``Column``. + +``descriptive_data`` items can include Rich objects, including styled text. + +To avoid printing a excessive information to the screen at once when a user presses tab, there is a maximum threshold for the number of CompletionItems -that will be shown. Its value is defined in cmd2.Cmd.max_completion_items. It -defaults to 50, but can be changed. If the number of completion suggestions +that will be shown. Its value is defined in ``cmd2.Cmd.max_completion_items``. +It defaults to 50, but can be changed. If the number of completion suggestions exceeds this number, they will be displayed in the typical columnized format -and will not include the description value of the CompletionItems. +and will not include the descriptive_data of the CompletionItems. **Patched argparse functions** @@ -200,8 +234,8 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens) - ``argparse.Action.get_choices_callable()`` - See `action_get_choices_callable` for more details. - ``argparse.Action.set_choices_provider()`` - See `_action_set_choices_provider` for more details. - ``argparse.Action.set_completer()`` - See `_action_set_completer` for more details. -- ``argparse.Action.get_descriptive_header()`` - See `_action_get_descriptive_header` for more details. -- ``argparse.Action.set_descriptive_header()`` - See `_action_set_descriptive_header` for more details. +- ``argparse.Action.get_descriptive_headers()`` - See `_action_get_descriptive_headers` for more details. +- ``argparse.Action.set_descriptive_headers()`` - See `_action_set_descriptive_headers` for more details. - ``argparse.Action.get_nargs_range()`` - See `_action_get_nargs_range` for more details. - ``argparse.Action.set_nargs_range()`` - See `_action_set_nargs_range` for more details. - ``argparse.Action.get_suppress_tab_hint()`` - See `_action_get_suppress_tab_hint` for more details. @@ -249,6 +283,7 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens) Group, RenderableType, ) +from rich.protocol import is_renderable from rich.table import Column, Table from rich.text import Text from rich_argparse import ( @@ -263,6 +298,7 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens) constants, rich_utils, ) +from .rich_utils import Cmd2Style if TYPE_CHECKING: # pragma: no cover from .argparse_completer import ( @@ -349,15 +385,17 @@ def __new__(cls, value: object, *_args: Any, **_kwargs: Any) -> 'CompletionItem' """Responsible for creating and returning a new instance, called before __init__ when an object is instantiated.""" return super().__new__(cls, value) - def __init__(self, value: object, description: str = '', *args: Any) -> None: + def __init__(self, value: object, descriptive_data: Sequence[Any], *args: Any) -> None: """CompletionItem Initializer. :param value: the value being tab completed - :param description: description text to display + :param descriptive_data: descriptive data to display :param args: args for str __init__ """ super().__init__(*args) - self.description = description + + # Make sure all objects are renderable by a Rich table. + self.descriptive_data = [obj if is_renderable(obj) else str(obj) for obj in descriptive_data] # Save the original value to support CompletionItems as argparse choices. # cmd2 has patched argparse so input is compared to this value instead of the CompletionItem instance. @@ -483,7 +521,7 @@ def choices_provider(self) -> ChoicesProviderFunc: ATTR_CHOICES_CALLABLE = 'choices_callable' # Descriptive header that prints when using CompletionItems -ATTR_DESCRIPTIVE_HEADER = 'descriptive_header' +ATTR_DESCRIPTIVE_HEADERS = 'descriptive_headers' # A tuple specifying nargs as a range (min, max) ATTR_NARGS_RANGE = 'nargs_range' @@ -580,38 +618,38 @@ def _action_set_completer( ############################################################################################################ -# Patch argparse.Action with accessors for descriptive_header attribute +# Patch argparse.Action with accessors for descriptive_headers attribute ############################################################################################################ -def _action_get_descriptive_header(self: argparse.Action) -> str | None: - """Get the descriptive_header attribute of an argparse Action. +def _action_get_descriptive_headers(self: argparse.Action) -> Sequence[str | Column] | None: + """Get the descriptive_headers attribute of an argparse Action. - This function is added by cmd2 as a method called ``get_descriptive_header()`` to ``argparse.Action`` class. + This function is added by cmd2 as a method called ``get_descriptive_headers()`` to ``argparse.Action`` class. - To call: ``action.get_descriptive_header()`` + To call: ``action.get_descriptive_headers()`` :param self: argparse Action being queried - :return: The value of descriptive_header or None if attribute does not exist + :return: The value of descriptive_headers or None if attribute does not exist """ - return cast(str | None, getattr(self, ATTR_DESCRIPTIVE_HEADER, None)) + return cast(Sequence[str | Column] | None, getattr(self, ATTR_DESCRIPTIVE_HEADERS, None)) -setattr(argparse.Action, 'get_descriptive_header', _action_get_descriptive_header) +setattr(argparse.Action, 'get_descriptive_headers', _action_get_descriptive_headers) -def _action_set_descriptive_header(self: argparse.Action, descriptive_header: str | None) -> None: - """Set the descriptive_header attribute of an argparse Action. +def _action_set_descriptive_headers(self: argparse.Action, descriptive_headers: Sequence[str | Column] | None) -> None: + """Set the descriptive_headers attribute of an argparse Action. - This function is added by cmd2 as a method called ``set_descriptive_header()`` to ``argparse.Action`` class. + This function is added by cmd2 as a method called ``set_descriptive_headers()`` to ``argparse.Action`` class. - To call: ``action.set_descriptive_header(descriptive_header)`` + To call: ``action.set_descriptive_headers(descriptive_headers)`` :param self: argparse Action being updated - :param descriptive_header: value being assigned + :param descriptive_headers: value being assigned """ - setattr(self, ATTR_DESCRIPTIVE_HEADER, descriptive_header) + setattr(self, ATTR_DESCRIPTIVE_HEADERS, descriptive_headers) -setattr(argparse.Action, 'set_descriptive_header', _action_set_descriptive_header) +setattr(argparse.Action, 'set_descriptive_headers', _action_set_descriptive_headers) ############################################################################################################ @@ -762,7 +800,7 @@ def _add_argument_wrapper( choices_provider: ChoicesProviderFunc | None = None, completer: CompleterFunc | None = None, suppress_tab_hint: bool = False, - descriptive_header: str | None = None, + descriptive_headers: list[Column | str] | None = None, **kwargs: Any, ) -> argparse.Action: """Wrap ActionsContainer.add_argument() which supports more settings used by cmd2. @@ -782,8 +820,8 @@ def _add_argument_wrapper( current argument's help text as a hint. Set this to True to suppress the hint. If this argument's help text is set to argparse.SUPPRESS, then tab hints will not display regardless of the value passed for suppress_tab_hint. Defaults to False. - :param descriptive_header: if the provided choices are CompletionItems, then this header will display - during tab completion. Defaults to None. + :param descriptive_headers: if the provided choices are CompletionItems, then these are the headers + of the descriptive data. Defaults to None. # Args from original function :param kwargs: keyword-arguments recognized by argparse._ActionsContainer.add_argument @@ -874,7 +912,7 @@ def _add_argument_wrapper( new_arg.set_completer(completer) # type: ignore[attr-defined] new_arg.set_suppress_tab_hint(suppress_tab_hint) # type: ignore[attr-defined] - new_arg.set_descriptive_header(descriptive_header) # type: ignore[attr-defined] + new_arg.set_descriptive_headers(descriptive_headers) # type: ignore[attr-defined] for keyword, value in custom_attribs.items(): attr_setter = getattr(new_arg, f'set_{keyword}', None) @@ -1445,7 +1483,7 @@ def error(self, message: str) -> NoReturn: # Add error style to message console = self._get_formatter().console with console.capture() as capture: - console.print(formatted_message, style="cmd2.error", crop=False) + console.print(formatted_message, style=Cmd2Style.ERROR, crop=False) formatted_message = f"{capture.get()}" self.exit(2, f'{formatted_message}\n') diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 1f9e5531..b3399b9f 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -63,8 +63,14 @@ cast, ) +from rich.box import SIMPLE_HEAD from rich.console import Group +from rich.rule import Rule from rich.style import StyleType +from rich.table import ( + Column, + Table, +) from rich.text import Text from . import ( @@ -123,7 +129,7 @@ StatementParser, shlex_split, ) -from .rich_utils import Cmd2Console, RichPrintKwargs +from .rich_utils import Cmd2Console, Cmd2Style, RichPrintKwargs # NOTE: When using gnureadline with Python 3.13, start_ipython needs to be imported before any readline-related stuff with contextlib.suppress(ImportError): @@ -141,10 +147,6 @@ rl_warning, vt100_support, ) -from .table_creator import ( - Column, - SimpleTable, -) from .utils import ( Settable, get_defining_class, @@ -155,7 +157,7 @@ # Set up readline if rl_type == RlType.NONE: # pragma: no cover - Cmd2Console(sys.stderr).print(Text(rl_warning, style="cmd2.warning")) + Cmd2Console(sys.stderr).print(rl_warning, style=Cmd2Style.WARNING) else: from .rl_utils import ( # type: ignore[attr-defined] readline, @@ -286,6 +288,7 @@ class Cmd(cmd.Cmd): Line-oriented command interpreters are often useful for test harnesses, internal tools, and rapid prototypes. """ + ruler = "─" DEFAULT_EDITOR = utils.find_editor() # Sorting keys for strings @@ -469,7 +472,13 @@ def __init__( self._multiline_in_progress = '' # Set the header used for the help function's listing of documented functions - self.doc_header = "Documented commands (use 'help -v' for verbose/'help ' for details):" + self.doc_header = "Documented commands (use 'help -v' for verbose/'help ' for details)" + + # Set header for table listing help topics not related to a command. + self.misc_header = "Miscellaneous Help Topics" + + # Set header for table listing commands that have no help info. + self.undoc_header = "Undocumented Commands" # The error that prints when no help information can be found self.help_error = "No help on {}" @@ -1147,7 +1156,7 @@ def allow_style_type(value: str) -> rich_utils.AllowStyle: self.add_settable(Settable('debug', bool, "Show full traceback on exception", self)) self.add_settable(Settable('echo', bool, "Echo command issued into output", self)) self.add_settable(Settable('editor', str, "Program used by 'edit'", self)) - self.add_settable(Settable('feedback_to_output', bool, "Include nonessentials in '|', '>' results", self)) + self.add_settable(Settable('feedback_to_output', bool, "Include nonessentials in '|' and '>' results", self)) self.add_settable( Settable('max_completion_items', int, "Maximum number of CompletionItems to display during tab completion", self) ) @@ -1271,7 +1280,7 @@ def perror( *objects: Any, sep: str = " ", end: str = "\n", - style: StyleType | None = "cmd2.error", + style: StyleType | None = Cmd2Style.ERROR, soft_wrap: bool | None = None, rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 @@ -1330,7 +1339,7 @@ def psuccess( *objects, sep=sep, end=end, - style="cmd2.success", + style=Cmd2Style.SUCCESS, soft_wrap=soft_wrap, rich_print_kwargs=rich_print_kwargs, ) @@ -1363,7 +1372,7 @@ def pwarning( *objects, sep=sep, end=end, - style="cmd2.warning", + style=Cmd2Style.WARNING, soft_wrap=soft_wrap, rich_print_kwargs=rich_print_kwargs, ) @@ -1394,7 +1403,7 @@ def pexcept( if not self.debug and 'debug' in self.settables: warning = "\nTo enable full traceback, run the following command: 'set debug true'" - final_msg.append(warning, style="cmd2.warning") + final_msg.append(warning, style=Cmd2Style.WARNING) if final_msg: self.perror( @@ -2108,7 +2117,7 @@ def _display_matches_gnu_readline( if self.formatted_completions: if not hint_printed: sys.stdout.write('\n') - sys.stdout.write('\n' + self.formatted_completions + '\n\n') + sys.stdout.write('\n' + self.formatted_completions + '\n') # Otherwise use readline's formatter else: @@ -2159,13 +2168,13 @@ def _display_matches_pyreadline(self, matches: list[str]) -> None: # pragma: no hint_printed = False if self.always_show_hint and self.completion_hint: hint_printed = True - readline.rl.mode.console.write('\n' + self.completion_hint) + sys.stdout.write('\n' + self.completion_hint) # Check if we already have formatted results to print if self.formatted_completions: if not hint_printed: - readline.rl.mode.console.write('\n') - readline.rl.mode.console.write('\n' + self.formatted_completions + '\n\n') + sys.stdout.write('\n') + sys.stdout.write('\n' + self.formatted_completions + '\n') # Redraw the prompt and input lines rl_force_redisplay() @@ -2470,7 +2479,7 @@ def complete( # type: ignore[override] sys.stdout, Text.assemble( "\n", - (err_str, "cmd2.error" if ex.apply_style else ""), + (err_str, Cmd2Style.ERROR if ex.apply_style else ""), ), ) rl_force_redisplay() @@ -2515,42 +2524,33 @@ def get_visible_commands(self) -> list[str]: if command not in self.hidden_commands and command not in self.disabled_commands ] - # Table displayed when tab completing aliases - _alias_completion_table = SimpleTable([Column('Value', width=80)], divider_char=None) - def _get_alias_completion_items(self) -> list[CompletionItem]: """Return list of alias names and values as CompletionItems.""" results: list[CompletionItem] = [] for cur_key in self.aliases: - row_data = [self.aliases[cur_key]] - results.append(CompletionItem(cur_key, self._alias_completion_table.generate_data_row(row_data))) + descriptive_data = [self.aliases[cur_key]] + results.append(CompletionItem(cur_key, descriptive_data)) return results - # Table displayed when tab completing macros - _macro_completion_table = SimpleTable([Column('Value', width=80)], divider_char=None) - def _get_macro_completion_items(self) -> list[CompletionItem]: """Return list of macro names and values as CompletionItems.""" results: list[CompletionItem] = [] for cur_key in self.macros: - row_data = [self.macros[cur_key].value] - results.append(CompletionItem(cur_key, self._macro_completion_table.generate_data_row(row_data))) + descriptive_data = [self.macros[cur_key].value] + results.append(CompletionItem(cur_key, descriptive_data)) return results - # Table displayed when tab completing Settables - _settable_completion_table = SimpleTable([Column('Value', width=30), Column('Description', width=60)], divider_char=None) - def _get_settable_completion_items(self) -> list[CompletionItem]: """Return list of Settable names, values, and descriptions as CompletionItems.""" results: list[CompletionItem] = [] for cur_key in self.settables: - row_data = [self.settables[cur_key].get_value(), self.settables[cur_key].description] - results.append(CompletionItem(cur_key, self._settable_completion_table.generate_data_row(row_data))) + descriptive_data = [self.settables[cur_key].get_value(), self.settables[cur_key].description] + results.append(CompletionItem(cur_key, descriptive_data)) return results @@ -3579,7 +3579,7 @@ def _build_alias_create_parser(cls) -> Cmd2ArgumentParser: alias_create_notes = Group( "If you want to use redirection, pipes, or terminators in the value of the alias, then quote them.", "\n", - Text(" alias create save_results print_results \">\" out.txt\n", style="cmd2.example"), + Text(" alias create save_results print_results \">\" out.txt\n", style=Cmd2Style.EXAMPLE), ( "Since aliases are resolved during parsing, tab completion will function as it would " "for the actual command the alias resolves to." @@ -3651,7 +3651,7 @@ def _build_alias_delete_parser(cls) -> Cmd2ArgumentParser: nargs=argparse.ZERO_OR_MORE, help='alias(es) to delete', choices_provider=cls._get_alias_completion_items, - descriptive_header=cls._alias_completion_table.generate_header(), + descriptive_headers=["Value"], ) return alias_delete_parser @@ -3693,7 +3693,7 @@ def _build_alias_list_parser(cls) -> Cmd2ArgumentParser: nargs=argparse.ZERO_OR_MORE, help='alias(es) to list', choices_provider=cls._get_alias_completion_items, - descriptive_header=cls._alias_completion_table.generate_header(), + descriptive_headers=["Value"], ) return alias_list_parser @@ -3792,14 +3792,14 @@ def _build_macro_create_parser(cls) -> Cmd2ArgumentParser: "\n", "The following creates a macro called my_macro that expects two arguments:", "\n", - Text(" macro create my_macro make_dinner --meat {1} --veggie {2}", style="cmd2.example"), + Text(" macro create my_macro make_dinner --meat {1} --veggie {2}", style=Cmd2Style.EXAMPLE), "\n", "When the macro is called, the provided arguments are resolved and the assembled command is run. For example:", "\n", Text.assemble( - (" my_macro beef broccoli", "cmd2.example"), + (" my_macro beef broccoli", Cmd2Style.EXAMPLE), (" ───> ", "bold"), - ("make_dinner --meat beef --veggie broccoli", "cmd2.example"), + ("make_dinner --meat beef --veggie broccoli", Cmd2Style.EXAMPLE), ), ) macro_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_create_description) @@ -3815,15 +3815,15 @@ def _build_macro_create_parser(cls) -> Cmd2ArgumentParser: "first argument will populate both {1} instances." ), "\n", - Text(" macro create ft file_taxes -p {1} -q {2} -r {1}", style="cmd2.example"), + Text(" macro create ft file_taxes -p {1} -q {2} -r {1}", style=Cmd2Style.EXAMPLE), "\n", "To quote an argument in the resolved command, quote it during creation.", "\n", - Text(" macro create backup !cp \"{1}\" \"{1}.orig\"", style="cmd2.example"), + Text(" macro create backup !cp \"{1}\" \"{1}.orig\"", style=Cmd2Style.EXAMPLE), "\n", "If you want to use redirection, pipes, or terminators in the value of the macro, then quote them.", "\n", - Text(" macro create show_results print_results -type {1} \"|\" less", style="cmd2.example"), + Text(" macro create show_results print_results -type {1} \"|\" less", style=Cmd2Style.EXAMPLE), "\n", ( "Since macros don't resolve until after you press Enter, their arguments tab complete as paths. " @@ -3939,7 +3939,7 @@ def _build_macro_delete_parser(cls) -> Cmd2ArgumentParser: nargs=argparse.ZERO_OR_MORE, help='macro(s) to delete', choices_provider=cls._get_macro_completion_items, - descriptive_header=cls._macro_completion_table.generate_header(), + descriptive_headers=["Value"], ) return macro_delete_parser @@ -3978,7 +3978,7 @@ def _macro_delete(self, args: argparse.Namespace) -> None: nargs=argparse.ZERO_OR_MORE, help='macro(s) to list', choices_provider=_get_macro_completion_items, - descriptive_header=_macro_completion_table.generate_header(), + descriptive_headers=["Value"], ) @as_subcommand_to('macro', 'list', macro_list_parser, help=macro_list_help) @@ -4087,9 +4087,9 @@ def do_help(self, args: argparse.Namespace) -> None: # If there is a help func delegate to do_help elif help_func is not None: - super().do_help(args.command) + help_func() - # If there's no help_func __doc__ then format and output it + # If the command function has a docstring, then print it elif func is not None and func.__doc__ is not None: self.poutput(pydoc.getdoc(func)) @@ -4104,7 +4104,7 @@ def do_help(self, args: argparse.Namespace) -> None: def print_topics(self, header: str, cmds: list[str] | None, cmdlen: int, maxcol: int) -> None: # noqa: ARG002 """Print groups of commands and topics in columns and an optional header. - Override of cmd's print_topics() to handle headers with newlines, ANSI style sequences, and wide characters. + Override of cmd's print_topics() to use Rich. :param header: string to print above commands being printed :param cmds: list of topics to print @@ -4112,10 +4112,11 @@ def print_topics(self, header: str, cmds: list[str] | None, cmdlen: int, maxcol: :param maxcol: max number of display columns to fit into """ if cmds: - self.poutput(header) + header_grid = Table.grid() + header_grid.add_row(header, style=Cmd2Style.HELP_TITLE) if self.ruler: - divider = utils.align_left('', fill_char=self.ruler, width=ansi.widest_line(header)) - self.poutput(divider) + header_grid.add_row(Rule(characters=self.ruler)) + self.poutput(header_grid) self.columnize(cmds, maxcol - 1) self.poutput() @@ -4180,12 +4181,12 @@ def _help_menu(self, verbose: bool = False) -> None: if not cmds_cats: # No categories found, fall back to standard behavior - self.poutput(self.doc_leader) + self.poutput(self.doc_leader, soft_wrap=False) self._print_topics(self.doc_header, cmds_doc, verbose) else: # Categories found, Organize all commands by category - self.poutput(self.doc_leader) - self.poutput(self.doc_header, end="\n\n") + self.poutput(self.doc_leader, style=Cmd2Style.HELP_HEADER, soft_wrap=False) + self.poutput(self.doc_header, style=Cmd2Style.HELP_HEADER, end="\n\n", soft_wrap=False) for category in sorted(cmds_cats.keys(), key=self.default_sort_key): self._print_topics(category, cmds_cats[category], verbose) self._print_topics(self.default_category, cmds_doc, verbose) @@ -4232,23 +4233,16 @@ def _print_topics(self, header: str, cmds: list[str], verbose: bool) -> None: if not verbose: self.print_topics(header, cmds, 15, 80) else: - # Find the widest command - widest = max([ansi.style_aware_wcswidth(command) for command in cmds]) - - # Define the table structure - name_column = Column('', width=max(widest, 20)) - desc_column = Column('', width=80) - - topic_table = SimpleTable([name_column, desc_column], divider_char=self.ruler) - - # Build the topic table - table_str_buf = io.StringIO() - if header: - table_str_buf.write(header + "\n") - - divider = topic_table.generate_divider() - if divider: - table_str_buf.write(divider + "\n") + category_grid = Table.grid() + category_grid.add_row(header, style=Cmd2Style.HELP_TITLE) + category_grid.add_row(Rule(characters=self.ruler)) + topics_table = Table( + Column("Name", no_wrap=True), + Column("Description", overflow="fold"), + box=SIMPLE_HEAD, + border_style=Cmd2Style.RULE_LINE, + show_edge=False, + ) # Try to get the documentation string for each command topics = self.get_help_topics() @@ -4272,8 +4266,9 @@ def _print_topics(self, header: str, cmds: list[str], verbose: bool) -> None: self.stdout = cast(TextIO, result) help_func() finally: - # restore internal stdout - self.stdout = stdout_orig + with self.sigint_protection: + # restore internal stdout + self.stdout = stdout_orig doc = result.getvalue() else: @@ -4283,10 +4278,10 @@ def _print_topics(self, header: str, cmds: list[str], verbose: bool) -> None: cmd_desc = strip_doc_annotations(doc) if doc else '' # Add this command to the table - table_row = topic_table.generate_data_row([command, cmd_desc]) - table_str_buf.write(table_row + '\n') + topics_table.add_row(command, cmd_desc) - self.poutput(table_str_buf.getvalue()) + category_grid.add_row(topics_table) + self.poutput(category_grid, "") @staticmethod def _build_shortcuts_parser() -> Cmd2ArgumentParser: @@ -4402,7 +4397,7 @@ def _build_base_set_parser(cls) -> Cmd2ArgumentParser: nargs=argparse.OPTIONAL, help='parameter to set or view', choices_provider=cls._get_settable_completion_items, - descriptive_header=cls._settable_completion_table.generate_header(), + descriptive_headers=["Value", "Description"], ) return base_set_parser @@ -4482,34 +4477,33 @@ def do_set(self, args: argparse.Namespace) -> None: return # Show one settable - to_show = [args.param] + to_show: list[str] = [args.param] else: # Show all settables to_show = list(self.settables.keys()) # Define the table structure - name_label = 'Name' - max_name_width = max([ansi.style_aware_wcswidth(param) for param in to_show]) - max_name_width = max(max_name_width, ansi.style_aware_wcswidth(name_label)) - - cols: list[Column] = [ - Column(name_label, width=max_name_width), - Column('Value', width=30), - Column('Description', width=60), - ] - - table = SimpleTable(cols, divider_char=self.ruler) - self.poutput(table.generate_header()) + settable_table = Table( + Column("Name", no_wrap=True), + Column("Value", overflow="fold"), + Column("Description", overflow="fold"), + box=SIMPLE_HEAD, + border_style=Cmd2Style.RULE_LINE, + show_edge=False, + ) # Build the table and populate self.last_result self.last_result = {} # dict[settable_name, settable_value] for param in sorted(to_show, key=self.default_sort_key): settable = self.settables[param] - row_data = [param, settable.get_value(), settable.description] - self.poutput(table.generate_data_row(row_data)) + settable_table.add_row(param, str(settable.get_value()), settable.description) self.last_result[param] = settable.get_value() + self.poutput() + self.poutput(settable_table) + self.poutput() + @classmethod def _build_shell_parser(cls) -> Cmd2ArgumentParser: shell_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Execute a command as if at the OS prompt.") @@ -5338,7 +5332,7 @@ def _build_edit_parser(cls) -> Cmd2ArgumentParser: "Note", Text.assemble( "To set a new editor, run: ", - ("set editor ", "cmd2.example"), + ("set editor ", Cmd2Style.EXAMPLE), ), ) diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 44e4ee29..6e7daa3a 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -1,5 +1,6 @@ """Provides common utilities to support Rich in cmd2 applications.""" +import sys from collections.abc import Mapping from enum import Enum from typing import ( @@ -24,6 +25,11 @@ from rich.theme import Theme from rich_argparse import RichHelpFormatter +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + from backports.strenum import StrEnum + class AllowStyle(Enum): """Values for ``cmd2.rich_utils.allow_style``.""" @@ -44,34 +50,55 @@ def __repr__(self) -> str: # Controls when ANSI style sequences are allowed in output allow_style = AllowStyle.TERMINAL -# Default styles for cmd2 + +class Cmd2Style(StrEnum): + """Names of styles defined in DEFAULT_CMD2_STYLES. + + Using this enum instead of string literals prevents typos and enables IDE + autocompletion, which makes it easier to discover and use the available + styles. + """ + + ERROR = "cmd2.error" + EXAMPLE = "cmd2.example" + HELP_HEADER = "cmd2.help.header" + HELP_TITLE = "cmd2.help.title" + RULE_LINE = "cmd2.rule.line" + SUCCESS = "cmd2.success" + WARNING = "cmd2.warning" + + +# Default styles used by cmd2 DEFAULT_CMD2_STYLES: dict[str, StyleType] = { - "cmd2.success": Style(color="green"), - "cmd2.warning": Style(color="bright_yellow"), - "cmd2.error": Style(color="bright_red"), - "cmd2.help_header": Style(color="bright_green", bold=True), - "cmd2.example": Style(color="cyan", bold=True), + Cmd2Style.ERROR: Style(color="bright_red"), + Cmd2Style.EXAMPLE: Style(color="cyan", bold=True), + Cmd2Style.HELP_HEADER: Style(color="cyan", bold=True), + Cmd2Style.HELP_TITLE: Style(color="bright_green", bold=True), + Cmd2Style.RULE_LINE: Style(color="bright_green"), + Cmd2Style.SUCCESS: Style(color="green"), + Cmd2Style.WARNING: Style(color="bright_yellow"), } -# Include default styles from RichHelpFormatter -DEFAULT_CMD2_STYLES.update(RichHelpFormatter.styles.copy()) - class Cmd2Theme(Theme): """Rich theme class used by Cmd2Console.""" - def __init__(self, styles: Mapping[str, StyleType] | None = None, inherit: bool = True) -> None: + def __init__(self, styles: Mapping[str, StyleType] | None = None) -> None: """Cmd2Theme initializer. :param styles: optional mapping of style names on to styles. Defaults to None for a theme with no styles. - :param inherit: Inherit default styles. Defaults to True. """ - cmd2_styles = DEFAULT_CMD2_STYLES.copy() if inherit else {} + cmd2_styles = DEFAULT_CMD2_STYLES.copy() + + # Include default styles from rich-argparse + cmd2_styles.update(RichHelpFormatter.styles.copy()) + if styles is not None: cmd2_styles.update(styles) - super().__init__(cmd2_styles, inherit=inherit) + # Set inherit to True to include Rich's default styles + super().__init__(cmd2_styles, inherit=True) # Current Rich theme used by Cmd2Console diff --git a/docs/features/builtin_commands.md b/docs/features/builtin_commands.md index ed0e2479..33e27aa2 100644 --- a/docs/features/builtin_commands.md +++ b/docs/features/builtin_commands.md @@ -86,7 +86,7 @@ always_show_hint False Display tab completion h debug True Show full traceback on exception echo False Echo command issued into output editor vi Program used by 'edit' -feedback_to_output False Include nonessentials in '|', '>' results +feedback_to_output False Include nonessentials in '|' and '>' results max_completion_items 50 Maximum number of CompletionItems to display during tab completion quiet False Don't print nonessential feedback diff --git a/examples/argparse_completion.py b/examples/argparse_completion.py index 43cad367..961c720a 100755 --- a/examples/argparse_completion.py +++ b/examples/argparse_completion.py @@ -3,12 +3,13 @@ import argparse +from rich.text import Text + from cmd2 import ( Cmd, Cmd2ArgumentParser, CompletionError, CompletionItem, - ansi, with_argparser, ) @@ -38,10 +39,10 @@ def choices_completion_error(self) -> list[str]: def choices_completion_item(self) -> list[CompletionItem]: """Return CompletionItem instead of strings. These give more context to what's being tab completed.""" - fancy_item = "These things can\ncontain newlines and\n" - fancy_item += ansi.style("styled text!!", fg=ansi.Fg.LIGHT_YELLOW, underline=True) + fancy_item = Text("These things can\ncontain newlines and\n") + Text("styled text!!", style="underline bright_yellow") + 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()] + return [CompletionItem(item_id, [description]) for item_id, description in items.items()] def choices_arg_tokens(self, arg_tokens: dict[str, list[str]]) -> list[str]: """If a choices or completer function/method takes a value called arg_tokens, then it will be @@ -86,7 +87,7 @@ def choices_arg_tokens(self, arg_tokens: dict[str, list[str]]) -> list[str]: '--completion_item', choices_provider=choices_completion_item, metavar="ITEM_ID", - descriptive_header="Description", + descriptive_headers=["Description"], help="demonstrate use of CompletionItems", ) diff --git a/pyproject.toml b/pyproject.toml index cce63b8e..8a99b54d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ + "backports.strenum; python_version == '3.10'", "gnureadline>=8; platform_system == 'Darwin'", "pyperclip>=1.8", "pyreadline3>=3.4; platform_system == 'Windows'", diff --git a/tests/conftest.py b/tests/conftest.py index df5159a3..40ab9abc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,37 +35,6 @@ def verify_help_text(cmd2_app: cmd2.Cmd, help_output: str | list[str], verbose_s assert verbose_string in help_text -# 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. - -Positional Arguments: - arg empty all history items - a one history item by number - a..b, a:b, a:, ..b items by indices (inclusive) - string items containing string - /regex/ items matching regular expression - -Optional Arguments: - -h, --help show this help message and exit - -r, --run run selected history items - -e, --edit edit and then run selected history items - -o, --output_file FILE - output commands to a script file, implies -s - -t, --transcript TRANSCRIPT_FILE - create a transcript file by re-running the commands, implies both -r and -s - -c, --clear clear all history - -Formatting: - -s, --script output commands in script format, i.e. without command numbers - -x, --expanded output fully parsed commands with shortcuts, aliases, and macros 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 previous sessions -""" - # Output from the shortcuts command with default built-in shortcuts SHORTCUTS_TXT = """Shortcuts for other commands: !: shell @@ -74,25 +43,6 @@ def verify_help_text(cmd2_app: cmd2.Cmd, help_output: str | list[str], verbose_s @@: _relative_run_script """ -# Output from the set command -SET_TXT = ( - "Name Value Description \n" - "====================================================================================================================\n" - "allow_style Terminal Allow ANSI text style sequences in output (valid values: \n" - " Always, Never, Terminal) \n" - "always_show_hint False Display tab completion hint even when completion suggestions\n" - " print \n" - "debug False Show full traceback on exception \n" - "echo False Echo command issued into output \n" - "editor vim Program used by 'edit' \n" - "feedback_to_output False Include nonessentials in '|', '>' results \n" - "max_completion_items 50 Maximum number of CompletionItems to display during tab \n" - " completion \n" - "quiet False Don't print nonessential feedback \n" - "scripts_add_to_history True Scripts and pyscripts add commands to history \n" - "timing False Report execution times \n" -) - def normalize(block): """Normalize a block of text to perform comparison. diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index b6713e87..27c96598 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -15,7 +15,6 @@ argparse_custom, with_argparser, ) -from cmd2.utils import align_right from .conftest import ( complete_tester, @@ -102,17 +101,20 @@ def do_pos_and_flag(self, args: argparse.Namespace) -> None: ############################################################################################################ STR_METAVAR = "HEADLESS" TUPLE_METAVAR = ('arg1', 'others') - CUSTOM_DESC_HEADER = "Custom Header" + CUSTOM_DESC_HEADERS = ("Custom Headers",) # tuples (for sake of immutability) used in our tests (there is a mix of sorted and unsorted on purpose) non_negative_num_choices = (1, 2, 3, 0.5, 22) num_choices = (-1, 1, -2, 2.5, 0, -12) static_choices_list = ('static', 'choices', 'stop', 'here') choices_from_provider = ('choices', 'provider', 'probably', 'improved') - completion_item_choices = (CompletionItem('choice_1', 'A description'), CompletionItem('choice_2', 'Another description')) + completion_item_choices = ( + CompletionItem('choice_1', ['A description']), + CompletionItem('choice_2', ['Another description']), + ) # This tests that CompletionItems created with numerical values are sorted as numbers. - num_completion_items = (CompletionItem(5, "Five"), CompletionItem(1.5, "One.Five"), CompletionItem(2, "Five")) + num_completion_items = (CompletionItem(5, ["Five"]), CompletionItem(1.5, ["One.Five"]), CompletionItem(2, ["Five"])) def choices_provider(self) -> tuple[str]: """Method that provides choices""" @@ -123,7 +125,7 @@ def completion_item_method(self) -> list[CompletionItem]: items = [] for i in range(10): main_str = f'main_str{i}' - items.append(CompletionItem(main_str, description='blah blah')) + items.append(CompletionItem(main_str, ['blah blah'])) return items choices_parser = Cmd2ArgumentParser() @@ -137,7 +139,7 @@ def completion_item_method(self) -> list[CompletionItem]: "--desc_header", help='this arg has a descriptive header', choices_provider=completion_item_method, - descriptive_header=CUSTOM_DESC_HEADER, + descriptive_headers=CUSTOM_DESC_HEADERS, ) choices_parser.add_argument( "--no_header", @@ -718,8 +720,8 @@ def test_completion_items(ac_app) -> None: line_found = False for line in ac_app.formatted_completions.splitlines(): # Since the CompletionItems were created from strings, the left-most column is left-aligned. - # Therefore choice_1 will begin the line. - if line.startswith('choice_1') and 'A description' in line: + # Therefore choice_1 will begin the line (with 1 space for padding). + if line.startswith(' choice_1') and 'A description' in line: line_found = True break @@ -738,11 +740,10 @@ def test_completion_items(ac_app) -> None: # Look for both the value and description in the hint table line_found = False - aligned_val = align_right('1.5', width=cmd2.ansi.style_aware_wcswidth('num_completion_items')) for line in ac_app.formatted_completions.splitlines(): # Since the CompletionItems were created from numbers, the left-most column is right-aligned. - # Therefore 1.5 will be right-aligned in a field as wide as the arg ("num_completion_items"). - if line.startswith(aligned_val) and 'One.Five' in line: + # Therefore 1.5 will be right-aligned. + if line.startswith(" 1.5") and "One.Five" in line: line_found = True break @@ -948,9 +949,9 @@ def test_completion_items_arg_header(ac_app) -> None: assert ac_app.TUPLE_METAVAR[1].upper() in normalize(ac_app.formatted_completions)[0] -def test_completion_items_descriptive_header(ac_app) -> None: +def test_completion_items_descriptive_headers(ac_app) -> None: from cmd2.argparse_completer import ( - DEFAULT_DESCRIPTIVE_HEADER, + DEFAULT_DESCRIPTIVE_HEADERS, ) # This argument provided a descriptive header @@ -960,16 +961,16 @@ def test_completion_items_descriptive_header(ac_app) -> None: begidx = endidx - len(text) complete_tester(text, line, begidx, endidx, ac_app) - assert ac_app.CUSTOM_DESC_HEADER in normalize(ac_app.formatted_completions)[0] + assert ac_app.CUSTOM_DESC_HEADERS[0] in normalize(ac_app.formatted_completions)[0] - # This argument did not provide a descriptive header, so it should be DEFAULT_DESCRIPTIVE_HEADER + # This argument did not provide a descriptive header, so it should be DEFAULT_DESCRIPTIVE_HEADERS text = '' line = f'choices --no_header {text}' endidx = len(line) begidx = endidx - len(text) complete_tester(text, line, begidx, endidx, ac_app) - assert DEFAULT_DESCRIPTIVE_HEADER in normalize(ac_app.formatted_completions)[0] + assert DEFAULT_DESCRIPTIVE_HEADERS[0] in normalize(ac_app.formatted_completions)[0] @pytest.mark.parametrize( diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index ec6ec91d..2ab59d29 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -33,8 +33,6 @@ ) from .conftest import ( - HELP_HISTORY, - SET_TXT, SHORTCUTS_TXT, complete_tester, normalize, @@ -153,12 +151,22 @@ def test_command_starts_with_shortcut() -> None: def test_base_set(base_app) -> None: - # force editor to be 'vim' so test is repeatable across platforms - base_app.editor = 'vim' + # Make sure all settables appear in output. out, err = run_cmd(base_app, 'set') - expected = normalize(SET_TXT) - assert out == expected + settables = sorted(base_app.settables.keys()) + + # The settables will appear in order in the table. + # Go line-by-line until all settables are found. + for line in out: + if not settables: + break + if line.lstrip().startswith(settables[0]): + settables.pop(0) + + # This will be empty if we found all settables in the output. + assert not settables + # Make sure all settables appear in last_result. assert len(base_app.last_result) == len(base_app.settables) for param in base_app.last_result: assert base_app.last_result[param] == base_app.settables[param].get_value() @@ -178,9 +186,9 @@ def test_set(base_app) -> None: out, err = run_cmd(base_app, 'set quiet') expected = normalize( """ -Name Value Description -=================================================================================================== -quiet True Don't print nonessential feedback + Name Value Description +─────────────────────────────────────────────────── + quiet True Don't print nonessential feedback """ ) assert out == expected @@ -1866,7 +1874,7 @@ def test_echo(capsys) -> None: app.runcmds_plus_hooks(commands) out, err = capsys.readouterr() - assert out.startswith(f'{app.prompt}{commands[0]}\n' + HELP_HISTORY.split()[0]) + assert out.startswith(f'{app.prompt}{commands[0]}\nUsage: history') def test_read_input_rawinput_true(capsys, monkeypatch) -> None: @@ -2095,7 +2103,7 @@ def test_get_alias_completion_items(base_app) -> None: for cur_res in results: assert cur_res in base_app.aliases # Strip trailing spaces from table output - assert cur_res.description.rstrip() == base_app.aliases[cur_res] + assert cur_res.descriptive_data[0].rstrip() == base_app.aliases[cur_res] def test_get_macro_completion_items(base_app) -> None: @@ -2108,7 +2116,7 @@ def test_get_macro_completion_items(base_app) -> None: for cur_res in results: assert cur_res in base_app.macros # Strip trailing spaces from table output - assert cur_res.description.rstrip() == base_app.macros[cur_res].value + assert cur_res.descriptive_data[0].rstrip() == base_app.macros[cur_res].value def test_get_settable_completion_items(base_app) -> None: @@ -2122,11 +2130,11 @@ def test_get_settable_completion_items(base_app) -> None: # These CompletionItem descriptions are a two column table (Settable Value and Settable Description) # First check if the description text starts with the value str_value = str(cur_settable.get_value()) - assert cur_res.description.startswith(str_value) + assert cur_res.descriptive_data[0].startswith(str_value) # The second column is likely to have wrapped long text. So we will just examine the # first couple characters to look for the Settable's description. - assert cur_settable.description[0:10] in cur_res.description + assert cur_settable.description[0:10] in cur_res.descriptive_data[1] def test_alias_no_subcommand(base_app) -> None: diff --git a/tests/test_history.py b/tests/test_history.py index 703966c2..9e698f64 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -12,7 +12,6 @@ import cmd2 from .conftest import ( - HELP_HISTORY, normalize, run_cmd, ) @@ -840,11 +839,6 @@ def test_history_script_expanded(base_app) -> None: verify_hi_last_result(base_app, 2) -def test_base_help_history(base_app) -> None: - out, err = run_cmd(base_app, 'help history') - assert out == normalize(HELP_HISTORY) - - def test_exclude_from_history(base_app) -> None: # Run history command run_cmd(base_app, 'history') diff --git a/tests/transcripts/regex_set.txt b/tests/transcripts/regex_set.txt index 8344af81..adaa68e2 100644 --- a/tests/transcripts/regex_set.txt +++ b/tests/transcripts/regex_set.txt @@ -10,19 +10,22 @@ now: 'Terminal' editor - was: '/.*/' now: 'vim' (Cmd) set -Name Value Description/ */ -==================================================================================================================== -allow_style Terminal Allow ANSI text style sequences in output (valid values:/ */ - Always, Never, Terminal)/ */ -always_show_hint False Display tab completion hint even when completion suggestions - print/ */ -debug False Show full traceback on exception/ */ -echo False Echo command issued into output/ */ -editor vim Program used by 'edit'/ */ -feedback_to_output False Include nonessentials in '|', '>' results/ */ -max_completion_items 50 Maximum number of CompletionItems to display during tab/ */ - completion/ */ -maxrepeats 3 Max number of `--repeat`s allowed/ */ -quiet False Don't print nonessential feedback/ */ -scripts_add_to_history True Scripts and pyscripts add commands to history/ */ -timing False Report execution times/ */ + + Name Value Description/ */ +───────────────────────────────────────────────────────────────────────────────/─*/ + allow_style Terminal Allow ANSI text style sequences in output/ */ + (valid values: Always, Never, Terminal)/ */ + always_show_hint False Display tab completion hint even when/ */ + completion suggestions print/ */ + debug False Show full traceback on exception/ */ + echo False Echo command issued into output/ */ + editor vim Program used by 'edit'/ */ + feedback_to_output False Include nonessentials in '|' and '>'/ */ + results/ */ + max_completion_items 50 Maximum number of CompletionItems to/ */ + display during tab completion/ */ + maxrepeats 3 Max number of `--repeat`s allowed/ */ + quiet False Don't print nonessential feedback/ */ + scripts_add_to_history True Scripts and pyscripts add commands to/ */ + history/ */ + timing False Report execution times/ */ diff --git a/tests_isolated/test_commandset/conftest.py b/tests_isolated/test_commandset/conftest.py index c2bdf81f..ec476bbf 100644 --- a/tests_isolated/test_commandset/conftest.py +++ b/tests_isolated/test_commandset/conftest.py @@ -40,41 +40,6 @@ def verify_help_text(cmd2_app: cmd2.Cmd, help_output: str | list[str], verbose_s assert verbose_string in help_text -# Help text for the history command -HELP_HISTORY = """Usage: history [-h] [-r | -e | -o FILE | -t TRANSCRIPT_FILE | -c] [-s] [-x] - [-v] [-a] - [arg] - -View, run, edit, save, or clear previously entered commands - -positional arguments: - arg empty all history items - a one history item by number - a..b, a:b, a:, ..b items by indices (inclusive) - string items containing string - /regex/ items matching regular expression - -optional arguments: - -h, --help show this help message and exit - -r, --run run selected history items - -e, --edit edit and then run selected history items - -o, --output_file FILE - output commands to a script file, implies -s - -t, --transcript TRANSCRIPT_FILE - output commands and results to a transcript file, - implies -s - -c, --clear clear all history - -formatting: - -s, --script output commands in script format, i.e. without command - numbers - -x, --expanded output fully parsed commands with any shortcuts, aliases, and macros 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 - previous sessions -""" - # Output from the shortcuts command with default built-in shortcuts SHORTCUTS_TXT = """Shortcuts for other commands: !: shell From db2654cc469e8eed4b5fd92d0b188bafd6fd131e Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sat, 16 Aug 2025 23:16:00 -0400 Subject: [PATCH 23/89] Change utils.get_types to use inspect.get_annotations that was added in Python 3.10 since we dropped support for 3.9 (#1476) Also: - Add Mac-specific .DS_Store to .gitignore - Upgrade dependencies in .pre-commit-config.yaml - Update utils.md to document the whole module so we don't miss anything --- .gitignore | 3 +++ .pre-commit-config.yaml | 4 +-- cmd2/utils.py | 19 ++++++-------- docs/api/utils.md | 56 +---------------------------------------- 4 files changed, 14 insertions(+), 68 deletions(-) diff --git a/.gitignore b/.gitignore index 51218eb8..10b3d3ce 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,6 @@ uv.lock # Node/npm used for installing Prettier locally to override the outdated version that is bundled with the VSCode extension node_modules/ package-lock.json + +# macOS +.DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8891368f..bae9bb13 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v5.0.0" + rev: "v6.0.0" hooks: - id: check-case-conflict - id: check-merge-conflict @@ -9,7 +9,7 @@ repos: - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.12.7" + rev: "v0.12.9" hooks: - id: ruff-format args: [--config=pyproject.toml] diff --git a/cmd2/utils.py b/cmd2/utils.py index 4327627b..c4b37ab3 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -16,7 +16,7 @@ from collections.abc import Callable, Iterable from difflib import SequenceMatcher from enum import Enum -from typing import TYPE_CHECKING, Any, TextIO, TypeVar, Union, cast, get_type_hints +from typing import TYPE_CHECKING, Any, TextIO, TypeVar, Union, cast from . import constants from .argparse_custom import ChoicesProviderFunc, CompleterFunc @@ -1245,24 +1245,21 @@ def suggest_similar( def get_types(func_or_method: Callable[..., Any]) -> tuple[dict[str, Any], Any]: - """Use typing.get_type_hints() to extract type hints for parameters and return value. + """Use inspect.get_annotations() to extract type hints for parameters and return value. - This exists because the inspect module doesn't have a safe way of doing this that works - both with and without importing annotations from __future__ until Python 3.10. - - TODO: Once cmd2 only supports Python 3.10+, change to use inspect.get_annotations(eval_str=True) + This is a thin convenience wrapper around inspect.get_annotations() that treats the return value + annotation separately. :param func_or_method: Function or method to return the type hints for - :return tuple with first element being dictionary mapping param names to type hints - and second element being return type hint, unspecified, returns None + :return: tuple with first element being dictionary mapping param names to type hints + and second element being the return type hint or None if there is no return value type hint + :raises ValueError: if the `func_or_method` argument is not a valid object to pass to `inspect.get_annotations` """ try: - type_hints = get_type_hints(func_or_method) # Get dictionary of type hints + type_hints = inspect.get_annotations(func_or_method, eval_str=True) # Get dictionary of type hints except TypeError as exc: raise ValueError("Argument passed to get_types should be a function or method") from exc ret_ann = type_hints.pop('return', None) # Pop off the return annotation if it exists if inspect.ismethod(func_or_method): type_hints.pop('self', None) # Pop off `self` hint for methods - if ret_ann is type(None): - ret_ann = None # Simplify logic to just return None instead of NoneType return type_hints, ret_ann diff --git a/docs/api/utils.md b/docs/api/utils.md index 93bbbf5b..20a92e6b 100644 --- a/docs/api/utils.md +++ b/docs/api/utils.md @@ -1,57 +1,3 @@ # cmd2.utils -## Settings - -::: cmd2.utils.Settable - -## Quote Handling - -::: cmd2.utils.is_quoted - -::: cmd2.utils.quote_string - -::: cmd2.utils.quote_string_if_needed - -::: cmd2.utils.strip_quotes - -## IO Handling - -::: cmd2.utils.StdSim - -::: cmd2.utils.ByteBuf - -::: cmd2.utils.ProcReader - -## Tab Completion - -::: cmd2.utils.CompletionMode - -::: cmd2.utils.CustomCompletionSettings - -## Text Alignment - -::: cmd2.utils.TextAlignment - -::: cmd2.utils.align_text - -::: cmd2.utils.align_left - -::: cmd2.utils.align_right - -::: cmd2.utils.align_center - -::: cmd2.utils.truncate_line - -## Miscellaneous - -::: cmd2.utils.to_bool - -::: cmd2.utils.categorize - -::: cmd2.utils.remove_duplicates - -::: cmd2.utils.alphabetical_sort - -::: cmd2.utils.natural_sort - -::: cmd2.utils.suggest_similar +::: cmd2.utils From 2ef7b9ae82c652333330040ae688a825682345a8 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 19 Aug 2025 09:34:57 -0400 Subject: [PATCH 24/89] Refactored cmd2 to better account for our use of Rich. (#1477) * Added string_utils.py which contains all string utility functions. This includes quoting and alignment functions from utils.py. This also includes style-related functions from ansi.py. * Added colors.py which contains a StrEnum of all color names supported by Rich. * Added styles.py which contains a StrEnum of all cmd2-specific style names and their respective style definitions. * Moved string styling functionality from ansi.py to string_utils.py. * Removed all text style Enums from ansi.py in favor of Rich styles. * Renamed ansi.py to terminal_utils.py to reflect the functions left in it. * Removed table_creator.py in favor of Rich tables. * Renamed rich_utils.allow_style to rich_utils.ALLOW_STYLE to be consistent with our other global settings. --------- Co-authored-by: Todd Leonhardt --- .github/CONTRIBUTING.md | 14 +- CHANGELOG.md | 13 +- cmd2/__init__.py | 32 +- cmd2/ansi.py | 1065 -------------------------- cmd2/argparse_completer.py | 12 +- cmd2/argparse_custom.py | 14 +- cmd2/cmd2.py | 95 ++- cmd2/colors.py | 270 +++++++ cmd2/constants.py | 6 - cmd2/exceptions.py | 2 +- cmd2/history.py | 10 +- cmd2/parsing.py | 15 +- cmd2/rich_utils.py | 101 +-- cmd2/string_utils.py | 166 ++++ cmd2/styles.py | 51 ++ cmd2/table_creator.py | 1121 ---------------------------- cmd2/terminal_utils.py | 144 ++++ cmd2/transcript.py | 14 +- cmd2/utils.py | 470 +----------- docs/api/ansi.md | 3 - docs/api/clipboard.md | 3 + docs/api/colors.md | 3 + docs/api/index.md | 12 +- docs/api/rich_utils.md | 3 + docs/api/rl_utils.md | 3 + docs/api/string_utils.md | 3 + docs/api/styles.md | 3 + docs/api/table_creator.md | 3 - docs/api/terminal_utils.md | 3 + docs/api/transcript.md | 3 + docs/features/generating_output.md | 13 +- docs/features/table_creation.md | 34 +- docs/requirements.txt | 7 - examples/async_printing.py | 18 +- examples/custom_parser.py | 10 +- examples/table_creation.py | 275 ------- mkdocs.yml | 10 +- pyproject.toml | 2 +- tests/test_ansi.py | 298 -------- tests/test_cmd2.py | 103 ++- tests/test_completion.py | 12 +- tests/test_parsing.py | 4 +- tests/test_run_pyscript.py | 4 +- tests/test_string_utils.py | 217 ++++++ tests/test_table_creator.py | 725 ------------------ tests/test_terminal_utils.py | 81 ++ tests/test_utils.py | 559 -------------- 47 files changed, 1235 insertions(+), 4794 deletions(-) delete mode 100644 cmd2/ansi.py create mode 100644 cmd2/colors.py create mode 100644 cmd2/string_utils.py create mode 100644 cmd2/styles.py delete mode 100644 cmd2/table_creator.py create mode 100644 cmd2/terminal_utils.py delete mode 100644 docs/api/ansi.md create mode 100644 docs/api/clipboard.md create mode 100644 docs/api/colors.md create mode 100644 docs/api/rich_utils.md create mode 100644 docs/api/rl_utils.md create mode 100644 docs/api/string_utils.md create mode 100644 docs/api/styles.md delete mode 100644 docs/api/table_creator.md create mode 100644 docs/api/terminal_utils.md create mode 100644 docs/api/transcript.md delete mode 100644 docs/requirements.txt delete mode 100755 examples/table_creation.py delete mode 100644 tests/test_ansi.py create mode 100644 tests/test_string_utils.py delete mode 100644 tests/test_table_creator.py create mode 100644 tests/test_terminal_utils.py diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 57049b58..875924a7 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -60,15 +60,19 @@ Nearly all project configuration, including for dependencies and quality tools i See the `dependencies` list under the `[project]` heading in [pyproject.toml](../pyproject.toml). -| Prerequisite | Minimum Version | Purpose | -| --------------------------------------------------- | --------------- | -------------------------------------- | -| [python](https://www.python.org/downloads/) | `3.10` | Python programming language | -| [pyperclip](https://github.com/asweigart/pyperclip) | `1.8` | Cross-platform clipboard functions | -| [wcwidth](https://pypi.python.org/pypi/wcwidth) | `0.2.10` | Measure the displayed width of unicode | +| Prerequisite | Minimum Version | Purpose | +| ---------------------------------------------------------- | --------------- | ------------------------------------------------------ | +| [python](https://www.python.org/downloads/) | `3.10` | Python programming language | +| [pyperclip](https://github.com/asweigart/pyperclip) | `1.8` | Cross-platform clipboard functions | +| [rich](https://github.com/Textualize/rich) | `14.1.0` | Add rich text and beautiful formatting in the terminal | +| [rich-argparse](https://github.com/hamdanal/rich-argparse) | `1.7.1` | A rich-enabled help formatter for argparse | > `macOS` and `Windows` each have an extra dependency to ensure they have a viable alternative to > [readline](https://tiswww.case.edu/php/chet/readline/rltop.html) available. +> Python 3.10 depends on [backports.strenum](https://github.com/clbarnes/backports.strenum) to use +> the `enum.StrEnum` class introduced in Python 3.11. + #### Additional prerequisites to build and publish cmd2 See the `build` list under the `[dependency-groups]` heading in [pyproject.toml](../pyproject.toml) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4fd32f4..83cdd55d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,17 +5,26 @@ - No longer setting parser's `prog` value in `with_argparser()` since it gets set in `Cmd._build_parser()`. This code had previously been restored to support backward compatibility in `cmd2` 2.0 family. + - Removed table_creator.py in favor of `Rich` tables. + - Moved string styling functionality from ansi.py to string_utils.py. + - Moved all string-related functions from utils.py to string_utils.py. + - Removed all text style Enums from ansi.py in favor of `Rich` styles. + - Renamed ansi.py to terminal_utils.py to reflect the functions left in it. - 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/main/examples/custom_parser.py) example for more details. - - Added `Cmd.macro_arg_complete()` which tab completes arguments to a macro. Its default behavior is to perform path completion, but it can be overridden as needed. - - All print methods (`poutput()`, `perror()`, `ppaged()`, etc.) have the ability to print Rich objects. + - Added string_utils.py which contains all string utility functions. This includes quoting and + alignment functions from utils.py. This also includes style-related functions from ansi.py. + This also includes style-related functions from ansi.py. + - Added colors.py which contains a StrEnum of all color names supported by Rich. + - Added styles.py which contains a StrEnum of all cmd2-specific style names and their respective + style definitions. - Bug Fixes - No longer redirecting `sys.stdout` if it's a different stream than `self.stdout`. This fixes diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 618c0472..e8aebdaf 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -9,17 +9,7 @@ from . import ( plugin, rich_utils, -) -from .ansi import ( - Bg, - Cursor, - EightBitBg, - EightBitFg, - Fg, - RgbBg, - RgbFg, - TextStyle, - style, + string_utils, ) from .argparse_completer import set_default_ap_completer_type from .argparse_custom import ( @@ -30,6 +20,7 @@ set_default_argument_parser_type, ) from .cmd2 import Cmd +from .colors import Color from .command_definition import ( CommandSet, with_default_category, @@ -53,6 +44,8 @@ ) from .parsing import Statement from .py_bridge import CommandResult +from .string_utils import stylize +from .styles import Cmd2Style from .utils import ( CompletionMode, CustomCompletionSettings, @@ -63,16 +56,6 @@ __all__: list[str] = [ # noqa: RUF022 'COMMAND_NAME', 'DEFAULT_SHORTCUTS', - # ANSI Exports - 'Cursor', - 'Bg', - 'Fg', - 'EightBitBg', - 'EightBitFg', - 'RgbBg', - 'RgbFg', - 'TextStyle', - 'style', # Argparse Exports 'Cmd2ArgumentParser', 'Cmd2AttributeWrapper', @@ -85,6 +68,8 @@ 'CommandResult', 'CommandSet', 'Statement', + # Colors + "Color", # Decorators 'with_argument_list', 'with_argparser', @@ -100,6 +85,11 @@ # modules 'plugin', 'rich_utils', + 'string_utils', + # String Utils + 'stylize', + # Styles, + "Cmd2Style", # Utilities 'categorize', 'CompletionMode', diff --git a/cmd2/ansi.py b/cmd2/ansi.py deleted file mode 100644 index 76c540c8..00000000 --- a/cmd2/ansi.py +++ /dev/null @@ -1,1065 +0,0 @@ -"""Support for ANSI escape sequences. - -These are used for things like applying style to text, setting the window title, and asynchronous alerts. -""" - -import functools -import re -from enum import ( - Enum, -) -from typing import ( - IO, - Any, - cast, -) - -from wcwidth import ( # type: ignore[import] - wcswidth, -) - -from . import rich_utils - -####################################################### -# Common ANSI escape sequence constants -####################################################### -ESC = '\x1b' -CSI = f'{ESC}[' -OSC = f'{ESC}]' -BEL = '\a' - - -# Regular expression to match ANSI style sequence -ANSI_STYLE_RE = re.compile(rf'{ESC}\[[^m]*m') - -# Matches standard foreground colors: CSI(30-37|90-97|39)m -STD_FG_RE = re.compile(rf'{ESC}\[(?:[39][0-7]|39)m') - -# Matches standard background colors: CSI(40-47|100-107|49)m -STD_BG_RE = re.compile(rf'{ESC}\[(?:(?:4|10)[0-7]|49)m') - -# Matches eight-bit foreground colors: CSI38;5;(0-255)m -EIGHT_BIT_FG_RE = re.compile(rf'{ESC}\[38;5;(?:1?[0-9]?[0-9]?|2[0-4][0-9]|25[0-5])m') - -# Matches eight-bit background colors: CSI48;5;(0-255)m -EIGHT_BIT_BG_RE = re.compile(rf'{ESC}\[48;5;(?:1?[0-9]?[0-9]?|2[0-4][0-9]|25[0-5])m') - -# Matches RGB foreground colors: CSI38;2;(0-255);(0-255);(0-255)m -RGB_FG_RE = re.compile(rf'{ESC}\[38;2(?:;(?:1?[0-9]?[0-9]?|2[0-4][0-9]|25[0-5])){{3}}m') - -# Matches RGB background colors: CSI48;2;(0-255);(0-255);(0-255)m -RGB_BG_RE = re.compile(rf'{ESC}\[48;2(?:;(?:1?[0-9]?[0-9]?|2[0-4][0-9]|25[0-5])){{3}}m') - - -def strip_style(text: str) -> str: - """Strip ANSI style sequences from a string. - - :param text: string which may contain ANSI style sequences - :return: the same string with any ANSI style sequences removed - """ - return ANSI_STYLE_RE.sub('', text) - - -def style_aware_wcswidth(text: str) -> int: - """Wrap wcswidth to make it compatible with strings that contain ANSI style sequences. - - This is intended for single line strings. If text contains a newline, this - function will return -1. For multiline strings, call widest_line() instead. - - :param text: the string being measured - :return: The width of the string when printed to the terminal if no errors occur. - If text contains characters with no absolute width (i.e. tabs), - then this function returns -1. Replace tabs with spaces before calling this. - """ - # Strip ANSI style sequences since they cause wcswidth to return -1 - return cast(int, wcswidth(strip_style(text))) - - -def widest_line(text: str) -> int: - """Return the width of the widest line in a multiline string. - - This wraps style_aware_wcswidth() so it handles ANSI style sequences and has the same - restrictions on non-printable characters. - - :param text: the string being measured - :return: The width of the string when printed to the terminal if no errors occur. - If text contains characters with no absolute width (i.e. tabs), - then this function returns -1. Replace tabs with spaces before calling this. - """ - if not text: - return 0 - - lines_widths = [style_aware_wcswidth(line) for line in text.splitlines()] - if -1 in lines_widths: - return -1 - - return max(lines_widths) - - -def style_aware_write(fileobj: IO[str], msg: str) -> None: - """Write a string to a fileobject and strip its ANSI style sequences if required by allow_style setting. - - :param fileobj: the file object being written to - :param msg: the string being written - """ - if rich_utils.allow_style == rich_utils.AllowStyle.NEVER or ( - rich_utils.allow_style == rich_utils.AllowStyle.TERMINAL and not fileobj.isatty() - ): - msg = strip_style(msg) - - fileobj.write(msg) - - -#################################################################################### -# Utility functions which create various ANSI sequences -#################################################################################### -def set_title(title: str) -> str: - """Generate a string that, when printed, sets a terminal's window title. - - :param title: new title for the window - :return: the set title string - """ - return f"{OSC}2;{title}{BEL}" - - -def clear_screen(clear_type: int = 2) -> str: - """Generate a string that, when printed, clears a terminal screen based on value of clear_type. - - :param clear_type: integer which specifies how to clear the screen (Defaults to 2) - Possible values: - 0 - clear from cursor to end of screen - 1 - clear from cursor to beginning of the screen - 2 - clear entire screen - 3 - clear entire screen and delete all lines saved in the scrollback buffer - :return: the clear screen string - :raises ValueError: if clear_type is not a valid value - """ - if 0 <= clear_type <= 3: - return f"{CSI}{clear_type}J" - raise ValueError("clear_type must in an integer from 0 to 3") - - -def clear_line(clear_type: int = 2) -> str: - """Generate a string that, when printed, clears a line based on value of clear_type. - - :param clear_type: integer which specifies how to clear the line (Defaults to 2) - Possible values: - 0 - clear from cursor to the end of the line - 1 - clear from cursor to beginning of the line - 2 - clear entire line - :return: the clear line string - :raises ValueError: if clear_type is not a valid value - """ - if 0 <= clear_type <= 2: - return f"{CSI}{clear_type}K" - raise ValueError("clear_type must in an integer from 0 to 2") - - -#################################################################################### -# Base classes which are not intended to be used directly -#################################################################################### -class AnsiSequence: - """Base class to create ANSI sequence strings.""" - - def __add__(self, other: Any) -> str: - """Support building an ANSI sequence string when self is the left operand. - - e.g. Fg.LIGHT_MAGENTA + "hello" - """ - return str(self) + str(other) - - def __radd__(self, other: Any) -> str: - """Support building an ANSI sequence string when self is the right operand. - - e.g. "hello" + Fg.RESET - """ - return str(other) + str(self) - - -class FgColor(AnsiSequence): - """Base class for ANSI Sequences which set foreground text color.""" - - -class BgColor(AnsiSequence): - """Base class for ANSI Sequences which set background text color.""" - - -#################################################################################### -# Implementations intended for direct use (do NOT use outside of cmd2) -#################################################################################### -class Cursor: - """Create ANSI sequences to alter the cursor position.""" - - @staticmethod - def UP(count: int = 1) -> str: # noqa: N802 - """Move the cursor up a specified amount of lines (Defaults to 1).""" - return f"{CSI}{count}A" - - @staticmethod - def DOWN(count: int = 1) -> str: # noqa: N802 - """Move the cursor down a specified amount of lines (Defaults to 1).""" - return f"{CSI}{count}B" - - @staticmethod - def FORWARD(count: int = 1) -> str: # noqa: N802 - """Move the cursor forward a specified amount of lines (Defaults to 1).""" - return f"{CSI}{count}C" - - @staticmethod - def BACK(count: int = 1) -> str: # noqa: N802 - """Move the cursor back a specified amount of lines (Defaults to 1).""" - return f"{CSI}{count}D" - - @staticmethod - def SET_POS(x: int, y: int) -> str: # noqa: N802 - """Set the cursor position to coordinates which are 1-based.""" - return f"{CSI}{y};{x}H" - - -class TextStyle(AnsiSequence, Enum): - """Create text style ANSI sequences.""" - - # Resets all styles and colors of text - RESET_ALL = 0 - ALT_RESET_ALL = '' - - INTENSITY_BOLD = 1 - INTENSITY_DIM = 2 - INTENSITY_NORMAL = 22 - - ITALIC_ENABLE = 3 - ITALIC_DISABLE = 23 - - OVERLINE_ENABLE = 53 - OVERLINE_DISABLE = 55 - - STRIKETHROUGH_ENABLE = 9 - STRIKETHROUGH_DISABLE = 29 - - UNDERLINE_ENABLE = 4 - UNDERLINE_DISABLE = 24 - - def __str__(self) -> str: - """Return ANSI text style sequence instead of enum name. - - This is helpful when using a TextStyle in an f-string or format() call - e.g. my_str = f"{TextStyle.UNDERLINE_ENABLE}hello{TextStyle.UNDERLINE_DISABLE}". - """ - return f"{CSI}{self.value}m" - - -class Fg(FgColor, Enum): - """Create ANSI sequences for the 16 standard terminal foreground text colors. - - A terminal's color settings affect how these colors appear. - To reset any foreground color, use Fg.RESET. - """ - - BLACK = 30 - RED = 31 - GREEN = 32 - YELLOW = 33 - BLUE = 34 - MAGENTA = 35 - CYAN = 36 - LIGHT_GRAY = 37 - DARK_GRAY = 90 - LIGHT_RED = 91 - LIGHT_GREEN = 92 - LIGHT_YELLOW = 93 - LIGHT_BLUE = 94 - LIGHT_MAGENTA = 95 - LIGHT_CYAN = 96 - WHITE = 97 - - RESET = 39 - - def __str__(self) -> str: - """Return ANSI color sequence instead of enum name. - - This is helpful when using an Fg in an f-string or format() call - e.g. my_str = f"{Fg.BLUE}hello{Fg.RESET}". - """ - return f"{CSI}{self.value}m" - - -class Bg(BgColor, Enum): - """Create ANSI sequences for the 16 standard terminal background text colors. - - A terminal's color settings affect how these colors appear. - To reset any background color, use Bg.RESET. - """ - - BLACK = 40 - RED = 41 - GREEN = 42 - YELLOW = 43 - BLUE = 44 - MAGENTA = 45 - CYAN = 46 - LIGHT_GRAY = 47 - DARK_GRAY = 100 - LIGHT_RED = 101 - LIGHT_GREEN = 102 - LIGHT_YELLOW = 103 - LIGHT_BLUE = 104 - LIGHT_MAGENTA = 105 - LIGHT_CYAN = 106 - WHITE = 107 - - RESET = 49 - - def __str__(self) -> str: - """Return ANSI color sequence instead of enum name. - - This is helpful when using a Bg in an f-string or format() call - e.g. my_str = f"{Bg.BLACK}hello{Bg.RESET}". - """ - return f"{CSI}{self.value}m" - - -class EightBitFg(FgColor, Enum): - """Create ANSI sequences for 8-bit terminal foreground text colors. Most terminals support 8-bit/256-color mode. - - The first 16 colors correspond to the 16 colors from Fg and behave the same way. - To reset any foreground color, including 8-bit, use Fg.RESET. - """ - - BLACK = 0 - RED = 1 - GREEN = 2 - YELLOW = 3 - BLUE = 4 - MAGENTA = 5 - CYAN = 6 - LIGHT_GRAY = 7 - DARK_GRAY = 8 - LIGHT_RED = 9 - LIGHT_GREEN = 10 - LIGHT_YELLOW = 11 - LIGHT_BLUE = 12 - LIGHT_MAGENTA = 13 - LIGHT_CYAN = 14 - WHITE = 15 - GRAY_0 = 16 - NAVY_BLUE = 17 - DARK_BLUE = 18 - BLUE_3A = 19 - BLUE_3B = 20 - BLUE_1 = 21 - DARK_GREEN = 22 - DEEP_SKY_BLUE_4A = 23 - DEEP_SKY_BLUE_4B = 24 - DEEP_SKY_BLUE_4C = 25 - DODGER_BLUE_3 = 26 - DODGER_BLUE_2 = 27 - GREEN_4 = 28 - SPRING_GREEN_4 = 29 - TURQUOISE_4 = 30 - DEEP_SKY_BLUE_3A = 31 - DEEP_SKY_BLUE_3B = 32 - DODGER_BLUE_1 = 33 - GREEN_3A = 34 - SPRING_GREEN_3A = 35 - DARK_CYAN = 36 - LIGHT_SEA_GREEN = 37 - DEEP_SKY_BLUE_2 = 38 - DEEP_SKY_BLUE_1 = 39 - GREEN_3B = 40 - SPRING_GREEN_3B = 41 - SPRING_GREEN_2A = 42 - CYAN_3 = 43 - DARK_TURQUOISE = 44 - TURQUOISE_2 = 45 - GREEN_1 = 46 - SPRING_GREEN_2B = 47 - SPRING_GREEN_1 = 48 - MEDIUM_SPRING_GREEN = 49 - CYAN_2 = 50 - CYAN_1 = 51 - DARK_RED_1 = 52 - DEEP_PINK_4A = 53 - PURPLE_4A = 54 - PURPLE_4B = 55 - PURPLE_3 = 56 - BLUE_VIOLET = 57 - ORANGE_4A = 58 - GRAY_37 = 59 - MEDIUM_PURPLE_4 = 60 - SLATE_BLUE_3A = 61 - SLATE_BLUE_3B = 62 - ROYAL_BLUE_1 = 63 - CHARTREUSE_4 = 64 - DARK_SEA_GREEN_4A = 65 - PALE_TURQUOISE_4 = 66 - STEEL_BLUE = 67 - STEEL_BLUE_3 = 68 - CORNFLOWER_BLUE = 69 - CHARTREUSE_3A = 70 - DARK_SEA_GREEN_4B = 71 - CADET_BLUE_2 = 72 - CADET_BLUE_1 = 73 - SKY_BLUE_3 = 74 - STEEL_BLUE_1A = 75 - CHARTREUSE_3B = 76 - PALE_GREEN_3A = 77 - SEA_GREEN_3 = 78 - AQUAMARINE_3 = 79 - MEDIUM_TURQUOISE = 80 - STEEL_BLUE_1B = 81 - CHARTREUSE_2A = 82 - SEA_GREEN_2 = 83 - SEA_GREEN_1A = 84 - SEA_GREEN_1B = 85 - AQUAMARINE_1A = 86 - DARK_SLATE_GRAY_2 = 87 - DARK_RED_2 = 88 - DEEP_PINK_4B = 89 - DARK_MAGENTA_1 = 90 - DARK_MAGENTA_2 = 91 - DARK_VIOLET_1A = 92 - PURPLE_1A = 93 - ORANGE_4B = 94 - LIGHT_PINK_4 = 95 - PLUM_4 = 96 - MEDIUM_PURPLE_3A = 97 - MEDIUM_PURPLE_3B = 98 - SLATE_BLUE_1 = 99 - YELLOW_4A = 100 - WHEAT_4 = 101 - GRAY_53 = 102 - LIGHT_SLATE_GRAY = 103 - MEDIUM_PURPLE = 104 - LIGHT_SLATE_BLUE = 105 - YELLOW_4B = 106 - DARK_OLIVE_GREEN_3A = 107 - DARK_GREEN_SEA = 108 - LIGHT_SKY_BLUE_3A = 109 - LIGHT_SKY_BLUE_3B = 110 - SKY_BLUE_2 = 111 - CHARTREUSE_2B = 112 - DARK_OLIVE_GREEN_3B = 113 - PALE_GREEN_3B = 114 - DARK_SEA_GREEN_3A = 115 - DARK_SLATE_GRAY_3 = 116 - SKY_BLUE_1 = 117 - CHARTREUSE_1 = 118 - LIGHT_GREEN_2 = 119 - LIGHT_GREEN_3 = 120 - PALE_GREEN_1A = 121 - AQUAMARINE_1B = 122 - DARK_SLATE_GRAY_1 = 123 - RED_3A = 124 - DEEP_PINK_4C = 125 - MEDIUM_VIOLET_RED = 126 - MAGENTA_3A = 127 - DARK_VIOLET_1B = 128 - PURPLE_1B = 129 - DARK_ORANGE_3A = 130 - INDIAN_RED_1A = 131 - HOT_PINK_3A = 132 - MEDIUM_ORCHID_3 = 133 - MEDIUM_ORCHID = 134 - MEDIUM_PURPLE_2A = 135 - DARK_GOLDENROD = 136 - LIGHT_SALMON_3A = 137 - ROSY_BROWN = 138 - GRAY_63 = 139 - MEDIUM_PURPLE_2B = 140 - MEDIUM_PURPLE_1 = 141 - GOLD_3A = 142 - DARK_KHAKI = 143 - NAVAJO_WHITE_3 = 144 - GRAY_69 = 145 - LIGHT_STEEL_BLUE_3 = 146 - LIGHT_STEEL_BLUE = 147 - YELLOW_3A = 148 - DARK_OLIVE_GREEN_3 = 149 - DARK_SEA_GREEN_3B = 150 - DARK_SEA_GREEN_2 = 151 - LIGHT_CYAN_3 = 152 - LIGHT_SKY_BLUE_1 = 153 - GREEN_YELLOW = 154 - DARK_OLIVE_GREEN_2 = 155 - PALE_GREEN_1B = 156 - DARK_SEA_GREEN_5B = 157 - DARK_SEA_GREEN_5A = 158 - PALE_TURQUOISE_1 = 159 - RED_3B = 160 - DEEP_PINK_3A = 161 - DEEP_PINK_3B = 162 - MAGENTA_3B = 163 - MAGENTA_3C = 164 - MAGENTA_2A = 165 - DARK_ORANGE_3B = 166 - INDIAN_RED_1B = 167 - HOT_PINK_3B = 168 - HOT_PINK_2 = 169 - ORCHID = 170 - MEDIUM_ORCHID_1A = 171 - ORANGE_3 = 172 - LIGHT_SALMON_3B = 173 - LIGHT_PINK_3 = 174 - PINK_3 = 175 - PLUM_3 = 176 - VIOLET = 177 - GOLD_3B = 178 - LIGHT_GOLDENROD_3 = 179 - TAN = 180 - MISTY_ROSE_3 = 181 - THISTLE_3 = 182 - PLUM_2 = 183 - YELLOW_3B = 184 - KHAKI_3 = 185 - LIGHT_GOLDENROD_2A = 186 - LIGHT_YELLOW_3 = 187 - GRAY_84 = 188 - LIGHT_STEEL_BLUE_1 = 189 - YELLOW_2 = 190 - DARK_OLIVE_GREEN_1A = 191 - DARK_OLIVE_GREEN_1B = 192 - DARK_SEA_GREEN_1 = 193 - HONEYDEW_2 = 194 - LIGHT_CYAN_1 = 195 - RED_1 = 196 - DEEP_PINK_2 = 197 - DEEP_PINK_1A = 198 - DEEP_PINK_1B = 199 - MAGENTA_2B = 200 - MAGENTA_1 = 201 - ORANGE_RED_1 = 202 - INDIAN_RED_1C = 203 - INDIAN_RED_1D = 204 - HOT_PINK_1A = 205 - HOT_PINK_1B = 206 - MEDIUM_ORCHID_1B = 207 - DARK_ORANGE = 208 - SALMON_1 = 209 - LIGHT_CORAL = 210 - PALE_VIOLET_RED_1 = 211 - ORCHID_2 = 212 - ORCHID_1 = 213 - ORANGE_1 = 214 - SANDY_BROWN = 215 - LIGHT_SALMON_1 = 216 - LIGHT_PINK_1 = 217 - PINK_1 = 218 - PLUM_1 = 219 - GOLD_1 = 220 - LIGHT_GOLDENROD_2B = 221 - LIGHT_GOLDENROD_2C = 222 - NAVAJO_WHITE_1 = 223 - MISTY_ROSE1 = 224 - THISTLE_1 = 225 - YELLOW_1 = 226 - LIGHT_GOLDENROD_1 = 227 - KHAKI_1 = 228 - WHEAT_1 = 229 - CORNSILK_1 = 230 - GRAY_100 = 231 - GRAY_3 = 232 - GRAY_7 = 233 - GRAY_11 = 234 - GRAY_15 = 235 - GRAY_19 = 236 - GRAY_23 = 237 - GRAY_27 = 238 - GRAY_30 = 239 - GRAY_35 = 240 - GRAY_39 = 241 - GRAY_42 = 242 - GRAY_46 = 243 - GRAY_50 = 244 - GRAY_54 = 245 - GRAY_58 = 246 - GRAY_62 = 247 - GRAY_66 = 248 - GRAY_70 = 249 - GRAY_74 = 250 - GRAY_78 = 251 - GRAY_82 = 252 - GRAY_85 = 253 - GRAY_89 = 254 - GRAY_93 = 255 - - def __str__(self) -> str: - """Return ANSI color sequence instead of enum name. - - This is helpful when using an EightBitFg in an f-string or format() call - e.g. my_str = f"{EightBitFg.SLATE_BLUE_1}hello{Fg.RESET}". - """ - return f"{CSI}38;5;{self.value}m" - - -class EightBitBg(BgColor, Enum): - """Create ANSI sequences for 8-bit terminal background text colors. Most terminals support 8-bit/256-color mode. - - The first 16 colors correspond to the 16 colors from Bg and behave the same way. - To reset any background color, including 8-bit, use Bg.RESET. - """ - - BLACK = 0 - RED = 1 - GREEN = 2 - YELLOW = 3 - BLUE = 4 - MAGENTA = 5 - CYAN = 6 - LIGHT_GRAY = 7 - DARK_GRAY = 8 - LIGHT_RED = 9 - LIGHT_GREEN = 10 - LIGHT_YELLOW = 11 - LIGHT_BLUE = 12 - LIGHT_MAGENTA = 13 - LIGHT_CYAN = 14 - WHITE = 15 - GRAY_0 = 16 - NAVY_BLUE = 17 - DARK_BLUE = 18 - BLUE_3A = 19 - BLUE_3B = 20 - BLUE_1 = 21 - DARK_GREEN = 22 - DEEP_SKY_BLUE_4A = 23 - DEEP_SKY_BLUE_4B = 24 - DEEP_SKY_BLUE_4C = 25 - DODGER_BLUE_3 = 26 - DODGER_BLUE_2 = 27 - GREEN_4 = 28 - SPRING_GREEN_4 = 29 - TURQUOISE_4 = 30 - DEEP_SKY_BLUE_3A = 31 - DEEP_SKY_BLUE_3B = 32 - DODGER_BLUE_1 = 33 - GREEN_3A = 34 - SPRING_GREEN_3A = 35 - DARK_CYAN = 36 - LIGHT_SEA_GREEN = 37 - DEEP_SKY_BLUE_2 = 38 - DEEP_SKY_BLUE_1 = 39 - GREEN_3B = 40 - SPRING_GREEN_3B = 41 - SPRING_GREEN_2A = 42 - CYAN_3 = 43 - DARK_TURQUOISE = 44 - TURQUOISE_2 = 45 - GREEN_1 = 46 - SPRING_GREEN_2B = 47 - SPRING_GREEN_1 = 48 - MEDIUM_SPRING_GREEN = 49 - CYAN_2 = 50 - CYAN_1 = 51 - DARK_RED_1 = 52 - DEEP_PINK_4A = 53 - PURPLE_4A = 54 - PURPLE_4B = 55 - PURPLE_3 = 56 - BLUE_VIOLET = 57 - ORANGE_4A = 58 - GRAY_37 = 59 - MEDIUM_PURPLE_4 = 60 - SLATE_BLUE_3A = 61 - SLATE_BLUE_3B = 62 - ROYAL_BLUE_1 = 63 - CHARTREUSE_4 = 64 - DARK_SEA_GREEN_4A = 65 - PALE_TURQUOISE_4 = 66 - STEEL_BLUE = 67 - STEEL_BLUE_3 = 68 - CORNFLOWER_BLUE = 69 - CHARTREUSE_3A = 70 - DARK_SEA_GREEN_4B = 71 - CADET_BLUE_2 = 72 - CADET_BLUE_1 = 73 - SKY_BLUE_3 = 74 - STEEL_BLUE_1A = 75 - CHARTREUSE_3B = 76 - PALE_GREEN_3A = 77 - SEA_GREEN_3 = 78 - AQUAMARINE_3 = 79 - MEDIUM_TURQUOISE = 80 - STEEL_BLUE_1B = 81 - CHARTREUSE_2A = 82 - SEA_GREEN_2 = 83 - SEA_GREEN_1A = 84 - SEA_GREEN_1B = 85 - AQUAMARINE_1A = 86 - DARK_SLATE_GRAY_2 = 87 - DARK_RED_2 = 88 - DEEP_PINK_4B = 89 - DARK_MAGENTA_1 = 90 - DARK_MAGENTA_2 = 91 - DARK_VIOLET_1A = 92 - PURPLE_1A = 93 - ORANGE_4B = 94 - LIGHT_PINK_4 = 95 - PLUM_4 = 96 - MEDIUM_PURPLE_3A = 97 - MEDIUM_PURPLE_3B = 98 - SLATE_BLUE_1 = 99 - YELLOW_4A = 100 - WHEAT_4 = 101 - GRAY_53 = 102 - LIGHT_SLATE_GRAY = 103 - MEDIUM_PURPLE = 104 - LIGHT_SLATE_BLUE = 105 - YELLOW_4B = 106 - DARK_OLIVE_GREEN_3A = 107 - DARK_GREEN_SEA = 108 - LIGHT_SKY_BLUE_3A = 109 - LIGHT_SKY_BLUE_3B = 110 - SKY_BLUE_2 = 111 - CHARTREUSE_2B = 112 - DARK_OLIVE_GREEN_3B = 113 - PALE_GREEN_3B = 114 - DARK_SEA_GREEN_3A = 115 - DARK_SLATE_GRAY_3 = 116 - SKY_BLUE_1 = 117 - CHARTREUSE_1 = 118 - LIGHT_GREEN_2 = 119 - LIGHT_GREEN_3 = 120 - PALE_GREEN_1A = 121 - AQUAMARINE_1B = 122 - DARK_SLATE_GRAY_1 = 123 - RED_3A = 124 - DEEP_PINK_4C = 125 - MEDIUM_VIOLET_RED = 126 - MAGENTA_3A = 127 - DARK_VIOLET_1B = 128 - PURPLE_1B = 129 - DARK_ORANGE_3A = 130 - INDIAN_RED_1A = 131 - HOT_PINK_3A = 132 - MEDIUM_ORCHID_3 = 133 - MEDIUM_ORCHID = 134 - MEDIUM_PURPLE_2A = 135 - DARK_GOLDENROD = 136 - LIGHT_SALMON_3A = 137 - ROSY_BROWN = 138 - GRAY_63 = 139 - MEDIUM_PURPLE_2B = 140 - MEDIUM_PURPLE_1 = 141 - GOLD_3A = 142 - DARK_KHAKI = 143 - NAVAJO_WHITE_3 = 144 - GRAY_69 = 145 - LIGHT_STEEL_BLUE_3 = 146 - LIGHT_STEEL_BLUE = 147 - YELLOW_3A = 148 - DARK_OLIVE_GREEN_3 = 149 - DARK_SEA_GREEN_3B = 150 - DARK_SEA_GREEN_2 = 151 - LIGHT_CYAN_3 = 152 - LIGHT_SKY_BLUE_1 = 153 - GREEN_YELLOW = 154 - DARK_OLIVE_GREEN_2 = 155 - PALE_GREEN_1B = 156 - DARK_SEA_GREEN_5B = 157 - DARK_SEA_GREEN_5A = 158 - PALE_TURQUOISE_1 = 159 - RED_3B = 160 - DEEP_PINK_3A = 161 - DEEP_PINK_3B = 162 - MAGENTA_3B = 163 - MAGENTA_3C = 164 - MAGENTA_2A = 165 - DARK_ORANGE_3B = 166 - INDIAN_RED_1B = 167 - HOT_PINK_3B = 168 - HOT_PINK_2 = 169 - ORCHID = 170 - MEDIUM_ORCHID_1A = 171 - ORANGE_3 = 172 - LIGHT_SALMON_3B = 173 - LIGHT_PINK_3 = 174 - PINK_3 = 175 - PLUM_3 = 176 - VIOLET = 177 - GOLD_3B = 178 - LIGHT_GOLDENROD_3 = 179 - TAN = 180 - MISTY_ROSE_3 = 181 - THISTLE_3 = 182 - PLUM_2 = 183 - YELLOW_3B = 184 - KHAKI_3 = 185 - LIGHT_GOLDENROD_2A = 186 - LIGHT_YELLOW_3 = 187 - GRAY_84 = 188 - LIGHT_STEEL_BLUE_1 = 189 - YELLOW_2 = 190 - DARK_OLIVE_GREEN_1A = 191 - DARK_OLIVE_GREEN_1B = 192 - DARK_SEA_GREEN_1 = 193 - HONEYDEW_2 = 194 - LIGHT_CYAN_1 = 195 - RED_1 = 196 - DEEP_PINK_2 = 197 - DEEP_PINK_1A = 198 - DEEP_PINK_1B = 199 - MAGENTA_2B = 200 - MAGENTA_1 = 201 - ORANGE_RED_1 = 202 - INDIAN_RED_1C = 203 - INDIAN_RED_1D = 204 - HOT_PINK_1A = 205 - HOT_PINK_1B = 206 - MEDIUM_ORCHID_1B = 207 - DARK_ORANGE = 208 - SALMON_1 = 209 - LIGHT_CORAL = 210 - PALE_VIOLET_RED_1 = 211 - ORCHID_2 = 212 - ORCHID_1 = 213 - ORANGE_1 = 214 - SANDY_BROWN = 215 - LIGHT_SALMON_1 = 216 - LIGHT_PINK_1 = 217 - PINK_1 = 218 - PLUM_1 = 219 - GOLD_1 = 220 - LIGHT_GOLDENROD_2B = 221 - LIGHT_GOLDENROD_2C = 222 - NAVAJO_WHITE_1 = 223 - MISTY_ROSE1 = 224 - THISTLE_1 = 225 - YELLOW_1 = 226 - LIGHT_GOLDENROD_1 = 227 - KHAKI_1 = 228 - WHEAT_1 = 229 - CORNSILK_1 = 230 - GRAY_100 = 231 - GRAY_3 = 232 - GRAY_7 = 233 - GRAY_11 = 234 - GRAY_15 = 235 - GRAY_19 = 236 - GRAY_23 = 237 - GRAY_27 = 238 - GRAY_30 = 239 - GRAY_35 = 240 - GRAY_39 = 241 - GRAY_42 = 242 - GRAY_46 = 243 - GRAY_50 = 244 - GRAY_54 = 245 - GRAY_58 = 246 - GRAY_62 = 247 - GRAY_66 = 248 - GRAY_70 = 249 - GRAY_74 = 250 - GRAY_78 = 251 - GRAY_82 = 252 - GRAY_85 = 253 - GRAY_89 = 254 - GRAY_93 = 255 - - def __str__(self) -> str: - """Return ANSI color sequence instead of enum name. - - This is helpful when using an EightBitBg in an f-string or format() call - e.g. my_str = f"{EightBitBg.KHAKI_3}hello{Bg.RESET}". - """ - return f"{CSI}48;5;{self.value}m" - - -class RgbFg(FgColor): - """Create ANSI sequences for 24-bit (RGB) terminal foreground text colors. The terminal must support 24-bit/true-color. - - To reset any foreground color, including 24-bit, use Fg.RESET. - """ - - def __init__(self, r: int, g: int, b: int) -> None: - """RgbFg initializer. - - :param r: integer from 0-255 for the red component of the color - :param g: integer from 0-255 for the green component of the color - :param b: integer from 0-255 for the blue component of the color - :raises ValueError: if r, g, or b is not in the range 0-255 - """ - if any(c < 0 or c > 255 for c in [r, g, b]): - raise ValueError("RGB values must be integers in the range of 0 to 255") - - self._sequence = f"{CSI}38;2;{r};{g};{b}m" - - def __str__(self) -> str: - """Return ANSI color sequence instead of enum name. - - This is helpful when using an RgbFg in an f-string or format() call - e.g. my_str = f"{RgbFg(0, 55, 100)}hello{Fg.RESET}". - """ - return self._sequence - - -class RgbBg(BgColor): - """Create ANSI sequences for 24-bit (RGB) terminal background text colors. The terminal must support 24-bit/true-color. - - To reset any background color, including 24-bit, use Bg.RESET. - """ - - def __init__(self, r: int, g: int, b: int) -> None: - """RgbBg initializer. - - :param r: integer from 0-255 for the red component of the color - :param g: integer from 0-255 for the green component of the color - :param b: integer from 0-255 for the blue component of the color - :raises ValueError: if r, g, or b is not in the range 0-255 - """ - if any(c < 0 or c > 255 for c in [r, g, b]): - raise ValueError("RGB values must be integers in the range of 0 to 255") - - self._sequence = f"{CSI}48;2;{r};{g};{b}m" - - def __str__(self) -> str: - """Return ANSI color sequence instead of enum name. - - This is helpful when using an RgbBg in an f-string or format() call - e.g. my_str = f"{RgbBg(100, 255, 27)}hello{Bg.RESET}". - """ - return self._sequence - - -def style( - value: Any, - *, - fg: FgColor | None = None, - bg: BgColor | None = None, - bold: bool | None = None, - dim: bool | None = None, - italic: bool | None = None, - overline: bool | None = None, - strikethrough: bool | None = None, - underline: bool | None = None, -) -> str: - """Apply ANSI colors and/or styles to a string and return it. - - The styling is self contained which means that at the end of the string reset code(s) are issued - to undo whatever styling was done at the beginning. - - :param value: object whose text is to be styled - :param fg: foreground color provided as any subclass of FgColor (e.g. Fg, EightBitFg, RgbFg) - Defaults to no color. - :param bg: foreground color provided as any subclass of BgColor (e.g. Bg, EightBitBg, RgbBg) - Defaults to no color. - :param bold: apply the bold style if True. Defaults to False. - :param dim: apply the dim style if True. Defaults to False. - :param italic: apply the italic style if True. Defaults to False. - :param overline: apply the overline style if True. Defaults to False. - :param strikethrough: apply the strikethrough style if True. Defaults to False. - :param underline: apply the underline style if True. Defaults to False. - :raises TypeError: if fg isn't None or a subclass of FgColor - :raises TypeError: if bg isn't None or a subclass of BgColor - :return: the stylized string - """ - # list of strings that add style - additions: list[AnsiSequence] = [] - - # list of strings that remove style - removals: list[AnsiSequence] = [] - - # Process the style settings - if fg is not None: - if not isinstance(fg, FgColor): - raise TypeError("fg must be a subclass of FgColor") - additions.append(fg) - removals.append(Fg.RESET) - - if bg is not None: - if not isinstance(bg, BgColor): - raise TypeError("bg must a subclass of BgColor") - additions.append(bg) - removals.append(Bg.RESET) - - if bold: - additions.append(TextStyle.INTENSITY_BOLD) - removals.append(TextStyle.INTENSITY_NORMAL) - - if dim: - additions.append(TextStyle.INTENSITY_DIM) - removals.append(TextStyle.INTENSITY_NORMAL) - - if italic: - additions.append(TextStyle.ITALIC_ENABLE) - removals.append(TextStyle.ITALIC_DISABLE) - - if overline: - additions.append(TextStyle.OVERLINE_ENABLE) - removals.append(TextStyle.OVERLINE_DISABLE) - - if strikethrough: - additions.append(TextStyle.STRIKETHROUGH_ENABLE) - removals.append(TextStyle.STRIKETHROUGH_DISABLE) - - if underline: - additions.append(TextStyle.UNDERLINE_ENABLE) - removals.append(TextStyle.UNDERLINE_DISABLE) - - # Combine the ANSI style sequences with the value's text - return "".join(map(str, additions)) + str(value) + "".join(map(str, removals)) - - -# Default styles for printing strings of various types. -# These can be altered to suit an application's needs and only need to be a -# function with the following structure: func(str) -> str -style_success = functools.partial(style, fg=Fg.GREEN) -"""Partial function supplying arguments to [cmd2.ansi.style][] which colors text to signify success""" - -style_warning = functools.partial(style, fg=Fg.LIGHT_YELLOW) -"""Partial function supplying arguments to [cmd2.ansi.style][] which colors text to signify a warning""" - -style_error = functools.partial(style, fg=Fg.LIGHT_RED) -"""Partial function supplying arguments to [cmd2.ansi.style][] which colors text to signify an error""" - - -def async_alert_str(*, terminal_columns: int, prompt: str, line: str, cursor_offset: int, alert_msg: str) -> str: - """Calculate the desired string, including ANSI escape codes, for displaying an asynchronous alert message. - - :param terminal_columns: terminal width (number of columns) - :param prompt: current onscreen prompt - :param line: current contents of the Readline line buffer - :param cursor_offset: the offset of the current cursor position within line - :param alert_msg: the message to display to the user - :return: the correct string so that the alert message appears to the user to be printed above the current line. - """ - # Split the prompt lines since it can contain newline characters. - prompt_lines = prompt.splitlines() or [''] - - # Calculate how many terminal lines are taken up by all prompt lines except for the last one. - # That will be included in the input lines calculations since that is where the cursor is. - num_prompt_terminal_lines = 0 - for prompt_line in prompt_lines[:-1]: - prompt_line_width = style_aware_wcswidth(prompt_line) - num_prompt_terminal_lines += int(prompt_line_width / terminal_columns) + 1 - - # Now calculate how many terminal lines are take up by the input - last_prompt_line = prompt_lines[-1] - last_prompt_line_width = style_aware_wcswidth(last_prompt_line) - - input_width = last_prompt_line_width + style_aware_wcswidth(line) - - num_input_terminal_lines = int(input_width / terminal_columns) + 1 - - # Get the cursor's offset from the beginning of the first input line - cursor_input_offset = last_prompt_line_width + cursor_offset - - # Calculate what input line the cursor is on - cursor_input_line = int(cursor_input_offset / terminal_columns) + 1 - - # Create a string that when printed will clear all input lines and display the alert - terminal_str = '' - - # Move the cursor down to the last input line - if cursor_input_line != num_input_terminal_lines: - terminal_str += Cursor.DOWN(num_input_terminal_lines - cursor_input_line) - - # Clear each line from the bottom up so that the cursor ends up on the first prompt line - total_lines = num_prompt_terminal_lines + num_input_terminal_lines - terminal_str += (clear_line() + Cursor.UP(1)) * (total_lines - 1) - - # Clear the first prompt line - terminal_str += clear_line() - - # Move the cursor to the beginning of the first prompt line and print the alert - terminal_str += '\r' + alert_msg - return terminal_str diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 2418d255..92dc6b0d 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -6,7 +6,6 @@ import argparse import inspect import numbers -import sys from collections import ( deque, ) @@ -20,17 +19,13 @@ from .constants import ( INFINITY, ) -from .rich_utils import ( - Cmd2Console, - Cmd2Style, -) +from .rich_utils import Cmd2Console if TYPE_CHECKING: # pragma: no cover from .cmd2 import ( Cmd, ) - from rich.box import SIMPLE_HEAD from rich.table import Column, Table @@ -46,6 +41,7 @@ from .exceptions import ( CompletionError, ) +from .styles import Cmd2Style # If no descriptive headers are supplied, then this will be used instead DEFAULT_DESCRIPTIVE_HEADERS: Sequence[str | Column] = ('Description',) @@ -594,9 +590,9 @@ def _format_completions(self, arg_state: _ArgumentState, completions: list[str] hint_table.add_row(item, *item.descriptive_data) # Generate the hint table string - console = Cmd2Console(sys.stdout) + console = Cmd2Console() with console.capture() as capture: - console.print(hint_table) + console.print(hint_table, end="") self._cmd2_app.formatted_completions = capture.get() # Return sorted list of completions diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index caa4aac5..f3e5344b 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -294,11 +294,9 @@ def get_items(self) -> list[CompletionItems]: RichHelpFormatter, ) -from . import ( - constants, - rich_utils, -) -from .rich_utils import Cmd2Style +from . import constants +from .rich_utils import Cmd2Console +from .styles import Cmd2Style if TYPE_CHECKING: # pragma: no cover from .argparse_completer import ( @@ -1115,12 +1113,12 @@ def __init__( max_help_position: int = 24, width: int | None = None, *, - console: rich_utils.Cmd2Console | None = None, + console: Cmd2Console | None = None, **kwargs: Any, ) -> None: """Initialize Cmd2HelpFormatter.""" if console is None: - console = rich_utils.Cmd2Console(sys.stdout) + console = Cmd2Console() super().__init__(prog, indent_increment, max_help_position, width, console=console, **kwargs) @@ -1483,7 +1481,7 @@ def error(self, message: str) -> NoReturn: # Add error style to message console = self._get_formatter().console with console.capture() as capture: - console.print(formatted_message, style=Cmd2Style.ERROR, crop=False) + console.print(formatted_message, style=Cmd2Style.ERROR) formatted_message = f"{capture.get()}" self.exit(2, f'{formatted_message}\n') diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index b3399b9f..979f562e 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -66,7 +66,7 @@ from rich.box import SIMPLE_HEAD from rich.console import Group from rich.rule import Rule -from rich.style import StyleType +from rich.style import Style, StyleType from rich.table import ( Column, Table, @@ -74,14 +74,14 @@ from rich.text import Text from . import ( - ansi, argparse_completer, argparse_custom, constants, plugin, - rich_utils, utils, ) +from . import rich_utils as ru +from . import string_utils as su from .argparse_custom import ( ChoicesProviderFunc, Cmd2ArgumentParser, @@ -129,7 +129,11 @@ StatementParser, shlex_split, ) -from .rich_utils import Cmd2Console, Cmd2Style, RichPrintKwargs +from .rich_utils import ( + Cmd2Console, + RichPrintKwargs, +) +from .styles import Cmd2Style # NOTE: When using gnureadline with Python 3.13, start_ipython needs to be imported before any readline-related stuff with contextlib.suppress(ImportError): @@ -292,7 +296,7 @@ class Cmd(cmd.Cmd): DEFAULT_EDITOR = utils.find_editor() # Sorting keys for strings - ALPHABETICAL_SORT_KEY = utils.norm_fold + ALPHABETICAL_SORT_KEY = su.norm_fold NATURAL_SORT_KEY = utils.natural_keys # List for storing transcript test file names @@ -496,7 +500,7 @@ def __init__( if startup_script: startup_script = os.path.abspath(os.path.expanduser(startup_script)) if os.path.exists(startup_script): - script_cmd = f"run_script {utils.quote_string(startup_script)}" + script_cmd = f"run_script {su.quote(startup_script)}" if silence_startup_script: script_cmd += f" {constants.REDIRECTION_OUTPUT} {os.devnull}" self._startup_commands.append(script_cmd) @@ -1127,16 +1131,15 @@ def build_settables(self) -> None: def get_allow_style_choices(_cli_self: Cmd) -> list[str]: """Tab complete allow_style values.""" - return [val.name.lower() for val in rich_utils.AllowStyle] + return [val.name.lower() for val in ru.AllowStyle] - def allow_style_type(value: str) -> rich_utils.AllowStyle: - """Convert a string value into an rich_utils.AllowStyle.""" + def allow_style_type(value: str) -> ru.AllowStyle: + """Convert a string value into an ru.AllowStyle.""" try: - return rich_utils.AllowStyle[value.upper()] + return ru.AllowStyle[value.upper()] except KeyError as ex: raise ValueError( - f"must be {rich_utils.AllowStyle.ALWAYS}, {rich_utils.AllowStyle.NEVER}, or " - f"{rich_utils.AllowStyle.TERMINAL} (case-insensitive)" + f"must be {ru.AllowStyle.ALWAYS}, {ru.AllowStyle.NEVER}, or {ru.AllowStyle.TERMINAL} (case-insensitive)" ) from ex self.add_settable( @@ -1144,7 +1147,7 @@ def allow_style_type(value: str) -> rich_utils.AllowStyle: 'allow_style', allow_style_type, 'Allow ANSI text style sequences in output (valid values: ' - f'{rich_utils.AllowStyle.ALWAYS}, {rich_utils.AllowStyle.NEVER}, {rich_utils.AllowStyle.TERMINAL})', + f'{ru.AllowStyle.ALWAYS}, {ru.AllowStyle.NEVER}, {ru.AllowStyle.TERMINAL})', self, choices_provider=cast(ChoicesProviderFunc, get_allow_style_choices), ) @@ -1167,14 +1170,14 @@ def allow_style_type(value: str) -> rich_utils.AllowStyle: # ----- Methods related to presenting output to the user ----- @property - def allow_style(self) -> rich_utils.AllowStyle: + def allow_style(self) -> ru.AllowStyle: """Read-only property needed to support do_set when it reads allow_style.""" - return rich_utils.allow_style + return ru.ALLOW_STYLE @allow_style.setter - def allow_style(self, new_val: rich_utils.AllowStyle) -> None: + def allow_style(self, new_val: ru.AllowStyle) -> None: """Setter property needed to support do_set when it updates allow_style.""" - rich_utils.allow_style = new_val + ru.ALLOW_STYLE = new_val def _completion_supported(self) -> bool: """Return whether tab completion is supported.""" @@ -1189,7 +1192,7 @@ def visible_prompt(self) -> str: :return: prompt stripped of any ANSI escape codes """ - return ansi.strip_style(self.prompt) + return su.strip_style(self.prompt) def print_to( self, @@ -1219,7 +1222,7 @@ def print_to( method and still call `super()` without encountering unexpected keyword argument errors. These arguments are not passed to Rich's Console.print(). """ - prepared_objects = rich_utils.prepare_objects_for_rich_print(*objects) + prepared_objects = ru.prepare_objects_for_rich_print(*objects) try: Cmd2Console(file).print( @@ -1290,7 +1293,7 @@ def perror( :param objects: objects to print :param sep: string to write between print data. Defaults to " ". :param end: string to write at end of print data. Defaults to a newline. - :param style: optional style to apply to output. Defaults to cmd2.error. + :param style: optional style to apply to output. Defaults to Cmd2Style.ERROR. :param soft_wrap: Enable soft wrap mode. If True, text lines will not be automatically word-wrapped to fit the terminal width; instead, any text that doesn't fit will run onto the following line(s), similar to the built-in print() function. Set to False to enable automatic word-wrapping. @@ -1320,7 +1323,7 @@ def psuccess( rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: - """Wrap poutput, but apply cmd2.success style. + """Wrap poutput, but apply Cmd2Style.SUCCESS. :param objects: objects to print :param sep: string to write between print data. Defaults to " ". @@ -1353,7 +1356,7 @@ def pwarning( rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: - """Wrap perror, but apply cmd2.warning style. + """Wrap perror, but apply Cmd2Style.WARNING. :param objects: objects to print :param sep: string to write between print data. Defaults to " ". @@ -1510,7 +1513,7 @@ def ppaged( # Check if we are outputting to a pager. if functional_terminal and can_block: - prepared_objects = rich_utils.prepare_objects_for_rich_print(*objects) + prepared_objects = ru.prepare_objects_for_rich_print(*objects) # Chopping overrides soft_wrap if chop: @@ -1627,7 +1630,7 @@ def tokens_for_completion(self, line: str, begidx: int, endidx: int) -> tuple[li raw_tokens = self.statement_parser.split_on_punctuation(initial_tokens) # Save the unquoted tokens - tokens = [utils.strip_quotes(cur_token) for cur_token in raw_tokens] + tokens = [su.strip_quotes(cur_token) for cur_token in raw_tokens] # If the token being completed had an unclosed quote, we need # to remove the closing quote that was added in order for it @@ -2129,7 +2132,7 @@ def _display_matches_gnu_readline( longest_match_length = 0 for cur_match in matches_to_display: - cur_length = ansi.style_aware_wcswidth(cur_match) + cur_length = su.str_width(cur_match) longest_match_length = max(longest_match_length, cur_length) else: matches_to_display = matches @@ -3121,7 +3124,7 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: mode = 'a' if statement.output == constants.REDIRECTION_APPEND else 'w' try: # Use line buffering - new_stdout = cast(TextIO, open(utils.strip_quotes(statement.output_to), mode=mode, buffering=1)) # noqa: SIM115 + new_stdout = cast(TextIO, open(su.strip_quotes(statement.output_to), mode=mode, buffering=1)) # noqa: SIM115 except OSError as ex: raise RedirectionError('Failed to redirect output') from ex @@ -3251,13 +3254,12 @@ def default(self, statement: Statement) -> bool | None: # type: ignore[override if self.default_to_shell: if 'shell' not in self.exclude_from_history: self.history.append(statement) - return self.do_shell(statement.command_and_args) + err_msg = self.default_error.format(statement.command) if self.suggest_similar_command and (suggested_command := self._suggest_similar_command(statement.command)): err_msg += f"\n{self.default_suggestion_message.format(suggested_command)}" - # Set apply_style to False so styles for default_error and default_suggestion_message are not overridden self.perror(err_msg, style=None) return None @@ -3798,7 +3800,7 @@ def _build_macro_create_parser(cls) -> Cmd2ArgumentParser: "\n", Text.assemble( (" my_macro beef broccoli", Cmd2Style.EXAMPLE), - (" ───> ", "bold"), + (" ───> ", Style(bold=True)), ("make_dinner --meat beef --veggie broccoli", Cmd2Style.EXAMPLE), ), ) @@ -4096,8 +4098,6 @@ def do_help(self, args: argparse.Namespace) -> None: # If there is no help information then print an error else: err_msg = self.help_error.format(args.command) - - # Set apply_style to False so help_error's style is not overridden self.perror(err_msg, style=None) self.last_result = False @@ -4151,7 +4151,7 @@ def columnize(self, str_list: list[str] | None, display_width: int = 80) -> None if i >= size: break x = str_list[i] - colwidth = max(colwidth, ansi.style_aware_wcswidth(x)) + colwidth = max(colwidth, su.str_width(x)) colwidths.append(colwidth) totwidth += colwidth + 2 if totwidth > display_width: @@ -4172,7 +4172,7 @@ def columnize(self, str_list: list[str] | None, display_width: int = 80) -> None while texts and not texts[-1]: del texts[-1] for col in range(len(texts)): - texts[col] = utils.align_left(texts[col], width=colwidths[col]) + texts[col] = su.align_left(texts[col], width=colwidths[col]) self.poutput(" ".join(texts)) def _help_menu(self, verbose: bool = False) -> None: @@ -4468,7 +4468,7 @@ def do_set(self, args: argparse.Namespace) -> None: # Try to update the settable's value try: orig_value = settable.get_value() - settable.set_value(utils.strip_quotes(args.value)) + settable.set_value(su.strip_quotes(args.value)) except ValueError as ex: self.perror(f"Error setting {args.param}: {ex}") else: @@ -5064,7 +5064,7 @@ def do_history(self, args: argparse.Namespace) -> bool | None: self.run_editor(fname) # self.last_result will be set by do_run_script() - return self.do_run_script(utils.quote_string(fname)) + return self.do_run_script(su.quote(fname)) finally: os.remove(fname) elif args.output_file: @@ -5359,9 +5359,9 @@ def run_editor(self, file_path: str | None = None) -> None: if not self.editor: raise OSError("Please use 'set editor' to specify your text editing program of choice.") - command = utils.quote_string(os.path.expanduser(self.editor)) + command = su.quote(os.path.expanduser(self.editor)) if file_path: - command += " " + utils.quote_string(os.path.expanduser(file_path)) + command += " " + su.quote(os.path.expanduser(file_path)) self.do_shell(command) @@ -5499,7 +5499,7 @@ def do__relative_run_script(self, args: argparse.Namespace) -> bool | None: relative_path = os.path.join(self._current_script_dir or '', script_path) # self.last_result will be set by do_run_script() - return self.do_run_script(utils.quote_string(relative_path)) + return self.do_run_script(su.quote(relative_path)) def _run_transcript_tests(self, transcript_paths: list[str]) -> None: """Run transcript tests for provided file(s). @@ -5531,11 +5531,11 @@ class TestMyAppCase(Cmd2TestCase): verinfo = ".".join(map(str, sys.version_info[:3])) num_transcripts = len(transcripts_expanded) plural = '' if len(transcripts_expanded) == 1 else 's' - self.poutput(ansi.style(utils.align_center(' cmd2 transcript test ', fill_char='='), bold=True)) + self.poutput(su.align_center(' cmd2 transcript test ', character=self.ruler), style=Style(bold=True)) self.poutput(f'platform {sys.platform} -- Python {verinfo}, cmd2-{cmd2.__version__}, readline-{rl_type}') self.poutput(f'cwd: {os.getcwd()}') self.poutput(f'cmd2 app: {sys.argv[0]}') - self.poutput(ansi.style(f'collected {num_transcripts} transcript{plural}', bold=True)) + self.poutput(f'collected {num_transcripts} transcript{plural}', style=Style(bold=True)) self.__class__.testfiles = transcripts_expanded sys.argv = [sys.argv[0]] # the --test argument upsets unittest.main() @@ -5548,7 +5548,7 @@ class TestMyAppCase(Cmd2TestCase): if test_results.wasSuccessful(): self.perror(stream.read(), end="", style=None) finish_msg = f' {num_transcripts} transcript{plural} passed in {execution_time:.3f} seconds ' - finish_msg = utils.align_center(finish_msg, fill_char='=') + finish_msg = su.align_center(finish_msg, character=self.ruler) self.psuccess(finish_msg) else: # Strip off the initial traceback which isn't particularly useful for end users @@ -5608,14 +5608,11 @@ def async_alert(self, alert_msg: str, new_prompt: str | None = None) -> None: # rl_set_prompt(self.prompt) if update_terminal: - import shutil - - # Prior to Python 3.11 this can return 0, so use a fallback if needed. - terminal_columns = shutil.get_terminal_size().columns or constants.DEFAULT_TERMINAL_WIDTH + from .terminal_utils import async_alert_str # Print a string which replaces the onscreen prompt and input lines with the alert. - terminal_str = ansi.async_alert_str( - terminal_columns=terminal_columns, + terminal_str = async_alert_str( + terminal_columns=ru.console_width(), prompt=rl_get_display_prompt(), line=readline.get_line_buffer(), cursor_offset=rl_get_point(), @@ -5691,8 +5688,10 @@ def set_window_title(title: str) -> None: # pragma: no cover if not vt100_support: return + from .terminal_utils import set_title_str + try: - sys.stderr.write(ansi.set_title(title)) + sys.stderr.write(set_title_str(title)) sys.stderr.flush() except AttributeError: # Debugging in Pycharm has issues with setting terminal title diff --git a/cmd2/colors.py b/cmd2/colors.py new file mode 100644 index 00000000..1e6853c4 --- /dev/null +++ b/cmd2/colors.py @@ -0,0 +1,270 @@ +"""Provides a convenient StrEnum for Rich color names.""" + +import sys + +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + from backports.strenum import StrEnum + + +class Color(StrEnum): + """An enumeration of all color names supported by the Rich library. + + Using this enum allows for autocompletion and prevents typos when referencing + color names. The members can be used for both foreground and background colors. + + Aside from DEFAULT, these colors come from the rich.color.ANSI_COLOR_NAMES dictionary. + + Note: The terminal color settings determines the appearance of the follow 16 colors. + + | | | + |----------------|---------------| + | BLACK | BRIGHT_WHITE | + | BLUE | BRIGHT_YELLOW | + | BRIGHT_BLACK | CYAN | + | BRIGHT_BLUE | GREEN | + | BRIGHT_CYAN | MAGENTA | + | BRIGHT_GREEN | RED | + | BRIGHT_MAGENTA | WHITE | + | BRIGHT_RED | YELLOW | + """ + + DEFAULT = "default" + """Represents the terminal's default foreground or background color.""" + + AQUAMARINE1 = "aquamarine1" + AQUAMARINE3 = "aquamarine3" + BLACK = "black" + BLUE = "blue" + BLUE1 = "blue1" + BLUE3 = "blue3" + BLUE_VIOLET = "blue_violet" + BRIGHT_BLACK = "bright_black" + BRIGHT_BLUE = "bright_blue" + BRIGHT_CYAN = "bright_cyan" + BRIGHT_GREEN = "bright_green" + BRIGHT_MAGENTA = "bright_magenta" + BRIGHT_RED = "bright_red" + BRIGHT_WHITE = "bright_white" + BRIGHT_YELLOW = "bright_yellow" + CADET_BLUE = "cadet_blue" + CHARTREUSE1 = "chartreuse1" + CHARTREUSE2 = "chartreuse2" + CHARTREUSE3 = "chartreuse3" + CHARTREUSE4 = "chartreuse4" + CORNFLOWER_BLUE = "cornflower_blue" + CORNSILK1 = "cornsilk1" + CYAN = "cyan" + CYAN1 = "cyan1" + CYAN2 = "cyan2" + CYAN3 = "cyan3" + DARK_BLUE = "dark_blue" + DARK_CYAN = "dark_cyan" + DARK_GOLDENROD = "dark_goldenrod" + DARK_GREEN = "dark_green" + DARK_KHAKI = "dark_khaki" + DARK_MAGENTA = "dark_magenta" + DARK_OLIVE_GREEN1 = "dark_olive_green1" + DARK_OLIVE_GREEN2 = "dark_olive_green2" + DARK_OLIVE_GREEN3 = "dark_olive_green3" + DARK_ORANGE = "dark_orange" + DARK_ORANGE3 = "dark_orange3" + DARK_RED = "dark_red" + DARK_SEA_GREEN = "dark_sea_green" + DARK_SEA_GREEN1 = "dark_sea_green1" + DARK_SEA_GREEN2 = "dark_sea_green2" + DARK_SEA_GREEN3 = "dark_sea_green3" + DARK_SEA_GREEN4 = "dark_sea_green4" + DARK_SLATE_GRAY1 = "dark_slate_gray1" + DARK_SLATE_GRAY2 = "dark_slate_gray2" + DARK_SLATE_GRAY3 = "dark_slate_gray3" + DARK_TURQUOISE = "dark_turquoise" + DARK_VIOLET = "dark_violet" + DEEP_PINK1 = "deep_pink1" + DEEP_PINK2 = "deep_pink2" + DEEP_PINK3 = "deep_pink3" + DEEP_PINK4 = "deep_pink4" + DEEP_SKY_BLUE1 = "deep_sky_blue1" + DEEP_SKY_BLUE2 = "deep_sky_blue2" + DEEP_SKY_BLUE3 = "deep_sky_blue3" + DEEP_SKY_BLUE4 = "deep_sky_blue4" + DODGER_BLUE1 = "dodger_blue1" + DODGER_BLUE2 = "dodger_blue2" + DODGER_BLUE3 = "dodger_blue3" + GOLD1 = "gold1" + GOLD3 = "gold3" + GRAY0 = "gray0" + GRAY3 = "gray3" + GRAY7 = "gray7" + GRAY11 = "gray11" + GRAY15 = "gray15" + GRAY19 = "gray19" + GRAY23 = "gray23" + GRAY27 = "gray27" + GRAY30 = "gray30" + GRAY35 = "gray35" + GRAY37 = "gray37" + GRAY39 = "gray39" + GRAY42 = "gray42" + GRAY46 = "gray46" + GRAY50 = "gray50" + GRAY53 = "gray53" + GRAY54 = "gray54" + GRAY58 = "gray58" + GRAY62 = "gray62" + GRAY63 = "gray63" + GRAY66 = "gray66" + GRAY69 = "gray69" + GRAY70 = "gray70" + GRAY74 = "gray74" + GRAY78 = "gray78" + GRAY82 = "gray82" + GRAY84 = "gray84" + GRAY85 = "gray85" + GRAY89 = "gray89" + GRAY93 = "gray93" + GRAY100 = "gray100" + GREEN = "green" + GREEN1 = "green1" + GREEN3 = "green3" + GREEN4 = "green4" + GREEN_YELLOW = "green_yellow" + GREY0 = "grey0" + GREY3 = "grey3" + GREY7 = "grey7" + GREY11 = "grey11" + GREY15 = "grey15" + GREY19 = "grey19" + GREY23 = "grey23" + GREY27 = "grey27" + GREY30 = "grey30" + GREY35 = "grey35" + GREY37 = "grey37" + GREY39 = "grey39" + GREY42 = "grey42" + GREY46 = "grey46" + GREY50 = "grey50" + GREY53 = "grey53" + GREY54 = "grey54" + GREY58 = "grey58" + GREY62 = "grey62" + GREY63 = "grey63" + GREY66 = "grey66" + GREY69 = "grey69" + GREY70 = "grey70" + GREY74 = "grey74" + GREY78 = "grey78" + GREY82 = "grey82" + GREY84 = "grey84" + GREY85 = "grey85" + GREY89 = "grey89" + GREY93 = "grey93" + GREY100 = "grey100" + HONEYDEW2 = "honeydew2" + HOT_PINK = "hot_pink" + HOT_PINK2 = "hot_pink2" + HOT_PINK3 = "hot_pink3" + INDIAN_RED = "indian_red" + INDIAN_RED1 = "indian_red1" + KHAKI1 = "khaki1" + KHAKI3 = "khaki3" + LIGHT_CORAL = "light_coral" + LIGHT_CYAN1 = "light_cyan1" + LIGHT_CYAN3 = "light_cyan3" + LIGHT_GOLDENROD1 = "light_goldenrod1" + LIGHT_GOLDENROD2 = "light_goldenrod2" + LIGHT_GOLDENROD3 = "light_goldenrod3" + LIGHT_GREEN = "light_green" + LIGHT_PINK1 = "light_pink1" + LIGHT_PINK3 = "light_pink3" + LIGHT_PINK4 = "light_pink4" + LIGHT_SALMON1 = "light_salmon1" + LIGHT_SALMON3 = "light_salmon3" + LIGHT_SEA_GREEN = "light_sea_green" + LIGHT_SKY_BLUE1 = "light_sky_blue1" + LIGHT_SKY_BLUE3 = "light_sky_blue3" + LIGHT_SLATE_BLUE = "light_slate_blue" + LIGHT_SLATE_GRAY = "light_slate_gray" + LIGHT_SLATE_GREY = "light_slate_grey" + LIGHT_STEEL_BLUE = "light_steel_blue" + LIGHT_STEEL_BLUE1 = "light_steel_blue1" + LIGHT_STEEL_BLUE3 = "light_steel_blue3" + LIGHT_YELLOW3 = "light_yellow3" + MAGENTA = "magenta" + MAGENTA1 = "magenta1" + MAGENTA2 = "magenta2" + MAGENTA3 = "magenta3" + MEDIUM_ORCHID = "medium_orchid" + MEDIUM_ORCHID1 = "medium_orchid1" + MEDIUM_ORCHID3 = "medium_orchid3" + MEDIUM_PURPLE = "medium_purple" + MEDIUM_PURPLE1 = "medium_purple1" + MEDIUM_PURPLE2 = "medium_purple2" + MEDIUM_PURPLE3 = "medium_purple3" + MEDIUM_PURPLE4 = "medium_purple4" + MEDIUM_SPRING_GREEN = "medium_spring_green" + MEDIUM_TURQUOISE = "medium_turquoise" + MEDIUM_VIOLET_RED = "medium_violet_red" + MISTY_ROSE1 = "misty_rose1" + MISTY_ROSE3 = "misty_rose3" + NAVAJO_WHITE1 = "navajo_white1" + NAVAJO_WHITE3 = "navajo_white3" + NAVY_BLUE = "navy_blue" + ORANGE1 = "orange1" + ORANGE3 = "orange3" + ORANGE4 = "orange4" + ORANGE_RED1 = "orange_red1" + ORCHID = "orchid" + ORCHID1 = "orchid1" + ORCHID2 = "orchid2" + PALE_GREEN1 = "pale_green1" + PALE_GREEN3 = "pale_green3" + PALE_TURQUOISE1 = "pale_turquoise1" + PALE_TURQUOISE4 = "pale_turquoise4" + PALE_VIOLET_RED1 = "pale_violet_red1" + PINK1 = "pink1" + PINK3 = "pink3" + PLUM1 = "plum1" + PLUM2 = "plum2" + PLUM3 = "plum3" + PLUM4 = "plum4" + PURPLE = "purple" + PURPLE3 = "purple3" + PURPLE4 = "purple4" + RED = "red" + RED1 = "red1" + RED3 = "red3" + ROSY_BROWN = "rosy_brown" + ROYAL_BLUE1 = "royal_blue1" + SALMON1 = "salmon1" + SANDY_BROWN = "sandy_brown" + SEA_GREEN1 = "sea_green1" + SEA_GREEN2 = "sea_green2" + SEA_GREEN3 = "sea_green3" + SKY_BLUE1 = "sky_blue1" + SKY_BLUE2 = "sky_blue2" + SKY_BLUE3 = "sky_blue3" + SLATE_BLUE1 = "slate_blue1" + SLATE_BLUE3 = "slate_blue3" + SPRING_GREEN1 = "spring_green1" + SPRING_GREEN2 = "spring_green2" + SPRING_GREEN3 = "spring_green3" + SPRING_GREEN4 = "spring_green4" + STEEL_BLUE = "steel_blue" + STEEL_BLUE1 = "steel_blue1" + STEEL_BLUE3 = "steel_blue3" + TAN = "tan" + THISTLE1 = "thistle1" + THISTLE3 = "thistle3" + TURQUOISE2 = "turquoise2" + TURQUOISE4 = "turquoise4" + VIOLET = "violet" + WHEAT1 = "wheat1" + WHEAT4 = "wheat4" + WHITE = "white" + YELLOW = "yellow" + YELLOW1 = "yellow1" + YELLOW2 = "yellow2" + YELLOW3 = "yellow3" + YELLOW4 = "yellow4" diff --git a/cmd2/constants.py b/cmd2/constants.py index c82b3ca1..5d3351eb 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -18,9 +18,6 @@ LINE_FEED = '\n' -# One character ellipsis -HORIZONTAL_ELLIPSIS = '…' - DEFAULT_SHORTCUTS = {'?': 'help', '!': 'shell', '@': 'run_script', '@@': '_relative_run_script'} # Used as the command name placeholder in disabled command messages. @@ -55,6 +52,3 @@ # custom attributes added to argparse Namespaces NS_ATTR_SUBCMD_HANDLER = '__subcmd_handler__' - -# For cases prior to Python 3.11 when shutil.get_terminal_size().columns can return 0. -DEFAULT_TERMINAL_WIDTH = 80 diff --git a/cmd2/exceptions.py b/cmd2/exceptions.py index 5d0cd190..052c93ee 100644 --- a/cmd2/exceptions.py +++ b/cmd2/exceptions.py @@ -40,7 +40,7 @@ class CompletionError(Exception): def __init__(self, *args: Any, apply_style: bool = True) -> None: """Initialize CompletionError instance. - :param apply_style: If True, then ansi.style_error will be applied to the message text when printed. + :param apply_style: If True, then styles.ERROR will be applied to the message text when printed. Set to False in cases where the message text already has the desired style. Defaults to True. """ diff --git a/cmd2/history.py b/cmd2/history.py index 6124c30c..e2bd67df 100644 --- a/cmd2/history.py +++ b/cmd2/history.py @@ -14,9 +14,7 @@ overload, ) -from . import ( - utils, -) +from . import string_utils as su from .parsing import ( Statement, shlex_split, @@ -287,9 +285,9 @@ def str_search(self, search: str, include_persisted: bool = False) -> 'OrderedDi def isin(history_item: HistoryItem) -> bool: """Filter function for string search of history.""" - sloppy = utils.norm_fold(search) - inraw = sloppy in utils.norm_fold(history_item.raw) - inexpanded = sloppy in utils.norm_fold(history_item.expanded) + sloppy = su.norm_fold(search) + inraw = sloppy in su.norm_fold(history_item.raw) + inexpanded = sloppy in su.norm_fold(history_item.expanded) return inraw or inexpanded start = 0 if include_persisted else self.session_start_index diff --git a/cmd2/parsing.py b/cmd2/parsing.py index 75e6fa41..8a6acb08 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -7,17 +7,14 @@ dataclass, field, ) -from typing import ( - Any, -) +from typing import Any from . import ( constants, utils, ) -from .exceptions import ( - Cmd2ShlexError, -) +from . import string_utils as su +from .exceptions import Cmd2ShlexError def shlex_split(str_to_split: str) -> list[str]: @@ -211,8 +208,8 @@ def argv(self) -> list[str]: If you want to strip quotes from the input, you can use ``argv[1:]``. """ if self.command: - rtn = [utils.strip_quotes(self.command)] - rtn.extend(utils.strip_quotes(cur_token) for cur_token in self.arg_list) + rtn = [su.strip_quotes(self.command)] + rtn.extend(su.strip_quotes(cur_token) for cur_token in self.arg_list) else: rtn = [] @@ -488,7 +485,7 @@ def parse(self, line: str) -> Statement: # Check if we are redirecting to a file if len(tokens) > output_index + 1: - unquoted_path = utils.strip_quotes(tokens[output_index + 1]) + unquoted_path = su.strip_quotes(tokens[output_index + 1]) if unquoted_path: output_to = utils.expand_user(tokens[output_index + 1]) diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 6e7daa3a..55f0f9ae 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -1,6 +1,5 @@ """Provides common utilities to support Rich in cmd2 applications.""" -import sys from collections.abc import Mapping from enum import Enum from typing import ( @@ -25,14 +24,11 @@ from rich.theme import Theme from rich_argparse import RichHelpFormatter -if sys.version_info >= (3, 11): - from enum import StrEnum -else: - from backports.strenum import StrEnum +from .styles import DEFAULT_CMD2_STYLES class AllowStyle(Enum): - """Values for ``cmd2.rich_utils.allow_style``.""" + """Values for ``cmd2.rich_utils.ALLOW_STYLE``.""" ALWAYS = 'Always' # Always output ANSI style sequences NEVER = 'Never' # Remove ANSI style sequences from all output @@ -48,40 +44,11 @@ def __repr__(self) -> str: # Controls when ANSI style sequences are allowed in output -allow_style = AllowStyle.TERMINAL - - -class Cmd2Style(StrEnum): - """Names of styles defined in DEFAULT_CMD2_STYLES. - - Using this enum instead of string literals prevents typos and enables IDE - autocompletion, which makes it easier to discover and use the available - styles. - """ - - ERROR = "cmd2.error" - EXAMPLE = "cmd2.example" - HELP_HEADER = "cmd2.help.header" - HELP_TITLE = "cmd2.help.title" - RULE_LINE = "cmd2.rule.line" - SUCCESS = "cmd2.success" - WARNING = "cmd2.warning" - - -# Default styles used by cmd2 -DEFAULT_CMD2_STYLES: dict[str, StyleType] = { - Cmd2Style.ERROR: Style(color="bright_red"), - Cmd2Style.EXAMPLE: Style(color="cyan", bold=True), - Cmd2Style.HELP_HEADER: Style(color="cyan", bold=True), - Cmd2Style.HELP_TITLE: Style(color="bright_green", bold=True), - Cmd2Style.RULE_LINE: Style(color="bright_green"), - Cmd2Style.SUCCESS: Style(color="green"), - Cmd2Style.WARNING: Style(color="bright_yellow"), -} +ALLOW_STYLE = AllowStyle.TERMINAL class Cmd2Theme(Theme): - """Rich theme class used by Cmd2Console.""" + """Rich theme class used by cmd2.""" def __init__(self, styles: Mapping[str, StyleType] | None = None) -> None: """Cmd2Theme initializer. @@ -106,7 +73,7 @@ def __init__(self, styles: Mapping[str, StyleType] | None = None) -> None: def set_theme(new_theme: Cmd2Theme) -> None: - """Set the Rich theme used by Cmd2Console and rich-argparse. + """Set the Rich theme used by cmd2. :param new_theme: new theme to use. """ @@ -148,20 +115,22 @@ class RichPrintKwargs(TypedDict, total=False): class Cmd2Console(Console): """Rich console with characteristics appropriate for cmd2 applications.""" - def __init__(self, file: IO[str]) -> None: + def __init__(self, file: IO[str] | None = None) -> None: """Cmd2Console initializer. - :param file: a file object where the console should write to + :param file: Optional file object where the console should write to. Defaults to sys.stdout. """ - kwargs: dict[str, Any] = {} - if allow_style == AllowStyle.ALWAYS: - kwargs["force_terminal"] = True + force_terminal: bool | None = None + force_interactive: bool | None = None + + if ALLOW_STYLE == AllowStyle.ALWAYS: + force_terminal = True # Turn off interactive mode if dest is not actually a terminal which supports it tmp_console = Console(file=file) - kwargs["force_interactive"] = tmp_console.is_interactive - elif allow_style == AllowStyle.NEVER: - kwargs["force_terminal"] = False + force_interactive = tmp_console.is_interactive + elif ALLOW_STYLE == AllowStyle.NEVER: + force_terminal = False # Configure console defaults to treat output as plain, unstructured text. # This involves enabling soft wrapping (no automatic word-wrap) and disabling @@ -172,12 +141,13 @@ def __init__(self, file: IO[str]) -> None: # in individual Console.print() calls or via cmd2's print methods. super().__init__( file=file, + force_terminal=force_terminal, + force_interactive=force_interactive, soft_wrap=True, markup=False, emoji=False, highlight=False, theme=THEME, - **kwargs, ) def on_broken_pipe(self) -> None: @@ -186,8 +156,39 @@ def on_broken_pipe(self) -> None: raise BrokenPipeError -def from_ansi(text: str) -> Text: - r"""Patched version of rich.Text.from_ansi() that handles a discarded newline issue. +def console_width() -> int: + """Return the width of the console.""" + return Cmd2Console().width + + +def rich_text_to_string(text: Text) -> str: + """Convert a Rich Text object to a string. + + This function's purpose is to render a Rich Text object, including any styles (e.g., color, bold), + to a plain Python string with ANSI escape codes. It differs from `text.plain`, which strips + all formatting. + + :param text: the text object to convert + :return: the resulting string with ANSI styles preserved. + """ + console = Console( + force_terminal=True, + soft_wrap=True, + no_color=False, + markup=False, + emoji=False, + highlight=False, + theme=THEME, + ) + with console.capture() as capture: + console.print(text, end="") + return capture.get() + + +def string_to_rich_text(text: str) -> Text: + r"""Create a Text object from a string which can contain ANSI escape codes. + + This wraps rich.Text.from_ansi() to handle a discarded newline issue. Text.from_ansi() currently removes the ending line break from string. e.g. "Hello\n" becomes "Hello" @@ -237,5 +238,5 @@ def prepare_objects_for_rich_print(*objects: Any) -> tuple[RenderableType, ...]: object_list = list(objects) for i, obj in enumerate(object_list): if not isinstance(obj, (ConsoleRenderable, RichCast)): - object_list[i] = from_ansi(str(obj)) + object_list[i] = string_to_rich_text(str(obj)) return tuple(object_list) diff --git a/cmd2/string_utils.py b/cmd2/string_utils.py new file mode 100644 index 00000000..1405b5f5 --- /dev/null +++ b/cmd2/string_utils.py @@ -0,0 +1,166 @@ +"""Provides string utility functions. + +This module offers a collection of string utility functions built on the Rich library. +These utilities are designed to correctly handle strings with complex formatting, such as +ANSI escape codes and full-width characters (like those used in CJK languages), which the +standard Python library's string methods do not properly support. +""" + +from rich.align import AlignMethod +from rich.style import StyleType + +from . import rich_utils as ru + + +def align( + val: str, + align: AlignMethod, + width: int | None = None, + character: str = " ", +) -> str: + """Align string to a given width. + + There are convenience wrappers around this function: align_left(), align_center(), and align_right() + + :param val: string to align + :param align: one of "left", "center", or "right". + :param width: Desired width. Defaults to width of the terminal. + :param character: Character to pad with. Defaults to " ". + + """ + if width is None: + width = ru.console_width() + + text = ru.string_to_rich_text(val) + text.align(align, width=width, character=character) + return ru.rich_text_to_string(text) + + +def align_left( + val: str, + width: int | None = None, + character: str = " ", +) -> str: + """Left-align string to a given width. + + :param val: string to align + :param width: Desired width. Defaults to width of the terminal. + :param character: Character to pad with. Defaults to " ". + + """ + return align(val, "left", width=width, character=character) + + +def align_center( + val: str, + width: int | None = None, + character: str = " ", +) -> str: + """Center-align string to a given width. + + :param val: string to align + :param width: Desired width. Defaults to width of the terminal. + :param character: Character to pad with. Defaults to " ". + + """ + return align(val, "center", width=width, character=character) + + +def align_right( + val: str, + width: int | None = None, + character: str = " ", +) -> str: + """Right-align string to a given width. + + :param val: string to align + :param width: Desired width. Defaults to width of the terminal. + :param character: Character to pad with. Defaults to " ". + + """ + return align(val, "right", width=width, character=character) + + +def stylize(val: str, style: StyleType) -> str: + """Apply ANSI style to a string. + + :param val: string to be styled + :param style: style instance or style definition to apply. + :return: the stylized string + """ + text = ru.string_to_rich_text(val) + text.stylize(style) + return ru.rich_text_to_string(text) + + +def strip_style(val: str) -> str: + """Strip ANSI style sequences from a string. + + :param val: string which may contain ANSI style sequences + :return: the same string with any ANSI style sequences removed + """ + text = ru.string_to_rich_text(val) + return text.plain + + +def str_width(val: str) -> int: + """Return the display width of a string. + + This is intended for single line strings. + Replace tabs with spaces before calling this. + + :param val: the string being measured + :return: width of the string when printed to the terminal + """ + text = ru.string_to_rich_text(val) + return text.cell_len + + +def is_quoted(val: str) -> bool: + """Check if a string is quoted. + + :param val: the string being checked for quotes + :return: True if a string is quoted + """ + from . import constants + + return len(val) > 1 and val[0] == val[-1] and val[0] in constants.QUOTES + + +def quote(val: str) -> str: + """Quote a string.""" + quote = "'" if '"' in val else '"' + + return quote + val + quote + + +def quote_if_needed(val: str) -> str: + """Quote a string if it contains spaces and isn't already quoted.""" + if is_quoted(val) or ' ' not in val: + return val + + return quote(val) + + +def strip_quotes(val: str) -> str: + """Strip outer quotes from a string. + + Applies to both single and double quotes. + + :param val: string to strip outer quotes from + :return: same string with potentially outer quotes stripped + """ + if is_quoted(val): + val = val[1:-1] + return val + + +def norm_fold(val: str) -> str: + """Normalize and casefold Unicode strings for saner comparisons. + + :param val: input unicode string + :return: a normalized and case-folded version of the input string + """ + import unicodedata + + return unicodedata.normalize('NFC', val).casefold() diff --git a/cmd2/styles.py b/cmd2/styles.py new file mode 100644 index 00000000..f11ab724 --- /dev/null +++ b/cmd2/styles.py @@ -0,0 +1,51 @@ +"""Defines custom Rich styles and their corresponding names for cmd2. + +This module provides a centralized and discoverable way to manage Rich styles used +within the cmd2 framework. It defines a StrEnum for style names and a dictionary +that maps these names to their default style objects. +""" + +import sys + +from rich.style import ( + Style, + StyleType, +) + +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + from backports.strenum import StrEnum + +from .colors import Color + + +class Cmd2Style(StrEnum): + """An enumeration of the names of custom Rich styles used in cmd2. + + Using this enum allows for autocompletion and prevents typos when + referencing cmd2-specific styles. + + This StrEnum is tightly coupled with DEFAULT_CMD2_STYLES. Any name + added here must have a corresponding style definition there. + """ + + ERROR = "cmd2.error" + EXAMPLE = "cmd2.example" + HELP_HEADER = "cmd2.help.header" + HELP_TITLE = "cmd2.help.title" + RULE_LINE = "cmd2.rule.line" + SUCCESS = "cmd2.success" + WARNING = "cmd2.warning" + + +# Default styles used by cmd2. Tightly coupled with the Cmd2Style enum. +DEFAULT_CMD2_STYLES: dict[str, StyleType] = { + Cmd2Style.ERROR: Style(color=Color.BRIGHT_RED), + Cmd2Style.EXAMPLE: Style(color=Color.CYAN, bold=True), + Cmd2Style.HELP_HEADER: Style(color=Color.CYAN, bold=True), + Cmd2Style.HELP_TITLE: Style(color=Color.BRIGHT_GREEN, bold=True), + Cmd2Style.RULE_LINE: Style(color=Color.BRIGHT_GREEN), + Cmd2Style.SUCCESS: Style(color=Color.GREEN), + Cmd2Style.WARNING: Style(color=Color.BRIGHT_YELLOW), +} diff --git a/cmd2/table_creator.py b/cmd2/table_creator.py deleted file mode 100644 index df1a722b..00000000 --- a/cmd2/table_creator.py +++ /dev/null @@ -1,1121 +0,0 @@ -"""cmd2 table creation API. - -This API is built upon two core classes: Column and TableCreator -The general use case is to inherit from TableCreator to create a table class with custom formatting options. -There are already implemented and ready-to-use examples of this below TableCreator's code. -""" - -import copy -import io -from collections import ( - deque, -) -from collections.abc import Sequence -from enum import ( - Enum, -) -from typing import ( - Any, -) - -from wcwidth import ( # type: ignore[import] - wcwidth, -) - -from . import ( - ansi, - constants, - utils, -) - -# Constants -EMPTY = '' -SPACE = ' ' - - -class HorizontalAlignment(Enum): - """Horizontal alignment of text in a cell.""" - - LEFT = 1 - CENTER = 2 - RIGHT = 3 - - -class VerticalAlignment(Enum): - """Vertical alignment of text in a cell.""" - - TOP = 1 - MIDDLE = 2 - BOTTOM = 3 - - -class Column: - """Table column configuration.""" - - def __init__( - self, - header: str, - *, - width: int | None = None, - header_horiz_align: HorizontalAlignment = HorizontalAlignment.LEFT, - header_vert_align: VerticalAlignment = VerticalAlignment.BOTTOM, - style_header_text: bool = True, - data_horiz_align: HorizontalAlignment = HorizontalAlignment.LEFT, - data_vert_align: VerticalAlignment = VerticalAlignment.TOP, - style_data_text: bool = True, - max_data_lines: float = constants.INFINITY, - ) -> None: - """Column initializer. - - :param header: label for column header - :param width: display width of column. This does not account for any borders or padding which - may be added (e.g pre_line, inter_cell, and post_line). Header and data text wrap within - this width using word-based wrapping (defaults to actual width of header or 1 if header is blank) - :param header_horiz_align: horizontal alignment of header cells (defaults to left) - :param header_vert_align: vertical alignment of header cells (defaults to bottom) - :param style_header_text: if True, then the table is allowed to apply styles to the header text, which may - conflict with any styles the header already has. If False, the header is printed as is. - Table classes which apply style to headers must account for the value of this flag. - (defaults to True) - :param data_horiz_align: horizontal alignment of data cells (defaults to left) - :param data_vert_align: vertical alignment of data cells (defaults to top) - :param style_data_text: if True, then the table is allowed to apply styles to the data text, which may - conflict with any styles the data already has. If False, the data is printed as is. - Table classes which apply style to data must account for the value of this flag. - (defaults to True) - :param max_data_lines: maximum lines allowed in a data cell. If line count exceeds this, then the final - line displayed will be truncated with an ellipsis. (defaults to INFINITY) - :raises ValueError: if width is less than 1 - :raises ValueError: if max_data_lines is less than 1 - """ - self.header = header - - if width is not None and width < 1: - raise ValueError("Column width cannot be less than 1") - self.width: int = width if width is not None else -1 - - self.header_horiz_align = header_horiz_align - self.header_vert_align = header_vert_align - self.style_header_text = style_header_text - - self.data_horiz_align = data_horiz_align - self.data_vert_align = data_vert_align - self.style_data_text = style_data_text - - if max_data_lines < 1: - raise ValueError("Max data lines cannot be less than 1") - - self.max_data_lines = max_data_lines - - -class TableCreator: - """Base table creation class. - - This class handles ANSI style sequences and characters with display widths greater than 1 - when performing width calculations. It was designed with the ability to build tables one row at a time. This helps - when you have large data sets that you don't want to hold in memory or when you receive portions of the data set - incrementally. - - TableCreator has one public method: generate_row() - - This function and the Column class provide all features needed to build tables with headers, borders, colors, - horizontal and vertical alignment, and wrapped text. However, it's generally easier to inherit from this class and - implement a more granular API rather than use TableCreator directly. There are ready-to-use examples of this - defined after this class. - """ - - def __init__(self, cols: Sequence[Column], *, tab_width: int = 4) -> None: - """TableCreator initializer. - - :param cols: column definitions for this table - :param tab_width: all tabs will be replaced with this many spaces. If a row's fill_char is a tab, - then it will be converted to one space. - :raises ValueError: if tab_width is less than 1 - """ - if tab_width < 1: - raise ValueError("Tab width cannot be less than 1") - - self.cols = copy.copy(cols) - self.tab_width = tab_width - - for col in self.cols: - # Replace tabs before calculating width of header strings - col.header = col.header.replace('\t', SPACE * self.tab_width) - - # For headers with the width not yet set, use the width of the - # widest line in the header or 1 if the header has no width - if col.width <= 0: - col.width = max(1, ansi.widest_line(col.header)) - - @staticmethod - def _wrap_long_word(word: str, max_width: int, max_lines: float, is_last_word: bool) -> tuple[str, int, int]: - """Wrap a long word over multiple lines, used by _wrap_text(). - - :param word: word being wrapped - :param max_width: maximum display width of a line - :param max_lines: maximum lines to wrap before ending the last line displayed with an ellipsis - :param is_last_word: True if this is the last word of the total text being wrapped - :return: Tuple(wrapped text, lines used, display width of last line) - """ - styles_dict = utils.get_styles_dict(word) - wrapped_buf = io.StringIO() - - # How many lines we've used - total_lines = 1 - - # Display width of the current line we are building - cur_line_width = 0 - - char_index = 0 - while char_index < len(word): - # We've reached the last line. Let truncate_line do the rest. - if total_lines == max_lines: - # If this isn't the last word, but it's gonna fill the final line, then force truncate_line - # to place an ellipsis at the end of it by making the word too wide. - remaining_word = word[char_index:] - if not is_last_word and ansi.style_aware_wcswidth(remaining_word) == max_width: - remaining_word += "EXTRA" - - truncated_line = utils.truncate_line(remaining_word, max_width) - cur_line_width = ansi.style_aware_wcswidth(truncated_line) - wrapped_buf.write(truncated_line) - break - - # Check if we're at a style sequence. These don't count toward display width. - if char_index in styles_dict: - wrapped_buf.write(styles_dict[char_index]) - char_index += len(styles_dict[char_index]) - continue - - cur_char = word[char_index] - cur_char_width = wcwidth(cur_char) - - if cur_char_width > max_width: - # We have a case where the character is wider than max_width. This can happen if max_width - # is 1 and the text contains wide characters (e.g. East Asian). Replace it with an ellipsis. - cur_char = constants.HORIZONTAL_ELLIPSIS - cur_char_width = wcwidth(cur_char) - - if cur_line_width + cur_char_width > max_width: - # Adding this char will exceed the max_width. Start a new line. - wrapped_buf.write('\n') - total_lines += 1 - cur_line_width = 0 - continue - - # Add this character and move to the next one - cur_line_width += cur_char_width - wrapped_buf.write(cur_char) - char_index += 1 - - return wrapped_buf.getvalue(), total_lines, cur_line_width - - @staticmethod - def _wrap_text(text: str, max_width: int, max_lines: float) -> str: - """Wrap text into lines with a display width no longer than max_width. - - This function breaks words on whitespace boundaries. If a word is longer than the space remaining on a line, - then it will start on a new line. ANSI escape sequences do not count toward the width of a line. - - :param text: text to be wrapped - :param max_width: maximum display width of a line - :param max_lines: maximum lines to wrap before ending the last line displayed with an ellipsis - :return: wrapped text - """ - # MyPy Issue #7057 documents regression requiring nonlocals to be defined earlier - cur_line_width = 0 - total_lines = 0 - - def add_word(word_to_add: str, is_last_word: bool) -> None: - """Aadd a word to the wrapped text, called from loop. - - :param word_to_add: the word being added - :param is_last_word: True if this is the last word of the total text being wrapped - """ - nonlocal cur_line_width - nonlocal total_lines - - # No more space to add word - if total_lines == max_lines and cur_line_width == max_width: - return - - word_width = ansi.style_aware_wcswidth(word_to_add) - - # If the word is wider than max width of a line, attempt to start it on its own line and wrap it - if word_width > max_width: - room_to_add = True - - if cur_line_width > 0: - # The current line already has text, check if there is room to create a new line - if total_lines < max_lines: - wrapped_buf.write('\n') - total_lines += 1 - else: - # We will truncate this word on the remaining line - room_to_add = False - - if room_to_add: - wrapped_word, lines_used, cur_line_width = TableCreator._wrap_long_word( - word_to_add, max_width, max_lines - total_lines + 1, is_last_word - ) - # Write the word to the buffer - wrapped_buf.write(wrapped_word) - total_lines += lines_used - 1 - return - - # We aren't going to wrap the word across multiple lines - remaining_width = max_width - cur_line_width - - # Check if we need to start a new line - if word_width > remaining_width and total_lines < max_lines: - # Save the last character in wrapped_buf, which can't be empty at this point. - seek_pos = wrapped_buf.tell() - 1 - wrapped_buf.seek(seek_pos) - last_char = wrapped_buf.read() - - wrapped_buf.write('\n') - total_lines += 1 - cur_line_width = 0 - remaining_width = max_width - - # Only when a space is following a space do we want to start the next line with it. - if word_to_add == SPACE and last_char != SPACE: - return - - # Check if we've hit the last line we're allowed to create - if total_lines == max_lines: - # If this word won't fit, truncate it - if word_width > remaining_width: - word_to_add = utils.truncate_line(word_to_add, remaining_width) - word_width = remaining_width - - # If this isn't the last word, but it's gonna fill the final line, then force truncate_line - # to place an ellipsis at the end of it by making the word too wide. - elif not is_last_word and word_width == remaining_width: - word_to_add = utils.truncate_line(word_to_add + "EXTRA", remaining_width) - - cur_line_width += word_width - wrapped_buf.write(word_to_add) - - ############################################################################################################ - # _wrap_text() main code - ############################################################################################################ - # Buffer of the wrapped text - wrapped_buf = io.StringIO() - - # How many lines we've used - total_lines = 0 - - # Respect the existing line breaks - data_str_lines = text.splitlines() - for data_line_index, data_line in enumerate(data_str_lines): - total_lines += 1 - - if data_line_index > 0: - wrapped_buf.write('\n') - - # If the last line is empty, then add a newline and stop - if data_line_index == len(data_str_lines) - 1 and not data_line: - wrapped_buf.write('\n') - break - - # Locate the styles in this line - styles_dict = utils.get_styles_dict(data_line) - - # Display width of the current line we are building - cur_line_width = 0 - - # Current word being built - cur_word_buf = io.StringIO() - - char_index = 0 - while char_index < len(data_line): - if total_lines == max_lines and cur_line_width == max_width: - break - - # Check if we're at a style sequence. These don't count toward display width. - if char_index in styles_dict: - cur_word_buf.write(styles_dict[char_index]) - char_index += len(styles_dict[char_index]) - continue - - cur_char = data_line[char_index] - if cur_char == SPACE: - # If we've reached the end of a word, then add the word to the wrapped text - if cur_word_buf.tell() > 0: - # is_last_word is False since there is a space after the word - add_word(cur_word_buf.getvalue(), is_last_word=False) - cur_word_buf = io.StringIO() - - # Add the space to the wrapped text - last_word = data_line_index == len(data_str_lines) - 1 and char_index == len(data_line) - 1 - add_word(cur_char, last_word) - else: - # Add this character to the word buffer - cur_word_buf.write(cur_char) - - char_index += 1 - - # Add the final word of this line if it's been started - if cur_word_buf.tell() > 0: - last_word = data_line_index == len(data_str_lines) - 1 and char_index == len(data_line) - add_word(cur_word_buf.getvalue(), last_word) - - # Stop line loop if we've written to max_lines - if total_lines == max_lines: - # If this isn't the last data line and there is space - # left on the final wrapped line, then add an ellipsis - if data_line_index < len(data_str_lines) - 1 and cur_line_width < max_width: - wrapped_buf.write(constants.HORIZONTAL_ELLIPSIS) - break - - return wrapped_buf.getvalue() - - def _generate_cell_lines(self, cell_data: Any, is_header: bool, col: Column, fill_char: str) -> tuple[deque[str], int]: - """Generate the lines of a table cell. - - :param cell_data: data to be included in cell - :param is_header: True if writing a header cell, otherwise writing a data cell. This determines whether to - use header or data alignment settings as well as maximum lines to wrap. - :param col: Column definition for this cell - :param fill_char: character that fills remaining space in a cell. If your text has a background color, - then give fill_char the same background color. (Cannot be a line breaking character) - :return: Tuple(deque of cell lines, display width of the cell) - """ - # Convert data to string and replace tabs with spaces - data_str = str(cell_data).replace('\t', SPACE * self.tab_width) - - # Wrap text in this cell - max_lines = constants.INFINITY if is_header else col.max_data_lines - wrapped_text = self._wrap_text(data_str, col.width, max_lines) - - # Align the text horizontally - horiz_alignment = col.header_horiz_align if is_header else col.data_horiz_align - if horiz_alignment == HorizontalAlignment.LEFT: - text_alignment = utils.TextAlignment.LEFT - elif horiz_alignment == HorizontalAlignment.CENTER: - text_alignment = utils.TextAlignment.CENTER - else: - text_alignment = utils.TextAlignment.RIGHT - - aligned_text = utils.align_text(wrapped_text, fill_char=fill_char, width=col.width, alignment=text_alignment) - - # Calculate cell_width first to avoid having 2 copies of aligned_text.splitlines() in memory - cell_width = ansi.widest_line(aligned_text) - lines = deque(aligned_text.splitlines()) - - return lines, cell_width - - def generate_row( - self, - row_data: Sequence[Any], - is_header: bool, - *, - fill_char: str = SPACE, - pre_line: str = EMPTY, - inter_cell: str = (2 * SPACE), - post_line: str = EMPTY, - ) -> str: - """Generate a header or data table row. - - :param row_data: data with an entry for each column in the row - :param is_header: True if writing a header cell, otherwise writing a data cell. This determines whether to - use header or data alignment settings as well as maximum lines to wrap. - :param fill_char: character that fills remaining space in a cell. Defaults to space. If this is a tab, - then it will be converted to one space. (Cannot be a line breaking character) - :param pre_line: string to print before each line of a row. This can be used for a left row border and - padding before the first cell's text. (Defaults to blank) - :param inter_cell: string to print where two cells meet. This can be used for a border between cells and padding - between it and the 2 cells' text. (Defaults to 2 spaces) - :param post_line: string to print after each line of a row. This can be used for padding after - the last cell's text and a right row border. (Defaults to blank) - :return: row string - :raises ValueError: if row_data isn't the same length as self.cols - :raises TypeError: if fill_char is more than one character (not including ANSI style sequences) - :raises ValueError: if fill_char, pre_line, inter_cell, or post_line contains an unprintable - character like a newline - """ - - class Cell: - """Inner class which represents a table cell.""" - - def __init__(self) -> None: - # Data in this cell split into individual lines - self.lines: deque[str] = deque() - - # Display width of this cell - self.width = 0 - - if len(row_data) != len(self.cols): - raise ValueError("Length of row_data must match length of cols") - - # Replace tabs (tabs in data strings will be handled in _generate_cell_lines()) - fill_char = fill_char.replace('\t', SPACE) - pre_line = pre_line.replace('\t', SPACE * self.tab_width) - inter_cell = inter_cell.replace('\t', SPACE * self.tab_width) - post_line = post_line.replace('\t', SPACE * self.tab_width) - - # Validate fill_char character count - if len(ansi.strip_style(fill_char)) != 1: - raise TypeError("Fill character must be exactly one character long") - - # Look for unprintable characters - validation_dict = {'fill_char': fill_char, 'pre_line': pre_line, 'inter_cell': inter_cell, 'post_line': post_line} - for key, val in validation_dict.items(): - if ansi.style_aware_wcswidth(val) == -1: - raise ValueError(f"{key} contains an unprintable character") - - # Number of lines this row uses - total_lines = 0 - - # Generate the cells for this row - cells = [] - - for col_index, col in enumerate(self.cols): - cell = Cell() - cell.lines, cell.width = self._generate_cell_lines(row_data[col_index], is_header, col, fill_char) - cells.append(cell) - total_lines = max(len(cell.lines), total_lines) - - row_buf = io.StringIO() - - # Vertically align each cell - for cell_index, cell in enumerate(cells): - col = self.cols[cell_index] - vert_align = col.header_vert_align if is_header else col.data_vert_align - - # Check if this cell need vertical filler - line_diff = total_lines - len(cell.lines) - if line_diff == 0: - continue - - # Add vertical filler lines - padding_line = utils.align_left(EMPTY, fill_char=fill_char, width=cell.width) - if vert_align == VerticalAlignment.TOP: - to_top = 0 - to_bottom = line_diff - elif vert_align == VerticalAlignment.MIDDLE: - to_top = line_diff // 2 - to_bottom = line_diff - to_top - else: - to_top = line_diff - to_bottom = 0 - - for _ in range(to_top): - cell.lines.appendleft(padding_line) - for _ in range(to_bottom): - cell.lines.append(padding_line) - - # Build this row one line at a time - for line_index in range(total_lines): - for cell_index, cell in enumerate(cells): - if cell_index == 0: - row_buf.write(pre_line) - - row_buf.write(cell.lines[line_index]) - - if cell_index < len(self.cols) - 1: - row_buf.write(inter_cell) - if cell_index == len(self.cols) - 1: - row_buf.write(post_line) - - # Add a newline if this is not the last line - if line_index < total_lines - 1: - row_buf.write('\n') - - return row_buf.getvalue() - - -############################################################################################################ -# The following are implementations of TableCreator which demonstrate how to make various types -# of tables. They can be used as-is or serve as inspiration for other custom table classes. -############################################################################################################ -class SimpleTable(TableCreator): - """Implementation of TableCreator which generates a borderless table with an optional divider row after the header. - - This class can be used to create the whole table at once or one row at a time. - """ - - def __init__( - self, - cols: Sequence[Column], - *, - column_spacing: int = 2, - tab_width: int = 4, - divider_char: str | None = '-', - header_bg: ansi.BgColor | None = None, - data_bg: ansi.BgColor | None = None, - ) -> None: - """SimpleTable initializer. - - :param cols: column definitions for this table - :param column_spacing: how many spaces to place between columns. Defaults to 2. - :param tab_width: all tabs will be replaced with this many spaces. If a row's fill_char is a tab, - then it will be converted to one space. - :param divider_char: optional character used to build the header divider row. Set this to blank or None if you don't - want a divider row. Defaults to dash. (Cannot be a line breaking character) - :param header_bg: optional background color for header cells (defaults to None) - :param data_bg: optional background color for data cells (defaults to None) - :raises ValueError: if tab_width is less than 1 - :raises ValueError: if column_spacing is less than 0 - :raises TypeError: if divider_char is longer than one character - :raises ValueError: if divider_char is an unprintable character - """ - super().__init__(cols, tab_width=tab_width) - - if column_spacing < 0: - raise ValueError("Column spacing cannot be less than 0") - - self.column_spacing = column_spacing - - if divider_char == '': - divider_char = None - - if divider_char is not None: - if len(ansi.strip_style(divider_char)) != 1: - raise TypeError("Divider character must be exactly one character long") - - divider_char_width = ansi.style_aware_wcswidth(divider_char) - if divider_char_width == -1: - raise ValueError("Divider character is an unprintable character") - - self.divider_char = divider_char - self.header_bg = header_bg - self.data_bg = data_bg - - def apply_header_bg(self, value: Any) -> str: - """If defined, apply the header background color to header text. - - :param value: object whose text is to be colored - :return: formatted text. - """ - if self.header_bg is None: - return str(value) - return ansi.style(value, bg=self.header_bg) - - def apply_data_bg(self, value: Any) -> str: - """If defined, apply the data background color to data text. - - :param value: object whose text is to be colored - :return: formatted data string. - """ - if self.data_bg is None: - return str(value) - return ansi.style(value, bg=self.data_bg) - - @classmethod - def base_width(cls, num_cols: int, *, column_spacing: int = 2) -> int: - """Calculate the display width required for a table before data is added to it. - - This is useful when determining how wide to make your columns to have a table be a specific width. - - :param num_cols: how many columns the table will have - :param column_spacing: how many spaces to place between columns. Defaults to 2. - :return: base width - :raises ValueError: if column_spacing is less than 0 - :raises ValueError: if num_cols is less than 1 - """ - if num_cols < 1: - raise ValueError("Column count cannot be less than 1") - - data_str = SPACE - data_width = ansi.style_aware_wcswidth(data_str) * num_cols - - tbl = cls([Column(data_str)] * num_cols, column_spacing=column_spacing) - data_row = tbl.generate_data_row([data_str] * num_cols) - - return ansi.style_aware_wcswidth(data_row) - data_width - - def total_width(self) -> int: - """Calculate the total display width of this table.""" - base_width = self.base_width(len(self.cols), column_spacing=self.column_spacing) - data_width = sum(col.width for col in self.cols) - return base_width + data_width - - def generate_header(self) -> str: - """Generate table header with an optional divider row.""" - header_buf = io.StringIO() - - fill_char = self.apply_header_bg(SPACE) - inter_cell = self.apply_header_bg(self.column_spacing * SPACE) - - # Apply background color to header text in Columns which allow it - to_display: list[Any] = [] - for col in self.cols: - if col.style_header_text: - to_display.append(self.apply_header_bg(col.header)) - else: - to_display.append(col.header) - - # Create the header labels - header_labels = self.generate_row(to_display, is_header=True, fill_char=fill_char, inter_cell=inter_cell) - header_buf.write(header_labels) - - # Add the divider if necessary - divider = self.generate_divider() - if divider: - header_buf.write('\n' + divider) - - return header_buf.getvalue() - - def generate_divider(self) -> str: - """Generate divider row.""" - if self.divider_char is None: - return '' - - return utils.align_left('', fill_char=self.divider_char, width=self.total_width()) - - def generate_data_row(self, row_data: Sequence[Any]) -> str: - """Generate a data row. - - :param row_data: data with an entry for each column in the row - :return: data row string - :raises ValueError: if row_data isn't the same length as self.cols - """ - if len(row_data) != len(self.cols): - raise ValueError("Length of row_data must match length of cols") - - fill_char = self.apply_data_bg(SPACE) - inter_cell = self.apply_data_bg(self.column_spacing * SPACE) - - # Apply background color to data text in Columns which allow it - to_display: list[Any] = [] - for index, col in enumerate(self.cols): - if col.style_data_text: - to_display.append(self.apply_data_bg(row_data[index])) - else: - to_display.append(row_data[index]) - - return self.generate_row(to_display, is_header=False, fill_char=fill_char, inter_cell=inter_cell) - - def generate_table(self, table_data: Sequence[Sequence[Any]], *, include_header: bool = True, row_spacing: int = 1) -> str: - """Generate a table from a data set. - - :param table_data: Data with an entry for each data row of the table. Each entry should have data for - each column in the row. - :param include_header: If True, then a header will be included at top of table. (Defaults to True) - :param row_spacing: A number 0 or greater specifying how many blank lines to place between - each row (Defaults to 1) - :raises ValueError: if row_spacing is less than 0 - """ - if row_spacing < 0: - raise ValueError("Row spacing cannot be less than 0") - - table_buf = io.StringIO() - - if include_header: - header = self.generate_header() - table_buf.write(header) - if len(table_data) > 0: - table_buf.write('\n') - - row_divider = utils.align_left('', fill_char=self.apply_data_bg(SPACE), width=self.total_width()) + '\n' - - for index, row_data in enumerate(table_data): - if index > 0 and row_spacing > 0: - table_buf.write(row_spacing * row_divider) - - row = self.generate_data_row(row_data) - table_buf.write(row) - if index < len(table_data) - 1: - table_buf.write('\n') - - return table_buf.getvalue() - - -class BorderedTable(TableCreator): - """Implementation of TableCreator which generates a table with borders around the table and between rows. - - Borders between columns can also be toggled. This class can be used to create the whole table at once or one row at a time. - """ - - def __init__( - self, - cols: Sequence[Column], - *, - tab_width: int = 4, - column_borders: bool = True, - padding: int = 1, - border_fg: ansi.FgColor | None = None, - border_bg: ansi.BgColor | None = None, - header_bg: ansi.BgColor | None = None, - data_bg: ansi.BgColor | None = None, - ) -> None: - """BorderedTable initializer. - - :param cols: column definitions for this table - :param tab_width: all tabs will be replaced with this many spaces. If a row's fill_char is a tab, - then it will be converted to one space. - :param column_borders: if True, borders between columns will be included. This gives the table a grid-like - appearance. Turning off column borders results in a unified appearance between - a row's cells. (Defaults to True) - :param padding: number of spaces between text and left/right borders of cell - :param border_fg: optional foreground color for borders (defaults to None) - :param border_bg: optional background color for borders (defaults to None) - :param header_bg: optional background color for header cells (defaults to None) - :param data_bg: optional background color for data cells (defaults to None) - :raises ValueError: if tab_width is less than 1 - :raises ValueError: if padding is less than 0 - """ - super().__init__(cols, tab_width=tab_width) - self.empty_data = [EMPTY] * len(self.cols) - self.column_borders = column_borders - - if padding < 0: - raise ValueError("Padding cannot be less than 0") - self.padding = padding - - self.border_fg = border_fg - self.border_bg = border_bg - self.header_bg = header_bg - self.data_bg = data_bg - - def apply_border_color(self, value: Any) -> str: - """If defined, apply the border foreground and background colors. - - :param value: object whose text is to be colored - :return: formatted text. - """ - if self.border_fg is None and self.border_bg is None: - return str(value) - return ansi.style(value, fg=self.border_fg, bg=self.border_bg) - - def apply_header_bg(self, value: Any) -> str: - """If defined, apply the header background color to header text. - - :param value: object whose text is to be colored - :return: formatted text. - """ - if self.header_bg is None: - return str(value) - return ansi.style(value, bg=self.header_bg) - - def apply_data_bg(self, value: Any) -> str: - """If defined, apply the data background color to data text. - - :param value: object whose text is to be colored - :return: formatted data string. - """ - if self.data_bg is None: - return str(value) - return ansi.style(value, bg=self.data_bg) - - @classmethod - def base_width(cls, num_cols: int, *, column_borders: bool = True, padding: int = 1) -> int: - """Calculate the display width required for a table before data is added to it. - - This is useful when determining how wide to make your columns to have a table be a specific width. - - :param num_cols: how many columns the table will have - :param column_borders: if True, borders between columns will be included in the calculation (Defaults to True) - :param padding: number of spaces between text and left/right borders of cell - :return: base width - :raises ValueError: if num_cols is less than 1 - """ - if num_cols < 1: - raise ValueError("Column count cannot be less than 1") - - data_str = SPACE - data_width = ansi.style_aware_wcswidth(data_str) * num_cols - - tbl = cls([Column(data_str)] * num_cols, column_borders=column_borders, padding=padding) - data_row = tbl.generate_data_row([data_str] * num_cols) - - return ansi.style_aware_wcswidth(data_row) - data_width - - def total_width(self) -> int: - """Calculate the total display width of this table.""" - base_width = self.base_width(len(self.cols), column_borders=self.column_borders, padding=self.padding) - data_width = sum(col.width for col in self.cols) - return base_width + data_width - - def generate_table_top_border(self) -> str: - """Generate a border which appears at the top of the header and data section.""" - fill_char = '═' - - pre_line = '╔' + self.padding * '═' - - inter_cell = self.padding * '═' - if self.column_borders: - inter_cell += "╤" - inter_cell += self.padding * '═' - - post_line = self.padding * '═' + '╗' - - return self.generate_row( - self.empty_data, - is_header=False, - fill_char=self.apply_border_color(fill_char), - pre_line=self.apply_border_color(pre_line), - inter_cell=self.apply_border_color(inter_cell), - post_line=self.apply_border_color(post_line), - ) - - def generate_header_bottom_border(self) -> str: - """Generate a border which appears at the bottom of the header.""" - fill_char = '═' - - pre_line = '╠' + self.padding * '═' - - inter_cell = self.padding * '═' - if self.column_borders: - inter_cell += '╪' - inter_cell += self.padding * '═' - - post_line = self.padding * '═' + '╣' - - return self.generate_row( - self.empty_data, - is_header=False, - fill_char=self.apply_border_color(fill_char), - pre_line=self.apply_border_color(pre_line), - inter_cell=self.apply_border_color(inter_cell), - post_line=self.apply_border_color(post_line), - ) - - def generate_row_bottom_border(self) -> str: - """Generate a border which appears at the bottom of rows.""" - fill_char = '─' - - pre_line = '╟' + self.padding * '─' - - inter_cell = self.padding * '─' - if self.column_borders: - inter_cell += '┼' - inter_cell += self.padding * '─' - - post_line = self.padding * '─' + '╢' - - return self.generate_row( - self.empty_data, - is_header=False, - fill_char=self.apply_border_color(fill_char), - pre_line=self.apply_border_color(pre_line), - inter_cell=self.apply_border_color(inter_cell), - post_line=self.apply_border_color(post_line), - ) - - def generate_table_bottom_border(self) -> str: - """Generate a border which appears at the bottom of the table.""" - fill_char = '═' - - pre_line = '╚' + self.padding * '═' - - inter_cell = self.padding * '═' - if self.column_borders: - inter_cell += '╧' - inter_cell += self.padding * '═' - - post_line = self.padding * '═' + '╝' - - return self.generate_row( - self.empty_data, - is_header=False, - fill_char=self.apply_border_color(fill_char), - pre_line=self.apply_border_color(pre_line), - inter_cell=self.apply_border_color(inter_cell), - post_line=self.apply_border_color(post_line), - ) - - def generate_header(self) -> str: - """Generate table header.""" - fill_char = self.apply_header_bg(SPACE) - - pre_line = self.apply_border_color('║') + self.apply_header_bg(self.padding * SPACE) - - inter_cell = self.apply_header_bg(self.padding * SPACE) - if self.column_borders: - inter_cell += self.apply_border_color('│') - inter_cell += self.apply_header_bg(self.padding * SPACE) - - post_line = self.apply_header_bg(self.padding * SPACE) + self.apply_border_color('║') - - # Apply background color to header text in Columns which allow it - to_display: list[Any] = [] - for col in self.cols: - if col.style_header_text: - to_display.append(self.apply_header_bg(col.header)) - else: - to_display.append(col.header) - - # Create the bordered header - header_buf = io.StringIO() - header_buf.write(self.generate_table_top_border()) - header_buf.write('\n') - header_buf.write( - self.generate_row( - to_display, is_header=True, fill_char=fill_char, pre_line=pre_line, inter_cell=inter_cell, post_line=post_line - ) - ) - header_buf.write('\n') - header_buf.write(self.generate_header_bottom_border()) - - return header_buf.getvalue() - - def generate_data_row(self, row_data: Sequence[Any]) -> str: - """Generate a data row. - - :param row_data: data with an entry for each column in the row - :return: data row string - :raises ValueError: if row_data isn't the same length as self.cols - """ - if len(row_data) != len(self.cols): - raise ValueError("Length of row_data must match length of cols") - - fill_char = self.apply_data_bg(SPACE) - - pre_line = self.apply_border_color('║') + self.apply_data_bg(self.padding * SPACE) - - inter_cell = self.apply_data_bg(self.padding * SPACE) - if self.column_borders: - inter_cell += self.apply_border_color('│') - inter_cell += self.apply_data_bg(self.padding * SPACE) - - post_line = self.apply_data_bg(self.padding * SPACE) + self.apply_border_color('║') - - # Apply background color to data text in Columns which allow it - to_display: list[Any] = [] - for index, col in enumerate(self.cols): - if col.style_data_text: - to_display.append(self.apply_data_bg(row_data[index])) - else: - to_display.append(row_data[index]) - - return self.generate_row( - to_display, is_header=False, fill_char=fill_char, pre_line=pre_line, inter_cell=inter_cell, post_line=post_line - ) - - def generate_table(self, table_data: Sequence[Sequence[Any]], *, include_header: bool = True) -> str: - """Generate a table from a data set. - - :param table_data: Data with an entry for each data row of the table. Each entry should have data for - each column in the row. - :param include_header: If True, then a header will be included at top of table. (Defaults to True) - """ - table_buf = io.StringIO() - - if include_header: - header = self.generate_header() - table_buf.write(header) - else: - top_border = self.generate_table_top_border() - table_buf.write(top_border) - - table_buf.write('\n') - - for index, row_data in enumerate(table_data): - if index > 0: - row_bottom_border = self.generate_row_bottom_border() - table_buf.write(row_bottom_border) - table_buf.write('\n') - - row = self.generate_data_row(row_data) - table_buf.write(row) - table_buf.write('\n') - - table_buf.write(self.generate_table_bottom_border()) - return table_buf.getvalue() - - -class AlternatingTable(BorderedTable): - """Implementation of BorderedTable which uses background colors to distinguish between rows instead of row border lines. - - This class can be used to create the whole table at once or one row at a time. - - To nest an AlternatingTable within another AlternatingTable, set style_data_text to False on the Column - which contains the nested table. That will prevent the current row's background color from affecting the colors - of the nested table. - """ - - def __init__( - self, - cols: Sequence[Column], - *, - tab_width: int = 4, - column_borders: bool = True, - padding: int = 1, - border_fg: ansi.FgColor | None = None, - border_bg: ansi.BgColor | None = None, - header_bg: ansi.BgColor | None = None, - odd_bg: ansi.BgColor | None = None, - even_bg: ansi.BgColor | None = ansi.Bg.DARK_GRAY, - ) -> None: - """AlternatingTable initializer. - - Note: Specify background colors using subclasses of BgColor (e.g. Bg, EightBitBg, RgbBg) - - :param cols: column definitions for this table - :param tab_width: all tabs will be replaced with this many spaces. If a row's fill_char is a tab, - then it will be converted to one space. - :param column_borders: if True, borders between columns will be included. This gives the table a grid-like - appearance. Turning off column borders results in a unified appearance between - a row's cells. (Defaults to True) - :param padding: number of spaces between text and left/right borders of cell - :param border_fg: optional foreground color for borders (defaults to None) - :param border_bg: optional background color for borders (defaults to None) - :param header_bg: optional background color for header cells (defaults to None) - :param odd_bg: optional background color for odd numbered data rows (defaults to None) - :param even_bg: optional background color for even numbered data rows (defaults to StdBg.DARK_GRAY) - :raises ValueError: if tab_width is less than 1 - :raises ValueError: if padding is less than 0 - """ - super().__init__( - cols, - tab_width=tab_width, - column_borders=column_borders, - padding=padding, - border_fg=border_fg, - border_bg=border_bg, - header_bg=header_bg, - ) - self.row_num = 1 - self.odd_bg = odd_bg - self.even_bg = even_bg - - def apply_data_bg(self, value: Any) -> str: - """Apply background color to data text based on what row is being generated and whether a color has been defined. - - :param value: object whose text is to be colored - :return: formatted data string. - """ - if self.row_num % 2 == 0 and self.even_bg is not None: - return ansi.style(value, bg=self.even_bg) - if self.row_num % 2 != 0 and self.odd_bg is not None: - return ansi.style(value, bg=self.odd_bg) - return str(value) - - def generate_data_row(self, row_data: Sequence[Any]) -> str: - """Generate a data row. - - :param row_data: data with an entry for each column in the row - :return: data row string - """ - row = super().generate_data_row(row_data) - self.row_num += 1 - return row - - def generate_table(self, table_data: Sequence[Sequence[Any]], *, include_header: bool = True) -> str: - """Generate a table from a data set. - - :param table_data: Data with an entry for each data row of the table. Each entry should have data for - each column in the row. - :param include_header: If True, then a header will be included at top of table. (Defaults to True) - """ - table_buf = io.StringIO() - - if include_header: - header = self.generate_header() - table_buf.write(header) - else: - top_border = self.generate_table_top_border() - table_buf.write(top_border) - - table_buf.write('\n') - - for row_data in table_data: - row = self.generate_data_row(row_data) - table_buf.write(row) - table_buf.write('\n') - - table_buf.write(self.generate_table_bottom_border()) - return table_buf.getvalue() diff --git a/cmd2/terminal_utils.py b/cmd2/terminal_utils.py new file mode 100644 index 00000000..1245803f --- /dev/null +++ b/cmd2/terminal_utils.py @@ -0,0 +1,144 @@ +r"""Support for terminal control escape sequences. + +These are used for things like setting the window title and asynchronous alerts. +""" + +from . import string_utils as su + +####################################################### +# Common ANSI escape sequence constants +####################################################### +ESC = '\x1b' +CSI = f'{ESC}[' +OSC = f'{ESC}]' +BEL = '\a' + + +#################################################################################### +# Utility functions which create various ANSI sequences +#################################################################################### +def set_title_str(title: str) -> str: + """Generate a string that, when printed, sets a terminal's window title. + + :param title: new title for the window + :return: the set title string + """ + return f"{OSC}2;{title}{BEL}" + + +def clear_screen_str(clear_type: int = 2) -> str: + """Generate a string that, when printed, clears a terminal screen based on value of clear_type. + + :param clear_type: integer which specifies how to clear the screen (Defaults to 2) + Possible values: + 0 - clear from cursor to end of screen + 1 - clear from cursor to beginning of the screen + 2 - clear entire screen + 3 - clear entire screen and delete all lines saved in the scrollback buffer + :return: the clear screen string + :raises ValueError: if clear_type is not a valid value + """ + if 0 <= clear_type <= 3: + return f"{CSI}{clear_type}J" + raise ValueError("clear_type must in an integer from 0 to 3") + + +def clear_line_str(clear_type: int = 2) -> str: + """Generate a string that, when printed, clears a line based on value of clear_type. + + :param clear_type: integer which specifies how to clear the line (Defaults to 2) + Possible values: + 0 - clear from cursor to the end of the line + 1 - clear from cursor to beginning of the line + 2 - clear entire line + :return: the clear line string + :raises ValueError: if clear_type is not a valid value + """ + if 0 <= clear_type <= 2: + return f"{CSI}{clear_type}K" + raise ValueError("clear_type must in an integer from 0 to 2") + + +#################################################################################### +# Implementations intended for direct use (do NOT use outside of cmd2) +#################################################################################### +class Cursor: + """Create ANSI sequences to alter the cursor position.""" + + @staticmethod + def UP(count: int = 1) -> str: # noqa: N802 + """Move the cursor up a specified amount of lines (Defaults to 1).""" + return f"{CSI}{count}A" + + @staticmethod + def DOWN(count: int = 1) -> str: # noqa: N802 + """Move the cursor down a specified amount of lines (Defaults to 1).""" + return f"{CSI}{count}B" + + @staticmethod + def FORWARD(count: int = 1) -> str: # noqa: N802 + """Move the cursor forward a specified amount of lines (Defaults to 1).""" + return f"{CSI}{count}C" + + @staticmethod + def BACK(count: int = 1) -> str: # noqa: N802 + """Move the cursor back a specified amount of lines (Defaults to 1).""" + return f"{CSI}{count}D" + + @staticmethod + def SET_POS(x: int, y: int) -> str: # noqa: N802 + """Set the cursor position to coordinates which are 1-based.""" + return f"{CSI}{y};{x}H" + + +def async_alert_str(*, terminal_columns: int, prompt: str, line: str, cursor_offset: int, alert_msg: str) -> str: + """Calculate the desired string, including ANSI escape codes, for displaying an asynchronous alert message. + + :param terminal_columns: terminal width (number of columns) + :param prompt: current onscreen prompt + :param line: current contents of the Readline line buffer + :param cursor_offset: the offset of the current cursor position within line + :param alert_msg: the message to display to the user + :return: the correct string so that the alert message appears to the user to be printed above the current line. + """ + # Split the prompt lines since it can contain newline characters. + prompt_lines = prompt.splitlines() or [''] + + # Calculate how many terminal lines are taken up by all prompt lines except for the last one. + # That will be included in the input lines calculations since that is where the cursor is. + num_prompt_terminal_lines = 0 + for prompt_line in prompt_lines[:-1]: + prompt_line_width = su.str_width(prompt_line) + num_prompt_terminal_lines += int(prompt_line_width / terminal_columns) + 1 + + # Now calculate how many terminal lines are take up by the input + last_prompt_line = prompt_lines[-1] + last_prompt_line_width = su.str_width(last_prompt_line) + + input_width = last_prompt_line_width + su.str_width(line) + + num_input_terminal_lines = int(input_width / terminal_columns) + 1 + + # Get the cursor's offset from the beginning of the first input line + cursor_input_offset = last_prompt_line_width + cursor_offset + + # Calculate what input line the cursor is on + cursor_input_line = int(cursor_input_offset / terminal_columns) + 1 + + # Create a string that when printed will clear all input lines and display the alert + terminal_str = '' + + # Move the cursor down to the last input line + if cursor_input_line != num_input_terminal_lines: + terminal_str += Cursor.DOWN(num_input_terminal_lines - cursor_input_line) + + # Clear each line from the bottom up so that the cursor ends up on the first prompt line + total_lines = num_prompt_terminal_lines + num_input_terminal_lines + terminal_str += (clear_line_str() + Cursor.UP(1)) * (total_lines - 1) + + # Clear the first prompt line + terminal_str += clear_line_str() + + # Move the cursor to the beginning of the first prompt line and print the alert + terminal_str += '\r' + alert_msg + return terminal_str diff --git a/cmd2/transcript.py b/cmd2/transcript.py index 05c5db6c..50a6fd61 100644 --- a/cmd2/transcript.py +++ b/cmd2/transcript.py @@ -18,10 +18,8 @@ class is used in cmd2.py::run_transcript_tests() cast, ) -from . import ( - ansi, - utils, -) +from . import string_utils as su +from . import utils if TYPE_CHECKING: # pragma: no cover from cmd2 import ( @@ -76,13 +74,13 @@ def _test_transcript(self, fname: str, transcript: Iterator[str]) -> None: line_num = 0 finished = False - line = ansi.strip_style(next(transcript)) + line = su.strip_style(next(transcript)) line_num += 1 while not finished: # Scroll forward to where actual commands begin while not line.startswith(self.cmdapp.visible_prompt): try: - line = ansi.strip_style(next(transcript)) + line = su.strip_style(next(transcript)) except StopIteration: finished = True break @@ -108,14 +106,14 @@ def _test_transcript(self, fname: str, transcript: Iterator[str]) -> None: result = self.cmdapp.stdout.read() stop_msg = 'Command indicated application should quit, but more commands in transcript' # Read the expected result from transcript - if ansi.strip_style(line).startswith(self.cmdapp.visible_prompt): + if su.strip_style(line).startswith(self.cmdapp.visible_prompt): message = f'\nFile {fname}, line {line_num}\nCommand was:\n{command}\nExpected: (nothing)\nGot:\n{result}\n' assert not result.strip(), message # noqa: S101 # If the command signaled the application to quit there should be no more commands assert not stop, stop_msg # noqa: S101 continue expected_parts = [] - while not ansi.strip_style(line).startswith(self.cmdapp.visible_prompt): + while not su.strip_style(line).startswith(self.cmdapp.visible_prompt): expected_parts.append(line) try: line = next(transcript) diff --git a/cmd2/utils.py b/cmd2/utils.py index c4b37ab3..bf4d1486 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -12,14 +12,27 @@ import subprocess import sys import threading -import unicodedata -from collections.abc import Callable, Iterable +from collections.abc import ( + Callable, + Iterable, +) from difflib import SequenceMatcher from enum import Enum -from typing import TYPE_CHECKING, Any, TextIO, TypeVar, Union, cast +from typing import ( + TYPE_CHECKING, + Any, + TextIO, + TypeVar, + Union, + cast, +) from . import constants -from .argparse_custom import ChoicesProviderFunc, CompleterFunc +from . import string_utils as su +from .argparse_custom import ( + ChoicesProviderFunc, + CompleterFunc, +) if TYPE_CHECKING: # pragma: no cover import cmd2 # noqa: F401 @@ -31,43 +44,6 @@ _T = TypeVar('_T') -def is_quoted(arg: str) -> bool: - """Check if a string is quoted. - - :param arg: the string being checked for quotes - :return: True if a string is quoted - """ - return len(arg) > 1 and arg[0] == arg[-1] and arg[0] in constants.QUOTES - - -def quote_string(arg: str) -> str: - """Quote a string.""" - quote = "'" if '"' in arg else '"' - - return quote + arg + quote - - -def quote_string_if_needed(arg: str) -> str: - """Quote a string if it contains spaces and isn't already quoted.""" - if is_quoted(arg) or ' ' not in arg: - return arg - - return quote_string(arg) - - -def strip_quotes(arg: str) -> str: - """Strip outer quotes from a string. - - Applies to both single and double quotes. - - :param arg: string to strip outer quotes from - :return: same string with potentially outer quotes stripped - """ - if is_quoted(arg): - arg = arg[1:-1] - return arg - - def to_bool(val: Any) -> bool: """Convert anything to a boolean based on its value. @@ -214,15 +190,6 @@ def remove_duplicates(list_to_prune: list[_T]) -> list[_T]: return list(temp_dict.keys()) -def norm_fold(astr: str) -> str: - """Normalize and casefold Unicode strings for saner comparisons. - - :param astr: input unicode string - :return: a normalized and case-folded version of the input string - """ - return unicodedata.normalize('NFC', astr).casefold() - - def alphabetical_sort(list_to_sort: Iterable[str]) -> list[str]: """Sorts a list of strings alphabetically. @@ -235,7 +202,7 @@ def alphabetical_sort(list_to_sort: Iterable[str]) -> list[str]: :param list_to_sort: the list being sorted :return: the sorted list """ - return sorted(list_to_sort, key=norm_fold) + return sorted(list_to_sort, key=su.norm_fold) def try_int_or_force_to_lower_case(input_str: str) -> int | str: @@ -247,7 +214,7 @@ def try_int_or_force_to_lower_case(input_str: str) -> int | str: try: return int(input_str) except ValueError: - return norm_fold(input_str) + return su.norm_fold(input_str) def natural_keys(input_str: str) -> list[int | str]: @@ -283,7 +250,7 @@ def quote_specific_tokens(tokens: list[str], tokens_to_quote: list[str]) -> None """ for i, token in enumerate(tokens): if token in tokens_to_quote: - tokens[i] = quote_string(token) + tokens[i] = su.quote(token) def unquote_specific_tokens(tokens: list[str], tokens_to_unquote: list[str]) -> None: @@ -293,7 +260,7 @@ def unquote_specific_tokens(tokens: list[str], tokens_to_unquote: list[str]) -> :param tokens_to_unquote: the tokens, which if present in tokens, to unquote """ for i, token in enumerate(tokens): - unquoted_token = strip_quotes(token) + unquoted_token = su.strip_quotes(token) if unquoted_token in tokens_to_unquote: tokens[i] = unquoted_token @@ -304,9 +271,9 @@ def expand_user(token: str) -> str: :param token: the string to expand """ if token: - if is_quoted(token): + if su.is_quoted(token): quote_char = token[0] - token = strip_quotes(token) + token = su.strip_quotes(token) else: quote_char = '' @@ -704,397 +671,6 @@ def __init__( self.saved_redirecting = saved_redirecting -def _remove_overridden_styles(styles_to_parse: list[str]) -> list[str]: - """Filter a style list down to only those which would still be in effect if all were processed in order. - - Utility function for align_text() / truncate_line(). - - This is mainly used to reduce how many style strings are stored in memory when - building large multiline strings with ANSI styles. We only need to carry over - styles from previous lines that are still in effect. - - :param styles_to_parse: list of styles to evaluate. - :return: list of styles that are still in effect. - """ - from . import ( - ansi, - ) - - class StyleState: - """Keeps track of what text styles are enabled.""" - - def __init__(self) -> None: - # Contains styles still in effect, keyed by their index in styles_to_parse - self.style_dict: dict[int, str] = {} - - # Indexes into style_dict - self.reset_all: int | None = None - self.fg: int | None = None - self.bg: int | None = None - self.intensity: int | None = None - self.italic: int | None = None - self.overline: int | None = None - self.strikethrough: int | None = None - self.underline: int | None = None - - # Read the previous styles in order and keep track of their states - style_state = StyleState() - - for index, style in enumerate(styles_to_parse): - # For styles types that we recognize, only keep their latest value from styles_to_parse. - # All unrecognized style types will be retained and their order preserved. - if style in (str(ansi.TextStyle.RESET_ALL), str(ansi.TextStyle.ALT_RESET_ALL)): - style_state = StyleState() - style_state.reset_all = index - elif ansi.STD_FG_RE.match(style) or ansi.EIGHT_BIT_FG_RE.match(style) or ansi.RGB_FG_RE.match(style): - if style_state.fg is not None: - style_state.style_dict.pop(style_state.fg) - style_state.fg = index - elif ansi.STD_BG_RE.match(style) or ansi.EIGHT_BIT_BG_RE.match(style) or ansi.RGB_BG_RE.match(style): - if style_state.bg is not None: - style_state.style_dict.pop(style_state.bg) - style_state.bg = index - elif style in ( - str(ansi.TextStyle.INTENSITY_BOLD), - str(ansi.TextStyle.INTENSITY_DIM), - str(ansi.TextStyle.INTENSITY_NORMAL), - ): - if style_state.intensity is not None: - style_state.style_dict.pop(style_state.intensity) - style_state.intensity = index - elif style in (str(ansi.TextStyle.ITALIC_ENABLE), str(ansi.TextStyle.ITALIC_DISABLE)): - if style_state.italic is not None: - style_state.style_dict.pop(style_state.italic) - style_state.italic = index - elif style in (str(ansi.TextStyle.OVERLINE_ENABLE), str(ansi.TextStyle.OVERLINE_DISABLE)): - if style_state.overline is not None: - style_state.style_dict.pop(style_state.overline) - style_state.overline = index - elif style in (str(ansi.TextStyle.STRIKETHROUGH_ENABLE), str(ansi.TextStyle.STRIKETHROUGH_DISABLE)): - if style_state.strikethrough is not None: - style_state.style_dict.pop(style_state.strikethrough) - style_state.strikethrough = index - elif style in (str(ansi.TextStyle.UNDERLINE_ENABLE), str(ansi.TextStyle.UNDERLINE_DISABLE)): - if style_state.underline is not None: - style_state.style_dict.pop(style_state.underline) - style_state.underline = index - - # Store this style and its location in the dictionary - style_state.style_dict[index] = style - - return list(style_state.style_dict.values()) - - -class TextAlignment(Enum): - """Horizontal text alignment.""" - - LEFT = 1 - CENTER = 2 - RIGHT = 3 - - -def align_text( - text: str, - alignment: TextAlignment, - *, - fill_char: str = ' ', - width: int | None = None, - tab_width: int = 4, - truncate: bool = False, -) -> str: - """Align text for display within a given width. Supports characters with display widths greater than 1. - - ANSI style sequences do not count toward the display width. If text has line breaks, then each line is aligned - independently. - - There are convenience wrappers around this function: align_left(), align_center(), and align_right() - - :param text: text to align (can contain multiple lines) - :param alignment: how to align the text - :param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character) - :param width: display width of the aligned text. Defaults to width of the terminal. - :param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will - be converted to one space. - :param truncate: if True, then each line will be shortened to fit within the display width. The truncated - portions are replaced by a '…' character. Defaults to False. - :return: aligned text - :raises TypeError: if fill_char is more than one character (not including ANSI style sequences) - :raises ValueError: if text or fill_char contains an unprintable character - :raises ValueError: if width is less than 1 - """ - import io - import shutil - - from . import ( - ansi, - ) - - if width is None: - # Prior to Python 3.11 this can return 0, so use a fallback if needed. - width = shutil.get_terminal_size().columns or constants.DEFAULT_TERMINAL_WIDTH - - if width < 1: - raise ValueError("width must be at least 1") - - # Convert tabs to spaces - text = text.replace('\t', ' ' * tab_width) - fill_char = fill_char.replace('\t', ' ') - - # Save fill_char with no styles for use later - stripped_fill_char = ansi.strip_style(fill_char) - if len(stripped_fill_char) != 1: - raise TypeError("Fill character must be exactly one character long") - - fill_char_width = ansi.style_aware_wcswidth(fill_char) - if fill_char_width == -1: - raise (ValueError("Fill character is an unprintable character")) - - # Isolate the style chars before and after the fill character. We will use them when building sequences of - # fill characters. Instead of repeating the style characters for each fill character, we'll wrap each sequence. - fill_char_style_begin, fill_char_style_end = fill_char.split(stripped_fill_char) - - lines = text.splitlines() if text else [''] - - text_buf = io.StringIO() - - # ANSI style sequences that may affect subsequent lines will be cancelled by the fill_char's style. - # To avoid this, we save styles which are still in effect so we can restore them when beginning the next line. - # This also allows lines to be used independently and still have their style. TableCreator does this. - previous_styles: list[str] = [] - - for index, line in enumerate(lines): - if index > 0: - text_buf.write('\n') - - if truncate: - line = truncate_line(line, width) # noqa: PLW2901 - - line_width = ansi.style_aware_wcswidth(line) - if line_width == -1: - raise (ValueError("Text to align contains an unprintable character")) - - # Get list of styles in this line - line_styles = list(get_styles_dict(line).values()) - - # Calculate how wide each side of filling needs to be - total_fill_width = 0 if line_width >= width else width - line_width - # Even if the line needs no fill chars, there may be styles sequences to restore - - if alignment == TextAlignment.LEFT: - left_fill_width = 0 - right_fill_width = total_fill_width - elif alignment == TextAlignment.CENTER: - left_fill_width = total_fill_width // 2 - right_fill_width = total_fill_width - left_fill_width - else: - left_fill_width = total_fill_width - right_fill_width = 0 - - # Determine how many fill characters are needed to cover the width - left_fill = (left_fill_width // fill_char_width) * stripped_fill_char - right_fill = (right_fill_width // fill_char_width) * stripped_fill_char - - # In cases where the fill character display width didn't divide evenly into - # the gap being filled, pad the remainder with space. - left_fill += ' ' * (left_fill_width - ansi.style_aware_wcswidth(left_fill)) - right_fill += ' ' * (right_fill_width - ansi.style_aware_wcswidth(right_fill)) - - # Don't allow styles in fill characters and text to affect one another - if fill_char_style_begin or fill_char_style_end or previous_styles or line_styles: - if left_fill: - left_fill = ansi.TextStyle.RESET_ALL + fill_char_style_begin + left_fill + fill_char_style_end - left_fill += ansi.TextStyle.RESET_ALL - - if right_fill: - right_fill = ansi.TextStyle.RESET_ALL + fill_char_style_begin + right_fill + fill_char_style_end - right_fill += ansi.TextStyle.RESET_ALL - - # Write the line and restore styles from previous lines which are still in effect - text_buf.write(left_fill + ''.join(previous_styles) + line + right_fill) - - # Update list of styles that are still in effect for the next line - previous_styles.extend(line_styles) - previous_styles = _remove_overridden_styles(previous_styles) - - return text_buf.getvalue() - - -def align_left( - text: str, *, fill_char: str = ' ', width: int | None = None, tab_width: int = 4, truncate: bool = False -) -> str: - """Left align text for display within a given width. Supports characters with display widths greater than 1. - - ANSI style sequences do not count toward the display width. If text has line breaks, then each line is aligned - independently. - - :param text: text to left align (can contain multiple lines) - :param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character) - :param width: display width of the aligned text. Defaults to width of the terminal. - :param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will - be converted to one space. - :param truncate: if True, then text will be shortened to fit within the display width. The truncated portion is - replaced by a '…' character. Defaults to False. - :return: left-aligned text - :raises TypeError: if fill_char is more than one character (not including ANSI style sequences) - :raises ValueError: if text or fill_char contains an unprintable character - :raises ValueError: if width is less than 1 - """ - return align_text(text, TextAlignment.LEFT, fill_char=fill_char, width=width, tab_width=tab_width, truncate=truncate) - - -def align_center( - text: str, *, fill_char: str = ' ', width: int | None = None, tab_width: int = 4, truncate: bool = False -) -> str: - """Center text for display within a given width. Supports characters with display widths greater than 1. - - ANSI style sequences do not count toward the display width. If text has line breaks, then each line is aligned - independently. - - :param text: text to center (can contain multiple lines) - :param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character) - :param width: display width of the aligned text. Defaults to width of the terminal. - :param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will - be converted to one space. - :param truncate: if True, then text will be shortened to fit within the display width. The truncated portion is - replaced by a '…' character. Defaults to False. - :return: centered text - :raises TypeError: if fill_char is more than one character (not including ANSI style sequences) - :raises ValueError: if text or fill_char contains an unprintable character - :raises ValueError: if width is less than 1 - """ - return align_text(text, TextAlignment.CENTER, fill_char=fill_char, width=width, tab_width=tab_width, truncate=truncate) - - -def align_right( - text: str, *, fill_char: str = ' ', width: int | None = None, tab_width: int = 4, truncate: bool = False -) -> str: - """Right align text for display within a given width. Supports characters with display widths greater than 1. - - ANSI style sequences do not count toward the display width. If text has line breaks, then each line is aligned - independently. - - :param text: text to right align (can contain multiple lines) - :param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character) - :param width: display width of the aligned text. Defaults to width of the terminal. - :param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will - be converted to one space. - :param truncate: if True, then text will be shortened to fit within the display width. The truncated portion is - replaced by a '…' character. Defaults to False. - :return: right-aligned text - :raises TypeError: if fill_char is more than one character (not including ANSI style sequences) - :raises ValueError: if text or fill_char contains an unprintable character - :raises ValueError: if width is less than 1 - """ - return align_text(text, TextAlignment.RIGHT, fill_char=fill_char, width=width, tab_width=tab_width, truncate=truncate) - - -def truncate_line(line: str, max_width: int, *, tab_width: int = 4) -> str: - """Truncate a single line to fit within a given display width. - - Any portion of the string that is truncated is replaced by a '…' character. Supports characters with display widths greater - than 1. ANSI style sequences do not count toward the display width. - - If there are ANSI style sequences in the string after where truncation occurs, this function will append them - to the returned string. - - This is done to prevent issues caused in cases like: truncate_line(Fg.BLUE + hello + Fg.RESET, 3) - In this case, "hello" would be truncated before Fg.RESET resets the color from blue. Appending the remaining style - sequences makes sure the style is in the same state had the entire string been printed. align_text() relies on this - behavior when preserving style over multiple lines. - - :param line: text to truncate - :param max_width: the maximum display width the resulting string is allowed to have - :param tab_width: any tabs in the text will be replaced with this many spaces - :return: line that has a display width less than or equal to width - :raises ValueError: if text contains an unprintable character like a newline - :raises ValueError: if max_width is less than 1 - """ - import io - - from . import ( - ansi, - ) - - # Handle tabs - line = line.replace('\t', ' ' * tab_width) - - if ansi.style_aware_wcswidth(line) == -1: - raise (ValueError("text contains an unprintable character")) - - if max_width < 1: - raise ValueError("max_width must be at least 1") - - if ansi.style_aware_wcswidth(line) <= max_width: - return line - - # Find all style sequences in the line - styles_dict = get_styles_dict(line) - - # Add characters one by one and preserve all style sequences - done = False - index = 0 - total_width = 0 - truncated_buf = io.StringIO() - - while not done: - # Check if a style sequence is at this index. These don't count toward display width. - if index in styles_dict: - truncated_buf.write(styles_dict[index]) - style_len = len(styles_dict[index]) - styles_dict.pop(index) - index += style_len - continue - - char = line[index] - char_width = ansi.style_aware_wcswidth(char) - - # This char will make the text too wide, add the ellipsis instead - if char_width + total_width >= max_width: - char = constants.HORIZONTAL_ELLIPSIS - char_width = ansi.style_aware_wcswidth(char) - done = True - - total_width += char_width - truncated_buf.write(char) - index += 1 - - # Filter out overridden styles from the remaining ones - remaining_styles = _remove_overridden_styles(list(styles_dict.values())) - - # Append the remaining styles to the truncated text - truncated_buf.write(''.join(remaining_styles)) - - return truncated_buf.getvalue() - - -def get_styles_dict(text: str) -> dict[int, str]: - """Return an OrderedDict containing all ANSI style sequences found in a string. - - The structure of the dictionary is: - key: index where sequences begins - value: ANSI style sequence found at index in text - - Keys are in ascending order - - :param text: text to search for style sequences - """ - from . import ( - ansi, - ) - - start = 0 - styles = collections.OrderedDict() - - while True: - match = ansi.ANSI_STYLE_RE.search(text, start) - if match is None: - break - styles[match.start()] = match.group() - start += len(match.group()) - - return styles - - def categorize(func: Callable[..., Any] | Iterable[Callable[..., Any]], category: str) -> None: """Categorize a function. diff --git a/docs/api/ansi.md b/docs/api/ansi.md deleted file mode 100644 index 754861d5..00000000 --- a/docs/api/ansi.md +++ /dev/null @@ -1,3 +0,0 @@ -# cmd2.ansi - -::: cmd2.ansi diff --git a/docs/api/clipboard.md b/docs/api/clipboard.md new file mode 100644 index 00000000..b3f9a2bf --- /dev/null +++ b/docs/api/clipboard.md @@ -0,0 +1,3 @@ +# cmd2.clipboard + +::: cmd2.clipboard diff --git a/docs/api/colors.md b/docs/api/colors.md new file mode 100644 index 00000000..cb37aece --- /dev/null +++ b/docs/api/colors.md @@ -0,0 +1,3 @@ +# cmd2.colors + +::: cmd2.colors diff --git a/docs/api/index.md b/docs/api/index.md index 291bcbcc..c52dca6f 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -12,10 +12,10 @@ incremented according to the [Semantic Version Specification](https://semver.org ## Modules - [cmd2.Cmd](./cmd.md) - functions and attributes of the main class in this library -- [cmd2.ansi](./ansi.md) - convenience classes and functions for generating ANSI escape sequences to - style text in the terminal - [cmd2.argparse_completer](./argparse_completer.md) - classes for `argparse`-based tab completion - [cmd2.argparse_custom](./argparse_custom.md) - classes and functions for extending `argparse` +- [cmd2.clipboard](./clipboard.md) - functions to copy from and paste to the clipboard/pastebuffer +- [cmd2.colors](./colors.md) - StrEnum of all color names supported by the Rich library - [cmd2.command_definition](./command_definition.md) - supports the definition of commands in separate classes to be composed into cmd2.Cmd - [cmd2.constants](./constants.md) - just like it says on the tin @@ -26,5 +26,11 @@ incremented according to the [Semantic Version Specification](https://semver.org - [cmd2.plugin](./plugin.md) - data classes for hook methods - [cmd2.py_bridge](./py_bridge.md) - classes for bridging calls from the embedded python environment to the host app -- [cmd2.table_creator](./table_creator.md) - table creation module +- [cmd2.rich_utils](./rich_utils.md) - common utilities to support Rich in cmd2 applications +- [cmd2.rl_utils](./rl_utils.md) - imports the proper Readline for the platform and provides utility + functions for it +- [cmd2.string_utils](./string_utils.md) - string utility functions +- [cmd2.styles](./styles.md) - cmd2-specific Rich styles and a StrEnum of their corresponding names +- [cmd2.terminal_utils](./terminal_utils.md) - support for terminal control escape sequences +- [cmd2.transcript](./transcript.md) - functions and classes for running and validating transcripts - [cmd2.utils](./utils.md) - various utility classes and functions diff --git a/docs/api/rich_utils.md b/docs/api/rich_utils.md new file mode 100644 index 00000000..e339843d --- /dev/null +++ b/docs/api/rich_utils.md @@ -0,0 +1,3 @@ +# cmd2.rich_utils + +::: cmd2.rich_utils diff --git a/docs/api/rl_utils.md b/docs/api/rl_utils.md new file mode 100644 index 00000000..52beb31b --- /dev/null +++ b/docs/api/rl_utils.md @@ -0,0 +1,3 @@ +# cmd2.rl_utils + +::: cmd2.rl_utils diff --git a/docs/api/string_utils.md b/docs/api/string_utils.md new file mode 100644 index 00000000..5717608b --- /dev/null +++ b/docs/api/string_utils.md @@ -0,0 +1,3 @@ +# cmd2.string_utils + +::: cmd2.string_utils diff --git a/docs/api/styles.md b/docs/api/styles.md new file mode 100644 index 00000000..4f10ccb1 --- /dev/null +++ b/docs/api/styles.md @@ -0,0 +1,3 @@ +# cmd2.styles + +::: cmd2.styles diff --git a/docs/api/table_creator.md b/docs/api/table_creator.md deleted file mode 100644 index 2d3887fc..00000000 --- a/docs/api/table_creator.md +++ /dev/null @@ -1,3 +0,0 @@ -# cmd2.table_creator - -::: cmd2.table_creator diff --git a/docs/api/terminal_utils.md b/docs/api/terminal_utils.md new file mode 100644 index 00000000..919f36dd --- /dev/null +++ b/docs/api/terminal_utils.md @@ -0,0 +1,3 @@ +# cmd2.terminal_utils + +::: cmd2.terminal_utils diff --git a/docs/api/transcript.md b/docs/api/transcript.md new file mode 100644 index 00000000..bde72d37 --- /dev/null +++ b/docs/api/transcript.md @@ -0,0 +1,3 @@ +# cmd2.transcript + +::: cmd2.transcript diff --git a/docs/features/generating_output.md b/docs/features/generating_output.md index 4892dd7e..9fc1f7f1 100644 --- a/docs/features/generating_output.md +++ b/docs/features/generating_output.md @@ -43,12 +43,11 @@ def do_echo(self, args): ## Error Messages When an error occurs in your program, you can display it on `sys.stderr` by calling the -`.cmd2.Cmd.perror` method. By default this method applies `cmd2.ansi.style_error` to the output. +`.cmd2.Cmd.perror` method. By default this method applies `Cmd2Style.ERROR` to the output. ## Warning Messages -`cmd2.Cmd.pwarning` is just like `cmd2.Cmd.perror` but applies `cmd2.ansi.style_warning` to the -output. +`cmd2.Cmd.pwarning` is just like `cmd2.Cmd.perror` but applies `Cmd2Style.WARNING` to the output. ## Feedback @@ -85,7 +84,13 @@ You can add your own [ANSI escape sequences](https://en.wikipedia.org/wiki/ANSI_ to your output which tell the terminal to change the foreground and background colors. `cmd2` provides a number of convenience functions and classes for adding color and other styles to -text. These are all documented in [cmd2.ansi][]. +text. These are all based on [rich](https://github.com/Textualize/rich) and are documented in the +following sectins: + +- [cmd2.colors][] +- [cmd2.rich_utils][] +- [cmd2.string_utils][] +- [cmd2.terminal_utils][] After adding the desired escape sequences to your output, you should use one of these methods to present the output to the user: diff --git a/docs/features/table_creation.md b/docs/features/table_creation.md index a41300a5..cd0a7278 100644 --- a/docs/features/table_creation.md +++ b/docs/features/table_creation.md @@ -1,33 +1,9 @@ # Table Creation -`cmd2` provides a table creation class called `cmd2.table_creator.TableCreator`. This class handles -ANSI style sequences and characters with display widths greater than 1 when performing width -calculations. It was designed with the ability to build tables one row at a time. This helps when -you have large data sets that you don't want to hold in memory or when you receive portions of the -data set incrementally. +As of version 3, `cmd2` no longer includes code for table creation. -`TableCreator` has one public method: `cmd2.table_creator.TableCreator.generate_row()`. +This is because `cmd2` now has a dependency on [rich](https://github.com/Textualize/rich) which has +excellent support for this feature. -This function and the `cmd2.table_creator.Column` class provide all features needed to build tables -with headers, borders, colors, horizontal and vertical alignment, and wrapped text. However, it's -generally easier to inherit from this class and implement a more granular API rather than use -`TableCreator` directly. - -The following table classes build upon `TableCreator` and are provided in the -[cmd2.table_creater](../api/table_creator.md) module. They can be used as is or as examples for how -to build your own table classes. - -`cmd2.table_creator.SimpleTable` - Implementation of TableCreator which generates a borderless table -with an optional divider row after the header. This class can be used to create the whole table at -once or one row at a time. - -`cmd2.table_creator.BorderedTable` - Implementation of TableCreator which generates a table with -borders around the table and between rows. Borders between columns can also be toggled. This class -can be used to create the whole table at once or one row at a time. - -`cmd2.table_creator.AlternatingTable` - Implementation of BorderedTable which uses background colors -to distinguish between rows instead of row border lines. This class can be used to create the whole -table at once or one row at a time. - -See the [table_creation](https://github.com/python-cmd2/cmd2/blob/main/examples/table_creation.py) -example to see these classes in use +Please see rich's docummentation on [Tables](https://rich.readthedocs.io/en/latest/tables.html) for +more information. diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index f680db57..00000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -mkdocs-include-markdown-plugin -mkdocs-macros-plugin -mkdocs-material -pyperclip -setuptools -setuptools-scm -wcwidth diff --git a/examples/async_printing.py b/examples/async_printing.py index 5655a62f..f1eac85d 100755 --- a/examples/async_printing.py +++ b/examples/async_printing.py @@ -9,8 +9,8 @@ import cmd2 from cmd2 import ( - Fg, - style, + Color, + stylize, ) ALERTS = [ @@ -139,20 +139,20 @@ def _generate_colored_prompt(self) -> str: """ rand_num = random.randint(1, 20) - status_color = Fg.RESET + status_color = Color.DEFAULT if rand_num == 1: - status_color = Fg.LIGHT_RED + status_color = Color.BRIGHT_RED elif rand_num == 2: - status_color = Fg.LIGHT_YELLOW + status_color = Color.BRIGHT_YELLOW elif rand_num == 3: - status_color = Fg.CYAN + status_color = Color.CYAN elif rand_num == 4: - status_color = Fg.LIGHT_GREEN + status_color = Color.BRIGHT_GREEN elif rand_num == 5: - status_color = Fg.LIGHT_BLUE + status_color = Color.BRIGHT_BLUE - return style(self.visible_prompt, fg=status_color) + return stylize(self.visible_prompt, style=status_color) def _alerter_thread_func(self) -> None: """Prints alerts and updates the prompt any time the prompt is showing.""" diff --git a/examples/custom_parser.py b/examples/custom_parser.py index d4c33116..70a279e8 100644 --- a/examples/custom_parser.py +++ b/examples/custom_parser.py @@ -8,9 +8,10 @@ from cmd2 import ( Cmd2ArgumentParser, - ansi, cmd2, set_default_argument_parser_type, + styles, + stylize, ) @@ -34,8 +35,11 @@ def error(self, message: str) -> NoReturn: self.print_usage(sys.stderr) - # Format errors with style_warning() - formatted_message = ansi.style_warning(formatted_message) + # Format errors with warning style + formatted_message = stylize( + formatted_message, + style=styles.WARNING, + ) self.exit(2, f'{formatted_message}\n\n') diff --git a/examples/table_creation.py b/examples/table_creation.py deleted file mode 100755 index 754fe972..00000000 --- a/examples/table_creation.py +++ /dev/null @@ -1,275 +0,0 @@ -#!/usr/bin/env python -"""Examples of using the cmd2 table creation API.""" - -import functools -import sys -from typing import Any - -from cmd2 import ( - EightBitBg, - EightBitFg, - Fg, - ansi, - rich_utils, -) -from cmd2.table_creator import ( - AlternatingTable, - BorderedTable, - Column, - HorizontalAlignment, - SimpleTable, -) - -# Text styles used in the tables -bold_yellow = functools.partial(ansi.style, fg=Fg.LIGHT_YELLOW, bold=True) -blue = functools.partial(ansi.style, fg=Fg.LIGHT_BLUE) -green = functools.partial(ansi.style, fg=Fg.GREEN) - - -class DollarFormatter: - """Example class to show that any object type can be passed as data to TableCreator and converted to a string.""" - - def __init__(self, val: float) -> None: - self.val = val - - def __str__(self) -> str: - """Returns the value in dollar currency form (e.g. $100.22).""" - return f"${self.val:,.2f}" - - -class Relative: - """Class used for example data.""" - - def __init__(self, name: str, relationship: str) -> None: - self.name = name - self.relationship = relationship - - -class Book: - """Class used for example data.""" - - def __init__(self, title: str, year_published: str) -> None: - self.title = title - self.year_published = year_published - - -class Author: - """Class used for example data.""" - - def __init__(self, name: str, birthday: str, place_of_birth: str) -> None: - self.name = name - self.birthday = birthday - self.place_of_birth = place_of_birth - self.books: list[Book] = [] - self.relatives: list[Relative] = [] - - -def ansi_print(text) -> None: - """Wraps style_aware_write so style can be stripped if needed.""" - ansi.style_aware_write(sys.stdout, text + '\n\n') - - -def basic_tables() -> None: - """Demonstrates basic examples of the table classes.""" - # Table data which demonstrates handling of wrapping and text styles - data_list: list[list[Any]] = [] - data_list.append(["Billy Smith", "123 Sesame St.\nFake Town, USA 33445", DollarFormatter(100333.03)]) - data_list.append( - [ - "William Longfellow Marmaduke III", - "984 Really Long Street Name Which Will Wrap Nicely\nApt 22G\nPensacola, FL 32501", - DollarFormatter(55135.22), - ] - ) - data_list.append( - [ - "James " + blue("Bluestone"), - bold_yellow("This address has line feeds,\ntext styles, and wrapping. ") - + blue("Style is preserved across lines."), - DollarFormatter(300876.10), - ] - ) - data_list.append(["John Jones", "9235 Highway 32\n" + green("Greenville") + ", SC 29604", DollarFormatter(82987.71)]) - - # Table Columns (width does not account for any borders or padding which may be added) - columns: list[Column] = [] - columns.append(Column("Name", width=20)) - columns.append(Column("Address", width=38)) - columns.append( - Column("Income", width=14, header_horiz_align=HorizontalAlignment.RIGHT, data_horiz_align=HorizontalAlignment.RIGHT) - ) - - st = SimpleTable(columns) - table = st.generate_table(data_list) - ansi_print(table) - - bt = BorderedTable(columns) - table = bt.generate_table(data_list) - ansi_print(table) - - at = AlternatingTable(columns) - table = at.generate_table(data_list) - ansi_print(table) - - -def nested_tables() -> None: - """Demonstrates how to nest tables with styles which conflict with the parent table by setting style_data_text to False. - It also demonstrates coloring various aspects of tables. - """ - # Create data for this example - author_data: list[Author] = [] - author_1 = Author("Frank Herbert", "10/08/1920", "Tacoma, Washington") - author_1.books.append(Book("Dune", "1965")) - author_1.books.append(Book("Dune Messiah", "1969")) - author_1.books.append(Book("Children of Dune", "1976")) - author_1.books.append(Book("God Emperor of Dune", "1981")) - author_1.books.append(Book("Heretics of Dune", "1984")) - author_1.books.append(Book("Chapterhouse: Dune", "1985")) - author_1.relatives.append(Relative("Flora Lillian Parkinson", "First Wife")) - author_1.relatives.append(Relative("Beverly Ann Stuart", "Second Wife")) - author_1.relatives.append(Relative("Theresa Diane Shackelford", "Third Wife")) - author_1.relatives.append(Relative("Penelope Herbert", "Daughter")) - author_1.relatives.append(Relative("Brian Patrick Herbert", "Son")) - author_1.relatives.append(Relative("Bruce Calvin Herbert", "Son")) - - author_2 = Author("Jane Austen", "12/16/1775", "Steventon, Hampshire, England") - author_2.books.append(Book("Sense and Sensibility", "1811")) - author_2.books.append(Book("Pride and Prejudice", "1813")) - author_2.books.append(Book("Mansfield Park ", "1814")) - author_2.books.append(Book("Emma", "1815")) - author_2.books.append(Book("Northanger Abbey", "1818")) - author_2.books.append(Book("Persuasion", "1818")) - author_2.books.append(Book("Lady Susan", "1871")) - author_2.relatives.append(Relative("James Austen", "Brother")) - author_2.relatives.append(Relative("George Austen", "Brother")) - author_2.relatives.append(Relative("Edward Austen", "Brother")) - author_2.relatives.append(Relative("Henry Thomas Austen", "Brother")) - author_2.relatives.append(Relative("Cassandra Elizabeth Austen", "Sister")) - author_2.relatives.append(Relative("Francis William Austen", "Brother")) - author_2.relatives.append(Relative("Charles John Austen", "Brother")) - - author_data.append(author_1) - author_data.append(author_2) - - # Define table which presents Author data fields vertically with no header. - # This will be nested in the parent table's first column. - author_columns: list[Column] = [] - author_columns.append(Column("", width=14)) - author_columns.append(Column("", width=20)) - - # The text labels in this table will be bold text. They will also be aligned by the table code. - # When styled text is aligned, a TextStyle.RESET_ALL sequence is inserted between the aligned text - # and the fill characters. Therefore, the Author table will contain TextStyle.RESET_ALL sequences, - # which would interfere with the background color applied by the parent table. To account for this, - # we will manually color the Author tables to match the background colors of the parent AlternatingTable's - # rows and set style_data_text to False in the Author column. - odd_author_tbl = SimpleTable(author_columns, data_bg=EightBitBg.GRAY_0) - even_author_tbl = SimpleTable(author_columns, data_bg=EightBitBg.GRAY_15) - - # Define AlternatingTable for books checked out by people in the first table. - # This will be nested in the parent table's second column. - books_columns: list[Column] = [] - books_columns.append(Column(ansi.style("Title", bold=True), width=25)) - books_columns.append( - Column( - ansi.style("Published", bold=True), - width=9, - header_horiz_align=HorizontalAlignment.RIGHT, - data_horiz_align=HorizontalAlignment.RIGHT, - ) - ) - - books_tbl = AlternatingTable( - books_columns, - column_borders=False, - border_fg=EightBitFg.GRAY_15, - header_bg=EightBitBg.GRAY_0, - odd_bg=EightBitBg.GRAY_0, - even_bg=EightBitBg.GRAY_15, - ) - - # Define BorderedTable for relatives of the author - # This will be nested in the parent table's third column. - relative_columns: list[Column] = [] - relative_columns.append(Column(ansi.style("Name", bold=True), width=25)) - relative_columns.append(Column(ansi.style("Relationship", bold=True), width=12)) - - # Since the header labels are bold, we have the same issue as the Author table. Therefore, we will manually - # color Relatives tables to match the background colors of the parent AlternatingTable's rows and set style_data_text - # to False in the Relatives column. - odd_relatives_tbl = BorderedTable( - relative_columns, - border_fg=EightBitFg.GRAY_15, - border_bg=EightBitBg.GRAY_0, - header_bg=EightBitBg.GRAY_0, - data_bg=EightBitBg.GRAY_0, - ) - - even_relatives_tbl = BorderedTable( - relative_columns, - border_fg=EightBitFg.GRAY_0, - border_bg=EightBitBg.GRAY_15, - header_bg=EightBitBg.GRAY_15, - data_bg=EightBitBg.GRAY_15, - ) - - # Define parent AlternatingTable which contains Author and Book tables - parent_tbl_columns: list[Column] = [] - - # All of the nested tables already have background colors. Set style_data_text - # to False so the parent AlternatingTable does not apply background color to them. - parent_tbl_columns.append( - Column(ansi.style("Author", bold=True), width=odd_author_tbl.total_width(), style_data_text=False) - ) - parent_tbl_columns.append(Column(ansi.style("Books", bold=True), width=books_tbl.total_width(), style_data_text=False)) - parent_tbl_columns.append( - Column(ansi.style("Relatives", bold=True), width=odd_relatives_tbl.total_width(), style_data_text=False) - ) - - parent_tbl = AlternatingTable( - parent_tbl_columns, - column_borders=False, - border_fg=EightBitFg.GRAY_93, - header_bg=EightBitBg.GRAY_0, - odd_bg=EightBitBg.GRAY_0, - even_bg=EightBitBg.GRAY_15, - ) - - # Construct the tables - parent_table_data: list[list[Any]] = [] - for row, author in enumerate(author_data, start=1): - # First build the author table and color it based on row number - author_tbl = even_author_tbl if row % 2 == 0 else odd_author_tbl - - # This table has three rows and two columns - table_data = [ - [ansi.style("Name", bold=True), author.name], - [ansi.style("Birthday", bold=True), author.birthday], - [ansi.style("Place of Birth", bold=True), author.place_of_birth], - ] - - # Build the author table string - author_tbl_str = author_tbl.generate_table(table_data, include_header=False, row_spacing=0) - - # Now build this author's book table - table_data = [[book.title, book.year_published] for book in author.books] - book_tbl_str = books_tbl.generate_table(table_data) - - # Lastly build the relatives table and color it based on row number - relatives_tbl = even_relatives_tbl if row % 2 == 0 else odd_relatives_tbl - table_data = [[relative.name, relative.relationship] for relative in author.relatives] - relatives_tbl_str = relatives_tbl.generate_table(table_data) - - # Add these tables to the parent table's data - parent_table_data.append(['\n' + author_tbl_str, '\n' + book_tbl_str + '\n\n', '\n' + relatives_tbl_str + '\n\n']) - - # Build the parent table - top_table_str = parent_tbl.generate_table(parent_table_data) - ansi_print(top_table_str) - - -if __name__ == '__main__': - # Default to terminal mode so redirecting to a file won't include the ANSI style sequences - rich_utils.allow_style = rich_utils.AllowStyle.TERMINAL - basic_tables() - nested_tables() diff --git a/mkdocs.yml b/mkdocs.yml index 77a3d3d7..be5275a2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -200,9 +200,10 @@ nav: - API Reference: - api/index.md - api/cmd.md - - api/ansi.md - api/argparse_completer.md - api/argparse_custom.md + - api/clipboard.md + - api/colors.md - api/command_definition.md - api/constants.md - api/decorators.md @@ -211,7 +212,12 @@ nav: - api/parsing.md - api/plugin.md - api/py_bridge.md - - api/table_creator.md + - api/rich_utils.md + - api/rl_utils.md + - api/string_utils.md + - api/styles.md + - api/terminal_utils.md + - api/transcript.md - api/utils.md - Meta: - doc_conventions.md diff --git a/pyproject.toml b/pyproject.toml index 8a99b54d..1ba77140 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,8 +32,8 @@ dependencies = [ "gnureadline>=8; platform_system == 'Darwin'", "pyperclip>=1.8", "pyreadline3>=3.4; platform_system == 'Windows'", + "rich>=14.1.0", "rich-argparse>=1.7.1", - "wcwidth>=0.2.10", ] [dependency-groups] diff --git a/tests/test_ansi.py b/tests/test_ansi.py deleted file mode 100644 index 84119072..00000000 --- a/tests/test_ansi.py +++ /dev/null @@ -1,298 +0,0 @@ -"""Unit testing for cmd2/ansi.py module""" - -import pytest - -from cmd2 import ( - ansi, -) - -HELLO_WORLD = 'Hello, world!' - - -def test_strip_style() -> None: - base_str = HELLO_WORLD - ansi_str = ansi.style(base_str, fg=ansi.Fg.GREEN) - assert base_str != ansi_str - assert base_str == ansi.strip_style(ansi_str) - - -def test_style_aware_wcswidth() -> None: - base_str = HELLO_WORLD - ansi_str = ansi.style(base_str, fg=ansi.Fg.GREEN) - assert ansi.style_aware_wcswidth(HELLO_WORLD) == ansi.style_aware_wcswidth(ansi_str) - - assert ansi.style_aware_wcswidth('i have a tab\t') == -1 - assert ansi.style_aware_wcswidth('i have a newline\n') == -1 - - -def test_widest_line() -> None: - text = ansi.style('i have\n3 lines\nThis is the longest one', fg=ansi.Fg.GREEN) - assert ansi.widest_line(text) == ansi.style_aware_wcswidth("This is the longest one") - - text = "I'm just one line" - assert ansi.widest_line(text) == ansi.style_aware_wcswidth(text) - - assert ansi.widest_line('i have a tab\t') == -1 - - -def test_style_none() -> None: - base_str = HELLO_WORLD - ansi_str = base_str - assert ansi.style(base_str) == ansi_str - - -@pytest.mark.parametrize('fg_color', [ansi.Fg.BLUE, ansi.EightBitFg.AQUAMARINE_1A, ansi.RgbFg(0, 2, 4)]) -def test_style_fg(fg_color) -> None: - base_str = HELLO_WORLD - ansi_str = fg_color + base_str + ansi.Fg.RESET - assert ansi.style(base_str, fg=fg_color) == ansi_str - - -@pytest.mark.parametrize('bg_color', [ansi.Bg.BLUE, ansi.EightBitBg.AQUAMARINE_1A, ansi.RgbBg(0, 2, 4)]) -def test_style_bg(bg_color) -> None: - base_str = HELLO_WORLD - ansi_str = bg_color + base_str + ansi.Bg.RESET - assert ansi.style(base_str, bg=bg_color) == ansi_str - - -def test_style_invalid_types() -> None: - # Use a BgColor with fg - with pytest.raises(TypeError): - ansi.style('test', fg=ansi.Bg.BLUE) - - # Use a FgColor with bg - with pytest.raises(TypeError): - ansi.style('test', bg=ansi.Fg.BLUE) - - -def test_style_bold() -> None: - base_str = HELLO_WORLD - ansi_str = ansi.TextStyle.INTENSITY_BOLD + base_str + ansi.TextStyle.INTENSITY_NORMAL - assert ansi.style(base_str, bold=True) == ansi_str - - -def test_style_dim() -> None: - base_str = HELLO_WORLD - ansi_str = ansi.TextStyle.INTENSITY_DIM + base_str + ansi.TextStyle.INTENSITY_NORMAL - assert ansi.style(base_str, dim=True) == ansi_str - - -def test_style_italic() -> None: - base_str = HELLO_WORLD - ansi_str = ansi.TextStyle.ITALIC_ENABLE + base_str + ansi.TextStyle.ITALIC_DISABLE - assert ansi.style(base_str, italic=True) == ansi_str - - -def test_style_overline() -> None: - base_str = HELLO_WORLD - ansi_str = ansi.TextStyle.OVERLINE_ENABLE + base_str + ansi.TextStyle.OVERLINE_DISABLE - assert ansi.style(base_str, overline=True) == ansi_str - - -def test_style_strikethrough() -> None: - base_str = HELLO_WORLD - ansi_str = ansi.TextStyle.STRIKETHROUGH_ENABLE + base_str + ansi.TextStyle.STRIKETHROUGH_DISABLE - assert ansi.style(base_str, strikethrough=True) == ansi_str - - -def test_style_underline() -> None: - base_str = HELLO_WORLD - ansi_str = ansi.TextStyle.UNDERLINE_ENABLE + base_str + ansi.TextStyle.UNDERLINE_DISABLE - assert ansi.style(base_str, underline=True) == ansi_str - - -def test_style_multi() -> None: - base_str = HELLO_WORLD - fg_color = ansi.Fg.LIGHT_BLUE - bg_color = ansi.Bg.LIGHT_GRAY - ansi_str = ( - fg_color - + bg_color - + ansi.TextStyle.INTENSITY_BOLD - + ansi.TextStyle.INTENSITY_DIM - + ansi.TextStyle.ITALIC_ENABLE - + ansi.TextStyle.OVERLINE_ENABLE - + ansi.TextStyle.STRIKETHROUGH_ENABLE - + ansi.TextStyle.UNDERLINE_ENABLE - + base_str - + ansi.Fg.RESET - + ansi.Bg.RESET - + ansi.TextStyle.INTENSITY_NORMAL - + ansi.TextStyle.INTENSITY_NORMAL - + ansi.TextStyle.ITALIC_DISABLE - + ansi.TextStyle.OVERLINE_DISABLE - + ansi.TextStyle.STRIKETHROUGH_DISABLE - + ansi.TextStyle.UNDERLINE_DISABLE - ) - assert ( - ansi.style( - base_str, - fg=fg_color, - bg=bg_color, - bold=True, - dim=True, - italic=True, - overline=True, - strikethrough=True, - underline=True, - ) - == ansi_str - ) - - -def test_set_title() -> None: - title = HELLO_WORLD - assert ansi.set_title(title) == ansi.OSC + '2;' + title + ansi.BEL - - -@pytest.mark.parametrize( - ('cols', 'prompt', 'line', 'cursor', 'msg', 'expected'), - [ - ( - 127, - '(Cmd) ', - 'help his', - 12, - ansi.style('Hello World!', fg=ansi.Fg.MAGENTA), - '\x1b[2K\r\x1b[35mHello World!\x1b[39m', - ), - (127, '\n(Cmd) ', 'help ', 5, 'foo', '\x1b[2K\x1b[1A\x1b[2K\rfoo'), - ( - 10, - '(Cmd) ', - 'help history of the american republic', - 4, - 'boo', - '\x1b[3B\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\rboo', - ), - ], -) -def test_async_alert_str(cols, prompt, line, cursor, msg, expected) -> None: - alert_str = ansi.async_alert_str(terminal_columns=cols, prompt=prompt, line=line, cursor_offset=cursor, alert_msg=msg) - assert alert_str == expected - - -def test_clear_screen() -> None: - clear_type = 2 - assert ansi.clear_screen(clear_type) == f"{ansi.CSI}{clear_type}J" - - clear_type = -1 - expected_err = "clear_type must in an integer from 0 to 3" - with pytest.raises(ValueError, match=expected_err): - ansi.clear_screen(clear_type) - - clear_type = 4 - with pytest.raises(ValueError, match=expected_err): - ansi.clear_screen(clear_type) - - -def test_clear_line() -> None: - clear_type = 2 - assert ansi.clear_line(clear_type) == f"{ansi.CSI}{clear_type}K" - - clear_type = -1 - expected_err = "clear_type must in an integer from 0 to 2" - with pytest.raises(ValueError, match=expected_err): - ansi.clear_line(clear_type) - - clear_type = 3 - with pytest.raises(ValueError, match=expected_err): - ansi.clear_line(clear_type) - - -def test_cursor() -> None: - count = 1 - assert ansi.Cursor.UP(count) == f"{ansi.CSI}{count}A" - assert ansi.Cursor.DOWN(count) == f"{ansi.CSI}{count}B" - assert ansi.Cursor.FORWARD(count) == f"{ansi.CSI}{count}C" - assert ansi.Cursor.BACK(count) == f"{ansi.CSI}{count}D" - - x = 4 - y = 5 - assert ansi.Cursor.SET_POS(x, y) == f"{ansi.CSI}{y};{x}H" - - -@pytest.mark.parametrize( - 'ansi_sequence', - [ - ansi.Fg.MAGENTA, - ansi.Bg.LIGHT_GRAY, - ansi.EightBitBg.CHARTREUSE_2A, - ansi.EightBitBg.MEDIUM_PURPLE, - ansi.RgbFg(0, 5, 22), - ansi.RgbBg(100, 150, 222), - ansi.TextStyle.OVERLINE_ENABLE, - ], -) -def test_sequence_str_building(ansi_sequence) -> None: - """This tests __add__(), __radd__(), and __str__() methods for AnsiSequences""" - assert ansi_sequence + ansi_sequence == str(ansi_sequence) + str(ansi_sequence) - - -@pytest.mark.parametrize( - ('r', 'g', 'b', 'valid'), - [ - (0, 0, 0, True), - (255, 255, 255, True), - (-1, 0, 0, False), - (256, 255, 255, False), - (0, -1, 0, False), - (255, 256, 255, False), - (0, 0, -1, False), - (255, 255, 256, False), - ], -) -def test_rgb_bounds(r, g, b, valid) -> None: - if valid: - ansi.RgbFg(r, g, b) - ansi.RgbBg(r, g, b) - else: - expected_err = "RGB values must be integers in the range of 0 to 255" - with pytest.raises(ValueError, match=expected_err): - ansi.RgbFg(r, g, b) - with pytest.raises(ValueError, match=expected_err): - ansi.RgbBg(r, g, b) - - -def test_std_color_re() -> None: - """Test regular expressions for matching standard foreground and background colors""" - for color in ansi.Fg: - assert ansi.STD_FG_RE.match(str(color)) - assert not ansi.STD_BG_RE.match(str(color)) - for color in ansi.Bg: - assert ansi.STD_BG_RE.match(str(color)) - assert not ansi.STD_FG_RE.match(str(color)) - - # Test an invalid color code - assert not ansi.STD_FG_RE.match(f'{ansi.CSI}38m') - assert not ansi.STD_BG_RE.match(f'{ansi.CSI}48m') - - -def test_eight_bit_color_re() -> None: - """Test regular expressions for matching eight-bit foreground and background colors""" - for color in ansi.EightBitFg: - assert ansi.EIGHT_BIT_FG_RE.match(str(color)) - assert not ansi.EIGHT_BIT_BG_RE.match(str(color)) - for color in ansi.EightBitBg: - assert ansi.EIGHT_BIT_BG_RE.match(str(color)) - assert not ansi.EIGHT_BIT_FG_RE.match(str(color)) - - # Test invalid eight-bit value (256) - assert not ansi.EIGHT_BIT_FG_RE.match(f'{ansi.CSI}38;5;256m') - assert not ansi.EIGHT_BIT_BG_RE.match(f'{ansi.CSI}48;5;256m') - - -def test_rgb_color_re() -> None: - """Test regular expressions for matching RGB foreground and background colors""" - for i in range(256): - fg_color = ansi.RgbFg(i, i, i) - assert ansi.RGB_FG_RE.match(str(fg_color)) - assert not ansi.RGB_BG_RE.match(str(fg_color)) - - bg_color = ansi.RgbBg(i, i, i) - assert ansi.RGB_BG_RE.match(str(bg_color)) - assert not ansi.RGB_FG_RE.match(str(bg_color)) - - # Test invalid RGB value (256) - assert not ansi.RGB_FG_RE.match(f'{ansi.CSI}38;2;256;256;256m') - assert not ansi.RGB_BG_RE.match(f'{ansi.CSI}48;2;256;256;256m') diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 2ab59d29..bb9172e0 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -10,9 +10,7 @@ InteractiveConsole, ) from typing import NoReturn -from unittest import ( - mock, -) +from unittest import mock import pytest from rich.text import Text @@ -20,17 +18,20 @@ import cmd2 from cmd2 import ( COMMAND_NAME, - ansi, + Cmd2Style, + Color, clipboard, constants, exceptions, plugin, - rich_utils, + stylize, utils, ) -from cmd2.rl_utils import ( - readline, # This ensures gnureadline is used in macOS tests -) +from cmd2 import rich_utils as ru +from cmd2 import string_utils as su + +# This ensures gnureadline is used in macOS tests +from cmd2.rl_utils import readline # type: ignore[atrr-defined] from .conftest import ( SHORTCUTS_TXT, @@ -48,12 +49,12 @@ def arg_decorator(func): @functools.wraps(func) def cmd_wrapper(*args, **kwargs): - old = rich_utils.allow_style - rich_utils.allow_style = style + old = ru.ALLOW_STYLE + ru.ALLOW_STYLE = style try: retval = func(*args, **kwargs) finally: - rich_utils.allow_style = old + ru.ALLOW_STYLE = old return retval return cmd_wrapper @@ -232,31 +233,31 @@ def test_set_no_settables(base_app) -> None: @pytest.mark.parametrize( ('new_val', 'is_valid', 'expected'), [ - (rich_utils.AllowStyle.NEVER, True, rich_utils.AllowStyle.NEVER), - ('neVeR', True, rich_utils.AllowStyle.NEVER), - (rich_utils.AllowStyle.TERMINAL, True, rich_utils.AllowStyle.TERMINAL), - ('TeRMInal', True, rich_utils.AllowStyle.TERMINAL), - (rich_utils.AllowStyle.ALWAYS, True, rich_utils.AllowStyle.ALWAYS), - ('AlWaYs', True, rich_utils.AllowStyle.ALWAYS), - ('invalid', False, rich_utils.AllowStyle.TERMINAL), + (ru.AllowStyle.NEVER, True, ru.AllowStyle.NEVER), + ('neVeR', True, ru.AllowStyle.NEVER), + (ru.AllowStyle.TERMINAL, True, ru.AllowStyle.TERMINAL), + ('TeRMInal', True, ru.AllowStyle.TERMINAL), + (ru.AllowStyle.ALWAYS, True, ru.AllowStyle.ALWAYS), + ('AlWaYs', True, ru.AllowStyle.ALWAYS), + ('invalid', False, ru.AllowStyle.TERMINAL), ], ) def test_set_allow_style(base_app, new_val, is_valid, expected) -> None: # Initialize allow_style for this test - rich_utils.allow_style = rich_utils.AllowStyle.TERMINAL + ru.ALLOW_STYLE = ru.AllowStyle.TERMINAL # Use the set command to alter it out, err = run_cmd(base_app, f'set allow_style {new_val}') assert base_app.last_result is is_valid # Verify the results - assert rich_utils.allow_style == expected + assert expected == ru.ALLOW_STYLE if is_valid: assert not err assert out # Reset allow_style to its default since it's an application-wide setting that can affect other unit tests - rich_utils.allow_style = rich_utils.AllowStyle.TERMINAL + ru.ALLOW_STYLE = ru.AllowStyle.TERMINAL def test_set_with_choices(base_app) -> None: @@ -575,8 +576,8 @@ def test_relative_run_script_with_odd_file_names(base_app, file_name, monkeypatc run_script_mock = mock.MagicMock(name='do_run_script') monkeypatch.setattr("cmd2.Cmd.do_run_script", run_script_mock) - run_cmd(base_app, f"_relative_run_script {utils.quote_string(file_name)}") - run_script_mock.assert_called_once_with(utils.quote_string(file_name)) + run_cmd(base_app, f"_relative_run_script {su.quote(file_name)}") + run_script_mock.assert_called_once_with(su.quote(file_name)) def test_relative_run_script_requires_an_argument(base_app) -> None: @@ -987,9 +988,9 @@ def test_edit_file_with_odd_file_names(base_app, file_name, monkeypatch) -> None monkeypatch.setattr("cmd2.Cmd.do_shell", shell_mock) base_app.editor = 'fooedit' - file_name = utils.quote_string('nothingweird.py') - run_cmd(base_app, f"edit {utils.quote_string(file_name)}") - shell_mock.assert_called_once_with(f'"fooedit" {utils.quote_string(file_name)}') + file_name = su.quote('nothingweird.py') + run_cmd(base_app, f"edit {su.quote(file_name)}") + shell_mock.assert_called_once_with(f'"fooedit" {su.quote(file_name)}') def test_edit_file_with_spaces(base_app, request, monkeypatch) -> None: @@ -1221,8 +1222,7 @@ def test_escaping_prompt() -> None: assert rl_escape_prompt(prompt) == prompt # This prompt has color which needs to be escaped - color = ansi.Fg.CYAN - prompt = ansi.style('InColor', fg=color) + prompt = stylize('InColor', style=Color.CYAN) escape_start = "\x01" escape_end = "\x02" @@ -1232,8 +1232,10 @@ def test_escaping_prompt() -> None: # PyReadline on Windows doesn't need to escape invisible characters assert escaped_prompt == prompt else: - assert escaped_prompt.startswith(escape_start + color + escape_end) - assert escaped_prompt.endswith(escape_start + ansi.Fg.RESET + escape_end) + cyan = "\x1b[36m" + reset_all = "\x1b[0m" + assert escaped_prompt.startswith(escape_start + cyan + escape_end) + assert escaped_prompt.endswith(escape_start + reset_all + escape_end) assert rl_unescape_prompt(escaped_prompt) == prompt @@ -2035,7 +2037,7 @@ def test_poutput_none(outsim_app) -> None: assert out == expected -@with_ansi_style(rich_utils.AllowStyle.ALWAYS) +@with_ansi_style(ru.AllowStyle.ALWAYS) def test_poutput_ansi_always(outsim_app) -> None: msg = 'Hello World' colored_msg = Text(msg, style="cyan") @@ -2044,7 +2046,7 @@ def test_poutput_ansi_always(outsim_app) -> None: assert out == "\x1b[36mHello World\x1b[0m\n" -@with_ansi_style(rich_utils.AllowStyle.NEVER) +@with_ansi_style(ru.AllowStyle.NEVER) def test_poutput_ansi_never(outsim_app) -> None: msg = 'Hello World' colored_msg = Text(msg, style="cyan") @@ -2054,7 +2056,7 @@ def test_poutput_ansi_never(outsim_app) -> None: assert out == expected -@with_ansi_style(rich_utils.AllowStyle.TERMINAL) +@with_ansi_style(ru.AllowStyle.TERMINAL) def test_poutput_ansi_terminal(outsim_app) -> None: """Test that AllowStyle.TERMINAL strips style when redirecting.""" msg = 'testing...' @@ -2495,7 +2497,7 @@ def test_nonexistent_macro(base_app) -> None: assert exception is not None -@with_ansi_style(rich_utils.AllowStyle.ALWAYS) +@with_ansi_style(ru.AllowStyle.ALWAYS) def test_perror_style(base_app, capsys) -> None: msg = 'testing...' base_app.perror(msg) @@ -2503,7 +2505,7 @@ def test_perror_style(base_app, capsys) -> None: assert err == "\x1b[91mtesting...\x1b[0m\x1b[91m\n\x1b[0m" -@with_ansi_style(rich_utils.AllowStyle.ALWAYS) +@with_ansi_style(ru.AllowStyle.ALWAYS) def test_perror_no_style(base_app, capsys) -> None: msg = 'testing...' end = '\n' @@ -2512,7 +2514,7 @@ def test_perror_no_style(base_app, capsys) -> None: assert err == msg + end -@with_ansi_style(rich_utils.AllowStyle.ALWAYS) +@with_ansi_style(ru.AllowStyle.ALWAYS) def test_pexcept_style(base_app, capsys) -> None: msg = Exception('testing...') @@ -2521,7 +2523,7 @@ def test_pexcept_style(base_app, capsys) -> None: assert err.startswith("\x1b[91mEXCEPTION of type 'Exception' occurred with message: testing") -@with_ansi_style(rich_utils.AllowStyle.NEVER) +@with_ansi_style(ru.AllowStyle.NEVER) def test_pexcept_no_style(base_app, capsys) -> None: msg = Exception('testing...') @@ -2530,7 +2532,7 @@ def test_pexcept_no_style(base_app, capsys) -> None: assert err.startswith("EXCEPTION of type 'Exception' occurred with message: testing...") -@with_ansi_style(rich_utils.AllowStyle.ALWAYS) +@with_ansi_style(ru.AllowStyle.ALWAYS) def test_pexcept_not_exception(base_app, capsys) -> None: # Pass in a msg that is not an Exception object msg = False @@ -2755,12 +2757,12 @@ def do_echo(self, args) -> None: self.perror(args) def do_echo_error(self, args) -> None: - self.poutput(ansi.style(args, fg=ansi.Fg.RED)) + self.poutput(args, style=Cmd2Style.ERROR) # perror uses colors by default self.perror(args) -@with_ansi_style(rich_utils.AllowStyle.ALWAYS) +@with_ansi_style(ru.AllowStyle.ALWAYS) def test_ansi_pouterr_always_tty(mocker, capsys) -> None: app = AnsiApp() mocker.patch.object(app.stdout, 'isatty', return_value=True) @@ -2783,7 +2785,7 @@ def test_ansi_pouterr_always_tty(mocker, capsys) -> None: assert 'oopsie' in err -@with_ansi_style(rich_utils.AllowStyle.ALWAYS) +@with_ansi_style(ru.AllowStyle.ALWAYS) def test_ansi_pouterr_always_notty(mocker, capsys) -> None: app = AnsiApp() mocker.patch.object(app.stdout, 'isatty', return_value=False) @@ -2806,7 +2808,7 @@ def test_ansi_pouterr_always_notty(mocker, capsys) -> None: assert 'oopsie' in err -@with_ansi_style(rich_utils.AllowStyle.TERMINAL) +@with_ansi_style(ru.AllowStyle.TERMINAL) def test_ansi_terminal_tty(mocker, capsys) -> None: app = AnsiApp() mocker.patch.object(app.stdout, 'isatty', return_value=True) @@ -2828,7 +2830,7 @@ def test_ansi_terminal_tty(mocker, capsys) -> None: assert 'oopsie' in err -@with_ansi_style(rich_utils.AllowStyle.TERMINAL) +@with_ansi_style(ru.AllowStyle.TERMINAL) def test_ansi_terminal_notty(mocker, capsys) -> None: app = AnsiApp() mocker.patch.object(app.stdout, 'isatty', return_value=False) @@ -2843,7 +2845,7 @@ def test_ansi_terminal_notty(mocker, capsys) -> None: assert out == err == 'oopsie\n' -@with_ansi_style(rich_utils.AllowStyle.NEVER) +@with_ansi_style(ru.AllowStyle.NEVER) def test_ansi_never_tty(mocker, capsys) -> None: app = AnsiApp() mocker.patch.object(app.stdout, 'isatty', return_value=True) @@ -2858,7 +2860,7 @@ def test_ansi_never_tty(mocker, capsys) -> None: assert out == err == 'oopsie\n' -@with_ansi_style(rich_utils.AllowStyle.NEVER) +@with_ansi_style(ru.AllowStyle.NEVER) def test_ansi_never_notty(mocker, capsys) -> None: app = AnsiApp() mocker.patch.object(app.stdout, 'isatty', return_value=False) @@ -3071,7 +3073,7 @@ def test_startup_script_with_odd_file_names(startup_script) -> None: app = cmd2.Cmd(allow_cli_args=False, startup_script=startup_script) assert len(app._startup_commands) == 1 - assert app._startup_commands[0] == f"run_script {utils.quote_string(os.path.abspath(startup_script))}" + assert app._startup_commands[0] == f"run_script {su.quote(os.path.abspath(startup_script))}" # Restore os.path.exists os.path.exists = saved_exists @@ -3083,15 +3085,6 @@ def test_transcripts_at_init() -> None: assert app._transcript_files == transcript_files -def test_columnize_too_wide(outsim_app) -> None: - """Test calling columnize with output that wider than display_width""" - str_list = ["way too wide", "much wider than the first"] - outsim_app.columnize(str_list, display_width=5) - - expected = "\n".join(str_list) + "\n" - assert outsim_app.stdout.getvalue() == expected - - def test_command_parser_retrieval(outsim_app: cmd2.Cmd) -> None: # Pass something that isn't a method not_a_method = "just a string" diff --git a/tests/test_completion.py b/tests/test_completion.py index 702d5bd5..95e0f314 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -8,19 +8,13 @@ import os import sys from typing import NoReturn -from unittest import ( - mock, -) +from unittest import mock import pytest import cmd2 -from cmd2 import ( - utils, -) -from examples.subcommands import ( - SubcommandsExample, -) +from cmd2 import utils +from examples.subcommands import SubcommandsExample from .conftest import ( complete_tester, diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 711868ca..b7af3714 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -8,8 +8,8 @@ from cmd2 import ( constants, exceptions, - utils, ) +from cmd2 import string_utils as su from cmd2.parsing import ( Statement, StatementParser, @@ -140,7 +140,7 @@ def test_parse_single_word(parser, line) -> None: statement = parser.parse(line) assert statement.command == line assert statement == '' - assert statement.argv == [utils.strip_quotes(line)] + assert statement.argv == [su.strip_quotes(line)] assert not statement.arg_list assert statement.args == statement assert statement.raw == line diff --git a/tests/test_run_pyscript.py b/tests/test_run_pyscript.py index 78739dc4..0d21379a 100644 --- a/tests/test_run_pyscript.py +++ b/tests/test_run_pyscript.py @@ -8,7 +8,7 @@ import pytest -from cmd2 import utils +from cmd2.string_utils import quote from .conftest import ( odd_file_names, @@ -63,7 +63,7 @@ def test_run_pyscript_with_odd_file_names(base_app, python_script) -> None: input_mock = mock.MagicMock(name='input', return_value='1') builtins.input = input_mock - out, err = run_cmd(base_app, f"run_pyscript {utils.quote_string(python_script)}") + out, err = run_cmd(base_app, f"run_pyscript {quote(python_script)}") err = ''.join(err) assert f"Error reading script file '{python_script}'" in err assert base_app.last_result is False diff --git a/tests/test_string_utils.py b/tests/test_string_utils.py new file mode 100644 index 00000000..f0c95516 --- /dev/null +++ b/tests/test_string_utils.py @@ -0,0 +1,217 @@ +"""Unit testing for cmd2/string_utils.py module""" + +from rich.style import Style + +from cmd2 import Color +from cmd2 import rich_utils as ru +from cmd2 import string_utils as su + +HELLO_WORLD = 'Hello, world!' + + +def test_align_blank() -> None: + text = '' + character = '-' + width = 5 + aligned = su.align(text, "left", width=width, character=character) + assert aligned == character * width + + +def test_align_wider_than_width() -> None: + text = 'long text field' + character = '-' + width = 8 + aligned = su.align(text, "left", width=width, character=character) + assert aligned == text[:width] + + +def test_align_term_width() -> None: + text = 'foo' + character = ' ' + + term_width = ru.console_width() + expected_padding = (term_width - su.str_width(text)) * character + + aligned = su.align(text, "left", character=character) + assert aligned == text + expected_padding + + +def test_align_left() -> None: + text = 'foo' + character = '-' + width = 5 + aligned = su.align_left(text, width=width, character=character) + assert aligned == text + character * 2 + + +def test_align_left_wide_text() -> None: + text = '苹' + character = '-' + width = 4 + aligned = su.align_left(text, width=width, character=character) + assert aligned == text + character * 2 + + +def test_align_left_with_style() -> None: + character = '-' + + styled_text = su.stylize('table', style=Color.BRIGHT_BLUE) + width = 8 + + aligned = su.align_left(styled_text, width=width, character=character) + assert aligned == styled_text + character * 3 + + +def test_align_center() -> None: + text = 'foo' + character = '-' + width = 5 + aligned = su.align_center(text, width=width, character=character) + assert aligned == character + text + character + + +def test_align_center_wide_text() -> None: + text = '苹' + character = '-' + width = 4 + aligned = su.align_center(text, width=width, character=character) + assert aligned == character + text + character + + +def test_align_center_with_style() -> None: + character = '-' + + styled_text = su.stylize('table', style=Color.BRIGHT_BLUE) + width = 8 + + aligned = su.align_center(styled_text, width=width, character=character) + assert aligned == character + styled_text + character * 2 + + +def test_align_right() -> None: + text = 'foo' + character = '-' + width = 5 + aligned = su.align_right(text, width=width, character=character) + assert aligned == character * 2 + text + + +def test_align_right_wide_text() -> None: + text = '苹' + character = '-' + width = 4 + aligned = su.align_right(text, width=width, character=character) + assert aligned == character * 2 + text + + +def test_align_right_with_style() -> None: + character = '-' + + styled_text = su.stylize('table', style=Color.BRIGHT_BLUE) + width = 8 + + aligned = su.align_right(styled_text, width=width, character=character) + assert aligned == character * 3 + styled_text + + +def test_stylize() -> None: + styled_str = su.stylize( + HELLO_WORLD, + style=Style( + color=Color.GREEN, + bgcolor=Color.BLUE, + bold=True, + underline=True, + ), + ) + + assert styled_str == "\x1b[1;4;32;44mHello, world!\x1b[0m" + + +def test_strip_style() -> None: + base_str = HELLO_WORLD + styled_str = su.stylize(base_str, style=Color.GREEN) + assert base_str != styled_str + assert base_str == su.strip_style(styled_str) + + +def test_str_width() -> None: + # Include a full-width character + base_str = HELLO_WORLD + "深" + styled_str = su.stylize(base_str, style=Color.GREEN) + expected_width = len(HELLO_WORLD) + 2 + assert su.str_width(base_str) == su.str_width(styled_str) == expected_width + + +def test_is_quoted_short() -> None: + my_str = '' + assert not su.is_quoted(my_str) + your_str = '"' + assert not su.is_quoted(your_str) + + +def test_is_quoted_yes() -> None: + my_str = '"This is a test"' + assert su.is_quoted(my_str) + your_str = "'of the emergengy broadcast system'" + assert su.is_quoted(your_str) + + +def test_is_quoted_no() -> None: + my_str = '"This is a test' + assert not su.is_quoted(my_str) + your_str = "of the emergengy broadcast system'" + assert not su.is_quoted(your_str) + simple_str = "hello world" + assert not su.is_quoted(simple_str) + + +def test_quote() -> None: + my_str = "Hello World" + assert su.quote(my_str) == '"' + my_str + '"' + + my_str = "'Hello World'" + assert su.quote(my_str) == '"' + my_str + '"' + + my_str = '"Hello World"' + assert su.quote(my_str) == "'" + my_str + "'" + + +def test_quote_if_needed_yes() -> None: + my_str = "Hello World" + assert su.quote_if_needed(my_str) == '"' + my_str + '"' + your_str = '"foo" bar' + assert su.quote_if_needed(your_str) == "'" + your_str + "'" + + +def test_quote_if_needed_no() -> None: + my_str = "HelloWorld" + assert su.quote_if_needed(my_str) == my_str + your_str = "'Hello World'" + assert su.quote_if_needed(your_str) == your_str + + +def test_strip_quotes_no_quotes() -> None: + base_str = HELLO_WORLD + stripped = su.strip_quotes(base_str) + assert base_str == stripped + + +def test_strip_quotes_with_quotes() -> None: + base_str = '"' + HELLO_WORLD + '"' + stripped = su.strip_quotes(base_str) + assert stripped == HELLO_WORLD + + +def test_unicode_normalization() -> None: + s1 = 'café' + s2 = 'cafe\u0301' + assert s1 != s2 + assert su.norm_fold(s1) == su.norm_fold(s2) + + +def test_unicode_casefold() -> None: + micro = 'µ' + micro_cf = micro.casefold() + assert micro != micro_cf + assert su.norm_fold(micro) == su.norm_fold(micro_cf) diff --git a/tests/test_table_creator.py b/tests/test_table_creator.py deleted file mode 100644 index caf19b7e..00000000 --- a/tests/test_table_creator.py +++ /dev/null @@ -1,725 +0,0 @@ -"""Unit testing for cmd2/table_creator.py module""" - -import pytest - -from cmd2 import ( - Bg, - Fg, - TextStyle, - ansi, -) -from cmd2.table_creator import ( - AlternatingTable, - BorderedTable, - Column, - HorizontalAlignment, - SimpleTable, - TableCreator, - VerticalAlignment, -) - -# Turn off black formatting for entire file so multiline strings -# can be visually aligned to match the tables being tested. -# fmt: off - - -def test_column_creation() -> None: - # Width less than 1 - with pytest.raises(ValueError, match="Column width cannot be less than 1"): - Column("Column 1", width=0) - - # Width specified - c = Column("header", width=20) - assert c.width == 20 - - # max_data_lines less than 1 - with pytest.raises(ValueError, match="Max data lines cannot be less than 1"): - Column("Column 1", max_data_lines=0) - - # No width specified, blank label - c = Column("") - assert c.width < 0 - tc = TableCreator([c]) - assert tc.cols[0].width == 1 - - # No width specified, label isn't blank but has no width - c = Column(ansi.style('', fg=Fg.GREEN)) - assert c.width < 0 - tc = TableCreator([c]) - assert tc.cols[0].width == 1 - - # No width specified, label has width - c = Column("a line") - assert c.width < 0 - tc = TableCreator([c]) - assert tc.cols[0].width == ansi.style_aware_wcswidth("a line") - - # No width specified, label has width and multiple lines - c = Column("short\nreally long") - assert c.width < 0 - tc = TableCreator([c]) - assert tc.cols[0].width == ansi.style_aware_wcswidth("really long") - - # No width specified, label has tabs - c = Column("line\twith\ttabs") - assert c.width < 0 - tc = TableCreator([c]) - assert tc.cols[0].width == ansi.style_aware_wcswidth("line with tabs") - - # Add basic tests for style_header_text and style_data_text to make sure these members don't get removed. - c = Column("Column 1") - assert c.style_header_text is True - assert c.style_data_text is True - - c = Column("Column 1", style_header_text=False) - assert c.style_header_text is False - assert c.style_data_text is True - - c = Column("Column 1", style_data_text=False) - assert c.style_header_text is True - assert c.style_data_text is False - - -def test_column_alignment() -> None: - column_1 = Column( - "Col 1", - width=10, - header_horiz_align=HorizontalAlignment.LEFT, - header_vert_align=VerticalAlignment.TOP, - data_horiz_align=HorizontalAlignment.RIGHT, - data_vert_align=VerticalAlignment.BOTTOM, - ) - column_2 = Column( - "Col 2", - width=10, - header_horiz_align=HorizontalAlignment.RIGHT, - header_vert_align=VerticalAlignment.BOTTOM, - data_horiz_align=HorizontalAlignment.CENTER, - data_vert_align=VerticalAlignment.MIDDLE, - ) - column_3 = Column( - "Col 3", - width=10, - header_horiz_align=HorizontalAlignment.CENTER, - header_vert_align=VerticalAlignment.MIDDLE, - data_horiz_align=HorizontalAlignment.LEFT, - data_vert_align=VerticalAlignment.TOP, - ) - column_4 = Column("Three\nline\nheader", width=10) - - columns = [column_1, column_2, column_3, column_4] - tc = TableCreator(columns) - - # Check defaults - assert column_4.header_horiz_align == HorizontalAlignment.LEFT - assert column_4.header_vert_align == VerticalAlignment.BOTTOM - assert column_4.data_horiz_align == HorizontalAlignment.LEFT - assert column_4.data_vert_align == VerticalAlignment.TOP - - # Create a header row - row_data = [col.header for col in columns] - header = tc.generate_row(row_data=row_data, is_header=True) - assert header == ( - 'Col 1 Three \n' - ' Col 3 line \n' - ' Col 2 header ' - ) - - # Create a data row - row_data = ["Val 1", "Val 2", "Val 3", "Three\nline\ndata"] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ( - ' Val 3 Three \n' - ' Val 2 line \n' - ' Val 1 data ' - ) - - -def test_blank_last_line() -> None: - """This tests that an empty line is inserted when the last data line is blank""" - column_1 = Column("Col 1", width=10) - tc = TableCreator([column_1]) - - row_data = ['my line\n\n'] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ('my line \n' - ' ') - - row_data = ['\n'] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ' ' - - row_data = [''] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ' ' - - -def test_wrap_text() -> None: - column_1 = Column("Col 1", width=10) - tc = TableCreator([column_1]) - - # Test normal wrapping - row_data = ['Some text to wrap\nA new line that will wrap\nNot wrap\n 1 2 3'] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ('Some text \n' - 'to wrap \n' - 'A new line\n' - 'that will \n' - 'wrap \n' - 'Not wrap \n' - ' 1 2 3 ') - - # Test preserving a multiple space sequence across a line break - row_data = ['First last one'] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ('First \n' - ' last one ') - - -def test_wrap_text_max_lines() -> None: - column_1 = Column("Col 1", width=10, max_data_lines=2) - tc = TableCreator([column_1]) - - # Test not needing to truncate the final line - row_data = ['First line last line'] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ('First line\n' - 'last line ') - - # Test having to truncate the last word because it's too long for the final line - row_data = ['First line last lineextratext'] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ('First line\n' - 'last line…') - - # Test having to truncate the last word because it fits the final line but there is more text not being included - row_data = ['First line thistxtfit extra'] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ('First line\n' - 'thistxtfi…') - - # Test having to truncate the last word because it fits the final line but there are more lines not being included - row_data = ['First line thistxtfit\nextra'] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ('First line\n' - 'thistxtfi…') - - # Test having space left on the final line and adding an ellipsis because there are more lines not being included - row_data = ['First line last line\nextra line'] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ('First line\n' - 'last line…') - - -def test_wrap_long_word() -> None: - # Make sure words wider than column start on own line and wrap - column_1 = Column("LongColumnName", width=10) - column_2 = Column("Col 2", width=10) - - columns = [column_1, column_2] - tc = TableCreator(columns) - - # Test header row - row_data = [col.header for col in columns] - header = tc.generate_row(row_data, is_header=True) - assert header == ('LongColumn \n' - 'Name Col 2 ') - - # Test data row - row_data = [] - - # Long word should start on the first line (style should not affect width) - row_data.append(ansi.style("LongerThan10", fg=Fg.GREEN)) - - # Long word should start on the second line - row_data.append("Word LongerThan10") - - row = tc.generate_row(row_data=row_data, is_header=False) - expected = ( - TextStyle.RESET_ALL - + Fg.GREEN - + "LongerThan" - + TextStyle.RESET_ALL - + " Word \n" - + TextStyle.RESET_ALL - + Fg.GREEN - + "10" - + Fg.RESET - + TextStyle.RESET_ALL - + ' ' - + TextStyle.RESET_ALL - + ' LongerThan\n' - ' 10 ' - ) - assert row == expected - - -def test_wrap_long_word_max_data_lines() -> None: - column_1 = Column("Col 1", width=10, max_data_lines=2) - column_2 = Column("Col 2", width=10, max_data_lines=2) - column_3 = Column("Col 3", width=10, max_data_lines=2) - column_4 = Column("Col 4", width=10, max_data_lines=1) - - columns = [column_1, column_2, column_3, column_4] - tc = TableCreator(columns) - - row_data = [] - - # This long word will exactly fit the last line and it's the final word in the text. No ellipsis should appear. - row_data.append("LongerThan10FitsLast") - - # This long word will exactly fit the last line but it's not the final word in the text. - # Make sure ellipsis word's final character. - row_data.append("LongerThan10FitsLast\nMore lines") - - # This long word will run over the last line. Make sure it is truncated. - row_data.append("LongerThan10RunsOverLast") - - # This long word will start on the final line after another word. Therefore it won't wrap but will instead be truncated. - row_data.append("A LongerThan10RunsOverLast") - - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ('LongerThan LongerThan LongerThan A LongerT…\n' - '10FitsLast 10FitsLas… 10RunsOve… ') - - -def test_wrap_long_char_wider_than_max_width() -> None: - """This tests case where a character is wider than max_width. This can happen if max_width - is 1 and the text contains wide characters (e.g. East Asian). Replace it with an ellipsis. - """ - column_1 = Column("Col 1", width=1) - tc = TableCreator([column_1]) - row = tc.generate_row(row_data=['深'], is_header=False) - assert row == '…' - - -def test_generate_row_exceptions() -> None: - column_1 = Column("Col 1") - tc = TableCreator([column_1]) - row_data = ['fake'] - - # fill_char too long - with pytest.raises(TypeError) as excinfo: - tc.generate_row(row_data=row_data, is_header=False, fill_char='too long') - assert "Fill character must be exactly one character long" in str(excinfo.value) - - # Unprintable characters - for arg in ['fill_char', 'pre_line', 'inter_cell', 'post_line']: - kwargs = {arg: '\n'} - with pytest.raises(ValueError, match=f"{arg} contains an unprintable character"): - tc.generate_row(row_data=row_data, is_header=False, **kwargs) - - # Data with too many columns - row_data = ['Data 1', 'Extra Column'] - with pytest.raises(ValueError, match="Length of row_data must match length of cols"): - tc.generate_row(row_data=row_data, is_header=False) - - -def test_tabs() -> None: - column_1 = Column("Col\t1", width=20) - column_2 = Column("Col 2") - columns = [column_1, column_2] - tc = TableCreator(columns, tab_width=2) - - row_data = [col.header for col in columns] - row = tc.generate_row(row_data, is_header=True, fill_char='\t', pre_line='\t', inter_cell='\t', post_line='\t') - assert row == ' Col 1 Col 2 ' - - with pytest.raises(ValueError, match="Tab width cannot be less than 1" ): - TableCreator([column_1, column_2], tab_width=0) - - -def test_simple_table_creation() -> None: - column_1 = Column("Col 1", width=16) - column_2 = Column("Col 2", width=16) - - row_data = [] - row_data.append(["Col 1 Row 1", "Col 2 Row 1"]) - row_data.append(["Col 1 Row 2", "Col 2 Row 2"]) - - # Default options - st = SimpleTable([column_1, column_2]) - table = st.generate_table(row_data) - - assert table == ( - 'Col 1 Col 2 \n' - '----------------------------------\n' - 'Col 1 Row 1 Col 2 Row 1 \n' - ' \n' - 'Col 1 Row 2 Col 2 Row 2 ' - ) - - # Custom column spacing - st = SimpleTable([column_1, column_2], column_spacing=5) - table = st.generate_table(row_data) - - assert table == ( - 'Col 1 Col 2 \n' - '-------------------------------------\n' - 'Col 1 Row 1 Col 2 Row 1 \n' - ' \n' - 'Col 1 Row 2 Col 2 Row 2 ' - ) - - # Custom divider - st = SimpleTable([column_1, column_2], divider_char='─') - table = st.generate_table(row_data) - - assert table == ( - 'Col 1 Col 2 \n' - '──────────────────────────────────\n' - 'Col 1 Row 1 Col 2 Row 1 \n' - ' \n' - 'Col 1 Row 2 Col 2 Row 2 ' - ) - - # No divider - st = SimpleTable([column_1, column_2], divider_char=None) - no_divider_1 = st.generate_table(row_data) - - st = SimpleTable([column_1, column_2], divider_char='') - no_divider_2 = st.generate_table(row_data) - - assert no_divider_1 == no_divider_2 == ( - 'Col 1 Col 2 \n' - 'Col 1 Row 1 Col 2 Row 1 \n' - ' \n' - 'Col 1 Row 2 Col 2 Row 2 ' - ) - - # No row spacing - st = SimpleTable([column_1, column_2]) - table = st.generate_table(row_data, row_spacing=0) - assert table == ( - 'Col 1 Col 2 \n' - '----------------------------------\n' - 'Col 1 Row 1 Col 2 Row 1 \n' - 'Col 1 Row 2 Col 2 Row 2 ' - ) - - # No header - st = SimpleTable([column_1, column_2]) - table = st.generate_table(row_data, include_header=False) - - assert table == ('Col 1 Row 1 Col 2 Row 1 \n' - ' \n' - 'Col 1 Row 2 Col 2 Row 2 ') - - # Wide custom divider (divider needs no padding) - st = SimpleTable([column_1, column_2], divider_char='深') - table = st.generate_table(row_data) - - assert table == ( - 'Col 1 Col 2 \n' - '深深深深深深深深深深深深深深深深深\n' - 'Col 1 Row 1 Col 2 Row 1 \n' - ' \n' - 'Col 1 Row 2 Col 2 Row 2 ' - ) - - # Wide custom divider (divider needs padding) - st = SimpleTable([column_1, Column("Col 2", width=17)], - divider_char='深') - table = st.generate_table(row_data) - - assert table == ( - 'Col 1 Col 2 \n' - '深深深深深深深深深深深深深深深深深 \n' - 'Col 1 Row 1 Col 2 Row 1 \n' - ' \n' - 'Col 1 Row 2 Col 2 Row 2 ' - ) - - # Invalid column spacing - with pytest.raises(ValueError, match="Column spacing cannot be less than 0"): - SimpleTable([column_1, column_2], column_spacing=-1) - - # Invalid divider character - with pytest.raises(TypeError, match="Divider character must be exactly one character long"): - SimpleTable([column_1, column_2], divider_char='too long') - - with pytest.raises(ValueError, match="Divider character is an unprintable character"): - SimpleTable([column_1, column_2], divider_char='\n') - - # Invalid row spacing - st = SimpleTable([column_1, column_2]) - with pytest.raises(ValueError, match="Row spacing cannot be less than 0"): - st.generate_table(row_data, row_spacing=-1) - - # Test header and data colors - st = SimpleTable([column_1, column_2], divider_char=None, header_bg=Bg.GREEN, data_bg=Bg.LIGHT_BLUE) - table = st.generate_table(row_data) - assert table == ( - '\x1b[0m\x1b[42mCol 1\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42mCol 2\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\n' - '\x1b[0m\x1b[104mCol 1 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 2 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\n' - '\x1b[0m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\n' - '\x1b[0m\x1b[104mCol 1 Row 2\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 2 Row 2\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m' - ) - - # Make sure SimpleTable respects style_header_text and style_data_text flags. - # Don't apply parent table's background colors to header or data text in second column. - st = SimpleTable([column_1, Column("Col 2", width=16, style_header_text=False, style_data_text=False)], - divider_char=None, header_bg=Bg.GREEN, data_bg=Bg.LIGHT_BLUE) - table = st.generate_table(row_data) - assert table == ( - '\x1b[0m\x1b[42mCol 1\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0mCol 2\x1b[0m\x1b[42m \x1b[49m\x1b[0m\n' - '\x1b[0m\x1b[104mCol 1 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0mCol 2 Row 1\x1b[0m\x1b[104m \x1b[49m\x1b[0m\n' - '\x1b[0m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\n' - '\x1b[0m\x1b[104mCol 1 Row 2\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0mCol 2 Row 2\x1b[0m\x1b[104m \x1b[49m\x1b[0m' - ) - - -def test_simple_table_width() -> None: - # Base width - for num_cols in range(1, 10): - assert SimpleTable.base_width(num_cols) == (num_cols - 1) * 2 - - # Invalid num_cols value - with pytest.raises(ValueError, match="Column count cannot be less than 1"): - SimpleTable.base_width(0) - - # Total width - column_1 = Column("Col 1", width=16) - column_2 = Column("Col 2", width=16) - - row_data = [] - row_data.append(["Col 1 Row 1", "Col 2 Row 1"]) - row_data.append(["Col 1 Row 2", "Col 2 Row 2"]) - - st = SimpleTable([column_1, column_2]) - assert st.total_width() == 34 - - -def test_simple_generate_data_row_exceptions() -> None: - column_1 = Column("Col 1") - tc = SimpleTable([column_1]) - - # Data with too many columns - row_data = ['Data 1', 'Extra Column'] - with pytest.raises(ValueError, match="Length of row_data must match length of cols"): - tc.generate_data_row(row_data=row_data) - - -def test_bordered_table_creation() -> None: - column_1 = Column("Col 1", width=15) - column_2 = Column("Col 2", width=15) - - row_data = [] - row_data.append(["Col 1 Row 1", "Col 2 Row 1"]) - row_data.append(["Col 1 Row 2", "Col 2 Row 2"]) - - # Default options - bt = BorderedTable([column_1, column_2]) - table = bt.generate_table(row_data) - assert table == ( - '╔═════════════════╤═════════════════╗\n' - '║ Col 1 │ Col 2 ║\n' - '╠═════════════════╪═════════════════╣\n' - '║ Col 1 Row 1 │ Col 2 Row 1 ║\n' - '╟─────────────────┼─────────────────╢\n' - '║ Col 1 Row 2 │ Col 2 Row 2 ║\n' - '╚═════════════════╧═════════════════╝' - ) - - # No column borders - bt = BorderedTable([column_1, column_2], column_borders=False) - table = bt.generate_table(row_data) - assert table == ( - '╔══════════════════════════════════╗\n' - '║ Col 1 Col 2 ║\n' - '╠══════════════════════════════════╣\n' - '║ Col 1 Row 1 Col 2 Row 1 ║\n' - '╟──────────────────────────────────╢\n' - '║ Col 1 Row 2 Col 2 Row 2 ║\n' - '╚══════════════════════════════════╝' - ) - - # No header - bt = BorderedTable([column_1, column_2]) - table = bt.generate_table(row_data, include_header=False) - assert table == ( - '╔═════════════════╤═════════════════╗\n' - '║ Col 1 Row 1 │ Col 2 Row 1 ║\n' - '╟─────────────────┼─────────────────╢\n' - '║ Col 1 Row 2 │ Col 2 Row 2 ║\n' - '╚═════════════════╧═════════════════╝' - ) - - # Non-default padding - bt = BorderedTable([column_1, column_2], padding=2) - table = bt.generate_table(row_data) - assert table == ( - '╔═══════════════════╤═══════════════════╗\n' - '║ Col 1 │ Col 2 ║\n' - '╠═══════════════════╪═══════════════════╣\n' - '║ Col 1 Row 1 │ Col 2 Row 1 ║\n' - '╟───────────────────┼───────────────────╢\n' - '║ Col 1 Row 2 │ Col 2 Row 2 ║\n' - '╚═══════════════════╧═══════════════════╝' - ) - - # Invalid padding - with pytest.raises(ValueError, match="Padding cannot be less than 0"): - BorderedTable([column_1, column_2], padding=-1) - - # Test border, header, and data colors - bt = BorderedTable([column_1, column_2], border_fg=Fg.LIGHT_YELLOW, border_bg=Bg.WHITE, - header_bg=Bg.GREEN, data_bg=Bg.LIGHT_BLUE) - table = bt.generate_table(row_data) - assert table == ( - '\x1b[93m\x1b[107m╔═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╤═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╗\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\x1b[42m \x1b[49m\x1b[0m\x1b[42mCol 1\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[93m\x1b[107m│\x1b[39m\x1b[49m\x1b[42m \x1b[49m\x1b[0m\x1b[42mCol 2\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m╠═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╪═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╣\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 1 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[93m\x1b[107m│\x1b[39m\x1b[49m\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 2 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m╟─\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m───────────────\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m─┼─\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m───────────────\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m─╢\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 1 Row 2\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[93m\x1b[107m│\x1b[39m\x1b[49m\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 2 Row 2\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m╚═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╧═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╝\x1b[39m\x1b[49m' - ) - - # Make sure BorderedTable respects style_header_text and style_data_text flags. - # Don't apply parent table's background colors to header or data text in second column. - bt = BorderedTable([column_1, Column("Col 2", width=15, style_header_text=False, style_data_text=False)], - header_bg=Bg.GREEN, data_bg=Bg.LIGHT_BLUE) - table = bt.generate_table(row_data) - assert table == ( - '╔═════════════════╤═════════════════╗\n' - '║\x1b[42m \x1b[49m\x1b[0m\x1b[42mCol 1\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m│\x1b[42m \x1b[49m\x1b[0mCol 2\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m║\n' - '╠═════════════════╪═════════════════╣\n' - '║\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 1 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m│\x1b[104m \x1b[49m\x1b[0mCol 2 Row 1\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m║\n' - '╟─────────────────┼─────────────────╢\n' - '║\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 1 Row 2\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m│\x1b[104m \x1b[49m\x1b[0mCol 2 Row 2\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m║\n' - '╚═════════════════╧═════════════════╝' - ) - - -def test_bordered_table_width() -> None: - # Default behavior (column_borders=True, padding=1) - assert BorderedTable.base_width(1) == 4 - assert BorderedTable.base_width(2) == 7 - assert BorderedTable.base_width(3) == 10 - - # No column borders - assert BorderedTable.base_width(1, column_borders=False) == 4 - assert BorderedTable.base_width(2, column_borders=False) == 6 - assert BorderedTable.base_width(3, column_borders=False) == 8 - - # No padding - assert BorderedTable.base_width(1, padding=0) == 2 - assert BorderedTable.base_width(2, padding=0) == 3 - assert BorderedTable.base_width(3, padding=0) == 4 - - # Extra padding - assert BorderedTable.base_width(1, padding=3) == 8 - assert BorderedTable.base_width(2, padding=3) == 15 - assert BorderedTable.base_width(3, padding=3) == 22 - - # Invalid num_cols value - with pytest.raises(ValueError, match="Column count cannot be less than 1"): - BorderedTable.base_width(0) - - # Total width - column_1 = Column("Col 1", width=15) - column_2 = Column("Col 2", width=15) - - row_data = [] - row_data.append(["Col 1 Row 1", "Col 2 Row 1"]) - row_data.append(["Col 1 Row 2", "Col 2 Row 2"]) - - bt = BorderedTable([column_1, column_2]) - assert bt.total_width() == 37 - - -def test_bordered_generate_data_row_exceptions() -> None: - column_1 = Column("Col 1") - tc = BorderedTable([column_1]) - - # Data with too many columns - row_data = ['Data 1', 'Extra Column'] - with pytest.raises(ValueError, match="Length of row_data must match length of cols"): - tc.generate_data_row(row_data=row_data) - - -def test_alternating_table_creation() -> None: - column_1 = Column("Col 1", width=15) - column_2 = Column("Col 2", width=15) - - row_data = [] - row_data.append(["Col 1 Row 1", "Col 2 Row 1"]) - row_data.append(["Col 1 Row 2", "Col 2 Row 2"]) - - # Default options - at = AlternatingTable([column_1, column_2]) - table = at.generate_table(row_data) - assert table == ( - '╔═════════════════╤═════════════════╗\n' - '║ Col 1 │ Col 2 ║\n' - '╠═════════════════╪═════════════════╣\n' - '║ Col 1 Row 1 │ Col 2 Row 1 ║\n' - '║\x1b[100m \x1b[49m\x1b[0m\x1b[100mCol 1 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[0m\x1b[100m \x1b[49m│\x1b[100m \x1b[49m\x1b[0m\x1b[100mCol 2 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[0m\x1b[100m \x1b[49m║\n' - '╚═════════════════╧═════════════════╝' - ) - - # No column borders - at = AlternatingTable([column_1, column_2], column_borders=False) - table = at.generate_table(row_data) - assert table == ( - '╔══════════════════════════════════╗\n' - '║ Col 1 Col 2 ║\n' - '╠══════════════════════════════════╣\n' - '║ Col 1 Row 1 Col 2 Row 1 ║\n' - '║\x1b[100m \x1b[49m\x1b[0m\x1b[100mCol 1 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[0m\x1b[100mCol 2 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[0m\x1b[100m \x1b[49m║\n' - '╚══════════════════════════════════╝' - ) - - # No header - at = AlternatingTable([column_1, column_2]) - table = at.generate_table(row_data, include_header=False) - assert table == ( - '╔═════════════════╤═════════════════╗\n' - '║ Col 1 Row 1 │ Col 2 Row 1 ║\n' - '║\x1b[100m \x1b[49m\x1b[0m\x1b[100mCol 1 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[0m\x1b[100m \x1b[49m│\x1b[100m \x1b[49m\x1b[0m\x1b[100mCol 2 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[0m\x1b[100m \x1b[49m║\n' - '╚═════════════════╧═════════════════╝' - ) - - # Non-default padding - at = AlternatingTable([column_1, column_2], padding=2) - table = at.generate_table(row_data) - assert table == ( - '╔═══════════════════╤═══════════════════╗\n' - '║ Col 1 │ Col 2 ║\n' - '╠═══════════════════╪═══════════════════╣\n' - '║ Col 1 Row 1 │ Col 2 Row 1 ║\n' - '║\x1b[100m \x1b[49m\x1b[0m\x1b[100mCol 1 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[0m\x1b[100m \x1b[49m│\x1b[100m \x1b[49m\x1b[0m\x1b[100mCol 2 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[0m\x1b[100m \x1b[49m║\n' - '╚═══════════════════╧═══════════════════╝' - ) - - # Invalid padding - with pytest.raises(ValueError, match="Padding cannot be less than 0"): - AlternatingTable([column_1, column_2], padding=-1) - - # Test border, header, and data colors - at = AlternatingTable([column_1, column_2], border_fg=Fg.LIGHT_YELLOW, border_bg=Bg.WHITE, - header_bg=Bg.GREEN, odd_bg=Bg.LIGHT_BLUE, even_bg=Bg.LIGHT_RED) - table = at.generate_table(row_data) - assert table == ( - '\x1b[93m\x1b[107m╔═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╤═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╗\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\x1b[42m \x1b[49m\x1b[0m\x1b[42mCol 1\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[93m\x1b[107m│\x1b[39m\x1b[49m\x1b[42m \x1b[49m\x1b[0m\x1b[42mCol 2\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m╠═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╪═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╣\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 1 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[93m\x1b[107m│\x1b[39m\x1b[49m\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 2 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\x1b[101m \x1b[49m\x1b[0m\x1b[101mCol 1 Row 2\x1b[49m\x1b[0m\x1b[101m \x1b[49m\x1b[0m\x1b[101m \x1b[49m\x1b[93m\x1b[107m│\x1b[39m\x1b[49m\x1b[101m \x1b[49m\x1b[0m\x1b[101mCol 2 Row 2\x1b[49m\x1b[0m\x1b[101m \x1b[49m\x1b[0m\x1b[101m \x1b[49m\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m╚═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╧═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╝\x1b[39m\x1b[49m' - ) - - # Make sure AlternatingTable respects style_header_text and style_data_text flags. - # Don't apply parent table's background colors to header or data text in second column. - at = AlternatingTable([column_1, Column("Col 2", width=15, style_header_text=False, style_data_text=False)], - header_bg=Bg.GREEN, odd_bg=Bg.LIGHT_BLUE, even_bg=Bg.LIGHT_RED) - table = at.generate_table(row_data) - assert table == ( - '╔═════════════════╤═════════════════╗\n' - '║\x1b[42m \x1b[49m\x1b[0m\x1b[42mCol 1\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m│\x1b[42m \x1b[49m\x1b[0mCol 2\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m║\n' - '╠═════════════════╪═════════════════╣\n' - '║\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 1 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m│\x1b[104m \x1b[49m\x1b[0mCol 2 Row 1\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m║\n' - '║\x1b[101m \x1b[49m\x1b[0m\x1b[101mCol 1 Row 2\x1b[49m\x1b[0m\x1b[101m \x1b[49m\x1b[0m\x1b[101m \x1b[49m│\x1b[101m \x1b[49m\x1b[0mCol 2 Row 2\x1b[0m\x1b[101m \x1b[49m\x1b[0m\x1b[101m \x1b[49m║\n' - '╚═════════════════╧═════════════════╝' - ) diff --git a/tests/test_terminal_utils.py b/tests/test_terminal_utils.py new file mode 100644 index 00000000..c7d8a22f --- /dev/null +++ b/tests/test_terminal_utils.py @@ -0,0 +1,81 @@ +"""Unit testing for cmd2/terminal_utils.py module""" + +import pytest + +from cmd2 import ( + Color, +) +from cmd2 import string_utils as su +from cmd2 import terminal_utils as tu + + +def test_set_title() -> None: + title = "Hello, world!" + assert tu.set_title_str(title) == tu.OSC + '2;' + title + tu.BEL + + +@pytest.mark.parametrize( + ('cols', 'prompt', 'line', 'cursor', 'msg', 'expected'), + [ + ( + 127, + '(Cmd) ', + 'help his', + 12, + su.stylize('Hello World!', style=Color.MAGENTA), + '\x1b[2K\r\x1b[35mHello World!\x1b[0m', + ), + (127, '\n(Cmd) ', 'help ', 5, 'foo', '\x1b[2K\x1b[1A\x1b[2K\rfoo'), + ( + 10, + '(Cmd) ', + 'help history of the american republic', + 4, + 'boo', + '\x1b[3B\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\rboo', + ), + ], +) +def test_async_alert_str(cols, prompt, line, cursor, msg, expected) -> None: + alert_str = tu.async_alert_str(terminal_columns=cols, prompt=prompt, line=line, cursor_offset=cursor, alert_msg=msg) + assert alert_str == expected + + +def test_clear_screen() -> None: + clear_type = 2 + assert tu.clear_screen_str(clear_type) == f"{tu.CSI}{clear_type}J" + + clear_type = -1 + expected_err = "clear_type must in an integer from 0 to 3" + with pytest.raises(ValueError, match=expected_err): + tu.clear_screen_str(clear_type) + + clear_type = 4 + with pytest.raises(ValueError, match=expected_err): + tu.clear_screen_str(clear_type) + + +def test_clear_line() -> None: + clear_type = 2 + assert tu.clear_line_str(clear_type) == f"{tu.CSI}{clear_type}K" + + clear_type = -1 + expected_err = "clear_type must in an integer from 0 to 2" + with pytest.raises(ValueError, match=expected_err): + tu.clear_line_str(clear_type) + + clear_type = 3 + with pytest.raises(ValueError, match=expected_err): + tu.clear_line_str(clear_type) + + +def test_cursor() -> None: + count = 1 + assert tu.Cursor.UP(count) == f"{tu.CSI}{count}A" + assert tu.Cursor.DOWN(count) == f"{tu.CSI}{count}B" + assert tu.Cursor.FORWARD(count) == f"{tu.CSI}{count}C" + assert tu.Cursor.BACK(count) == f"{tu.CSI}{count}D" + + x = 4 + y = 5 + assert tu.Cursor.SET_POS(x, y) == f"{tu.CSI}{y};{x}H" diff --git a/tests/test_utils.py b/tests/test_utils.py index 334b1300..a5a83ba1 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -11,29 +11,10 @@ import pytest import cmd2.utils as cu -from cmd2 import ( - ansi, - constants, -) -from cmd2.constants import ( - HORIZONTAL_ELLIPSIS, -) HELLO_WORLD = 'Hello, world!' -def test_strip_quotes_no_quotes() -> None: - base_str = HELLO_WORLD - stripped = cu.strip_quotes(base_str) - assert base_str == stripped - - -def test_strip_quotes_with_quotes() -> None: - base_str = '"' + HELLO_WORLD + '"' - stripped = cu.strip_quotes(base_str) - assert stripped == HELLO_WORLD - - def test_remove_duplicates_no_duplicates() -> None: no_dups = [5, 4, 3, 2, 1] assert cu.remove_duplicates(no_dups) == no_dups @@ -44,20 +25,6 @@ def test_remove_duplicates_with_duplicates() -> None: assert cu.remove_duplicates(duplicates) == [1, 2, 3, 9, 7, 8] -def test_unicode_normalization() -> None: - s1 = 'café' - s2 = 'cafe\u0301' - assert s1 != s2 - assert cu.norm_fold(s1) == cu.norm_fold(s2) - - -def test_unicode_casefold() -> None: - micro = 'µ' - micro_cf = micro.casefold() - assert micro != micro_cf - assert cu.norm_fold(micro) == cu.norm_fold(micro_cf) - - def test_alphabetical_sort() -> None: my_list = ['café', 'µ', 'A', 'micro', 'unity', 'cafeteria'] assert cu.alphabetical_sort(my_list) == ['A', 'cafeteria', 'café', 'micro', 'unity', 'µ'] @@ -92,54 +59,6 @@ def test_natural_sort() -> None: assert cu.natural_sort(my_list) == ['a1', 'A2', 'a3', 'A11', 'a22'] -def test_is_quoted_short() -> None: - my_str = '' - assert not cu.is_quoted(my_str) - your_str = '"' - assert not cu.is_quoted(your_str) - - -def test_is_quoted_yes() -> None: - my_str = '"This is a test"' - assert cu.is_quoted(my_str) - your_str = "'of the emergengy broadcast system'" - assert cu.is_quoted(your_str) - - -def test_is_quoted_no() -> None: - my_str = '"This is a test' - assert not cu.is_quoted(my_str) - your_str = "of the emergengy broadcast system'" - assert not cu.is_quoted(your_str) - simple_str = "hello world" - assert not cu.is_quoted(simple_str) - - -def test_quote_string() -> None: - my_str = "Hello World" - assert cu.quote_string(my_str) == '"' + my_str + '"' - - my_str = "'Hello World'" - assert cu.quote_string(my_str) == '"' + my_str + '"' - - my_str = '"Hello World"' - assert cu.quote_string(my_str) == "'" + my_str + "'" - - -def test_quote_string_if_needed_yes() -> None: - my_str = "Hello World" - assert cu.quote_string_if_needed(my_str) == '"' + my_str + '"' - your_str = '"foo" bar' - assert cu.quote_string_if_needed(your_str) == "'" + your_str + "'" - - -def test_quote_string_if_needed_no() -> None: - my_str = "HelloWorld" - assert cu.quote_string_if_needed(my_str) == my_str - your_str = "'Hello World'" - assert cu.quote_string_if_needed(your_str) == your_str - - @pytest.fixture def stdout_sim(): return cu.StdSim(sys.stdout, echo=True) @@ -329,484 +248,6 @@ def test_context_flag_exit_err(context_flag) -> None: context_flag.__exit__() -def test_remove_overridden_styles() -> None: - from cmd2 import ( - Bg, - EightBitBg, - EightBitFg, - Fg, - RgbBg, - RgbFg, - TextStyle, - ) - - def make_strs(styles_list: list[ansi.AnsiSequence]) -> list[str]: - return [str(s) for s in styles_list] - - # Test Reset All - styles_to_parse = make_strs([Fg.BLUE, TextStyle.UNDERLINE_DISABLE, TextStyle.INTENSITY_DIM, TextStyle.RESET_ALL]) - expected = make_strs([TextStyle.RESET_ALL]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - styles_to_parse = make_strs([Fg.BLUE, TextStyle.UNDERLINE_DISABLE, TextStyle.INTENSITY_DIM, TextStyle.ALT_RESET_ALL]) - expected = make_strs([TextStyle.ALT_RESET_ALL]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - # Test colors - styles_to_parse = make_strs([Fg.BLUE, Fg.RED, Fg.GREEN, Bg.BLUE, Bg.RED, Bg.GREEN]) - expected = make_strs([Fg.GREEN, Bg.GREEN]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - styles_to_parse = make_strs([EightBitFg.BLUE, EightBitFg.RED, EightBitBg.BLUE, EightBitBg.RED]) - expected = make_strs([EightBitFg.RED, EightBitBg.RED]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - styles_to_parse = make_strs([RgbFg(0, 3, 4), RgbFg(5, 6, 7), RgbBg(8, 9, 10), RgbBg(11, 12, 13)]) - expected = make_strs([RgbFg(5, 6, 7), RgbBg(11, 12, 13)]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - # Test text styles - styles_to_parse = make_strs([TextStyle.INTENSITY_DIM, TextStyle.INTENSITY_NORMAL, TextStyle.ITALIC_ENABLE]) - expected = make_strs([TextStyle.INTENSITY_NORMAL, TextStyle.ITALIC_ENABLE]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - styles_to_parse = make_strs([TextStyle.INTENSITY_DIM, TextStyle.ITALIC_ENABLE, TextStyle.ITALIC_DISABLE]) - expected = make_strs([TextStyle.INTENSITY_DIM, TextStyle.ITALIC_DISABLE]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - styles_to_parse = make_strs([TextStyle.INTENSITY_BOLD, TextStyle.OVERLINE_DISABLE, TextStyle.OVERLINE_ENABLE]) - expected = make_strs([TextStyle.INTENSITY_BOLD, TextStyle.OVERLINE_ENABLE]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - styles_to_parse = make_strs([TextStyle.OVERLINE_DISABLE, TextStyle.STRIKETHROUGH_DISABLE, TextStyle.STRIKETHROUGH_ENABLE]) - expected = make_strs([TextStyle.OVERLINE_DISABLE, TextStyle.STRIKETHROUGH_ENABLE]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - styles_to_parse = make_strs([TextStyle.STRIKETHROUGH_DISABLE, TextStyle.UNDERLINE_DISABLE, TextStyle.UNDERLINE_ENABLE]) - expected = make_strs([TextStyle.STRIKETHROUGH_DISABLE, TextStyle.UNDERLINE_ENABLE]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - styles_to_parse = make_strs([TextStyle.UNDERLINE_DISABLE]) - expected = make_strs([TextStyle.UNDERLINE_DISABLE]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - # Test unrecognized styles - slow_blink = ansi.CSI + str(5) - rapid_blink = ansi.CSI + str(6) - styles_to_parse = [slow_blink, rapid_blink] - expected = styles_to_parse - assert cu._remove_overridden_styles(styles_to_parse) == expected - - -def test_truncate_line() -> None: - line = 'long' - max_width = 3 - truncated = cu.truncate_line(line, max_width) - assert truncated == 'lo' + HORIZONTAL_ELLIPSIS - - -def test_truncate_line_already_fits() -> None: - line = 'long' - max_width = 4 - truncated = cu.truncate_line(line, max_width) - assert truncated == line - - -def test_truncate_line_with_newline() -> None: - line = 'fo\no' - max_width = 2 - with pytest.raises(ValueError, match="text contains an unprintable character"): - cu.truncate_line(line, max_width) - - -def test_truncate_line_width_is_too_small() -> None: - line = 'foo' - max_width = 0 - with pytest.raises(ValueError, match="max_width must be at least 1"): - cu.truncate_line(line, max_width) - - -def test_truncate_line_wide_text() -> None: - line = '苹苹other' - max_width = 6 - truncated = cu.truncate_line(line, max_width) - assert truncated == '苹苹o' + HORIZONTAL_ELLIPSIS - - -def test_truncate_line_split_wide_text() -> None: - """Test when truncation results in a string which is shorter than max_width""" - line = '1苹2苹' - max_width = 3 - truncated = cu.truncate_line(line, max_width) - assert truncated == '1' + HORIZONTAL_ELLIPSIS - - -def test_truncate_line_tabs() -> None: - line = 'has\ttab' - max_width = 9 - truncated = cu.truncate_line(line, max_width) - assert truncated == 'has t' + HORIZONTAL_ELLIPSIS - - -def test_truncate_with_style() -> None: - from cmd2 import ( - Fg, - TextStyle, - ) - - before_text = Fg.BLUE + TextStyle.UNDERLINE_ENABLE - after_text = Fg.RESET + TextStyle.UNDERLINE_DISABLE + TextStyle.ITALIC_ENABLE + TextStyle.ITALIC_DISABLE - - # This is what the styles after the truncated text should look like since they will be - # filtered by _remove_overridden_styles. - filtered_after_text = Fg.RESET + TextStyle.UNDERLINE_DISABLE + TextStyle.ITALIC_DISABLE - - # Style only before truncated text - line = before_text + 'long' - max_width = 3 - truncated = cu.truncate_line(line, max_width) - assert truncated == before_text + 'lo' + HORIZONTAL_ELLIPSIS - - # Style before and after truncated text - line = before_text + 'long' + after_text - max_width = 3 - truncated = cu.truncate_line(line, max_width) - assert truncated == before_text + 'lo' + HORIZONTAL_ELLIPSIS + filtered_after_text - - # Style only after truncated text - line = 'long' + after_text - max_width = 3 - truncated = cu.truncate_line(line, max_width) - assert truncated == 'lo' + HORIZONTAL_ELLIPSIS + filtered_after_text - - -def test_align_text_fill_char_is_tab() -> None: - text = 'foo' - fill_char = '\t' - width = 5 - aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width) - assert aligned == text + ' ' - - -def test_align_text_with_style() -> None: - from cmd2 import ( - Fg, - TextStyle, - style, - ) - - fill_char = '-' - styled_fill_char = style(fill_char, fg=Fg.LIGHT_YELLOW) - - # Single line with only left fill - text = style('line1', fg=Fg.LIGHT_BLUE) - width = 8 - - aligned = cu.align_text(text, cu.TextAlignment.RIGHT, fill_char=styled_fill_char, width=width) - - left_fill = TextStyle.RESET_ALL + Fg.LIGHT_YELLOW + (fill_char * 3) + Fg.RESET + TextStyle.RESET_ALL - right_fill = TextStyle.RESET_ALL - line_1_text = Fg.LIGHT_BLUE + 'line1' + Fg.RESET - - assert aligned == (left_fill + line_1_text + right_fill) - - # Single line with only right fill - text = style('line1', fg=Fg.LIGHT_BLUE) - width = 8 - - aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=styled_fill_char, width=width) - - left_fill = TextStyle.RESET_ALL - right_fill = TextStyle.RESET_ALL + Fg.LIGHT_YELLOW + (fill_char * 3) + Fg.RESET + TextStyle.RESET_ALL - line_1_text = Fg.LIGHT_BLUE + 'line1' + Fg.RESET - - assert aligned == (left_fill + line_1_text + right_fill) - - # Multiple lines to show that style is preserved across all lines. Also has left and right fill. - text = style('line1\nline2', fg=Fg.LIGHT_BLUE) - width = 9 - - aligned = cu.align_text(text, cu.TextAlignment.CENTER, fill_char=styled_fill_char, width=width) - - left_fill = TextStyle.RESET_ALL + Fg.LIGHT_YELLOW + (fill_char * 2) + Fg.RESET + TextStyle.RESET_ALL - right_fill = TextStyle.RESET_ALL + Fg.LIGHT_YELLOW + (fill_char * 2) + Fg.RESET + TextStyle.RESET_ALL - line_1_text = Fg.LIGHT_BLUE + 'line1' - line_2_text = Fg.LIGHT_BLUE + 'line2' + Fg.RESET - - assert aligned == (left_fill + line_1_text + right_fill + '\n' + left_fill + line_2_text + right_fill) - - -def test_align_text_width_is_too_small() -> None: - text = 'foo' - fill_char = '-' - width = 0 - with pytest.raises(ValueError, match="width must be at least 1"): - cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width) - - -def test_align_text_fill_char_is_too_long() -> None: - text = 'foo' - fill_char = 'fill' - width = 5 - with pytest.raises(TypeError): - cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width) - - -def test_align_text_fill_char_is_newline() -> None: - text = 'foo' - fill_char = '\n' - width = 5 - with pytest.raises(ValueError, match="Fill character is an unprintable character"): - cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width) - - -def test_align_text_has_tabs() -> None: - text = '\t\tfoo' - fill_char = '-' - width = 10 - aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width, tab_width=2) - assert aligned == ' ' + 'foo' + '---' - - -def test_align_text_blank() -> None: - text = '' - fill_char = '-' - width = 5 - aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width) - assert aligned == fill_char * width - - -def test_align_text_wider_than_width() -> None: - text = 'long text field' - fill_char = '-' - width = 8 - aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width) - assert aligned == text - - -def test_align_text_wider_than_width_truncate() -> None: - text = 'long text field' - fill_char = '-' - width = 8 - aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width, truncate=True) - assert aligned == 'long te' + HORIZONTAL_ELLIPSIS - - -def test_align_text_wider_than_width_truncate_add_fill() -> None: - """Test when truncation results in a string which is shorter than width and align_text adds filler""" - text = '1苹2苹' - fill_char = '-' - width = 3 - aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width, truncate=True) - assert aligned == '1' + HORIZONTAL_ELLIPSIS + fill_char - - -def test_align_text_has_unprintable() -> None: - text = 'foo\x02' - fill_char = '-' - width = 5 - with pytest.raises(ValueError, match="Text to align contains an unprintable character"): - cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width) - - -def test_align_text_term_width() -> None: - import shutil - - text = 'foo' - fill_char = ' ' - - # Prior to Python 3.11 this can return 0, so use a fallback, so - # use the same fallback that cu.align_text() does if needed. - term_width = shutil.get_terminal_size().columns or constants.DEFAULT_TERMINAL_WIDTH - expected_fill = (term_width - ansi.style_aware_wcswidth(text)) * fill_char - - aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char) - assert aligned == text + expected_fill - - -def test_align_left() -> None: - text = 'foo' - fill_char = '-' - width = 5 - aligned = cu.align_left(text, fill_char=fill_char, width=width) - assert aligned == text + fill_char + fill_char - - -def test_align_left_multiline() -> None: - # Without style - text = "foo\nshoes" - fill_char = '-' - width = 7 - aligned = cu.align_left(text, fill_char=fill_char, width=width) - assert aligned == 'foo----\nshoes--' - - # With style - reset_all = str(ansi.TextStyle.RESET_ALL) - blue = str(ansi.Fg.BLUE) - red = str(ansi.Fg.RED) - green = str(ansi.Fg.GREEN) - fg_reset = str(ansi.Fg.RESET) - - text = f"{blue}foo{red}moo\nshoes{fg_reset}" - fill_char = f"{green}-{fg_reset}" - width = 7 - aligned = cu.align_left(text, fill_char=fill_char, width=width) - - expected = f"{reset_all}{blue}foo{red}moo{reset_all}{green}-{fg_reset}{reset_all}\n" - expected += f"{reset_all}{red}shoes{fg_reset}{reset_all}{green}--{fg_reset}{reset_all}" - assert aligned == expected - - -def test_align_left_wide_text() -> None: - text = '苹' - fill_char = '-' - width = 4 - aligned = cu.align_left(text, fill_char=fill_char, width=width) - assert aligned == text + fill_char + fill_char - - -def test_align_left_wide_fill() -> None: - text = 'foo' - fill_char = '苹' - width = 5 - aligned = cu.align_left(text, fill_char=fill_char, width=width) - assert aligned == text + fill_char - - -def test_align_left_wide_fill_needs_padding() -> None: - """Test when fill_char's display width does not divide evenly into gap""" - text = 'foo' - fill_char = '苹' - width = 6 - aligned = cu.align_left(text, fill_char=fill_char, width=width) - assert aligned == text + fill_char + ' ' - - -def test_align_center() -> None: - text = 'foo' - fill_char = '-' - width = 5 - aligned = cu.align_center(text, fill_char=fill_char, width=width) - assert aligned == fill_char + text + fill_char - - -def test_align_center_multiline() -> None: - # Without style - text = "foo\nshoes" - fill_char = '-' - width = 7 - aligned = cu.align_center(text, fill_char=fill_char, width=width) - assert aligned == '--foo--\n-shoes-' - - # With style - reset_all = str(ansi.TextStyle.RESET_ALL) - blue = str(ansi.Fg.BLUE) - red = str(ansi.Fg.RED) - green = str(ansi.Fg.GREEN) - fg_reset = str(ansi.Fg.RESET) - - text = f"{blue}foo{red}moo\nshoes{fg_reset}" - fill_char = f"{green}-{fg_reset}" - width = 10 - aligned = cu.align_center(text, fill_char=fill_char, width=width) - - expected = f"{reset_all}{green}--{fg_reset}{reset_all}{blue}foo{red}moo{reset_all}{green}--{fg_reset}{reset_all}\n" - expected += f"{reset_all}{green}--{fg_reset}{reset_all}{red}shoes{fg_reset}{reset_all}{green}---{fg_reset}{reset_all}" - assert aligned == expected - - -def test_align_center_wide_text() -> None: - text = '苹' - fill_char = '-' - width = 4 - aligned = cu.align_center(text, fill_char=fill_char, width=width) - assert aligned == fill_char + text + fill_char - - -def test_align_center_wide_fill() -> None: - text = 'foo' - fill_char = '苹' - width = 7 - aligned = cu.align_center(text, fill_char=fill_char, width=width) - assert aligned == fill_char + text + fill_char - - -def test_align_center_wide_fill_needs_right_padding() -> None: - """Test when fill_char's display width does not divide evenly into right gap""" - text = 'foo' - fill_char = '苹' - width = 8 - aligned = cu.align_center(text, fill_char=fill_char, width=width) - assert aligned == fill_char + text + fill_char + ' ' - - -def test_align_center_wide_fill_needs_left_and_right_padding() -> None: - """Test when fill_char's display width does not divide evenly into either gap""" - text = 'foo' - fill_char = '苹' - width = 9 - aligned = cu.align_center(text, fill_char=fill_char, width=width) - assert aligned == fill_char + ' ' + text + fill_char + ' ' - - -def test_align_right() -> None: - text = 'foo' - fill_char = '-' - width = 5 - aligned = cu.align_right(text, fill_char=fill_char, width=width) - assert aligned == fill_char + fill_char + text - - -def test_align_right_multiline() -> None: - # Without style - text = "foo\nshoes" - fill_char = '-' - width = 7 - aligned = cu.align_right(text, fill_char=fill_char, width=width) - assert aligned == '----foo\n--shoes' - - # With style - reset_all = str(ansi.TextStyle.RESET_ALL) - blue = str(ansi.Fg.BLUE) - red = str(ansi.Fg.RED) - green = str(ansi.Fg.GREEN) - fg_reset = str(ansi.Fg.RESET) - - text = f"{blue}foo{red}moo\nshoes{fg_reset}" - fill_char = f"{green}-{fg_reset}" - width = 7 - aligned = cu.align_right(text, fill_char=fill_char, width=width) - - expected = f"{reset_all}{green}-{fg_reset}{reset_all}{blue}foo{red}moo{reset_all}\n" - expected += f"{reset_all}{green}--{fg_reset}{reset_all}{red}shoes{fg_reset}{reset_all}" - assert aligned == expected - - -def test_align_right_wide_text() -> None: - text = '苹' - fill_char = '-' - width = 4 - aligned = cu.align_right(text, fill_char=fill_char, width=width) - assert aligned == fill_char + fill_char + text - - -def test_align_right_wide_fill() -> None: - text = 'foo' - fill_char = '苹' - width = 5 - aligned = cu.align_right(text, fill_char=fill_char, width=width) - assert aligned == fill_char + text - - -def test_align_right_wide_fill_needs_padding() -> None: - """Test when fill_char's display width does not divide evenly into gap""" - text = 'foo' - fill_char = '苹' - width = 6 - aligned = cu.align_right(text, fill_char=fill_char, width=width) - assert aligned == fill_char + ' ' + text - - def test_to_bool_str_true() -> None: assert cu.to_bool('true') assert cu.to_bool('True') From c8a89355b3e28e1198d4f87b718db7f5aa6dec2c Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 19 Aug 2025 17:06:26 -0400 Subject: [PATCH 25/89] Refactored the help functions. (#1478) --- cmd2/cmd2.py | 266 +++++++++++++++++++++++---------------------- cmd2/styles.py | 6 +- tests/test_cmd2.py | 57 ++++++++++ 3 files changed, 199 insertions(+), 130 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 979f562e..7aa08dc2 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -475,8 +475,11 @@ def __init__( # The multiline command currently being typed which is used to tab complete multiline commands. self._multiline_in_progress = '' - # Set the header used for the help function's listing of documented functions - self.doc_header = "Documented commands (use 'help -v' for verbose/'help ' for details)" + # Set text which prints right before all of the help topics are listed. + self.doc_leader = "" + + # Set header for table listing documented commands. + self.doc_header = "Documented Commands" # Set header for table listing help topics not related to a command. self.misc_header = "Miscellaneous Help Topics" @@ -484,6 +487,10 @@ def __init__( # Set header for table listing commands that have no help info. self.undoc_header = "Undocumented Commands" + # If any command has been categorized, then all other commands that haven't been categorized + # will display under this section in the help output. + self.default_category = "Uncategorized Commands" + # The error that prints when no help information can be found self.help_error = "No help on {}" @@ -551,10 +558,6 @@ def __init__( # values are DisabledCommand objects. self.disabled_commands: dict[str, DisabledCommand] = {} - # If any command has been categorized, then all other commands that haven't been categorized - # will display under this section in the help output. - self.default_category = 'Uncategorized' - # The default key for sorting string results. Its default value performs a case-insensitive alphabetical sort. # If natural sorting is preferred, then set this to NATURAL_SORT_KEY. # cmd2 uses this key for sorting: @@ -4039,6 +4042,45 @@ def complete_help_subcommands( completer = argparse_completer.DEFAULT_AP_COMPLETER(argparser, self) return completer.complete_subcommand_help(text, line, begidx, endidx, arg_tokens['subcommands']) + def _build_command_info(self) -> tuple[dict[str, list[str]], list[str], list[str], list[str]]: + """Categorizes and sorts visible commands and help topics for display. + + :return: tuple containing: + - dictionary mapping category names to lists of command names + - list of documented command names + - list of undocumented command names + - list of help topic names that are not also commands + """ + # Get a sorted list of help topics + help_topics = sorted(self.get_help_topics(), key=self.default_sort_key) + + # Get a sorted list of visible command names + visible_commands = sorted(self.get_visible_commands(), key=self.default_sort_key) + cmds_doc: list[str] = [] + cmds_undoc: list[str] = [] + cmds_cats: dict[str, list[str]] = {} + for command in visible_commands: + func = cast(CommandFunc, self.cmd_func(command)) + has_help_func = False + has_parser = func in self._command_parsers + + if command in help_topics: + # Prevent the command from showing as both a command and help topic in the output + help_topics.remove(command) + + # Non-argparse commands can have help_functions for their documentation + has_help_func = not has_parser + + if hasattr(func, constants.CMD_ATTR_HELP_CATEGORY): + category: str = getattr(func, constants.CMD_ATTR_HELP_CATEGORY) + cmds_cats.setdefault(category, []) + cmds_cats[category].append(command) + elif func.__doc__ or has_help_func or has_parser: + cmds_doc.append(command) + else: + cmds_undoc.append(command) + return cmds_cats, cmds_doc, cmds_undoc, help_topics + @classmethod def _build_help_parser(cls) -> Cmd2ArgumentParser: help_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( @@ -4074,7 +4116,24 @@ def do_help(self, args: argparse.Namespace) -> None: self.last_result = True if not args.command or args.verbose: - self._help_menu(args.verbose) + cmds_cats, cmds_doc, cmds_undoc, help_topics = self._build_command_info() + + if self.doc_leader: + self.poutput() + self.poutput(self.doc_leader, style=Cmd2Style.HELP_LEADER, soft_wrap=False) + self.poutput() + + if not cmds_cats: + # No categories found, fall back to standard behavior + self._print_documented_command_topics(self.doc_header, cmds_doc, args.verbose) + else: + # Categories found, Organize all commands by category + for category in sorted(cmds_cats.keys(), key=self.default_sort_key): + self._print_documented_command_topics(category, cmds_cats[category], args.verbose) + self._print_documented_command_topics(self.default_category, cmds_doc, args.verbose) + + self.print_topics(self.misc_header, help_topics, 15, 80) + self.print_topics(self.undoc_header, cmds_undoc, 15, 80) else: # Getting help for a specific command @@ -4111,14 +4170,77 @@ def print_topics(self, header: str, cmds: list[str] | None, cmdlen: int, maxcol: :param cmdlen: unused, even by cmd's version :param maxcol: max number of display columns to fit into """ - if cmds: - header_grid = Table.grid() - header_grid.add_row(header, style=Cmd2Style.HELP_TITLE) - if self.ruler: - header_grid.add_row(Rule(characters=self.ruler)) - self.poutput(header_grid) - self.columnize(cmds, maxcol - 1) - self.poutput() + if not cmds: + return + + header_grid = Table.grid() + header_grid.add_row(header, style=Cmd2Style.HELP_HEADER) + if self.ruler: + header_grid.add_row(Rule(characters=self.ruler)) + self.poutput(header_grid) + self.columnize(cmds, maxcol - 1) + self.poutput() + + def _print_documented_command_topics(self, header: str, cmds: list[str], verbose: bool) -> None: + """Print topics which are documented commands, switching between verbose or traditional output.""" + import io + + if not cmds: + return + + if not verbose: + self.print_topics(header, cmds, 15, 80) + return + + category_grid = Table.grid() + category_grid.add_row(header, style=Cmd2Style.HELP_HEADER) + category_grid.add_row(Rule(characters=self.ruler)) + topics_table = Table( + Column("Name", no_wrap=True), + Column("Description", overflow="fold"), + box=SIMPLE_HEAD, + border_style=Cmd2Style.RULE_LINE, + show_edge=False, + ) + + # Try to get the documentation string for each command + topics = self.get_help_topics() + for command in cmds: + if (cmd_func := self.cmd_func(command)) is None: + continue + + doc: str | None + + # Non-argparse commands can have help_functions for their documentation + if command in topics: + help_func = getattr(self, constants.HELP_FUNC_PREFIX + command) + result = io.StringIO() + + # try to redirect system stdout + with contextlib.redirect_stdout(result): + # save our internal stdout + stdout_orig = self.stdout + try: + # redirect our internal stdout + self.stdout = cast(TextIO, result) + help_func() + finally: + with self.sigint_protection: + # restore internal stdout + self.stdout = stdout_orig + doc = result.getvalue() + + else: + doc = cmd_func.__doc__ + + # Attempt to locate the first documentation block + cmd_desc = strip_doc_annotations(doc) if doc else '' + + # Add this command to the table + topics_table.add_row(command, cmd_desc) + + category_grid.add_row(topics_table) + self.poutput(category_grid, "") def columnize(self, str_list: list[str] | None, display_width: int = 80) -> None: """Display a list of single-line strings as a compact set of columns. @@ -4132,9 +4254,6 @@ def columnize(self, str_list: list[str] | None, display_width: int = 80) -> None self.poutput("") return - nonstrings = [i for i in range(len(str_list)) if not isinstance(str_list[i], str)] - if nonstrings: - raise TypeError(f"str_list[i] not a string for i in {nonstrings}") size = len(str_list) if size == 1: self.poutput(str_list[0]) @@ -4162,7 +4281,8 @@ def columnize(self, str_list: list[str] | None, display_width: int = 80) -> None # The output is wider than display_width. Print 1 column with each string on its own row. nrows = len(str_list) ncols = 1 - colwidths = [1] + max_width = max(su.str_width(s) for s in str_list) + colwidths = [max_width] for row in range(nrows): texts = [] for col in range(ncols): @@ -4175,114 +4295,6 @@ def columnize(self, str_list: list[str] | None, display_width: int = 80) -> None texts[col] = su.align_left(texts[col], width=colwidths[col]) self.poutput(" ".join(texts)) - def _help_menu(self, verbose: bool = False) -> None: - """Show a list of commands which help can be displayed for.""" - cmds_cats, cmds_doc, cmds_undoc, help_topics = self._build_command_info() - - if not cmds_cats: - # No categories found, fall back to standard behavior - self.poutput(self.doc_leader, soft_wrap=False) - self._print_topics(self.doc_header, cmds_doc, verbose) - else: - # Categories found, Organize all commands by category - self.poutput(self.doc_leader, style=Cmd2Style.HELP_HEADER, soft_wrap=False) - self.poutput(self.doc_header, style=Cmd2Style.HELP_HEADER, end="\n\n", soft_wrap=False) - for category in sorted(cmds_cats.keys(), key=self.default_sort_key): - self._print_topics(category, cmds_cats[category], verbose) - self._print_topics(self.default_category, cmds_doc, verbose) - - self.print_topics(self.misc_header, help_topics, 15, 80) - self.print_topics(self.undoc_header, cmds_undoc, 15, 80) - - def _build_command_info(self) -> tuple[dict[str, list[str]], list[str], list[str], list[str]]: - # Get a sorted list of help topics - help_topics = sorted(self.get_help_topics(), key=self.default_sort_key) - - # Get a sorted list of visible command names - visible_commands = sorted(self.get_visible_commands(), key=self.default_sort_key) - cmds_doc: list[str] = [] - cmds_undoc: list[str] = [] - cmds_cats: dict[str, list[str]] = {} - for command in visible_commands: - func = cast(CommandFunc, self.cmd_func(command)) - has_help_func = False - has_parser = func in self._command_parsers - - if command in help_topics: - # Prevent the command from showing as both a command and help topic in the output - help_topics.remove(command) - - # Non-argparse commands can have help_functions for their documentation - has_help_func = not has_parser - - if hasattr(func, constants.CMD_ATTR_HELP_CATEGORY): - category: str = getattr(func, constants.CMD_ATTR_HELP_CATEGORY) - cmds_cats.setdefault(category, []) - cmds_cats[category].append(command) - elif func.__doc__ or has_help_func or has_parser: - cmds_doc.append(command) - else: - cmds_undoc.append(command) - return cmds_cats, cmds_doc, cmds_undoc, help_topics - - def _print_topics(self, header: str, cmds: list[str], verbose: bool) -> None: - """Print topics, switching between verbose or traditional output.""" - import io - - if cmds: - if not verbose: - self.print_topics(header, cmds, 15, 80) - else: - category_grid = Table.grid() - category_grid.add_row(header, style=Cmd2Style.HELP_TITLE) - category_grid.add_row(Rule(characters=self.ruler)) - topics_table = Table( - Column("Name", no_wrap=True), - Column("Description", overflow="fold"), - box=SIMPLE_HEAD, - border_style=Cmd2Style.RULE_LINE, - show_edge=False, - ) - - # Try to get the documentation string for each command - topics = self.get_help_topics() - for command in cmds: - if (cmd_func := self.cmd_func(command)) is None: - continue - - doc: str | None - - # Non-argparse commands can have help_functions for their documentation - if command in topics: - help_func = getattr(self, constants.HELP_FUNC_PREFIX + command) - result = io.StringIO() - - # try to redirect system stdout - with contextlib.redirect_stdout(result): - # save our internal stdout - stdout_orig = self.stdout - try: - # redirect our internal stdout - self.stdout = cast(TextIO, result) - help_func() - finally: - with self.sigint_protection: - # restore internal stdout - self.stdout = stdout_orig - doc = result.getvalue() - - else: - doc = cmd_func.__doc__ - - # Attempt to locate the first documentation block - cmd_desc = strip_doc_annotations(doc) if doc else '' - - # Add this command to the table - topics_table.add_row(command, cmd_desc) - - category_grid.add_row(topics_table) - self.poutput(category_grid, "") - @staticmethod def _build_shortcuts_parser() -> Cmd2ArgumentParser: return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="List available shortcuts.") diff --git a/cmd2/styles.py b/cmd2/styles.py index f11ab724..22cba9f9 100644 --- a/cmd2/styles.py +++ b/cmd2/styles.py @@ -33,7 +33,7 @@ class Cmd2Style(StrEnum): ERROR = "cmd2.error" EXAMPLE = "cmd2.example" HELP_HEADER = "cmd2.help.header" - HELP_TITLE = "cmd2.help.title" + HELP_LEADER = "cmd2.help.leader" RULE_LINE = "cmd2.rule.line" SUCCESS = "cmd2.success" WARNING = "cmd2.warning" @@ -43,8 +43,8 @@ class Cmd2Style(StrEnum): DEFAULT_CMD2_STYLES: dict[str, StyleType] = { Cmd2Style.ERROR: Style(color=Color.BRIGHT_RED), Cmd2Style.EXAMPLE: Style(color=Color.CYAN, bold=True), - Cmd2Style.HELP_HEADER: Style(color=Color.CYAN, bold=True), - Cmd2Style.HELP_TITLE: Style(color=Color.BRIGHT_GREEN, bold=True), + Cmd2Style.HELP_HEADER: Style(color=Color.BRIGHT_GREEN, bold=True), + Cmd2Style.HELP_LEADER: Style(color=Color.CYAN, bold=True), Cmd2Style.RULE_LINE: Style(color=Color.BRIGHT_GREEN), Cmd2Style.SUCCESS: Style(color=Color.GREEN), Cmd2Style.WARNING: Style(color=Color.BRIGHT_YELLOW), diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index bb9172e0..341f132c 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1245,6 +1245,10 @@ class HelpApp(cmd2.Cmd): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) + self.doc_leader = "I now present you with a list of help topics." + self.doc_header = "My very custom doc header." + self.misc_header = "Various topics found here." + self.undoc_header = "Why did no one document these?" def do_squat(self, arg) -> None: """This docstring help will never be shown because the help_squat method overrides it.""" @@ -1266,6 +1270,10 @@ def do_multiline_docstr(self, arg) -> None: tabs """ + def help_physics(self): + """A miscellaneous help topic.""" + self.poutput("Here is some help on physics.") + parser_cmd_parser = cmd2.Cmd2ArgumentParser(description="This is the description.") @cmd2.with_argparser(parser_cmd_parser) @@ -1278,6 +1286,18 @@ def help_app(): return HelpApp() +def test_help_headers(capsys) -> None: + help_app = HelpApp() + help_app.onecmd_plus_hooks('help') + out, err = capsys.readouterr() + + assert help_app.doc_leader in out + assert help_app.doc_header in out + assert help_app.misc_header in out + assert help_app.undoc_header in out + assert help_app.last_result is True + + def test_custom_command_help(help_app) -> None: out, err = run_cmd(help_app, 'help squat') expected = normalize('This command does diddly squat...') @@ -1288,6 +1308,7 @@ def test_custom_command_help(help_app) -> None: def test_custom_help_menu(help_app) -> None: out, err = run_cmd(help_app, 'help') verify_help_text(help_app, out) + assert help_app.last_result is True def test_help_undocumented(help_app) -> None: @@ -1310,12 +1331,48 @@ def test_help_multiline_docstring(help_app) -> None: assert help_app.last_result is True +def test_miscellaneous_help_topic(help_app) -> None: + out, err = run_cmd(help_app, 'help physics') + expected = normalize("Here is some help on physics.") + assert out == expected + assert help_app.last_result is True + + def test_help_verbose_uses_parser_description(help_app: HelpApp) -> None: out, err = run_cmd(help_app, 'help --verbose') expected_verbose = utils.strip_doc_annotations(help_app.do_parser_cmd.__doc__) verify_help_text(help_app, out, verbose_strings=[expected_verbose]) +def test_help_verbose_with_fake_command(capsys) -> None: + """Verify that only actual command functions appear in verbose output.""" + help_app = HelpApp() + + cmds = ["alias", "fake_command"] + help_app._print_documented_command_topics(help_app.doc_header, cmds, verbose=True) + out, err = capsys.readouterr() + assert cmds[0] in out + assert cmds[1] not in out + + +def test_columnize_empty_list(capsys) -> None: + help_app = HelpApp() + no_strs = [] + help_app.columnize(no_strs) + out, err = capsys.readouterr() + assert "" in out + + +def test_columnize_too_wide(capsys) -> None: + help_app = HelpApp() + commands = ["kind_of_long_string", "a_slightly_longer_string"] + help_app.columnize(commands, display_width=10) + out, err = capsys.readouterr() + + expected = "kind_of_long_string \na_slightly_longer_string\n" + assert expected == out + + class HelpCategoriesApp(cmd2.Cmd): """Class for testing custom help_* methods which override docstring help.""" From 44948e5840d1f09002bd4701a225de7a03434ebb Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 19 Aug 2025 23:08:23 -0400 Subject: [PATCH 26/89] Updated comments. --- cmd2/rich_utils.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 55f0f9ae..3e06dfab 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -188,10 +188,8 @@ def rich_text_to_string(text: Text) -> str: def string_to_rich_text(text: str) -> Text: r"""Create a Text object from a string which can contain ANSI escape codes. - This wraps rich.Text.from_ansi() to handle a discarded newline issue. - - Text.from_ansi() currently removes the ending line break from string. - e.g. "Hello\n" becomes "Hello" + This wraps rich.Text.from_ansi() to handle an issue where it removes the + trailing line break from a string (e.g. "Hello\n" becomes "Hello"). There is currently a pull request to fix this. https://github.com/Textualize/rich/pull/3793 @@ -201,8 +199,9 @@ def string_to_rich_text(text: str) -> Text: """ result = Text.from_ansi(text) - # If 'text' ends with a line break character, restore the missing newline to 'result'. - # Note: '\r\n' is handled as its last character is '\n'. + # If the original string ends with a recognized line break character, + # then restore the missing newline. We use "\n" because Text.from_ansi() + # converts all line breaks into newlines. # Source: https://docs.python.org/3/library/stdtypes.html#str.splitlines line_break_chars = { "\n", # Line Feed @@ -217,7 +216,6 @@ def string_to_rich_text(text: str) -> Text: "\u2029", # Paragraph Separator } if text and text[-1] in line_break_chars: - # We use "\n" because Text.from_ansi() converts all line breaks chars into newlines. result.append("\n") return result From aa38e49d8c20d4beb0c89226c2c1119d181852d8 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 20 Aug 2025 09:52:19 -0400 Subject: [PATCH 27/89] Corrected theme management functions. --- cmd2/rich_utils.py | 65 +++++++++++++++++++++------------------------- 1 file changed, 29 insertions(+), 36 deletions(-) diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 3e06dfab..6d0c613a 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -1,4 +1,4 @@ -"""Provides common utilities to support Rich in cmd2 applications.""" +"""Provides common utilities to support Rich in cmd2-based applications.""" from collections.abc import Mapping from enum import Enum @@ -17,7 +17,6 @@ RichCast, ) from rich.style import ( - Style, StyleType, ) from rich.text import Text @@ -47,47 +46,41 @@ def __repr__(self) -> str: ALLOW_STYLE = AllowStyle.TERMINAL -class Cmd2Theme(Theme): - """Rich theme class used by cmd2.""" +def _create_default_theme() -> Theme: + """Create a default theme for cmd2-based applications. - def __init__(self, styles: Mapping[str, StyleType] | None = None) -> None: - """Cmd2Theme initializer. - - :param styles: optional mapping of style names on to styles. - Defaults to None for a theme with no styles. - """ - cmd2_styles = DEFAULT_CMD2_STYLES.copy() - - # Include default styles from rich-argparse - cmd2_styles.update(RichHelpFormatter.styles.copy()) + This theme combines the default styles from cmd2, rich-argparse, and Rich. + """ + app_styles = DEFAULT_CMD2_STYLES.copy() + app_styles.update(RichHelpFormatter.styles.copy()) + return Theme(app_styles, inherit=True) - if styles is not None: - cmd2_styles.update(styles) - # Set inherit to True to include Rich's default styles - super().__init__(cmd2_styles, inherit=True) +def set_theme(styles: Mapping[str, StyleType] | None = None) -> None: + """Set the Rich theme used by cmd2. + :param styles: optional mapping of style names to styles + """ + global APP_THEME # noqa: PLW0603 -# Current Rich theme used by Cmd2Console -THEME: Cmd2Theme = Cmd2Theme() + # Start with a fresh copy of the default styles. + app_styles: dict[str, StyleType] = {} + app_styles.update(_create_default_theme().styles) + # Incorporate custom styles. + if styles is not None: + app_styles.update(styles) -def set_theme(new_theme: Cmd2Theme) -> None: - """Set the Rich theme used by cmd2. + APP_THEME = Theme(app_styles) - :param new_theme: new theme to use. - """ - global THEME # noqa: PLW0603 - THEME = new_theme + # Synchronize rich-argparse styles with the main application theme. + for name in RichHelpFormatter.styles.keys() & APP_THEME.styles.keys(): + RichHelpFormatter.styles[name] = APP_THEME.styles[name] - # Make sure the new theme has all style names included in a Cmd2Theme. - missing_names = Cmd2Theme().styles.keys() - THEME.styles.keys() - for name in missing_names: - THEME.styles[name] = Style() - # Update rich-argparse styles - for name in RichHelpFormatter.styles.keys() & THEME.styles.keys(): - RichHelpFormatter.styles[name] = THEME.styles[name] +# The main theme for cmd2-based applications. +# You can change it with set_theme(). +APP_THEME = _create_default_theme() class RichPrintKwargs(TypedDict, total=False): @@ -113,7 +106,7 @@ class RichPrintKwargs(TypedDict, total=False): class Cmd2Console(Console): - """Rich console with characteristics appropriate for cmd2 applications.""" + """Rich console with characteristics appropriate for cmd2-based applications.""" def __init__(self, file: IO[str] | None = None) -> None: """Cmd2Console initializer. @@ -147,7 +140,7 @@ def __init__(self, file: IO[str] | None = None) -> None: markup=False, emoji=False, highlight=False, - theme=THEME, + theme=APP_THEME, ) def on_broken_pipe(self) -> None: @@ -178,7 +171,7 @@ def rich_text_to_string(text: Text) -> str: markup=False, emoji=False, highlight=False, - theme=THEME, + theme=APP_THEME, ) with console.capture() as capture: console.print(text, end="") From 14a052bcd11a657754a02d0f8edee3373a548315 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 20 Aug 2025 15:26:17 -0400 Subject: [PATCH 28/89] Added conditional check to prevent rich.Text.from_ansi() bug workaround from running when bug isn't present. --- cmd2/rich_utils.py | 41 +++++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 6d0c613a..0974aef8 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -178,6 +178,10 @@ def rich_text_to_string(text: Text) -> str: return capture.get() +# If True, Rich still has the bug addressed in string_to_rich_text(). +_from_ansi_has_newline_bug = Text.from_ansi("\n").plain == "" + + def string_to_rich_text(text: str) -> Text: r"""Create a Text object from a string which can contain ANSI escape codes. @@ -192,24 +196,25 @@ def string_to_rich_text(text: str) -> Text: """ result = Text.from_ansi(text) - # If the original string ends with a recognized line break character, - # then restore the missing newline. We use "\n" because Text.from_ansi() - # converts all line breaks into newlines. - # Source: https://docs.python.org/3/library/stdtypes.html#str.splitlines - line_break_chars = { - "\n", # Line Feed - "\r", # Carriage Return - "\v", # Vertical Tab - "\f", # Form Feed - "\x1c", # File Separator - "\x1d", # Group Separator - "\x1e", # Record Separator - "\x85", # Next Line (NEL) - "\u2028", # Line Separator - "\u2029", # Paragraph Separator - } - if text and text[-1] in line_break_chars: - result.append("\n") + if _from_ansi_has_newline_bug: + # If the original string ends with a recognized line break character, + # then restore the missing newline. We use "\n" because Text.from_ansi() + # converts all line breaks into newlines. + # Source: https://docs.python.org/3/library/stdtypes.html#str.splitlines + line_break_chars = { + "\n", # Line Feed + "\r", # Carriage Return + "\v", # Vertical Tab + "\f", # Form Feed + "\x1c", # File Separator + "\x1d", # Group Separator + "\x1e", # Record Separator + "\x85", # Next Line (NEL) + "\u2028", # Line Separator + "\u2029", # Paragraph Separator + } + if text and text[-1] in line_break_chars: + result.append("\n") return result From 6f74dd5ace5dcb684b8479301f36d6ecb663dcb8 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 20 Aug 2025 15:35:33 -0400 Subject: [PATCH 29/89] Updated comments. --- cmd2/rich_utils.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 0974aef8..95cd431d 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -59,6 +59,9 @@ def _create_default_theme() -> Theme: def set_theme(styles: Mapping[str, StyleType] | None = None) -> None: """Set the Rich theme used by cmd2. + Call set_theme() with no arguments to reset to the default theme. + This will clear any custom styles that were previously applied. + :param styles: optional mapping of style names to styles """ global APP_THEME # noqa: PLW0603 From 28296ba777f0ca4bfbdcc25ae176c49bc588d756 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 20 Aug 2025 18:13:13 -0400 Subject: [PATCH 30/89] Documented default cmd2 styles. --- cmd2/cmd2.py | 9 +++++---- cmd2/styles.py | 14 +++++++------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 7aa08dc2..b9e8d98e 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -292,7 +292,6 @@ class Cmd(cmd.Cmd): Line-oriented command interpreters are often useful for test harnesses, internal tools, and rapid prototypes. """ - ruler = "─" DEFAULT_EDITOR = utils.find_editor() # Sorting keys for strings @@ -475,7 +474,10 @@ def __init__( # The multiline command currently being typed which is used to tab complete multiline commands. self._multiline_in_progress = '' - # Set text which prints right before all of the help topics are listed. + # Characters used to draw a horizontal rule. Should not be blank. + self.ruler = "─" + + # Set text which prints right before all of the help tables are listed. self.doc_leader = "" # Set header for table listing documented commands. @@ -4175,8 +4177,7 @@ def print_topics(self, header: str, cmds: list[str] | None, cmdlen: int, maxcol: header_grid = Table.grid() header_grid.add_row(header, style=Cmd2Style.HELP_HEADER) - if self.ruler: - header_grid.add_row(Rule(characters=self.ruler)) + header_grid.add_row(Rule(characters=self.ruler)) self.poutput(header_grid) self.columnize(cmds, maxcol - 1) self.poutput() diff --git a/cmd2/styles.py b/cmd2/styles.py index 22cba9f9..57b78606 100644 --- a/cmd2/styles.py +++ b/cmd2/styles.py @@ -30,13 +30,13 @@ class Cmd2Style(StrEnum): added here must have a corresponding style definition there. """ - ERROR = "cmd2.error" - EXAMPLE = "cmd2.example" - HELP_HEADER = "cmd2.help.header" - HELP_LEADER = "cmd2.help.leader" - RULE_LINE = "cmd2.rule.line" - SUCCESS = "cmd2.success" - WARNING = "cmd2.warning" + ERROR = "cmd2.error" # Error text (used by perror()) + EXAMPLE = "cmd2.example" # Command line examples in help text + HELP_HEADER = "cmd2.help.header" # Help table header text + HELP_LEADER = "cmd2.help.leader" # Text right before the help tables are listed + RULE_LINE = "rule.line" # Rich style for horizontal rules + SUCCESS = "cmd2.success" # Success text (used by psuccess()) + WARNING = "cmd2.warning" # Warning text (used by pwarning()) # Default styles used by cmd2. Tightly coupled with the Cmd2Style enum. From 2fe2b317fdb9498d62ab3d010519d19e389f304a Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 20 Aug 2025 18:51:17 -0400 Subject: [PATCH 31/89] Added unit tests for rich_utils.py. --- tests/test_rich_utils.py | 85 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 tests/test_rich_utils.py diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py new file mode 100644 index 00000000..af6f4b91 --- /dev/null +++ b/tests/test_rich_utils.py @@ -0,0 +1,85 @@ +"""Unit testing for cmd2/rich_utils.py module""" + +import pytest +from rich.style import Style +from rich.text import Text + +from cmd2 import ( + Cmd2Style, + Color, +) +from cmd2 import rich_utils as ru +from cmd2 import string_utils as su + + +def test_string_to_rich_text() -> None: + # Line breaks recognized by str.splitlines(). + # Source: https://docs.python.org/3/library/stdtypes.html#str.splitlines + line_breaks = { + "\n", # Line Feed + "\r", # Carriage Return + "\r\n", # Carriage Return + Line Feed + "\v", # Vertical Tab + "\f", # Form Feed + "\x1c", # File Separator + "\x1d", # Group Separator + "\x1e", # Record Separator + "\x85", # Next Line (NEL) + "\u2028", # Line Separator + "\u2029", # Paragraph Separator + } + + # Test all line breaks + for lb in line_breaks: + input_string = f"Text{lb}" + expected_output = input_string.replace(lb, "\n") + assert ru.string_to_rich_text(input_string).plain == expected_output + + # Test string without trailing line break + input_string = "No trailing\nline break" + assert ru.string_to_rich_text(input_string).plain == input_string + + # Test empty string + input_string = "" + assert ru.string_to_rich_text(input_string).plain == input_string + + +@pytest.mark.parametrize( + ('rich_text', 'string'), + [ + (Text("Hello"), "Hello"), + (Text("Hello\n"), "Hello\n"), + (Text("Hello", style="blue"), su.stylize("Hello", style="blue")), + ], +) +def test_rich_text_to_string(rich_text: Text, string: str) -> None: + assert ru.rich_text_to_string(rich_text) == string + + +def test_set_style() -> None: + # Save a cmd2, rich-argparse, and rich-specific style. + cmd2_style_key = Cmd2Style.ERROR + argparse_style_key = "argparse.args" + rich_style_key = "inspect.attr" + + orig_cmd2_style = ru.APP_THEME.styles[cmd2_style_key] + orig_argparse_style = ru.APP_THEME.styles[argparse_style_key] + orig_rich_style = ru.APP_THEME.styles[rich_style_key] + + # Overwrite these styles by setting a new theme. + theme = { + cmd2_style_key: Style(color=Color.CYAN), + argparse_style_key: Style(color=Color.AQUAMARINE3, underline=True), + rich_style_key: Style(color=Color.DARK_GOLDENROD, bold=True), + } + ru.set_theme(theme) + + # Verify theme styles have changed to our custom values. + assert ru.APP_THEME.styles[cmd2_style_key] != orig_cmd2_style + assert ru.APP_THEME.styles[cmd2_style_key] == theme[cmd2_style_key] + + assert ru.APP_THEME.styles[argparse_style_key] != orig_argparse_style + assert ru.APP_THEME.styles[argparse_style_key] == theme[argparse_style_key] + + assert ru.APP_THEME.styles[rich_style_key] != orig_rich_style + assert ru.APP_THEME.styles[rich_style_key] == theme[rich_style_key] From 8bbe6880323a428ed51c68e689a978660f50d549 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 20 Aug 2025 19:51:45 -0400 Subject: [PATCH 32/89] Using Rich Rule for transcript output lines. --- cmd2/cmd2.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index b9e8d98e..52be358e 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -5544,7 +5544,10 @@ class TestMyAppCase(Cmd2TestCase): verinfo = ".".join(map(str, sys.version_info[:3])) num_transcripts = len(transcripts_expanded) plural = '' if len(transcripts_expanded) == 1 else 's' - self.poutput(su.align_center(' cmd2 transcript test ', character=self.ruler), style=Style(bold=True)) + self.poutput( + Rule("cmd2 transcript test", style=Style.null()), + style=Style(bold=True), + ) self.poutput(f'platform {sys.platform} -- Python {verinfo}, cmd2-{cmd2.__version__}, readline-{rl_type}') self.poutput(f'cwd: {os.getcwd()}') self.poutput(f'cmd2 app: {sys.argv[0]}') @@ -5560,9 +5563,8 @@ class TestMyAppCase(Cmd2TestCase): execution_time = time.time() - start_time if test_results.wasSuccessful(): self.perror(stream.read(), end="", style=None) - finish_msg = f' {num_transcripts} transcript{plural} passed in {execution_time:.3f} seconds ' - finish_msg = su.align_center(finish_msg, character=self.ruler) - self.psuccess(finish_msg) + finish_msg = f'{num_transcripts} transcript{plural} passed in {execution_time:.3f} seconds' + self.psuccess(Rule(finish_msg, style=Style.null())) else: # Strip off the initial traceback which isn't particularly useful for end users error_str = stream.read() From 28729a21a2102e89371bc0f6cfa7ccf68a314059 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Wed, 20 Aug 2025 22:40:34 -0400 Subject: [PATCH 33/89] Fix: Remove unused mypy ignores and fix type errors (#1481) Fix: Remove unused mypy ignores and fix type errors (#1481) Also: - Fix a typo in comment in argparse_completer.py - Add draft GEMINI.md file for use with Gemini CLI --- GEMINI.md | 38 ++++++++++++++++++++++++++++++++++++++ cmd2/argparse_completer.py | 8 ++++---- cmd2/argparse_custom.py | 4 ++-- cmd2/clipboard.py | 2 +- cmd2/cmd2.py | 36 ++++++++++++++++++------------------ cmd2/decorators.py | 4 ++-- cmd2/parsing.py | 2 +- cmd2/rl_utils.py | 11 ++++++++--- cmd2/utils.py | 4 ++-- 9 files changed, 76 insertions(+), 33 deletions(-) create mode 100644 GEMINI.md diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 00000000..3d525c1c --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,38 @@ +# Instructions for Gemini CLI in a `uv` Python project + +This `GEMINI.md` file provides context and instructions for the Gemini CLI when working with this +Python project, which utilizes `uv` for environment and package management. + +## General Instructions + +- **Environment Management:** Prefer using `uv` for all Python environment management tasks. +- **Package Installation:** Always use `uv` to install packages and ensure they are installed within + the project's virtual environment. +- **Running Scripts/Commands:** + - To run Python scripts within the project's virtual environment, use `uv run ...`. + - To run programs directly from a PyPI package (installing it on the fly if necessary), use + `uvx ...` (shortcut for `uv tool run`). +- **New Dependencies:** If a new dependency is required, please state the reason for its inclusion. + +## Python Code Standards + +To ensure Python code adheres to required standards, the following commands **must** be run before +creating or modifying any `.py` files: + +```bash +make check +``` + +To run unit tests use the following command: + +```bash +make test +``` + +To make sure the documentation builds properly, use the following command: + +```bash +make docs-test +``` + +All 3 of the above commands should be run prior to committing code. diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 92dc6b0d..5fc48460 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -1,4 +1,4 @@ -"""Module efines the ArgparseCompleter class which provides argparse-based tab completion to cmd2 apps. +"""Module defines the ArgparseCompleter class which provides argparse-based tab completion to cmd2 apps. See the header of argparse_custom.py for instructions on how to use these features. """ @@ -533,7 +533,7 @@ def _format_completions(self, arg_state: _ArgumentState, completions: list[str] 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: @@ -726,12 +726,12 @@ def _complete_arg( if not arg_choices.is_completer: choices_func = arg_choices.choices_provider if isinstance(choices_func, ChoicesProviderFuncWithTokens): - completion_items = choices_func(*args, **kwargs) # type: ignore[arg-type] + completion_items = choices_func(*args, **kwargs) else: # pragma: no cover # This won't hit because runtime checking doesn't check function argument types and will always # resolve true above. Mypy, however, does see the difference and gives an error that can't be # ignored. Mypy issue #5485 discusses this problem - completion_items = choices_func(*args) # type: ignore[arg-type] + completion_items = choices_func(*args) # else case is already covered above else: completion_items = arg_choices diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index f3e5344b..4f0e99f6 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -1433,7 +1433,7 @@ def __init__( 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, @@ -1498,7 +1498,7 @@ def format_help(self) -> str: formatter = self._get_formatter() # usage - formatter.add_usage(self.usage, self._actions, self._mutually_exclusive_groups) # type: ignore[arg-type] + formatter.add_usage(self.usage, self._actions, self._mutually_exclusive_groups) # description formatter.add_text(self.description) diff --git a/cmd2/clipboard.py b/cmd2/clipboard.py index 284d57df..4f78925c 100644 --- a/cmd2/clipboard.py +++ b/cmd2/clipboard.py @@ -2,7 +2,7 @@ import typing -import pyperclip # type: ignore[import] +import pyperclip # type: ignore[import-untyped] def get_paste_buffer() -> str: diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 52be358e..87c9ce1d 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -137,7 +137,7 @@ # NOTE: When using gnureadline with Python 3.13, start_ipython needs to be imported before any readline-related stuff with contextlib.suppress(ImportError): - from IPython import start_ipython # type: ignore[import] + from IPython import start_ipython from .rl_utils import ( RlType, @@ -163,7 +163,7 @@ if rl_type == RlType.NONE: # pragma: no cover Cmd2Console(sys.stderr).print(rl_warning, style=Cmd2Style.WARNING) else: - from .rl_utils import ( # type: ignore[attr-defined] + from .rl_utils import ( readline, rl_force_redisplay, ) @@ -1068,7 +1068,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 @@ -3094,11 +3094,11 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: kwargs['executable'] = shell # For any stream that is a StdSim, we will use a pipe so we can capture its output - proc = subprocess.Popen( # type: ignore[call-overload] # noqa: S602 + proc = subprocess.Popen( # noqa: S602 statement.pipe_to, stdin=subproc_stdin, stdout=subprocess.PIPE if isinstance(self.stdout, utils.StdSim) else self.stdout, # type: ignore[unreachable] - stderr=subprocess.PIPE if isinstance(sys.stderr, utils.StdSim) else sys.stderr, # type: ignore[unreachable] + stderr=subprocess.PIPE if isinstance(sys.stderr, utils.StdSim) else sys.stderr, shell=True, **kwargs, ) @@ -3115,7 +3115,7 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: subproc_stdin.close() new_stdout.close() raise RedirectionError(f'Pipe process exited with code {proc.returncode} before command could run') - redir_saved_state.redirecting = True # type: ignore[unreachable] + redir_saved_state.redirecting = True cmd_pipe_proc_reader = utils.ProcReader(proc, cast(TextIO, self.stdout), sys.stderr) self.stdout = new_stdout @@ -3351,7 +3351,7 @@ def complete_none(text: str, state: int) -> str | None: # pragma: no cover # n parser.add_argument( 'arg', suppress_tab_hint=True, - choices=choices, # type: ignore[arg-type] + choices=choices, choices_provider=choices_provider, completer=completer, ) @@ -4435,7 +4435,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, ) @@ -4566,15 +4566,15 @@ def do_shell(self, args: argparse.Namespace) -> None: # still receive the SIGINT since it is in the same process group as us. with self.sigint_protection: # For any stream that is a StdSim, we will use a pipe so we can capture its output - proc = subprocess.Popen( # type: ignore[call-overload] # noqa: S602 + proc = subprocess.Popen( # noqa: S602 expanded_command, stdout=subprocess.PIPE if isinstance(self.stdout, utils.StdSim) else self.stdout, # type: ignore[unreachable] - stderr=subprocess.PIPE if isinstance(sys.stderr, utils.StdSim) else sys.stderr, # type: ignore[unreachable] + stderr=subprocess.PIPE if isinstance(sys.stderr, utils.StdSim) else sys.stderr, shell=True, **kwargs, ) - proc_reader = utils.ProcReader(proc, cast(TextIO, self.stdout), sys.stderr) # type: ignore[arg-type] + proc_reader = utils.ProcReader(proc, cast(TextIO, self.stdout), sys.stderr) proc_reader.wait() # Save the return code of the application for use in a pyscript @@ -4656,9 +4656,9 @@ def _set_up_py_shell_env(self, interp: InteractiveConsole) -> _SavedCmd2Env: # Save off the current completer and set a new one in the Python console # Make sure it tab completes from its locals() dictionary cmd2_env.readline_settings.completer = readline.get_completer() - interp.runcode("from rlcompleter import Completer") # type: ignore[arg-type] - interp.runcode("import readline") # type: ignore[arg-type] - interp.runcode("readline.set_completer(Completer(locals()).complete)") # type: ignore[arg-type] + interp.runcode(compile("from rlcompleter import Completer", "", "exec")) + interp.runcode(compile("import readline", "", "exec")) + interp.runcode(compile("readline.set_completer(Completer(locals()).complete)", "", "exec")) # Set up sys module for the Python console self._reset_py_display() @@ -4889,18 +4889,18 @@ def do_ipy(self, _: argparse.Namespace) -> bool | None: # pragma: no cover # Detect whether IPython is installed try: - import traitlets.config.loader as traitlets_loader # type: ignore[import] + import traitlets.config.loader as traitlets_loader # Allow users to install ipython from a cmd2 prompt when needed and still have ipy command work try: _dummy = start_ipython # noqa: F823 except NameError: - from IPython import start_ipython # type: ignore[import] + from IPython import start_ipython - from IPython.terminal.interactiveshell import ( # type: ignore[import] + from IPython.terminal.interactiveshell import ( TerminalInteractiveShell, ) - from IPython.terminal.ipapp import ( # type: ignore[import] + from IPython.terminal.ipapp import ( TerminalIPythonApp, ) except ImportError: diff --git a/cmd2/decorators.py b/cmd2/decorators.py index cae1b399..de4bc2e5 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -180,7 +180,7 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: 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__ @@ -336,7 +336,7 @@ def cmd_wrapper(*args: Any, **kwargs: dict[str, Any]) -> bool | None: 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) :] diff --git a/cmd2/parsing.py b/cmd2/parsing.py index 8a6acb08..5dca03d3 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -81,7 +81,7 @@ class Macro: @dataclass(frozen=True) -class Statement(str): # type: ignore[override] # noqa: SLOT000 +class Statement(str): # noqa: SLOT000 """String subclass with additional attributes to store the results of parsing. The ``cmd`` module in the standard library passes commands around as a diff --git a/cmd2/rl_utils.py b/cmd2/rl_utils.py index b6ae824c..29d8b4e5 100644 --- a/cmd2/rl_utils.py +++ b/cmd2/rl_utils.py @@ -24,11 +24,11 @@ # Prefer statically linked gnureadline if installed due to compatibility issues with libedit try: - import gnureadline as readline # type: ignore[import] + import gnureadline as readline # type: ignore[import-not-found] except ImportError: # Note: If this actually fails, you should install gnureadline on Linux/Mac or pyreadline3 on Windows. with contextlib.suppress(ImportError): - import readline # type: ignore[no-redef] + import readline class RlType(Enum): @@ -279,7 +279,7 @@ def rl_in_search_mode() -> bool: # pragma: no cover readline_state = ctypes.c_int.in_dll(readline_lib, "rl_readline_state").value return bool(in_search_mode & readline_state) if rl_type == RlType.PYREADLINE: - from pyreadline3.modes.emacs import ( # type: ignore[import] + from pyreadline3.modes.emacs import ( # type: ignore[import-not-found] EmacsMode, ) @@ -294,3 +294,8 @@ def rl_in_search_mode() -> bool: # pragma: no cover ) return readline.rl.mode.process_keyevent_queue[-1] in search_funcs return False + + +__all__ = [ + 'readline', +] diff --git a/cmd2/utils.py b/cmd2/utils.py index bf4d1486..68508443 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -697,7 +697,7 @@ def do_echo(self, arglist): for item in func: setattr(item, constants.CMD_ATTR_HELP_CATEGORY, category) elif inspect.ismethod(func): - setattr(func.__func__, constants.CMD_ATTR_HELP_CATEGORY, category) # type: ignore[attr-defined] + setattr(func.__func__, constants.CMD_ATTR_HELP_CATEGORY, category) else: setattr(func, constants.CMD_ATTR_HELP_CATEGORY, category) @@ -716,7 +716,7 @@ def get_defining_class(meth: Callable[..., Any]) -> type[Any] | None: if inspect.ismethod(meth) or ( inspect.isbuiltin(meth) and hasattr(meth, '__self__') and hasattr(meth.__self__, '__class__') ): - for cls in inspect.getmro(meth.__self__.__class__): # type: ignore[attr-defined] + for cls in inspect.getmro(meth.__self__.__class__): if meth.__name__ in cls.__dict__: return cls meth = getattr(meth, '__func__', meth) # fallback to __qualname__ parsing From cab433d9ec23323264dc6018a7dfc01212c13146 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 21 Aug 2025 00:59:28 -0400 Subject: [PATCH 34/89] Corrected a comment and some type hints. --- cmd2/argparse_custom.py | 6 +++--- cmd2/rich_utils.py | 12 +++++------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 4f0e99f6..73a1aa46 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -1261,7 +1261,7 @@ def _format_action_invocation(self, action: argparse.Action) -> str: def _determine_metavar( self, action: argparse.Action, - default_metavar: str | tuple[str, ...], + default_metavar: str, ) -> str | tuple[str, ...]: """Determine what to use as the metavar value of an action.""" if action.metavar is not None: @@ -1278,7 +1278,7 @@ def _determine_metavar( def _metavar_formatter( self, action: argparse.Action, - default_metavar: str | tuple[str, ...], + default_metavar: str, ) -> Callable[[int], tuple[str, ...]]: metavar = self._determine_metavar(action, default_metavar) @@ -1289,7 +1289,7 @@ def format_tuple(tuple_size: int) -> tuple[str, ...]: return format_tuple - def _format_args(self, action: argparse.Action, default_metavar: str | tuple[str, ...]) -> str: + def _format_args(self, action: argparse.Action, default_metavar: str) -> str: """Handle ranged nargs and make other output less verbose.""" metavar = self._determine_metavar(action, default_metavar) metavar_formatter = self._metavar_formatter(action, default_metavar) diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 95cd431d..05ef523b 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -128,13 +128,11 @@ def __init__(self, file: IO[str] | None = None) -> None: elif ALLOW_STYLE == AllowStyle.NEVER: force_terminal = False - # Configure console defaults to treat output as plain, unstructured text. - # This involves enabling soft wrapping (no automatic word-wrap) and disabling - # Rich's automatic markup, emoji, and highlight processing. - # While these automatic features are off by default, the console fully supports - # rendering explicitly created Rich objects (e.g., Panel, Table). - # Any of these default settings or other print behaviors can be overridden - # in individual Console.print() calls or via cmd2's print methods. + # The console's defaults are configured to handle pre-formatted text. It enables soft wrap, + # which prevents automatic word-wrapping, and disables Rich's automatic processing for + # markup, emoji, and highlighting. While these features are off by default, the console + # can still fully render explicit Rich objects like Panels and Tables. These defaults can + # be overridden in calls to Cmd2Console.print() and cmd2's print methods. super().__init__( file=file, force_terminal=force_terminal, From fca3b95566aa90013635f7246d8d3fe6e9394bf9 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 21 Aug 2025 09:04:18 -0400 Subject: [PATCH 35/89] Add a simple example to demonstrate the colors available in cmd2.Color (#1482) Also: - Deleted old examples/colors.py and updated documentation --------- Co-authored-by: Kevin Van Brunt --- docs/features/completion.md | 3 +- examples/README.md | 4 +- examples/color.py | 51 +++++++++++++++++++++ examples/colors.py | 89 ------------------------------------- 4 files changed, 54 insertions(+), 93 deletions(-) create mode 100755 examples/color.py delete mode 100755 examples/colors.py diff --git a/docs/features/completion.md b/docs/features/completion.md index 47ba9d07..3d08bf87 100644 --- a/docs/features/completion.md +++ b/docs/features/completion.md @@ -96,8 +96,7 @@ Tab completion of argument values can be configured by using one of three parame - `completer` See the [arg_decorators](https://github.com/python-cmd2/cmd2/blob/main/examples/arg_decorators.py) -or [colors](https://github.com/python-cmd2/cmd2/blob/main/examples/colors.py) example for a -demonstration of how to use the `choices` parameter. See the +example for a demonstration of how to use the `choices` parameter. See the [argparse_completion](https://github.com/python-cmd2/cmd2/blob/main/examples/argparse_completion.py) example for a demonstration of how to use the `choices_provider` parameter. See the [arg_decorators](https://github.com/python-cmd2/cmd2/blob/main/examples/arg_decorators.py) or diff --git a/examples/README.md b/examples/README.md index 67bd4278..aad2072b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -32,8 +32,8 @@ each: - Show how to enable custom tab completion by assigning a completer function to `do_*` commands - [cmd2_as_argument.py](https://github.com/python-cmd2/cmd2/blob/main/examples/cmd_as_argument.py) - Demonstrates how to accept and parse command-line arguments when invoking a cmd2 application -- [colors.py](https://github.com/python-cmd2/cmd2/blob/main/examples/colors.py) - - Show various ways of using colorized output within a cmd2 application +- [color.py](https://github.com/python-cmd2/cmd2/blob/main/examples/color.py) + - Show the numerous colors available to use in your cmd2 applications - [custom_parser.py](https://github.com/python-cmd2/cmd2/blob/main/examples/custom_parser.py) - Demonstrates how to create your own custom `Cmd2ArgumentParser` - [decorator_example.py](https://github.com/python-cmd2/cmd2/blob/main/examples/decorator_example.py) diff --git a/examples/color.py b/examples/color.py new file mode 100755 index 00000000..c9cd65b2 --- /dev/null +++ b/examples/color.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python +"""A sample application for cmd2. Demonstrating colors available in the cmd2.colors.Color enum. + +Execute the taste_the_rainbow command to see the colors available. +""" + +import argparse + +from rich.style import Style + +import cmd2 +from cmd2 import ( + Color, + stylize, +) + + +class CmdLineApp(cmd2.Cmd): + """Example cmd2 application demonstrating colorized output.""" + + def __init__(self) -> None: + # Set include_ipy to True to enable the "ipy" command which runs an interactive IPython shell + super().__init__(include_ipy=True) + self.intro = 'Run the taste_the_rainbow command to see all of the colors available to you in cmd2.' + + rainbow_parser = cmd2.Cmd2ArgumentParser() + rainbow_parser.add_argument('-b', '--background', action='store_true', help='show background colors as well') + rainbow_parser.add_argument('-p', '--paged', action='store_true', help='display output using a pager') + + @cmd2.with_argparser(rainbow_parser) + def do_taste_the_rainbow(self, args: argparse.Namespace) -> None: + """Show all of the colors available within cmd2's Color StrEnum class.""" + + color_names = [] + for color_member in Color: + style = Style(bgcolor=color_member) if args.background else Style(color=color_member) + styled_name = stylize(color_member.name, style=style) + if args.paged: + color_names.append(styled_name) + else: + self.poutput(styled_name) + + if args.paged: + self.ppaged('\n'.join(color_names)) + + +if __name__ == '__main__': + import sys + + c = CmdLineApp() + sys.exit(c.cmdloop()) diff --git a/examples/colors.py b/examples/colors.py deleted file mode 100755 index fad3c958..00000000 --- a/examples/colors.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env python -"""A sample application for cmd2. Demonstrating colorized output. - -Experiment with the command line options on the `speak` command to see how -different output colors ca - -The allow_style setting has three possible values: - -Never - poutput(), pfeedback(), and ppaged() strip all ANSI style sequences - which instruct the terminal to colorize output - -Terminal - (the default value) poutput(), pfeedback(), and ppaged() do not strip any - ANSI style sequences when the output is a terminal, but if the output is - a pipe or a file the style sequences are stripped. If you want colorized - output, add ANSI style sequences using cmd2's internal ansi module. - -Always - poutput(), pfeedback(), and ppaged() never strip ANSI style sequences, - regardless of the output destination -""" - -import cmd2 -from cmd2 import ( - Bg, - Fg, - ansi, -) - -fg_choices = [c.name.lower() for c in Fg] -bg_choices = [c.name.lower() for c in Bg] - - -class CmdLineApp(cmd2.Cmd): - """Example cmd2 application demonstrating colorized output.""" - - def __init__(self) -> None: - # Set include_ipy to True to enable the "ipy" command which runs an interactive IPython shell - super().__init__(include_ipy=True) - - self.maxrepeats = 3 - # Make maxrepeats settable at runtime - self.add_settable(cmd2.Settable('maxrepeats', int, 'max repetitions for speak command', self)) - - # Should ANSI color output be allowed - self.allow_style = ansi.AllowStyle.TERMINAL - - speak_parser = cmd2.Cmd2ArgumentParser() - speak_parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay') - speak_parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE') - speak_parser.add_argument('-r', '--repeat', type=int, help='output [n] times') - speak_parser.add_argument('-f', '--fg', choices=fg_choices, help='foreground color to apply to output') - speak_parser.add_argument('-b', '--bg', choices=bg_choices, help='background color to apply to output') - speak_parser.add_argument('-l', '--bold', action='store_true', help='bold the output') - speak_parser.add_argument('-u', '--underline', action='store_true', help='underline the output') - speak_parser.add_argument('words', nargs='+', help='words to say') - - @cmd2.with_argparser(speak_parser) - def do_speak(self, args) -> None: - """Repeats what you tell me to.""" - words = [] - for word in args.words: - if args.piglatin: - word = f'{word[1:]}{word[0]}ay' - if args.shout: - word = word.upper() - words.append(word) - - repetitions = args.repeat or 1 - - fg_color = Fg[args.fg.upper()] if args.fg else None - bg_color = Bg[args.bg.upper()] if args.bg else None - output_str = ansi.style(' '.join(words), fg=fg_color, bg=bg_color, bold=args.bold, underline=args.underline) - - for _ in range(min(repetitions, self.maxrepeats)): - # .poutput handles newlines, and accommodates output redirection too - self.poutput(output_str) - - def do_timetravel(self, _) -> None: - """A command which always generates an error message, to demonstrate custom error colors.""" - self.perror('Mr. Fusion failed to start. Could not energize flux capacitor.') - - -if __name__ == '__main__': - import sys - - c = CmdLineApp() - sys.exit(c.cmdloop()) From 13ddf698066928ae0a2826171bfa78bc11558886 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 21 Aug 2025 16:20:39 -0400 Subject: [PATCH 36/89] Updated stylize() unit test. --- cmd2/string_utils.py | 9 +++++---- tests/test_string_utils.py | 18 ++++++++---------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/cmd2/string_utils.py b/cmd2/string_utils.py index 1405b5f5..663f8633 100644 --- a/cmd2/string_utils.py +++ b/cmd2/string_utils.py @@ -82,22 +82,23 @@ def align_right( def stylize(val: str, style: StyleType) -> str: - """Apply ANSI style to a string. + """Apply an ANSI style to a string, preserving any existing styles. :param val: string to be styled :param style: style instance or style definition to apply. :return: the stylized string """ + # Convert to a Rich Text object to parse and preserve existing ANSI styles. text = ru.string_to_rich_text(val) text.stylize(style) return ru.rich_text_to_string(text) def strip_style(val: str) -> str: - """Strip ANSI style sequences from a string. + """Strip all ANSI styles from a string. - :param val: string which may contain ANSI style sequences - :return: the same string with any ANSI style sequences removed + :param val: string to be stripped + :return: the stripped string """ text = ru.string_to_rich_text(val) return text.plain diff --git a/tests/test_string_utils.py b/tests/test_string_utils.py index f0c95516..7e1aa5f7 100644 --- a/tests/test_string_utils.py +++ b/tests/test_string_utils.py @@ -115,18 +115,16 @@ def test_align_right_with_style() -> None: def test_stylize() -> None: - styled_str = su.stylize( - HELLO_WORLD, - style=Style( - color=Color.GREEN, - bgcolor=Color.BLUE, - bold=True, - underline=True, - ), - ) - + # Test string with no existing style + style = Style(color=Color.GREEN, bgcolor=Color.BLUE, bold=True, underline=True) + styled_str = su.stylize(HELLO_WORLD, style=style) assert styled_str == "\x1b[1;4;32;44mHello, world!\x1b[0m" + # Add style to already-styled string + updated_style = Style.combine([style, Style(strike=True)]) + restyled_string = su.stylize(styled_str, style=updated_style) + assert restyled_string == "\x1b[1;4;9;32;44mHello, world!\x1b[0m" + def test_strip_style() -> None: base_str = HELLO_WORLD From 18831d19e8572971e6f0310d09dba4ad4d4fd507 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 21 Aug 2025 19:49:55 -0400 Subject: [PATCH 37/89] Fix: rich-argparse help text truncation Created Cmd2RichArgparseConsole, which doesn't enable soft wrapping. This resolves a conflict with the rich-argparse formatter's explicit no_wrap and overflow settings. --- cmd2/argparse_completer.py | 4 +-- cmd2/argparse_custom.py | 8 ++--- cmd2/cmd2.py | 26 +++++++------- cmd2/rich_utils.py | 73 ++++++++++++++++++++++++++++---------- tests/test_rich_utils.py | 17 ++++++++- 5 files changed, 90 insertions(+), 38 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 5fc48460..e859c94a 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -19,7 +19,7 @@ from .constants import ( INFINITY, ) -from .rich_utils import Cmd2Console +from .rich_utils import Cmd2GeneralConsole if TYPE_CHECKING: # pragma: no cover from .cmd2 import ( @@ -590,7 +590,7 @@ def _format_completions(self, arg_state: _ArgumentState, completions: list[str] hint_table.add_row(item, *item.descriptive_data) # Generate the hint table string - console = Cmd2Console() + console = Cmd2GeneralConsole() with console.capture() as capture: console.print(hint_table, end="") self._cmd2_app.formatted_completions = capture.get() diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 73a1aa46..516388cb 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -295,7 +295,7 @@ def get_items(self) -> list[CompletionItems]: ) from . import constants -from .rich_utils import Cmd2Console +from .rich_utils import Cmd2RichArgparseConsole from .styles import Cmd2Style if TYPE_CHECKING: # pragma: no cover @@ -1113,12 +1113,12 @@ def __init__( max_help_position: int = 24, width: int | None = None, *, - console: Cmd2Console | None = None, + console: Cmd2RichArgparseConsole | None = None, **kwargs: Any, ) -> None: """Initialize Cmd2HelpFormatter.""" if console is None: - console = Cmd2Console() + console = Cmd2RichArgparseConsole() super().__init__(prog, indent_increment, max_help_position, width, console=console, **kwargs) @@ -1481,7 +1481,7 @@ def error(self, message: str) -> NoReturn: # Add error style to message console = self._get_formatter().console with console.capture() as capture: - console.print(formatted_message, style=Cmd2Style.ERROR) + console.print(formatted_message, style=Cmd2Style.ERROR, crop=False) formatted_message = f"{capture.get()}" self.exit(2, f'{formatted_message}\n') diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 87c9ce1d..7ee98338 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -130,7 +130,7 @@ shlex_split, ) from .rich_utils import ( - Cmd2Console, + Cmd2GeneralConsole, RichPrintKwargs, ) from .styles import Cmd2Style @@ -161,7 +161,7 @@ # Set up readline if rl_type == RlType.NONE: # pragma: no cover - Cmd2Console(sys.stderr).print(rl_warning, style=Cmd2Style.WARNING) + Cmd2GeneralConsole(sys.stderr).print(rl_warning, style=Cmd2Style.WARNING) else: from .rl_utils import ( readline, @@ -1221,7 +1221,7 @@ def print_to( terminal width; instead, any text that doesn't fit will run onto the following line(s), similar to the built-in print() function. Set to False to enable automatic word-wrapping. If None (the default for this parameter), the output will default to no word-wrapping, as - configured by the Cmd2Console. + configured by the Cmd2GeneralConsole. :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this method and still call `super()` without encountering unexpected keyword argument errors. @@ -1230,7 +1230,7 @@ def print_to( prepared_objects = ru.prepare_objects_for_rich_print(*objects) try: - Cmd2Console(file).print( + Cmd2GeneralConsole(file).print( *prepared_objects, sep=sep, end=end, @@ -1245,7 +1245,7 @@ def print_to( # warning message, then set the broken_pipe_warning attribute # to the message you want printed. if self.broken_pipe_warning and file != sys.stderr: - Cmd2Console(sys.stderr).print(self.broken_pipe_warning) + Cmd2GeneralConsole(sys.stderr).print(self.broken_pipe_warning) def poutput( self, @@ -1267,7 +1267,7 @@ def poutput( terminal width; instead, any text that doesn't fit will run onto the following line(s), similar to the built-in print() function. Set to False to enable automatic word-wrapping. If None (the default for this parameter), the output will default to no word-wrapping, as - configured by the Cmd2Console. + configured by the Cmd2GeneralConsole. :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this method and still call `super()` without encountering unexpected keyword argument errors. @@ -1303,7 +1303,7 @@ def perror( terminal width; instead, any text that doesn't fit will run onto the following line(s), similar to the built-in print() function. Set to False to enable automatic word-wrapping. If None (the default for this parameter), the output will default to no word-wrapping, as - configured by the Cmd2Console. + configured by the Cmd2GeneralConsole. :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this method and still call `super()` without encountering unexpected keyword argument errors. @@ -1337,7 +1337,7 @@ def psuccess( terminal width; instead, any text that doesn't fit will run onto the following line(s), similar to the built-in print() function. Set to False to enable automatic word-wrapping. If None (the default for this parameter), the output will default to no word-wrapping, as - configured by the Cmd2Console. + configured by the Cmd2GeneralConsole. :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this method and still call `super()` without encountering unexpected keyword argument errors. @@ -1370,7 +1370,7 @@ def pwarning( terminal width; instead, any text that doesn't fit will run onto the following line(s), similar to the built-in print() function. Set to False to enable automatic word-wrapping. If None (the default for this parameter), the output will default to no word-wrapping, as - configured by the Cmd2Console. + configured by the Cmd2GeneralConsole. :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this method and still call `super()` without encountering unexpected keyword argument errors. @@ -1404,7 +1404,7 @@ def pexcept( final_msg = Text() if self.debug and sys.exc_info() != (None, None, None): - console = Cmd2Console(sys.stderr) + console = Cmd2GeneralConsole(sys.stderr) console.print_exception(word_wrap=True) else: final_msg += f"EXCEPTION of type '{type(exception).__name__}' occurred with message: {exception}" @@ -1442,7 +1442,7 @@ def pfeedback( terminal width; instead, any text that doesn't fit will run onto the following line(s), similar to the built-in print() function. Set to False to enable automatic word-wrapping. If None (the default for this parameter), the output will default to no word-wrapping, as - configured by the Cmd2Console. + configured by the Cmd2GeneralConsole. :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this method and still call `super()` without encountering unexpected keyword argument errors. @@ -1498,7 +1498,7 @@ def ppaged( terminal width; instead, any text that doesn't fit will run onto the following line(s), similar to the built-in print() function. Set to False to enable automatic word-wrapping. If None (the default for this parameter), the output will default to no word-wrapping, as - configured by the Cmd2Console. + configured by the Cmd2GeneralConsole. Note: If chop is True and a pager is used, soft_wrap is automatically set to True. :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this @@ -1525,7 +1525,7 @@ def ppaged( soft_wrap = True # Generate the bytes to send to the pager - console = Cmd2Console(self.stdout) + console = Cmd2GeneralConsole(self.stdout) with console.capture() as capture: console.print( *prepared_objects, diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 05ef523b..07fcef8a 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -16,9 +16,7 @@ RenderableType, RichCast, ) -from rich.style import ( - StyleType, -) +from rich.style import StyleType from rich.text import Text from rich.theme import Theme from rich_argparse import RichHelpFormatter @@ -108,14 +106,33 @@ class RichPrintKwargs(TypedDict, total=False): new_line_start: bool -class Cmd2Console(Console): - """Rich console with characteristics appropriate for cmd2-based applications.""" +class Cmd2BaseConsole(Console): + """A base class for Rich consoles in cmd2-based applications.""" - def __init__(self, file: IO[str] | None = None) -> None: - """Cmd2Console initializer. + def __init__(self, file: IO[str] | None = None, **kwargs: Any) -> None: + """Cmd2BaseConsole initializer. - :param file: Optional file object where the console should write to. Defaults to sys.stdout. + :param file: optional file object where the console should write to. Defaults to sys.stdout. """ + # Don't allow force_terminal or force_interactive to be passed in, as their + # behavior is controlled by the ALLOW_STYLE setting. + if "force_terminal" in kwargs: + raise TypeError( + "Passing 'force_terminal' is not allowed. Its behavior is controlled by the 'ALLOW_STYLE' setting." + ) + if "force_interactive" in kwargs: + raise TypeError( + "Passing 'force_interactive' is not allowed. Its behavior is controlled by the 'ALLOW_STYLE' setting." + ) + + # Don't allow a theme to be passed in, as it is controlled by the global APP_THEME. + # Use cmd2.rich_utils.set_theme() to set the global theme or use a temporary + # theme with console.use_theme(). + if "theme" in kwargs: + raise TypeError( + "Passing 'theme' is not allowed. Its behavior is controlled by the global APP_THEME and set_theme()." + ) + force_terminal: bool | None = None force_interactive: bool | None = None @@ -128,20 +145,12 @@ def __init__(self, file: IO[str] | None = None) -> None: elif ALLOW_STYLE == AllowStyle.NEVER: force_terminal = False - # The console's defaults are configured to handle pre-formatted text. It enables soft wrap, - # which prevents automatic word-wrapping, and disables Rich's automatic processing for - # markup, emoji, and highlighting. While these features are off by default, the console - # can still fully render explicit Rich objects like Panels and Tables. These defaults can - # be overridden in calls to Cmd2Console.print() and cmd2's print methods. super().__init__( file=file, force_terminal=force_terminal, force_interactive=force_interactive, - soft_wrap=True, - markup=False, - emoji=False, - highlight=False, theme=APP_THEME, + **kwargs, ) def on_broken_pipe(self) -> None: @@ -150,9 +159,37 @@ def on_broken_pipe(self) -> None: raise BrokenPipeError +class Cmd2GeneralConsole(Cmd2BaseConsole): + """Rich console for general-purpose printing in cmd2-based applications.""" + + def __init__(self, file: IO[str] | None = None) -> None: + """Cmd2GeneralConsole initializer. + + :param file: optional file object where the console should write to. Defaults to sys.stdout. + """ + # This console is configured for general-purpose printing. It enables soft wrap + # and disables Rich's automatic processing for markup, emoji, and highlighting. + # These defaults can be overridden in calls to the console's or cmd2's print methods. + super().__init__( + file=file, + soft_wrap=True, + markup=False, + emoji=False, + highlight=False, + ) + + +class Cmd2RichArgparseConsole(Cmd2BaseConsole): + """Rich console for rich-argparse output in cmd2-based applications. + + This class ensures long lines in help text are not truncated by avoiding soft_wrap, + which conflicts with rich-argparse's explicit no_wrap and overflow settings. + """ + + def console_width() -> int: """Return the width of the console.""" - return Cmd2Console().width + return Cmd2BaseConsole().width def rich_text_to_string(text: Text) -> str: diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py index af6f4b91..61da5423 100644 --- a/tests/test_rich_utils.py +++ b/tests/test_rich_utils.py @@ -12,6 +12,21 @@ from cmd2 import string_utils as su +def test_cmd2_base_console() -> None: + # Test the keyword arguments which are not allowed. + with pytest.raises(TypeError) as excinfo: + ru.Cmd2BaseConsole(force_terminal=True) + assert 'force_terminal' in str(excinfo.value) + + with pytest.raises(TypeError) as excinfo: + ru.Cmd2BaseConsole(force_interactive=True) + assert 'force_interactive' in str(excinfo.value) + + with pytest.raises(TypeError) as excinfo: + ru.Cmd2BaseConsole(theme=None) + assert 'theme' in str(excinfo.value) + + def test_string_to_rich_text() -> None: # Line breaks recognized by str.splitlines(). # Source: https://docs.python.org/3/library/stdtypes.html#str.splitlines @@ -56,7 +71,7 @@ def test_rich_text_to_string(rich_text: Text, string: str) -> None: assert ru.rich_text_to_string(rich_text) == string -def test_set_style() -> None: +def test_set_theme() -> None: # Save a cmd2, rich-argparse, and rich-specific style. cmd2_style_key = Cmd2Style.ERROR argparse_style_key = "argparse.args" From aa112e0148323f7b79a33f7a5bc733cdf4472955 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 22 Aug 2025 14:53:29 -0400 Subject: [PATCH 38/89] Refactor: Isolate column rendering from printing. This change moves the core logic for rendering columns from the columnize() method to a new helper method, render_columns(). --- cmd2/cmd2.py | 39 +++++++++++++++++++++++++++++---------- cmd2/string_utils.py | 7 +++---- tests/test_cmd2.py | 33 +++++++++++++++++++++++---------- 3 files changed, 55 insertions(+), 24 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 7ee98338..d9161982 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -4243,22 +4243,26 @@ def _print_documented_command_topics(self, header: str, cmds: list[str], verbose category_grid.add_row(topics_table) self.poutput(category_grid, "") - def columnize(self, str_list: list[str] | None, display_width: int = 80) -> None: - """Display a list of single-line strings as a compact set of columns. + def render_columns(self, str_list: list[str] | None, display_width: int = 80) -> str: + """Render a list of single-line strings as a compact set of columns. - Override of cmd's columnize() to handle strings with ANSI style sequences and wide characters. + This method correctly handles strings containing ANSI escape codes and + full-width characters (like those used in CJK languages). Each column is + only as wide as necessary and columns are separated by two spaces. - Each column is only as wide as necessary. - Columns are separated by two spaces (one was not legible enough). + :param str_list: list of single-line strings to display + :param display_width: max number of display columns to fit into + :return: a string containing the columnized output """ if not str_list: - self.poutput("") - return + return "" size = len(str_list) if size == 1: - self.poutput(str_list[0]) - return + return str_list[0] + + rows: list[str] = [] + # Try every row count from 1 upwards for nrows in range(1, len(str_list)): ncols = (size + nrows - 1) // nrows @@ -4294,7 +4298,22 @@ def columnize(self, str_list: list[str] | None, display_width: int = 80) -> None del texts[-1] for col in range(len(texts)): texts[col] = su.align_left(texts[col], width=colwidths[col]) - self.poutput(" ".join(texts)) + rows.append(" ".join(texts)) + + return "\n".join(rows) + + def columnize(self, str_list: list[str] | None, display_width: int = 80) -> None: + """Display a list of single-line strings as a compact set of columns. + + Override of cmd's columnize() that uses the render_columns() method. + The method correctly handles strings with ANSI style sequences and + full-width characters (like those used in CJK languages). + + :param str_list: list of single-line strings to display + :param display_width: max number of display columns to fit into + """ + columnized_strs = self.render_columns(str_list, display_width) + self.poutput(columnized_strs) @staticmethod def _build_shortcuts_parser() -> Cmd2ArgumentParser: diff --git a/cmd2/string_utils.py b/cmd2/string_utils.py index 663f8633..a77eb5f6 100644 --- a/cmd2/string_utils.py +++ b/cmd2/string_utils.py @@ -1,9 +1,8 @@ """Provides string utility functions. This module offers a collection of string utility functions built on the Rich library. -These utilities are designed to correctly handle strings with complex formatting, such as -ANSI escape codes and full-width characters (like those used in CJK languages), which the -standard Python library's string methods do not properly support. +These utilities are designed to correctly handle strings with ANSI escape codes and +full-width characters (like those used in CJK languages). """ from rich.align import AlignMethod @@ -107,7 +106,7 @@ def strip_style(val: str) -> str: def str_width(val: str) -> int: """Return the display width of a string. - This is intended for single line strings. + This is intended for single-line strings. Replace tabs with spaces before calling this. :param val: the string being measured diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 341f132c..7ecc973a 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1355,22 +1355,35 @@ def test_help_verbose_with_fake_command(capsys) -> None: assert cmds[1] not in out -def test_columnize_empty_list(capsys) -> None: - help_app = HelpApp() +def test_render_columns_no_strs(help_app: HelpApp) -> None: no_strs = [] - help_app.columnize(no_strs) - out, err = capsys.readouterr() - assert "" in out + result = help_app.render_columns(no_strs) + assert result == "" -def test_columnize_too_wide(capsys) -> None: - help_app = HelpApp() +def test_render_columns_one_str(help_app: HelpApp) -> None: + one_str = ["one_string"] + result = help_app.render_columns(one_str) + assert result == "one_string" + + +def test_render_columns_too_wide(help_app: HelpApp) -> None: commands = ["kind_of_long_string", "a_slightly_longer_string"] - help_app.columnize(commands, display_width=10) + result = help_app.render_columns(commands, display_width=10) + + expected = "kind_of_long_string \na_slightly_longer_string" + assert result == expected + + +def test_columnize(capsys: pytest.CaptureFixture[str]) -> None: + help_app = HelpApp() + items = ["one", "two"] + help_app.columnize(items) out, err = capsys.readouterr() - expected = "kind_of_long_string \na_slightly_longer_string\n" - assert expected == out + # poutput() adds a newline at the end. + expected = "one two\n" + assert out == expected class HelpCategoriesApp(cmd2.Cmd): From fd6011afbc0b8c0fd6f714afd79fc2646ff491fb Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 22 Aug 2025 20:16:11 -0400 Subject: [PATCH 39/89] Reformatted cmd2 tables and help output. Added Cmd2Style.TABLE_BORDER style. --- cmd2/argparse_completer.py | 24 ++++++--------- cmd2/cmd2.py | 52 ++++++++++++++++++++------------ cmd2/styles.py | 2 ++ tests/test_argparse_completer.py | 20 ++++++------ tests/test_cmd2.py | 15 +++++---- 5 files changed, 60 insertions(+), 53 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index e859c94a..f23a371f 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -16,18 +16,17 @@ cast, ) -from .constants import ( - INFINITY, -) +from .constants import INFINITY from .rich_utils import Cmd2GeneralConsole if TYPE_CHECKING: # pragma: no cover - from .cmd2 import ( - Cmd, - ) + from .cmd2 import Cmd from rich.box import SIMPLE_HEAD -from rich.table import Column, Table +from rich.table import ( + Column, + Table, +) from .argparse_custom import ( ChoicesCallable, @@ -35,12 +34,8 @@ CompletionItem, generate_range_error, ) -from .command_definition import ( - CommandSet, -) -from .exceptions import ( - CompletionError, -) +from .command_definition import CommandSet +from .exceptions import CompletionError from .styles import Cmd2Style # If no descriptive headers are supplied, then this will be used instead @@ -583,8 +578,7 @@ def _format_completions(self, arg_state: _ArgumentState, completions: list[str] hint_table = Table( *headers, box=SIMPLE_HEAD, - show_edge=False, - border_style=Cmd2Style.RULE_LINE, + border_style=Cmd2Style.TABLE_BORDER, ) for item in completion_items: hint_table.add_row(item, *item.descriptive_data) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index d9161982..b430cc79 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -63,8 +63,9 @@ cast, ) -from rich.box import SIMPLE_HEAD +import rich.box from rich.console import Group +from rich.padding import Padding from rich.rule import Rule from rich.style import Style, StyleType from rich.table import ( @@ -2125,7 +2126,7 @@ def _display_matches_gnu_readline( if self.formatted_completions: if not hint_printed: sys.stdout.write('\n') - sys.stdout.write('\n' + self.formatted_completions + '\n') + sys.stdout.write(self.formatted_completions) # Otherwise use readline's formatter else: @@ -2182,7 +2183,7 @@ def _display_matches_pyreadline(self, matches: list[str]) -> None: # pragma: no if self.formatted_completions: if not hint_printed: sys.stdout.write('\n') - sys.stdout.write('\n' + self.formatted_completions + '\n') + sys.stdout.write(self.formatted_completions) # Redraw the prompt and input lines rl_force_redisplay() @@ -4121,8 +4122,13 @@ def do_help(self, args: argparse.Namespace) -> None: cmds_cats, cmds_doc, cmds_undoc, help_topics = self._build_command_info() if self.doc_leader: + # Indent doc_leader to align with the help tables. self.poutput() - self.poutput(self.doc_leader, style=Cmd2Style.HELP_LEADER, soft_wrap=False) + self.poutput( + Padding.indent(self.doc_leader, 1), + style=Cmd2Style.HELP_LEADER, + soft_wrap=False, + ) self.poutput() if not cmds_cats: @@ -4167,6 +4173,9 @@ def print_topics(self, header: str, cmds: list[str] | None, cmdlen: int, maxcol: Override of cmd's print_topics() to use Rich. + The output for both the header and the commands is indented by one space to align + with the tables printed by the `help -v` command. + :param header: string to print above commands being printed :param cmds: list of topics to print :param cmdlen: unused, even by cmd's version @@ -4177,9 +4186,13 @@ def print_topics(self, header: str, cmds: list[str] | None, cmdlen: int, maxcol: header_grid = Table.grid() header_grid.add_row(header, style=Cmd2Style.HELP_HEADER) - header_grid.add_row(Rule(characters=self.ruler)) - self.poutput(header_grid) - self.columnize(cmds, maxcol - 1) + header_grid.add_row(Rule(characters=self.ruler, style=Cmd2Style.TABLE_BORDER)) + self.poutput(Padding.indent(header_grid, 1)) + + # Subtract 1 from maxcol to account for indentation. + maxcol = min(maxcol, ru.console_width()) - 1 + columnized_cmds = self.render_columns(cmds, maxcol) + self.poutput(Padding.indent(columnized_cmds, 1), soft_wrap=False) self.poutput() def _print_documented_command_topics(self, header: str, cmds: list[str], verbose: bool) -> None: @@ -4193,15 +4206,17 @@ def _print_documented_command_topics(self, header: str, cmds: list[str], verbose self.print_topics(header, cmds, 15, 80) return - category_grid = Table.grid() - category_grid.add_row(header, style=Cmd2Style.HELP_HEADER) - category_grid.add_row(Rule(characters=self.ruler)) + # Indent header to align with the help tables. + self.poutput( + Padding.indent(header, 1), + style=Cmd2Style.HELP_HEADER, + soft_wrap=False, + ) topics_table = Table( Column("Name", no_wrap=True), Column("Description", overflow="fold"), - box=SIMPLE_HEAD, - border_style=Cmd2Style.RULE_LINE, - show_edge=False, + box=rich.box.HORIZONTALS, + border_style=Cmd2Style.TABLE_BORDER, ) # Try to get the documentation string for each command @@ -4240,8 +4255,8 @@ def _print_documented_command_topics(self, header: str, cmds: list[str], verbose # Add this command to the table topics_table.add_row(command, cmd_desc) - category_grid.add_row(topics_table) - self.poutput(category_grid, "") + self.poutput(topics_table) + self.poutput() def render_columns(self, str_list: list[str] | None, display_width: int = 80) -> str: """Render a list of single-line strings as a compact set of columns. @@ -4519,9 +4534,8 @@ def do_set(self, args: argparse.Namespace) -> None: Column("Name", no_wrap=True), Column("Value", overflow="fold"), Column("Description", overflow="fold"), - box=SIMPLE_HEAD, - border_style=Cmd2Style.RULE_LINE, - show_edge=False, + box=rich.box.SIMPLE_HEAD, + border_style=Cmd2Style.TABLE_BORDER, ) # Build the table and populate self.last_result @@ -4532,9 +4546,7 @@ def do_set(self, args: argparse.Namespace) -> None: settable_table.add_row(param, str(settable.get_value()), settable.description) self.last_result[param] = settable.get_value() - self.poutput() self.poutput(settable_table) - self.poutput() @classmethod def _build_shell_parser(cls) -> Cmd2ArgumentParser: diff --git a/cmd2/styles.py b/cmd2/styles.py index 57b78606..37171a8c 100644 --- a/cmd2/styles.py +++ b/cmd2/styles.py @@ -36,6 +36,7 @@ class Cmd2Style(StrEnum): HELP_LEADER = "cmd2.help.leader" # Text right before the help tables are listed RULE_LINE = "rule.line" # Rich style for horizontal rules SUCCESS = "cmd2.success" # Success text (used by psuccess()) + TABLE_BORDER = "cmd2.table_border" # Applied to cmd2's table borders WARNING = "cmd2.warning" # Warning text (used by pwarning()) @@ -47,5 +48,6 @@ class Cmd2Style(StrEnum): Cmd2Style.HELP_LEADER: Style(color=Color.CYAN, bold=True), Cmd2Style.RULE_LINE: Style(color=Color.BRIGHT_GREEN), Cmd2Style.SUCCESS: Style(color=Color.GREEN), + Cmd2Style.TABLE_BORDER: Style(color=Color.BRIGHT_GREEN), Cmd2Style.WARNING: Style(color=Color.BRIGHT_YELLOW), } diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 27c96598..7d7d735d 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -720,8 +720,8 @@ def test_completion_items(ac_app) -> None: line_found = False for line in ac_app.formatted_completions.splitlines(): # Since the CompletionItems were created from strings, the left-most column is left-aligned. - # Therefore choice_1 will begin the line (with 1 space for padding). - if line.startswith(' choice_1') and 'A description' in line: + # Therefore choice_1 will begin the line (with 2 spaces for padding). + if line.startswith(' choice_1') and 'A description' in line: line_found = True break @@ -743,7 +743,7 @@ def test_completion_items(ac_app) -> None: for line in ac_app.formatted_completions.splitlines(): # Since the CompletionItems were created from numbers, the left-most column is right-aligned. # Therefore 1.5 will be right-aligned. - if line.startswith(" 1.5") and "One.Five" in line: + if line.startswith(" 1.5") and "One.Five" in line: line_found = True break @@ -908,7 +908,7 @@ def test_completion_items_arg_header(ac_app) -> None: begidx = endidx - len(text) complete_tester(text, line, begidx, endidx, ac_app) - assert "DESC_HEADER" in normalize(ac_app.formatted_completions)[0] + assert "DESC_HEADER" in normalize(ac_app.formatted_completions)[1] # Test when metavar is a string text = '' @@ -917,7 +917,7 @@ def test_completion_items_arg_header(ac_app) -> None: begidx = endidx - len(text) complete_tester(text, line, begidx, endidx, ac_app) - assert ac_app.STR_METAVAR in normalize(ac_app.formatted_completions)[0] + assert ac_app.STR_METAVAR in normalize(ac_app.formatted_completions)[1] # Test when metavar is a tuple text = '' @@ -927,7 +927,7 @@ def test_completion_items_arg_header(ac_app) -> None: # We are completing the first argument of this flag. The first element in the tuple should be the column header. complete_tester(text, line, begidx, endidx, ac_app) - assert ac_app.TUPLE_METAVAR[0].upper() in normalize(ac_app.formatted_completions)[0] + assert ac_app.TUPLE_METAVAR[0].upper() in normalize(ac_app.formatted_completions)[1] text = '' line = f'choices --tuple_metavar token_1 {text}' @@ -936,7 +936,7 @@ def test_completion_items_arg_header(ac_app) -> None: # We are completing the second argument of this flag. The second element in the tuple should be the column header. complete_tester(text, line, begidx, endidx, ac_app) - assert ac_app.TUPLE_METAVAR[1].upper() in normalize(ac_app.formatted_completions)[0] + assert ac_app.TUPLE_METAVAR[1].upper() in normalize(ac_app.formatted_completions)[1] text = '' line = f'choices --tuple_metavar token_1 token_2 {text}' @@ -946,7 +946,7 @@ def test_completion_items_arg_header(ac_app) -> None: # We are completing the third argument of this flag. It should still be the second tuple element # in the column header since the tuple only has two strings in it. complete_tester(text, line, begidx, endidx, ac_app) - assert ac_app.TUPLE_METAVAR[1].upper() in normalize(ac_app.formatted_completions)[0] + assert ac_app.TUPLE_METAVAR[1].upper() in normalize(ac_app.formatted_completions)[1] def test_completion_items_descriptive_headers(ac_app) -> None: @@ -961,7 +961,7 @@ def test_completion_items_descriptive_headers(ac_app) -> None: begidx = endidx - len(text) complete_tester(text, line, begidx, endidx, ac_app) - assert ac_app.CUSTOM_DESC_HEADERS[0] in normalize(ac_app.formatted_completions)[0] + assert ac_app.CUSTOM_DESC_HEADERS[0] in normalize(ac_app.formatted_completions)[1] # This argument did not provide a descriptive header, so it should be DEFAULT_DESCRIPTIVE_HEADERS text = '' @@ -970,7 +970,7 @@ def test_completion_items_descriptive_headers(ac_app) -> None: begidx = endidx - len(text) complete_tester(text, line, begidx, endidx, ac_app) - assert DEFAULT_DESCRIPTIVE_HEADERS[0] in normalize(ac_app.formatted_completions)[0] + assert DEFAULT_DESCRIPTIVE_HEADERS[0] in normalize(ac_app.formatted_completions)[1] @pytest.mark.parametrize( diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 7ecc973a..395fa5c9 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -184,15 +184,14 @@ def test_set(base_app) -> None: assert out == expected assert base_app.last_result is True + line_found = False out, err = run_cmd(base_app, 'set quiet') - expected = normalize( - """ - Name Value Description -─────────────────────────────────────────────────── - quiet True Don't print nonessential feedback -""" - ) - assert out == expected + for line in out: + if "quiet" in line and "True" in line and "False" not in line: + line_found = True + break + + assert line_found assert len(base_app.last_result) == 1 assert base_app.last_result['quiet'] is True From b8a0edb0058a22bf30863c88fc8ac7d352e02d4a Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 23 Aug 2025 02:23:03 -0400 Subject: [PATCH 40/89] Simplified docstrings for the print functions which call print_to(). --- cmd2/cmd2.py | 108 +++++++++++-------------------------------------- cmd2/styles.py | 2 - 2 files changed, 24 insertions(+), 86 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index b430cc79..5c2f67ea 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1260,19 +1260,7 @@ def poutput( ) -> None: """Print objects to self.stdout. - :param objects: objects to print - :param sep: string to write between print data. Defaults to " ". - :param end: string to write at end of print data. Defaults to a newline. - :param style: optional style to apply to output - :param soft_wrap: Enable soft wrap mode. If True, text lines will not be automatically word-wrapped to fit the - terminal width; instead, any text that doesn't fit will run onto the following line(s), - similar to the built-in print() function. Set to False to enable automatic word-wrapping. - If None (the default for this parameter), the output will default to no word-wrapping, as - configured by the Cmd2GeneralConsole. - :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). - :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this - method and still call `super()` without encountering unexpected keyword argument errors. - These arguments are not passed to Rich's Console.print(). + For details on the parameters, refer to the `print_to` method documentation. """ self.print_to( self.stdout, @@ -1296,19 +1284,9 @@ def perror( ) -> None: """Print objects to sys.stderr. - :param objects: objects to print - :param sep: string to write between print data. Defaults to " ". - :param end: string to write at end of print data. Defaults to a newline. :param style: optional style to apply to output. Defaults to Cmd2Style.ERROR. - :param soft_wrap: Enable soft wrap mode. If True, text lines will not be automatically word-wrapped to fit the - terminal width; instead, any text that doesn't fit will run onto the following line(s), - similar to the built-in print() function. Set to False to enable automatic word-wrapping. - If None (the default for this parameter), the output will default to no word-wrapping, as - configured by the Cmd2GeneralConsole. - :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). - :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this - method and still call `super()` without encountering unexpected keyword argument errors. - These arguments are not passed to Rich's Console.print(). + + For details on the other parameters, refer to the `print_to` method documentation. """ self.print_to( sys.stderr, @@ -1331,18 +1309,7 @@ def psuccess( ) -> None: """Wrap poutput, but apply Cmd2Style.SUCCESS. - :param objects: objects to print - :param sep: string to write between print data. Defaults to " ". - :param end: string to write at end of print data. Defaults to a newline. - :param soft_wrap: Enable soft wrap mode. If True, text lines will not be automatically word-wrapped to fit the - terminal width; instead, any text that doesn't fit will run onto the following line(s), - similar to the built-in print() function. Set to False to enable automatic word-wrapping. - If None (the default for this parameter), the output will default to no word-wrapping, as - configured by the Cmd2GeneralConsole. - :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). - :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this - method and still call `super()` without encountering unexpected keyword argument errors. - These arguments are not passed to Rich's Console.print(). + For details on the parameters, refer to the `print_to` method documentation. """ self.poutput( *objects, @@ -1364,18 +1331,7 @@ def pwarning( ) -> None: """Wrap perror, but apply Cmd2Style.WARNING. - :param objects: objects to print - :param sep: string to write between print data. Defaults to " ". - :param end: string to write at end of print data. Defaults to a newline. - :param soft_wrap: Enable soft wrap mode. If True, text lines will not be automatically word-wrapped to fit the - terminal width; instead, any text that doesn't fit will run onto the following line(s), - similar to the built-in print() function. Set to False to enable automatic word-wrapping. - If None (the default for this parameter), the output will default to no word-wrapping, as - configured by the Cmd2GeneralConsole. - :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). - :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this - method and still call `super()` without encountering unexpected keyword argument errors. - These arguments are not passed to Rich's Console.print(). + For details on the parameters, refer to the `print_to` method documentation. """ self.perror( *objects, @@ -1393,20 +1349,19 @@ def pexcept( rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: - """Print exception to sys.stderr. If debug is true, print exception traceback if one exists. + """Print an exception to sys.stderr. - :param exception: the exception to print. - :param end: string to write at end of print data. Defaults to a newline. - :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). - :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this - method and still call `super()` without encountering unexpected keyword argument errors. - These arguments are not passed to Rich's Console.print(). + If `debug` is true, a full exception traceback is also printed, if one exists. + + :param exception: the exception to be printed. + + For details on the other parameters, refer to the `print_to` method documentation. """ final_msg = Text() if self.debug and sys.exc_info() != (None, None, None): console = Cmd2GeneralConsole(sys.stderr) - console.print_exception(word_wrap=True) + console.print_exception(word_wrap=True, max_frames=0) else: final_msg += f"EXCEPTION of type '{type(exception).__name__}' occurred with message: {exception}" @@ -1431,23 +1386,12 @@ def pfeedback( rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: - """For printing nonessential feedback. Can be silenced with `quiet`. + """Print nonessential feedback. - Inclusion in redirected output is controlled by `feedback_to_output`. + The output can be silenced with the `quiet` setting and its inclusion in redirected output + is controlled by the `feedback_to_output` setting. - :param objects: objects to print - :param sep: string to write between print data. Defaults to " ". - :param end: string to write at end of print data. Defaults to a newline. - :param style: optional style to apply to output - :param soft_wrap: Enable soft wrap mode. If True, text lines will not be automatically word-wrapped to fit the - terminal width; instead, any text that doesn't fit will run onto the following line(s), - similar to the built-in print() function. Set to False to enable automatic word-wrapping. - If None (the default for this parameter), the output will default to no word-wrapping, as - configured by the Cmd2GeneralConsole. - :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). - :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this - method and still call `super()` without encountering unexpected keyword argument errors. - These arguments are not passed to Rich's Console.print(). + For details on the parameters, refer to the `print_to` method documentation. """ if not self.quiet: if self.feedback_to_output: @@ -1480,15 +1424,12 @@ def ppaged( rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: - """Print output using a pager if it would go off screen and stdout isn't currently being redirected. + """Print output using a pager. - Never uses a pager inside a script (Python or text) or when output is being redirected or piped or when - stdout or stdin are not a fully functional terminal. + A pager is used when the terminal is interactive and may exit immediately if the output + fits on the screen. A pager is not used inside a script (Python or text) or when output is + redirected or piped, and in these cases, output is sent to `poutput`. - :param objects: objects to print - :param sep: string to write between print data. Defaults to " ". - :param end: string to write at end of print data. Defaults to a newline. - :param style: optional style to apply to output :param chop: True -> causes lines longer than the screen width to be chopped (truncated) rather than wrapped - truncated text is still accessible by scrolling with the right & left arrow keys - chopping is ideal for displaying wide tabular data as is done in utilities like pgcli @@ -1500,13 +1441,12 @@ def ppaged( similar to the built-in print() function. Set to False to enable automatic word-wrapping. If None (the default for this parameter), the output will default to no word-wrapping, as configured by the Cmd2GeneralConsole. + Note: If chop is True and a pager is used, soft_wrap is automatically set to True. - :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). - :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this - method and still call `super()` without encountering unexpected keyword argument errors. - These arguments are not passed to Rich's Console.print(). + + For details on the other parameters, refer to the `print_to` method documentation. """ - # Detect if we are running within a fully functional terminal. + # Detect if we are running within an interactive terminal. # Don't try to use the pager when being run by a continuous integration system like Jenkins + pexpect. functional_terminal = ( self.stdin.isatty() diff --git a/cmd2/styles.py b/cmd2/styles.py index 37171a8c..b2d8f14e 100644 --- a/cmd2/styles.py +++ b/cmd2/styles.py @@ -34,7 +34,6 @@ class Cmd2Style(StrEnum): EXAMPLE = "cmd2.example" # Command line examples in help text HELP_HEADER = "cmd2.help.header" # Help table header text HELP_LEADER = "cmd2.help.leader" # Text right before the help tables are listed - RULE_LINE = "rule.line" # Rich style for horizontal rules SUCCESS = "cmd2.success" # Success text (used by psuccess()) TABLE_BORDER = "cmd2.table_border" # Applied to cmd2's table borders WARNING = "cmd2.warning" # Warning text (used by pwarning()) @@ -46,7 +45,6 @@ class Cmd2Style(StrEnum): Cmd2Style.EXAMPLE: Style(color=Color.CYAN, bold=True), Cmd2Style.HELP_HEADER: Style(color=Color.BRIGHT_GREEN, bold=True), Cmd2Style.HELP_LEADER: Style(color=Color.CYAN, bold=True), - Cmd2Style.RULE_LINE: Style(color=Color.BRIGHT_GREEN), Cmd2Style.SUCCESS: Style(color=Color.GREEN), Cmd2Style.TABLE_BORDER: Style(color=Color.BRIGHT_GREEN), Cmd2Style.WARNING: Style(color=Color.BRIGHT_YELLOW), From 891c570dd4bce1e43b45d41b9d1424a64594c2fa Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 23 Aug 2025 02:41:09 -0400 Subject: [PATCH 41/89] Removed regex_set.txt test which was difficult to maintain and tested features which are already covered in other tests. --- tests/test_transcript.py | 1 - tests/transcripts/regex_set.txt | 31 ------------------------------- 2 files changed, 32 deletions(-) delete mode 100644 tests/transcripts/regex_set.txt diff --git a/tests/test_transcript.py b/tests/test_transcript.py index 0739c0c7..8a654ecd 100644 --- a/tests/test_transcript.py +++ b/tests/test_transcript.py @@ -115,7 +115,6 @@ def test_commands_at_invocation() -> None: ('multiline_regex.txt', False), ('no_output.txt', False), ('no_output_last.txt', False), - ('regex_set.txt', False), ('singleslash.txt', False), ('slashes_escaped.txt', False), ('slashslash.txt', False), diff --git a/tests/transcripts/regex_set.txt b/tests/transcripts/regex_set.txt deleted file mode 100644 index adaa68e2..00000000 --- a/tests/transcripts/regex_set.txt +++ /dev/null @@ -1,31 +0,0 @@ -# Run this transcript with "python example.py -t transcript_regex.txt" -# The regex for allow_style will match any setting for the previous value. -# The regex for editor will match whatever program you use. -# Regexes on prompts just make the trailing space obvious - -(Cmd) set allow_style Terminal -allow_style - was: '/.*/' -now: 'Terminal' -(Cmd) set editor vim -editor - was: '/.*/' -now: 'vim' -(Cmd) set - - Name Value Description/ */ -───────────────────────────────────────────────────────────────────────────────/─*/ - allow_style Terminal Allow ANSI text style sequences in output/ */ - (valid values: Always, Never, Terminal)/ */ - always_show_hint False Display tab completion hint even when/ */ - completion suggestions print/ */ - debug False Show full traceback on exception/ */ - echo False Echo command issued into output/ */ - editor vim Program used by 'edit'/ */ - feedback_to_output False Include nonessentials in '|' and '>'/ */ - results/ */ - max_completion_items 50 Maximum number of CompletionItems to/ */ - display during tab completion/ */ - maxrepeats 3 Max number of `--repeat`s allowed/ */ - quiet False Don't print nonessential feedback/ */ - scripts_add_to_history True Scripts and pyscripts add commands to/ */ - history/ */ - timing False Report execution times/ */ From 71751612d09df5156447aa2244bf12f6eae322a1 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 23 Aug 2025 18:23:04 -0400 Subject: [PATCH 42/89] Word wrapping long exceptions in pexcept(). --- cmd2/cmd2.py | 99 +++++++++++++++++++++++++--------------------- cmd2/rich_utils.py | 35 +++++++++++----- cmd2/styles.py | 6 ++- tests/test_cmd2.py | 29 ++++---------- 4 files changed, 93 insertions(+), 76 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 5c2f67ea..529b1352 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -131,6 +131,7 @@ shlex_split, ) from .rich_utils import ( + Cmd2ExceptionConsole, Cmd2GeneralConsole, RichPrintKwargs, ) @@ -1207,7 +1208,7 @@ def print_to( sep: str = " ", end: str = "\n", style: StyleType | None = None, - soft_wrap: bool | None = None, + soft_wrap: bool = True, rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: @@ -1218,11 +1219,8 @@ def print_to( :param sep: string to write between print data. Defaults to " ". :param end: string to write at end of print data. Defaults to a newline. :param style: optional style to apply to output - :param soft_wrap: Enable soft wrap mode. If True, text lines will not be automatically word-wrapped to fit the - terminal width; instead, any text that doesn't fit will run onto the following line(s), - similar to the built-in print() function. Set to False to enable automatic word-wrapping. - If None (the default for this parameter), the output will default to no word-wrapping, as - configured by the Cmd2GeneralConsole. + :param soft_wrap: Enable soft wrap mode. If True, lines of text will not be word-wrapped or cropped to + fit the terminal width. Defaults to True. :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this method and still call `super()` without encountering unexpected keyword argument errors. @@ -1254,7 +1252,7 @@ def poutput( sep: str = " ", end: str = "\n", style: StyleType | None = None, - soft_wrap: bool | None = None, + soft_wrap: bool = True, rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: @@ -1278,7 +1276,7 @@ def perror( sep: str = " ", end: str = "\n", style: StyleType | None = Cmd2Style.ERROR, - soft_wrap: bool | None = None, + soft_wrap: bool = True, rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: @@ -1303,7 +1301,7 @@ def psuccess( *objects: Any, sep: str = " ", end: str = "\n", - soft_wrap: bool | None = None, + soft_wrap: bool = True, rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: @@ -1325,7 +1323,7 @@ def pwarning( *objects: Any, sep: str = " ", end: str = "\n", - soft_wrap: bool | None = None, + soft_wrap: bool = True, rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: @@ -1345,36 +1343,51 @@ def pwarning( def pexcept( self, exception: BaseException, - end: str = "\n", - rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: """Print an exception to sys.stderr. - If `debug` is true, a full exception traceback is also printed, if one exists. + If `debug` is true, a full traceback is also printed, if one exists. :param exception: the exception to be printed. - - For details on the other parameters, refer to the `print_to` method documentation. + :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this + method and still call `super()` without encountering unexpected keyword argument errors. """ - final_msg = Text() + console = Cmd2ExceptionConsole(sys.stderr) + # Only print a traceback if we're in debug mode and one exists. if self.debug and sys.exc_info() != (None, None, None): - console = Cmd2GeneralConsole(sys.stderr) - console.print_exception(word_wrap=True, max_frames=0) - else: - final_msg += f"EXCEPTION of type '{type(exception).__name__}' occurred with message: {exception}" + console.print_exception( + width=console.width, + show_locals=True, + max_frames=0, # 0 means full traceback. + word_wrap=True, # Wrap long lines of code instead of truncate + ) + console.print() + return - if not self.debug and 'debug' in self.settables: - warning = "\nTo enable full traceback, run the following command: 'set debug true'" - final_msg.append(warning, style=Cmd2Style.WARNING) + # Otherwise highlight and print the exception. + from rich.highlighter import ReprHighlighter - if final_msg: - self.perror( - final_msg, - end=end, - rich_print_kwargs=rich_print_kwargs, + highlighter = ReprHighlighter() + + final_msg = Text.assemble( + ("EXCEPTION of type ", Cmd2Style.ERROR), + (f"{type(exception).__name__}", Cmd2Style.EXCEPTION_TYPE), + (" occurred with message: ", Cmd2Style.ERROR), + highlighter(str(exception)), + ) + + if not self.debug and 'debug' in self.settables: + help_msg = Text.assemble( + "\n\n", + ("To enable full traceback, run the following command: ", Cmd2Style.WARNING), + ("set debug true", Cmd2Style.COMMAND_LINE), ) + final_msg.append(help_msg) + + console.print(final_msg) + console.print() def pfeedback( self, @@ -1382,7 +1395,7 @@ def pfeedback( sep: str = " ", end: str = "\n", style: StyleType | None = None, - soft_wrap: bool | None = None, + soft_wrap: bool = True, rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: @@ -1420,7 +1433,7 @@ def ppaged( end: str = "\n", style: StyleType | None = None, chop: bool = False, - soft_wrap: bool | None = None, + soft_wrap: bool = True, rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: @@ -1436,13 +1449,11 @@ def ppaged( False -> causes lines longer than the screen width to wrap to the next line - wrapping is ideal when you want to keep users from having to use horizontal scrolling WARNING: On Windows, the text always wraps regardless of what the chop argument is set to - :param soft_wrap: Enable soft wrap mode. If True, text lines will not be automatically word-wrapped to fit the - terminal width; instead, any text that doesn't fit will run onto the following line(s), - similar to the built-in print() function. Set to False to enable automatic word-wrapping. - If None (the default for this parameter), the output will default to no word-wrapping, as - configured by the Cmd2GeneralConsole. + :param soft_wrap: Enable soft wrap mode. If True, lines of text will not be word-wrapped or cropped to + fit the terminal width. Defaults to True. - Note: If chop is True and a pager is used, soft_wrap is automatically set to True. + Note: If chop is True and a pager is used, soft_wrap is automatically set to True to + prevent wrapping and allow for horizontal scrolling. For details on the other parameters, refer to the `print_to` method documentation. """ @@ -3527,7 +3538,7 @@ def _build_alias_create_parser(cls) -> Cmd2ArgumentParser: alias_create_notes = Group( "If you want to use redirection, pipes, or terminators in the value of the alias, then quote them.", "\n", - Text(" alias create save_results print_results \">\" out.txt\n", style=Cmd2Style.EXAMPLE), + Text(" alias create save_results print_results \">\" out.txt\n", style=Cmd2Style.COMMAND_LINE), ( "Since aliases are resolved during parsing, tab completion will function as it would " "for the actual command the alias resolves to." @@ -3740,14 +3751,14 @@ def _build_macro_create_parser(cls) -> Cmd2ArgumentParser: "\n", "The following creates a macro called my_macro that expects two arguments:", "\n", - Text(" macro create my_macro make_dinner --meat {1} --veggie {2}", style=Cmd2Style.EXAMPLE), + Text(" macro create my_macro make_dinner --meat {1} --veggie {2}", style=Cmd2Style.COMMAND_LINE), "\n", "When the macro is called, the provided arguments are resolved and the assembled command is run. For example:", "\n", Text.assemble( - (" my_macro beef broccoli", Cmd2Style.EXAMPLE), + (" my_macro beef broccoli", Cmd2Style.COMMAND_LINE), (" ───> ", Style(bold=True)), - ("make_dinner --meat beef --veggie broccoli", Cmd2Style.EXAMPLE), + ("make_dinner --meat beef --veggie broccoli", Cmd2Style.COMMAND_LINE), ), ) macro_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_create_description) @@ -3763,15 +3774,15 @@ def _build_macro_create_parser(cls) -> Cmd2ArgumentParser: "first argument will populate both {1} instances." ), "\n", - Text(" macro create ft file_taxes -p {1} -q {2} -r {1}", style=Cmd2Style.EXAMPLE), + Text(" macro create ft file_taxes -p {1} -q {2} -r {1}", style=Cmd2Style.COMMAND_LINE), "\n", "To quote an argument in the resolved command, quote it during creation.", "\n", - Text(" macro create backup !cp \"{1}\" \"{1}.orig\"", style=Cmd2Style.EXAMPLE), + Text(" macro create backup !cp \"{1}\" \"{1}.orig\"", style=Cmd2Style.COMMAND_LINE), "\n", "If you want to use redirection, pipes, or terminators in the value of the macro, then quote them.", "\n", - Text(" macro create show_results print_results -type {1} \"|\" less", style=Cmd2Style.EXAMPLE), + Text(" macro create show_results print_results -type {1} \"|\" less", style=Cmd2Style.COMMAND_LINE), "\n", ( "Since macros don't resolve until after you press Enter, their arguments tab complete as paths. " @@ -5316,7 +5327,7 @@ def _build_edit_parser(cls) -> Cmd2ArgumentParser: "Note", Text.assemble( "To set a new editor, run: ", - ("set editor ", Cmd2Style.EXAMPLE), + ("set editor ", Cmd2Style.COMMAND_LINE), ), ) diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 07fcef8a..a47ed8dd 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -45,7 +45,7 @@ def __repr__(self) -> str: def _create_default_theme() -> Theme: - """Create a default theme for cmd2-based applications. + """Create a default theme for the application. This theme combines the default styles from cmd2, rich-argparse, and Rich. """ @@ -79,8 +79,7 @@ def set_theme(styles: Mapping[str, StyleType] | None = None) -> None: RichHelpFormatter.styles[name] = APP_THEME.styles[name] -# The main theme for cmd2-based applications. -# You can change it with set_theme(). +# The application-wide theme. You can change it with set_theme(). APP_THEME = _create_default_theme() @@ -107,12 +106,22 @@ class RichPrintKwargs(TypedDict, total=False): class Cmd2BaseConsole(Console): - """A base class for Rich consoles in cmd2-based applications.""" + """Base class for all cmd2 Rich consoles. - def __init__(self, file: IO[str] | None = None, **kwargs: Any) -> None: + This class handles the core logic for managing Rich behavior based on + cmd2's global settings, such as `ALLOW_STYLE` and `APP_THEME`. + """ + + def __init__( + self, + file: IO[str] | None = None, + **kwargs: Any, + ) -> None: """Cmd2BaseConsole initializer. - :param file: optional file object where the console should write to. Defaults to sys.stdout. + :param file: optional file object where the console should write to. + Defaults to sys.stdout. + :param kwargs: keyword arguments passed to the parent Console class. """ # Don't allow force_terminal or force_interactive to be passed in, as their # behavior is controlled by the ALLOW_STYLE setting. @@ -160,12 +169,13 @@ def on_broken_pipe(self) -> None: class Cmd2GeneralConsole(Cmd2BaseConsole): - """Rich console for general-purpose printing in cmd2-based applications.""" + """Rich console for general-purpose printing.""" def __init__(self, file: IO[str] | None = None) -> None: """Cmd2GeneralConsole initializer. - :param file: optional file object where the console should write to. Defaults to sys.stdout. + :param file: optional file object where the console should write to. + Defaults to sys.stdout. """ # This console is configured for general-purpose printing. It enables soft wrap # and disables Rich's automatic processing for markup, emoji, and highlighting. @@ -180,13 +190,20 @@ def __init__(self, file: IO[str] | None = None) -> None: class Cmd2RichArgparseConsole(Cmd2BaseConsole): - """Rich console for rich-argparse output in cmd2-based applications. + """Rich console for rich-argparse output. This class ensures long lines in help text are not truncated by avoiding soft_wrap, which conflicts with rich-argparse's explicit no_wrap and overflow settings. """ +class Cmd2ExceptionConsole(Cmd2BaseConsole): + """Rich console for printing exceptions. + + Ensures that long exception messages word wrap for readability by keeping soft_wrap disabled. + """ + + def console_width() -> int: """Return the width of the console.""" return Cmd2BaseConsole().width diff --git a/cmd2/styles.py b/cmd2/styles.py index b2d8f14e..99cabc2c 100644 --- a/cmd2/styles.py +++ b/cmd2/styles.py @@ -30,8 +30,9 @@ class Cmd2Style(StrEnum): added here must have a corresponding style definition there. """ + COMMAND_LINE = "cmd2.example" # Command line examples in help text ERROR = "cmd2.error" # Error text (used by perror()) - EXAMPLE = "cmd2.example" # Command line examples in help text + EXCEPTION_TYPE = "cmd2.exception.type" # Used by pexcept to mark an exception type HELP_HEADER = "cmd2.help.header" # Help table header text HELP_LEADER = "cmd2.help.leader" # Text right before the help tables are listed SUCCESS = "cmd2.success" # Success text (used by psuccess()) @@ -41,8 +42,9 @@ class Cmd2Style(StrEnum): # Default styles used by cmd2. Tightly coupled with the Cmd2Style enum. DEFAULT_CMD2_STYLES: dict[str, StyleType] = { + Cmd2Style.COMMAND_LINE: Style(color=Color.CYAN, bold=True), Cmd2Style.ERROR: Style(color=Color.BRIGHT_RED), - Cmd2Style.EXAMPLE: Style(color=Color.CYAN, bold=True), + Cmd2Style.EXCEPTION_TYPE: Style(color=Color.DARK_ORANGE, bold=True), Cmd2Style.HELP_HEADER: Style(color=Color.BRIGHT_GREEN, bold=True), Cmd2Style.HELP_LEADER: Style(color=Color.CYAN, bold=True), Cmd2Style.SUCCESS: Style(color=Color.GREEN), diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 395fa5c9..f03d5224 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -906,29 +906,14 @@ def test_base_timing(base_app) -> None: assert err[0].startswith('Elapsed: 0:00:00.0') -def _expected_no_editor_error(): - expected_exception = 'OSError' - # If PyPy, expect a different exception than with Python 3 - if hasattr(sys, "pypy_translation_info"): - expected_exception = 'EnvironmentError' - - return normalize( - f""" -EXCEPTION of type '{expected_exception}' occurred with message: Please use 'set editor' to specify your text editing program of choice. -To enable full traceback, run the following command: 'set debug true' -""" - ) - - def test_base_debug(base_app) -> None: # Purposely set the editor to None base_app.editor = None # Make sure we get an exception, but cmd2 handles it out, err = run_cmd(base_app, 'edit') - - expected = _expected_no_editor_error() - assert err == expected + assert "EXCEPTION of type" in err[0] + assert "Please use 'set editor'" in err[0] # Set debug true out, err = run_cmd(base_app, 'set debug True') @@ -2589,7 +2574,9 @@ def test_pexcept_style(base_app, capsys) -> None: base_app.pexcept(msg) out, err = capsys.readouterr() - assert err.startswith("\x1b[91mEXCEPTION of type 'Exception' occurred with message: testing") + expected = su.stylize("EXCEPTION of type ", style=Cmd2Style.ERROR) + expected += su.stylize("Exception", style=Cmd2Style.EXCEPTION_TYPE) + assert err.startswith(expected) @with_ansi_style(ru.AllowStyle.NEVER) @@ -2598,17 +2585,17 @@ def test_pexcept_no_style(base_app, capsys) -> None: base_app.pexcept(msg) out, err = capsys.readouterr() - assert err.startswith("EXCEPTION of type 'Exception' occurred with message: testing...") + assert err.startswith("EXCEPTION of type Exception occurred with message: testing...") -@with_ansi_style(ru.AllowStyle.ALWAYS) +@with_ansi_style(ru.AllowStyle.NEVER) def test_pexcept_not_exception(base_app, capsys) -> None: # Pass in a msg that is not an Exception object msg = False base_app.pexcept(msg) out, err = capsys.readouterr() - assert err.startswith("\x1b[91mEXCEPTION of type 'bool' occurred with message: False") + assert err.startswith("EXCEPTION of type bool occurred with message: False") @pytest.mark.parametrize('chop', [True, False]) From 5d49d55a8eb1e376f2640d758a4eb442c273ec85 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 23 Aug 2025 18:43:45 -0400 Subject: [PATCH 43/89] Changed mypy ignore to work on both Windows and Linux. --- cmd2/rl_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd2/rl_utils.py b/cmd2/rl_utils.py index 29d8b4e5..c7f37a0d 100644 --- a/cmd2/rl_utils.py +++ b/cmd2/rl_utils.py @@ -279,7 +279,7 @@ def rl_in_search_mode() -> bool: # pragma: no cover readline_state = ctypes.c_int.in_dll(readline_lib, "rl_readline_state").value return bool(in_search_mode & readline_state) if rl_type == RlType.PYREADLINE: - from pyreadline3.modes.emacs import ( # type: ignore[import-not-found] + from pyreadline3.modes.emacs import ( # type: ignore[import] EmacsMode, ) @@ -287,7 +287,7 @@ def rl_in_search_mode() -> bool: # pragma: no cover if not isinstance(readline.rl.mode, EmacsMode): return False - # While in search mode, the current keyevent function is set one of the following. + # While in search mode, the current keyevent function is set to one of the following. search_funcs = ( readline.rl.mode._process_incremental_search_keyevent, readline.rl.mode._process_non_incremental_search_keyevent, From c43d9875b0211bd03551bfca6856f015cba98835 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sat, 23 Aug 2025 19:06:09 -0400 Subject: [PATCH 44/89] Update examples (#1485) * Updated color.py and argparse_completion.py examples for cmd2 3.0. * Added a table to a CompletionItem description in argparse_completion.py example. * Updated documentation for Cmd2Style. * Updated initialization.py and python_scripting.py examples for cmd2 3.0. Deleted basic.py and pirate.py examples. * Fix typo and slightly re-order a couple things in top-level README * Fix initialization.py example so it uses a valid startup script and delete redundant alias_startup.py example * Edit initialization.md to auto-load code from the initialization.py example instead of manually copying it by hand * Changed the example title to show examples/initialization.py to make it clearer * Merge redundant examples The following 4 examples all demonstrate various aspects of using `argparse` for command argument processing and have been merged into a single comprehensive example called `argparse_example.py`: - arg_decorators.py - decorator_example.py - arg_print.py - subcommands.py `first_app.py` and `initialization.py` had a lot of overlap in demonstrating basic features of cmd2. I combined them into a single `getting_started.py` example * Create a new command_sets.py example command_sets.py merges three previous examples: - modular_commands_basic.py - modular_commands_dynamic.py - modular_subcommands.py * Added type hints to argparse_example.py and fixed other ruff issues * Fixed comments in examples/command_sets.py * Fixed strings and types in getting_started.py --------- Co-authored-by: Kevin Van Brunt --- .github/CODEOWNERS | 7 +- .github/CONTRIBUTING.md | 4 +- README.md | 18 +- cmd2/cmd2.py | 2 +- cmd2/transcript.py | 2 +- .../{first_app.md => getting_started.md} | 18 +- docs/examples/index.md | 2 +- docs/features/argument_processing.md | 13 +- docs/features/generating_output.md | 17 +- docs/features/initialization.md | 80 +----- docs/features/os.md | 18 +- docs/features/prompt.md | 12 +- docs/features/startup_commands.md | 6 +- docs/features/transcripts.md | 4 +- docs/migrating/next_steps.md | 2 +- docs/overview/index.md | 4 +- examples/README.md | 47 +--- examples/alias_startup.py | 27 -- examples/arg_decorators.py | 60 ----- examples/arg_print.py | 67 ----- examples/argparse_completion.py | 28 ++- examples/argparse_example.py | 233 ++++++++++++++++++ examples/basic.py | 51 ---- examples/cmd_as_argument.py | 2 +- examples/color.py | 26 +- examples/command_sets.py | 162 ++++++++++++ examples/decorator_example.py | 113 --------- examples/first_app.py | 58 ----- .../{initialization.py => getting_started.py} | 59 +++-- ...r_commands_main.py => modular_commands.py} | 8 +- examples/modular_commands_basic.py | 35 --- examples/modular_commands_dynamic.py | 88 ------- examples/modular_subcommands.py | 116 --------- examples/pirate.py | 100 -------- examples/python_scripting.py | 14 +- examples/subcommands.py | 116 --------- .../{example.py => transcript_example.py} | 6 +- examples/transcripts/exampleSession.txt | 2 +- examples/transcripts/transcript_regex.txt | 2 +- mkdocs.yml | 2 +- tests/test_completion.py | 102 +++++++- 41 files changed, 672 insertions(+), 1061 deletions(-) rename docs/examples/{first_app.md => getting_started.md} (97%) delete mode 100755 examples/alias_startup.py delete mode 100755 examples/arg_decorators.py delete mode 100755 examples/arg_print.py create mode 100755 examples/argparse_example.py delete mode 100755 examples/basic.py create mode 100755 examples/command_sets.py delete mode 100755 examples/decorator_example.py delete mode 100755 examples/first_app.py rename examples/{initialization.py => getting_started.py} (52%) rename examples/{modular_commands_main.py => modular_commands.py} (89%) delete mode 100755 examples/modular_commands_basic.py delete mode 100755 examples/modular_commands_dynamic.py delete mode 100755 examples/modular_subcommands.py delete mode 100755 examples/pirate.py delete mode 100755 examples/subcommands.py rename examples/{example.py => transcript_example.py} (91%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4f1fd9c5..3e963a9f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -27,10 +27,10 @@ # cmd2 code cmd2/__init__.py @kmvanbrunt @tleonhardt -cmd2/ansi.py @kmvanbrunt @tleonhardt cmd2/argparse_*.py @kmvanbrunt @anselor cmd2/clipboard.py @tleonhardt cmd2/cmd2.py @tleonhardt @kmvanbrunt +cmd2/colors.py @tleonhardt @kmvanbrunt cmd2/command_definition.py @anselor cmd2/constants.py @tleonhardt @kmvanbrunt cmd2/decorators.py @kmvanbrunt @anselor @@ -39,8 +39,11 @@ cmd2/history.py @tleonhardt cmd2/parsing.py @kmvanbrunt cmd2/plugin.py @anselor cmd2/py_bridge.py @kmvanbrunt +cmd2/rich_utils.py @kmvanbrunt cmd2/rl_utils.py @kmvanbrunt -cmd2/table_creator.py @kmvanbrunt +cmd2/string_utils.py @kmvanbrunt +cmd2/styles.py @tleonhardt @kmvanbrunt +cmd2/terminal_utils.py @kmvanbrunt cmd2/transcript.py @tleonhardt cmd2/utils.py @tleonhardt @kmvanbrunt diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 875924a7..81c8d087 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -269,7 +269,7 @@ uv venv --python 3.12 Then you can run commands in this isolated virtual environment using `uv` like so: ```sh -uv run examples/basic.py +uv run examples/hello_cmd2.py ``` Alternatively you can activate the virtual environment using the OS-specific command such as this on @@ -329,7 +329,7 @@ environment is set up and working properly. You can also run the example app and see a prompt that says "(Cmd)" running the command: ```sh -$ uv run examples/example.py +$ uv run examples/getting_started.py ``` You can type `help` to get help or `quit` to quit. If you see that, then congratulations – you're diff --git a/README.md b/README.md index 688ed57a..a21bb1ed 100755 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ menagerie of simple command line tools created by strangers on github and the gu Unfortunately, when CLIs become significantly complex the ease of command discoverability tends to fade quickly. On the other hand, Web and traditional desktop GUIs are first in class when it comes to easily discovering functionality. The price we pay for beautifully colored displays is complexity -required to aggregate disperate applications into larger systems. `cmd2` fills the niche between +required to aggregate disparate applications into larger systems. `cmd2` fills the niche between high [ease of command discovery](https://clig.dev/#ease-of-discovery) applications and smart workflow automation systems. @@ -105,20 +105,16 @@ examples. ## Tutorials -- PyOhio 2019 presentation: - - [video](https://www.youtube.com/watch?v=pebeWrTqIIw) - - [slides](https://github.com/python-cmd2/talks/blob/master/PyOhio_2019/cmd2-PyOhio_2019.pdf) - - [example code](https://github.com/python-cmd2/talks/tree/master/PyOhio_2019/examples) -- [Cookiecutter](https://github.com/cookiecutter/cookiecutter) Templates from community - - Basic cookiecutter template for cmd2 application : - https://github.com/jayrod/cookiecutter-python-cmd2 - - Advanced cookiecutter template with external plugin support : - https://github.com/jayrod/cookiecutter-python-cmd2-ext-plug - [cmd2 example applications](https://github.com/python-cmd2/cmd2/tree/main/examples) - Basic cmd2 examples to demonstrate how to use various features - [Advanced Examples](https://github.com/jayrod/cmd2-example-apps) - More complex examples that demonstrate more featuers about how to put together a complete application +- [Cookiecutter](https://github.com/cookiecutter/cookiecutter) Templates from community + - Basic cookiecutter template for cmd2 application : + https://github.com/jayrod/cookiecutter-python-cmd2 + - Advanced cookiecutter template with external plugin support : + https://github.com/jayrod/cookiecutter-python-cmd2-ext-plug ## Hello World @@ -161,7 +157,6 @@ reproduce the bug. At a minimum, please state the following: | Application Name | Description | Organization or Author | | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | -| [Pobshell](https://github.com/pdalloz/pobshell) | A Bash‑like shell for live Python objects: `cd`, `ls`, `cat`, `find` and _CLI piping_ for object code, str values & more | [Peter Dalloz](https://www.linkedin.com/in/pdalloz) | | [CephFS Shell](https://github.com/ceph/ceph) | The Ceph File System, or CephFS, is a POSIX-compliant file system built on top of Ceph’s distributed object store | [ceph](https://ceph.com/) | | [garak](https://github.com/NVIDIA/garak) | LLM vulnerability scanner that checks if an LLM can be made to fail in a way we don't want | [NVIDIA](https://github.com/NVIDIA) | | [medusa](https://github.com/Ch0pin/medusa) | Binary instrumentation framework that that automates processes for the dynamic analysis of Android and iOS Applications | [Ch0pin](https://github.com/Ch0pin) | @@ -176,6 +171,7 @@ reproduce the bug. At a minimum, please state the following: | [tomcatmanager](https://github.com/tomcatmanager/tomcatmanager) | A command line tool and python library for managing a tomcat server | [tomcatmanager](https://github.com/tomcatmanager) | | [Falcon Toolkit](https://github.com/CrowdStrike/Falcon-Toolkit) | Unleash the power of the CrowdStrike Falcon Platform at the CLI | [CrowdStrike](https://github.com/CrowdStrike) | | [EXPLIoT](https://gitlab.com/expliot_framework/expliot) | Internet of Things Security Testing and Exploitation framework | [expliot_framework](https://gitlab.com/expliot_framework/) | +| [Pobshell](https://github.com/pdalloz/pobshell) | A Bash‑like shell for live Python objects: `cd`, `ls`, `cat`, `find` and _CLI piping_ for object code, str values & more | [Peter Dalloz](https://www.linkedin.com/in/pdalloz) | Possibly defunct but still good examples diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 529b1352..b213b971 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -10,7 +10,7 @@ Settable environment parameters Parsing commands with `argparse` argument parsers (flags) Redirection to file or paste buffer (clipboard) with > or >> -Easy transcript-based testing of applications (see examples/example.py) +Easy transcript-based testing of applications (see examples/transcript_example.py) Bash-style ``select`` available Note, if self.stdout is different than sys.stdout, then redirection with > and | diff --git a/cmd2/transcript.py b/cmd2/transcript.py index 50a6fd61..430ad8ce 100644 --- a/cmd2/transcript.py +++ b/cmd2/transcript.py @@ -34,7 +34,7 @@ class Cmd2TestCase(unittest.TestCase): that will execute the commands in a transcript file and expect the results shown. - See example.py + See transcript_example.py """ cmdapp: Optional['Cmd'] = None diff --git a/docs/examples/first_app.md b/docs/examples/getting_started.md similarity index 97% rename from docs/examples/first_app.md rename to docs/examples/getting_started.md index 86efd70f..0ab7289e 100644 --- a/docs/examples/first_app.md +++ b/docs/examples/getting_started.md @@ -1,6 +1,6 @@ -# First Application +# Getting Started -Here's a quick walkthrough of a simple application which demonstrates 8 features of `cmd2`: +Here's a quick walkthrough of a simple application which demonstrates 10 features of `cmd2`: - [Settings](../features/settings.md) - [Commands](../features/commands.md) @@ -14,17 +14,17 @@ Here's a quick walkthrough of a simple application which demonstrates 8 features If you don't want to type as we go, here is the complete source (you can click to expand and then click the **Copy** button in the top-right): -??? example +!!! example "getting_started.py" ```py {% - include "../../examples/first_app.py" + include "../../examples/getting_started.py" %} ``` ## Basic Application -First we need to create a new `cmd2` application. Create a new file `first_app.py` with the +First we need to create a new `cmd2` application. Create a new file `getting_started.py` with the following contents: ```py @@ -47,7 +47,7 @@ We have a new class `FirstApp` which is a subclass of [cmd2.Cmd][]. When we tell file like this: ```shell -$ python first_app.py +$ python getting_started.py ``` it creates an instance of our class, and calls the `cmd2.Cmd.cmdloop` method. This method accepts @@ -77,7 +77,7 @@ In that initializer, the first thing to do is to make sure we initialize `cmd2`. run the script, and enter the `set` command to see the settings, like this: ```shell -$ python first_app.py +$ python getting_started.py (Cmd) set ``` @@ -88,8 +88,8 @@ you will see our `maxrepeats` setting show up with it's default value of `3`. Now we will create our first command, called `speak` which will echo back whatever we tell it to say. We are going to use an [argument processor](../features/argument_processing.md) so the `speak` command can shout and talk piglatin. We will also use some built in methods for -[generating output](../features/generating_output.md). Add this code to `first_app.py`, so that the -`speak_parser` attribute and the `do_speak()` method are part of the `CmdLineApp()` class: +[generating output](../features/generating_output.md). Add this code to `getting_started.py`, so +that the `speak_parser` attribute and the `do_speak()` method are part of the `CmdLineApp()` class: ```py speak_parser = cmd2.Cmd2ArgumentParser() diff --git a/docs/examples/index.md b/docs/examples/index.md index 23001e97..6aad5a59 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -2,7 +2,7 @@ -- [First Application](first_app.md) +- [Getting Started](getting_started.md) - [Alternate Event Loops](alternate_event_loops.md) - [List of cmd2 examples](examples.md) diff --git a/docs/features/argument_processing.md b/docs/features/argument_processing.md index 9750a596..33db6723 100644 --- a/docs/features/argument_processing.md +++ b/docs/features/argument_processing.md @@ -14,10 +14,10 @@ following for you: These features are all provided by the `@with_argparser` decorator which is importable from `cmd2`. -See the either the [argprint](https://github.com/python-cmd2/cmd2/blob/main/examples/arg_print.py) -or [decorator](https://github.com/python-cmd2/cmd2/blob/main/examples/decorator_example.py) example -to learn more about how to use the various `cmd2` argument processing decorators in your `cmd2` -applications. +See the +[argparse_example](https://github.com/python-cmd2/cmd2/blob/main/examples/argparse_example.py) +example to learn more about how to use the various `cmd2` argument processing decorators in your +`cmd2` applications. `cmd2` provides the following [decorators](../api/decorators.md) for assisting with parsing arguments passed to commands: @@ -286,8 +286,9 @@ argparse sub-parsers. You may add multiple layers of subcommands for your command. `cmd2` will automatically traverse and tab complete subcommands for all commands using argparse. -See the [subcommands](https://github.com/python-cmd2/cmd2/blob/main/examples/subcommands.py) example -to learn more about how to use subcommands in your `cmd2` application. +See the +[argparse_example](https://github.com/python-cmd2/cmd2/blob/main/examples/argparse_example.py) +example to learn more about how to use subcommands in your `cmd2` application. ## Argparse Extensions diff --git a/docs/features/generating_output.md b/docs/features/generating_output.md index 9fc1f7f1..beed0c1c 100644 --- a/docs/features/generating_output.md +++ b/docs/features/generating_output.md @@ -126,21 +126,6 @@ you can pad it appropriately with spaces. However, there are categories of Unico occupy 2 cells, and other that occupy 0. To further complicate matters, you might have included ANSI escape sequences in the output to generate colors on the terminal. -The `cmd2.ansi.style_aware_wcswidth` function solves both of these problems. Pass it a string, and +The `cmd2.string_utils.str_width` function solves both of these problems. Pass it a string, and regardless of which Unicode characters and ANSI text style escape sequences it contains, it will tell you how many characters on the screen that string will consume when printed. - -## Pretty Printing Data Structures - -The `cmd2.Cmd.ppretty` method is similar to the Python -[pprint](https://docs.python.org/3/library/pprint.html) function from the standard `pprint` module. -`cmd2.Cmd.pprint` adds the same conveniences as `cmd2.Cmd.poutput`. - -This method provides a capability to “pretty-print” arbitrary Python data structures in a form which -can be used as input to the interpreter and is easy for humans to read. - -The formatted representation keeps objects on a single line if it can, and breaks them onto multiple -lines if they don’t fit within the allowed width, adjustable by the width parameter defaulting to 80 -characters. - -Dictionaries are sorted by key before the display is computed. diff --git a/docs/features/initialization.md b/docs/features/initialization.md index 478a2eb2..5fe6e008 100644 --- a/docs/features/initialization.md +++ b/docs/features/initialization.md @@ -2,81 +2,13 @@ Here is a basic example `cmd2` application which demonstrates many capabilities which you may wish to utilize while initializing the app: -```py - #!/usr/bin/env python3 - # coding=utf-8 - """A simple example cmd2 application demonstrating the following: - 1) Colorizing/stylizing output - 2) Using multiline commands - 3) Persistent history - 4) How to run an initialization script at startup - 5) How to group and categorize commands when displaying them in help - 6) Opting-in to using the ipy command to run an IPython shell - 7) Allowing access to your application in py and ipy - 8) Displaying an intro banner upon starting your application - 9) Using a custom prompt - 10) How to make custom attributes settable at runtime - """ - import cmd2 - from cmd2 import ( - Bg, - Fg, - style, - ) +!!! example "examples/getting_started.py" - - class BasicApp(cmd2.Cmd): - CUSTOM_CATEGORY = 'My Custom Commands' - - def __init__(self): - super().__init__( - multiline_commands=['echo'], - persistent_history_file='cmd2_history.dat', - startup_script='scripts/startup.txt', - include_ipy=True, - ) - - # Prints an intro banner once upon application startup - self.intro = style('Welcome to cmd2!', fg=Fg.RED, bg=Bg.WHITE, bold=True) - - # Show this as the prompt when asking for input - self.prompt = 'myapp> ' - - # Used as prompt for multiline commands after the first line - self.continuation_prompt = '... ' - - # Allow access to your application in py and ipy via self - self.self_in_py = True - - # Set the default category name - self.default_category = 'cmd2 Built-in Commands' - - # Color to output text in with echo command - self.foreground_color = Fg.CYAN.name.lower() - - # Make echo_fg settable at runtime - fg_colors = [c.name.lower() for c in Fg] - self.add_settable( - cmd2.Settable('foreground_color', str, 'Foreground color to use with echo command', self, - choices=fg_colors) - ) - - @cmd2.with_category(CUSTOM_CATEGORY) - def do_intro(self, _): - """Display the intro banner""" - self.poutput(self.intro) - - @cmd2.with_category(CUSTOM_CATEGORY) - def do_echo(self, arg): - """Example of a multiline command""" - fg_color = Fg[self.foreground_color.upper()] - self.poutput(style(arg, fg=fg_color)) - - - if __name__ == '__main__': - app = BasicApp() - app.cmdloop() -``` + ```py + {% + include "../../examples/getting_started.py" + %} + ``` ## Cmd class initializer diff --git a/docs/features/os.md b/docs/features/os.md index d1da31bf..313b988b 100644 --- a/docs/features/os.md +++ b/docs/features/os.md @@ -69,23 +69,23 @@ user to enter commands, which are then executed by your program. You may want to execute commands in your program without prompting the user for any input. There are several ways you might accomplish this task. The easiest one is to pipe commands and their arguments into your program via standard input. You don't need to do anything to your program in order to use -this technique. Here's a demonstration using the `examples/example.py` included in the source code -of `cmd2`: +this technique. Here's a demonstration using the `examples/transcript_example.py` included in the +source code of `cmd2`: - $ echo "speak -p some words" | python examples/example.py + $ echo "speak -p some words" | python examples/transcript_example.py omesay ordsway Using this same approach you could create a text file containing the commands you would like to run, one command per line in the file. Say your file was called `somecmds.txt`. To run the commands in the text file using your `cmd2` program (from a Windows command prompt): - c:\cmd2> type somecmds.txt | python.exe examples/example.py + c:\cmd2> type somecmds.txt | python.exe examples/transcript_example.py omesay ordsway By default, `cmd2` programs also look for commands pass as arguments from the operating system shell, and execute those commands before entering the command loop: - $ python examples/example.py help + $ python examples/transcript_example.py help Documented commands (use 'help -v' for verbose/'help ' for details): =========================================================================== @@ -99,8 +99,8 @@ example, you might have a command inside your `cmd2` program which itself accept maybe even option strings. Say you wanted to run the `speak` command from the operating system shell, but have it say it in pig latin: - $ python example/example.py speak -p hello there - python example.py speak -p hello there + $ python example/transcript_example.py speak -p hello there + python transcript_example.py speak -p hello there usage: speak [-h] [-p] [-s] [-r REPEAT] words [words ...] speak: error: the following arguments are required: words *** Unknown syntax: -p @@ -122,7 +122,7 @@ Check the source code of this example, especially the `main()` function, to see Alternatively you can simply wrap the command plus arguments in quotes (either single or double quotes): - $ python example/example.py "speak -p hello there" + $ python example/transcript_example.py "speak -p hello there" ellohay heretay (Cmd) @@ -148,6 +148,6 @@ quits while returning an exit code: Here is another example using `quit`: - $ python example/example.py "speak -p hello there" quit + $ python example/transcript_example.py "speak -p hello there" quit ellohay heretay $ diff --git a/docs/features/prompt.md b/docs/features/prompt.md index 0ae8b179..ad385fbc 100644 --- a/docs/features/prompt.md +++ b/docs/features/prompt.md @@ -6,8 +6,8 @@ This prompt can be configured by setting the `cmd2.Cmd.prompt` instance attribute. This contains the string which should be printed as a prompt for user input. See the -[Pirate](https://github.com/python-cmd2/cmd2/blob/main/examples/pirate.py#L39) example for the -simple use case of statically setting the prompt. +[getting_started](https://github.com/python-cmd2/cmd2/blob/main/examples/getting_started.py) example +for the simple use case of statically setting the prompt. ## Continuation Prompt @@ -15,16 +15,16 @@ When a user types a [Multiline Command](./multiline_commands.md) it may span mor input. The prompt for the first line of input is specified by the `cmd2.Cmd.prompt` instance attribute. The prompt for subsequent lines of input is defined by the `cmd2.Cmd.continuation_prompt` attribute.See the -[Initialization](https://github.com/python-cmd2/cmd2/blob/main/examples/initialization.py#L42) -example for a demonstration of customizing the continuation prompt. +[getting_started](https://github.com/python-cmd2/cmd2/blob/main/examples/getting_started.py) example +for a demonstration of customizing the continuation prompt. ## Updating the prompt If you wish to update the prompt between commands, you can do so using one of the [Application Lifecycle Hooks](./hooks.md#application-lifecycle-hooks) such as a [Postcommand hook](./hooks.md#postcommand-hooks). See -[PythonScripting](https://github.com/python-cmd2/cmd2/blob/main/examples/python_scripting.py#L38-L55) -for an example of dynamically updating the prompt. +[PythonScripting](https://github.com/python-cmd2/cmd2/blob/main/examples/python_scripting.py) for an +example of dynamically updating the prompt. ## Asynchronous Feedback diff --git a/docs/features/startup_commands.md b/docs/features/startup_commands.md index b695c43d..1bd563ab 100644 --- a/docs/features/startup_commands.md +++ b/docs/features/startup_commands.md @@ -16,7 +16,7 @@ program. `cmd2` interprets each argument as a separate command, so you should en in quotation marks if it is more than a one-word command. You can use either single or double quotes for this purpose. - $ python examples/example.py "say hello" "say Gracie" quit + $ python examples/transcript_example.py "say hello" "say Gracie" quit hello Gracie @@ -47,8 +47,8 @@ class StartupApp(cmd2.Cmd): ``` This text file should contain a [Command Script](./scripting.md#command-scripts). See the -[AliasStartup](https://github.com/python-cmd2/cmd2/blob/main/examples/alias_startup.py) example for -a demonstration. +[initialization](https://github.com/python-cmd2/cmd2/blob/main/examples/initialization.py) example +for a demonstration. You can silence a startup script's output by setting `silence_startup_script` to True: diff --git a/docs/features/transcripts.md b/docs/features/transcripts.md index 037fc5dd..1368b13e 100644 --- a/docs/features/transcripts.md +++ b/docs/features/transcripts.md @@ -40,7 +40,7 @@ testing as your `cmd2` application changes. ## Creating Manually -Here's a transcript created from `python examples/example.py`: +Here's a transcript created from `python examples/transcript_example.py`: ```text (Cmd) say -r 3 Goodnight, Gracie @@ -155,7 +155,7 @@ Once you have created a transcript, it's easy to have your application play it b output. From within the `examples/` directory: ```text -$ python example.py --test transcript_regex.txt +$ python transcript_example.py --test transcript_regex.txt . ---------------------------------------------------------------------- Ran 1 test in 0.013s diff --git a/docs/migrating/next_steps.md b/docs/migrating/next_steps.md index 7d56e2f4..892e05c7 100644 --- a/docs/migrating/next_steps.md +++ b/docs/migrating/next_steps.md @@ -41,5 +41,5 @@ to `cmd2.Cmd.poutput`, `cmd2.Cmd.perror`, and `cmd2.Cmd.pfeedback`. These method of the built in [Settings](../features/settings.md) to allow the user to view or suppress feedback (i.e. progress or status output). They also properly handle ansi colored output according to user preference. Speaking of colored output, you can use any color library you want, or use the included -`cmd2.ansi.style` function. These and other related topics are covered in +`cmd2.string_utils.stylize` function. These and other related topics are covered in [Generating Output](../features/generating_output.md). diff --git a/docs/overview/index.md b/docs/overview/index.md index 8038b9c1..a8cb8ee2 100644 --- a/docs/overview/index.md +++ b/docs/overview/index.md @@ -11,8 +11,8 @@ if this library is a good fit for your needs. - [Installation Instructions](installation.md) - how to install `cmd2` and associated optional dependencies -- [First Application](../examples/first_app.md) - a sample application showing 8 key features of - `cmd2` +- [Getting Started Application](../examples/getting_started.md) - a sample application showing many + key features of `cmd2` - [Integrate cmd2 Into Your Project](integrating.md) - adding `cmd2` to your project - [Alternatives](alternatives.md) - other python packages that might meet your needs - [Resources](resources.md) - related links and other materials diff --git a/examples/README.md b/examples/README.md index aad2072b..aea040bd 100644 --- a/examples/README.md +++ b/examples/README.md @@ -11,33 +11,26 @@ application, if you are looking for that then see Here is the list of examples in alphabetical order by filename along with a brief description of each: -- [alias_startup.py](https://github.com/python-cmd2/cmd2/blob/main/examples/alias_startup.py) - - Demonstrates how to add custom command aliases and how to run an initialization script at - startup -- [arg_decorators.py](https://github.com/python-cmd2/cmd2/blob/main/examples/arg_decorators.py) - - Demonstrates how to use the `cmd2.with_argparser` decorator to specify command arguments using - [argparse](https://docs.python.org/3/library/argparse.html) -- [arg_print.py](https://github.com/python-cmd2/cmd2/blob/main/examples/arg_print.py) - - Demonstrates how arguments and options get parsed and passed to commands and shows how - shortcuts work - [argparse_completion.py](https://github.com/python-cmd2/cmd2/blob/main/examples/argparse_completion.py) - Shows how to integrate tab-completion with argparse-based commands +- [argparse_example.py](https://github.com/python-cmd2/cmd2/blob/main/examples/argparse_example.py) + - Demonstrates how to use the `cmd2.with_argparser` decorator to specify command arguments using + [argparse](https://docs.python.org/3/library/argparse.html) - [async_printing.py](https://github.com/python-cmd2/cmd2/blob/main/examples/async_printing.py) - Shows how to asynchronously print alerts, update the prompt in realtime, and change the window title -- [basic.py](https://github.com/python-cmd2/cmd2/blob/main/examples/basic.py) - - Shows how to add a command, add help for it, and create persistent command history for your - application - [basic_completion.py](https://github.com/python-cmd2/cmd2/blob/main/examples/basic_completion.py) - Show how to enable custom tab completion by assigning a completer function to `do_*` commands - [cmd2_as_argument.py](https://github.com/python-cmd2/cmd2/blob/main/examples/cmd_as_argument.py) - Demonstrates how to accept and parse command-line arguments when invoking a cmd2 application - [color.py](https://github.com/python-cmd2/cmd2/blob/main/examples/color.py) - Show the numerous colors available to use in your cmd2 applications +- [command_sets.py](https://github.com/python-cmd2/cmd2/blob/main/examples/command_sets.py) + - Example that demonstrates the `CommandSet` features for modularizing commands and demonstrates + all main capabilities including basic CommandSets, dynamic loading an unloading, using + subcommands, etc. - [custom_parser.py](https://github.com/python-cmd2/cmd2/blob/main/examples/custom_parser.py) - Demonstrates how to create your own custom `Cmd2ArgumentParser` -- [decorator_example.py](https://github.com/python-cmd2/cmd2/blob/main/examples/decorator_example.py) - - Shows how to use cmd2's various argparse decorators to processes command-line arguments - [default_categories.py](https://github.com/python-cmd2/cmd2/blob/main/examples/default_categories.py) - Demonstrates usage of `@with_default_category` decorator to group and categorize commands and `CommandSet` use @@ -49,13 +42,10 @@ each: - [event_loops.py](https://github.com/python-cmd2/cmd2/blob/main/examples/event_loops.py) - Shows how to integrate a `cmd2` application with an external event loop which isn't managed by `cmd2` -- [example.py](https://github.com/python-cmd2/cmd2/blob/main/examples/example.py) - - This example is intended to demonstrate `cmd2's` build-in transcript testing capability - [exit_code.py](https://github.com/python-cmd2/cmd2/blob/main/examples/exit_code.py) - Show how to emit a non-zero exit code from your `cmd2` application when it exits -- [first_app.py](https://github.com/python-cmd2/cmd2/blob/main/examples/first_app.py) - - Short application that demonstrates 8 key features: Settings, Commands, Argument Parsing, - Generating Output, Help, Shortcuts, Multiple Commands, and History +- [getting_started.py](https://github.com/python-cmd2/cmd2/blob/main/examples/getting_started.py) + - Short application that demonstrates many key features of cmd2 - [hello_cmd2.py](https://github.com/python-cmd2/cmd2/blob/main/examples/hello_cmd2.py) - Completely bare-bones `cmd2` application suitable for rapid testing and debugging of `cmd2` itself @@ -64,26 +54,15 @@ each: command - [hooks.py](https://github.com/python-cmd2/cmd2/blob/main/examples/hooks.py) - Shows how to use various `cmd2` application lifecycle hooks -- [initialization.py](https://github.com/python-cmd2/cmd2/blob/main/examples/initialization.py) - - Shows how to colorize output, use multiline command, add persistent history, and more - [migrating.py](https://github.com/python-cmd2/cmd2/blob/main/examples/migrating.py) - A simple `cmd` application that you can migrate to `cmd2` by changing one line -- [modular_commands_basic.py](https://github.com/python-cmd2/cmd2/blob/main/examples/modular_commands_basic.py) - - Demonstrates based `CommandSet` usage -- [modular_commands_dynamic.py](https://github.com/python-cmd2/cmd2/blob/main/examples/modular_commands_dynamic.py) - - Demonstrates dynamic `CommandSet` loading and unloading -- [modular_commands_main.py](https://github.com/python-cmd2/cmd2/blob/main/examples/modular_commands_main.py) +- [modular_commands.py](https://github.com/python-cmd2/cmd2/blob/main/examples/modular_commands.py) - Complex example demonstrating a variety of methods to load `CommandSets` using a mix of command decorators -- [modular_subcommands.py](https://github.com/python-cmd2/cmd2/blob/main/examples/modular_subcommands.py) - - Shows how to dynamically add and remove subcommands at runtime using `CommandSets` - [paged_output.py](https://github.com/python-cmd2/cmd2/blob/main/examples/paged_output.py) - Shows how to use output pagination within `cmd2` apps via the `ppaged` method - [persistent_history.py](https://github.com/python-cmd2/cmd2/blob/main/examples/persistent_history.py) - Shows how to enable persistent history in your `cmd2` application -- [pirate.py](https://github.com/python-cmd2/cmd2/blob/main/examples/pirate.py) - - Demonstrates many features including colorized output, multiline commands, shorcuts, - defaulting to shell, etc. - [pretty_print.py](https://github.com/python-cmd2/cmd2/blob/main/examples/pretty_print.py) - Demonstrates use of cmd2.Cmd.ppretty() for pretty-printing arbitrary Python data structures like dictionaries. @@ -99,13 +78,11 @@ each: - [remove_settable.py](https://github.com/python-cmd2/cmd2/blob/main/examples/remove_settable.py) - Shows how to remove any of the built-in cmd2 `Settables` you do not want in your cmd2 application -- [subcommands.py](https://github.com/python-cmd2/cmd2/blob/main/examples/subcommands.py) - - Shows how to use `argparse` to easily support sub-commands within your cmd2 commands -- [table_creation.py](https://github.com/python-cmd2/cmd2/blob/main/examples/table_creation.py) - - Contains various examples of using cmd2's table creation capabilities - [tmux_launch.sh](https://github.com/python-cmd2/cmd2/blob/main/examples/tmux_launch.sh) - Shell script that launches two applications using tmux in different windows/tabs - [tmux_split.sh](https://github.com/python-cmd2/cmd2/blob/main/examples/tmux_split.sh) - Shell script that launches two applications using tmux in a split pane view +- [transcript_example.py](https://github.com/python-cmd2/cmd2/blob/main/examples/transcript_example.py) + - This example is intended to demonstrate `cmd2's` build-in transcript testing capability - [unicode_commands.py](https://github.com/python-cmd2/cmd2/blob/main/examples/unicode_commands.py) - Shows that cmd2 supports unicode everywhere, including within command names diff --git a/examples/alias_startup.py b/examples/alias_startup.py deleted file mode 100755 index f6e401a0..00000000 --- a/examples/alias_startup.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python -"""A simple example demonstrating the following: -1) How to add custom command aliases using the alias command -2) How to run an initialization script at startup. -""" - -import os - -import cmd2 - - -class AliasAndStartup(cmd2.Cmd): - """Example cmd2 application where we create commands that just print the arguments they are called with.""" - - def __init__(self) -> None: - alias_script = os.path.join(os.path.dirname(__file__), '.cmd2rc') - super().__init__(startup_script=alias_script) - - def do_nothing(self, args) -> None: - """This command does nothing and produces no output.""" - - -if __name__ == '__main__': - import sys - - app = AliasAndStartup() - sys.exit(app.cmdloop()) diff --git a/examples/arg_decorators.py b/examples/arg_decorators.py deleted file mode 100755 index 5fe262d4..00000000 --- a/examples/arg_decorators.py +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env python3 -"""An example demonstrating how use one of cmd2's argument parsing decorators.""" - -import argparse -import os - -import cmd2 - - -class ArgparsingApp(cmd2.Cmd): - def __init__(self) -> None: - super().__init__(include_ipy=True) - self.intro = 'cmd2 has awesome decorators to make it easy to use Argparse to parse command arguments' - - # do_fsize parser - fsize_parser = cmd2.Cmd2ArgumentParser(description='Obtain the size of a file') - fsize_parser.add_argument('-c', '--comma', action='store_true', help='add comma for thousands separator') - fsize_parser.add_argument('-u', '--unit', choices=['MB', 'KB'], help='unit to display size in') - fsize_parser.add_argument('file_path', help='path of file', completer=cmd2.Cmd.path_complete) - - @cmd2.with_argparser(fsize_parser) - def do_fsize(self, args: argparse.Namespace) -> None: - """Obtain the size of a file.""" - expanded_path = os.path.expanduser(args.file_path) - - try: - size = os.path.getsize(expanded_path) - except OSError as ex: - self.perror(f"Error retrieving size: {ex}") - return - - if args.unit == 'KB': - size /= 1024 - elif args.unit == 'MB': - size /= 1024 * 1024 - else: - args.unit = 'bytes' - size = round(size, 2) - - if args.comma: - size = f'{size:,}' - self.poutput(f'{size} {args.unit}') - - # do_pow parser - pow_parser = cmd2.Cmd2ArgumentParser() - pow_parser.add_argument('base', type=int) - pow_parser.add_argument('exponent', type=int, choices=range(-5, 6)) - - @cmd2.with_argparser(pow_parser) - def do_pow(self, args: argparse.Namespace) -> None: - """Raise an integer to a small integer exponent, either positive or negative. - - :param args: argparse arguments - """ - self.poutput(f'{args.base} ** {args.exponent} == {args.base**args.exponent}') - - -if __name__ == '__main__': - app = ArgparsingApp() - app.cmdloop() diff --git a/examples/arg_print.py b/examples/arg_print.py deleted file mode 100755 index 506e9225..00000000 --- a/examples/arg_print.py +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env python -"""A simple example demonstrating the following: - 1) How arguments and options get parsed and passed to commands - 2) How to change what syntax gets parsed as a comment and stripped from the arguments. - -This is intended to serve as a live demonstration so that developers can -experiment with and understand how command and argument parsing work. - -It also serves as an example of how to create shortcuts. -""" - -import cmd2 - - -class ArgumentAndOptionPrinter(cmd2.Cmd): - """Example cmd2 application where we create commands that just print the arguments they are called with.""" - - def __init__(self) -> None: - # Create command shortcuts which are typically 1 character abbreviations which can be used in place of a command - shortcuts = dict(cmd2.DEFAULT_SHORTCUTS) - shortcuts.update({'$': 'aprint', '%': 'oprint'}) - super().__init__(shortcuts=shortcuts) - - def do_aprint(self, statement) -> None: - """Print the argument string this basic command is called with.""" - self.poutput(f'aprint was called with argument: {statement!r}') - self.poutput(f'statement.raw = {statement.raw!r}') - self.poutput(f'statement.argv = {statement.argv!r}') - self.poutput(f'statement.command = {statement.command!r}') - - @cmd2.with_argument_list - def do_lprint(self, arglist) -> None: - """Print the argument list this basic command is called with.""" - self.poutput(f'lprint was called with the following list of arguments: {arglist!r}') - - @cmd2.with_argument_list(preserve_quotes=True) - def do_rprint(self, arglist) -> None: - """Print the argument list this basic command is called with (with quotes preserved).""" - self.poutput(f'rprint was called with the following list of arguments: {arglist!r}') - - oprint_parser = cmd2.Cmd2ArgumentParser() - oprint_parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay') - oprint_parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE') - oprint_parser.add_argument('-r', '--repeat', type=int, help='output [n] times') - oprint_parser.add_argument('words', nargs='+', help='words to print') - - @cmd2.with_argparser(oprint_parser) - def do_oprint(self, args) -> None: - """Print the options and argument list this options command was called with.""" - self.poutput(f'oprint was called with the following\n\toptions: {args!r}') - - pprint_parser = cmd2.Cmd2ArgumentParser() - pprint_parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay') - pprint_parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE') - pprint_parser.add_argument('-r', '--repeat', type=int, help='output [n] times') - - @cmd2.with_argparser(pprint_parser, with_unknown_args=True) - def do_pprint(self, args, unknown) -> None: - """Print the options and argument list this options command was called with.""" - self.poutput(f'oprint was called with the following\n\toptions: {args!r}\n\targuments: {unknown}') - - -if __name__ == '__main__': - import sys - - app = ArgumentAndOptionPrinter() - sys.exit(app.cmdloop()) diff --git a/examples/argparse_completion.py b/examples/argparse_completion.py index 961c720a..90d2d104 100755 --- a/examples/argparse_completion.py +++ b/examples/argparse_completion.py @@ -3,11 +3,16 @@ import argparse +from rich.box import SIMPLE_HEAD +from rich.style import Style +from rich.table import Table from rich.text import Text from cmd2 import ( Cmd, Cmd2ArgumentParser, + Cmd2Style, + Color, CompletionError, CompletionItem, with_argparser, @@ -18,8 +23,8 @@ class ArgparseCompletion(Cmd): - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) + def __init__(self) -> None: + super().__init__(include_ipy=True) self.sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball'] def choices_provider(self) -> list[str]: @@ -39,9 +44,22 @@ def choices_completion_error(self) -> list[str]: def choices_completion_item(self) -> list[CompletionItem]: """Return CompletionItem instead of strings. These give more context to what's being tab completed.""" - fancy_item = Text("These things can\ncontain newlines and\n") + Text("styled text!!", style="underline bright_yellow") - - items = {1: "My item", 2: "Another item", 3: "Yet another item", 4: fancy_item} + fancy_item = Text.assemble( + "These things can\ncontain newlines and\n", + Text("styled text!!", style=Style(color=Color.BRIGHT_YELLOW, underline=True)), + ) + + table_item = Table("Left Column", "Right Column", box=SIMPLE_HEAD, border_style=Cmd2Style.TABLE_BORDER) + table_item.add_row("Yes, it's true.", "CompletionItems can") + table_item.add_row("even display description", "data in tables!") + + items = { + 1: "My item", + 2: "Another item", + 3: "Yet another item", + 4: fancy_item, + 5: table_item, + } return [CompletionItem(item_id, [description]) for item_id, description in items.items()] def choices_arg_tokens(self, arg_tokens: dict[str, list[str]]) -> list[str]: diff --git a/examples/argparse_example.py b/examples/argparse_example.py new file mode 100755 index 00000000..dedad6c9 --- /dev/null +++ b/examples/argparse_example.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +"""A comprehensive example demonstrating various aspects of using `argparse` for command argument processing. + +Demonstrates basic usage of the `cmd2.with_argparser` decorator for passing a `cmd2.Cmd2ArgumentParser` to a `do_*` command +method. The `fsize` and `pow` commands demonstrate various different types of arguments, actions, choices, and completers that +can be used. + +The `print_args` and `print_unknown` commands display how argparse arguments are passed to commands in the cases that unknown +arguments are not captured and are captured, respectively. + +The `base` and `alternate` commands show an easy way for a single command to have many subcommands, each of which take +different arguments and provides separate contextual help. + +Lastly, this example shows how you can also use `argparse` to parse command-line arguments when launching a cmd2 application. +""" + +import argparse +import os + +import cmd2 +from cmd2.string_utils import stylize + +# Command categories +ARGPARSE_USAGE = 'Argparse Basic Usage' +ARGPARSE_PRINTING = 'Argparse Printing' +ARGPARSE_SUBCOMMANDS = 'Argparse Subcommands' + + +class ArgparsingApp(cmd2.Cmd): + def __init__(self, color: str) -> None: + """Cmd2 application for demonstrating the use of argparse for command argument parsing.""" + super().__init__(include_ipy=True) + self.intro = stylize( + 'cmd2 has awesome decorators to make it easy to use Argparse to parse command arguments', style=color + ) + + ## ------ Basic examples of using argparse for command argument parsing ----- + + # do_fsize parser + fsize_parser = cmd2.Cmd2ArgumentParser(description='Obtain the size of a file') + fsize_parser.add_argument('-c', '--comma', action='store_true', help='add comma for thousands separator') + fsize_parser.add_argument('-u', '--unit', choices=['MB', 'KB'], help='unit to display size in') + fsize_parser.add_argument('file_path', help='path of file', completer=cmd2.Cmd.path_complete) + + @cmd2.with_argparser(fsize_parser) + @cmd2.with_category(ARGPARSE_USAGE) + def do_fsize(self, args: argparse.Namespace) -> None: + """Obtain the size of a file.""" + expanded_path = os.path.expanduser(args.file_path) + + try: + size = os.path.getsize(expanded_path) + except OSError as ex: + self.perror(f"Error retrieving size: {ex}") + return + + if args.unit == 'KB': + size //= 1024 + elif args.unit == 'MB': + size //= 1024 * 1024 + else: + args.unit = 'bytes' + size = round(size, 2) + + size_str = f'{size:,}' if args.comma else f'{size}' + self.poutput(f'{size_str} {args.unit}') + + # do_pow parser + pow_parser = cmd2.Cmd2ArgumentParser() + pow_parser.add_argument('base', type=int) + pow_parser.add_argument('exponent', type=int, choices=range(-5, 6)) + + @cmd2.with_argparser(pow_parser) + @cmd2.with_category(ARGPARSE_USAGE) + def do_pow(self, args: argparse.Namespace) -> None: + """Raise an integer to a small integer exponent, either positive or negative. + + :param args: argparse arguments + """ + self.poutput(f'{args.base} ** {args.exponent} == {args.base**args.exponent}') + + ## ------ Examples displaying how argparse arguments are passed to commands by printing them out ----- + + argprint_parser = cmd2.Cmd2ArgumentParser() + argprint_parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay') + argprint_parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE') + argprint_parser.add_argument('-r', '--repeat', type=int, help='output [n] times') + argprint_parser.add_argument('words', nargs='+', help='words to print') + + @cmd2.with_argparser(argprint_parser) + @cmd2.with_category(ARGPARSE_PRINTING) + def do_print_args(self, args: argparse.Namespace) -> None: + """Print the arpgarse argument list this command was called with.""" + self.poutput(f'print_args was called with the following\n\targuments: {args!r}') + + unknownprint_parser = cmd2.Cmd2ArgumentParser() + unknownprint_parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay') + unknownprint_parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE') + unknownprint_parser.add_argument('-r', '--repeat', type=int, help='output [n] times') + + @cmd2.with_argparser(unknownprint_parser, with_unknown_args=True) + @cmd2.with_category(ARGPARSE_PRINTING) + def do_print_unknown(self, args: argparse.Namespace, unknown: list[str]) -> None: + """Print the arpgarse argument list this command was called with, including unknown arguments.""" + self.poutput(f'print_unknown was called with the following arguments\n\tknown: {args!r}\n\tunknown: {unknown}') + + ## ------ Examples demonstrating how to use argparse subcommands ----- + + sport_item_strs = ('Bat', 'Basket', 'Basketball', 'Football', 'Space Ball') + + # create the top-level parser for the base command + base_parser = cmd2.Cmd2ArgumentParser() + base_subparsers = base_parser.add_subparsers(title='subcommands', help='subcommand help') + + # create the parser for the "foo" subcommand + parser_foo = base_subparsers.add_parser('foo', help='foo help') + parser_foo.add_argument('-x', type=int, default=1, help='integer') + parser_foo.add_argument('y', type=float, help='float') + parser_foo.add_argument('input_file', type=str, help='Input File') + + # create the parser for the "bar" subcommand + parser_bar = base_subparsers.add_parser('bar', help='bar help') + + bar_subparsers = parser_bar.add_subparsers(title='layer3', help='help for 3rd layer of commands') + parser_bar.add_argument('z', help='string') + + bar_subparsers.add_parser('apple', help='apple help') + bar_subparsers.add_parser('artichoke', help='artichoke help') + bar_subparsers.add_parser('cranberries', help='cranberries help') + + # create the parser for the "sport" subcommand + parser_sport = base_subparsers.add_parser('sport', help='sport help') + sport_arg = parser_sport.add_argument('sport', help='Enter name of a sport', choices=sport_item_strs) + + # create the top-level parser for the alternate command + # The alternate command doesn't provide its own help flag + base2_parser = cmd2.Cmd2ArgumentParser(add_help=False) + base2_subparsers = base2_parser.add_subparsers(title='subcommands', help='subcommand help') + + # create the parser for the "foo" subcommand + parser_foo2 = base2_subparsers.add_parser('foo', help='foo help') + parser_foo2.add_argument('-x', type=int, default=1, help='integer') + parser_foo2.add_argument('y', type=float, help='float') + parser_foo2.add_argument('input_file', type=str, help='Input File') + + # create the parser for the "bar" subcommand + parser_bar2 = base2_subparsers.add_parser('bar', help='bar help') + + bar2_subparsers = parser_bar2.add_subparsers(title='layer3', help='help for 3rd layer of commands') + parser_bar2.add_argument('z', help='string') + + bar2_subparsers.add_parser('apple', help='apple help') + bar2_subparsers.add_parser('artichoke', help='artichoke help') + bar2_subparsers.add_parser('cranberries', help='cranberries help') + + # create the parser for the "sport" subcommand + parser_sport2 = base2_subparsers.add_parser('sport', help='sport help') + sport2_arg = parser_sport2.add_argument('sport', help='Enter name of a sport', choices=sport_item_strs) + + # subcommand functions for the base command + def base_foo(self, args: argparse.Namespace) -> None: + """Foo subcommand of base command.""" + self.poutput(args.x * args.y) + + def base_bar(self, args: argparse.Namespace) -> None: + """Bar subcommand of base command.""" + self.poutput(f'(({args.z}))') + + def base_sport(self, args: argparse.Namespace) -> None: + """Sport subcommand of base command.""" + self.poutput(f'Sport is {args.sport}') + + # Set handler functions for the subcommands + parser_foo.set_defaults(func=base_foo) + parser_bar.set_defaults(func=base_bar) + parser_sport.set_defaults(func=base_sport) + + @cmd2.with_argparser(base_parser) + @cmd2.with_category(ARGPARSE_SUBCOMMANDS) + def do_base(self, args: argparse.Namespace) -> None: + """Base command help.""" + func = getattr(args, 'func', None) + if func is not None: + # Call whatever subcommand function was selected + func(self, args) + else: + # No subcommand was provided, so call help + self.do_help('base') + + @cmd2.with_argparser(base2_parser) + @cmd2.with_category(ARGPARSE_SUBCOMMANDS) + def do_alternate(self, args: argparse.Namespace) -> None: + """Alternate command help.""" + func = getattr(args, 'func', None) + if func is not None: + # Call whatever subcommand function was selected + func(self, args) + else: + # No subcommand was provided, so call help + self.do_help('alternate') + + +if __name__ == '__main__': + import sys + + from cmd2.colors import Color + + # You can do your custom Argparse parsing here to meet your application's needs + parser = cmd2.Cmd2ArgumentParser(description='Process the arguments however you like.') + + # Add an argument which we will pass to the app to change some behavior + parser.add_argument( + '-c', + '--color', + choices=[Color.RED, Color.ORANGE1, Color.YELLOW, Color.GREEN, Color.BLUE, Color.PURPLE, Color.VIOLET, Color.WHITE], + help='Color of intro text', + ) + + # Parse the arguments + args, unknown_args = parser.parse_known_args() + + color = Color.WHITE + if args.color: + color = args.color + + # Perform surgery on sys.argv to remove the arguments which have already been processed by argparse + sys.argv = sys.argv[:1] + unknown_args + + # Instantiate your cmd2 application + app = ArgparsingApp(color) + + # And run your cmd2 application + sys.exit(app.cmdloop()) diff --git a/examples/basic.py b/examples/basic.py deleted file mode 100755 index 20ebe20a..00000000 --- a/examples/basic.py +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env python3 -"""A simple example demonstrating the following: -1) How to add a command -2) How to add help for that command -3) Persistent history -4) How to run an initialization script at startup -5) How to add custom command aliases using the alias command -6) Shell-like capabilities. -""" - -import cmd2 -from cmd2 import ( - Bg, - Fg, - style, -) - - -class BasicApp(cmd2.Cmd): - CUSTOM_CATEGORY = 'My Custom Commands' - - def __init__(self) -> None: - super().__init__( - multiline_commands=['echo'], - persistent_history_file='cmd2_history.dat', - startup_script='scripts/startup.txt', - include_ipy=True, - ) - - self.intro = style('Welcome to PyOhio 2019 and cmd2!', fg=Fg.RED, bg=Bg.WHITE, bold=True) + ' 😀' - - # Allow access to your application in py and ipy via self - self.self_in_py = True - - # Set the default category name - self.default_category = 'cmd2 Built-in Commands' - - @cmd2.with_category(CUSTOM_CATEGORY) - def do_intro(self, _) -> None: - """Display the intro banner.""" - self.poutput(self.intro) - - @cmd2.with_category(CUSTOM_CATEGORY) - def do_echo(self, arg) -> None: - """Example of a multiline command.""" - self.poutput(arg) - - -if __name__ == '__main__': - app = BasicApp() - app.cmdloop() diff --git a/examples/cmd_as_argument.py b/examples/cmd_as_argument.py index dd265074..b9db4acd 100755 --- a/examples/cmd_as_argument.py +++ b/examples/cmd_as_argument.py @@ -1,7 +1,7 @@ #!/usr/bin/env python """A sample application for cmd2. -This example is very similar to example.py, but had additional +This example is very similar to transcript_example.py, but had additional code in main() that shows how to accept a command from the command line at invocation: diff --git a/examples/color.py b/examples/color.py index c9cd65b2..e6e2cf26 100755 --- a/examples/color.py +++ b/examples/color.py @@ -7,12 +7,10 @@ import argparse from rich.style import Style +from rich.text import Text import cmd2 -from cmd2 import ( - Color, - stylize, -) +from cmd2 import Color class CmdLineApp(cmd2.Cmd): @@ -31,17 +29,19 @@ def __init__(self) -> None: def do_taste_the_rainbow(self, args: argparse.Namespace) -> None: """Show all of the colors available within cmd2's Color StrEnum class.""" - color_names = [] - for color_member in Color: - style = Style(bgcolor=color_member) if args.background else Style(color=color_member) - styled_name = stylize(color_member.name, style=style) - if args.paged: - color_names.append(styled_name) - else: - self.poutput(styled_name) + def create_style(color: Color) -> Style: + """Create a foreground or background color Style.""" + if args.background: + return Style(bgcolor=color) + return Style(color=color) + + styled_names = [Text(color.name, style=create_style(color)) for color in Color] + output = Text("\n").join(styled_names) if args.paged: - self.ppaged('\n'.join(color_names)) + self.ppaged(output) + else: + self.poutput(output) if __name__ == '__main__': diff --git a/examples/command_sets.py b/examples/command_sets.py new file mode 100755 index 00000000..ed51c6f4 --- /dev/null +++ b/examples/command_sets.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Example revolving around the CommandSet feature for modularizing commands. + +It attempts to cover basic usage as well as more complex usage including dynamic loading and unloading of CommandSets, using +CommandSets to add subcommands, as well as how to categorize command in CommandSets. Here we have kept the implementation for +most commands trivial because the intent is to focus on the CommandSet feature set. + +The `AutoLoadCommandSet` is a basic command set which is loaded automatically at application startup and stays loaded until +application exit. Ths is the simplest case of simply modularizing command definitions to different classes and/or files. + +The `LoadableFruits` and `LoadableVegetables` CommandSets are dynamically loadable and un-loadable at runtime using the `load` +and `unload` commands. This demonstrates the ability to load and unload CommandSets based on application state. Each of these +also loads a subcommand of the `cut` command. +""" + +import argparse + +import cmd2 +from cmd2 import ( + CommandSet, + with_argparser, + with_category, + with_default_category, +) + +COMMANDSET_BASIC = "Basic CommandSet" +COMMANDSET_DYNAMIC = "Dynamic CommandSet" +COMMANDSET_LOAD_UNLOAD = "Loading and Unloading CommandSets" +COMMANDSET_SUBCOMMAND = "Subcommands with CommandSet" + + +@with_default_category(COMMANDSET_BASIC) +class AutoLoadCommandSet(CommandSet): + def __init__(self) -> None: + """CommandSet class for auto-loading commands at startup.""" + super().__init__() + + def do_hello(self, _: cmd2.Statement) -> None: + """Print hello.""" + self._cmd.poutput('Hello') + + def do_world(self, _: cmd2.Statement) -> None: + """Print World.""" + self._cmd.poutput('World') + + +@with_default_category(COMMANDSET_DYNAMIC) +class LoadableFruits(CommandSet): + def __init__(self) -> None: + """CommandSet class for dynamically loading commands related to fruits.""" + super().__init__() + + def do_apple(self, _: cmd2.Statement) -> None: + """Print Apple.""" + self._cmd.poutput('Apple') + + def do_banana(self, _: cmd2.Statement) -> None: + """Print Banana.""" + self._cmd.poutput('Banana') + + banana_description = "Cut a banana" + banana_parser = cmd2.Cmd2ArgumentParser(description=banana_description) + banana_parser.add_argument('direction', choices=['discs', 'lengthwise']) + + @cmd2.as_subcommand_to('cut', 'banana', banana_parser, help=banana_description.lower()) + def cut_banana(self, ns: argparse.Namespace) -> None: + """Cut banana.""" + self._cmd.poutput('cutting banana: ' + ns.direction) + + +@with_default_category(COMMANDSET_DYNAMIC) +class LoadableVegetables(CommandSet): + def __init__(self) -> None: + """CommandSet class for dynamically loading commands related to vegetables.""" + super().__init__() + + def do_arugula(self, _: cmd2.Statement) -> None: + "Print Arguula." + self._cmd.poutput('Arugula') + + def do_bokchoy(self, _: cmd2.Statement) -> None: + """Print Bok Choy.""" + self._cmd.poutput('Bok Choy') + + bokchoy_description = "Cut some bokchoy" + bokchoy_parser = cmd2.Cmd2ArgumentParser(description=bokchoy_description) + bokchoy_parser.add_argument('style', choices=['quartered', 'diced']) + + @cmd2.as_subcommand_to('cut', 'bokchoy', bokchoy_parser, help=bokchoy_description.lower()) + def cut_bokchoy(self, ns: argparse.Namespace) -> None: + """Cut bokchoy.""" + self._cmd.poutput('Bok Choy: ' + ns.style) + + +class CommandSetApp(cmd2.Cmd): + """CommandSets are automatically loaded. Nothing needs to be done.""" + + def __init__(self) -> None: + """Cmd2 application for demonstrating the CommandSet features.""" + # This prevents all CommandSets from auto-loading, which is necessary if you don't want some to load at startup + super().__init__(auto_load_commands=False) + + self.register_command_set(AutoLoadCommandSet()) + + # Store the dyanmic CommandSet classes for ease of loading and unloading + self._fruits = LoadableFruits() + self._vegetables = LoadableVegetables() + + self.intro = 'The CommandSet feature allows defining commands in multiple files and the dynamic load/unload at runtime' + + load_parser = cmd2.Cmd2ArgumentParser() + load_parser.add_argument('cmds', choices=['fruits', 'vegetables']) + + @with_argparser(load_parser) + @with_category(COMMANDSET_LOAD_UNLOAD) + def do_load(self, ns: argparse.Namespace) -> None: + """Load a CommandSet at runtime.""" + if ns.cmds == 'fruits': + try: + self.register_command_set(self._fruits) + self.poutput('Fruits loaded') + except ValueError: + self.poutput('Fruits already loaded') + + if ns.cmds == 'vegetables': + try: + self.register_command_set(self._vegetables) + self.poutput('Vegetables loaded') + except ValueError: + self.poutput('Vegetables already loaded') + + @with_argparser(load_parser) + @with_category(COMMANDSET_LOAD_UNLOAD) + def do_unload(self, ns: argparse.Namespace) -> None: + """Unload a CommandSet at runtime.""" + if ns.cmds == 'fruits': + self.unregister_command_set(self._fruits) + self.poutput('Fruits unloaded') + + if ns.cmds == 'vegetables': + self.unregister_command_set(self._vegetables) + self.poutput('Vegetables unloaded') + + cut_parser = cmd2.Cmd2ArgumentParser() + cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut') + + @with_argparser(cut_parser) + @with_category(COMMANDSET_SUBCOMMAND) + def do_cut(self, ns: argparse.Namespace) -> None: + """Intended to be used with dyanmically loaded subcommands specifically.""" + handler = ns.cmd2_handler.get() + if handler is not None: + handler(ns) + else: + # No subcommand was provided, so call help + self.poutput('This command does nothing without sub-parsers registered') + self.do_help('cut') + + +if __name__ == '__main__': + app = CommandSetApp() + app.cmdloop() diff --git a/examples/decorator_example.py b/examples/decorator_example.py deleted file mode 100755 index 736c729e..00000000 --- a/examples/decorator_example.py +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env python -"""A sample application showing how to use cmd2's argparse decorators to -process command line arguments for your application. - -Thanks to cmd2's built-in transcript testing capability, it also -serves as a test suite when used with the exampleSession.txt transcript. - -Running `python decorator_example.py -t exampleSession.txt` will run -all the commands in the transcript against decorator_example.py, -verifying that the output produced matches the transcript. -""" - -import argparse - -import cmd2 - - -class CmdLineApp(cmd2.Cmd): - """Example cmd2 application.""" - - def __init__(self, ip_addr=None, port=None, transcript_files=None) -> None: - shortcuts = dict(cmd2.DEFAULT_SHORTCUTS) - shortcuts.update({'&': 'speak'}) - super().__init__(transcript_files=transcript_files, multiline_commands=['orate'], shortcuts=shortcuts) - - self.maxrepeats = 3 - # Make maxrepeats settable at runtime - self.add_settable(cmd2.Settable('maxrepeats', int, 'max repetitions for speak command', self)) - - # Example of args set from the command-line (but they aren't being used here) - self._ip = ip_addr - self._port = port - - # Setting this true makes it run a shell command if a cmd2/cmd command doesn't exist - # self.default_to_shell = True # noqa: ERA001 - - speak_parser = cmd2.Cmd2ArgumentParser() - speak_parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay') - speak_parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE') - speak_parser.add_argument('-r', '--repeat', type=int, help='output [n] times') - speak_parser.add_argument('words', nargs='+', help='words to say') - - @cmd2.with_argparser(speak_parser) - def do_speak(self, args: argparse.Namespace) -> None: - """Repeats what you tell me to.""" - words = [] - for word in args.words: - if args.piglatin: - word = f'{word[1:]}{word[0]}ay' - if args.shout: - word = word.upper() - words.append(word) - repetitions = args.repeat or 1 - for _ in range(min(repetitions, self.maxrepeats)): - self.poutput(' '.join(words)) - - do_say = do_speak # now "say" is a synonym for "speak" - do_orate = do_speak # another synonym, but this one takes multi-line input - - tag_parser = cmd2.Cmd2ArgumentParser() - tag_parser.add_argument('tag', help='tag') - tag_parser.add_argument('content', nargs='+', help='content to surround with tag') - - @cmd2.with_argparser(tag_parser) - def do_tag(self, args: argparse.Namespace) -> None: - """Create an html tag.""" - # The Namespace always includes the Statement object created when parsing the command line - statement = args.cmd2_statement.get() - - self.poutput(f"The command line you ran was: {statement.command_and_args}") - self.poutput("It generated this tag:") - self.poutput('<{0}>{1}'.format(args.tag, ' '.join(args.content))) - - @cmd2.with_argument_list - def do_tagg(self, arglist: list[str]) -> None: - """Version of creating an html tag using arglist instead of argparser.""" - if len(arglist) >= 2: - tag = arglist[0] - content = arglist[1:] - self.poutput('<{0}>{1}'.format(tag, ' '.join(content))) - else: - self.perror("tagg requires at least 2 arguments") - - -if __name__ == '__main__': - import sys - - # You can do your custom Argparse parsing here to meet your application's needs - parser = cmd2.Cmd2ArgumentParser(description='Process the arguments however you like.') - - # Add a few arguments which aren't really used, but just to get the gist - parser.add_argument('-p', '--port', type=int, help='TCP port') - parser.add_argument('-i', '--ip', type=str, help='IPv4 address') - - # Add an argument which enables transcript testing - args, unknown_args = parser.parse_known_args() - - port = None - if args.port: - port = args.port - - ip_addr = None - if args.ip: - ip_addr = args.ip - - # Perform surgery on sys.argv to remove the arguments which have already been processed by argparse - sys.argv = sys.argv[:1] + unknown_args - - # Instantiate your cmd2 application - c = CmdLineApp() - - # And run your cmd2 application - sys.exit(c.cmdloop()) diff --git a/examples/first_app.py b/examples/first_app.py deleted file mode 100755 index c82768a3..00000000 --- a/examples/first_app.py +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env python -"""A simple application using cmd2 which demonstrates 8 key features: - -* Settings -* Commands -* Argument Parsing -* Generating Output -* Help -* Shortcuts -* Multiline Commands -* History -""" - -import cmd2 - - -class FirstApp(cmd2.Cmd): - """A simple cmd2 application.""" - - def __init__(self) -> None: - shortcuts = cmd2.DEFAULT_SHORTCUTS - shortcuts.update({'&': 'speak'}) - super().__init__(multiline_commands=['orate'], shortcuts=shortcuts) - - # Make maxrepeats settable at runtime - self.maxrepeats = 3 - self.add_settable(cmd2.Settable('maxrepeats', int, 'max repetitions for speak command', self)) - - speak_parser = cmd2.Cmd2ArgumentParser() - speak_parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay') - speak_parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE') - speak_parser.add_argument('-r', '--repeat', type=int, help='output [n] times') - speak_parser.add_argument('words', nargs='+', help='words to say') - - @cmd2.with_argparser(speak_parser) - def do_speak(self, args) -> None: - """Repeats what you tell me to.""" - words = [] - for word in args.words: - if args.piglatin: - word = f'{word[1:]}{word[0]}ay' - if args.shout: - word = word.upper() - words.append(word) - repetitions = args.repeat or 1 - for _ in range(min(repetitions, self.maxrepeats)): - # .poutput handles newlines, and accommodates output redirection too - self.poutput(' '.join(words)) - - # orate is a synonym for speak which takes multiline input - do_orate = do_speak - - -if __name__ == '__main__': - import sys - - c = FirstApp() - sys.exit(c.cmdloop()) diff --git a/examples/initialization.py b/examples/getting_started.py similarity index 52% rename from examples/initialization.py rename to examples/getting_started.py index 22de3ff2..19c57aa0 100755 --- a/examples/initialization.py +++ b/examples/getting_started.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 -"""A simple example cmd2 application demonstrating the following: +"""A simple example cmd2 application demonstrating many common features. + +Features demonstrated include all of the following: 1) Colorizing/stylizing output 2) Using multiline commands 3) Persistent history @@ -10,29 +12,46 @@ 8) Displaying an intro banner upon starting your application 9) Using a custom prompt 10) How to make custom attributes settable at runtime. +11) Shortcuts for commands """ +import pathlib + +from rich.style import Style + import cmd2 from cmd2 import ( - Bg, - Fg, - style, + Color, + stylize, ) class BasicApp(cmd2.Cmd): + """Cmd2 application to demonstrate many common features.""" + CUSTOM_CATEGORY = 'My Custom Commands' def __init__(self) -> None: + """Initialize the cmd2 application.""" + # Startup script that defines a couple aliases for running shell commands + alias_script = pathlib.Path(__file__).absolute().parent / '.cmd2rc' + + # Create a shortcut for one of our commands + shortcuts = cmd2.DEFAULT_SHORTCUTS + shortcuts.update({'&': 'intro'}) super().__init__( + include_ipy=True, multiline_commands=['echo'], persistent_history_file='cmd2_history.dat', - startup_script='scripts/startup.txt', - include_ipy=True, + shortcuts=shortcuts, + startup_script=str(alias_script), ) # Prints an intro banner once upon application startup - self.intro = style('Welcome to cmd2!', fg=Fg.RED, bg=Bg.WHITE, bold=True) + self.intro = stylize( + 'Welcome to cmd2!', + style=Style(color=Color.RED, bgcolor=Color.WHITE, bold=True), + ) # Show this as the prompt when asking for input self.prompt = 'myapp> ' @@ -47,24 +66,34 @@ def __init__(self) -> None: self.default_category = 'cmd2 Built-in Commands' # Color to output text in with echo command - self.foreground_color = Fg.CYAN.name.lower() + self.foreground_color = Color.CYAN.value # Make echo_fg settable at runtime - fg_colors = [c.name.lower() for c in Fg] + fg_colors = [c.value for c in Color] self.add_settable( - cmd2.Settable('foreground_color', str, 'Foreground color to use with echo command', self, choices=fg_colors) + cmd2.Settable( + 'foreground_color', + str, + 'Foreground color to use with echo command', + self, + choices=fg_colors, + ) ) @cmd2.with_category(CUSTOM_CATEGORY) - def do_intro(self, _) -> None: + def do_intro(self, _: cmd2.Statement) -> None: """Display the intro banner.""" self.poutput(self.intro) @cmd2.with_category(CUSTOM_CATEGORY) - def do_echo(self, arg) -> None: - """Example of a multiline command.""" - fg_color = Fg[self.foreground_color.upper()] - self.poutput(style(arg, fg=fg_color)) + def do_echo(self, arg: cmd2.Statement) -> None: + """Multiline command.""" + self.poutput( + stylize( + arg, + style=Style(color=self.foreground_color), + ) + ) if __name__ == '__main__': diff --git a/examples/modular_commands_main.py b/examples/modular_commands.py similarity index 89% rename from examples/modular_commands_main.py rename to examples/modular_commands.py index 2fba205e..582d1605 100755 --- a/examples/modular_commands_main.py +++ b/examples/modular_commands.py @@ -1,6 +1,7 @@ #!/usr/bin/env python -"""A complex example demonstrating a variety of methods to load CommandSets using a mix of command decorators -with examples of how to integrate tab completion with argparse-based commands. +"""A complex example demonstrating a variety of methods to load CommandSets using a mix of command decorators. + +Includes examples of how to integrate tab completion with argparse-based commands. """ import argparse @@ -26,6 +27,7 @@ class WithCommandSets(Cmd): def __init__(self, command_sets: Iterable[CommandSet] | None = None) -> None: + """Cmd2 application to demonstrate a variety of methods for loading CommandSets.""" super().__init__(command_sets=command_sets) self.sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball'] @@ -54,7 +56,7 @@ def choices_provider(self) -> list[str]: @with_argparser(example_parser) def do_example(self, _: argparse.Namespace) -> None: - """The example command.""" + """An example command.""" self.poutput("I do nothing") diff --git a/examples/modular_commands_basic.py b/examples/modular_commands_basic.py deleted file mode 100755 index c681a389..00000000 --- a/examples/modular_commands_basic.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python3 -"""Simple example demonstrating basic CommandSet usage.""" - -import cmd2 -from cmd2 import ( - CommandSet, - with_default_category, -) - - -@with_default_category('My Category') -class AutoLoadCommandSet(CommandSet): - def __init__(self) -> None: - super().__init__() - - def do_hello(self, _: cmd2.Statement) -> None: - self._cmd.poutput('Hello') - - def do_world(self, _: cmd2.Statement) -> None: - self._cmd.poutput('World') - - -class ExampleApp(cmd2.Cmd): - """CommandSets are automatically loaded. Nothing needs to be done.""" - - def __init__(self) -> None: - super().__init__() - - def do_something(self, _arg) -> None: - self.poutput('this is the something command') - - -if __name__ == '__main__': - app = ExampleApp() - app.cmdloop() diff --git a/examples/modular_commands_dynamic.py b/examples/modular_commands_dynamic.py deleted file mode 100755 index 163c9dc8..00000000 --- a/examples/modular_commands_dynamic.py +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env python3 -"""Simple example demonstrating dynamic CommandSet loading and unloading. - -There are 2 CommandSets defined. ExampleApp sets the `auto_load_commands` flag to false. - -The `load` and `unload` commands will load and unload the CommandSets. The available commands will change depending -on which CommandSets are loaded -""" - -import argparse - -import cmd2 -from cmd2 import ( - CommandSet, - with_argparser, - with_category, - with_default_category, -) - - -@with_default_category('Fruits') -class LoadableFruits(CommandSet): - def __init__(self) -> None: - super().__init__() - - def do_apple(self, _: cmd2.Statement) -> None: - self._cmd.poutput('Apple') - - def do_banana(self, _: cmd2.Statement) -> None: - self._cmd.poutput('Banana') - - -@with_default_category('Vegetables') -class LoadableVegetables(CommandSet): - def __init__(self) -> None: - super().__init__() - - def do_arugula(self, _: cmd2.Statement) -> None: - self._cmd.poutput('Arugula') - - def do_bokchoy(self, _: cmd2.Statement) -> None: - self._cmd.poutput('Bok Choy') - - -class ExampleApp(cmd2.Cmd): - """CommandSets are loaded via the `load` and `unload` commands.""" - - def __init__(self, *args, **kwargs) -> None: - # gotta have this or neither the plugin or cmd2 will initialize - super().__init__(*args, auto_load_commands=False, **kwargs) - - self._fruits = LoadableFruits() - self._vegetables = LoadableVegetables() - - load_parser = cmd2.Cmd2ArgumentParser() - load_parser.add_argument('cmds', choices=['fruits', 'vegetables']) - - @with_argparser(load_parser) - @with_category('Command Loading') - def do_load(self, ns: argparse.Namespace) -> None: - if ns.cmds == 'fruits': - try: - self.register_command_set(self._fruits) - self.poutput('Fruits loaded') - except ValueError: - self.poutput('Fruits already loaded') - - if ns.cmds == 'vegetables': - try: - self.register_command_set(self._vegetables) - self.poutput('Vegetables loaded') - except ValueError: - self.poutput('Vegetables already loaded') - - @with_argparser(load_parser) - def do_unload(self, ns: argparse.Namespace) -> None: - if ns.cmds == 'fruits': - self.unregister_command_set(self._fruits) - self.poutput('Fruits unloaded') - - if ns.cmds == 'vegetables': - self.unregister_command_set(self._vegetables) - self.poutput('Vegetables unloaded') - - -if __name__ == '__main__': - app = ExampleApp() - app.cmdloop() diff --git a/examples/modular_subcommands.py b/examples/modular_subcommands.py deleted file mode 100755 index f1dbd024..00000000 --- a/examples/modular_subcommands.py +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env python3 -"""A simple example demonstrating modular subcommand loading through CommandSets. - -In this example, there are loadable CommandSets defined. Each CommandSet has 1 subcommand defined that will be -attached to the 'cut' command. - -The cut command is implemented with the `do_cut` function that has been tagged as an argparse command. - -The `load` and `unload` command will load and unload the CommandSets. The available top level commands as well as -subcommands to the `cut` command will change depending on which CommandSets are loaded. -""" - -import argparse - -import cmd2 -from cmd2 import ( - CommandSet, - with_argparser, - with_category, - with_default_category, -) - - -@with_default_category('Fruits') -class LoadableFruits(CommandSet): - def __init__(self) -> None: - super().__init__() - - def do_apple(self, _: cmd2.Statement) -> None: - self._cmd.poutput('Apple') - - banana_description = "Cut a banana" - banana_parser = cmd2.Cmd2ArgumentParser(description=banana_description) - banana_parser.add_argument('direction', choices=['discs', 'lengthwise']) - - @cmd2.as_subcommand_to('cut', 'banana', banana_parser, help=banana_description.lower()) - def cut_banana(self, ns: argparse.Namespace) -> None: - """Cut banana.""" - self._cmd.poutput('cutting banana: ' + ns.direction) - - -@with_default_category('Vegetables') -class LoadableVegetables(CommandSet): - def __init__(self) -> None: - super().__init__() - - def do_arugula(self, _: cmd2.Statement) -> None: - self._cmd.poutput('Arugula') - - bokchoy_description = "Cut some bokchoy" - bokchoy_parser = cmd2.Cmd2ArgumentParser(description=bokchoy_description) - bokchoy_parser.add_argument('style', choices=['quartered', 'diced']) - - @cmd2.as_subcommand_to('cut', 'bokchoy', bokchoy_parser, help=bokchoy_description.lower()) - def cut_bokchoy(self, _: argparse.Namespace) -> None: - self._cmd.poutput('Bok Choy') - - -class ExampleApp(cmd2.Cmd): - """CommandSets are automatically loaded. Nothing needs to be done.""" - - def __init__(self, *args, **kwargs) -> None: - # gotta have this or neither the plugin or cmd2 will initialize - super().__init__(*args, auto_load_commands=False, **kwargs) - - self._fruits = LoadableFruits() - self._vegetables = LoadableVegetables() - - load_parser = cmd2.Cmd2ArgumentParser() - load_parser.add_argument('cmds', choices=['fruits', 'vegetables']) - - @with_argparser(load_parser) - @with_category('Command Loading') - def do_load(self, ns: argparse.Namespace) -> None: - if ns.cmds == 'fruits': - try: - self.register_command_set(self._fruits) - self.poutput('Fruits loaded') - except ValueError: - self.poutput('Fruits already loaded') - - if ns.cmds == 'vegetables': - try: - self.register_command_set(self._vegetables) - self.poutput('Vegetables loaded') - except ValueError: - self.poutput('Vegetables already loaded') - - @with_argparser(load_parser) - def do_unload(self, ns: argparse.Namespace) -> None: - if ns.cmds == 'fruits': - self.unregister_command_set(self._fruits) - self.poutput('Fruits unloaded') - - if ns.cmds == 'vegetables': - self.unregister_command_set(self._vegetables) - self.poutput('Vegetables unloaded') - - cut_parser = cmd2.Cmd2ArgumentParser() - cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut') - - @with_argparser(cut_parser) - def do_cut(self, ns: argparse.Namespace) -> None: - # Call handler for whatever subcommand was selected - handler = ns.cmd2_handler.get() - if handler is not None: - handler(ns) - else: - # No subcommand was provided, so call help - self.poutput('This command does nothing without sub-parsers registered') - self.do_help('cut') - - -if __name__ == '__main__': - app = ExampleApp() - app.cmdloop() diff --git a/examples/pirate.py b/examples/pirate.py deleted file mode 100755 index b15dae4f..00000000 --- a/examples/pirate.py +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env python -"""This example is adapted from the pirate8.py example created by Catherine Devlin and -presented as part of her PyCon 2010 talk. - -It demonstrates many features of cmd2. -""" - -import cmd2 -from cmd2 import ( - Fg, -) -from cmd2.constants import ( - MULTILINE_TERMINATOR, -) - -color_choices = [c.name.lower() for c in Fg] - - -class Pirate(cmd2.Cmd): - """A piratical example cmd2 application involving looting and drinking.""" - - def __init__(self) -> None: - """Initialize the base class as well as this one.""" - shortcuts = dict(cmd2.DEFAULT_SHORTCUTS) - shortcuts.update({'~': 'sing'}) - super().__init__(multiline_commands=['sing'], terminators=[MULTILINE_TERMINATOR, '...'], shortcuts=shortcuts) - - self.default_to_shell = True - self.songcolor = 'blue' - - # Make songcolor settable at runtime - self.add_settable(cmd2.Settable('songcolor', str, 'Color to ``sing``', self, choices=color_choices)) - - # prompts and defaults - self.gold = 0 - self.initial_gold = self.gold - self.prompt = 'arrr> ' - - def precmd(self, line): - """Runs just before a command line is parsed, but after the prompt is presented.""" - self.initial_gold = self.gold - return line - - def postcmd(self, stop, _line): - """Runs right before a command is about to return.""" - if self.gold != self.initial_gold: - self.poutput(f'Now we gots {self.gold} doubloons') - if self.gold < 0: - self.poutput("Off to debtorrr's prison.") - self.exit_code = 1 - stop = True - return stop - - def do_loot(self, _arg) -> None: - """Seize booty from a passing ship.""" - self.gold += 1 - - def do_drink(self, arg) -> None: - """Drown your sorrrows in rrrum. - - drink [n] - drink [n] barrel[s] o' rum. - """ - try: - self.gold -= int(arg) - except ValueError: - if arg: - self.poutput(f'''What's "{arg}"? I'll take rrrum.''') - self.gold -= 1 - - def do_quit(self, _arg) -> bool: - """Quit the application gracefully.""" - self.poutput("Quiterrr!") - return True - - def do_sing(self, arg) -> None: - """Sing a colorful song.""" - self.poutput(cmd2.ansi.style(arg, fg=Fg[self.songcolor.upper()])) - - yo_parser = cmd2.Cmd2ArgumentParser() - yo_parser.add_argument('--ho', type=int, default=2, help="How often to chant 'ho'") - yo_parser.add_argument('-c', '--commas', action='store_true', help='Intersperse commas') - yo_parser.add_argument('beverage', help='beverage to drink with the chant') - - @cmd2.with_argparser(yo_parser) - def do_yo(self, args) -> None: - """Compose a yo-ho-ho type chant with flexible options.""" - chant = ['yo'] + ['ho'] * args.ho - separator = ', ' if args.commas else ' ' - chant = separator.join(chant) - self.poutput(f'{chant} and a bottle of {args.beverage}') - - -if __name__ == '__main__': - import sys - - # Create an instance of the Pirate derived class and enter the REPL with cmdloop(). - pirate = Pirate() - sys_exit_code = pirate.cmdloop() - print(f'Exiting with code: {sys_exit_code!r}') - sys.exit(sys_exit_code) diff --git a/examples/python_scripting.py b/examples/python_scripting.py index 393e31fd..0e5c6fc6 100755 --- a/examples/python_scripting.py +++ b/examples/python_scripting.py @@ -20,10 +20,14 @@ example for one way in which this can be done. """ +import argparse import os import cmd2 -from cmd2 import ansi +from cmd2 import ( + Color, + stylize, +) class CmdLineApp(cmd2.Cmd): @@ -38,7 +42,7 @@ def __init__(self) -> None: def _set_prompt(self) -> None: """Set prompt so it displays the current working directory.""" self.cwd = os.getcwd() - self.prompt = ansi.style(f'{self.cwd} $ ', fg=ansi.Fg.CYAN) + self.prompt = stylize(f'{self.cwd} $ ', style=Color.CYAN) def postcmd(self, stop: bool, _line: str) -> bool: """Hook method executed just after a command dispatch is finished. @@ -52,7 +56,7 @@ def postcmd(self, stop: bool, _line: str) -> bool: return stop @cmd2.with_argument_list - def do_cd(self, arglist) -> None: + def do_cd(self, arglist: list[str]) -> None: """Change directory. Usage: cd . @@ -88,7 +92,7 @@ def do_cd(self, arglist) -> None: self.last_result = data # Enable tab completion for cd command - def complete_cd(self, text, line, begidx, endidx): + def complete_cd(self, text: str, line: str, begidx: int, endidx: int) -> list[str]: # Tab complete only directories return self.path_complete(text, line, begidx, endidx, path_filter=os.path.isdir) @@ -96,7 +100,7 @@ def complete_cd(self, text, line, begidx, endidx): dir_parser.add_argument('-l', '--long', action='store_true', help="display in long format with one item per line") @cmd2.with_argparser(dir_parser, with_unknown_args=True) - def do_dir(self, _args, unknown) -> None: + def do_dir(self, _args: argparse.Namespace, unknown: list[str]) -> None: """List contents of current directory.""" # No arguments for this command if unknown: diff --git a/examples/subcommands.py b/examples/subcommands.py deleted file mode 100755 index b2768cff..00000000 --- a/examples/subcommands.py +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env python3 -"""A simple example demonstrating how to use Argparse to support subcommands. - -This example shows an easy way for a single command to have many subcommands, each of which takes different arguments -and provides separate contextual help. -""" - -import cmd2 - -sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball'] - -# create the top-level parser for the base command -base_parser = cmd2.Cmd2ArgumentParser() -base_subparsers = base_parser.add_subparsers(title='subcommands', help='subcommand help') - -# create the parser for the "foo" subcommand -parser_foo = base_subparsers.add_parser('foo', help='foo help') -parser_foo.add_argument('-x', type=int, default=1, help='integer') -parser_foo.add_argument('y', type=float, help='float') -parser_foo.add_argument('input_file', type=str, help='Input File') - -# create the parser for the "bar" subcommand -parser_bar = base_subparsers.add_parser('bar', help='bar help') - -bar_subparsers = parser_bar.add_subparsers(title='layer3', help='help for 3rd layer of commands') -parser_bar.add_argument('z', help='string') - -bar_subparsers.add_parser('apple', help='apple help') -bar_subparsers.add_parser('artichoke', help='artichoke help') -bar_subparsers.add_parser('cranberries', help='cranberries help') - -# create the parser for the "sport" subcommand -parser_sport = base_subparsers.add_parser('sport', help='sport help') -sport_arg = parser_sport.add_argument('sport', help='Enter name of a sport', choices=sport_item_strs) - - -# create the top-level parser for the alternate command -# The alternate command doesn't provide its own help flag -base2_parser = cmd2.Cmd2ArgumentParser(add_help=False) -base2_subparsers = base2_parser.add_subparsers(title='subcommands', help='subcommand help') - -# create the parser for the "foo" subcommand -parser_foo2 = base2_subparsers.add_parser('foo', help='foo help') -parser_foo2.add_argument('-x', type=int, default=1, help='integer') -parser_foo2.add_argument('y', type=float, help='float') -parser_foo2.add_argument('input_file', type=str, help='Input File') - -# create the parser for the "bar" subcommand -parser_bar2 = base2_subparsers.add_parser('bar', help='bar help') - -bar2_subparsers = parser_bar2.add_subparsers(title='layer3', help='help for 3rd layer of commands') -parser_bar2.add_argument('z', help='string') - -bar2_subparsers.add_parser('apple', help='apple help') -bar2_subparsers.add_parser('artichoke', help='artichoke help') -bar2_subparsers.add_parser('cranberries', help='cranberries help') - -# create the parser for the "sport" subcommand -parser_sport2 = base2_subparsers.add_parser('sport', help='sport help') -sport2_arg = parser_sport2.add_argument('sport', help='Enter name of a sport', choices=sport_item_strs) - - -class SubcommandsExample(cmd2.Cmd): - """Example cmd2 application where we a base command which has a couple subcommands - and the "sport" subcommand has tab completion enabled. - """ - - def __init__(self) -> None: - super().__init__() - - # subcommand functions for the base command - def base_foo(self, args) -> None: - """Foo subcommand of base command.""" - self.poutput(args.x * args.y) - - def base_bar(self, args) -> None: - """Bar subcommand of base command.""" - self.poutput(f'(({args.z}))') - - def base_sport(self, args) -> None: - """Sport subcommand of base command.""" - self.poutput(f'Sport is {args.sport}') - - # Set handler functions for the subcommands - parser_foo.set_defaults(func=base_foo) - parser_bar.set_defaults(func=base_bar) - parser_sport.set_defaults(func=base_sport) - - @cmd2.with_argparser(base_parser) - def do_base(self, args) -> None: - """Base command help.""" - func = getattr(args, 'func', None) - if func is not None: - # Call whatever subcommand function was selected - func(self, args) - else: - # No subcommand was provided, so call help - self.do_help('base') - - @cmd2.with_argparser(base2_parser) - def do_alternate(self, args) -> None: - """Alternate command help.""" - func = getattr(args, 'func', None) - if func is not None: - # Call whatever subcommand function was selected - func(self, args) - else: - # No subcommand was provided, so call help - self.do_help('alternate') - - -if __name__ == '__main__': - import sys - - app = SubcommandsExample() - sys.exit(app.cmdloop()) diff --git a/examples/example.py b/examples/transcript_example.py similarity index 91% rename from examples/example.py rename to examples/transcript_example.py index 20918152..06b06c2d 100755 --- a/examples/example.py +++ b/examples/transcript_example.py @@ -2,10 +2,10 @@ """A sample application for cmd2. Thanks to cmd2's built-in transcript testing capability, it also serves as a -test suite for example.py when used with the transcript_regex.txt transcript. +test suite for transcript_example.py when used with the transcript_regex.txt transcript. -Running `python example.py -t transcript_regex.txt` will run all the commands in -the transcript against example.py, verifying that the output produced matches +Running `python transcript_example.py -t transcript_regex.txt` will run all the commands in +the transcript against transcript_example.py, verifying that the output produced matches the transcript. """ diff --git a/examples/transcripts/exampleSession.txt b/examples/transcripts/exampleSession.txt index 85b985d3..84ff1e3f 100644 --- a/examples/transcripts/exampleSession.txt +++ b/examples/transcripts/exampleSession.txt @@ -1,4 +1,4 @@ -# Run this transcript with "python decorator_example.py -t exampleSession.txt" +# Run this transcript with "python transcript_example.py -t exampleSession.txt" # Anything between two forward slashes, /, is interpreted as a regular expression (regex). # The regex for editor will match whatever program you use. # regexes on prompts just make the trailing space obvious diff --git a/examples/transcripts/transcript_regex.txt b/examples/transcripts/transcript_regex.txt index 3065aae5..1eef1427 100644 --- a/examples/transcripts/transcript_regex.txt +++ b/examples/transcripts/transcript_regex.txt @@ -1,4 +1,4 @@ -# Run this transcript with "python example.py -t transcript_regex.txt" +# Run this transcript with "python transcript_example.py -t transcript_regex.txt" # Anything between two forward slashes, /, is interpreted as a regular expression (regex). # The regex for editor will match whatever program you use. # regexes on prompts just make the trailing space obvious diff --git a/mkdocs.yml b/mkdocs.yml index be5275a2..df42da4a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -189,7 +189,7 @@ nav: - features/transcripts.md - Examples: - examples/index.md - - examples/first_app.md + - examples/getting_started.md - examples/alternate_event_loops.md - examples/examples.md - Plugins: diff --git a/tests/test_completion.py b/tests/test_completion.py index 95e0f314..f98e3b96 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -14,7 +14,6 @@ import cmd2 from cmd2 import utils -from examples.subcommands import SubcommandsExample from .conftest import ( complete_tester, @@ -22,6 +21,107 @@ run_cmd, ) + +class SubcommandsExample(cmd2.Cmd): + """Example cmd2 application where we a base command which has a couple subcommands + and the "sport" subcommand has tab completion enabled. + """ + + sport_item_strs = ('Bat', 'Basket', 'Basketball', 'Football', 'Space Ball') + + # create the top-level parser for the base command + base_parser = cmd2.Cmd2ArgumentParser() + base_subparsers = base_parser.add_subparsers(title='subcommands', help='subcommand help') + + # create the parser for the "foo" subcommand + parser_foo = base_subparsers.add_parser('foo', help='foo help') + parser_foo.add_argument('-x', type=int, default=1, help='integer') + parser_foo.add_argument('y', type=float, help='float') + parser_foo.add_argument('input_file', type=str, help='Input File') + + # create the parser for the "bar" subcommand + parser_bar = base_subparsers.add_parser('bar', help='bar help') + + bar_subparsers = parser_bar.add_subparsers(title='layer3', help='help for 3rd layer of commands') + parser_bar.add_argument('z', help='string') + + bar_subparsers.add_parser('apple', help='apple help') + bar_subparsers.add_parser('artichoke', help='artichoke help') + bar_subparsers.add_parser('cranberries', help='cranberries help') + + # create the parser for the "sport" subcommand + parser_sport = base_subparsers.add_parser('sport', help='sport help') + sport_arg = parser_sport.add_argument('sport', help='Enter name of a sport', choices=sport_item_strs) + + # create the top-level parser for the alternate command + # The alternate command doesn't provide its own help flag + base2_parser = cmd2.Cmd2ArgumentParser(add_help=False) + base2_subparsers = base2_parser.add_subparsers(title='subcommands', help='subcommand help') + + # create the parser for the "foo" subcommand + parser_foo2 = base2_subparsers.add_parser('foo', help='foo help') + parser_foo2.add_argument('-x', type=int, default=1, help='integer') + parser_foo2.add_argument('y', type=float, help='float') + parser_foo2.add_argument('input_file', type=str, help='Input File') + + # create the parser for the "bar" subcommand + parser_bar2 = base2_subparsers.add_parser('bar', help='bar help') + + bar2_subparsers = parser_bar2.add_subparsers(title='layer3', help='help for 3rd layer of commands') + parser_bar2.add_argument('z', help='string') + + bar2_subparsers.add_parser('apple', help='apple help') + bar2_subparsers.add_parser('artichoke', help='artichoke help') + bar2_subparsers.add_parser('cranberries', help='cranberries help') + + # create the parser for the "sport" subcommand + parser_sport2 = base2_subparsers.add_parser('sport', help='sport help') + sport2_arg = parser_sport2.add_argument('sport', help='Enter name of a sport', choices=sport_item_strs) + + def __init__(self) -> None: + super().__init__() + + # subcommand functions for the base command + def base_foo(self, args) -> None: + """Foo subcommand of base command.""" + self.poutput(args.x * args.y) + + def base_bar(self, args) -> None: + """Bar subcommand of base command.""" + self.poutput(f'(({args.z}))') + + def base_sport(self, args) -> None: + """Sport subcommand of base command.""" + self.poutput(f'Sport is {args.sport}') + + # Set handler functions for the subcommands + parser_foo.set_defaults(func=base_foo) + parser_bar.set_defaults(func=base_bar) + parser_sport.set_defaults(func=base_sport) + + @cmd2.with_argparser(base_parser) + def do_base(self, args) -> None: + """Base command help.""" + func = getattr(args, 'func', None) + if func is not None: + # Call whatever subcommand function was selected + func(self, args) + else: + # No subcommand was provided, so call help + self.do_help('base') + + @cmd2.with_argparser(base2_parser) + def do_alternate(self, args) -> None: + """Alternate command help.""" + func = getattr(args, 'func', None) + if func is not None: + # Call whatever subcommand function was selected + func(self, args) + else: + # No subcommand was provided, so call help + self.do_help('alternate') + + # List of strings used with completion functions food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato', 'Cheese "Pizza"'] sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball'] From 1771308cab19cb6f1f25ff3e04416c4347b0c5af Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sat, 23 Aug 2025 19:23:25 -0400 Subject: [PATCH 45/89] Change intro colors for getting_started.py example to something higher contrast --- examples/getting_started.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/getting_started.py b/examples/getting_started.py index 19c57aa0..b9a7e5d3 100755 --- a/examples/getting_started.py +++ b/examples/getting_started.py @@ -50,7 +50,7 @@ def __init__(self) -> None: # Prints an intro banner once upon application startup self.intro = stylize( 'Welcome to cmd2!', - style=Style(color=Color.RED, bgcolor=Color.WHITE, bold=True), + style=Style(color=Color.GREEN1, bgcolor=Color.GRAY0, bold=True), ) # Show this as the prompt when asking for input From e7b017e79e188c4a575cedb377136cf044640b80 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sat, 23 Aug 2025 19:32:19 -0400 Subject: [PATCH 46/89] Improve getting_started.py intro banner by including unicode emojis to make it very clear we support that --- examples/getting_started.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/examples/getting_started.py b/examples/getting_started.py index b9a7e5d3..43e9a904 100755 --- a/examples/getting_started.py +++ b/examples/getting_started.py @@ -48,9 +48,12 @@ def __init__(self) -> None: ) # Prints an intro banner once upon application startup - self.intro = stylize( - 'Welcome to cmd2!', - style=Style(color=Color.GREEN1, bgcolor=Color.GRAY0, bold=True), + self.intro = ( + stylize( + 'Welcome to cmd2!', + style=Style(color=Color.GREEN1, bgcolor=Color.GRAY0, bold=True), + ) + + ' Note the full Unicode support: 😇 💩' ) # Show this as the prompt when asking for input From 7fe289abb253a878847dd1ada4e34f122e85d2b0 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 23 Aug 2025 21:11:33 -0400 Subject: [PATCH 47/89] Removed bottom border from verbose help table. --- cmd2/cmd2.py | 4 +++- cmd2/rich_utils.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index b213b971..3882d4d6 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -4135,11 +4135,13 @@ def print_topics(self, header: str, cmds: list[str] | None, cmdlen: int, maxcol: if not cmds: return + # Add a row that looks like a table header. header_grid = Table.grid() header_grid.add_row(header, style=Cmd2Style.HELP_HEADER) header_grid.add_row(Rule(characters=self.ruler, style=Cmd2Style.TABLE_BORDER)) self.poutput(Padding.indent(header_grid, 1)) + # Print the topics in columns. # Subtract 1 from maxcol to account for indentation. maxcol = min(maxcol, ru.console_width()) - 1 columnized_cmds = self.render_columns(cmds, maxcol) @@ -4166,7 +4168,7 @@ def _print_documented_command_topics(self, header: str, cmds: list[str], verbose topics_table = Table( Column("Name", no_wrap=True), Column("Description", overflow="fold"), - box=rich.box.HORIZONTALS, + box=ru.TOP_AND_HEAD, border_style=Cmd2Style.TABLE_BORDER, ) diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index a47ed8dd..2c08c6da 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -8,6 +8,7 @@ TypedDict, ) +from rich.box import Box from rich.console import ( Console, ConsoleRenderable, @@ -105,6 +106,19 @@ class RichPrintKwargs(TypedDict, total=False): new_line_start: bool +# Custom Rich Box for tables which has a top border and a head row separator. +TOP_AND_HEAD: Box = Box( + " ── \n" # top + " \n" + " ── \n" # head_row + " \n" + " \n" + " \n" + " \n" + " \n" +) + + class Cmd2BaseConsole(Console): """Base class for all cmd2 Rich consoles. From 5b5f80e85bc7468cc0c4106d661c4c0fb71c3812 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sat, 23 Aug 2025 21:32:12 -0400 Subject: [PATCH 48/89] Delete atom from list of editors since it was deprecated in Dec 2022 --- cmd2/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd2/utils.py b/cmd2/utils.py index 68508443..b94e657c 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -306,7 +306,7 @@ def find_editor() -> str | None: if sys.platform[:3] == 'win': editors = ['code.cmd', 'notepad++.exe', 'notepad.exe'] else: - editors = ['vim', 'vi', 'emacs', 'nano', 'pico', 'joe', 'code', 'subl', 'atom', 'gedit', 'geany', 'kate'] + editors = ['vim', 'vi', 'emacs', 'nano', 'pico', 'joe', 'code', 'subl', 'gedit', 'kate'] # Get a list of every directory in the PATH environment variable and ignore symbolic links env_path = os.getenv('PATH') From 91e5bc29be3affe99ad1b3af8af5939a8a779cae Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sat, 23 Aug 2025 21:35:44 -0400 Subject: [PATCH 49/89] Prefer new MS Edit command-line text editor on Windows --- cmd2/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd2/utils.py b/cmd2/utils.py index b94e657c..12ba3c8d 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -304,7 +304,7 @@ def find_editor() -> str | None: editor = os.environ.get('EDITOR') if not editor: if sys.platform[:3] == 'win': - editors = ['code.cmd', 'notepad++.exe', 'notepad.exe'] + editors = ['edit', 'code.cmd', 'notepad++.exe', 'notepad.exe'] else: editors = ['vim', 'vi', 'emacs', 'nano', 'pico', 'joe', 'code', 'subl', 'gedit', 'kate'] From 2f55890c8be28e731ac726a2a0f4a348f9fa9a7c Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sun, 24 Aug 2025 21:55:29 -0400 Subject: [PATCH 50/89] Rich table example (#1488) Created simple example of using rich tables within a cmd2 application. --- examples/rich_tables.py | 123 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100755 examples/rich_tables.py diff --git a/examples/rich_tables.py b/examples/rich_tables.py new file mode 100755 index 00000000..0d4a0900 --- /dev/null +++ b/examples/rich_tables.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""An example of using Rich Tables within a cmd2 application for displaying tabular data. + +While you can use any Python library for displaying tabular data within a cmd2 application, +we recommend using rich since that is built into cmd2. + +Data comes from World Population Review: https://worldpopulationreview.com/ +and https://en.wikipedia.org/wiki/List_of_countries_by_GDP_(nominal) +""" + +from rich.table import Table + +import cmd2 +from cmd2.colors import Color + +CITY_HEADERS = ['Flag', 'City', 'Country', '2025 Population'] +CITY_DATA = [ + ["🇯🇵", "Tokyo (東京)", "Japan", 37_036_200], + ["🇮🇳", "Delhi", "India", 34_665_600], + ["🇨🇳", "Shanghai (上海)", "China", 30_482_100], + ["🇧🇩", "Dhaka", "Bangladesh", 24_652_900], + ["🇪🇬", "Cairo (القاهرة)", "Egypt", 23_074_200], + ["🇪🇬", "São Paulo", "Brazil", 22_990_000], + ["🇲🇽", "Mexico City", "Mexico", 22_752_400], + ["🇨🇳", "Beijing (北京)", "China", 22_596_500], + ["🇮🇳", "Mumbai", "India", 22_089_000], + ["🇯🇵", "Osaka (大阪)", "Japan", 18_921_600], +] +CITY_TITLE = "10 Largest Cities by Population 2025" +CITY_CAPTION = "Data from https://worldpopulationreview.com/" + +COUNTRY_HEADERS = [ + 'Flag', + 'Country', + '2025 Population', + 'Area (M km^2)', + 'Population Density (/km^2)', + 'GDP (million US$)', + 'GDP per capita (US$)', +] +COUNTRY_DATA = [ + ["🇮🇳", "India", 1_463_870_000, 3.3, 492, 4_187_017, 2_878], + ["🇨🇳", "China (中国)", 1_416_100_000, 9.7, 150, 19_231_705, 13_687], + ["🇺🇸", "United States", 347_276_000, 9.4, 38, 30_507_217, 89_105], + ["🇮🇩", "Indonesia", 285_721_000, 1.9, 152, 1_429_743, 5_027], + ["🇵🇰", "Pakistan", 255_220_000, 0.9, 331, 373_072, 1_484], + ["🇳🇬", "Nigeria", 237_528_000, 0.9, 261, 188_271, 807], + ["🇧🇷", "Brazil", 212_812_000, 8.5, 25, 2_125_958, 9_964], + ["🇧🇩", "Bangladesh", 175_687_000, 0.1, 1_350, 467_218, 2_689], + ["🇷🇺", "Russia (россия)", 143_997_000, 17.1, 9, 2_076_396, 14_258], + ["🇪🇹", "Ethiopia (እትዮጵያ)", 135_472_000, 1.1, 120, 117_457, 1_066], +] +COUNTRY_TITLE = "10 Largest Countries by Population 2025" +COUNTRY_CAPTION = "Data from https://worldpopulationreview.com/ and Wikipedia" + + +class TableApp(cmd2.Cmd): + """Cmd2 application to demonstrate displaying tabular data using rich.""" + + TABLE_CATEGORY = 'Table Commands' + + def __init__(self) -> None: + """Initialize the cmd2 application.""" + super().__init__() + + # Prints an intro banner once upon application startup + self.intro = 'Are you curious which countries and cities on Earth have the largest populations?' + + # Set the default category name + self.default_category = 'cmd2 Built-in Commands' + + @cmd2.with_category(TABLE_CATEGORY) + def do_cities(self, _: cmd2.Statement) -> None: + """Display the cities with the largest population.""" + table = Table(title=CITY_TITLE, caption=CITY_CAPTION) + + for header in CITY_HEADERS: + table.add_column(header) + + for row in CITY_DATA: + # Convert integers or floats to strings, since rich tables can not render int/float + str_row = [f"{item:,}" if isinstance(item, int) else str(item) for item in row] + table.add_row(*str_row) + + self.poutput(table) + + @cmd2.with_category(TABLE_CATEGORY) + def do_countries(self, _: cmd2.Statement) -> None: + """Display the countries with the largest population.""" + table = Table(title=COUNTRY_TITLE, caption=COUNTRY_CAPTION) + + for header in COUNTRY_HEADERS: + justify = "right" + header_style = None + style = None + match header: + case population if "2025 Population" in population: + header_style = Color.BRIGHT_BLUE + style = Color.BLUE + case density if "Density" in density: + header_style = Color.BRIGHT_RED + style = Color.RED + case percap if "per capita" in percap: + header_style = Color.BRIGHT_GREEN + style = Color.GREEN + case flag if 'Flag' in flag: + justify = "center" + case country if 'Country' in country: + justify = "left" + + table.add_column(header, justify=justify, header_style=header_style, style=style) + + for row in COUNTRY_DATA: + # Convert integers or floats to strings, since rich tables can not render int/float + str_row = [f"{item:,}" if isinstance(item, int) else str(item) for item in row] + table.add_row(*str_row) + + self.poutput(table) + + +if __name__ == '__main__': + app = TableApp() + app.cmdloop() From ed88780d3f95f9a74229851b9a2fb7f7455524a4 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sun, 24 Aug 2025 23:22:50 -0400 Subject: [PATCH 51/89] Added rich_utils.indent() to ensure indented text wraps instead of truncating when soft_wrap is enabled. (#1487) --- cmd2/argparse_custom.py | 16 +++++----------- cmd2/cmd2.py | 33 +++++++++++++++----------------- cmd2/rich_utils.py | 29 ++++++++++++++++++++++++++++ tests/test_rich_utils.py | 41 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 29 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 516388cb..b0461659 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -284,7 +284,7 @@ def get_items(self) -> list[CompletionItems]: RenderableType, ) from rich.protocol import is_renderable -from rich.table import Column, Table +from rich.table import Column from rich.text import Text from rich_argparse import ( ArgumentDefaultsRichHelpFormatter, @@ -295,6 +295,7 @@ def get_items(self) -> list[CompletionItems]: ) from . import constants +from . import rich_utils as ru from .rich_utils import Cmd2RichArgparseConsole from .styles import Cmd2Style @@ -1377,17 +1378,10 @@ def __rich__(self) -> Group: style=formatter.styles["argparse.groups"], ) - # Left pad the text like an argparse argument group does - left_padding = formatter._indent_increment - text_table = Table( - Column(overflow="fold"), - box=None, - show_header=False, - padding=(0, 0, 0, left_padding), - ) - text_table.add_row(self.text) + # Indent text like an argparse argument group does + indented_text = ru.indent(self.text, formatter._indent_increment) - return Group(styled_title, text_table) + return Group(styled_title, indented_text) class Cmd2ArgumentParser(argparse.ArgumentParser): diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 3882d4d6..fe8740a4 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -65,7 +65,6 @@ import rich.box from rich.console import Group -from rich.padding import Padding from rich.rule import Rule from rich.style import Style, StyleType from rich.table import ( @@ -4076,9 +4075,8 @@ def do_help(self, args: argparse.Namespace) -> None: # Indent doc_leader to align with the help tables. self.poutput() self.poutput( - Padding.indent(self.doc_leader, 1), + ru.indent(self.doc_leader, 1), style=Cmd2Style.HELP_LEADER, - soft_wrap=False, ) self.poutput() @@ -4135,17 +4133,20 @@ def print_topics(self, header: str, cmds: list[str] | None, cmdlen: int, maxcol: if not cmds: return - # Add a row that looks like a table header. - header_grid = Table.grid() - header_grid.add_row(header, style=Cmd2Style.HELP_HEADER) - header_grid.add_row(Rule(characters=self.ruler, style=Cmd2Style.TABLE_BORDER)) - self.poutput(Padding.indent(header_grid, 1)) + # Print a row that looks like a table header. + if header: + header_grid = Table.grid() + header_grid.add_row(header, style=Cmd2Style.HELP_HEADER) + header_grid.add_row(Rule(characters=self.ruler, style=Cmd2Style.TABLE_BORDER)) + self.poutput(ru.indent(header_grid, 1)) + + # Subtract 2 from the max column width to account for the + # one-space indentation and a one-space right margin. + maxcol = min(maxcol, ru.console_width()) - 2 # Print the topics in columns. - # Subtract 1 from maxcol to account for indentation. - maxcol = min(maxcol, ru.console_width()) - 1 columnized_cmds = self.render_columns(cmds, maxcol) - self.poutput(Padding.indent(columnized_cmds, 1), soft_wrap=False) + self.poutput(ru.indent(columnized_cmds, 1)) self.poutput() def _print_documented_command_topics(self, header: str, cmds: list[str], verbose: bool) -> None: @@ -4160,11 +4161,7 @@ def _print_documented_command_topics(self, header: str, cmds: list[str], verbose return # Indent header to align with the help tables. - self.poutput( - Padding.indent(header, 1), - style=Cmd2Style.HELP_HEADER, - soft_wrap=False, - ) + self.poutput(ru.indent(header, 1), style=Cmd2Style.HELP_HEADER) topics_table = Table( Column("Name", no_wrap=True), Column("Description", overflow="fold"), @@ -5529,7 +5526,7 @@ class TestMyAppCase(Cmd2TestCase): num_transcripts = len(transcripts_expanded) plural = '' if len(transcripts_expanded) == 1 else 's' self.poutput( - Rule("cmd2 transcript test", style=Style.null()), + Rule("cmd2 transcript test", characters=self.ruler, style=Style.null()), style=Style(bold=True), ) self.poutput(f'platform {sys.platform} -- Python {verinfo}, cmd2-{cmd2.__version__}, readline-{rl_type}') @@ -5548,7 +5545,7 @@ class TestMyAppCase(Cmd2TestCase): if test_results.wasSuccessful(): self.perror(stream.read(), end="", style=None) finish_msg = f'{num_transcripts} transcript{plural} passed in {execution_time:.3f} seconds' - self.psuccess(Rule(finish_msg, style=Style.null())) + self.psuccess(Rule(finish_msg, characters=self.ruler, style=Style.null())) else: # Strip off the initial traceback which isn't particularly useful for end users error_str = stream.read() diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 2c08c6da..c2da6915 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -17,7 +17,12 @@ RenderableType, RichCast, ) +from rich.padding import Padding from rich.style import StyleType +from rich.table import ( + Column, + Table, +) from rich.text import Text from rich.theme import Theme from rich_argparse import RichHelpFormatter @@ -288,6 +293,30 @@ def string_to_rich_text(text: str) -> Text: return result +def indent(renderable: RenderableType, level: int) -> Padding: + """Indent a Rich renderable. + + When soft-wrapping is enabled, a Rich console is unable to properly print a + Padding object of indented text, as it truncates long strings instead of wrapping + them. This function provides a workaround for this issue, ensuring that indented + text is printed correctly regardless of the soft-wrap setting. + + For non-text objects, this function merely serves as a convenience + wrapper around Padding.indent(). + + :param renderable: a Rich renderable to indent. + :param level: number of characters to indent. + :return: a Padding object containing the indented content. + """ + if isinstance(renderable, (str, Text)): + # Wrap text in a grid to handle the wrapping. + text_grid = Table.grid(Column(overflow="fold")) + text_grid.add_row(renderable) + renderable = text_grid + + return Padding.indent(renderable, level) + + def prepare_objects_for_rich_print(*objects: Any) -> tuple[RenderableType, ...]: """Prepare a tuple of objects for printing by Rich's Console.print(). diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py index 61da5423..f471d7d5 100644 --- a/tests/test_rich_utils.py +++ b/tests/test_rich_utils.py @@ -1,7 +1,9 @@ """Unit testing for cmd2/rich_utils.py module""" import pytest +import rich.box from rich.style import Style +from rich.table import Table from rich.text import Text from cmd2 import ( @@ -59,6 +61,45 @@ def test_string_to_rich_text() -> None: assert ru.string_to_rich_text(input_string).plain == input_string +def test_indented_text() -> None: + console = ru.Cmd2GeneralConsole() + + # With an indention of 10, text will be evenly split across two lines. + console.width = 20 + text = "A" * 20 + level = 10 + indented_text = ru.indent(text, level) + + with console.capture() as capture: + console.print(indented_text) + result = capture.get().splitlines() + + padding = " " * level + expected_line = padding + ("A" * 10) + assert result[0] == expected_line + assert result[1] == expected_line + + +def test_indented_table() -> None: + console = ru.Cmd2GeneralConsole() + + level = 2 + table = Table("Column", box=rich.box.ASCII) + table.add_row("Some Data") + indented_table = ru.indent(table, level) + + with console.capture() as capture: + console.print(indented_table) + result = capture.get().splitlines() + + padding = " " * level + assert result[0].startswith(padding + "+-----------+") + assert result[1].startswith(padding + "| Column |") + assert result[2].startswith(padding + "|-----------|") + assert result[3].startswith(padding + "| Some Data |") + assert result[4].startswith(padding + "+-----------+") + + @pytest.mark.parametrize( ('rich_text', 'string'), [ From c1af80245482b0a648a17b44ce0d04938ee55ffd Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 25 Aug 2025 00:57:32 -0400 Subject: [PATCH 52/89] No longer indenting help headers and topic lists. --- cmd2/argparse_completer.py | 1 + cmd2/cmd2.py | 42 +++++++++++++++----------------- cmd2/rich_utils.py | 14 ----------- tests/test_argparse_completer.py | 20 +++++++-------- 4 files changed, 30 insertions(+), 47 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index f23a371f..1e366509 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -578,6 +578,7 @@ def _format_completions(self, arg_state: _ArgumentState, completions: list[str] hint_table = Table( *headers, box=SIMPLE_HEAD, + show_edge=False, border_style=Cmd2Style.TABLE_BORDER, ) for item in completion_items: diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index fe8740a4..e6550bad 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -2076,7 +2076,7 @@ def _display_matches_gnu_readline( if self.formatted_completions: if not hint_printed: sys.stdout.write('\n') - sys.stdout.write(self.formatted_completions) + sys.stdout.write('\n' + self.formatted_completions + '\n') # Otherwise use readline's formatter else: @@ -2133,7 +2133,7 @@ def _display_matches_pyreadline(self, matches: list[str]) -> None: # pragma: no if self.formatted_completions: if not hint_printed: sys.stdout.write('\n') - sys.stdout.write(self.formatted_completions) + sys.stdout.write('\n' + self.formatted_completions + '\n') # Redraw the prompt and input lines rl_force_redisplay() @@ -4072,12 +4072,8 @@ def do_help(self, args: argparse.Namespace) -> None: cmds_cats, cmds_doc, cmds_undoc, help_topics = self._build_command_info() if self.doc_leader: - # Indent doc_leader to align with the help tables. self.poutput() - self.poutput( - ru.indent(self.doc_leader, 1), - style=Cmd2Style.HELP_LEADER, - ) + self.poutput(self.doc_leader, style=Cmd2Style.HELP_LEADER) self.poutput() if not cmds_cats: @@ -4122,9 +4118,6 @@ def print_topics(self, header: str, cmds: list[str] | None, cmdlen: int, maxcol: Override of cmd's print_topics() to use Rich. - The output for both the header and the commands is indented by one space to align - with the tables printed by the `help -v` command. - :param header: string to print above commands being printed :param cmds: list of topics to print :param cmdlen: unused, even by cmd's version @@ -4137,16 +4130,11 @@ def print_topics(self, header: str, cmds: list[str] | None, cmdlen: int, maxcol: if header: header_grid = Table.grid() header_grid.add_row(header, style=Cmd2Style.HELP_HEADER) - header_grid.add_row(Rule(characters=self.ruler, style=Cmd2Style.TABLE_BORDER)) - self.poutput(ru.indent(header_grid, 1)) - - # Subtract 2 from the max column width to account for the - # one-space indentation and a one-space right margin. - maxcol = min(maxcol, ru.console_width()) - 2 + header_grid.add_row(Rule(characters=self.ruler)) + self.poutput(header_grid) - # Print the topics in columns. - columnized_cmds = self.render_columns(cmds, maxcol) - self.poutput(ru.indent(columnized_cmds, 1)) + # Subtract 1 from maxcol to account for a one-space right margin. + self.columnize(cmds, maxcol - 1) self.poutput() def _print_documented_command_topics(self, header: str, cmds: list[str], verbose: bool) -> None: @@ -4160,13 +4148,17 @@ def _print_documented_command_topics(self, header: str, cmds: list[str], verbose self.print_topics(header, cmds, 15, 80) return - # Indent header to align with the help tables. - self.poutput(ru.indent(header, 1), style=Cmd2Style.HELP_HEADER) + # Create a grid to hold the header and the topics table + category_grid = Table.grid() + category_grid.add_row(header, style=Cmd2Style.HELP_HEADER) + category_grid.add_row(Rule(characters=self.ruler)) + topics_table = Table( Column("Name", no_wrap=True), Column("Description", overflow="fold"), - box=ru.TOP_AND_HEAD, + box=rich.box.SIMPLE_HEAD, border_style=Cmd2Style.TABLE_BORDER, + show_edge=False, ) # Try to get the documentation string for each command @@ -4205,7 +4197,8 @@ def _print_documented_command_topics(self, header: str, cmds: list[str], verbose # Add this command to the table topics_table.add_row(command, cmd_desc) - self.poutput(topics_table) + category_grid.add_row(topics_table) + self.poutput(category_grid) self.poutput() def render_columns(self, str_list: list[str] | None, display_width: int = 80) -> str: @@ -4486,6 +4479,7 @@ def do_set(self, args: argparse.Namespace) -> None: Column("Description", overflow="fold"), box=rich.box.SIMPLE_HEAD, border_style=Cmd2Style.TABLE_BORDER, + show_edge=False, ) # Build the table and populate self.last_result @@ -4496,7 +4490,9 @@ def do_set(self, args: argparse.Namespace) -> None: settable_table.add_row(param, str(settable.get_value()), settable.description) self.last_result[param] = settable.get_value() + self.poutput() self.poutput(settable_table) + self.poutput() @classmethod def _build_shell_parser(cls) -> Cmd2ArgumentParser: diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index c2da6915..bdd1dbb9 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -8,7 +8,6 @@ TypedDict, ) -from rich.box import Box from rich.console import ( Console, ConsoleRenderable, @@ -111,19 +110,6 @@ class RichPrintKwargs(TypedDict, total=False): new_line_start: bool -# Custom Rich Box for tables which has a top border and a head row separator. -TOP_AND_HEAD: Box = Box( - " ── \n" # top - " \n" - " ── \n" # head_row - " \n" - " \n" - " \n" - " \n" - " \n" -) - - class Cmd2BaseConsole(Console): """Base class for all cmd2 Rich consoles. diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 7d7d735d..27c96598 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -720,8 +720,8 @@ def test_completion_items(ac_app) -> None: line_found = False for line in ac_app.formatted_completions.splitlines(): # Since the CompletionItems were created from strings, the left-most column is left-aligned. - # Therefore choice_1 will begin the line (with 2 spaces for padding). - if line.startswith(' choice_1') and 'A description' in line: + # Therefore choice_1 will begin the line (with 1 space for padding). + if line.startswith(' choice_1') and 'A description' in line: line_found = True break @@ -743,7 +743,7 @@ def test_completion_items(ac_app) -> None: for line in ac_app.formatted_completions.splitlines(): # Since the CompletionItems were created from numbers, the left-most column is right-aligned. # Therefore 1.5 will be right-aligned. - if line.startswith(" 1.5") and "One.Five" in line: + if line.startswith(" 1.5") and "One.Five" in line: line_found = True break @@ -908,7 +908,7 @@ def test_completion_items_arg_header(ac_app) -> None: begidx = endidx - len(text) complete_tester(text, line, begidx, endidx, ac_app) - assert "DESC_HEADER" in normalize(ac_app.formatted_completions)[1] + assert "DESC_HEADER" in normalize(ac_app.formatted_completions)[0] # Test when metavar is a string text = '' @@ -917,7 +917,7 @@ def test_completion_items_arg_header(ac_app) -> None: begidx = endidx - len(text) complete_tester(text, line, begidx, endidx, ac_app) - assert ac_app.STR_METAVAR in normalize(ac_app.formatted_completions)[1] + assert ac_app.STR_METAVAR in normalize(ac_app.formatted_completions)[0] # Test when metavar is a tuple text = '' @@ -927,7 +927,7 @@ def test_completion_items_arg_header(ac_app) -> None: # We are completing the first argument of this flag. The first element in the tuple should be the column header. complete_tester(text, line, begidx, endidx, ac_app) - assert ac_app.TUPLE_METAVAR[0].upper() in normalize(ac_app.formatted_completions)[1] + assert ac_app.TUPLE_METAVAR[0].upper() in normalize(ac_app.formatted_completions)[0] text = '' line = f'choices --tuple_metavar token_1 {text}' @@ -936,7 +936,7 @@ def test_completion_items_arg_header(ac_app) -> None: # We are completing the second argument of this flag. The second element in the tuple should be the column header. complete_tester(text, line, begidx, endidx, ac_app) - assert ac_app.TUPLE_METAVAR[1].upper() in normalize(ac_app.formatted_completions)[1] + assert ac_app.TUPLE_METAVAR[1].upper() in normalize(ac_app.formatted_completions)[0] text = '' line = f'choices --tuple_metavar token_1 token_2 {text}' @@ -946,7 +946,7 @@ def test_completion_items_arg_header(ac_app) -> None: # We are completing the third argument of this flag. It should still be the second tuple element # in the column header since the tuple only has two strings in it. complete_tester(text, line, begidx, endidx, ac_app) - assert ac_app.TUPLE_METAVAR[1].upper() in normalize(ac_app.formatted_completions)[1] + assert ac_app.TUPLE_METAVAR[1].upper() in normalize(ac_app.formatted_completions)[0] def test_completion_items_descriptive_headers(ac_app) -> None: @@ -961,7 +961,7 @@ def test_completion_items_descriptive_headers(ac_app) -> None: begidx = endidx - len(text) complete_tester(text, line, begidx, endidx, ac_app) - assert ac_app.CUSTOM_DESC_HEADERS[0] in normalize(ac_app.formatted_completions)[1] + assert ac_app.CUSTOM_DESC_HEADERS[0] in normalize(ac_app.formatted_completions)[0] # This argument did not provide a descriptive header, so it should be DEFAULT_DESCRIPTIVE_HEADERS text = '' @@ -970,7 +970,7 @@ def test_completion_items_descriptive_headers(ac_app) -> None: begidx = endidx - len(text) complete_tester(text, line, begidx, endidx, ac_app) - assert DEFAULT_DESCRIPTIVE_HEADERS[0] in normalize(ac_app.formatted_completions)[1] + assert DEFAULT_DESCRIPTIVE_HEADERS[0] in normalize(ac_app.formatted_completions)[0] @pytest.mark.parametrize( From e9938c28e8f0c6a04d5b36accd7efec989a63b39 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 25 Aug 2025 22:01:45 -0400 Subject: [PATCH 53/89] Fix: Improper string conversion (#1489) * Updated rich_utils.prepare_objects_for_rendering() to only convert styled strings to Rich Text objects. * Corrected display width issue with styled strings in CompletionItem.descriptive_data. --- cmd2/__init__.py | 3 + cmd2/argparse_custom.py | 11 ++- cmd2/cmd2.py | 35 ++++--- cmd2/rich_utils.py | 39 +++++--- examples/argparse_completion.py | 9 +- tests/conftest.py | 28 ++++-- tests/test_argparse.py | 2 +- tests/test_argparse_completer.py | 52 ++++++----- tests/test_cmd2.py | 152 +++++++++++++++++++++++++------ 9 files changed, 241 insertions(+), 90 deletions(-) diff --git a/cmd2/__init__.py b/cmd2/__init__.py index e8aebdaf..1313bc1a 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -44,6 +44,7 @@ ) from .parsing import Statement from .py_bridge import CommandResult +from .rich_utils import RichPrintKwargs from .string_utils import stylize from .styles import Cmd2Style from .utils import ( @@ -86,6 +87,8 @@ 'plugin', 'rich_utils', 'string_utils', + # Rich Utils + 'RichPrintKwargs', # String Utils 'stylize', # Styles, diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index b0461659..c99ca82a 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -194,7 +194,7 @@ def get_items(self) -> list[CompletionItems]: truncated with an ellipsis at the end. You can override this and other settings when you create the ``Column``. -``descriptive_data`` items can include Rich objects, including styled text. +``descriptive_data`` items can include Rich objects, including styled Text and Tables. To avoid printing a excessive information to the screen at once when a user presses tab, there is a maximum threshold for the number of CompletionItems @@ -388,13 +388,18 @@ def __init__(self, value: object, descriptive_data: Sequence[Any], *args: Any) - """CompletionItem Initializer. :param value: the value being tab completed - :param descriptive_data: descriptive data to display + :param descriptive_data: a list of descriptive data to display in the columns that follow + the completion value. The number of items in this list must equal + the number of descriptive headers defined for the argument. :param args: args for str __init__ """ super().__init__(*args) # Make sure all objects are renderable by a Rich table. - self.descriptive_data = [obj if is_renderable(obj) else str(obj) for obj in descriptive_data] + renderable_data = [obj if is_renderable(obj) else str(obj) for obj in descriptive_data] + + # Convert objects with ANSI styles to Rich Text for correct display width. + self.descriptive_data = ru.prepare_objects_for_rendering(*renderable_data) # Save the original value to support CompletionItems as argparse choices. # cmd2 has patched argparse so input is compared to this value instead of the CompletionItem instance. diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index e6550bad..c082ac3c 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1225,7 +1225,7 @@ def print_to( method and still call `super()` without encountering unexpected keyword argument errors. These arguments are not passed to Rich's Console.print(). """ - prepared_objects = ru.prepare_objects_for_rich_print(*objects) + prepared_objects = ru.prepare_objects_for_rendering(*objects) try: Cmd2GeneralConsole(file).print( @@ -1469,7 +1469,7 @@ def ppaged( # Check if we are outputting to a pager. if functional_terminal and can_block: - prepared_objects = ru.prepare_objects_for_rich_print(*objects) + prepared_objects = ru.prepare_objects_for_rendering(*objects) # Chopping overrides soft_wrap if chop: @@ -2487,9 +2487,9 @@ def _get_alias_completion_items(self) -> list[CompletionItem]: """Return list of alias names and values as CompletionItems.""" results: list[CompletionItem] = [] - for cur_key in self.aliases: - descriptive_data = [self.aliases[cur_key]] - results.append(CompletionItem(cur_key, descriptive_data)) + for name, value in self.aliases.items(): + descriptive_data = [value] + results.append(CompletionItem(name, descriptive_data)) return results @@ -2497,9 +2497,9 @@ 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: - descriptive_data = [self.macros[cur_key].value] - results.append(CompletionItem(cur_key, descriptive_data)) + for name, macro in self.macros.items(): + descriptive_data = [macro.value] + results.append(CompletionItem(name, descriptive_data)) return results @@ -2507,9 +2507,12 @@ def _get_settable_completion_items(self) -> list[CompletionItem]: """Return list of Settable names, values, and descriptions as CompletionItems.""" results: list[CompletionItem] = [] - for cur_key in self.settables: - descriptive_data = [self.settables[cur_key].get_value(), self.settables[cur_key].description] - results.append(CompletionItem(cur_key, descriptive_data)) + for name, settable in self.settables.items(): + descriptive_data = [ + str(settable.get_value()), + settable.description, + ] + results.append(CompletionItem(name, descriptive_data)) return results @@ -4157,8 +4160,8 @@ def _print_documented_command_topics(self, header: str, cmds: list[str], verbose Column("Name", no_wrap=True), Column("Description", overflow="fold"), box=rich.box.SIMPLE_HEAD, - border_style=Cmd2Style.TABLE_BORDER, show_edge=False, + border_style=Cmd2Style.TABLE_BORDER, ) # Try to get the documentation string for each command @@ -4478,8 +4481,8 @@ def do_set(self, args: argparse.Namespace) -> None: Column("Value", overflow="fold"), Column("Description", overflow="fold"), box=rich.box.SIMPLE_HEAD, - border_style=Cmd2Style.TABLE_BORDER, show_edge=False, + border_style=Cmd2Style.TABLE_BORDER, ) # Build the table and populate self.last_result @@ -4487,7 +4490,11 @@ def do_set(self, args: argparse.Namespace) -> None: for param in sorted(to_show, key=self.default_sort_key): settable = self.settables[param] - settable_table.add_row(param, str(settable.get_value()), settable.description) + settable_table.add_row( + param, + str(settable.get_value()), + settable.description, + ) self.last_result[param] = settable.get_value() self.poutput() diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index bdd1dbb9..3d387317 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -14,9 +14,9 @@ JustifyMethod, OverflowMethod, RenderableType, - RichCast, ) from rich.padding import Padding +from rich.protocol import rich_cast from rich.style import StyleType from rich.table import ( Column, @@ -40,10 +40,6 @@ def __str__(self) -> str: """Return value instead of enum name for printing in cmd2's set command.""" return str(self.value) - def __repr__(self) -> str: - """Return quoted value instead of enum description for printing in cmd2's set command.""" - return repr(self.value) - # Controls when ANSI style sequences are allowed in output ALLOW_STYLE = AllowStyle.TERMINAL @@ -303,20 +299,35 @@ def indent(renderable: RenderableType, level: int) -> Padding: return Padding.indent(renderable, level) -def prepare_objects_for_rich_print(*objects: Any) -> tuple[RenderableType, ...]: +def prepare_objects_for_rendering(*objects: Any) -> tuple[Any, ...]: """Prepare a tuple of objects for printing by Rich's Console.print(). - Converts any non-Rich objects (i.e., not ConsoleRenderable or RichCast) - into rich.Text objects by stringifying them and processing them with - from_ansi(). This ensures Rich correctly interprets any embedded ANSI - escape sequences. + This function converts any non-Rich object whose string representation contains + ANSI style codes into a rich.Text object. This ensures correct display width + calculation, as Rich can then properly parse and account for the non-printing + ANSI codes. All other objects are left untouched, allowing Rich's native + renderers to handle them. :param objects: objects to prepare - :return: a tuple containing the processed objects, where non-Rich objects are - converted to rich.Text. + :return: a tuple containing the processed objects. """ object_list = list(objects) + for i, obj in enumerate(object_list): - if not isinstance(obj, (ConsoleRenderable, RichCast)): - object_list[i] = string_to_rich_text(str(obj)) + # Resolve the object's final renderable form, including those + # with a __rich__ method that might return a string. + renderable = rich_cast(obj) + + # This object implements the Rich console protocol, so no preprocessing is needed. + if isinstance(renderable, ConsoleRenderable): + continue + + # Check if the object's string representation contains ANSI styles, and if so, + # replace it with a Rich Text object for correct width calculation. + renderable_as_str = str(renderable) + renderable_as_text = string_to_rich_text(renderable_as_str) + + if renderable_as_text.plain != renderable_as_str: + object_list[i] = renderable_as_text + return tuple(object_list) diff --git a/examples/argparse_completion.py b/examples/argparse_completion.py index 90d2d104..8d2c3dca 100755 --- a/examples/argparse_completion.py +++ b/examples/argparse_completion.py @@ -3,7 +3,7 @@ import argparse -from rich.box import SIMPLE_HEAD +import rich.box from rich.style import Style from rich.table import Table from rich.text import Text @@ -49,7 +49,12 @@ def choices_completion_item(self) -> list[CompletionItem]: Text("styled text!!", style=Style(color=Color.BRIGHT_YELLOW, underline=True)), ) - table_item = Table("Left Column", "Right Column", box=SIMPLE_HEAD, border_style=Cmd2Style.TABLE_BORDER) + table_item = Table( + "Left Column", + "Right Column", + box=rich.box.ROUNDED, + border_style=Cmd2Style.TABLE_BORDER, + ) table_item.add_row("Yes, it's true.", "CompletionItems can") table_item.add_row("even display description", "data in tables!") diff --git a/tests/conftest.py b/tests/conftest.py index 40ab9abc..814d9a48 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,12 +10,9 @@ import pytest import cmd2 -from cmd2.rl_utils import ( - readline, -) -from cmd2.utils import ( - StdSim, -) +from cmd2 import rich_utils as ru +from cmd2.rl_utils import readline +from cmd2.utils import StdSim def verify_help_text(cmd2_app: cmd2.Cmd, help_output: str | list[str], verbose_strings: list[str] | None = None) -> None: @@ -88,6 +85,25 @@ def base_app(): return cmd2.Cmd(include_py=True, include_ipy=True) +def with_ansi_style(style): + def arg_decorator(func): + import functools + + @functools.wraps(func) + def cmd_wrapper(*args, **kwargs): + old = ru.ALLOW_STYLE + ru.ALLOW_STYLE = style + try: + retval = func(*args, **kwargs) + finally: + ru.ALLOW_STYLE = old + return retval + + return cmd_wrapper + + return arg_decorator + + # These are odd file names for testing quoting of them odd_file_names = ['nothingweird', 'has spaces', '"is_double_quoted"', "'is_single_quoted'"] diff --git a/tests/test_argparse.py b/tests/test_argparse.py index afcae62e..dd567434 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -425,7 +425,7 @@ def test_subcmd_decorator(subcommand_app) -> None: # Test subcommand that has no help option out, err = run_cmd(subcommand_app, 'test_subcmd_decorator helpless_subcmd') - assert "'subcommand': 'helpless_subcmd'" in out[0] + assert "'subcommand': 'helpless_subcmd'" in out[1] out, err = run_cmd(subcommand_app, 'help test_subcmd_decorator helpless_subcmd') assert out[0] == 'Usage: test_subcmd_decorator helpless_subcmd' diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 27c96598..38f84a73 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -7,6 +7,7 @@ import pytest import cmd2 +import cmd2.string_utils as su from cmd2 import ( Cmd2ArgumentParser, CompletionError, @@ -15,11 +16,13 @@ argparse_custom, with_argparser, ) +from cmd2 import rich_utils as ru from .conftest import ( complete_tester, normalize, run_cmd, + with_ansi_style, ) # Data and functions for testing standalone choice_provider and completer @@ -109,12 +112,18 @@ def do_pos_and_flag(self, args: argparse.Namespace) -> None: static_choices_list = ('static', 'choices', 'stop', 'here') choices_from_provider = ('choices', 'provider', 'probably', 'improved') completion_item_choices = ( - CompletionItem('choice_1', ['A description']), - CompletionItem('choice_2', ['Another description']), + CompletionItem('choice_1', ['Description 1']), + # Make this the longest description so we can test display width. + CompletionItem('choice_2', [su.stylize("String with style", style=cmd2.Color.BLUE)]), + CompletionItem('choice_3', [su.stylize("Text with style", style=cmd2.Color.RED)]), ) # This tests that CompletionItems created with numerical values are sorted as numbers. - num_completion_items = (CompletionItem(5, ["Five"]), CompletionItem(1.5, ["One.Five"]), CompletionItem(2, ["Five"])) + num_completion_items = ( + CompletionItem(5, ["Five"]), + CompletionItem(1.5, ["One.Five"]), + CompletionItem(2, ["Five"]), + ) def choices_provider(self) -> tuple[str]: """Method that provides choices""" @@ -704,6 +713,7 @@ def test_autocomp_blank_token(ac_app) -> None: assert sorted(completions) == sorted(ArgparseCompleterTester.completions_for_pos_2) +@with_ansi_style(ru.AllowStyle.ALWAYS) def test_completion_items(ac_app) -> None: # First test CompletionItems created from strings text = '' @@ -716,16 +726,20 @@ def test_completion_items(ac_app) -> None: assert len(ac_app.completion_matches) == len(ac_app.completion_item_choices) assert len(ac_app.display_matches) == len(ac_app.completion_item_choices) - # Look for both the value and description in the hint table - line_found = False - for line in ac_app.formatted_completions.splitlines(): - # Since the CompletionItems were created from strings, the left-most column is left-aligned. - # Therefore choice_1 will begin the line (with 1 space for padding). - if line.startswith(' choice_1') and 'A description' in line: - line_found = True - break + lines = ac_app.formatted_completions.splitlines() + + # Since the CompletionItems were created from strings, the left-most column is left-aligned. + # Therefore choice_1 will begin the line (with 1 space for padding). + assert lines[2].startswith(' choice_1') + assert lines[2].strip().endswith('Description 1') + + # Verify that the styled string was converted to a Rich Text object so that + # Rich could correctly calculate its display width. Since it was the longest + # description in the table, we should only see one space of padding after it. + assert lines[3].endswith("\x1b[34mString with style\x1b[0m ") - assert line_found + # Verify that the styled Rich Text also rendered. + assert lines[4].endswith("\x1b[31mText with style\x1b[0m ") # Now test CompletionItems created from numbers text = '' @@ -738,16 +752,12 @@ def test_completion_items(ac_app) -> None: assert len(ac_app.completion_matches) == len(ac_app.num_completion_items) assert len(ac_app.display_matches) == len(ac_app.num_completion_items) - # Look for both the value and description in the hint table - line_found = False - for line in ac_app.formatted_completions.splitlines(): - # Since the CompletionItems were created from numbers, the left-most column is right-aligned. - # Therefore 1.5 will be right-aligned. - if line.startswith(" 1.5") and "One.Five" in line: - line_found = True - break + lines = ac_app.formatted_completions.splitlines() - assert line_found + # Since the CompletionItems were created from numbers, the left-most column is right-aligned. + # Therefore 1.5 will be right-aligned. + assert lines[2].startswith(" 1.5") + assert lines[2].strip().endswith('One.Five') @pytest.mark.parametrize( diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index f03d5224..295ae720 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -20,6 +20,7 @@ COMMAND_NAME, Cmd2Style, Color, + RichPrintKwargs, clipboard, constants, exceptions, @@ -40,28 +41,10 @@ odd_file_names, run_cmd, verify_help_text, + with_ansi_style, ) -def with_ansi_style(style): - def arg_decorator(func): - import functools - - @functools.wraps(func) - def cmd_wrapper(*args, **kwargs): - old = ru.ALLOW_STYLE - ru.ALLOW_STYLE = style - try: - retval = func(*args, **kwargs) - finally: - ru.ALLOW_STYLE = old - return retval - - return cmd_wrapper - - return arg_decorator - - def create_outsim_app(): c = cmd2.Cmd() c.stdout = utils.StdSim(c.stdout) @@ -2092,21 +2075,32 @@ def test_poutput_none(outsim_app) -> None: @with_ansi_style(ru.AllowStyle.ALWAYS) -def test_poutput_ansi_always(outsim_app) -> None: - msg = 'Hello World' - colored_msg = Text(msg, style="cyan") - outsim_app.poutput(colored_msg) +@pytest.mark.parametrize( + # Test a Rich Text and a string. + ('styled_msg', 'expected'), + [ + (Text("A Text object", style="cyan"), "\x1b[36mA Text object\x1b[0m\n"), + (su.stylize("A str object", style="blue"), "\x1b[34mA str object\x1b[0m\n"), + ], +) +def test_poutput_ansi_always(styled_msg, expected, outsim_app) -> None: + outsim_app.poutput(styled_msg) out = outsim_app.stdout.getvalue() - assert out == "\x1b[36mHello World\x1b[0m\n" + assert out == expected @with_ansi_style(ru.AllowStyle.NEVER) -def test_poutput_ansi_never(outsim_app) -> None: - msg = 'Hello World' - colored_msg = Text(msg, style="cyan") - outsim_app.poutput(colored_msg) +@pytest.mark.parametrize( + # Test a Rich Text and a string. + ('styled_msg', 'expected'), + [ + (Text("A Text object", style="cyan"), "A Text object\n"), + (su.stylize("A str object", style="blue"), "A str object\n"), + ], +) +def test_poutput_ansi_never(styled_msg, expected, outsim_app) -> None: + outsim_app.poutput(styled_msg) out = outsim_app.stdout.getvalue() - expected = msg + '\n' assert out == expected @@ -2122,6 +2116,106 @@ def test_poutput_ansi_terminal(outsim_app) -> None: assert out == expected +@with_ansi_style(ru.AllowStyle.ALWAYS) +def test_poutput_highlight(outsim_app): + rich_print_kwargs = RichPrintKwargs(highlight=True) + outsim_app.poutput( + "My IP Address is 192.168.1.100.", + rich_print_kwargs=rich_print_kwargs, + ) + out = outsim_app.stdout.getvalue() + assert out == "My IP Address is \x1b[1;92m192.168.1.100\x1b[0m.\n" + + +@with_ansi_style(ru.AllowStyle.ALWAYS) +def test_poutput_markup(outsim_app): + rich_print_kwargs = RichPrintKwargs(markup=True) + outsim_app.poutput( + "The leaves are [green]green[/green].", + rich_print_kwargs=rich_print_kwargs, + ) + out = outsim_app.stdout.getvalue() + assert out == "The leaves are \x1b[32mgreen\x1b[0m.\n" + + +@with_ansi_style(ru.AllowStyle.ALWAYS) +def test_poutput_emoji(outsim_app): + rich_print_kwargs = RichPrintKwargs(emoji=True) + outsim_app.poutput( + "Look at the emoji :1234:.", + rich_print_kwargs=rich_print_kwargs, + ) + out = outsim_app.stdout.getvalue() + assert out == "Look at the emoji 🔢.\n" + + +@with_ansi_style(ru.AllowStyle.ALWAYS) +def test_poutput_justify_and_width(outsim_app): + rich_print_kwargs = RichPrintKwargs(justify="right", width=10) + + # Use a styled-string when justifying to check if its display width is correct. + outsim_app.poutput( + su.stylize("Hello", style="blue"), + rich_print_kwargs=rich_print_kwargs, + ) + out = outsim_app.stdout.getvalue() + assert out == " \x1b[34mHello\x1b[0m\n" + + +@with_ansi_style(ru.AllowStyle.ALWAYS) +def test_poutput_no_wrap_and_overflow(outsim_app): + rich_print_kwargs = RichPrintKwargs(no_wrap=True, overflow="ellipsis", width=10) + + outsim_app.poutput( + "This is longer than width.", + soft_wrap=False, + rich_print_kwargs=rich_print_kwargs, + ) + out = outsim_app.stdout.getvalue() + assert out.startswith("This is l…\n") + + +@with_ansi_style(ru.AllowStyle.ALWAYS) +def test_poutput_pretty_print(outsim_app): + """Test that cmd2 passes objects through so they can be pretty-printed when highlighting is enabled.""" + rich_print_kwargs = RichPrintKwargs(highlight=True) + dictionary = {1: 'hello', 2: 'person', 3: 'who', 4: 'codes'} + + outsim_app.poutput( + dictionary, + rich_print_kwargs=rich_print_kwargs, + ) + out = outsim_app.stdout.getvalue() + assert out.startswith("\x1b[1m{\x1b[0m\x1b[1;36m1\x1b[0m: \x1b[32m'hello'\x1b[0m") + + +@with_ansi_style(ru.AllowStyle.ALWAYS) +def test_poutput_all_keyword_args(outsim_app): + """Test that all fields in RichPrintKwargs are recognized by Rich's Console.print().""" + rich_print_kwargs = RichPrintKwargs( + justify="center", + overflow="ellipsis", + no_wrap=True, + markup=True, + emoji=True, + highlight=True, + width=40, + height=50, + crop=False, + new_line_start=True, + ) + + outsim_app.poutput( + "My string", + rich_print_kwargs=rich_print_kwargs, + ) + + # Verify that something printed which means Console.print() didn't + # raise a TypeError for an unexpected keyword argument. + out = outsim_app.stdout.getvalue() + assert "My string" in out + + def test_broken_pipe_error(outsim_app, monkeypatch, capsys): write_mock = mock.MagicMock() write_mock.side_effect = BrokenPipeError From e7bce6680a4753785a2ea1b69899e9a977ad6f60 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 26 Aug 2025 10:20:58 -0400 Subject: [PATCH 54/89] Refactor: Use Python properties for value access in Settable. (#1490) --- CHANGELOG.md | 2 ++ cmd2/cmd2.py | 12 ++++++------ cmd2/rich_utils.py | 4 ++++ cmd2/utils.py | 8 +++++--- tests/test_cmd2.py | 4 ++-- 5 files changed, 19 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83cdd55d..2d6d5ea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ - Moved all string-related functions from utils.py to string_utils.py. - Removed all text style Enums from ansi.py in favor of `Rich` styles. - Renamed ansi.py to terminal_utils.py to reflect the functions left in it. + - Replaced `utils.Settable.get_value()` and `utils.Settable.set_value()` in favor of a Python + property called `Settable.value`. - Enhancements - Simplified the process to set a custom parser for `cmd2's` built-in commands. See diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index c082ac3c..d069be7d 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -2509,7 +2509,7 @@ def _get_settable_completion_items(self) -> list[CompletionItem]: for name, settable in self.settables.items(): descriptive_data = [ - str(settable.get_value()), + str(settable.value), settable.description, ] results.append(CompletionItem(name, descriptive_data)) @@ -4460,12 +4460,12 @@ def do_set(self, args: argparse.Namespace) -> None: if args.value: # Try to update the settable's value try: - orig_value = settable.get_value() - settable.set_value(su.strip_quotes(args.value)) + orig_value = settable.value + settable.value = su.strip_quotes(args.value) except ValueError as ex: self.perror(f"Error setting {args.param}: {ex}") else: - self.poutput(f"{args.param} - was: {orig_value!r}\nnow: {settable.get_value()!r}") + self.poutput(f"{args.param} - was: {orig_value!r}\nnow: {settable.value!r}") self.last_result = True return @@ -4492,10 +4492,10 @@ def do_set(self, args: argparse.Namespace) -> None: settable = self.settables[param] settable_table.add_row( param, - str(settable.get_value()), + str(settable.value), settable.description, ) - self.last_result[param] = settable.get_value() + self.last_result[param] = settable.value self.poutput() self.poutput(settable_table) diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 3d387317..477a6ab8 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -40,6 +40,10 @@ def __str__(self) -> str: """Return value instead of enum name for printing in cmd2's set command.""" return str(self.value) + def __repr__(self) -> str: + """Return quoted value instead of enum description for printing in cmd2's set command.""" + return repr(self.value) + # Controls when ANSI style sequences are allowed in output ALLOW_STYLE = AllowStyle.TERMINAL diff --git a/cmd2/utils.py b/cmd2/utils.py index 12ba3c8d..35a875b5 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -126,11 +126,13 @@ def get_bool_choices(_: str) -> list[str]: self.choices_provider = choices_provider self.completer = completer - def get_value(self) -> Any: + @property + def value(self) -> Any: """Get the value of the settable attribute.""" return getattr(self.settable_obj, self.settable_attrib_name) - def set_value(self, value: Any) -> None: + @value.setter + def value(self, value: Any) -> None: """Set the settable attribute on the specified destination object. :param value: new value to set @@ -144,7 +146,7 @@ def set_value(self, value: Any) -> None: raise ValueError(f"invalid choice: {new_value!r} (choose from {choices_str})") # Try to update the settable's value - orig_value = self.get_value() + orig_value = self.value setattr(self.settable_obj, self.settable_attrib_name, new_value) # Check if we need to call an onchange callback diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 295ae720..f9f511f8 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -153,7 +153,7 @@ def test_base_set(base_app) -> None: # Make sure all settables appear in last_result. assert len(base_app.last_result) == len(base_app.settables) for param in base_app.last_result: - assert base_app.last_result[param] == base_app.settables[param].get_value() + assert base_app.last_result[param] == base_app.settables[param].value def test_set(base_app) -> None: @@ -2279,7 +2279,7 @@ def test_get_settable_completion_items(base_app) -> None: # These CompletionItem descriptions are a two column table (Settable Value and Settable Description) # First check if the description text starts with the value - str_value = str(cur_settable.get_value()) + str_value = str(cur_settable.value) assert cur_res.descriptive_data[0].startswith(str_value) # The second column is likely to have wrapped long text. So we will just examine the From 77cfdb8526648580fb580ec5a93898e0e8bc0295 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 26 Aug 2025 10:50:46 -0400 Subject: [PATCH 55/89] Improved styles.py documentation. --- cmd2/styles.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/cmd2/styles.py b/cmd2/styles.py index 99cabc2c..56ebb0d7 100644 --- a/cmd2/styles.py +++ b/cmd2/styles.py @@ -1,8 +1,27 @@ """Defines custom Rich styles and their corresponding names for cmd2. -This module provides a centralized and discoverable way to manage Rich styles used -within the cmd2 framework. It defines a StrEnum for style names and a dictionary -that maps these names to their default style objects. +This module provides a centralized and discoverable way to manage Rich styles +used within the cmd2 framework. It defines a StrEnum for style names and a +dictionary that maps these names to their default style objects. + +**Notes** + +Cmd2 uses Rich for its terminal output, and while this module defines a set of +cmd2-specific styles, it's important to understand that these aren't the only +styles that can appear. Components like Rich tracebacks and the rich-argparse +library, which cmd2 uses for its help output, also apply their own built-in +styles. Additionally, app developers may use other Rich objects that have +their own default styles. + +For a complete theming experience, you can create a custom theme that includes +styles from Rich and rich-argparse. The `cmd2.rich_utils.set_theme()` function +automatically updates rich-argparse's styles with any custom styles provided in +your theme dictionary, so you don't have to modify them directly. + +You can find Rich's default styles in the `rich.default_styles` module. +For rich-argparse, the style names are defined in the +`rich_argparse.RichHelpFormatter.styles` dictionary. + """ import sys @@ -26,7 +45,7 @@ class Cmd2Style(StrEnum): Using this enum allows for autocompletion and prevents typos when referencing cmd2-specific styles. - This StrEnum is tightly coupled with DEFAULT_CMD2_STYLES. Any name + This StrEnum is tightly coupled with `DEFAULT_CMD2_STYLES`. Any name added here must have a corresponding style definition there. """ From d6407ba82884c337233d8f053473d448711bcc81 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 26 Aug 2025 11:26:12 -0400 Subject: [PATCH 56/89] Added spacing between verbose help tables for better readability. --- cmd2/cmd2.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index d069be7d..200d0c1c 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -4079,14 +4079,23 @@ def do_help(self, args: argparse.Namespace) -> None: self.poutput(self.doc_leader, style=Cmd2Style.HELP_LEADER) self.poutput() - if not cmds_cats: - # No categories found, fall back to standard behavior - self._print_documented_command_topics(self.doc_header, cmds_doc, args.verbose) - else: - # Categories found, Organize all commands by category - for category in sorted(cmds_cats.keys(), key=self.default_sort_key): - self._print_documented_command_topics(category, cmds_cats[category], args.verbose) - self._print_documented_command_topics(self.default_category, cmds_doc, args.verbose) + # Print any categories first and then the default category. + sorted_categories = sorted(cmds_cats.keys(), key=self.default_sort_key) + all_cmds = {category: cmds_cats[category] for category in sorted_categories} + all_cmds[self.doc_header] = cmds_doc + + # Used to provide verbose table separation for better readability. + previous_table_printed = False + + for category, commands in all_cmds.items(): + if previous_table_printed: + self.poutput() + + self._print_documented_command_topics(category, commands, args.verbose) + previous_table_printed = bool(commands) and args.verbose + + if previous_table_printed and (help_topics or cmds_undoc): + self.poutput() self.print_topics(self.misc_header, help_topics, 15, 80) self.print_topics(self.undoc_header, cmds_undoc, 15, 80) @@ -4102,7 +4111,7 @@ def do_help(self, args: argparse.Namespace) -> None: completer = argparse_completer.DEFAULT_AP_COMPLETER(argparser, self) completer.print_help(args.subcommands, self.stdout) - # If there is a help func delegate to do_help + # If the command has a custom help function, then call it elif help_func is not None: help_func() From 0442344b3dc2fd7e9eb11f30428e763e6af39371 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 26 Aug 2025 12:57:47 -0400 Subject: [PATCH 57/89] Write terminal control codes to stdout instead of stderr. --- cmd2/cmd2.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 200d0c1c..555908a6 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -5626,11 +5626,9 @@ def async_alert(self, alert_msg: str, new_prompt: str | None = None) -> None: # cursor_offset=rl_get_point(), alert_msg=alert_msg, ) - if rl_type == RlType.GNU: - sys.stderr.write(terminal_str) - sys.stderr.flush() - elif rl_type == RlType.PYREADLINE: - readline.rl.mode.console.write(terminal_str) + + sys.stdout.write(terminal_str) + sys.stdout.flush() # Redraw the prompt and input lines below the alert rl_force_redisplay() @@ -5688,9 +5686,6 @@ def need_prompt_refresh(self) -> bool: # pragma: no cover def set_window_title(title: str) -> None: # pragma: no cover """Set the terminal window title. - NOTE: This function writes to stderr. Therefore, if you call this during a command run by a pyscript, - the string which updates the title will appear in that command's CommandResult.stderr data. - :param title: the new window title """ if not vt100_support: @@ -5699,8 +5694,8 @@ def set_window_title(title: str) -> None: # pragma: no cover from .terminal_utils import set_title_str try: - sys.stderr.write(set_title_str(title)) - sys.stderr.flush() + sys.stdout.write(set_title_str(title)) + sys.stdout.flush() except AttributeError: # Debugging in Pycharm has issues with setting terminal title pass From d02a43fe66222f67a5a6426d0b7aacffe74ffa69 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 26 Aug 2025 13:15:00 -0400 Subject: [PATCH 58/89] Fixed unit test, --- tests/test_argparse_completer.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 38f84a73..e4cdab79 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -5,6 +5,7 @@ from typing import cast import pytest +from rich.text import Text import cmd2 import cmd2.string_utils as su @@ -115,7 +116,7 @@ def do_pos_and_flag(self, args: argparse.Namespace) -> None: CompletionItem('choice_1', ['Description 1']), # Make this the longest description so we can test display width. CompletionItem('choice_2', [su.stylize("String with style", style=cmd2.Color.BLUE)]), - CompletionItem('choice_3', [su.stylize("Text with style", style=cmd2.Color.RED)]), + CompletionItem('choice_3', [Text("Text with style", style=cmd2.Color.RED)]), ) # This tests that CompletionItems created with numerical values are sorted as numbers. @@ -739,7 +740,7 @@ def test_completion_items(ac_app) -> None: assert lines[3].endswith("\x1b[34mString with style\x1b[0m ") # Verify that the styled Rich Text also rendered. - assert lines[4].endswith("\x1b[31mText with style\x1b[0m ") + assert lines[4].endswith("\x1b[31mText with style \x1b[0m ") # Now test CompletionItems created from numbers text = '' From de69b1587d7fae6eab70c3f08e6868e851c9f777 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 26 Aug 2025 15:20:06 -0400 Subject: [PATCH 59/89] Switch from Group to Text.assemble() when building help text. --- cmd2/cmd2.py | 84 +++++++++++++++++++++++++--------------------------- 1 file changed, 41 insertions(+), 43 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 555908a6..d60b752b 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3508,9 +3508,9 @@ def _cmdloop(self) -> None: # Top-level parser for alias @staticmethod def _build_alias_parser() -> Cmd2ArgumentParser: - alias_description = Group( + alias_description = Text.assemble( "Manage aliases.", - "\n", + "\n\n", "An alias is a command that enables replacement of a word by another string.", ) alias_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_description) @@ -3537,10 +3537,11 @@ def _build_alias_create_parser(cls) -> Cmd2ArgumentParser: alias_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_create_description) # Add Notes epilog - alias_create_notes = Group( + alias_create_notes = Text.assemble( "If you want to use redirection, pipes, or terminators in the value of the alias, then quote them.", - "\n", - Text(" alias create save_results print_results \">\" out.txt\n", style=Cmd2Style.COMMAND_LINE), + "\n\n", + (" alias create save_results print_results \">\" out.txt\n", Cmd2Style.COMMAND_LINE), + "\n\n", ( "Since aliases are resolved during parsing, tab completion will function as it would " "for the actual command the alias resolves to." @@ -3639,12 +3640,12 @@ def _alias_delete(self, args: argparse.Namespace) -> None: # alias -> list @classmethod def _build_alias_list_parser(cls) -> Cmd2ArgumentParser: - alias_list_description = Group( + alias_list_description = Text.assemble( ( "List specified aliases in a reusable form that can be saved to a startup " "script to preserve aliases across sessions." ), - "\n", + "\n\n", "Without arguments, all aliases will be listed.", ) @@ -3719,9 +3720,9 @@ def macro_arg_complete( # Top-level parser for macro @staticmethod def _build_macro_parser() -> Cmd2ArgumentParser: - macro_description = Group( + macro_description = Text.assemble( "Manage macros.", - "\n", + "\n\n", "A macro is similar to an alias, but it can contain argument placeholders.", ) macro_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_description) @@ -3744,48 +3745,46 @@ def do_macro(self, args: argparse.Namespace) -> None: # macro -> create @classmethod def _build_macro_create_parser(cls) -> Cmd2ArgumentParser: - macro_create_description = Group( + macro_create_description = Text.assemble( "Create or overwrite a macro.", - "\n", + "\n\n", "A macro is similar to an alias, but it can contain argument placeholders.", - "\n", + "\n\n", "Arguments are expressed when creating a macro using {#} notation where {1} means the first argument.", - "\n", + "\n\n", "The following creates a macro called my_macro that expects two arguments:", - "\n", - Text(" macro create my_macro make_dinner --meat {1} --veggie {2}", style=Cmd2Style.COMMAND_LINE), - "\n", + "\n\n", + (" macro create my_macro make_dinner --meat {1} --veggie {2}", Cmd2Style.COMMAND_LINE), + "\n\n", "When the macro is called, the provided arguments are resolved and the assembled command is run. For example:", - "\n", - Text.assemble( - (" my_macro beef broccoli", Cmd2Style.COMMAND_LINE), - (" ───> ", Style(bold=True)), - ("make_dinner --meat beef --veggie broccoli", Cmd2Style.COMMAND_LINE), - ), + "\n\n", + (" my_macro beef broccoli", Cmd2Style.COMMAND_LINE), + (" ───> ", Style(bold=True)), + ("make_dinner --meat beef --veggie broccoli", Cmd2Style.COMMAND_LINE), ) macro_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_create_description) # Add Notes epilog - macro_create_notes = Group( + macro_create_notes = Text.assemble( "To use the literal string {1} in your command, escape it this way: {{1}}.", - "\n", + "\n\n", "Extra arguments passed to a macro are appended to resolved command.", - "\n", + "\n\n", ( "An argument number can be repeated in a macro. In the following example the " "first argument will populate both {1} instances." ), - "\n", - Text(" macro create ft file_taxes -p {1} -q {2} -r {1}", style=Cmd2Style.COMMAND_LINE), - "\n", + "\n\n", + (" macro create ft file_taxes -p {1} -q {2} -r {1}", Cmd2Style.COMMAND_LINE), + "\n\n", "To quote an argument in the resolved command, quote it during creation.", - "\n", - Text(" macro create backup !cp \"{1}\" \"{1}.orig\"", style=Cmd2Style.COMMAND_LINE), - "\n", + "\n\n", + (" macro create backup !cp \"{1}\" \"{1}.orig\"", Cmd2Style.COMMAND_LINE), + "\n\n", "If you want to use redirection, pipes, or terminators in the value of the macro, then quote them.", - "\n", - Text(" macro create show_results print_results -type {1} \"|\" less", style=Cmd2Style.COMMAND_LINE), - "\n", + "\n\n", + (" macro create show_results print_results -type {1} \"|\" less", Cmd2Style.COMMAND_LINE), + "\n\n", ( "Since macros don't resolve until after you press Enter, their arguments tab complete as paths. " "This default behavior changes if custom tab completion for macro arguments has been implemented." @@ -3926,11 +3925,10 @@ def _macro_delete(self, args: argparse.Namespace) -> None: # macro -> list macro_list_help = "list macros" - macro_list_description = ( - "List specified macros in a reusable form that can be saved to a startup script\n" - "to preserve macros across sessions\n" - "\n" - "Without arguments, all macros will be listed." + macro_list_description = Text.assemble( + "List specified macros in a reusable form that can be saved to a startup script to preserve macros across sessions.", + "\n\n", + "Without arguments, all macros will be listed.", ) macro_list_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_list_description) @@ -4385,9 +4383,9 @@ def select(self, opts: str | list[str] | list[tuple[Any, str | None]], prompt: s def _build_base_set_parser(cls) -> Cmd2ArgumentParser: # When tab completing value, we recreate the set command parser with a value argument specific to # the settable being edited. To make this easier, define a base parser with all the common elements. - set_description = Group( + set_description = Text.assemble( "Set a settable parameter or show current settings of parameters.", - "\n", + "\n\n", ( "Call without arguments for a list of all settable parameters with their values. " "Call with just param to view that parameter's value." @@ -5380,9 +5378,9 @@ def _current_script_dir(self) -> str | None: @classmethod def _build_base_run_script_parser(cls) -> Cmd2ArgumentParser: - run_script_description = Group( + run_script_description = Text.assemble( "Run text script.", - "\n", + "\n\n", "Scripts should contain one command per line, entered as you would in the console.", ) From 378e825f1ba9fc4a9e3e69a685ce34e65b7e0999 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 26 Aug 2025 15:35:18 -0400 Subject: [PATCH 60/89] Disabled automatic detection for markup, emoji, and highlighting in Cmd2RichArgparseConsole. --- cmd2/rich_utils.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 477a6ab8..20001df1 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -183,7 +183,7 @@ def __init__(self, file: IO[str] | None = None) -> None: Defaults to sys.stdout. """ # This console is configured for general-purpose printing. It enables soft wrap - # and disables Rich's automatic processing for markup, emoji, and highlighting. + # and disables Rich's automatic detection for markup, emoji, and highlighting. # These defaults can be overridden in calls to the console's or cmd2's print methods. super().__init__( file=file, @@ -201,6 +201,22 @@ class Cmd2RichArgparseConsole(Cmd2BaseConsole): which conflicts with rich-argparse's explicit no_wrap and overflow settings. """ + def __init__(self, file: IO[str] | None = None) -> None: + """Cmd2RichArgparseConsole initializer. + + :param file: optional file object where the console should write to. + Defaults to sys.stdout. + """ + # Disable Rich's automatic detection for markup, emoji, and highlighting. + # rich-argparse does markup and highlighting without involving the console + # so these won't affect its internal functionality. + super().__init__( + file=file, + markup=False, + emoji=False, + highlight=False, + ) + class Cmd2ExceptionConsole(Cmd2BaseConsole): """Rich console for printing exceptions. From fefb01ee22fb14710d607387941e74ad1cd7ed4e Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 26 Aug 2025 22:48:55 -0400 Subject: [PATCH 61/89] Added regular expression to detect ANSI style sequences. (#1492) --- cmd2/argparse_custom.py | 2 +- cmd2/cmd2.py | 10 +++++----- cmd2/rich_utils.py | 28 +++++++++++++++------------- cmd2/string_utils.py | 9 ++++----- 4 files changed, 25 insertions(+), 24 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index c99ca82a..bdf94da4 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -398,7 +398,7 @@ def __init__(self, value: object, descriptive_data: Sequence[Any], *args: Any) - # Make sure all objects are renderable by a Rich table. renderable_data = [obj if is_renderable(obj) else str(obj) for obj in descriptive_data] - # Convert objects with ANSI styles to Rich Text for correct display width. + # Convert strings containing ANSI style sequences to Rich Text objects for correct display width. self.descriptive_data = ru.prepare_objects_for_rendering(*renderable_data) # Save the original value to support CompletionItems as argparse choices. diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index d60b752b..2eceae2f 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1191,12 +1191,12 @@ def _completion_supported(self) -> bool: @property def visible_prompt(self) -> str: - """Read-only property to get the visible prompt with any ANSI style escape codes stripped. + """Read-only property to get the visible prompt with any ANSI style sequences stripped. - Used by transcript testing to make it easier and more reliable when users are doing things like coloring the - prompt using ANSI color codes. + Used by transcript testing to make it easier and more reliable when users are doing things like + coloring the prompt. - :return: prompt stripped of any ANSI escape codes + :return: the stripped prompt """ return su.strip_style(self.prompt) @@ -4214,7 +4214,7 @@ def _print_documented_command_topics(self, header: str, cmds: list[str], verbose def render_columns(self, str_list: list[str] | None, display_width: int = 80) -> str: """Render a list of single-line strings as a compact set of columns. - This method correctly handles strings containing ANSI escape codes and + This method correctly handles strings containing ANSI style sequences and full-width characters (like those used in CJK languages). Each column is only as wide as necessary and columns are separated by two spaces. diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 20001df1..dfef892f 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -1,5 +1,6 @@ """Provides common utilities to support Rich in cmd2-based applications.""" +import re from collections.abc import Mapping from enum import Enum from typing import ( @@ -28,13 +29,16 @@ from .styles import DEFAULT_CMD2_STYLES +# A compiled regular expression to detect ANSI style sequences. +ANSI_STYLE_SEQUENCE_RE = re.compile(r"\x1b\[[0-9;?]*m") + class AllowStyle(Enum): """Values for ``cmd2.rich_utils.ALLOW_STYLE``.""" - ALWAYS = 'Always' # Always output ANSI style sequences - NEVER = 'Never' # Remove ANSI style sequences from all output - TERMINAL = 'Terminal' # Remove ANSI style sequences if the output is not going to the terminal + ALWAYS = "Always" # Always output ANSI style sequences + NEVER = "Never" # Remove ANSI style sequences from all output + TERMINAL = "Terminal" # Remove ANSI style sequences if the output is not going to the terminal def __str__(self) -> str: """Return value instead of enum name for printing in cmd2's set command.""" @@ -234,7 +238,7 @@ def rich_text_to_string(text: Text) -> str: """Convert a Rich Text object to a string. This function's purpose is to render a Rich Text object, including any styles (e.g., color, bold), - to a plain Python string with ANSI escape codes. It differs from `text.plain`, which strips + to a plain Python string with ANSI style sequences. It differs from `text.plain`, which strips all formatting. :param text: the text object to convert @@ -259,7 +263,7 @@ def rich_text_to_string(text: Text) -> str: def string_to_rich_text(text: str) -> Text: - r"""Create a Text object from a string which can contain ANSI escape codes. + r"""Create a Rich Text object from a string which can contain ANSI style sequences. This wraps rich.Text.from_ansi() to handle an issue where it removes the trailing line break from a string (e.g. "Hello\n" becomes "Hello"). @@ -323,9 +327,9 @@ def prepare_objects_for_rendering(*objects: Any) -> tuple[Any, ...]: """Prepare a tuple of objects for printing by Rich's Console.print(). This function converts any non-Rich object whose string representation contains - ANSI style codes into a rich.Text object. This ensures correct display width - calculation, as Rich can then properly parse and account for the non-printing - ANSI codes. All other objects are left untouched, allowing Rich's native + ANSI style sequences into a Rich Text object. This ensures correct display width + calculation, as Rich can then properly parse and account for these non-printing + codes. All other objects are left untouched, allowing Rich's native renderers to handle them. :param objects: objects to prepare @@ -342,12 +346,10 @@ def prepare_objects_for_rendering(*objects: Any) -> tuple[Any, ...]: if isinstance(renderable, ConsoleRenderable): continue - # Check if the object's string representation contains ANSI styles, and if so, - # replace it with a Rich Text object for correct width calculation. renderable_as_str = str(renderable) - renderable_as_text = string_to_rich_text(renderable_as_str) - if renderable_as_text.plain != renderable_as_str: - object_list[i] = renderable_as_text + # Check for any ANSI style sequences in the string. + if ANSI_STYLE_SEQUENCE_RE.search(renderable_as_str): + object_list[i] = string_to_rich_text(renderable_as_str) return tuple(object_list) diff --git a/cmd2/string_utils.py b/cmd2/string_utils.py index a77eb5f6..9b9d590c 100644 --- a/cmd2/string_utils.py +++ b/cmd2/string_utils.py @@ -1,7 +1,7 @@ """Provides string utility functions. This module offers a collection of string utility functions built on the Rich library. -These utilities are designed to correctly handle strings with ANSI escape codes and +These utilities are designed to correctly handle strings with ANSI style sequences and full-width characters (like those used in CJK languages). """ @@ -94,13 +94,12 @@ def stylize(val: str, style: StyleType) -> str: def strip_style(val: str) -> str: - """Strip all ANSI styles from a string. + """Strip all ANSI style sequences from a string. :param val: string to be stripped :return: the stripped string """ - text = ru.string_to_rich_text(val) - return text.plain + return ru.ANSI_STYLE_SEQUENCE_RE.sub("", val) def str_width(val: str) -> int: @@ -163,4 +162,4 @@ def norm_fold(val: str) -> str: """ import unicodedata - return unicodedata.normalize('NFC', val).casefold() + return unicodedata.normalize("NFC", val).casefold() From 96465a821b3ee48ba2fe4743f55a0399dfe5149c Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 27 Aug 2025 11:06:18 -0400 Subject: [PATCH 62/89] Raise ValueError instead of OSError when self.editor is not set. --- cmd2/cmd2.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 2eceae2f..d41b29d6 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -5358,10 +5358,10 @@ def run_editor(self, file_path: str | None = None) -> None: """Run a text editor and optionally open a file with it. :param file_path: optional path of the file to edit. Defaults to None. - :raises EnvironmentError: if self.editor is not set + :raises ValueError: if self.editor is not set """ if not self.editor: - raise OSError("Please use 'set editor' to specify your text editing program of choice.") + raise ValueError("Please use 'set editor' to specify your text editing program of choice.") command = su.quote(os.path.expanduser(self.editor)) if file_path: From 5c420e3f9aa3e712d3a4a89a293f98e570c91322 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 27 Aug 2025 13:15:14 -0400 Subject: [PATCH 63/89] Formatting an exception like Rich does after a traceback. --- cmd2/cmd2.py | 22 +++++++++++++--------- tests/test_cmd2.py | 34 ++++++++++++++++------------------ 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index d41b29d6..f351b4f8 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -65,6 +65,7 @@ import rich.box from rich.console import Group +from rich.highlighter import ReprHighlighter from rich.rule import Rule from rich.style import Style, StyleType from rich.table import ( @@ -1365,18 +1366,21 @@ def pexcept( console.print() return - # Otherwise highlight and print the exception. - from rich.highlighter import ReprHighlighter + # Print the exception in the same style Rich uses after a traceback. + exception_str = str(exception) - highlighter = ReprHighlighter() + if exception_str: + highlighter = ReprHighlighter() - final_msg = Text.assemble( - ("EXCEPTION of type ", Cmd2Style.ERROR), - (f"{type(exception).__name__}", Cmd2Style.EXCEPTION_TYPE), - (" occurred with message: ", Cmd2Style.ERROR), - highlighter(str(exception)), - ) + final_msg = Text.assemble( + (f"{type(exception).__name__}: ", "traceback.exc_type"), + highlighter(exception_str), + ) + else: + final_msg = Text(f"{type(exception).__name__}", style="traceback.exc_type") + # If not in debug mode and the 'debug' setting is available, + # inform the user how to enable full tracebacks. if not self.debug and 'debug' in self.settables: help_msg = Text.assemble( "\n\n", diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index f9f511f8..079edf06 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -895,8 +895,8 @@ def test_base_debug(base_app) -> None: # Make sure we get an exception, but cmd2 handles it out, err = run_cmd(base_app, 'edit') - assert "EXCEPTION of type" in err[0] - assert "Please use 'set editor'" in err[0] + assert "ValueError: Please use 'set editor'" in err[0] + assert "To enable full traceback" in err[3] # Set debug true out, err = run_cmd(base_app, 'set debug True') @@ -918,11 +918,20 @@ def test_debug_not_settable(base_app) -> None: base_app.debug = False base_app.remove_settable('debug') - # Cause an exception - out, err = run_cmd(base_app, 'bad "quote') + # Cause an exception by setting editor to None and running edit + base_app.editor = None + out, err = run_cmd(base_app, 'edit') # Since debug is unsettable, the user will not be given the option to enable a full traceback - assert err == ['Invalid syntax: No closing quotation'] + assert err == ["ValueError: Please use 'set editor' to specify your text editing program of", 'choice.'] + + +def test_blank_exception(mocker, base_app): + mocker.patch("cmd2.Cmd.do_help", side_effect=Exception) + out, err = run_cmd(base_app, 'help') + + # When an exception has no message, the first error line is just its type. + assert err[0] == "Exception" def test_remove_settable_keyerror(base_app) -> None: @@ -2668,8 +2677,7 @@ def test_pexcept_style(base_app, capsys) -> None: base_app.pexcept(msg) out, err = capsys.readouterr() - expected = su.stylize("EXCEPTION of type ", style=Cmd2Style.ERROR) - expected += su.stylize("Exception", style=Cmd2Style.EXCEPTION_TYPE) + expected = su.stylize("Exception: ", style="traceback.exc_type") assert err.startswith(expected) @@ -2679,17 +2687,7 @@ def test_pexcept_no_style(base_app, capsys) -> None: base_app.pexcept(msg) out, err = capsys.readouterr() - assert err.startswith("EXCEPTION of type Exception occurred with message: testing...") - - -@with_ansi_style(ru.AllowStyle.NEVER) -def test_pexcept_not_exception(base_app, capsys) -> None: - # Pass in a msg that is not an Exception object - msg = False - - base_app.pexcept(msg) - out, err = capsys.readouterr() - assert err.startswith("EXCEPTION of type bool occurred with message: False") + assert err.startswith("Exception: testing...") @pytest.mark.parametrize('chop', [True, False]) From 5c689c5c7bc3b74512b08cd0855ff7f2bd1ed0d7 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 27 Aug 2025 15:05:17 -0400 Subject: [PATCH 64/89] Updated pyproject.toml to use new license format. --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1ba77140..c25b3497 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,14 +10,14 @@ authors = [{ name = "cmd2 Contributors" }] readme = "README.md" requires-python = ">=3.10" keywords = ["CLI", "cmd", "command", "interactive", "prompt", "Python"] -license = { file = "LICENSE" } +license = "MIT" +license-files = ["LICENSE"] classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Console", "Operating System :: OS Independent", "Intended Audience :: Developers", "Intended Audience :: System Administrators", - "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", From bc7fa0c9207e52e21dc1623c5288c07d5294b02b Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 27 Aug 2025 16:06:27 -0400 Subject: [PATCH 65/89] Fit topics table within screen width. --- cmd2/cmd2.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index f351b4f8..d99bd577 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -4148,7 +4148,8 @@ def print_topics(self, header: str, cmds: list[str] | None, cmdlen: int, maxcol: self.poutput(header_grid) # Subtract 1 from maxcol to account for a one-space right margin. - self.columnize(cmds, maxcol - 1) + maxcol = min(maxcol, ru.console_width()) - 1 + self.columnize(cmds, maxcol) self.poutput() def _print_documented_command_topics(self, header: str, cmds: list[str], verbose: bool) -> None: From 0d22fc904bafc74aa0ab6d678b8c6e988a989a0e Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 28 Aug 2025 16:40:57 -0400 Subject: [PATCH 66/89] Updated documentation for the Settable class. --- cmd2/utils.py | 45 ++++++++++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/cmd2/utils.py b/cmd2/utils.py index 35a875b5..b0a03f9b 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -83,25 +83,32 @@ def __init__( ) -> None: """Settable Initializer. - :param name: name of the instance attribute being made settable - :param val_type: callable used to cast the string value from the command line into its proper type and - even validate its value. Setting this to bool provides tab completion for true/false and - validation using to_bool(). The val_type function should raise an exception if it fails. - This exception will be caught and printed by Cmd.do_set(). - :param description: string describing this setting - :param settable_object: object to which the instance attribute belongs (e.g. self) - :param settable_attrib_name: name which displays to the user in the output of the set command. - Defaults to `name` if not specified. - :param onchange_cb: optional function or method to call when the value of this settable is altered - by the set command. (e.g. onchange_cb=self.debug_changed) - - Cmd.do_set() passes the following 3 arguments to onchange_cb: - param_name: str - name of the changed parameter - old_value: Any - the value before being changed - new_value: Any - the value after being changed - - The following optional settings provide tab completion for a parameter's values. They correspond to the - same settings in argparse-based tab completion. A maximum of one of these should be provided. + :param name: The user-facing name for this setting in the CLI. + :param val_type: A callable used to cast the string value from the CLI into its + proper type and validate it. This function should raise an + exception (like ValueError or TypeError) if the conversion or + validation fails, which will be caught and displayed to the user + by the set command. For example, setting this to int ensures the + input is a valid integer. Specifying bool automatically provides + tab completion for 'true' and 'false' and uses a built-in function + for conversion and validation. + :param description: A concise string that describes the purpose of this setting. + :param settable_object: The object that owns the attribute being made settable (e.g. self). + :param settable_attrib_name: The name of the attribute on the settable_object that + will be modified. This defaults to the value of the name + parameter if not specified. + :param onchange_cb: An optional function or method to call when the value of this + setting is altered by the set command. The callback is invoked + only if the new value is different from the old one. + + It receives three arguments: + param_name: str - name of the parameter + old_value: Any - the parameter's old value + new_value: Any - the parameter's new value + + The following optional settings provide tab completion for a parameter's values. + They correspond to the same settings in argparse-based tab completion. A maximum + of one of these should be provided. :param choices: iterable of accepted values :param choices_provider: function that provides choices for this argument From 96224cfdef99a3627968f079778fe1b459477eb4 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 28 Aug 2025 17:06:07 -0400 Subject: [PATCH 67/89] Updated documentation for the CommandSet._cmd property. --- cmd2/command_definition.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/cmd2/command_definition.py b/cmd2/command_definition.py index 3bf6afc8..e07db902 100644 --- a/cmd2/command_definition.py +++ b/cmd2/command_definition.py @@ -102,8 +102,17 @@ def _cmd(self) -> 'cmd2.Cmd': Using this property ensures that self.__cmd_internal has been set and it tells type checkers that it's no longer a None type. - Override this property if you need to change its return type to a - child class of Cmd. + Override this property to specify a more specific return type for static + type checking. The typing.cast function can be used to assert to the + type checker that the parent cmd2.Cmd instance is of a more specific + subclass, enabling better autocompletion and type safety in the child class. + + For example: + + @property + def _cmd(self) -> CustomCmdApp: + return cast(CustomCmdApp, super()._cmd) + :raises CommandSetRegistrationError: if CommandSet is not registered. """ From c2dc175dcf59d5cf97eeee61588f976c7aafcd5a Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 28 Aug 2025 18:41:48 -0400 Subject: [PATCH 68/89] Use all available width for traceback panel and code display. --- cmd2/cmd2.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index d99bd577..fd04f89e 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -73,6 +73,7 @@ Table, ) from rich.text import Text +from rich.traceback import Traceback from . import ( argparse_completer, @@ -1357,12 +1358,14 @@ def pexcept( # Only print a traceback if we're in debug mode and one exists. if self.debug and sys.exc_info() != (None, None, None): - console.print_exception( - width=console.width, + traceback = Traceback( + width=None, # Use all available width + code_width=None, # Use all available width show_locals=True, max_frames=0, # 0 means full traceback. word_wrap=True, # Wrap long lines of code instead of truncate ) + console.print(traceback) console.print() return From 9046e81765f383ec5214e57f75a2d396e90dd613 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 29 Aug 2025 17:39:33 -0400 Subject: [PATCH 69/89] Refactor: Use a single parser instance for subcommands This commit refactors how subcommand parsers are created. Instead of manually copying attributes from a pre-configured parser to a new one created by argparse, the code now directly attaches a single, fully configured parser instance to the subcommand action's internal map. This eliminates fragile attribute-by-attribute copying and ensures all custom parser settings are retained. --- cmd2/cmd2.py | 50 +++++++++++++++++++------------------------------- 1 file changed, 19 insertions(+), 31 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index fd04f89e..7ccc64b3 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -987,46 +987,34 @@ def find_subcommand(action: argparse.ArgumentParser, subcmd_names: list[str]) -> target_parser = find_subcommand(command_parser, subcommand_names) + # Create the subcommand parser and configure it subcmd_parser = self._build_parser(cmdset, subcmd_parser_builder, f'{command_name} {subcommand_name}') if subcmd_parser.description is None and method.__doc__: subcmd_parser.description = strip_doc_annotations(method.__doc__) + # Set the subcommand handler + defaults = {constants.NS_ATTR_SUBCMD_HANDLER: method} + subcmd_parser.set_defaults(**defaults) + + # Set what instance the handler is bound to + setattr(subcmd_parser, constants.PARSER_ATTR_COMMANDSET, cmdset) + + # Find the argparse action that handles subcommands for action in target_parser._actions: if isinstance(action, argparse._SubParsersAction): # Get the kwargs for add_parser() add_parser_kwargs = getattr(method, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS, {}) - # Set subcmd_parser as the parent to the parser we're creating to get its arguments - add_parser_kwargs['parents'] = [subcmd_parser] - - # argparse only copies actions from a parent and not the following settings. - # To retain these settings, we will copy them from subcmd_parser and pass them - # as ArgumentParser constructor arguments to add_parser(). - add_parser_kwargs['prog'] = subcmd_parser.prog - add_parser_kwargs['usage'] = subcmd_parser.usage - add_parser_kwargs['description'] = subcmd_parser.description - add_parser_kwargs['epilog'] = subcmd_parser.epilog - add_parser_kwargs['formatter_class'] = subcmd_parser.formatter_class - add_parser_kwargs['prefix_chars'] = subcmd_parser.prefix_chars - add_parser_kwargs['fromfile_prefix_chars'] = subcmd_parser.fromfile_prefix_chars - add_parser_kwargs['argument_default'] = subcmd_parser.argument_default - add_parser_kwargs['conflict_handler'] = subcmd_parser.conflict_handler - add_parser_kwargs['allow_abbrev'] = subcmd_parser.allow_abbrev - - # Set add_help to False and use whatever help option subcmd_parser already has - add_parser_kwargs['add_help'] = False - - attached_parser = action.add_parser(subcommand_name, **add_parser_kwargs) - - # Set the subcommand handler - defaults = {constants.NS_ATTR_SUBCMD_HANDLER: method} - attached_parser.set_defaults(**defaults) - - # Copy value for custom ArgparseCompleter type, which will be None if not present on subcmd_parser - attached_parser.set_ap_completer_type(subcmd_parser.get_ap_completer_type()) # type: ignore[attr-defined] - - # Set what instance the handler is bound to - setattr(attached_parser, constants.PARSER_ATTR_COMMANDSET, cmdset) + # Use add_parser to register the subcommand name and any aliases + action.add_parser(subcommand_name, **add_parser_kwargs) + + # Replace the parser created by add_parser() with our pre-configured one + action._name_parser_map[subcommand_name] = subcmd_parser + + # Also remap any aliases to our pre-configured parser + for alias in add_parser_kwargs.get("aliases", []): + action._name_parser_map[alias] = subcmd_parser + break def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: From 7c90454e543c1b90df5a559872780d06fa2d0d9b Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 29 Aug 2025 17:40:54 -0400 Subject: [PATCH 70/89] Improved docstring for TextGroup.__rich__(). --- cmd2/argparse_custom.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index bdf94da4..f7ba4c4c 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -1375,7 +1375,11 @@ def __init__( self.formatter_creator = formatter_creator def __rich__(self) -> Group: - """Perform custom rendering.""" + """Return a renderable Rich Group object for the class instance. + + This method formats the title and indents the text to match argparse + group styling, making the object displayable by a Rich console. + """ formatter = self.formatter_creator() styled_title = Text( From 471fe54470b4f3082b2874c0e3ced0cfd8afb55e Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 30 Aug 2025 14:02:27 -0400 Subject: [PATCH 71/89] Fixed issue where help table header Rule() objects were not using the TABLE_BORDER style. --- cmd2/cmd2.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 7ccc64b3..d71e2aa3 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -4135,7 +4135,7 @@ def print_topics(self, header: str, cmds: list[str] | None, cmdlen: int, maxcol: if header: header_grid = Table.grid() header_grid.add_row(header, style=Cmd2Style.HELP_HEADER) - header_grid.add_row(Rule(characters=self.ruler)) + header_grid.add_row(Rule(characters=self.ruler, style=Cmd2Style.TABLE_BORDER)) self.poutput(header_grid) # Subtract 1 from maxcol to account for a one-space right margin. @@ -4157,7 +4157,7 @@ def _print_documented_command_topics(self, header: str, cmds: list[str], verbose # Create a grid to hold the header and the topics table category_grid = Table.grid() category_grid.add_row(header, style=Cmd2Style.HELP_HEADER) - category_grid.add_row(Rule(characters=self.ruler)) + category_grid.add_row(Rule(characters=self.ruler, style=Cmd2Style.TABLE_BORDER)) topics_table = Table( Column("Name", no_wrap=True), From b9c636979c35829eeef4cc676f0254eba7921761 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sat, 30 Aug 2025 14:10:08 -0400 Subject: [PATCH 72/89] Fix display of doc_leader with background color --- cmd2/cmd2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index d71e2aa3..5c566f89 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -4069,7 +4069,7 @@ def do_help(self, args: argparse.Namespace) -> None: if self.doc_leader: self.poutput() - self.poutput(self.doc_leader, style=Cmd2Style.HELP_LEADER) + self.poutput(Text(self.doc_leader, style=Cmd2Style.HELP_LEADER)) self.poutput() # Print any categories first and then the default category. From d6af09c8b2282ba68b98eed03451210131b028ed Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sat, 30 Aug 2025 21:51:04 -0400 Subject: [PATCH 73/89] Add example for setting a theme (#1497) * Draft example for setting a theme * Fix typos * Add some more elements to the custom theme * Added in a custom self.doc_leader * Add in setting background color for one style to make it discoverable that this is an option * Change some styles to show off different options * Add comments about how colors can be defined * Fix typo * Overrode "traceback.exc_type" --- examples/rich_theme.py | 53 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100755 examples/rich_theme.py diff --git a/examples/rich_theme.py b/examples/rich_theme.py new file mode 100755 index 00000000..e07dfdf9 --- /dev/null +++ b/examples/rich_theme.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""A simple example of setting a custom theme for a cmd2 application.""" + +from rich.style import Style + +import cmd2 +import cmd2.rich_utils as ru +from cmd2 import Cmd2Style, Color + + +class ThemedApp(cmd2.Cmd): + """A simple cmd2 application with a custom theme.""" + + def __init__(self, *args, **kwargs): + """Initialize the application.""" + super().__init__(*args, **kwargs) + self.intro = "This is a themed application. Try the 'theme_show' command." + + # Set text which prints right before all of the help tables are listed. + self.doc_leader = "Welcome to this glorious help ..." + + # Create a custom theme + # Colors can come from the cmd2.color.Color StrEnum class, be RGB hex values, or + # be any of the rich standard colors: https://rich.readthedocs.io/en/stable/appendix/colors.html + custom_theme = { + Cmd2Style.SUCCESS: Style(color=Color.GREEN), # Use color from cmd2 Color class + Cmd2Style.WARNING: Style(color=Color.ORANGE1), + Cmd2Style.ERROR: Style(color=Color.PINK1), + Cmd2Style.HELP_HEADER: Style(color=Color.CYAN, bgcolor="#44475a"), + Cmd2Style.HELP_LEADER: Style(color="#f8f8f2", bgcolor="#282a36"), # use RGB hex colors + Cmd2Style.TABLE_BORDER: Style(color="turquoise2"), # use a rich standard color + "traceback.exc_type": Style(color=Color.RED, bgcolor=Color.LIGHT_YELLOW3), + "argparse.args": Style(color=Color.AQUAMARINE3, underline=True), + "inspect.attr": Style(color=Color.DARK_GOLDENROD, bold=True), + } + ru.set_theme(custom_theme) + + @cmd2.with_category("Theme Commands") + def do_theme_show(self, _: cmd2.Statement): + """Showcases the custom theme by printing messages with different styles.""" + # NOTE: Using soft_wrap=False will ensure display looks correct when background colors are part of the style + self.poutput("This is a basic output message.") + self.psuccess("This is a success message.", soft_wrap=False) + self.pwarning("This is a warning message.", soft_wrap=False) + self.perror("This is an error message.", soft_wrap=False) + self.pexcept(ValueError("This is a dummy ValueError exception.")) + + +if __name__ == "__main__": + import sys + + app = ThemedApp() + sys.exit(app.cmdloop()) From 3a15f1a3be4ed20256c666370a9d72cba3c8e35a Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sun, 31 Aug 2025 00:06:36 -0400 Subject: [PATCH 74/89] Only apply style to the help header text instead of the entire row. --- cmd2/cmd2.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 5c566f89..80e783bd 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -4134,7 +4134,7 @@ def print_topics(self, header: str, cmds: list[str] | None, cmdlen: int, maxcol: # Print a row that looks like a table header. if header: header_grid = Table.grid() - header_grid.add_row(header, style=Cmd2Style.HELP_HEADER) + header_grid.add_row(Text(header, style=Cmd2Style.HELP_HEADER)) header_grid.add_row(Rule(characters=self.ruler, style=Cmd2Style.TABLE_BORDER)) self.poutput(header_grid) @@ -4156,7 +4156,7 @@ def _print_documented_command_topics(self, header: str, cmds: list[str], verbose # Create a grid to hold the header and the topics table category_grid = Table.grid() - category_grid.add_row(header, style=Cmd2Style.HELP_HEADER) + category_grid.add_row(Text(header, style=Cmd2Style.HELP_HEADER)) category_grid.add_row(Rule(characters=self.ruler, style=Cmd2Style.TABLE_BORDER)) topics_table = Table( From 221b766e378453bf2ce87e5beae077910c61c901 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Mon, 1 Sep 2025 11:33:59 -0400 Subject: [PATCH 75/89] No longer set inspect.attr in the custom theme --- examples/rich_theme.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/rich_theme.py b/examples/rich_theme.py index e07dfdf9..03d8ff2a 100755 --- a/examples/rich_theme.py +++ b/examples/rich_theme.py @@ -29,9 +29,8 @@ def __init__(self, *args, **kwargs): Cmd2Style.HELP_HEADER: Style(color=Color.CYAN, bgcolor="#44475a"), Cmd2Style.HELP_LEADER: Style(color="#f8f8f2", bgcolor="#282a36"), # use RGB hex colors Cmd2Style.TABLE_BORDER: Style(color="turquoise2"), # use a rich standard color - "traceback.exc_type": Style(color=Color.RED, bgcolor=Color.LIGHT_YELLOW3), + "traceback.exc_type": Style(color=Color.RED, bgcolor=Color.LIGHT_YELLOW3, bold=True), "argparse.args": Style(color=Color.AQUAMARINE3, underline=True), - "inspect.attr": Style(color=Color.DARK_GOLDENROD, bold=True), } ru.set_theme(custom_theme) From 82353eea3df175c0577f53da26a84d4ccb2a4e21 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Mon, 1 Sep 2025 12:03:19 -0400 Subject: [PATCH 76/89] Added links to a couple new examples to the list of examples --- examples/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/README.md b/examples/README.md index aea040bd..d8c06de1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -78,6 +78,10 @@ each: - [remove_settable.py](https://github.com/python-cmd2/cmd2/blob/main/examples/remove_settable.py) - Shows how to remove any of the built-in cmd2 `Settables` you do not want in your cmd2 application +- [rich_tables.py](https://github.com/python-cmd2/cmd2/blob/main/examples/rich_tables.py) + - Example of using Rich Tables within a cmd2 application for displaying tabular data +- [rich_theme.py](https://github.com/python-cmd2/cmd2/blob/main/examples/rich_theme.py) + - Demonstrates how to create a custom theme for a cmd2 application - [tmux_launch.sh](https://github.com/python-cmd2/cmd2/blob/main/examples/tmux_launch.sh) - Shell script that launches two applications using tmux in different windows/tabs - [tmux_split.sh](https://github.com/python-cmd2/cmd2/blob/main/examples/tmux_split.sh) From 61273d606cba9256fb1e3e7445c1af99401279fa Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Mon, 1 Sep 2025 12:14:26 -0400 Subject: [PATCH 77/89] Updated example/README.md --- examples/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/README.md b/examples/README.md index d8c06de1..c46968cb 100644 --- a/examples/README.md +++ b/examples/README.md @@ -14,8 +14,9 @@ each: - [argparse_completion.py](https://github.com/python-cmd2/cmd2/blob/main/examples/argparse_completion.py) - Shows how to integrate tab-completion with argparse-based commands - [argparse_example.py](https://github.com/python-cmd2/cmd2/blob/main/examples/argparse_example.py) - - Demonstrates how to use the `cmd2.with_argparser` decorator to specify command arguments using - [argparse](https://docs.python.org/3/library/argparse.html) + - Comprehensive example demonstrating various aspects of using + [argparse](https://docs.python.org/3/library/argparse.html) for command argument processing + via the `cmd2.with_argparser` decorator - [async_printing.py](https://github.com/python-cmd2/cmd2/blob/main/examples/async_printing.py) - Shows how to asynchronously print alerts, update the prompt in realtime, and change the window title From def952442f48f8a056afce3eef7ffda8622a962e Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Mon, 1 Sep 2025 19:34:04 -0400 Subject: [PATCH 78/89] Simplify subcommand example within argparse_example.py (#1498) Simplify subcommand example within argparse_example.py to be a simple calculate command with add and subtract subcommands. The original subcommand example was overly complicated. This replaces it with a much simpler one that is easier to digest and understand. Also switched to using the cmd2.as_subcommand_to decorator. --- examples/argparse_example.py | 123 ++++++++++------------------------- 1 file changed, 33 insertions(+), 90 deletions(-) diff --git a/examples/argparse_example.py b/examples/argparse_example.py index dedad6c9..564f4be9 100755 --- a/examples/argparse_example.py +++ b/examples/argparse_example.py @@ -106,98 +106,41 @@ def do_print_unknown(self, args: argparse.Namespace, unknown: list[str]) -> None ## ------ Examples demonstrating how to use argparse subcommands ----- - sport_item_strs = ('Bat', 'Basket', 'Basketball', 'Football', 'Space Ball') - # create the top-level parser for the base command - base_parser = cmd2.Cmd2ArgumentParser() - base_subparsers = base_parser.add_subparsers(title='subcommands', help='subcommand help') - - # create the parser for the "foo" subcommand - parser_foo = base_subparsers.add_parser('foo', help='foo help') - parser_foo.add_argument('-x', type=int, default=1, help='integer') - parser_foo.add_argument('y', type=float, help='float') - parser_foo.add_argument('input_file', type=str, help='Input File') - - # create the parser for the "bar" subcommand - parser_bar = base_subparsers.add_parser('bar', help='bar help') - - bar_subparsers = parser_bar.add_subparsers(title='layer3', help='help for 3rd layer of commands') - parser_bar.add_argument('z', help='string') - - bar_subparsers.add_parser('apple', help='apple help') - bar_subparsers.add_parser('artichoke', help='artichoke help') - bar_subparsers.add_parser('cranberries', help='cranberries help') - - # create the parser for the "sport" subcommand - parser_sport = base_subparsers.add_parser('sport', help='sport help') - sport_arg = parser_sport.add_argument('sport', help='Enter name of a sport', choices=sport_item_strs) - - # create the top-level parser for the alternate command - # The alternate command doesn't provide its own help flag - base2_parser = cmd2.Cmd2ArgumentParser(add_help=False) - base2_subparsers = base2_parser.add_subparsers(title='subcommands', help='subcommand help') - - # create the parser for the "foo" subcommand - parser_foo2 = base2_subparsers.add_parser('foo', help='foo help') - parser_foo2.add_argument('-x', type=int, default=1, help='integer') - parser_foo2.add_argument('y', type=float, help='float') - parser_foo2.add_argument('input_file', type=str, help='Input File') - - # create the parser for the "bar" subcommand - parser_bar2 = base2_subparsers.add_parser('bar', help='bar help') - - bar2_subparsers = parser_bar2.add_subparsers(title='layer3', help='help for 3rd layer of commands') - parser_bar2.add_argument('z', help='string') - - bar2_subparsers.add_parser('apple', help='apple help') - bar2_subparsers.add_parser('artichoke', help='artichoke help') - bar2_subparsers.add_parser('cranberries', help='cranberries help') - - # create the parser for the "sport" subcommand - parser_sport2 = base2_subparsers.add_parser('sport', help='sport help') - sport2_arg = parser_sport2.add_argument('sport', help='Enter name of a sport', choices=sport_item_strs) - - # subcommand functions for the base command - def base_foo(self, args: argparse.Namespace) -> None: - """Foo subcommand of base command.""" - self.poutput(args.x * args.y) - - def base_bar(self, args: argparse.Namespace) -> None: - """Bar subcommand of base command.""" - self.poutput(f'(({args.z}))') - - def base_sport(self, args: argparse.Namespace) -> None: - """Sport subcommand of base command.""" - self.poutput(f'Sport is {args.sport}') - - # Set handler functions for the subcommands - parser_foo.set_defaults(func=base_foo) - parser_bar.set_defaults(func=base_bar) - parser_sport.set_defaults(func=base_sport) - - @cmd2.with_argparser(base_parser) + calculate_parser = cmd2.Cmd2ArgumentParser(description="Perform simple mathematical calculations.") + calculate_subparsers = calculate_parser.add_subparsers(title='operation', help='Available operations', required=True) + + # create the parser for the "add" subcommand + add_description = "Add two numbers" + add_parser = cmd2.Cmd2ArgumentParser("add", description=add_description) + add_parser.add_argument('num1', type=int, help='The first number') + add_parser.add_argument('num2', type=int, help='The second number') + + # create the parser for the "add" subcommand + subtract_description = "Subtract two numbers" + subtract_parser = cmd2.Cmd2ArgumentParser("subtract", description=subtract_description) + subtract_parser.add_argument('num1', type=int, help='The first number') + subtract_parser.add_argument('num2', type=int, help='The second number') + + # subcommand functions for the calculate command + @cmd2.as_subcommand_to('calculate', 'add', add_parser, help=add_description.lower()) + def add(self, args: argparse.Namespace) -> None: + """add subcommand of calculate command.""" + result = args.num1 + args.num2 + self.poutput(f"{args.num1} + {args.num2} = {result}") + + @cmd2.as_subcommand_to('calculate', 'subtract', subtract_parser, help=subtract_description.lower()) + def subtract(self, args: argparse.Namespace) -> None: + """subtract subcommand of calculate command.""" + result = args.num1 - args.num2 + self.poutput(f"{args.num1} - {args.num2} = {result}") + + @cmd2.with_argparser(calculate_parser) @cmd2.with_category(ARGPARSE_SUBCOMMANDS) - def do_base(self, args: argparse.Namespace) -> None: - """Base command help.""" - func = getattr(args, 'func', None) - if func is not None: - # Call whatever subcommand function was selected - func(self, args) - else: - # No subcommand was provided, so call help - self.do_help('base') - - @cmd2.with_argparser(base2_parser) - @cmd2.with_category(ARGPARSE_SUBCOMMANDS) - def do_alternate(self, args: argparse.Namespace) -> None: - """Alternate command help.""" - func = getattr(args, 'func', None) - if func is not None: - # Call whatever subcommand function was selected - func(self, args) - else: - # No subcommand was provided, so call help - self.do_help('alternate') + def do_calculate(self, args: argparse.Namespace) -> None: + """Calculate a simple mathematical operation on two integers.""" + handler = args.cmd2_handler.get() + handler(args) if __name__ == '__main__': From 707e5b5c0fa35b313462df40024f1a918cf9f69f Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Mon, 1 Sep 2025 19:42:58 -0400 Subject: [PATCH 79/89] Update cmd2.png to reflect 3.0 look and feel --- cmd2.png | Bin 209050 -> 384011 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/cmd2.png b/cmd2.png index a73df9b097b4765073a65fd07c228e2d1749017a..8f2ec005f4e546bdbeb8c60ff3dcc98613e7feb6 100644 GIT binary patch literal 384011 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 z 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 z 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 z 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

3@_~9`kQ!4% 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

=>_>0|uH>0E1u(Z_@x(IYr|}-T&FYv+ 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 z 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

2X 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 G5C0#MBb8?W literal 209050 zcmeFZg44EKh__jSm0-h@L%_c?keuZ2M0Pr*{K?6r8PM)UK#pFFHm=ul5kW*4d4nW0F z1gHoBvohe3%0=Qyc1Hopi?n5IGu22V)Y-t@LE4YP=Y_*Df)pTAYw9foXjMOHS`SYe z8-0-H3%8%P-45w+09n%DxXny0z{WH9W;p}e-m|_(mMq!3a+qULE4DY~zW^hb`DxNtD?4xmYsC2ku-B3^RI=uL}a(6n?B zTL`g~PH{)dyM>~TAMZR(+nB@>agNCVUmyEPe8Tg6!?`hw)uEWz={t&Umj9G7Xa;1o z+|=}gF_c2elE7-^17U&~GlDH`u+==5!~!2Pr4vdwAi;p6aIZqAeeaf+?zVAblQm?j z%r7DFAxV^4HzEA&fth<9{y5m1O$iq155)o8OA5u_Q%g?BAyiVC&#IBaBH1D=1Aof+ z8nsSxQqCx8AlM~GYWCFO{(Wq#aGnS4tTf5uRXXp7VIfe^+PiW_7VD9%Ku`C*kp=DK^boc;5yN5MwD$|lb)VgoMTd8>Qo32V%deKu(G zp(L1MOSC5ocI*m&c1OBAw98L&XU47q`hoPzmYtWTw?vUUhc)Lw6n#}MMpr!p>k{pn ztE}Y0$b!FTozUc!YdO2Pu--niUMvJRLj5`b1i0gigN{bSVyJ{OF3BHLb^7>co6kj}G6~N_-`%rRbs;!CQ`(G((U*&K3 zej(lVvyCCRWy6Wj?xcU<&m0qb(|I4QNQzRLqp0B6XPBg1pOV9b5u0jrB;2wm<{@_| z*(hC&lJ@*ZENeT&u+C@XwE{tBdD!gSDVO#(^k{&3=-RqNg6}sanfmmt3 z$@WPDEl|H}SVgNNc^%R(NFab2UB{*q5^>$0um~QY`Kg^ieulf>Dtk0ZPFV^>Pj>WDRY>4wue3y$p_$f zrnTtOD$imSXve z!yo)0Fq-QQM%TS^DOysBz<>)z$)}iiBOcs0f6Bxt8~;_ug!^T9kt}Nr-BY3y40}wW zFdd1x*e745vuQ5L++ap5M3b^nsuqsy{gU->1*u`mlDwl-Zy6deb;A;~)V_;V(Tj%Z zN!eu3eRrrL!AQpL=@{gaj|@02V`0Y{Xxo_aEW%{#EGQE$!qeNffZ|LAGA})_N>@pa^=^3ml(`rMNtyn# zILuM*uwab+$~Z?Vy3(AiILI`_Py)7QIAHz+t|0@FT|a&B9<9%BLpq!h@9AZ>(HPU1 z*qGgz+Dqmi5X}Hf1!o4FxL03a+TW>I5i31(x~Ix zv&Zz1gxu`n?ETMa8H=BrGsk~SIr_Nci>9}{*OL~P^^x!??3-{L+pq|KOz~Fz-IRsi zq-T2uR}rO7&uck`tVp|1yOhx^+0lxrHFLbGEeo}GbfXP9lV83~(0`%JN&C_)^39{5;=@wTDbCU5 zQXlEtX0&#s=7NX_ck=&`RPmsl_Vdr40gng$cnRLAzz19(n0psQk=~p>Rn*$fQE2od`A7IC%wfPV)~9cu4x1L5PnwC{5v%*( z)7G1(8!i05VIBwSlF?r$U*mc@UpQV=c#V73UOQi;T;kmn+|XZYUqsxvT#K*I*;UqG z*KwkM#MnR!!B{$Ka)rAxqnEeEw7qYu0jFgIW`t+pv~RViv{$z`Ggvb?NGS*2mFkq9 z4qRHJfd}1Cd)p+-&oq^duT~w^9fcps-#NQ;5}F(Ky{jy2muV@YDm-3}U&fEcd!ixl zdX#^ZH`6Dap+~0ob&p6C9mNxYGaXu9YpsQ)y6I!IFJ(XP3+{Wr(R1&mf>HlLd`q8XoPgqiqSJGTaxn)U5H9%=(@f#bv})L_@dOTJ@S4l^B%;Zv&4P z&$Owt*`;ZM>FHC~_xODSdn0>Rvo`jb_GcSEx0yB%hVO6ZY|_QT?9;=vN#S|# zvy<6j`~ZU1)PUhTL*^+K{4S#lA@Dl5^Pv3Jmth-CgK~pD|DhlTf+b9=fBWrG8}> zWZT_s)9a;;w7Tt&Ot30k(6G`~(t+rDIYcdg*r}egAea`@6R^`oxSw6UKlQOnu342z zv6!uinuzL;qT9T)8I{T|P`vcBoUw3C&QxL(`Asf0$;P`(#CHqX0;VV91Vl)s-FGaBKCwd$zZ; zmyNW#w7jvs@pWw3eTdSMQizZJmHCTGO@~d@=B&bKRTxBQ%BShbbs29q>{DC{GpJV4 zqTb`(SpM!8W98a3JaGr4<own$X%IIvjc^f36Dv7^zbf9_J}LhD6&QFf}jkktqaf~AYAid@drZ#+Vn&+?9& z@#%8tep_r`)bLKd(cX5Lg_DSOw_4n`Tn6n&fNqa&v#MQQ+H60+96*X|H1W7rZ*xw! zYF%Ys(>D!yAXb(7v;%4<4+Rg0nqRoz?sX2rvSFL)dt!WE#J8TM;-#(nOfdx5Vpv=m z!gD5oqiuS?_dWpEYJIG*rk5BkOX!v26W=zUjvd|&2|Jt&V(;Y)S;l%V$@J&Sg|_-vYN1D#WujZJ-1USn1Z2IK5dDX-qAK|f{;`Nwz>~lL zk}wF=8>L=^n?j$xQBnf1qSCkkOf+f$7Al2?dc@FZ{!YuGu>dgsC`ShXLd*e}f3;CY zy?Z4fT&-+TtpNZL zS7B7r%GA*i>}q9c?I7$b%J@eMVO088F()JVk0y>5qKr@_6|l68y(yTVgNuWUQ4Ajp z28-C6yb*pWBllN#R8Exft)ru@Fej&riwlPfFNcl287H@pkPs&q4<`=~1l0oK;AZV; z=nAoRxc}!zet$>C)WO)^+}6?D#v1(VyM{(KPL86CjK2o@`}%X9rmp7yjAZTb7g#6& zIe*n~a&vHT{{3xKSCL<(!Ybyjrk0vA=2j@2p~ess;1(45qy7J<=ARM&qbKyAp4@!g zT>shiKdSy`S2YJydubah)R>N9|E$+vo&U4)uZ|*|zd-*FRQ!qOKT1)E7Q+|e{Cm+* z8K0F2)H>3d%e;7vdZVc9*M(+=`eFI=jY^~Gm-cf=3#L8mPEEPjOgotTY?m zZ}bz(+Dm!DYxZX7@T0@wN3$ikvScc8VbZfP-#v817wrfwH=7qpNbsO66g~W-+{!J*Stl&7f#Bf6krO^t6}LjcCGZ{)|WGKIr2F< zbG^g0b^0(+t*2$ySk~Cf{q=3_h0gcR&}pO|YIHWY{Xhxs3DiUk;-!DfsEKdD^=Ro7TTNf8O%^1i6mrz2;nYQM)=l z{xglZez^S5?e9CO%apj6i51b=HEb!ZRoYD!hGr(WlYBDmS?@c_45q8>nw%Fj5 zW8LgdUCQS4*t(!e_k+5wX9eS@jE8Il@VAo+Z=0l;jnc*}zwfYManm#2gp)sfS>c%y zev~s-pvH!n*1D~Z2SPqPA7@g^HtY<(D)n=2h<=>6EP+q3Tki~>$zr0VAtsb+q04*I z)pY}27MmUwF8a6|;zWUE#E^`cSC>8+(FE38VIrInyM$k%;5h_&-_9K>R4m^?B9G3e zOURQ;_WD7iiQrm!}egDW~Eu*LcK%UfiAT5 z^F*OWp1)VKgwGU1*^loBp6l}9Gl?H~@D_9%yG9ZJVtqW!OY(;guWV9~+8k+5ToUz~ z-TOYsE-nOU_t>@@`s7`jvISh}imOy#Q(BDg(vIyLa}kg}FnDT1B0R%( z5gATubPA@n0OxfsQ=}YQ)*Qxk*HIne?*X3Q&dRQ5wr^mcRj^0B3!Myj8zlT#SNz)4 zcy{AgBOw+vHoe>O=eTe8I>Pj&n~+>F@X?jTbp%Bt%$M;Di)`U#yQpbWY|^(wPzZYu z#FyL%C-Dlld>j4T*MC|32Dy69*}}J1h_;-6g9!72p=0^@ENr$d>)$x;IDH1+csz7= z>$0XoBG(e}4pMx{(|!8wx;Q^x@O2&I^nz%8Rn1SlWPr1^KmO6HY(2xz$imXw z2^G5SK9U2>A3}w1xewD`x7xB$alA|1WtaRBzB$(H;qs%gduxcXMF#8Y;q%1)o6Bw< zpLTT3f=Zl+4>viiz(eaGbZpjk%a~=-^#>B@|8}duF~N2B4I*kO_w+@b^aFf|CY~$8 zx4z}b!hvIXs?w)-U+E{Z>$2*5A)+E_1e6yS?kOO!qdsDJUA#5wXew>$eQ*uE%b&Y* zpV)=3?7huWQT|@bF2|T+Edd2b{A{J!K$9E%Bb85>)>tY4WF3?K%op?NzuYV75&$Ew z&stvvR9y}Vi)%hqSue_c;J0EV0CK!PU-Ho3+65-UgLak{9i3@w@!`7Ycw$9ujXDUj zjh);<&Av{nZw981mADS<~v(p^!PB}kqCR0_8L7L<`QV_I1b+E;A_O50=LDSif zaqTJH#xIJgK-?2s%T_50pB!y!jIR*97{LF2x3$IsFx~6w&1Wle$i6>u-g{u|O~bbA z2``?oE3u+35G=jzhHOdpy>_k6cd?l4`U=rF=X*Gi)W4N_npo{|7SrdWoVb=e|6y!w z%;&y7J@5ZM)E~2uxtph5TjF4Yv3psf+t@#2N4CL6zD=+z5PxNBu^#9~wxJrt+<)IK zhvR31WuMWHQDexDaSyV~-O^T*p^ripuhV8;#luX7WbT5|{`cF@M+hwxPUPe_qh74_ zEQv#RBlS?OzwVgKadf7Kb+A?g1V0i6k!_dbiDBbCr{H}5Mu}#?7wM6obCW*wa_Oe; zcA);`=>I}0U);7pu28^>u16RGhcSy z3Yo28&O{VBj=x-5?`HYGAn5@kVBm79So=vj3I?s39?RZjcB z@B6@tC*prDl{y}}YnjDjL-k|p1?Zc>WQKu6NMX6Rz}u0w<`Y7SER;B3Qtn-5_uApFY zyw=U*aFLQz!fEHq|vziBv=JtE2ql0h>%t1=&7>$rSZSjt6 z>6`yh*w;Qi8*XU-o8`9^yf8qVPAAn^&G(ROY^VDSHI~aYov<@Y zPCG2;d+2X0qzIqyMJcud2+ifZA7guDec!Xa1R$_vz0UX1TQD(^&U&U(r}kNs!U;{B$!85HVTiY~^#RrkargwcWaIp*eyL7xE+q)@fBqy;D<1Y5b2w&2s35 z^x93IxXgwYE+U^dWx0iC_&~I4#;o(epwSi!Y=n%jP-}NUJoI+!wfU`<`R#(`3z_Th z!K3y~@g_yJ9A-HusoFs|MwE37zfX`0oI82@9p#5_FlSn?4;mGw`1$OFkJ`#cAY-eBV+U#9<8GQVQI5Tl241(FH2uQy6hr2wX%vpVMJ$*_Pc5%kz%z?Bkg;4yMTVrER7{b0VO-un2BM18kN{30}2 z+$M3aY|(kgIk)@Xj244^N2BKoACESFt~Et%Tzox5?7_R`N5|ZjSPf1NI2QpV zGo|4d_5GJ2zad%y$!F9M^p=77Er5jCAtWF@LkZni%z+)AO0oKU^GX`Y`h2kHW~RRp zEkNoEAZDQT7ew!}h2~#-ukfw{3Y{n)fHR0G9~d-|P&~%XTaLVkQUOKK*|!*~q>E}> z0*e>D20ZqRjl|Y~N7#U=fC|9#t2_Y%i1<9D2!Ua^G~Ui|GnD@1x$~l_nfXjci{eK? z?y+ay!cpjosWn#FlAjw&?U)uWFd|%ulphmko>45?b{paKi`n+AWqUXcWZIeyeWVJp zxi)Q-y+tV~dhJgqTP)^d~Z6}s(HDuPcz*rwR!h`T(;Mj_EZY` zeFo(8P*07!A8V_o+eQ+^B}Yob-0+Ka9&ugQDxrgL6SOi^S8HfDM+-kuhiLk z3i40D5SA+MOv{vBi(}`1?KB`;XWauiry8D`Fb;khSlFq7^t|pN!m#3Mv-_geiBtNi zZhKOBx7;TEV9O_wO7&b))3dgMeliG3C6Yb<#!D=T}zi_9Rdo0V+q^iNRgqS(e( zm&zL$vU?5E30eXjRa$h;bp$+}I1Rs%`DhEt^3q}Z{$wT-q()`ioOLVshE{y8dKWM) zQ{k(TRgOz_fY*2qTv;0TNm=lC3+j!sxC?|DL|=dNIi+Ub^Og}lfvk9=GwX==khs-} zA0x;PB6KZW;TT1bHkSUD5QF0&gsqOCKClRK{c+eK%mQ~Ctv944i{vfIHr5Yd8_xEl z#TZV#Df+qS2F?z4Te{U2!(?dR{T!rUx)A%uy-1Xs=O~B6%^}1KiQ%fc35l5*{rZCjKhX#VV26( ziRF%Z`@!qU@|N2TF};WzG;>MB;qV#p%`X%+SZ3eSGZ7h)nE@J~PU!gPkJ3v?R+1MC z{({2KhZ+snfyS~-a^Zc4xENGxFqvE08+BIJZDIpoRZTOf0!>@4b_(&O&bLx+^duuY zdco%`>6Y^iwPD1MAki+?Iu<7vrjdT>a9R;*E~hljIZaE4OCUsFDv zM^b{?$7ay=UUVmuGb9<4XmbV-U9I4Vuvk`p<0ofwS~;X)2Ujo=AknK;J?(UK8yxdr z%U(3V2_zMw|uwxfdnqg3)-yKa@pG49CeZ$XRaFeZ|STF zcN|~D5xoU$1LCj*&ylA@Y=Wt_;VuuJJJc%dt*tIR3yuEX~0mJA)D@B#7-tFc6sIhfo(C%^?3_ z+fqF(^#UZ_7g?>R28WIqSJTg~H8Kzb*EBbVZS6edniw(mcN|@=93#&dn{r>kC zTRwl>)$dgnj(7!TYhHgbDaO9Bc{HHK|JSkSrzHtMrwF83 zK6zYV+>#vNM;hpIv!!(eXD}*lJZMPV?Bc^UeZw750}j0eQXF^aVXk9@Hg4hE(KKWr z!Vrbd%3(o^rG7@-wJa9tv1mE$vPG1&DLq)ojApSDR}egdG0<#H>4&!FfiRX$(EOQz z3xwKDMkBB=assC8H3yDfV(}$WkF%0ZFwNL=Y(1*ESk~LOJhlYnQzT8eyll zprY1Q$W&8@c9S;?E9-mf#fHmgvByW%bk<~XdWPI{C11)YKPo1N_(tOhLd9gUL_V%* z)tChq+}q+6O>*^Ja$S#Nqr?n?G3c0;ba>`A+1Cqns@tv_ubcg?=-iOD!6U^#b{(D^9! zFk-vKi4W5VN^`xMYSUt;UZ8DRE{ip~)FsSDfJR{yi$w_Frj$u7^X!>IA46bCbgpG2 z1dvAgnK8CgV@O91bgY58WJA4ycPC+&03`-Ac+pxioYUrS`BMp=sRmEtG)?Ev%dDe7!r@ zXt#UP_u9crMU&iekNt!Or=v->XNbm(jg_AFo??k#0W*itPL4AC`B97-(-8V{zOX4w z@N?SHX^=a^0gRFxeY6R4q4awI)=w%D(eEJo@16r*T zMLuJqNx!tgWz$XX*H%8BuuPaLgNET!DKDK~{X(j@c4XXdo4{*2w8(ZY<;rrR9vGM2 zki5j=FKt9ka!(+DAu`AEcv^=dt@~o!Cs)S!@`FyOk*`?=en*q$H=GVnpW5M>@>bdX zR34&A%Ba-G>Y`L*Jn`0nCod>jhF<$fQ_4KSaDxQD7j(0l>{e`cFPF>+8_8oFNiZ${ z?(8ZEaR#dPW7#^CAF#5b<`+D7*b16nx!=$ zpz1waWrOIvWM_^Jz!QTEVu(-l{HC93KY;i6s#pg)wl68TT-G;7FwlbrEQRUc_7>d~ z3a-X;US^~*&yt7LFfBL_Kx!iid?|+`srgu~V+mF7ORT0!TIO$%9J&>%7iB+-lZbqdQp-XbGw)sWYk0T0 z#9hY7yVpe>od)u(THwb;_vy@~P|acB(Bd$~+tMZn{$y5AVFLZZr_Z1wymSXIzdRs=;{o$M&N4Na=%a)llM*#wY+|udse`fEv~<*ppHkFt;x1z$vTbqK zeNMI2=Qz!1e|l8T^1W2`p2B|bHdU1;T0;=e0bq1gm=%+~_(<5T2}#JPdrH24B}_V$ z>s2@NC@&xyi>RlO!U7c0hRjF4{cV6o*}1{&Qy1tJbow*tBA6o>dbO__l01339S4bevElDw{${9E04Jqwdb3Gy9-pn3JY|(|5k0U2#8~7MHjzyCC{ZK8+`p z9LK459~5C_c+y9L+p6eL`~u|$uQP5PIUP0Y!&Q42-*;}M&f-&&DSven~N0k z__cUltCw+vWZe676Y_*cT=9fGB$j@Yy0c&UaWHNIOWO&}RG;hq9Sqg$ZS6fHefAeLR6&|K0N5R}M}6^#}E)d$Oqw#Yj27>a3ZHG7_GX( zg2IAaqD>w*9m8j>Yi6Y(@W=wK_vE-2)Ce3at3wJ+ZU@xss z)cTxXNB?zK^7?!JE*rI7?Dw>BhXy%XMK3;bakIp4>i3c~M+hQc z?}wzyxTT8&^V0H433&hV`?QJs0oZ%LK@=K#$sXpR+(chGq{Q{xL@{Rj@I**x@}-5Rz$2BwJ`(siNbiDzMB5#G+UZqJ5wkLWwDOS%w&9Ga(+bJO%_K`ch0`SI%qb) zmgF&hHT1U~RATZj%Y;*^@NQs>=>64{e$S3C<2*fONZv%eU3L9T;mm$RcB)g_K;e!| zk+YDTrbA~gRqacK((nCDOixq0MJ9d}>CP4{sp)gGM1~!(67IJDpz=dBx_do}sdvwy zG{7kMu_DM4x)4RfZ{Knc@l!&F_~prJI99;(kKgmo(l*7eGH*vX{Fl9B?q2WWW7Vr; zm%dW`bYm7|l;UlR7$*=)J=3)zH}M++V#d*vUvlc|Nonw>d?HO`{==M;SB+_aQkSt@3;V|@TM!M1d_I^4>e-`w@ugAyj^~7NQ4l~Cw9fp!wg&m0^Jf+juL<{a7wG)_ zm7ps!Ld%!mauQZ!Kp4cW;1t~7(NseZ9o%lXU;}bmWmdc8Zd~CpXGLojBo#qRaEc;Y zOD4MY?Yf5cbcni6+bL~#CJPHq)xJ(EzFjtTN5=;rShTH`zM6!+;Xoq+NZ$Gj4?J}J zQe`o@BgQOw?FZG%kk75CEw~sWA`#ANT;n>a7PMOLQDTINt6hI3iAFE)ze!@(dk#xKHBX;24Q>b2S4WhCT-QJ{RyXisLonm~TG`WHse~CEhW>pysOz__8CCI_ znGJkQL);sre@hs9aN+Z05)|`X#5ATkAb_sX-VYR-jOViP>fWHD?*NY&SM)HAvoGra zgLF$*77SDuY`HLKvpbdU9;gO7M$eN^h_8AGrf|Xd+kodSpfX!l_7--7GNuPw*B!0i3E0Wwb*opRvP`sgS!u)Gn( zwG?`Hm)p7w%S4Gi1TCStKHS}M@u4_frf~my3YrC#VFWkM)bg=(JPXWwC8`BQP-uB< z%b!thKN!Qn>*VxD7-vID$syv30*!ZAy5?+NJcxeO%ch|5^=1vL!m-cj>B3l@sl0}BlMTVm-i0c&;`V_ za=9;+O~AAf@S3p5k~Gw@uAG~j5<}*KhhG)w5m;Q(8BrN5TTsSnGNBsRAyU*h+QL~A z1TpADH$$D06k97h+CSDsX6gZ7jxHrW)c?SH5gJ&ZW%=i-zL@#;DGTw@Fox8X_Ag-7;LF~;z zEs(LIb{3x1g;dM(n*vvU?~_-PVK)-V0(U4LxsxgI7QXP}NIhUzxciQiS7gO!o0lgsPp&j>Te`(W; z$k;#CM~QED+bqI=nF^c8E{cO;CWJGjy%~h)53wD9VBz);=nQ)$7!i_2T+^t?7`6jU z*3ix}K{;P6_AXWe$|FhIKwN@Rs?7Y>x6J*M*<7$QiXD@#d#AI+cW9;3ZQA`kj{p<| zeSzyb$sxL`5;qt$Wzr~N*{LL~;5(;I&x%ek#Sfy0k2B)zFBu`@#=T;KK1Tr{@{#R3 z_vK_vg(7T1i@kjf2n?xu-^P*lUCgx@9xsY79SOCGxyn)kXUn$Q`R0nX1q%DEP!74> z{uSxuSEzj~*z5Yeg4DZ`VQ<*y=HeK=Rt|U7i@T(o27`3PP&defVyy|w$8ZtF5zYuA zw((lpWrb8?Qr+A zlW_g4_VzQS{ADnPI{mOQC4A0-YHYp$P4bnEj=5=hH{}AzV^ypD6LbC~Dk=o%Ar$D~SPQQ~`!(;E>+mrD9d!vhQ&RSNU=qOh}c?Y7MUM&u$I`l;>5)!mPd~C~>I_hp^E&664Ivb#G$7 zGheORO};4M*fPzrz_)+0*Kpka>~IEhaql_40%ra|VnW{O{GbAff%O-5WF0a;K{o(< zuJ!R8NzrIPrl~u?X~bbagY3Igqi4$1w_%4{@DL73iQi_Uo<&Ka>OPHJyq_;jIy`Rj z>&e}V(_xUfm7m{Vz`P^!E1Qqbi#8<1LAwjAAst~TxegFrekeD?NEPs8&!SW*SoT$` zXjOn;7AMD4HWvxoyQZ}imK7q5&>run@`6M0^^>q;q6O891Aq46Wq-b+i+z;mvUY)I z=w4(NP@jmPbe6X96fJSO=4*V8Aq|omn}LB^E|~kc5Kftt|I?l8ZbFA};oPJ7 z2Q6Gyz480NbGj?yc7Dw^FB}5?{x8ODMNj9atR@-h(VLzf`F*t{Xq1wa8&U`&_zwJ-7YX}7 zeBku%Pm`pNnkw(>& z6dmXyv?q3Z>dfqwb|p7}H~OB(LFum_5sy+$V-!oG(b>1TQJWgA*9Z;K-9@M~Gh>pxISWkOK&d{327OT? z3+qRm;n$=#UoRNa)@x2uEi1wr#r7-3x$gi!9Lu2>tsbZO!mWG#L*y4)1&J#!YOgE_ zxIwgBRG~NudjUSKl{n{8Gfo68#$2OD3~9y+=Q?ZGXw&Qm&vbSTU=Ds^&DB#{10Ni% zgKQPO+hoG%5%?pB4QX8XX>R@2U zQ3}oj_(md5IVYT}A|zd7^3r2VwwB5*>rDL&qS08eUDfWeo|W2Pqfz}NE55%h{KdS% zbx%PwxL$3kHSy(&dUo-Gw@ygNJP6^7oEmLS#+PJy^~qstu$afYhSMRt>w#R7xERDDHPAo1}ZFES)FgdlZ7~xXT}_lAO0B^P`?e-Uo~;6&Ug) zjU{+hfYLxVDlF0V>vs8i0u=YV_mNlH^^d-_vS9vlKFxZalN$$LciV5$(CV-#ogLys z8q-BOj<`LC*Xb%>@H*M{tJ0;3{Q?CVLNgb7L8%mPCOOsrw(;+NA-+aUZA9c-pMoq2*~Uars% zVf^G3Up{fB?v9A;Ac}IO$q>(@wdRy?9{At~Y&d@(Vq3S?DExGQW50}~YM=-aSz;Uc|}hF$&2+%cVN7A&Vx^6B(C z)7H2K$)`k{&z)j&kv#OrIvlCp*-OXh6#sh} z_>{ryRcb*5?{~zWE*Fy)4gU(j=qsS`>OY-fKpNi#v$pZ!ds^J4N@oVacn#5~g$>h> zQ}*;}`nROtmJs#&iOFcDGw;r$M0y@)mk%U*O#*RgOm)gjd%0=0vhY}UFp<*kbds~|i*(R7x!aSVFja-;Y%Yx^>ZdNXfHzn$| zs!)VX;sxH_2O%N44P9Z!e9NtJ{Us&qIDYiV$H!X2S88!L_J+e=f8*7jIi^rB&4c7b z8$-v~66KRW4sthi1*9WX8K#Y4cW=4w0Jy2tyKc`U48`iuPZ%Cg=UU{y`l37(;_2Fq z(+YEag&FtC)+x^XD-P#UYRXG9x4-S-bIKAym(gYQQ{IW%+P#o1KECO0eTyf6GuUqt z!50&0j@gh;0Hl!7W2l3w;PD^kB!Ef&wh!FpV;&&j;-@478 ziLXHtI?3m0v+>%4g|JIjThqAbA@%yvV?4eTXkZRKe``Vsu%W8 z3eRk|RJDt}irv2{`oAWsqknZ|Ii?PGI-YJb`Xshxu}Ebc^6x?G>gMR1K={Z;ns_Qt zN@>RppgLKe@_OFJdf=b^^Iu0p71oo5Y7h1nhI1knKyW*z$p4DHe+nf&;l>ldha!|# z`murVpWNxp9Yw+a(A0mOrwICHN7KVGqot>|&;L29{_BL?m{mq9ta{rNmnxGvKRtOx z;j_<)g@tuO<2ATVC(QQXe<6uK3FG4Y?or^SL^@8my#~K2kMrJ)4<{YH&~N7X+ZFi_ zFn^S6q)4D(`QYh?jL`hnY2>})+nl4gIH!_4%xkCqJHqL|PWHi3DbtGPV1Tp%A4$X| z8j3m$SO#wkOG)TO{&h^$|DeI_XwvGr_A9aUr2fZ8`adT2S5^Xv>k5Q#u8HW(7PT(9 zmn4j4$#0kPP09RUPmzR2vg5vqJzVLTHH0xoP z{{HwtxN`DzmA?09E`5lZf7E{m_4f)*;QD1Ul5cJ2bG2rE2)tCcdXi|KXIo|$;f4<< zuRFr{SM0Wl!z5HyowNsQe`FLiX$^QBSgv)5=+552OStIo{A*VUEOZN?Q=Hr6K|i+c zQPCELp|{yI_f@hB<;CAofdBO`S5`m@D4^#cTjdvQQuR+#jdX9E=Jy{B;^j-D^6YZK=2BtxW<7s=r-}aB+c=;b#yIIG*N3K@ORT|NHWo;=@ z<$9nv>m2fb2zw8xrn+ux_z_V=ln4l@2q*|By+|)nX(CN}2azT<^bjCHrAwDCB`Ur5 z5?TbL1dz~MfY5vIA&`7|-uJujcc1&;G5(B^kupwBa(4Dwd#$O19|8VMBj!Q#;rk6BK1u1Y+^ zkb4bvr4NcMRm5(Cd-PQAN7XT@p+(GDKbfBAjrB?&Y6Dg5Yx~LIn%EXQW$cUXM<1~j zHDkPAv0RrwlDKP5#hvul?Y1R~wyynSf{eh(IQ!~A*p@fa(q0?S4FYH*a2X0mtsmxa z=$=bPK&<32vAa-Sab_$wD_TNLY}QBK9$T6rbK0irj?Xx?)-&+hdhQBuUjDrp6E_GX zJYh6*R}ALd94+E%7N0Z}n|8fwKVDk$95kkutCqpN$fcHmR`!3~(Q(L=K;qq&9&E+O z^nB$|wV6KKKiF^tb^EdUzm97m%bD7@jBlq-%-}~TY>WwzFbF8GK!%TYla<#|6Qgrg zRB_$z3;W@!(C1Eruk@3OV-fM3sCodA%WBqNC#f%kNx)(_?j=5xM|#VrezP-02;K`l zyBjIZW z?uR9H<0m_v_)wu9S@2A*<3<9?{OUd{d#um^8B!req zyeLW1H;hZb2Vw)PbgJ)e>zE=PtgGNj1dm|Ry$Ky~I{?9a-{lg-5eMj$1p2Y^{m8?t87ty_`(D8=Pcj8Rurt9ZxI{#?nsLpau35qgheu)s>?*h6|&C-;hImv#0Rof zbTSgS6GB;`uL$@7_WdKh$7DtGmZviun`Psw{mpZ<_kUo2h~9rxyXaiA9zf&^xDb-G zH)trU$(7k(3-m2ZLg{0+)2n$!iei2_*G)e%aj8JOa}r(qC0|rMuU4Aus$5tFpRdKG z{?ki7B9iyLZ#nnxj9;mwmi>F94^@{cc}kNsf%xfMQK#Jh(gG0Z^tny@`bi&v za6n~0lpDosQva6I|DrOA3oz ztc|l-T%r{2e4u@`HwFlMSVJ{j82c-rv_;!{$_=llof5iC%wfzBfJ5b8^&{6Rxw8|@ zM;XmrOSh`PK*)TKf%V!pm}4fq&0kwaG-=Z7m|yq>dcF&OD;W+W$#=uI!YUo$Ftlo-;-zv z30YVlzS+S%fTSAXo)8-WqW49&P7nYVUCqD;d9Kwbwd+>;hfhl#Jzb>>&|65#FDoL^ zWT0gxb#FCfEc1&oj!enqr(%Fx(yB1rvItL`v*;Z|yq-Ij7ofG)bap(wAQK1 zaN#HY15RNY<7a(bb~sS?pCw9v+0#&A7z+ zf!pM=i{t)2Ld^T?0SjKBo>wrX*bDJp_6$%29z3|(OF~;Y>Qhpzn68dhD zzei#Gi;jL)M)#6$!C-Wy+dp0RCvL8{y1MOcGSPxtRxI`moRJUsHD`UCvX^~gb{tXc zfLQ^r&9OuSbEuh1&-*J9XSurt{H_h&W@foXyE~M0TvAssQ=!Q7QGLgGTYiRo{2g+` zsxn@)XUIt0=oiU(zxLZ>$@A^NWDeEj&f)gBT=ld!s~XH1`O)Xz)0TdcCu{87xD>$d zYCvaEA6oXZ-b(o0z^8_k1t(ym_Ctmfz_ef9T?U4pLxC3Fx*0ccjxez{=(>X!!WS-A z*{Kh`7Ez*cX_=r#I8Zebgf_@)eIM72A1leY(c*LRkko~3P;LtvZf@**h)?5*`OGH; zPGV`?=I&+W8&WjFYS)Pj#82 zOfe~+=Z7YzT2c-VcSC$-kI8eVM>Ck}uhhmch<(9Dvuj14MXg$rJ!AxFXa>Sv*)bn; z!MqXM88MgI$bK;U?aU0rEDOz5%QWHTIZ9UuHVwjE_q{ybnSLoY7$8BgAZBu4)l>OB zMuN2^-a+o{*_U?Jyoj;d`D(M{m+w5>S!pxgzhkIM5-+%yLH|}C&!c5ZEaZHNDc@_6%n&G6YYb3eZcMb|T;a%fKTPr)i=A>bWOzOJ$J}vd zt*1U!w(2t`ZwX{`XR^P+m%NuPpYc_?rPG!4E5pn8Lx03cdEKp;WtB$>$e7zw_Ii53WMpOpuvTztKCfNT`TJj!U zP_HdpH7D)L{+NZpWz?@4N$r4`q>WSg%8#g%Oj+N0xv1e|PfoQ9Gb8;AIh8SxBY^JY z0|ELgh(FTpcGhR96*wG9+*`7k|8&h3gW)!w8a&c!N?fN|--hc+c zEPVMq1Nk-pO&9D`TIFh%YAP^!A~JaV?GFSHV-m5724jJ)SsxY`_Jnfrl1!Pn2GH;} zd+&cRw7*^43q9JW+=bqj=8OTO*I4-y?EPC!4g0KUsw+uJx&Di~U7%NBq|f0?0gf