Skip to content

Commit 91c8043

Browse files
committed
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 enabled. This avoids Rich's automatic color detection, which can strip colors in test environments where TERM=dumb is set.
1 parent 0b0ef95 commit 91c8043

File tree

4 files changed

+133
-33
lines changed

4 files changed

+133
-33
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## 3.5.0 (TBD)
2+
3+
- Bug Fixes
4+
- Fixed issue where Rich stripped colors from text in test environments where TERM=dumb.
5+
16
## 3.4.0 (March 3, 2026)
27

38
- Enhancements

cmd2/rich_utils.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,9 @@ def __init__(
136136
:param kwargs: keyword arguments passed to the parent Console class.
137137
:raises TypeError: if disallowed keyword argument is passed in.
138138
"""
139-
# Don't allow force_terminal or force_interactive to be passed in, as their
140-
# behavior is controlled by the ALLOW_STYLE setting.
139+
# These settings are controlled by the ALLOW_STYLE setting and cannot be overridden.
140+
if "color_system" in kwargs:
141+
raise TypeError("Passing 'color_system' is not allowed. Its behavior is controlled by the 'ALLOW_STYLE' setting.")
141142
if "force_terminal" in kwargs:
142143
raise TypeError(
143144
"Passing 'force_terminal' is not allowed. Its behavior is controlled by the 'ALLOW_STYLE' setting."
@@ -157,18 +158,24 @@ def __init__(
157158

158159
force_terminal: bool | None = None
159160
force_interactive: bool | None = None
161+
allow_style = False
160162

161163
if ALLOW_STYLE == AllowStyle.ALWAYS:
162164
force_terminal = True
165+
allow_style = True
163166

164167
# Turn off interactive mode if dest is not actually a terminal which supports it
165168
tmp_console = Console(file=file)
166169
force_interactive = tmp_console.is_interactive
170+
elif ALLOW_STYLE == AllowStyle.TERMINAL:
171+
tmp_console = Console(file=file)
172+
allow_style = tmp_console.is_terminal
167173
elif ALLOW_STYLE == AllowStyle.NEVER:
168174
force_terminal = False
169175

170176
super().__init__(
171177
file=file,
178+
color_system="truecolor" if allow_style else None,
172179
force_terminal=force_terminal,
173180
force_interactive=force_interactive,
174181
theme=APP_THEME,
@@ -412,6 +419,7 @@ def rich_text_to_string(text: Text) -> str:
412419
"""
413420
console = Console(
414421
force_terminal=True,
422+
color_system="truecolor",
415423
soft_wrap=True,
416424
no_color=False,
417425
theme=APP_THEME,

tests/test_cmd2.py

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

29652965
def do_echo_error(self, args) -> None:
29662966
self.poutput(args, style=Cmd2Style.ERROR)
2967-
# perror uses colors by default
29682967
self.perror(args)
29692968

29702969

@@ -2974,21 +2973,18 @@ def test_ansi_pouterr_always_tty(mocker, capsys) -> None:
29742973
mocker.patch.object(app.stdout, 'isatty', return_value=True)
29752974
mocker.patch.object(sys.stderr, 'isatty', return_value=True)
29762975

2976+
expected_plain = 'oopsie\n'
2977+
expected_styled = su.stylize('oopsie\n', Cmd2Style.ERROR)
2978+
29772979
app.onecmd_plus_hooks('echo_error oopsie')
29782980
out, err = capsys.readouterr()
2979-
# if colors are on, the output should have some ANSI style sequences in it
2980-
assert len(out) > len('oopsie\n')
2981-
assert 'oopsie' in out
2982-
assert len(err) > len('oopsie\n')
2983-
assert 'oopsie' in err
2981+
assert out == expected_styled
2982+
assert err == expected_styled
29842983

2985-
# but this one shouldn't
29862984
app.onecmd_plus_hooks('echo oopsie')
29872985
out, err = capsys.readouterr()
2988-
assert out == 'oopsie\n'
2989-
# errors always have colors
2990-
assert len(err) > len('oopsie\n')
2991-
assert 'oopsie' in err
2986+
assert out == expected_plain
2987+
assert err == expected_styled
29922988

29932989

29942990
@with_ansi_style(ru.AllowStyle.ALWAYS)
@@ -2997,21 +2993,18 @@ def test_ansi_pouterr_always_notty(mocker, capsys) -> None:
29972993
mocker.patch.object(app.stdout, 'isatty', return_value=False)
29982994
mocker.patch.object(sys.stderr, 'isatty', return_value=False)
29992995

2996+
expected_plain = 'oopsie\n'
2997+
expected_styled = su.stylize('oopsie\n', Cmd2Style.ERROR)
2998+
30002999
app.onecmd_plus_hooks('echo_error oopsie')
30013000
out, err = capsys.readouterr()
3002-
# if colors are on, the output should have some ANSI style sequences in it
3003-
assert len(out) > len('oopsie\n')
3004-
assert 'oopsie' in out
3005-
assert len(err) > len('oopsie\n')
3006-
assert 'oopsie' in err
3001+
assert out == expected_styled
3002+
assert err == expected_styled
30073003

3008-
# but this one shouldn't
30093004
app.onecmd_plus_hooks('echo oopsie')
30103005
out, err = capsys.readouterr()
3011-
assert out == 'oopsie\n'
3012-
# errors always have colors
3013-
assert len(err) > len('oopsie\n')
3014-
assert 'oopsie' in err
3006+
assert out == expected_plain
3007+
assert err == expected_styled
30153008

30163009

30173010
@with_ansi_style(ru.AllowStyle.TERMINAL)
@@ -3020,20 +3013,18 @@ def test_ansi_terminal_tty(mocker, capsys) -> None:
30203013
mocker.patch.object(app.stdout, 'isatty', return_value=True)
30213014
mocker.patch.object(sys.stderr, 'isatty', return_value=True)
30223015

3016+
expected_plain = 'oopsie\n'
3017+
expected_styled = su.stylize('oopsie\n', Cmd2Style.ERROR)
3018+
30233019
app.onecmd_plus_hooks('echo_error oopsie')
3024-
# if colors are on, the output should have some ANSI style sequences in it
30253020
out, err = capsys.readouterr()
3026-
assert len(out) > len('oopsie\n')
3027-
assert 'oopsie' in out
3028-
assert len(err) > len('oopsie\n')
3029-
assert 'oopsie' in err
3021+
assert out == expected_styled
3022+
assert err == expected_styled
30303023

3031-
# but this one shouldn't
30323024
app.onecmd_plus_hooks('echo oopsie')
30333025
out, err = capsys.readouterr()
3034-
assert out == 'oopsie\n'
3035-
assert len(err) > len('oopsie\n')
3036-
assert 'oopsie' in err
3026+
assert out == expected_plain
3027+
assert err == expected_styled
30373028

30383029

30393030
@with_ansi_style(ru.AllowStyle.TERMINAL)

tests/test_rich_utils.py

Lines changed: 97 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 rich.console import Console
@@ -18,6 +20,10 @@
1820

1921
def test_cmd2_base_console() -> None:
2022
# Test the keyword arguments which are not allowed.
23+
with pytest.raises(TypeError) as excinfo:
24+
ru.Cmd2BaseConsole(color_system="auto")
25+
assert 'color_system' in str(excinfo.value)
26+
2127
with pytest.raises(TypeError) as excinfo:
2228
ru.Cmd2BaseConsole(force_terminal=True)
2329
assert 'force_terminal' in str(excinfo.value)
@@ -74,7 +80,12 @@ def test_indented_table() -> None:
7480
[
7581
(Text("Hello"), "Hello"),
7682
(Text("Hello\n"), "Hello\n"),
77-
(Text("Hello", style="blue"), "\x1b[34mHello\x1b[0m"),
83+
# Test standard color support
84+
(Text("Standard", style="blue"), "\x1b[34mStandard\x1b[0m"),
85+
# Test 256-color support
86+
(Text("256-color", style=Color.NAVY_BLUE), "\x1b[38;5;17m256-color\x1b[0m"),
87+
# Test 24-bit color (TrueColor) support
88+
(Text("TrueColor", style="#123456"), "\x1b[38;2;18;52;86mTrueColor\x1b[0m"),
7889
],
7990
)
8091
def test_rich_text_to_string(rich_text: Text, string: str) -> None:
@@ -156,3 +167,88 @@ def test_cmd2_base_console_log() -> None:
156167
# Verify stack offset: the log line should point to this file, not rich_utils.py
157168
# Rich logs include the filename and line number on the right.
158169
assert "test_rich_utils.py" in result
170+
171+
172+
@with_ansi_style(ru.AllowStyle.ALWAYS)
173+
def test_cmd2_base_console_init_always_interactive_true() -> None:
174+
"""Test Cmd2BaseConsole initialization when ALLOW_STYLE is ALWAYS and is_interactive is True."""
175+
with (
176+
mock.patch('rich.console.Console.__init__', return_value=None) as mock_base_init,
177+
mock.patch('cmd2.rich_utils.Console', autospec=True) as mock_detect_console_class,
178+
):
179+
mock_detect_console = mock_detect_console_class.return_value
180+
mock_detect_console.is_interactive = True
181+
182+
ru.Cmd2BaseConsole()
183+
184+
# Verify arguments passed to super().__init__
185+
_, kwargs = mock_base_init.call_args
186+
assert kwargs['color_system'] == "truecolor"
187+
assert kwargs['force_terminal'] is True
188+
assert kwargs['force_interactive'] is True
189+
190+
191+
@with_ansi_style(ru.AllowStyle.ALWAYS)
192+
def test_cmd2_base_console_init_always_interactive_false() -> None:
193+
"""Test Cmd2BaseConsole initialization when ALLOW_STYLE is ALWAYS and is_interactive is False."""
194+
with (
195+
mock.patch('rich.console.Console.__init__', return_value=None) as mock_base_init,
196+
mock.patch('cmd2.rich_utils.Console', autospec=True) as mock_detect_console_class,
197+
):
198+
mock_detect_console = mock_detect_console_class.return_value
199+
mock_detect_console.is_interactive = False
200+
201+
ru.Cmd2BaseConsole()
202+
203+
_, kwargs = mock_base_init.call_args
204+
assert kwargs['color_system'] == "truecolor"
205+
assert kwargs['force_terminal'] is True
206+
assert kwargs['force_interactive'] is False
207+
208+
209+
@with_ansi_style(ru.AllowStyle.TERMINAL)
210+
def test_cmd2_base_console_init_terminal_true() -> None:
211+
"""Test Cmd2BaseConsole initialization when ALLOW_STYLE is TERMINAL and it is a terminal."""
212+
with (
213+
mock.patch('rich.console.Console.__init__', return_value=None) as mock_base_init,
214+
mock.patch('cmd2.rich_utils.Console', autospec=True) as mock_detect_console_class,
215+
):
216+
mock_detect_console = mock_detect_console_class.return_value
217+
mock_detect_console.is_terminal = True
218+
219+
ru.Cmd2BaseConsole()
220+
221+
_, kwargs = mock_base_init.call_args
222+
assert kwargs['color_system'] == "truecolor"
223+
assert kwargs['force_terminal'] is None
224+
assert kwargs['force_interactive'] is None
225+
226+
227+
@with_ansi_style(ru.AllowStyle.TERMINAL)
228+
def test_cmd2_base_console_init_terminal_false() -> None:
229+
"""Test Cmd2BaseConsole initialization when ALLOW_STYLE is TERMINAL and it is not a terminal."""
230+
with (
231+
mock.patch('rich.console.Console.__init__', return_value=None) as mock_base_init,
232+
mock.patch('cmd2.rich_utils.Console', autospec=True) as mock_detect_console_class,
233+
):
234+
mock_detect_console = mock_detect_console_class.return_value
235+
mock_detect_console.is_terminal = False
236+
237+
ru.Cmd2BaseConsole()
238+
239+
_, kwargs = mock_base_init.call_args
240+
assert kwargs['color_system'] is None
241+
assert kwargs['force_terminal'] is None
242+
assert kwargs['force_interactive'] is None
243+
244+
245+
@with_ansi_style(ru.AllowStyle.NEVER)
246+
def test_cmd2_base_console_init_never() -> None:
247+
"""Test Cmd2BaseConsole initialization when ALLOW_STYLE is NEVER."""
248+
with mock.patch('rich.console.Console.__init__', return_value=None) as mock_base_init:
249+
ru.Cmd2BaseConsole()
250+
251+
_, kwargs = mock_base_init.call_args
252+
assert kwargs['color_system'] is None
253+
assert kwargs['force_terminal'] is False
254+
assert kwargs['force_interactive'] is None

0 commit comments

Comments
 (0)