From cf99bc33e2942eb286c03d81b38d9fad18ed3d66 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 6 Jul 2026 23:28:13 -0400 Subject: [PATCH 1/2] Fixed cmd2 bypassing NO_COLOR and allow_style when setting prompt-toolkit's color depth. --- CHANGELOG.md | 1 + cmd2/cmd2.py | 5 +++-- cmd2/pt_utils.py | 9 +++++++++ cmd2/styles.py | 5 ++++- tests/test_cmd2.py | 5 ++++- tests/test_pt_utils.py | 30 ++++++++++++++++++++++++++++++ 6 files changed, 51 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdc517584..d4ed91462 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Bug Fixes - Fixed type hinting so that methods decorated with `with_annotated` no longer trigger spurious mypy errors and preserve their original signature. + - Fixed cmd2 bypassing NO_COLOR and allow_style when setting prompt-toolkit's color depth. - Experimental features - `@with_annotated` now supports `frozenset[T]` collection parameters, alongside the existing diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 8ac7dcfea..e48516191 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -79,7 +79,6 @@ from prompt_toolkit.input import DummyInput, create_input from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.output import DummyOutput, create_output -from prompt_toolkit.output.color_depth import ColorDepth from prompt_toolkit.patch_stdout import patch_stdout from prompt_toolkit.shortcuts import CompleteStyle, PromptSession, choice, set_title from prompt_toolkit.styles import DynamicStyle @@ -202,6 +201,7 @@ def __init__(self, msg: str = "") -> None: Cmd2History, Cmd2Lexer, pt_filter_style, + pt_resolve_color_depth, ) from .utils import ( Settable, @@ -778,7 +778,7 @@ def _(event: Any) -> None: # pragma: no cover kwargs: dict[str, Any] = { "auto_suggest": AutoSuggestFromHistory() if auto_suggest else None, "bottom_toolbar": self.get_bottom_toolbar if enable_bottom_toolbar else None, - "color_depth": ColorDepth.TRUE_COLOR, + "color_depth": pt_resolve_color_depth(), "complete_style": CompleteStyle.MULTI_COLUMN, "complete_in_thread": complete_in_thread, "complete_while_typing": False, @@ -1443,6 +1443,7 @@ def allow_style(self) -> ru.AllowStyle: def allow_style(self, value: ru.AllowStyle) -> None: """Setter property needed to support do_set when it updates allow_style.""" ru.ALLOW_STYLE = value + self.main_session.color_depth = pt_resolve_color_depth() @property def traceback_show_locals(self) -> bool: diff --git a/cmd2/pt_utils.py b/cmd2/pt_utils.py index e2ca6196a..8dedf31fc 100644 --- a/cmd2/pt_utils.py +++ b/cmd2/pt_utils.py @@ -1,5 +1,6 @@ """Utilities for integrating prompt_toolkit with cmd2.""" +import os import re from collections.abc import ( Callable, @@ -21,6 +22,7 @@ from prompt_toolkit.formatted_text import ANSI from prompt_toolkit.history import History from prompt_toolkit.lexers import Lexer +from prompt_toolkit.output.color_depth import ColorDepth from rich.style import Style, StyleType from . import ( @@ -75,6 +77,13 @@ def pt_filter_style(text: str | ANSI) -> str | ANSI: return text if isinstance(text, ANSI) else ANSI(text) +def pt_resolve_color_depth() -> ColorDepth: + """Determine the prompt-toolkit ColorDepth based on NO_COLOR and ru.ALLOW_STYLE.""" + if "NO_COLOR" in os.environ or ru.ALLOW_STYLE == ru.AllowStyle.NEVER: + return ColorDepth.DEPTH_1_BIT + return ColorDepth.TRUE_COLOR + + @lru_cache(maxsize=256) def rich_to_pt_color(color: "Color | None") -> str: """Convert a rich Color object to a prompt_toolkit color string.""" diff --git a/cmd2/styles.py b/cmd2/styles.py index a615eb407..0c5cde209 100644 --- a/cmd2/styles.py +++ b/cmd2/styles.py @@ -72,7 +72,10 @@ class Cmd2Style(StrEnum): Cmd2Style.COMMAND_LINE: Style(color=Color.CYAN, bold=True), Cmd2Style.COMPLETION_MENU: Style(color="#000000", bgcolor="#bbbbbb"), # prompt-toolkit default Cmd2Style.COMPLETION_MENU_COMPLETION: Style(), # prompt-toolkit default - Cmd2Style.COMPLETION_MENU_CURRENT: Style(color=Color.GREEN, bgcolor=Color.BLACK), # This style swaps FG and BG colors + # Defined with swapped FG/BG colors because prompt-toolkit applies 'reverse' by default. + # This yields a black-on-green highlight in color mode, while maintaining a fallback + # reverse-video highlight when colors are disabled (e.g. NO_COLOR=1 or allow_style=never). + Cmd2Style.COMPLETION_MENU_CURRENT: Style(color=Color.GREEN, bgcolor=Color.BLACK), Cmd2Style.COMPLETION_MENU_META: Style(color="#000000", bgcolor="#bbbbbb"), # prompt-toolkit default Cmd2Style.COMPLETION_MENU_META_CURRENT: Style(color=Color.BLACK, bgcolor=Color.BRIGHT_GREEN), Cmd2Style.ERROR: Style(color=Color.BRIGHT_RED), diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index e872beaad..7ba496d80 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -245,7 +245,9 @@ def test_set_no_settables(base_app) -> None: ], ) @with_ansi_style(ru.AllowStyle.TERMINAL) -def test_set_allow_style(base_app, new_val, is_valid, expected) -> None: +def test_set_allow_style(base_app: cmd2.Cmd, new_val: str, is_valid: bool, expected: ru.AllowStyle) -> None: + from cmd2.pt_utils import pt_resolve_color_depth + # Use the set command to alter allow_style out, err = run_cmd(base_app, f"set allow_style {new_val}") assert base_app.last_result is is_valid @@ -255,6 +257,7 @@ def test_set_allow_style(base_app, new_val, is_valid, expected) -> None: if is_valid: assert out assert not err + assert base_app.main_session.color_depth == pt_resolve_color_depth() def test_set_traceback_show_locals(base_app: cmd2.Cmd) -> None: diff --git a/tests/test_pt_utils.py b/tests/test_pt_utils.py index c1e5135de..1c7b6bc9a 100644 --- a/tests/test_pt_utils.py +++ b/tests/test_pt_utils.py @@ -11,6 +11,7 @@ ANSI, to_formatted_text, ) +from prompt_toolkit.output.color_depth import ColorDepth from rich.table import Table import cmd2 @@ -25,6 +26,7 @@ from cmd2.pt_utils import ( Cmd2Lexer, pt_filter_style, + pt_resolve_color_depth, ) from .conftest import with_ansi_style @@ -96,6 +98,34 @@ def test_pt_filter_style_never() -> None: assert result == su.strip_style(styled) +@with_ansi_style(ru.AllowStyle.ALWAYS) +def test_pt_resolve_color_depth_always(monkeypatch) -> None: + # Ensure any host NO_COLOR environment variable doesn't affect the test + monkeypatch.delenv("NO_COLOR", raising=False) + assert pt_resolve_color_depth() == ColorDepth.TRUE_COLOR + + +@with_ansi_style(ru.AllowStyle.TERMINAL) +def test_pt_resolve_color_depth_terminal(monkeypatch) -> None: + # Ensure any host NO_COLOR environment variable doesn't affect the test + monkeypatch.delenv("NO_COLOR", raising=False) + assert pt_resolve_color_depth() == ColorDepth.TRUE_COLOR + + +@with_ansi_style(ru.AllowStyle.NEVER) +def test_pt_resolve_color_depth_never(monkeypatch) -> None: + # Under NEVER, colors are suppressed regardless of NO_COLOR + monkeypatch.delenv("NO_COLOR", raising=False) + assert pt_resolve_color_depth() == ColorDepth.DEPTH_1_BIT + + +@with_ansi_style(ru.AllowStyle.ALWAYS) +def test_pt_resolve_color_depth_no_color(monkeypatch) -> None: + # Mock NO_COLOR=1 to ensure colors are suppressed + monkeypatch.setenv("NO_COLOR", "1") + assert pt_resolve_color_depth() == ColorDepth.DEPTH_1_BIT + + class TestCmd2Lexer: @with_ansi_style(ru.AllowStyle.NEVER) def test_lex_document_no_style(self, mock_cmd_app): From 2cb95e2067a84fb3ca4d45764241324473e12053 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 7 Jul 2026 11:55:26 -0400 Subject: [PATCH 2/2] Fixed handling of NO_COLOR environment variable. --- cmd2/pt_utils.py | 2 +- tests/test_pt_utils.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd2/pt_utils.py b/cmd2/pt_utils.py index 8dedf31fc..e232bf59d 100644 --- a/cmd2/pt_utils.py +++ b/cmd2/pt_utils.py @@ -79,7 +79,7 @@ def pt_filter_style(text: str | ANSI) -> str | ANSI: def pt_resolve_color_depth() -> ColorDepth: """Determine the prompt-toolkit ColorDepth based on NO_COLOR and ru.ALLOW_STYLE.""" - if "NO_COLOR" in os.environ or ru.ALLOW_STYLE == ru.AllowStyle.NEVER: + if os.environ.get("NO_COLOR") or ru.ALLOW_STYLE == ru.AllowStyle.NEVER: return ColorDepth.DEPTH_1_BIT return ColorDepth.TRUE_COLOR diff --git a/tests/test_pt_utils.py b/tests/test_pt_utils.py index 1c7b6bc9a..d0e234164 100644 --- a/tests/test_pt_utils.py +++ b/tests/test_pt_utils.py @@ -100,15 +100,15 @@ def test_pt_filter_style_never() -> None: @with_ansi_style(ru.AllowStyle.ALWAYS) def test_pt_resolve_color_depth_always(monkeypatch) -> None: - # Ensure any host NO_COLOR environment variable doesn't affect the test + # Clear NO_COLOR to ensure colors are not suppressed monkeypatch.delenv("NO_COLOR", raising=False) assert pt_resolve_color_depth() == ColorDepth.TRUE_COLOR @with_ansi_style(ru.AllowStyle.TERMINAL) def test_pt_resolve_color_depth_terminal(monkeypatch) -> None: - # Ensure any host NO_COLOR environment variable doesn't affect the test - monkeypatch.delenv("NO_COLOR", raising=False) + # Mock NO_COLOR to an empty string to ensure colors are not suppressed + monkeypatch.setenv("NO_COLOR", "") assert pt_resolve_color_depth() == ColorDepth.TRUE_COLOR @@ -121,7 +121,7 @@ def test_pt_resolve_color_depth_never(monkeypatch) -> None: @with_ansi_style(ru.AllowStyle.ALWAYS) def test_pt_resolve_color_depth_no_color(monkeypatch) -> None: - # Mock NO_COLOR=1 to ensure colors are suppressed + # Mock NO_COLOR to ensure colors are suppressed monkeypatch.setenv("NO_COLOR", "1") assert pt_resolve_color_depth() == ColorDepth.DEPTH_1_BIT