From 8e64642280f4138ec824a73379b9a0e8bfb2b90b Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 26 Aug 2025 10:50:46 -0400 Subject: [PATCH 1/6] 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 99cabc2cc..56ebb0d71 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 a42f09d0e88b130edad44c30f65c3b1c903064af Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 26 Aug 2025 11:26:12 -0400 Subject: [PATCH 2/6] 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 d069be7d7..200d0c1ce 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 42dabd4051c34d0dcb3ad1ed18dc3a028d598771 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 26 Aug 2025 12:57:47 -0400 Subject: [PATCH 3/6] 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 200d0c1ce..555908a6e 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 a5545b859a27f989b97a1dd32c1bfff70d617be8 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 26 Aug 2025 13:15:00 -0400 Subject: [PATCH 4/6] 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 38f84a73d..e4cdab795 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 b093bc9045ea771dc3ee32325fb42a0cda49f514 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 26 Aug 2025 15:20:06 -0400 Subject: [PATCH 5/6] 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 555908a6e..d60b752bb 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 c07f5dc8b9f06fe3e8e02cbc251888420bb94085 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 26 Aug 2025 15:35:18 -0400 Subject: [PATCH 6/6] 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 477a6ab8e..20001df13 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.