# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Drop-in replacement for :class:`cloup.Style` with extra features.
The module name mirrors :mod:`cloup.styling`, the upstream module that hosts
the original ``Style`` class. Click Extra's :class:`Style` is a subclass that
keeps cloup's runtime contract (calling, equality, hashing, ``with_()``)
intact and adds:
- A compact, single-line ``__repr__`` that hides ``None`` and falsy
attributes and renders RGB tuples as ``#rrggbb`` hex.
- Hex-string color shorthand: ``Style(fg="#f1fa8c")`` works alongside
``Style(fg=(241, 250, 140))``.
- A ``__str__`` that returns the styled word ``"sample"`` so REPL prints and
debuggers visualize what the style does, not just its fields.
- A composition operator ``a | b`` that merges two styles, with the right
operand winning on conflicts.
- A :meth:`Style.cascade` method that fills the style's ``None`` fields from
a base style without overriding any value already set.
- :meth:`Style.to_dict` / :meth:`Style.from_dict` for round-tripping styles
through TOML/JSON/YAML.
- :meth:`Style.to_css` for emitting CSS-equivalent declarations: useful for
HTML renderings of help screens.
- :meth:`Style.from_ansi` for parsing an ANSI SGR escape sequence back into
a ``Style`` instance.
- :meth:`Style.contrast_ratio` returning the WCAG contrast ratio between
two foreground colors. Useful for theme designers checking accessibility.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, fields
import cloup
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Callable
from typing import Any
# --- Color conversion utilities ----------------------------------------------
# 16 standard ANSI colors as approximate sRGB values. Used by :meth:`to_css`
# (where ``bright_*`` named colors aren't valid CSS keywords) and by
# :meth:`contrast_ratio` (which needs RGB to compute luminance).
_ANSI_16_RGB: tuple[tuple[int, int, int], ...] = (
(0, 0, 0), # 0: black
(170, 0, 0), # 1: red
(0, 170, 0), # 2: green
(170, 85, 0), # 3: yellow
(0, 0, 170), # 4: blue
(170, 0, 170), # 5: magenta
(0, 170, 170), # 6: cyan
(170, 170, 170), # 7: white
(85, 85, 85), # 8: bright_black
(255, 85, 85), # 9: bright_red
(85, 255, 85), # 10: bright_green
(255, 255, 85), # 11: bright_yellow
(85, 85, 255), # 12: bright_blue
(255, 85, 255), # 13: bright_magenta
(85, 255, 255), # 14: bright_cyan
(255, 255, 255), # 15: bright_white
)
_ANSI_NAMES: tuple[str, ...] = (
"black", "red", "green", "yellow",
"blue", "magenta", "cyan", "white",
)
# Channel values for the 6Γ6Γ6 color cube (palette indices 16β231).
_CUBE_VALUES: tuple[int, ...] = (0, 95, 135, 175, 215, 255)
# Boolean style attributes processed in repr/css/from_ansi.
_BOOL_ATTRS: tuple[str, ...] = (
"bold", "dim", "italic", "underline", "overline",
"blink", "reverse", "strikethrough",
)
# Match a single ANSI SGR escape: ``\x1b[...m``.
_ANSI_SGR_RE: re.Pattern[str] = re.compile(r"\x1b\[(\d+(?:;\d+)*)m")
# --- Shared dict round-trip helpers ------------------------------------------
#
# ``Style`` (per-attribute) and ``HelpExtraTheme`` (per-slot) both serialize
# their dataclass fields to plain dicts for TOML/JSON/YAML round-tripping.
# These helpers codify the shared rules: walk ``dataclasses.fields``, skip
# cloup's lazy ``_style_kwargs`` cache, skip values that match the field
# default, and raise on unknown keys with a clear message.
[docs]
def fields_to_dict(
instance: Any,
*,
encode: Callable[[Any, Any], Any] = lambda field, value: value,
keep: Callable[[Any, Any], bool] = lambda field, value: True,
) -> dict[str, Any]:
"""Serialize a dataclass instance to a dict of set fields.
Walks every field via :func:`dataclasses.fields`, skips the internal
``_style_kwargs`` cache, applies *keep* to decide which fields are
written (default: every non-default field), and passes the surviving
values through *encode* (default: identity).
:param instance: the dataclass to serialize.
:param encode: callable ``(field, value) -> encoded_value`` applied to
every kept value. Use to convert RGB tuples to ``#rrggbb`` strings,
nested dataclasses to dicts, etc.
:param keep: callable ``(field, value) -> bool`` deciding whether the
field is emitted. Default keeps everything that differs from the
field's declared default.
"""
out: dict[str, Any] = {}
for f in fields(instance):
if f.name == "_style_kwargs":
continue
value = getattr(instance, f.name)
if value == f.default:
continue
if not keep(f, value):
continue
out[f.name] = encode(f, value)
return out
[docs]
def dict_to_fields(
cls: type,
data: dict[str, Any],
*,
decode: Callable[[Any, Any], Any] = lambda field, raw: raw,
) -> dict[str, Any]:
"""Validate *data*'s keys against *cls*'s dataclass fields and decode them.
Returns a kwargs dict ready to splat into ``cls(**kwargs)``. Raises
:class:`TypeError` listing every unknown key, so callers can build
a constructor call without an extra pre-validation pass.
:param cls: the dataclass type whose fields define the legal keys.
:param data: mapping from field name to a serialized value.
:param decode: callable ``(field, raw) -> decoded_value`` invoked for
every recognized key. Default returns the raw value unchanged.
"""
fields_by_name = {f.name: f for f in fields(cls)}
unknown = set(data).difference(fields_by_name)
if unknown:
raise TypeError(
f"Unknown {cls.__name__} field(s): {', '.join(sorted(unknown))}"
)
kwargs: dict[str, Any] = {}
for name, raw in data.items():
kwargs[name] = decode(fields_by_name[name], raw)
return kwargs
[docs]
def cascade_fields(
base: Any,
overlay: Any,
*,
is_set: Callable[[Any, Any], bool] = lambda field, value: value != field.default,
) -> dict[str, Any]:
"""Layer *overlay*'s set fields on top of *base*, returning a merged kwargs dict.
Walks both instances' fields and produces a dict suitable for
``type(base)(**kwargs)`` (or ``dataclasses.replace(base, **kwargs)``).
The slot-level analogue of ``Style.cascade``'s attribute-level merge.
:param base: the underlying instance whose fields fill any gaps.
:param overlay: the instance whose set fields win on conflicts.
:param is_set: callable ``(field, value) -> bool`` distinguishing
"set" from "unset" fields. Default treats a value equal to the
field default as unset.
"""
out: dict[str, Any] = {}
for f in fields(base):
if f.name == "_style_kwargs":
continue
overlay_val = getattr(overlay, f.name)
if is_set(f, overlay_val):
out[f.name] = overlay_val
else:
out[f.name] = getattr(base, f.name)
return out
def _hex_to_rgb(value: str) -> tuple[int, int, int]:
"""Parse a hex color (``#rrggbb`` or shorthand ``#rgb``) to an RGB tuple."""
s = value.lstrip("#").lower()
if len(s) not in (3, 6):
raise ValueError(f"Not a valid hex color: {value!r}")
try:
if len(s) == 3:
return int(s[0] * 2, 16), int(s[1] * 2, 16), int(s[2] * 2, 16)
return int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)
except ValueError as exc:
raise ValueError(f"Not a valid hex color: {value!r}") from exc
def _palette_to_rgb(idx: int) -> tuple[int, int, int]:
"""Convert a 256-color palette index to an approximate sRGB tuple."""
if 0 <= idx < 16:
return _ANSI_16_RGB[idx]
if 16 <= idx < 232:
idx -= 16
return (
_CUBE_VALUES[idx // 36],
_CUBE_VALUES[(idx // 6) % 6],
_CUBE_VALUES[idx % 6],
)
if 232 <= idx < 256:
v = (idx - 232) * 10 + 8
return v, v, v
raise ValueError(f"Palette index out of range: {idx}")
def _resolve_rgb(color: object) -> tuple[int, int, int]:
"""Best-effort conversion of any color value to an ``(r, g, b)`` tuple.
Accepts hex strings, named ANSI strings (``"red"``, ``"bright_blue"``),
palette indices (``int``), and ``Color``-enum-like objects with a
``.name`` attribute.
"""
if isinstance(color, tuple) and len(color) == 3:
return color
if isinstance(color, str):
if color.startswith("#"):
return _hex_to_rgb(color)
if color.startswith("bright_"):
return _ANSI_16_RGB[_ANSI_NAMES.index(color[7:]) + 8]
return _ANSI_16_RGB[_ANSI_NAMES.index(color)]
if isinstance(color, int):
return _palette_to_rgb(color)
if hasattr(color, "name") and not isinstance(color, type):
return _resolve_rgb(color.name)
raise ValueError(f"Cannot resolve color: {color!r}")
def _color_repr(value: object) -> str:
"""Compact human-readable form of a color value for ``__repr__``."""
if isinstance(value, tuple) and len(value) == 3:
return f"#{value[0]:02x}{value[1]:02x}{value[2]:02x}"
if hasattr(value, "name") and not isinstance(value, str):
return value.name # type: ignore[no-any-return]
return repr(value)
def _color_to_css(color: object) -> str:
"""Render a color value as a CSS color string."""
if isinstance(color, tuple) and len(color) == 3:
return f"#{color[0]:02x}{color[1]:02x}{color[2]:02x}"
if isinstance(color, str):
if color.startswith("#"):
return color
if color.startswith("bright_"):
r, g, b = _resolve_rgb(color)
return f"#{r:02x}{g:02x}{b:02x}"
return color # plain CSS keyword: 'red', 'blue', etc.
if isinstance(color, int):
r, g, b = _palette_to_rgb(color)
return f"#{r:02x}{g:02x}{b:02x}"
if hasattr(color, "name") and not isinstance(color, type):
return _color_to_css(color.name)
return str(color)
def _relative_luminance(color: object) -> float:
"""WCAG relative luminance for a color value, in ``[0, 1]``.
See: https://www.w3.org/TR/WCAG22/#dfn-relative-luminance
"""
r, g, b = _resolve_rgb(color)
def _channel(c: int) -> float:
c01 = c / 255
return c01 / 12.92 if c01 <= 0.03928 else ((c01 + 0.055) / 1.055) ** 2.4
return 0.2126 * _channel(r) + 0.7152 * _channel(g) + 0.0722 * _channel(b)
# --- Style ------------------------------------------------------------------
[docs]
@dataclass(frozen=True, repr=False)
class Style(cloup.Style):
""":class:`cloup.Style` with extra ergonomics.
See the module docstring for the full list of additions. The runtime
contract (calling the instance to apply styling, equality, hashing,
``with_()``) is otherwise identical to :class:`cloup.Style`.
"""
fg: str | tuple[int, int, int] | int | None = None # type: ignore[assignment]
"""Foreground color: named ANSI string, ``#rrggbb`` hex, RGB tuple, or palette index."""
bg: str | tuple[int, int, int] | int | None = None # type: ignore[assignment]
"""Background color: named ANSI string, ``#rrggbb`` hex, RGB tuple, or palette index."""
def __post_init__(self) -> None:
"""Convert ``#rrggbb`` shorthand strings on ``fg``/``bg`` to RGB tuples.
Frozen dataclass: must use :func:`object.__setattr__` to bypass the
frozen guard. Runs once at construction; cloup's lazy
``_style_kwargs`` cache (built on first ``__call__``) picks up the
converted values.
"""
if isinstance(self.fg, str) and self.fg.startswith("#"):
object.__setattr__(self, "fg", _hex_to_rgb(self.fg))
if isinstance(self.bg, str) and self.bg.startswith("#"):
object.__setattr__(self, "bg", _hex_to_rgb(self.bg))
def __repr__(self) -> str:
"""Compact repr that lists only the attributes actually set."""
parts: list[str] = []
if self.fg is not None:
parts.append(f"fg={_color_repr(self.fg)}")
if self.bg is not None:
parts.append(f"bg={_color_repr(self.bg)}")
parts.extend(attr for attr in _BOOL_ATTRS if getattr(self, attr, None))
text_transform = getattr(self, "text_transform", None)
if text_transform is not None:
parts.append(f"text_transform={text_transform!r}")
return f"Style({', '.join(parts)})"
def __str__(self) -> str:
"""Return the word ``"sample"`` styled with this Style.
Lets ``print(style)`` and debuggers visualize the style instead of
dumping its fields.
"""
return self("sample")
def __eq__(self, other: object) -> bool:
"""Equality on the publicly-set fields.
Excludes cloup's lazily-populated ``_style_kwargs`` cache so two
otherwise-identical styles compare equal whether or not either has
been called yet.
"""
if not isinstance(other, cloup.Style):
return NotImplemented
for f in fields(self):
if f.name == "_style_kwargs":
continue
if getattr(self, f.name) != getattr(other, f.name):
return False
return True
def __hash__(self) -> int:
"""Hash mirroring :meth:`__eq__`: skip the lazy ``_style_kwargs`` cache."""
return hash(
tuple(
getattr(self, f.name)
for f in fields(self)
if f.name != "_style_kwargs"
)
)
@staticmethod
def _merge(base: cloup.Style, top: cloup.Style) -> Style:
"""Return a :class:`Style` where *top*'s set fields override *base*'s.
Field walked from *base* so we don't depend on *top* being our own
subclass: cloup's :class:`~cloup.Style` works fine as the right
operand of ``|``.
"""
merged: dict[str, Any] = {}
for f in fields(base):
if f.name == "_style_kwargs":
continue
top_val = getattr(top, f.name)
merged[f.name] = top_val if top_val is not None else getattr(base, f.name)
# Pick the most specific class present so ``my_style | cloup_style``
# still returns a ``Style`` (this subclass).
cls = type(top) if isinstance(top, Style) else type(base)
if not isinstance(cls, type) or not issubclass(cls, Style):
cls = Style
return cls(**merged)
def __or__(self, other: object) -> Style:
"""``a | b`` merges two styles. ``b``'s set fields win on conflicts."""
if not isinstance(other, cloup.Style):
return NotImplemented
return self._merge(self, other)
def __ror__(self, other: object) -> Style:
"""Reflected ``|``: ``other | self`` where ``self``'s fields win."""
if not isinstance(other, cloup.Style):
return NotImplemented
return self._merge(other, self)
[docs]
def cascade(self, base: cloup.Style) -> Style:
"""Return a copy with ``None`` fields filled in from *base*.
The instance's own non-``None`` values always win: ``cascade`` only
fills gaps. Useful for theme inheritance: ``derived.cascade(parent)``
keeps ``derived``'s overrides and inherits the rest from ``parent``.
"""
if not isinstance(base, cloup.Style):
raise TypeError(
f"Cannot cascade onto {type(base).__name__}: not a Style."
)
return self._merge(base, self)
@staticmethod
def _encode_field(_field: Any, value: Any) -> Any:
"""Encode a field value for :meth:`to_dict`.
RGB tuples become ``#rrggbb`` strings; enum-shaped objects with a
``.name`` are serialized by name; everything else passes through.
"""
if isinstance(value, tuple) and len(value) == 3:
return f"#{value[0]:02x}{value[1]:02x}{value[2]:02x}"
if hasattr(value, "name") and not isinstance(value, str):
return value.name
return value
[docs]
def to_dict(self) -> dict[str, Any]:
"""Serialize to a plain dict with only the set fields.
RGB tuples are emitted as ``#rrggbb`` strings so the result
round-trips through TOML/JSON/YAML untouched. Pair with
:meth:`from_dict` to rebuild a :class:`Style`.
"""
return fields_to_dict(self, encode=self._encode_field)
[docs]
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Style:
"""Build a :class:`Style` from the plain dict produced by :meth:`to_dict`.
Validates that every key in *data* names a known :class:`Style` field
and raises :class:`TypeError` otherwise. Pair with :meth:`to_dict`
to round-trip through TOML/JSON/YAML.
"""
return cls(**dict_to_fields(cls, data))
[docs]
def to_css(self) -> str:
"""Render the style as a semicolon-separated CSS declaration list.
``Style(fg="#f1fa8c", bold=True).to_css()`` returns
``"color: #f1fa8c; font-weight: bold"``. Suitable for inline
``style="..."`` attributes on HTML spans.
"""
parts: list[str] = []
if self.fg is not None:
parts.append(f"color: {_color_to_css(self.fg)}")
if self.bg is not None:
parts.append(f"background-color: {_color_to_css(self.bg)}")
if self.bold:
parts.append("font-weight: bold")
if self.italic:
parts.append("font-style: italic")
decorations: list[str] = []
if self.underline:
decorations.append("underline")
if self.overline:
decorations.append("overline")
if self.strikethrough:
decorations.append("line-through")
if decorations:
parts.append(f"text-decoration: {' '.join(decorations)}")
if self.dim:
parts.append("opacity: 0.6")
if self.reverse:
parts.append("filter: invert(1)")
return "; ".join(parts)
[docs]
@classmethod
def from_ansi(cls, escape: str) -> Style:
"""Parse one or more consecutive ANSI SGR escapes into a :class:`Style`.
Supports the standard 8/16-color codes (30β37, 40β47, 90β97,
100β107), the ``38;5;n`` / ``48;5;n`` 256-color extension, and the
``38;2;r;g;b`` / ``48;2;r;g;b`` 24-bit extension. Reset codes (``0``)
are ignored. Multiple back-to-back escapes (as click emits when
combining colors with attributes: ``\\x1b[31m\\x1b[1m``) are merged
into a single :class:`Style`.
"""
matches = list(_ANSI_SGR_RE.finditer(escape))
if not matches:
raise ValueError(f"Not an ANSI SGR escape: {escape!r}")
codes: list[int] = []
for m in matches:
codes.extend(int(c) for c in m.group(1).split(";"))
kwargs: dict[str, Any] = {}
i = 0
while i < len(codes):
c = codes[i]
if c == 0:
pass # reset, skip.
elif c == 1:
kwargs["bold"] = True
elif c == 2:
kwargs["dim"] = True
elif c == 3:
kwargs["italic"] = True
elif c == 4:
kwargs["underline"] = True
elif c == 5:
kwargs["blink"] = True
elif c == 7:
kwargs["reverse"] = True
elif c == 9:
kwargs["strikethrough"] = True
elif c == 53:
kwargs["overline"] = True
elif 30 <= c <= 37:
kwargs["fg"] = _ANSI_NAMES[c - 30]
elif 40 <= c <= 47:
kwargs["bg"] = _ANSI_NAMES[c - 40]
elif 90 <= c <= 97:
kwargs["fg"] = "bright_" + _ANSI_NAMES[c - 90]
elif 100 <= c <= 107:
kwargs["bg"] = "bright_" + _ANSI_NAMES[c - 100]
elif c in (38, 48):
key = "fg" if c == 38 else "bg"
if i + 2 < len(codes) and codes[i + 1] == 5:
kwargs[key] = codes[i + 2]
i += 2
elif i + 4 < len(codes) and codes[i + 1] == 2:
kwargs[key] = (codes[i + 2], codes[i + 3], codes[i + 4])
i += 4
i += 1
return cls(**kwargs)
[docs]
def contrast_ratio(self, other: cloup.Style) -> float:
"""Return the WCAG 2.x contrast ratio between this fg and *other*'s fg.
Result is in ``[1, 21]``: 1 = identical colors (no contrast),
21 = maximum contrast (black on white). WCAG AA requires 4.5+ for
normal text, 3.0+ for large text; AAA wants 7.0+ and 4.5+ respectively.
"""
if self.fg is None or other.fg is None:
raise ValueError(
"contrast_ratio requires both styles to have a foreground color."
)
a = _relative_luminance(self.fg)
b = _relative_luminance(other.fg)
if a < b:
a, b = b, a
return (a + 0.05) / (b + 0.05)