diff --git a/CHANGELOG.md b/CHANGELOG.md index b12238143..5cf9b497f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,7 +84,18 @@ prompt is displayed. - `TextGroup` is now a standalone Rich renderable. - Removed `formatter_creator` parameter from `TextGroup.__init__()`. - Removed `Cmd2ArgumentParser.create_text_group()` method. - - Renamed `argparse_custom` module to `argparse_utils`. + - `argparse` and `Rich` integration refactoring: + - Renamed `argparse_custom` module to `argparse_utils`. + - Moved the following classes from `argparse_utils` to `rich_utils`: + - `Cmd2HelpFormatter` + - `ArgumentDefaultsCmd2HelpFormatter` + - `MetavarTypeCmd2HelpFormatter` + - `RawDescriptionCmd2HelpFormatter` + - `RawTextCmd2HelpFormatter` + - `TextGroup` + - Replaced the global `APP_THEME` constant in `rich_utils.py` with `get_theme()` and + `set_theme()` functions to support lazy initialization and safer in-place updates of the + theme. - 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 8259b1629..3505a0ed1 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -14,7 +14,6 @@ from .argparse_completer import set_default_ap_completer_type from .argparse_utils import ( Cmd2ArgumentParser, - TextGroup, register_argparse_argument_parameter, set_default_argument_parser_type, ) @@ -45,7 +44,17 @@ ) from .parsing import Statement from .py_bridge import CommandResult -from .rich_utils import RichPrintKwargs +from .rich_utils import ( + ArgumentDefaultsCmd2HelpFormatter, + Cmd2HelpFormatter, + MetavarTypeCmd2HelpFormatter, + RawDescriptionCmd2HelpFormatter, + RawTextCmd2HelpFormatter, + RichPrintKwargs, + TextGroup, + get_theme, + set_theme, +) from .string_utils import stylize from .styles import Cmd2Style from .utils import ( @@ -60,7 +69,6 @@ 'DEFAULT_SHORTCUTS', # Argparse Exports 'Cmd2ArgumentParser', - 'TextGroup', 'register_argparse_argument_parameter', 'set_default_ap_completer_type', 'set_default_argument_parser_type', @@ -91,7 +99,15 @@ 'rich_utils', 'string_utils', # Rich Utils + 'ArgumentDefaultsCmd2HelpFormatter', + 'Cmd2HelpFormatter', + 'get_theme', + 'MetavarTypeCmd2HelpFormatter', + 'RawDescriptionCmd2HelpFormatter', + 'RawTextCmd2HelpFormatter', 'RichPrintKwargs', + 'set_theme', + 'TextGroup', # String Utils 'stylize', # Styles, diff --git a/cmd2/argparse_utils.py b/cmd2/argparse_utils.py index aeef1619d..a6a029b92 100644 --- a/cmd2/argparse_utils.py +++ b/cmd2/argparse_utils.py @@ -231,38 +231,21 @@ def get_choices(self) -> Choices: from collections.abc import ( Callable, Iterable, - Iterator, Sequence, ) from typing import ( TYPE_CHECKING, Any, - ClassVar, NoReturn, cast, ) -from rich.console import ( - Console, - ConsoleOptions, - Group, - RenderableType, - RenderResult, -) +from rich.console import RenderableType from rich.table import Column -from rich.text import Text -from rich_argparse import ( - ArgumentDefaultsRichHelpFormatter, - MetavarTypeRichHelpFormatter, - RawDescriptionRichHelpFormatter, - RawTextRichHelpFormatter, - RichHelpFormatter, -) from . import constants -from . import rich_utils as ru from .completion import CompletionItem -from .rich_utils import Cmd2RichArgparseConsole +from .rich_utils import Cmd2HelpFormatter from .styles import Cmd2Style from .types import ( CmdOrSetT, @@ -512,230 +495,6 @@ def _ActionsContainer_add_argument( # noqa: N802 argparse._ActionsContainer.add_argument = _ActionsContainer_add_argument # type: ignore[method-assign] -############################################################################################################ -# Unless otherwise noted, everything below this point are copied from Python's -# argparse implementation with minor tweaks to adjust output. -# Changes are noted if it's buried in a block of copied code. Otherwise the -# function will check for a special case and fall back to the parent function -############################################################################################################ - - -class Cmd2HelpFormatter(RichHelpFormatter): - """Custom help formatter to configure ordering of help text.""" - - # Disable automatic highlighting in the help text. - highlights: ClassVar[list[str]] = [] - - # Disable markup rendering in usage, help, description, and epilog text. - # cmd2's built-in commands do not escape opening brackets in their help text - # and therefore rely on these settings being False. If you desire to use - # markup in your help text, inherit from Cmd2HelpFormatter and override - # these settings in that child class. - usage_markup: ClassVar[bool] = False - 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: int | None = None, - *, - console: Cmd2RichArgparseConsole | None = None, - **kwargs: Any, - ) -> None: - """Initialize Cmd2HelpFormatter.""" - super().__init__(prog, indent_increment, max_help_position, width, console=console, **kwargs) - - # Recast to assist type checkers - self._console: Cmd2RichArgparseConsole | None - - @property # type: ignore[override] - def console(self) -> Cmd2RichArgparseConsole: - """Return our console instance.""" - if self._console is None: - self._console = Cmd2RichArgparseConsole() - return self._console - - @console.setter - 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. - - This override is needed because Python 3.15 added a 'file' keyword argument - to _set_color() which some versions of RichHelpFormatter don't support. - """ - # Argparse didn't add color support until 3.14 - if sys.version_info < (3, 14): - return - - try: # type: ignore[unreachable] - super()._set_color(color, **kwargs) - except TypeError: - # Fallback for older versions of RichHelpFormatter that don't support keyword arguments - super()._set_color(color) - - def _build_nargs_range_str(self, nargs_range: tuple[int, int | float]) -> str: - """Build nargs range string for help text.""" - if nargs_range[1] == constants.INFINITY: - # {min+} - range_str = f"{{{nargs_range[0]}+}}" - else: - # {min..max} - range_str = f"{{{nargs_range[0]}..{nargs_range[1]}}}" - - return range_str - - def _format_args(self, action: argparse.Action, default_metavar: str) -> str: - """Override to handle cmd2's custom nargs formatting. - - All formats in this function need to be handled by _rich_metavar_parts(). - """ - get_metavar = self._metavar_formatter(action, default_metavar) - - # Handle nargs specified as a range - nargs_range = action.get_nargs_range() # type: ignore[attr-defined] - if nargs_range is not None: - arg_str = '%s' % get_metavar(1) # noqa: UP031 - range_str = self._build_nargs_range_str(nargs_range) - return f"{arg_str}{range_str}" - - # When nargs is just a number, argparse repeats the arg in the help text. - # For instance, when nargs=5 the help text looks like: 'command arg arg arg arg arg'. - # To make this less verbose, format it like: 'command arg{5}'. - # Do not customize the output when metavar is a tuple of strings. Allow argparse's - # formatter to handle that instead. - if not isinstance(action.metavar, tuple) and isinstance(action.nargs, int) and action.nargs > 1: - arg_str = '%s' % get_metavar(1) # noqa: UP031 - return f"{arg_str}{{{action.nargs}}}" - - # Fallback to parent for all other cases - return super()._format_args(action, default_metavar) - - def _rich_metavar_parts( - self, - action: argparse.Action, - default_metavar: str, - ) -> Iterator[tuple[str, bool]]: - """Override to handle all cmd2-specific formatting in _format_args().""" - get_metavar = self._metavar_formatter(action, default_metavar) - - # Handle nargs specified as a range - nargs_range = action.get_nargs_range() # type: ignore[attr-defined] - if nargs_range is not None: - yield "%s" % get_metavar(1), True # noqa: UP031 - yield self._build_nargs_range_str(nargs_range), False - return - - # Handle specific integer nargs (e.g., nargs=5 -> arg{5}) - if not isinstance(action.metavar, tuple) and isinstance(action.nargs, int) and action.nargs > 1: - yield "%s" % get_metavar(1), True # noqa: UP031 - yield f"{{{action.nargs}}}", False - return - - # Fallback to parent for all other cases - yield from super()._rich_metavar_parts(action, default_metavar) - - -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, - ) -> None: - """TextGroup initializer. - - :param title: the group's title - :param text: the group's text (string or object that may be rendered by Rich) - """ - self.title = title - self.text = text - - 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: 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}:"), - style=formatter.styles["argparse.groups"], - ) - - # Indent text like an argparse argument group does - indented_text = ru.indent(self.text, formatter._indent_increment) - - yield Group(styled_title, indented_text) - - class Cmd2ArgumentParser(argparse.ArgumentParser): """Custom ArgumentParser class that improves error and help output.""" diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 58cb7b115..90d4e4d0e 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -108,10 +108,7 @@ ) from . import rich_utils as ru from . import string_utils as su -from .argparse_utils import ( - Cmd2ArgumentParser, - TextGroup, -) +from .argparse_utils import Cmd2ArgumentParser from .clipboard import ( get_paste_buffer, write_to_paste_buffer, @@ -160,6 +157,7 @@ Cmd2GeneralConsole, Cmd2SimpleTable, RichPrintKwargs, + TextGroup, ) from .styles import Cmd2Style from .types import ( @@ -5122,7 +5120,7 @@ def _build_history_parser(cls) -> Cmd2ArgumentParser: history_description = "View, run, edit, save, or clear previously entered commands." history_parser = argparse_utils.DEFAULT_ARGUMENT_PARSER( - description=history_description, formatter_class=argparse_utils.RawTextCmd2HelpFormatter + description=history_description, formatter_class=ru.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') diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 897d070f8..0169244d0 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -1,26 +1,31 @@ """Provides common utilities to support Rich in cmd2-based applications.""" +import argparse import re +import sys import threading -from collections.abc import Mapping +from collections.abc import ( + Iterator, + Mapping, +) from enum import Enum from typing import ( IO, - TYPE_CHECKING, Any, + ClassVar, TypedDict, ) -if TYPE_CHECKING: - from .argparse_utils import Cmd2HelpFormatter - from rich.box import SIMPLE_HEAD from rich.console import ( Console, + ConsoleOptions, ConsoleRenderable, + Group, JustifyMethod, OverflowMethod, RenderableType, + RenderResult, ) from rich.padding import Padding from rich.pretty import is_expandable @@ -32,9 +37,17 @@ ) from rich.text import Text from rich.theme import Theme -from rich_argparse import RichHelpFormatter +from rich_argparse import ( + ArgumentDefaultsRichHelpFormatter, + MetavarTypeRichHelpFormatter, + RawDescriptionRichHelpFormatter, + RawTextRichHelpFormatter, + RichHelpFormatter, +) +from . import constants from .styles import ( + DEFAULT_ARGPARSE_STYLES, DEFAULT_CMD2_STYLES, Cmd2Style, ) @@ -66,71 +79,287 @@ def __repr__(self) -> str: ALLOW_STYLE = AllowStyle.TERMINAL -def _create_default_theme() -> Theme: - """Create a default theme for the application. +class Cmd2HelpFormatter(RichHelpFormatter): + """Custom help formatter to configure ordering of help text.""" - This theme combines the default styles from cmd2, rich-argparse, and Rich. + # Have our own copy of the styles so set_theme() can synchronize them with + # the cmd2 application theme without overwriting RichHelpFormatter's defaults. + styles: ClassVar[dict[str, StyleType]] = DEFAULT_ARGPARSE_STYLES.copy() + + # Disable automatic highlighting in the help text. + highlights: ClassVar[list[str]] = [] + + # Disable markup rendering in usage, help, description, and epilog text. + # cmd2's built-in commands do not escape opening brackets in their help text + # and therefore rely on these settings being False. If you desire to use + # markup in your help text, inherit from Cmd2HelpFormatter and override + # these settings in that child class. + usage_markup: ClassVar[bool] = False + 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: int | None = None, + *, + console: "Cmd2RichArgparseConsole | None" = None, + **kwargs: Any, + ) -> None: + """Initialize Cmd2HelpFormatter.""" + super().__init__(prog, indent_increment, max_help_position, width, console=console, **kwargs) + + # Recast to assist type checkers + self._console: Cmd2RichArgparseConsole | None + + @property # type: ignore[override] + def console(self) -> "Cmd2RichArgparseConsole": + """Return our console instance.""" + if self._console is None: + self._console = Cmd2RichArgparseConsole() + return self._console + + @console.setter + 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. + + This override is needed because Python 3.15 added a 'file' keyword argument + to _set_color() which some versions of RichHelpFormatter don't support. + """ + # Argparse didn't add color support until 3.14 + if sys.version_info < (3, 14): + return + + try: # type: ignore[unreachable] + super()._set_color(color, **kwargs) + except TypeError: + # Fallback for older versions of RichHelpFormatter that don't support keyword arguments + super()._set_color(color) + + def _build_nargs_range_str(self, nargs_range: tuple[int, int | float]) -> str: + """Build nargs range string for help text.""" + if nargs_range[1] == constants.INFINITY: + # {min+} + range_str = f"{{{nargs_range[0]}+}}" + else: + # {min..max} + range_str = f"{{{nargs_range[0]}..{nargs_range[1]}}}" + + return range_str + + def _format_args(self, action: argparse.Action, default_metavar: str) -> str: + """Override to handle cmd2's custom nargs formatting. + + All formats in this function need to be handled by _rich_metavar_parts(). + """ + get_metavar = self._metavar_formatter(action, default_metavar) + + # Handle nargs specified as a range + nargs_range = action.get_nargs_range() # type: ignore[attr-defined] + if nargs_range is not None: + arg_str = '%s' % get_metavar(1) # noqa: UP031 + range_str = self._build_nargs_range_str(nargs_range) + return f"{arg_str}{range_str}" + + # When nargs is just a number, argparse repeats the arg in the help text. + # For instance, when nargs=5 the help text looks like: 'command arg arg arg arg arg'. + # To make this less verbose, format it like: 'command arg{5}'. + # Do not customize the output when metavar is a tuple of strings. Allow argparse's + # formatter to handle that instead. + if not isinstance(action.metavar, tuple) and isinstance(action.nargs, int) and action.nargs > 1: + arg_str = '%s' % get_metavar(1) # noqa: UP031 + return f"{arg_str}{{{action.nargs}}}" + + # Fallback to parent for all other cases + return super()._format_args(action, default_metavar) + + def _rich_metavar_parts( + self, + action: argparse.Action, + default_metavar: str, + ) -> Iterator[tuple[str, bool]]: + """Override to handle all cmd2-specific formatting in _format_args().""" + get_metavar = self._metavar_formatter(action, default_metavar) + + # Handle nargs specified as a range + nargs_range = action.get_nargs_range() # type: ignore[attr-defined] + if nargs_range is not None: + yield "%s" % get_metavar(1), True # noqa: UP031 + yield self._build_nargs_range_str(nargs_range), False + return + + # Handle specific integer nargs (e.g., nargs=5 -> arg{5}) + if not isinstance(action.metavar, tuple) and isinstance(action.nargs, int) and action.nargs > 1: + yield "%s" % get_metavar(1), True # noqa: UP031 + yield f"{{{action.nargs}}}", False + return + + # Fallback to parent for all other cases + yield from super()._rich_metavar_parts(action, default_metavar) + + +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. """ - app_styles = DEFAULT_CMD2_STYLES.copy() - app_styles.update(RichHelpFormatter.styles.copy()) - return Theme(app_styles, inherit=True) + + def __init__( + self, + title: str, + text: RenderableType, + ) -> None: + """TextGroup initializer. + + :param title: the group's title + :param text: the group's text (string or object that may be rendered by Rich) + """ + self.title = title + self.text = text + + 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: 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}:"), + style=formatter.styles["argparse.groups"], + ) + + # Indent text like an argparse argument group does + indented_text = indent(self.text, formatter._indent_increment) + + yield Group(styled_title, indented_text) + + +# The application-wide theme. Use get_theme() and set_theme() to access it. +_APP_THEME: Theme | None = None + + +def get_theme() -> Theme: + """Get the application-wide theme. Initializes it on the first call.""" + global _APP_THEME # noqa: PLW0603 + if _APP_THEME is None: + _APP_THEME = _create_default_theme() + return _APP_THEME def set_theme(styles: Mapping[str, StyleType] | None = None) -> None: """Set the Rich theme used by cmd2. + This function performs an in-place update of the existing theme's + styles. This ensures that any Console objects already using the theme + will reflect the changes immediately without needing to be recreated. + 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 + theme = get_theme() # Start with a fresh copy of the default styles. - app_styles: dict[str, StyleType] = {} - app_styles.update(_create_default_theme().styles) + unparsed_styles: dict[str, StyleType] = {} + unparsed_styles.update(_create_default_theme().styles) - # Incorporate custom styles. + # Add the custom styles, which may contain unparsed strings if styles is not None: - app_styles.update(styles) + unparsed_styles.update(styles) - APP_THEME = Theme(app_styles) - - # 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] + # Use Rich's Theme class to perform the parsing + parsed_styles = Theme(unparsed_styles).styles + # Perform the in-place update with the results + theme.styles.clear() + theme.styles.update(parsed_styles) -# The application-wide theme. You can change it with set_theme(). -APP_THEME = _create_default_theme() - - -class RichPrintKwargs(TypedDict, total=False): - """Infrequently used Rich Console.print() keyword arguments. + # Synchronize rich-argparse styles with the main application theme. + for name in Cmd2HelpFormatter.styles.keys() & theme.styles.keys(): + Cmd2HelpFormatter.styles[name] = theme.styles[name] - These arguments are supported by cmd2's print methods (e.g., poutput()) - via their ``rich_print_kwargs`` parameter. - See Rich's Console.print() documentation for full details: - https://rich.readthedocs.io/en/stable/reference/console.html#rich.console.Console.print +def _create_default_theme() -> Theme: + """Create a default theme for the application. - Note: All fields are optional (total=False). If a key is not present, - Rich's default behavior for that argument will apply. + This theme combines the default styles from cmd2, rich-argparse, and Rich. """ - - overflow: OverflowMethod | None - no_wrap: bool | None - width: int | None - height: int | None - crop: bool - new_line_start: bool + app_styles = DEFAULT_CMD2_STYLES.copy() + app_styles.update(DEFAULT_ARGPARSE_STYLES) + return Theme(app_styles, inherit=True) class Cmd2BaseConsole(Console): """Base class for all cmd2 Rich consoles. This class handles the core logic for managing Rich behavior based on - cmd2's global settings, such as `ALLOW_STYLE` and `APP_THEME`. + cmd2's global settings, such as ALLOW_STYLE and the application theme. """ def __init__( @@ -158,13 +387,11 @@ def __init__( "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. + # Don't allow a theme to be passed in, as it is controlled by get_theme() and set_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()." - ) + raise TypeError("Passing 'theme' is not allowed. Its behavior is controlled by get_theme() and set_theme().") # Store the configuration key used by cmd2 to cache this console. self._config_key = self._build_config_key(file=file, **kwargs) @@ -191,7 +418,7 @@ def __init__( color_system="truecolor" if allow_style else None, force_terminal=force_terminal, force_interactive=force_interactive, - theme=APP_THEME, + theme=get_theme(), **kwargs, ) @@ -203,7 +430,7 @@ def _build_config_key( ) -> tuple[Any, ...]: """Build a key representing the settings used to initialize a console. - This key includes the file identity, global settings (ALLOW_STYLE, APP_THEME), + This key includes the file identity, global settings (ALLOW_STYLE, application theme), and any other settings passed in via kwargs. :param file: file stream being checked @@ -212,7 +439,7 @@ def _build_config_key( return ( id(file), ALLOW_STYLE, - id(APP_THEME), + id(get_theme()), tuple(sorted(kwargs.items())), ) @@ -404,6 +631,27 @@ def __init__(self, *, file: IO[str] | None = None) -> None: ) +class RichPrintKwargs(TypedDict, total=False): + """Infrequently used Rich Console.print() keyword arguments. + + These arguments are supported by cmd2's print methods (e.g., poutput()) + via their ``rich_print_kwargs`` parameter. + + See Rich's Console.print() documentation for full details: + 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, + Rich's default behavior for that argument will apply. + """ + + overflow: OverflowMethod | None + no_wrap: bool | None + width: int | None + height: int | None + crop: bool + new_line_start: bool + + class Cmd2SimpleTable(Table): """A clean, lightweight Rich Table tailored for cmd2's internal use.""" @@ -443,7 +691,7 @@ def rich_text_to_string(text: Text) -> str: color_system="truecolor", soft_wrap=True, no_color=False, - theme=APP_THEME, + theme=get_theme(), ) with console.capture() as capture: console.print(text, end="") diff --git a/cmd2/styles.py b/cmd2/styles.py index 4fb86d72b..15489d46e 100644 --- a/cmd2/styles.py +++ b/cmd2/styles.py @@ -30,6 +30,7 @@ Style, StyleType, ) +from rich_argparse import RichHelpFormatter if sys.version_info >= (3, 11): from enum import StrEnum @@ -58,7 +59,8 @@ class Cmd2Style(StrEnum): WARNING = "cmd2.warning" # Warning text (used by pwarning()) -# Default styles used by cmd2. Tightly coupled with the Cmd2Style enum. +# Default styles used by cmd2. Used to perform theme resets. +# 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), @@ -68,3 +70,7 @@ class Cmd2Style(StrEnum): Cmd2Style.TABLE_BORDER: Style(color=Color.BRIGHT_GREEN), Cmd2Style.WARNING: Style(color=Color.BRIGHT_YELLOW), } + +# Default styles for argparse output. Used to perform theme resets. +# Any cmd2-specific settings or overrides should be added to this dictionary. +DEFAULT_ARGPARSE_STYLES = RichHelpFormatter.styles.copy() diff --git a/tests/test_argparse_utils.py b/tests/test_argparse_utils.py index a303558a4..8510432f2 100644 --- a/tests/test_argparse_utils.py +++ b/tests/test_argparse_utils.py @@ -4,7 +4,6 @@ import sys import pytest -from rich.console import Console import cmd2 from cmd2 import ( @@ -14,77 +13,13 @@ constants, ) from cmd2.argparse_utils import ( - Cmd2HelpFormatter, build_range_error, register_argparse_argument_parameter, ) -from cmd2.rich_utils import Cmd2RichArgparseConsole 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_utils.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_utils.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_utils.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_utils.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""" @@ -304,8 +239,6 @@ def test_apcustom_narg_tuple_other_ranges() -> None: def test_apcustom_print_message(capsys) -> None: - import sys - test_message = 'The test message' # Specify the file @@ -570,50 +503,6 @@ def test_completion_items_as_choices(capsys) -> None: assert 'invalid choice: 3 (choose from 1, 2)' in err -def test_formatter_console() -> None: - # self._console = console (inside console.setter) - formatter = Cmd2HelpFormatter(prog='test') - new_console = Cmd2RichArgparseConsole() - formatter.console = new_console - assert formatter._console is new_console - - -@pytest.mark.skipif( - sys.version_info < (3, 14), - reason="Argparse didn't support color until Python 3.14", -) -def test_formatter_set_color(mocker) -> None: - formatter = Cmd2HelpFormatter(prog='test') - - # return (inside _set_color if sys.version_info < (3, 14)) - mocker.patch('cmd2.argparse_utils.sys.version_info', (3, 13, 0)) - # This should return early without calling super()._set_color - mock_set_color = mocker.patch('rich_argparse.RichHelpFormatter._set_color') - formatter._set_color(True) - mock_set_color.assert_not_called() - - # except TypeError and super()._set_color(color) - mocker.patch('cmd2.argparse_utils.sys.version_info', (3, 15, 0)) - - # Reset mock and make it raise TypeError when called with kwargs - mock_set_color.reset_mock() - - def side_effect(color, **kwargs): - if kwargs: - raise TypeError("unexpected keyword argument 'file'") - return - - mock_set_color.side_effect = side_effect - - # This call should trigger the TypeError and then the fallback call - formatter._set_color(True, file=sys.stdout) - - # It should have been called twice: once with kwargs (failed) and once without (fallback) - assert mock_set_color.call_count == 2 - mock_set_color.assert_any_call(True, file=sys.stdout) - mock_set_color.assert_any_call(True) - - def test_update_prog() -> None: """Test Cmd2ArgumentParser.update_prog() across various scenarios.""" diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index d17427f46..2c4225dd6 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -2621,9 +2621,9 @@ def test_get_core_print_console_invalidation(base_app: cmd2.Cmd, stream: str) -> # Changing the theme should create a new console from rich.theme import Theme - old_theme = ru.APP_THEME + old_theme = ru.get_theme() try: - ru.APP_THEME = Theme() + ru._APP_THEME = Theme() console6 = base_app._get_core_print_console( file=file, emoji=False, @@ -2633,7 +2633,7 @@ def test_get_core_print_console_invalidation(base_app: cmd2.Cmd, stream: str) -> assert console6 is not console5 assert getattr(base_app._console_cache, stream) is console6 finally: - ru.APP_THEME = old_theme + ru._APP_THEME = old_theme def test_get_core_print_console_non_cached(base_app: cmd2.Cmd) -> None: diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py index 38412f6b7..1a90406ab 100644 --- a/tests/test_rich_utils.py +++ b/tests/test_rich_utils.py @@ -1,5 +1,7 @@ """Unit testing for cmd2/rich_utils.py module""" +import sys +from typing import Any from unittest import mock import pytest @@ -11,6 +13,7 @@ from rich.text import Text from cmd2 import ( + Cmd2ArgumentParser, Cmd2Style, Color, ) @@ -105,27 +108,28 @@ def test_set_theme() -> None: 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] + theme = ru.get_theme() + orig_cmd2_style = theme.styles[cmd2_style_key] + orig_argparse_style = theme.styles[argparse_style_key] + orig_rich_style = theme.styles[rich_style_key] # Overwrite these styles by setting a new theme. - theme = { + new_styles = { 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) + ru.set_theme(new_styles) # 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 theme.styles[cmd2_style_key] != orig_cmd2_style + assert theme.styles[cmd2_style_key] == new_styles[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 theme.styles[argparse_style_key] != orig_argparse_style + assert theme.styles[argparse_style_key] == new_styles[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] + assert theme.styles[rich_style_key] != orig_rich_style + assert theme.styles[rich_style_key] == new_styles[rich_style_key] def test_cmd2_base_console_print(mocker: MockerFixture) -> None: @@ -253,3 +257,107 @@ def test_cmd2_base_console_init_never() -> None: assert kwargs['color_system'] is None assert kwargs['force_terminal'] is False assert kwargs['force_interactive'] is None + + +def test_text_group_direct_cmd2() -> None: + """Print a TextGroup directly using a Cmd2RichArgparseConsole.""" + title = "Notes" + content = "Some text" + text_group = ru.TextGroup(title, content) + console = ru.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 = ru.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: pytest.CaptureFixture[str]) -> None: + """Print a TextGroup with argparse using a Cmd2RichArgparseConsole.""" + parser = Cmd2ArgumentParser(prog="test") + parser.epilog = ru.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: pytest.CaptureFixture[str]) -> None: + """Print a TextGroup with argparse not using a Cmd2RichArgparseConsole.""" + + class CustomParser(Cmd2ArgumentParser): + def _get_formatter(self, **kwargs: Any) -> ru.Cmd2HelpFormatter: + """Overwrite the formatter's console with a plain one.""" + formatter = super()._get_formatter(**kwargs) + formatter.console = Console() # type: ignore[assignment] + return formatter + + parser = CustomParser(prog="test") + parser.epilog = ru.TextGroup("Notes", "Some text") + + # Render help + parser.print_help() + out, _ = capsys.readouterr() + + assert "Notes:" in out + assert " Some text" in out + + +def test_formatter_console() -> None: + # self._console = console (inside console.setter) + formatter = ru.Cmd2HelpFormatter(prog='test') + new_console = ru.Cmd2RichArgparseConsole() + formatter.console = new_console + assert formatter._console is new_console + + +@pytest.mark.skipif( + sys.version_info < (3, 14), + reason="Argparse didn't support color until Python 3.14", +) +def test_formatter_set_color(mocker: MockerFixture) -> None: + formatter = ru.Cmd2HelpFormatter(prog='test') + + # return (inside _set_color if sys.version_info < (3, 14)) + mocker.patch('cmd2.argparse_utils.sys.version_info', (3, 13, 0)) + # This should return early without calling super()._set_color + mock_set_color = mocker.patch('rich_argparse.RichHelpFormatter._set_color') + formatter._set_color(True) + mock_set_color.assert_not_called() + + # except TypeError and super()._set_color(color) + mocker.patch('cmd2.argparse_utils.sys.version_info', (3, 15, 0)) + + # Reset mock and make it raise TypeError when called with kwargs + mock_set_color.reset_mock() + + def side_effect(color: bool, **kwargs: Any) -> None: + if kwargs: + raise TypeError("unexpected keyword argument 'file'") + return + + mock_set_color.side_effect = side_effect + + # This call should trigger the TypeError and then the fallback call + formatter._set_color(True, file=sys.stdout) + + # It should have been called twice: once with kwargs (failed) and once without (fallback) + assert mock_set_color.call_count == 2 + mock_set_color.assert_any_call(True, file=sys.stdout) + mock_set_color.assert_any_call(True)