diff --git a/CHANGES.md b/CHANGES.md index c47fbd2c5..9c017fe2b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -11,6 +11,12 @@ 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`, 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 4780902e4..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. @@ -675,17 +689,11 @@ def style( bits = [] - if fg: - try: - bits.append(f"\033[{_interpret_color(fg)}m") - except KeyError: - raise TypeError(_("Unknown color {colour!r}").format(colour=fg)) from None + if fg is not None: + bits.append(f"\033[{_interpret_color(fg)}m") - if bg: - try: - bits.append(f"\033[{_interpret_color(bg, 10)}m") - except KeyError: - raise TypeError(_("Unknown color {colour!r}").format(colour=bg)) from None + if bg is not 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 b7a4259e9..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,28 @@ 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"), + ({"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"), @@ -156,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