Skip to content

Commit 343509e

Browse files
authored
Force truecolor support to avoid automatic color detection. (#1634)
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.
1 parent ea66804 commit 343509e

File tree

4 files changed

+135
-33
lines changed

4 files changed

+135
-33
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@ prompt is displayed.
108108
- For more details and examples, see the [Help](docs/features/help.md) documentation and the
109109
`examples/default_categories.py` file.
110110

111+
## 3.5.0 (April 13, 2026)
112+
113+
- Bug Fixes
114+
- Fixed issue where Rich stripped colors from text in test environments where TERM=dumb.
115+
111116
## 3.4.0 (March 3, 2026)
112117

113118
- Enhancements

cmd2/rich_utils.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,9 @@ def __init__(
141141
:param kwargs: keyword arguments passed to the parent Console class.
142142
:raises TypeError: if disallowed keyword argument is passed in.
143143
"""
144-
# Don't allow force_terminal or force_interactive to be passed in, as their
145-
# behavior is controlled by the ALLOW_STYLE setting.
144+
# These settings are controlled by the ALLOW_STYLE setting and cannot be overridden.
145+
if "color_system" in kwargs:
146+
raise TypeError("Passing 'color_system' is not allowed. Its behavior is controlled by the 'ALLOW_STYLE' setting.")
146147
if "force_terminal" in kwargs:
147148
raise TypeError(
148149
"Passing 'force_terminal' is not allowed. Its behavior is controlled by the 'ALLOW_STYLE' setting."
@@ -165,18 +166,24 @@ def __init__(
165166

166167
force_terminal: bool | None = None
167168
force_interactive: bool | None = None
169+
allow_style = False
168170

169171
if ALLOW_STYLE == AllowStyle.ALWAYS:
170172
force_terminal = True
173+
allow_style = True
171174

172175
# Turn off interactive mode if dest is not a terminal which supports it.
173176
tmp_console = Console(file=file)
174177
force_interactive = tmp_console.is_interactive
178+
elif ALLOW_STYLE == AllowStyle.TERMINAL:
179+
tmp_console = Console(file=file)
180+
allow_style = tmp_console.is_terminal
175181
elif ALLOW_STYLE == AllowStyle.NEVER:
176182
force_terminal = False
177183

178184
super().__init__(
179185
file=file,
186+
color_system="truecolor" if allow_style else None,
180187
force_terminal=force_terminal,
181188
force_interactive=force_interactive,
182189
theme=APP_THEME,
@@ -414,6 +421,7 @@ def rich_text_to_string(text: Text) -> str:
414421

415422
console = Console(
416423
force_terminal=True,
424+
color_system="truecolor",
417425
soft_wrap=True,
418426
no_color=False,
419427
theme=APP_THEME,

tests/test_cmd2.py

Lines changed: 21 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3701,7 +3701,6 @@ def do_echo(self, args) -> None:
37013701

37023702
def do_echo_error(self, args) -> None:
37033703
self.poutput(args, style=Cmd2Style.ERROR)
3704-
# perror uses colors by default
37053704
self.perror(args)
37063705

37073706

@@ -3711,21 +3710,18 @@ def test_ansi_pouterr_always_tty(mocker, capsys) -> None:
37113710
mocker.patch.object(app.stdout, 'isatty', return_value=True)
37123711
mocker.patch.object(sys.stderr, 'isatty', return_value=True)
37133712

3713+
expected_plain = 'oopsie\n'
3714+
expected_styled = su.stylize('oopsie\n', Cmd2Style.ERROR)
3715+
37143716
app.onecmd_plus_hooks('echo_error oopsie')
37153717
out, err = capsys.readouterr()
3716-
# if colors are on, the output should have some ANSI style sequences in it
3717-
assert len(out) > len('oopsie\n')
3718-
assert 'oopsie' in out
3719-
assert len(err) > len('oopsie\n')
3720-
assert 'oopsie' in err
3718+
assert out == expected_styled
3719+
assert err == expected_styled
37213720

3722-
# but this one shouldn't
37233721
app.onecmd_plus_hooks('echo oopsie')
37243722
out, err = capsys.readouterr()
3725-
assert out == 'oopsie\n'
3726-
# errors always have colors
3727-
assert len(err) > len('oopsie\n')
3728-
assert 'oopsie' in err
3723+
assert out == expected_plain
3724+
assert err == expected_styled
37293725

37303726

37313727
@with_ansi_style(ru.AllowStyle.ALWAYS)
@@ -3734,21 +3730,18 @@ def test_ansi_pouterr_always_notty(mocker, capsys) -> None:
37343730
mocker.patch.object(app.stdout, 'isatty', return_value=False)
37353731
mocker.patch.object(sys.stderr, 'isatty', return_value=False)
37363732

3733+
expected_plain = 'oopsie\n'
3734+
expected_styled = su.stylize('oopsie\n', Cmd2Style.ERROR)
3735+
37373736
app.onecmd_plus_hooks('echo_error oopsie')
37383737
out, err = capsys.readouterr()
3739-
# if colors are on, the output should have some ANSI style sequences in it
3740-
assert len(out) > len('oopsie\n')
3741-
assert 'oopsie' in out
3742-
assert len(err) > len('oopsie\n')
3743-
assert 'oopsie' in err
3738+
assert out == expected_styled
3739+
assert err == expected_styled
37443740

3745-
# but this one shouldn't
37463741
app.onecmd_plus_hooks('echo oopsie')
37473742
out, err = capsys.readouterr()
3748-
assert out == 'oopsie\n'
3749-
# errors always have colors
3750-
assert len(err) > len('oopsie\n')
3751-
assert 'oopsie' in err
3743+
assert out == expected_plain
3744+
assert err == expected_styled
37523745

37533746

37543747
@with_ansi_style(ru.AllowStyle.TERMINAL)
@@ -3757,20 +3750,18 @@ def test_ansi_terminal_tty(mocker, capsys) -> None:
37573750
mocker.patch.object(app.stdout, 'isatty', return_value=True)
37583751
mocker.patch.object(sys.stderr, 'isatty', return_value=True)
37593752

3753+
expected_plain = 'oopsie\n'
3754+
expected_styled = su.stylize('oopsie\n', Cmd2Style.ERROR)
3755+
37603756
app.onecmd_plus_hooks('echo_error oopsie')
3761-
# if colors are on, the output should have some ANSI style sequences in it
37623757
out, err = capsys.readouterr()
3763-
assert len(out) > len('oopsie\n')
3764-
assert 'oopsie' in out
3765-
assert len(err) > len('oopsie\n')
3766-
assert 'oopsie' in err
3758+
assert out == expected_styled
3759+
assert err == expected_styled
37673760

3768-
# but this one shouldn't
37693761
app.onecmd_plus_hooks('echo oopsie')
37703762
out, err = capsys.readouterr()
3771-
assert out == 'oopsie\n'
3772-
assert len(err) > len('oopsie\n')
3773-
assert 'oopsie' in err
3763+
assert out == expected_plain
3764+
assert err == expected_styled
37743765

37753766

37763767
@with_ansi_style(ru.AllowStyle.TERMINAL)

tests/test_rich_utils.py

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Unit testing for cmd2/rich_utils.py module"""
22

3+
from unittest import mock
4+
35
import pytest
46
import rich.box
57
from pytest_mock import MockerFixture
@@ -14,9 +16,15 @@
1416
)
1517
from cmd2 import rich_utils as ru
1618

19+
from .conftest import with_ansi_style
20+
1721

1822
def test_cmd2_base_console() -> None:
1923
# Test the keyword arguments which are not allowed.
24+
with pytest.raises(TypeError) as excinfo:
25+
ru.Cmd2BaseConsole(color_system="auto")
26+
assert 'color_system' in str(excinfo.value)
27+
2028
with pytest.raises(TypeError) as excinfo:
2129
ru.Cmd2BaseConsole(force_terminal=True)
2230
assert 'force_terminal' in str(excinfo.value)
@@ -73,7 +81,12 @@ def test_indented_table() -> None:
7381
[
7482
(Text("Hello"), "Hello"),
7583
(Text("Hello\n"), "Hello\n"),
76-
(Text("Hello", style="blue"), "\x1b[34mHello\x1b[0m"),
84+
# Test standard color support
85+
(Text("Standard", style="blue"), "\x1b[34mStandard\x1b[0m"),
86+
# Test 256-color support
87+
(Text("256-color", style=Color.NAVY_BLUE), "\x1b[38;5;17m256-color\x1b[0m"),
88+
# Test 24-bit color (TrueColor) support
89+
(Text("TrueColor", style="#123456"), "\x1b[38;2;18;52;86mTrueColor\x1b[0m"),
7790
],
7891
)
7992
def test_rich_text_to_string(rich_text: Text, string: str) -> None:
@@ -155,3 +168,88 @@ def test_cmd2_base_console_log(mocker: MockerFixture) -> None:
155168
args, kwargs = mock_super_log.call_args
156169
assert args == prepared_val
157170
assert kwargs["_stack_offset"] == 3
171+
172+
173+
@with_ansi_style(ru.AllowStyle.ALWAYS)
174+
def test_cmd2_base_console_init_always_interactive_true() -> None:
175+
"""Test Cmd2BaseConsole initialization when ALLOW_STYLE is ALWAYS and is_interactive is True."""
176+
with (
177+
mock.patch('rich.console.Console.__init__', return_value=None) as mock_base_init,
178+
mock.patch('cmd2.rich_utils.Console', autospec=True) as mock_detect_console_class,
179+
):
180+
mock_detect_console = mock_detect_console_class.return_value
181+
mock_detect_console.is_interactive = True
182+
183+
ru.Cmd2BaseConsole()
184+
185+
# Verify arguments passed to super().__init__
186+
_, kwargs = mock_base_init.call_args
187+
assert kwargs['color_system'] == "truecolor"
188+
assert kwargs['force_terminal'] is True
189+
assert kwargs['force_interactive'] is True
190+
191+
192+
@with_ansi_style(ru.AllowStyle.ALWAYS)
193+
def test_cmd2_base_console_init_always_interactive_false() -> None:
194+
"""Test Cmd2BaseConsole initialization when ALLOW_STYLE is ALWAYS and is_interactive is False."""
195+
with (
196+
mock.patch('rich.console.Console.__init__', return_value=None) as mock_base_init,
197+
mock.patch('cmd2.rich_utils.Console', autospec=True) as mock_detect_console_class,
198+
):
199+
mock_detect_console = mock_detect_console_class.return_value
200+
mock_detect_console.is_interactive = False
201+
202+
ru.Cmd2BaseConsole()
203+
204+
_, kwargs = mock_base_init.call_args
205+
assert kwargs['color_system'] == "truecolor"
206+
assert kwargs['force_terminal'] is True
207+
assert kwargs['force_interactive'] is False
208+
209+
210+
@with_ansi_style(ru.AllowStyle.TERMINAL)
211+
def test_cmd2_base_console_init_terminal_true() -> None:
212+
"""Test Cmd2BaseConsole initialization when ALLOW_STYLE is TERMINAL and it is a terminal."""
213+
with (
214+
mock.patch('rich.console.Console.__init__', return_value=None) as mock_base_init,
215+
mock.patch('cmd2.rich_utils.Console', autospec=True) as mock_detect_console_class,
216+
):
217+
mock_detect_console = mock_detect_console_class.return_value
218+
mock_detect_console.is_terminal = True
219+
220+
ru.Cmd2BaseConsole()
221+
222+
_, kwargs = mock_base_init.call_args
223+
assert kwargs['color_system'] == "truecolor"
224+
assert kwargs['force_terminal'] is None
225+
assert kwargs['force_interactive'] is None
226+
227+
228+
@with_ansi_style(ru.AllowStyle.TERMINAL)
229+
def test_cmd2_base_console_init_terminal_false() -> None:
230+
"""Test Cmd2BaseConsole initialization when ALLOW_STYLE is TERMINAL and it is not a terminal."""
231+
with (
232+
mock.patch('rich.console.Console.__init__', return_value=None) as mock_base_init,
233+
mock.patch('cmd2.rich_utils.Console', autospec=True) as mock_detect_console_class,
234+
):
235+
mock_detect_console = mock_detect_console_class.return_value
236+
mock_detect_console.is_terminal = False
237+
238+
ru.Cmd2BaseConsole()
239+
240+
_, kwargs = mock_base_init.call_args
241+
assert kwargs['color_system'] is None
242+
assert kwargs['force_terminal'] is None
243+
assert kwargs['force_interactive'] is None
244+
245+
246+
@with_ansi_style(ru.AllowStyle.NEVER)
247+
def test_cmd2_base_console_init_never() -> None:
248+
"""Test Cmd2BaseConsole initialization when ALLOW_STYLE is NEVER."""
249+
with mock.patch('rich.console.Console.__init__', return_value=None) as mock_base_init:
250+
ru.Cmd2BaseConsole()
251+
252+
_, kwargs = mock_base_init.call_args
253+
assert kwargs['color_system'] is None
254+
assert kwargs['force_terminal'] is False
255+
assert kwargs['force_interactive'] is None

0 commit comments

Comments
 (0)