From 275b88b86368ea53045cdcacc07b742c78bec3e4 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 13 Apr 2026 18:20:53 -0400 Subject: [PATCH] Force truecolor support to avoid automatic color detection. Explicitly set the color system to "truecolor" in Cmd2BaseConsole and rich_text_to_string() when styling is allowed. This avoids Rich's automatic color detection, which can strip colors in test environments where TERM=dumb is set. --- CHANGELOG.md | 5 ++ cmd2/rich_utils.py | 12 ++++- tests/test_cmd2.py | 51 +++++++++------------ tests/test_rich_utils.py | 98 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 133 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95422b0e4..316ab0a1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 3.5.0 (TBD) + +- Bug Fixes + - Fixed issue where Rich stripped colors from text in test environments where TERM=dumb. + ## 3.4.0 (March 3, 2026) - Enhancements diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index efc9a29a2..5278fb784 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -136,8 +136,9 @@ def __init__( :param kwargs: keyword arguments passed to the parent Console class. :raises TypeError: if disallowed keyword argument is passed in. """ - # Don't allow force_terminal or force_interactive to be passed in, as their - # behavior is controlled by the ALLOW_STYLE setting. + # These settings are controlled by the ALLOW_STYLE setting and cannot be overridden. + if "color_system" in kwargs: + raise TypeError("Passing 'color_system' is not allowed. Its behavior is controlled by the 'ALLOW_STYLE' setting.") if "force_terminal" in kwargs: raise TypeError( "Passing 'force_terminal' is not allowed. Its behavior is controlled by the 'ALLOW_STYLE' setting." @@ -157,18 +158,24 @@ def __init__( force_terminal: bool | None = None force_interactive: bool | None = None + allow_style = False if ALLOW_STYLE == AllowStyle.ALWAYS: force_terminal = True + allow_style = True # Turn off interactive mode if dest is not actually a terminal which supports it tmp_console = Console(file=file) force_interactive = tmp_console.is_interactive + elif ALLOW_STYLE == AllowStyle.TERMINAL: + tmp_console = Console(file=file) + allow_style = tmp_console.is_terminal elif ALLOW_STYLE == AllowStyle.NEVER: force_terminal = False super().__init__( file=file, + color_system="truecolor" if allow_style else None, force_terminal=force_terminal, force_interactive=force_interactive, theme=APP_THEME, @@ -412,6 +419,7 @@ def rich_text_to_string(text: Text) -> str: """ console = Console( force_terminal=True, + color_system="truecolor", soft_wrap=True, no_color=False, theme=APP_THEME, diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index e1a650749..92e6e3823 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -2964,7 +2964,6 @@ def do_echo(self, args) -> None: def do_echo_error(self, args) -> None: self.poutput(args, style=Cmd2Style.ERROR) - # perror uses colors by default self.perror(args) @@ -2974,21 +2973,18 @@ def test_ansi_pouterr_always_tty(mocker, capsys) -> None: mocker.patch.object(app.stdout, 'isatty', return_value=True) mocker.patch.object(sys.stderr, 'isatty', return_value=True) + expected_plain = 'oopsie\n' + expected_styled = su.stylize('oopsie\n', Cmd2Style.ERROR) + app.onecmd_plus_hooks('echo_error oopsie') out, err = capsys.readouterr() - # if colors are on, the output should have some ANSI style sequences in it - assert len(out) > len('oopsie\n') - assert 'oopsie' in out - assert len(err) > len('oopsie\n') - assert 'oopsie' in err + assert out == expected_styled + assert err == expected_styled - # but this one shouldn't app.onecmd_plus_hooks('echo oopsie') out, err = capsys.readouterr() - assert out == 'oopsie\n' - # errors always have colors - assert len(err) > len('oopsie\n') - assert 'oopsie' in err + assert out == expected_plain + assert err == expected_styled @with_ansi_style(ru.AllowStyle.ALWAYS) @@ -2997,21 +2993,18 @@ def test_ansi_pouterr_always_notty(mocker, capsys) -> None: mocker.patch.object(app.stdout, 'isatty', return_value=False) mocker.patch.object(sys.stderr, 'isatty', return_value=False) + expected_plain = 'oopsie\n' + expected_styled = su.stylize('oopsie\n', Cmd2Style.ERROR) + app.onecmd_plus_hooks('echo_error oopsie') out, err = capsys.readouterr() - # if colors are on, the output should have some ANSI style sequences in it - assert len(out) > len('oopsie\n') - assert 'oopsie' in out - assert len(err) > len('oopsie\n') - assert 'oopsie' in err + assert out == expected_styled + assert err == expected_styled - # but this one shouldn't app.onecmd_plus_hooks('echo oopsie') out, err = capsys.readouterr() - assert out == 'oopsie\n' - # errors always have colors - assert len(err) > len('oopsie\n') - assert 'oopsie' in err + assert out == expected_plain + assert err == expected_styled @with_ansi_style(ru.AllowStyle.TERMINAL) @@ -3020,20 +3013,18 @@ def test_ansi_terminal_tty(mocker, capsys) -> None: mocker.patch.object(app.stdout, 'isatty', return_value=True) mocker.patch.object(sys.stderr, 'isatty', return_value=True) + expected_plain = 'oopsie\n' + expected_styled = su.stylize('oopsie\n', Cmd2Style.ERROR) + app.onecmd_plus_hooks('echo_error oopsie') - # if colors are on, the output should have some ANSI style sequences in it out, err = capsys.readouterr() - assert len(out) > len('oopsie\n') - assert 'oopsie' in out - assert len(err) > len('oopsie\n') - assert 'oopsie' in err + assert out == expected_styled + assert err == expected_styled - # but this one shouldn't app.onecmd_plus_hooks('echo oopsie') out, err = capsys.readouterr() - assert out == 'oopsie\n' - assert len(err) > len('oopsie\n') - assert 'oopsie' in err + assert out == expected_plain + assert err == expected_styled @with_ansi_style(ru.AllowStyle.TERMINAL) diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py index 71e3f5741..e2ee20806 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""" +from unittest import mock + import pytest import rich.box from rich.console import Console @@ -18,6 +20,10 @@ def test_cmd2_base_console() -> None: # Test the keyword arguments which are not allowed. + with pytest.raises(TypeError) as excinfo: + ru.Cmd2BaseConsole(color_system="auto") + assert 'color_system' in str(excinfo.value) + with pytest.raises(TypeError) as excinfo: ru.Cmd2BaseConsole(force_terminal=True) assert 'force_terminal' in str(excinfo.value) @@ -74,7 +80,12 @@ def test_indented_table() -> None: [ (Text("Hello"), "Hello"), (Text("Hello\n"), "Hello\n"), - (Text("Hello", style="blue"), "\x1b[34mHello\x1b[0m"), + # Test standard color support + (Text("Standard", style="blue"), "\x1b[34mStandard\x1b[0m"), + # Test 256-color support + (Text("256-color", style=Color.NAVY_BLUE), "\x1b[38;5;17m256-color\x1b[0m"), + # Test 24-bit color (TrueColor) support + (Text("TrueColor", style="#123456"), "\x1b[38;2;18;52;86mTrueColor\x1b[0m"), ], ) def test_rich_text_to_string(rich_text: Text, string: str) -> None: @@ -156,3 +167,88 @@ def test_cmd2_base_console_log() -> None: # Verify stack offset: the log line should point to this file, not rich_utils.py # Rich logs include the filename and line number on the right. assert "test_rich_utils.py" in result + + +@with_ansi_style(ru.AllowStyle.ALWAYS) +def test_cmd2_base_console_init_always_interactive_true() -> None: + """Test Cmd2BaseConsole initialization when ALLOW_STYLE is ALWAYS and is_interactive is True.""" + with ( + mock.patch('rich.console.Console.__init__', return_value=None) as mock_base_init, + mock.patch('cmd2.rich_utils.Console', autospec=True) as mock_detect_console_class, + ): + mock_detect_console = mock_detect_console_class.return_value + mock_detect_console.is_interactive = True + + ru.Cmd2BaseConsole() + + # Verify arguments passed to super().__init__ + _, kwargs = mock_base_init.call_args + assert kwargs['color_system'] == "truecolor" + assert kwargs['force_terminal'] is True + assert kwargs['force_interactive'] is True + + +@with_ansi_style(ru.AllowStyle.ALWAYS) +def test_cmd2_base_console_init_always_interactive_false() -> None: + """Test Cmd2BaseConsole initialization when ALLOW_STYLE is ALWAYS and is_interactive is False.""" + with ( + mock.patch('rich.console.Console.__init__', return_value=None) as mock_base_init, + mock.patch('cmd2.rich_utils.Console', autospec=True) as mock_detect_console_class, + ): + mock_detect_console = mock_detect_console_class.return_value + mock_detect_console.is_interactive = False + + ru.Cmd2BaseConsole() + + _, kwargs = mock_base_init.call_args + assert kwargs['color_system'] == "truecolor" + assert kwargs['force_terminal'] is True + assert kwargs['force_interactive'] is False + + +@with_ansi_style(ru.AllowStyle.TERMINAL) +def test_cmd2_base_console_init_terminal_true() -> None: + """Test Cmd2BaseConsole initialization when ALLOW_STYLE is TERMINAL and it is a terminal.""" + with ( + mock.patch('rich.console.Console.__init__', return_value=None) as mock_base_init, + mock.patch('cmd2.rich_utils.Console', autospec=True) as mock_detect_console_class, + ): + mock_detect_console = mock_detect_console_class.return_value + mock_detect_console.is_terminal = True + + ru.Cmd2BaseConsole() + + _, kwargs = mock_base_init.call_args + assert kwargs['color_system'] == "truecolor" + assert kwargs['force_terminal'] is None + assert kwargs['force_interactive'] is None + + +@with_ansi_style(ru.AllowStyle.TERMINAL) +def test_cmd2_base_console_init_terminal_false() -> None: + """Test Cmd2BaseConsole initialization when ALLOW_STYLE is TERMINAL and it is not a terminal.""" + with ( + mock.patch('rich.console.Console.__init__', return_value=None) as mock_base_init, + mock.patch('cmd2.rich_utils.Console', autospec=True) as mock_detect_console_class, + ): + mock_detect_console = mock_detect_console_class.return_value + mock_detect_console.is_terminal = False + + ru.Cmd2BaseConsole() + + _, kwargs = mock_base_init.call_args + assert kwargs['color_system'] is None + assert kwargs['force_terminal'] is None + assert kwargs['force_interactive'] is None + + +@with_ansi_style(ru.AllowStyle.NEVER) +def test_cmd2_base_console_init_never() -> None: + """Test Cmd2BaseConsole initialization when ALLOW_STYLE is NEVER.""" + with mock.patch('rich.console.Console.__init__', return_value=None) as mock_base_init: + ru.Cmd2BaseConsole() + + _, kwargs = mock_base_init.call_args + assert kwargs['color_system'] is None + assert kwargs['force_terminal'] is False + assert kwargs['force_interactive'] is None