diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6d8e0a7e4..e97ccf4d4 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 9c851c146..4f6a7daf2 100644 --- a/Makefile +++ b/Makefile @@ -33,8 +33,8 @@ typecheck: ## Perform type checking .PHONY: test test: ## Test the code with pytest. @echo "🚀 Testing code: Running pytest" - @uv run python -m pytest --cov --cov-config=pyproject.toml --cov-report=xml tests - @uv run python -m pytest --cov --cov-config=pyproject.toml --cov-report=xml tests_isolated + @uv run python -Xutf8 -m pytest --cov --cov-config=pyproject.toml --cov-report=xml tests + @uv run python -Xutf8 -m pytest --cov --cov-config=pyproject.toml --cov-report=xml tests_isolated .PHONY: docs-test docs-test: ## Test if documentation can be built without warnings or errors diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 5aeef609f..2418d2555 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 df20ceb6f..caa4aac55 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 1f9e55312..b3399b9f6 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 44e4ee29a..6e7daa3a7 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 ed0e24796..33e27aa20 100644 --- a/docs/features/builtin_commands.md +++ b/docs/features/builtin_commands.md @@ -86,7 +86,7 @@ always_show_hint False Display tab completion h debug True Show full traceback on exception echo False Echo command issued into output editor vi Program used by 'edit' -feedback_to_output False Include nonessentials in '|', '>' results +feedback_to_output False Include nonessentials in '|' and '>' results max_completion_items 50 Maximum number of CompletionItems to display during tab completion quiet False Don't print nonessential feedback diff --git a/examples/argparse_completion.py b/examples/argparse_completion.py index 43cad367b..961c720ac 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 cce63b8ef..8a99b54d4 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 df5159a36..40ab9abc3 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 b6713e879..27c965987 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 ec6ec91d0..2ab59d29b 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 703966c2b..9e698f648 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -12,7 +12,6 @@ import cmd2 from .conftest import ( - HELP_HISTORY, normalize, run_cmd, ) @@ -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 8344af818..adaa68e2f 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 c2bdf81fa..ec476bbfc 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