Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ prompt is displayed.
- For more details and examples, see the [Help](docs/features/help.md) documentation and the
`examples/default_categories.py` file.

## 3.5.0 (April 13, 2026)

- Bug Fixes
- Fixed issue where Rich stripped colors from text in test environments where TERM=dumb.

## 3.4.0 (March 3, 2026)

- Enhancements
Expand Down
12 changes: 10 additions & 2 deletions cmd2/rich_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,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."
Expand All @@ -165,18 +166,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 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,
Expand Down Expand Up @@ -414,6 +421,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,
Expand Down
51 changes: 21 additions & 30 deletions tests/test_cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -3701,7 +3701,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)


Expand All @@ -3711,21 +3710,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)
Expand All @@ -3734,21 +3730,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)
Expand All @@ -3757,20 +3750,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)
Expand Down
100 changes: 99 additions & 1 deletion tests/test_rich_utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Unit testing for cmd2/rich_utils.py module"""

from unittest import mock

import pytest
import rich.box
from pytest_mock import MockerFixture
Expand All @@ -14,9 +16,15 @@
)
from cmd2 import rich_utils as ru

from .conftest import with_ansi_style


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)
Expand Down Expand Up @@ -73,7 +81,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:
Expand Down Expand Up @@ -155,3 +168,88 @@ def test_cmd2_base_console_log(mocker: MockerFixture) -> None:
args, kwargs = mock_super_log.call_args
assert args == prepared_val
assert kwargs["_stack_offset"] == 3


@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
Loading