From e786f06ca79a76fbf4007b55bfaa48e31b9c8b1d Mon Sep 17 00:00:00 2001 From: Sean Kim Date: Mon, 6 Jul 2026 23:18:05 -0700 Subject: [PATCH 1/2] Do not drop 256-color index 0 in style() `style()` (and `secho()`) guarded foreground/background colors with a truthiness check (`if fg:` / `if bg:`), so the valid 256-color index 0 (black) was treated as unset and silently produced uncolored text. The RGB form `(0, 0, 0)` and every nonzero index worked, making the omission an inconsistency rather than intended behavior. Guard on `is not None` instead, matching how the other style attributes in the same function are handled. --- CHANGES.md | 3 +++ src/click/termui.py | 4 ++-- tests/test_utils.py | 4 ++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index c47fbd2c5..a3cd31fdc 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -11,6 +11,9 @@ Unreleased This stripping was lost in `8.4.0` when {pr}`2969` began writing the prompt with `input()` directly. {issue}`3572` {pr}`3653` - Fix test failures when using pytest >= 9.1. {pr}`3656` +- `style()` and `secho()` no longer silently drop the 256-color index `0` + (black) passed as `fg` or `bg`. A truthiness check treated the valid index + `0` as unset. {pr}`3666` ## Version 8.4.2 diff --git a/src/click/termui.py b/src/click/termui.py index 4780902e4..53522fe79 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -675,13 +675,13 @@ def style( bits = [] - if fg: + if fg is not None: try: bits.append(f"\033[{_interpret_color(fg)}m") except KeyError: raise TypeError(_("Unknown color {colour!r}").format(colour=fg)) from None - if bg: + if bg is not None: try: bits.append(f"\033[{_interpret_color(bg, 10)}m") except KeyError: diff --git a/tests/test_utils.py b/tests/test_utils.py index b7a4259e9..e12dfcb99 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -132,6 +132,10 @@ def test_echo_no_streams(monkeypatch, runner): ({"bg": "white"}, "\x1b[47mx y\x1b[0m"), ({"bg": 91}, "\x1b[48;5;91mx y\x1b[0m"), ({"bg": (135, 0, 175)}, "\x1b[48;2;135;0;175mx y\x1b[0m"), + # 256-color index 0 (black) is valid and must not be dropped by a + # truthiness check on fg/bg. + ({"fg": 0}, "\x1b[38;5;0mx y\x1b[0m"), + ({"bg": 0}, "\x1b[48;5;0mx y\x1b[0m"), ({"bold": True}, "\x1b[1mx y\x1b[0m"), ({"dim": True}, "\x1b[2mx y\x1b[0m"), ({"underline": True}, "\x1b[4mx y\x1b[0m"), From be05c0670f0aedf3ad72c2accb6a56dd42373cd3 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Tue, 7 Jul 2026 13:03:22 +0200 Subject: [PATCH 2/2] Validate `style()` color arguments Booleans, malformed tuples and out-of-range values now raise the same `TypeError` --- CHANGES.md | 7 ++++-- src/click/termui.py | 40 ++++++++++++++++++++------------- tests/test_utils.py | 55 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 18 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index a3cd31fdc..9c017fe2b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -12,8 +12,11 @@ Unreleased prompt with `input()` directly. {issue}`3572` {pr}`3653` - Fix test failures when using pytest >= 9.1. {pr}`3656` - `style()` and `secho()` no longer silently drop the 256-color index `0` - (black) passed as `fg` or `bg`. A truthiness check treated the valid index - `0` as unset. {pr}`3666` + (black) passed as `fg` or `bg`, and now validate color arguments: unknown + names, `bool` values, indices or RGB components outside `[0, 255]`, and + malformed tuples all raise `TypeError`. Previously, falsy values (`""`, + `()`, `False`) were silently ignored, and malformed truthy values raised + inconsistent errors or emitted invalid escape codes. {pr}`3666` ## Version 8.4.2 diff --git a/src/click/termui.py b/src/click/termui.py index 53522fe79..362fe4c4e 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -570,14 +570,22 @@ def clear() -> None: def _interpret_color(color: int | tuple[int, int, int] | str, offset: int = 0) -> str: - if isinstance(color, int): - return f"{38 + offset};5;{color:d}" - - if isinstance(color, (tuple, list)): - r, g, b = color - return f"{38 + offset};2;{r:d};{g:d};{b:d}" - - return str(_ansi_colors[color] + offset) + # bool is an int subclass: without the exclusion, True and False would + # silently render as the palette indices 1 and 0. + if isinstance(color, int) and not isinstance(color, bool): + if 0 <= color <= 255: + return f"{38 + offset};5;{color:d}" + elif isinstance(color, (tuple, list)): + if len(color) == 3 and all( + isinstance(c, int) and not isinstance(c, bool) and 0 <= c <= 255 + for c in color + ): + r, g, b = color + return f"{38 + offset};2;{r:d};{g:d};{b:d}" + elif isinstance(color, str) and color in _ansi_colors: + return str(_ansi_colors[color] + offset) + + raise TypeError(_("Unknown color {colour!r}").format(colour=color)) def style( @@ -655,6 +663,12 @@ def style( string which means that styles do not carry over. This can be disabled to compose styles. + .. versionchanged:: 8.5 + The 256-color index ``0`` is no longer ignored. All other invalid + ``fg`` and ``bg`` values now raise :exc:`TypeError`: previously, + falsy values were silently ignored and malformed truthy values + raised inconsistent errors or emitted invalid escape codes. + .. versionchanged:: 8.0 A non-string ``message`` is converted to a string. @@ -676,16 +690,10 @@ def style( bits = [] if fg is not None: - try: - bits.append(f"\033[{_interpret_color(fg)}m") - except KeyError: - raise TypeError(_("Unknown color {colour!r}").format(colour=fg)) from None + bits.append(f"\033[{_interpret_color(fg)}m") if bg is not None: - try: - bits.append(f"\033[{_interpret_color(bg, 10)}m") - except KeyError: - raise TypeError(_("Unknown color {colour!r}").format(colour=bg)) from None + bits.append(f"\033[{_interpret_color(bg, 10)}m") if bold is not None: bits.append(f"\033[{1 if bold else 22}m") diff --git a/tests/test_utils.py b/tests/test_utils.py index e12dfcb99..dace53452 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -114,6 +114,7 @@ def test_echo_no_streams(monkeypatch, runner): @pytest.mark.parametrize( ("styles", "ref"), [ + ({}, "x y\x1b[0m"), ({"fg": "black"}, "\x1b[30mx y\x1b[0m"), ({"fg": "red"}, "\x1b[31mx y\x1b[0m"), ({"fg": "green"}, "\x1b[32mx y\x1b[0m"), @@ -122,6 +123,15 @@ def test_echo_no_streams(monkeypatch, runner): ({"fg": "magenta"}, "\x1b[35mx y\x1b[0m"), ({"fg": "cyan"}, "\x1b[36mx y\x1b[0m"), ({"fg": "white"}, "\x1b[37mx y\x1b[0m"), + ({"fg": "bright_black"}, "\x1b[90mx y\x1b[0m"), + ({"fg": "bright_red"}, "\x1b[91mx y\x1b[0m"), + ({"fg": "bright_green"}, "\x1b[92mx y\x1b[0m"), + ({"fg": "bright_yellow"}, "\x1b[93mx y\x1b[0m"), + ({"fg": "bright_blue"}, "\x1b[94mx y\x1b[0m"), + ({"fg": "bright_magenta"}, "\x1b[95mx y\x1b[0m"), + ({"fg": "bright_cyan"}, "\x1b[96mx y\x1b[0m"), + ({"fg": "bright_white"}, "\x1b[97mx y\x1b[0m"), + ({"fg": "reset"}, "\x1b[39mx y\x1b[0m"), ({"bg": "black"}, "\x1b[40mx y\x1b[0m"), ({"bg": "red"}, "\x1b[41mx y\x1b[0m"), ({"bg": "green"}, "\x1b[42mx y\x1b[0m"), @@ -130,8 +140,24 @@ def test_echo_no_streams(monkeypatch, runner): ({"bg": "magenta"}, "\x1b[45mx y\x1b[0m"), ({"bg": "cyan"}, "\x1b[46mx y\x1b[0m"), ({"bg": "white"}, "\x1b[47mx y\x1b[0m"), + ({"bg": "bright_black"}, "\x1b[100mx y\x1b[0m"), + ({"bg": "bright_red"}, "\x1b[101mx y\x1b[0m"), + ({"bg": "bright_green"}, "\x1b[102mx y\x1b[0m"), + ({"bg": "bright_yellow"}, "\x1b[103mx y\x1b[0m"), + ({"bg": "bright_blue"}, "\x1b[104mx y\x1b[0m"), + ({"bg": "bright_magenta"}, "\x1b[105mx y\x1b[0m"), + ({"bg": "bright_cyan"}, "\x1b[106mx y\x1b[0m"), + ({"bg": "bright_white"}, "\x1b[107mx y\x1b[0m"), + ({"bg": "reset"}, "\x1b[49mx y\x1b[0m"), + ({"fg": 91}, "\x1b[38;5;91mx y\x1b[0m"), ({"bg": 91}, "\x1b[48;5;91mx y\x1b[0m"), + ({"fg": 255}, "\x1b[38;5;255mx y\x1b[0m"), + ({"bg": 255}, "\x1b[48;5;255mx y\x1b[0m"), + ({"fg": (135, 0, 175)}, "\x1b[38;2;135;0;175mx y\x1b[0m"), ({"bg": (135, 0, 175)}, "\x1b[48;2;135;0;175mx y\x1b[0m"), + ({"bg": [135, 0, 175]}, "\x1b[48;2;135;0;175mx y\x1b[0m"), + ({"fg": (0, 0, 0)}, "\x1b[38;2;0;0;0mx y\x1b[0m"), + ({"fg": (255, 255, 255)}, "\x1b[38;2;255;255;255mx y\x1b[0m"), # 256-color index 0 (black) is valid and must not be dropped by a # truthiness check on fg/bg. ({"fg": 0}, "\x1b[38;5;0mx y\x1b[0m"), @@ -160,6 +186,35 @@ def test_styling(styles, ref): assert click.unstyle(ref) == "x y" +@pytest.mark.parametrize("param", ["fg", "bg"]) +@pytest.mark.parametrize( + "value", + [ + "", + "banana", + "BLACK", + b"red", + True, + False, + -1, + 256, + 0.0, + (), + [], + (0, 0), + (0, 0, 0, 0), + ("0", "0", "0"), + (True, False, True), + (-1, 0, 0), + (0, 256, 0), + (0.0, 0.0, 0.0), + ], +) +def test_styling_invalid_color(param, value): + with pytest.raises(TypeError, match="Unknown color"): + click.style("x y", **{param: value}) + + @pytest.mark.parametrize(("text", "expect"), [("\x1b[?25lx y\x1b[?25h", "x y")]) def test_unstyle_other_ansi(text, expect): assert click.unstyle(text) == expect