diff --git a/CHANGELOG.md b/CHANGELOG.md index 131f1074b..fed209083 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,9 @@ prompt is displayed. - `cmd2` no longer sets a default title for a subparsers group. If you desire a title, you will need to pass one in like this `parser.add_subparsers(title="subcommands")`. This is standard `argparse` behavior. + - `TextGroup` is now a standalone Rich renderable. + - Removed `formatter_creator` parameter from `TextGroup.__init__()`. + - Removed `Cmd2ArgumentParser.create_text_group()` method. - Enhancements - New `cmd2.Cmd` parameters - **auto_suggest**: (boolean) if `True`, provide fish shell style auto-suggestions. These diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 9aa9bd769..2b2e51539 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -14,6 +14,7 @@ from .argparse_completer import set_default_ap_completer_type from .argparse_custom import ( Cmd2ArgumentParser, + TextGroup, register_argparse_argument_parameter, set_default_argument_parser_type, ) @@ -59,6 +60,7 @@ 'DEFAULT_SHORTCUTS', # Argparse Exports 'Cmd2ArgumentParser', + 'TextGroup', 'register_argparse_argument_parameter', 'set_default_ap_completer_type', 'set_default_argument_parser_type', @@ -68,7 +70,7 @@ 'CommandSet', 'Statement', # Colors - "Color", + 'Color', # Completion 'Choices', 'CompletionItem', diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 8cb7c7e95..aeef1619d 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -243,8 +243,11 @@ def get_choices(self) -> Choices: ) from rich.console import ( + Console, + ConsoleOptions, Group, RenderableType, + RenderResult, ) from rich.table import Column from rich.text import Text @@ -506,7 +509,7 @@ def _ActionsContainer_add_argument( # noqa: N802 # Overwrite _ActionsContainer.add_argument with our patch -setattr(argparse._ActionsContainer, 'add_argument', _ActionsContainer_add_argument) +argparse._ActionsContainer.add_argument = _ActionsContainer_add_argument # type: ignore[method-assign] ############################################################################################################ @@ -560,6 +563,20 @@ def console(self, console: Cmd2RichArgparseConsole) -> None: """Set our console instance.""" self._console = console + def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: + """Provide this help formatter to renderables via the console.""" + if isinstance(console, Cmd2RichArgparseConsole): + old_formatter = console.help_formatter + console.help_formatter = self + try: + yield from super().__rich_console__(console, options) + finally: + console.help_formatter = old_formatter + else: + # Handle rendering on a console type other than Cmd2RichArgparseConsole. + # In this case, we don't set the help_formatter on the console. + yield from super().__rich_console__(console, options) + def _set_color(self, color: bool, **kwargs: Any) -> None: """Set the color for the help output. @@ -680,25 +697,33 @@ 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: + def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: """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() + formatter: Cmd2HelpFormatter | None = None + if isinstance(console, Cmd2RichArgparseConsole): + formatter = console.help_formatter + + # This occurs if the console is not a Cmd2RichArgparseConsole or if the + # TextGroup is printed directly instead of as part of an argparse help message. + if formatter is None: + # If console is the wrong type, then have Cmd2HelpFormatter create its own. + formatter = Cmd2HelpFormatter( + prog="", + console=console if isinstance(console, Cmd2RichArgparseConsole) else None, + ) styled_title = Text( type(formatter).group_name_formatter(f"{self.title}:"), @@ -708,7 +733,7 @@ def __rich__(self) -> Group: # Indent text like an argparse argument group does indented_text = ru.indent(self.text, formatter._indent_increment) - return Group(styled_title, indented_text) + yield Group(styled_title, indented_text) class Cmd2ArgumentParser(argparse.ArgumentParser): @@ -762,7 +787,7 @@ def __init__( add_help=add_help, allow_abbrev=allow_abbrev, exit_on_error=exit_on_error, - **kwargs, # added in Python 3.14 + **kwargs, ) self.ap_completer_type = ap_completer_type @@ -995,10 +1020,6 @@ def format_help(self) -> str: """Override to add a newline.""" return super().format_help() + '\n' - 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) - def _get_nargs_pattern(self, action: argparse.Action) -> str: """Override to support nargs ranges.""" nargs_range = action.get_nargs_range() # type: ignore[attr-defined] diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 0dcc5c6c3..dcef8f3d5 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -108,7 +108,10 @@ ) from . import rich_utils as ru from . import string_utils as su -from .argparse_custom import Cmd2ArgumentParser +from .argparse_custom import ( + Cmd2ArgumentParser, + TextGroup, +) from .clipboard import ( get_paste_buffer, write_to_paste_buffer, @@ -3725,7 +3728,7 @@ 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( + alias_parser.epilog = TextGroup( "See Also", "macro", ) @@ -3757,7 +3760,7 @@ def _build_alias_create_parser(cls) -> Cmd2ArgumentParser: "for the actual command the alias resolves to." ), ) - alias_create_parser.epilog = alias_create_parser.create_text_group("Notes", alias_create_notes) + alias_create_parser.epilog = TextGroup("Notes", alias_create_notes) # Add arguments alias_create_parser.add_argument('name', help='name of this alias') @@ -3941,7 +3944,7 @@ def _build_macro_parser() -> Cmd2ArgumentParser: "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( + macro_parser.epilog = TextGroup( "See Also", "alias", ) @@ -4004,7 +4007,7 @@ def _build_macro_create_parser(cls) -> Cmd2ArgumentParser: "This default behavior changes if custom completion for macro arguments has been implemented." ), ) - macro_create_parser.epilog = macro_create_parser.create_text_group("Notes", macro_create_notes) + macro_create_parser.epilog = TextGroup("Notes", macro_create_notes) # Add arguments macro_create_parser.add_argument('name', help='name of this macro') @@ -4511,7 +4514,7 @@ def do_shortcuts(self, _: argparse.Namespace) -> None: @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( + _eof_parser.epilog = TextGroup( "Note", "This command is for internal use and is not intended to be called from the command line.", ) @@ -5388,7 +5391,7 @@ def _persist_history(self) -> None: def _build_edit_parser(cls) -> Cmd2ArgumentParser: edit_description = "Run a text editor and optionally open a file with it." edit_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=edit_description) - edit_parser.epilog = edit_parser.create_text_group( + edit_parser.epilog = TextGroup( "Note", Text.assemble( "To set a new editor, run: ", @@ -5519,7 +5522,7 @@ def _build__relative_run_script_parser(cls) -> Cmd2ArgumentParser: ), ) - _relative_run_script_parser.epilog = _relative_run_script_parser.create_text_group( + _relative_run_script_parser.epilog = TextGroup( "Note", "This command is intended to be used from within a text script.", ) diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 1a58e4d04..786d8ff44 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -1,14 +1,19 @@ """Provides common utilities to support Rich in cmd2-based applications.""" import re +import threading from collections.abc import Mapping from enum import Enum from typing import ( IO, + TYPE_CHECKING, Any, TypedDict, ) +if TYPE_CHECKING: + from .argparse_custom import Cmd2HelpFormatter + from rich.box import SIMPLE_HEAD from rich.console import ( Console, @@ -345,6 +350,9 @@ class Cmd2RichArgparseConsole(Cmd2BaseConsole): and highlighting. Because rich-argparse does markup and highlighting without involving the console, disabling these settings does not affect the library's internal functionality. + + Additionally, this console serves as a context carrier for the active help formatter, + allowing renderables to access formatting settings during help generation. """ def __init__(self, *, file: IO[str] | None = None) -> None: @@ -360,6 +368,17 @@ def __init__(self, *, file: IO[str] | None = None) -> None: emoji=False, highlight=False, ) + self._thread_local = threading.local() + + @property + def help_formatter(self) -> 'Cmd2HelpFormatter | None': + """Return the active help formatter for this thread.""" + return getattr(self._thread_local, 'help_formatter', None) + + @help_formatter.setter + def help_formatter(self, value: 'Cmd2HelpFormatter | None') -> None: + """Set the active help formatter for this thread.""" + self._thread_local.help_formatter = value class Cmd2ExceptionConsole(Cmd2BaseConsole): diff --git a/ruff.toml b/ruff.toml index 706aa072b..64ccea3db 100644 --- a/ruff.toml +++ b/ruff.toml @@ -138,9 +138,6 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" mccabe.max-complexity = 49 [lint.per-file-ignores] -# Do not call setattr with constant attribute value -"cmd2/argparse_custom.py" = ["B010"] - # Ignore various warnings in examples/ directory "examples/*.py" = [ "ANN", # Ignore all type annotation rules in examples folder diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index 0ac393b49..7d71aad65 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -4,6 +4,7 @@ import sys import pytest +from rich.console import Console import cmd2 from cmd2 import ( @@ -22,6 +23,68 @@ from .conftest import run_cmd +def test_text_group_direct_cmd2() -> None: + """Print a TextGroup directly using a Cmd2RichArgparseConsole.""" + title = "Notes" + content = "Some text" + text_group = argparse_custom.TextGroup(title, content) + console = Cmd2RichArgparseConsole() + with console.capture() as capture: + console.print(text_group) + output = capture.get() + assert "Notes:" in output + assert " Some text" in output + + +def test_text_group_direct_plain() -> None: + """Print a TextGroup directly not using a Cmd2RichArgparseConsole.""" + title = "Notes" + content = "Some text" + text_group = argparse_custom.TextGroup(title, content) + console = Console() + with console.capture() as capture: + console.print(text_group) + output = capture.get() + assert "Notes:" in output + assert " Some text" in output + + +def test_text_group_in_parser_cmd2(capsys) -> None: + """Print a TextGroup with argparse using a Cmd2RichArgparseConsole.""" + parser = Cmd2ArgumentParser(prog="test") + parser.epilog = argparse_custom.TextGroup("Notes", "Some text") + + # Render help + parser.print_help() + out, _ = capsys.readouterr() + + assert "Notes:" in out + assert " Some text" in out + + +def test_text_group_in_parser_plain(capsys) -> None: + """Print a TextGroup with argparse not using a Cmd2RichArgparseConsole.""" + + class CustomParser(Cmd2ArgumentParser): + from typing import Any + + def _get_formatter(self, **kwargs: Any) -> Cmd2HelpFormatter: + """Overwrite the formatter's console with a plain one.""" + formatter = super()._get_formatter(**kwargs) + formatter.console = Console() + return formatter + + parser = CustomParser(prog="test") + parser.epilog = argparse_custom.TextGroup("Notes", "Some text") + + # Render help + parser.print_help() + out, _ = capsys.readouterr() + + assert "Notes:" in out + assert " Some text" in out + + class ApCustomTestApp(cmd2.Cmd): """Test app for cmd2's argparse customization"""