# 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.
"""Resolve whether terminal output should be colored.
Owns the ``--color[=WHEN]`` and ``--no-color`` options, the color environment
variables (``NO_COLOR``, ``FORCE_COLOR``, ``CLICOLOR``, and friends), and the
tri-state WHEN resolution. The actual styling lives elsewhere:
:mod:`click_extra.styling` (the ``Style`` primitive), :mod:`click_extra.theme`
(palettes), and :mod:`click_extra.highlight` (help-screen rendering).
"""
from __future__ import annotations
import os
from collections.abc import Iterator
from configparser import RawConfigParser
from contextlib import contextmanager
from gettext import gettext as _
import click
from . import ParameterSource
from .parameters import ExtraOption
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Sequence
from typing import Any, ClassVar
from click.parser import _OptionParser
color_envvars = {
# Colors.
"COLOR": True,
"COLORS": True,
"CLICOLOR": True,
"CLICOLORS": True,
"FORCE_COLOR": True,
"FORCE_COLORS": True,
"CLICOLOR_FORCE": True,
"CLICOLORS_FORCE": True,
# No colors.
"NOCOLOR": False,
"NOCOLORS": False,
"NO_COLOR": False,
"NO_COLORS": False,
# LLM agents have no use for ANSI codes.
"LLM": False,
}
"""List of environment variables recognized as flags to switch color rendering on or
off.
The key is the name of the variable and the boolean value the value to pass to
``--color`` option flag when encountered.
Source:
- https://github.com/pallets/click/issues/558
- https://github.com/pallets/click/issues/1090
- https://github.com/pallets/click/issues/1498
- https://github.com/pallets/click/issues/3022
- https://blog.codemine.be/posts/2026/20260222-be-quiet/
"""
COLOR_DISABLING_TERMS = frozenset({"dumb", "unknown"})
"""``TERM`` values marking a terminal too limited for ANSI niceties.
A ``dumb`` or ``unknown`` terminal advertises neither SGR color nor the
cursor-control codes (carriage return, clear-line) an animation relies on, so both
Click Extra's color resolution (:func:`~click_extra.color.resolve_color_env`) and the
spinner's animation gating (``Spinner._resolve_enabled``) treat these two values as a
hard opt-out. Sharing the set keeps the color and animation axes from drifting apart.
An *unset* ``TERM`` is deliberately excluded: it is common on legitimately
color-capable streams (subprocesses, some IDEs) where defaulting to off would be a
regression. This matches `Rich
<https://github.com/Textualize/rich/blob/master/rich/console.py>`_, which keys its
own dumb-terminal detection on the same two values and not on absence.
"""
[docs]
def resolve_color_env() -> bool | None:
"""Reconcile the recognized color environment variables into a tri-state.
Inspects every variable listed in :data:`color_envvars` and returns:
- ``True`` if at least one *enabling* variable (``FORCE_COLOR``, ``CLICOLOR``, β¦)
is set. Enabling wins over disabling, so a single one is enough to keep colors.
- ``False`` if only *disabling* variables (``NO_COLOR``, ``LLM``, β¦) are set.
- ``None`` when no recognized variable is present, leaving the caller free to
apply its own default (typically ``auto``).
A bare variable (no value), or one whose value cannot be parsed as a boolean,
counts as activation, in the permissive spirit of the `NO_COLOR
<https://no-color.org>`_ and `FORCE_COLOR <https://force-color.org>`_ conventions.
A ``dumb`` or ``unknown`` ``TERM`` (see :data:`COLOR_DISABLING_TERMS`) casts a
disabling vote as well, so a terminal that cannot render ANSI is treated as
color-off even when it still reports as a TTY. Because enabling wins, an explicit
``FORCE_COLOR`` stays authoritative over it.
"""
enabling = set()
for var, enables in color_envvars.items():
if var in os.environ:
# Presence without a value encodes an activation, hence the default to
# "true"; an unparsable value falls back to True in the same spirit.
raw_value = os.environ.get(var, "true")
parsed = RawConfigParser.BOOLEAN_STATES.get(raw_value.lower(), True)
enabling.add(enables ^ (not parsed))
# A dumb/unknown terminal cannot render ANSI color: cast a disabling vote that an
# explicit enabling variable still overrides, but which beats the auto default
# when no recognized color variable is set.
if os.environ.get("TERM", "").lower() in COLOR_DISABLING_TERMS:
enabling.add(False)
if not enabling:
return None
return True in enabling
[docs]
@contextmanager
def forced_color() -> Iterator[None]:
"""Force ANSI color while Click Extra captures CLI text for documentation.
Click Extra renders CLI help and output into docs from both the MkDocs plugin
(:mod:`click_extra.mkdocs`) and the Sphinx directives
(:mod:`click_extra.sphinx.click`). During a build that output is a pipe, not a TTY,
so the underlying renderers strip their escape codes. Two independent color systems
have to be defeated:
- Click's, gated by ``should_strip_ansi`` / ``ctx.color`` (what ``click.echo`` and
the Click and Click Extra help formatters consult). Sphinx's runner additionally
flips this one with ``click.testing.CliRunner(color=True)``.
- Rich's, gated by ``rich.console.Console.is_terminal``, which ignores the above and
reads ``FORCE_COLOR`` (https://force-color.org). This is the system ``rich-click``
uses, and ``color=True`` never reaches it.
``FORCE_COLOR`` is the only signal common to both systems (Rich reads it directly;
Click Extra recognizes it through :data:`color_envvars`), so it is the lever we set
here. We also clear the color-disabling variables Click Extra recognizes
(``NO_COLOR``, ``LLM``, β¦) so an opt-out in the build environment cannot suppress the
rendering, and pin ``COLORTERM=truecolor`` so the branded 24-bit themes render at
full depth instead of being quantized to the 256-color palette (see
:func:`~click_extra.styling.supports_truecolor`). The previous environment is
restored on exit, so the override never leaks beyond a single capture.
"""
disabling = [var for var, enables in color_envvars.items() if not enables]
saved = {
var: os.environ.get(var) for var in ("FORCE_COLOR", "COLORTERM", *disabling)
}
os.environ["FORCE_COLOR"] = "1"
os.environ["COLORTERM"] = "truecolor"
for var in disabling:
os.environ.pop(var, None)
try:
yield
finally:
for var, value in saved.items():
if value is None:
os.environ.pop(var, None)
else:
os.environ[var] = value
COLOR_WHEN = ("auto", "always", "never")
"""GNU-canonical tri-state values accepted by ``--color=<WHEN>``.
``auto`` defers to terminal detection, ``always`` forces ANSI on, ``never`` strips
it. See `GNU coreutils
<https://www.gnu.org/software/coreutils/manual/html_node/General-output-formatting.html>`_
and `this discussion <https://news.ycombinator.com/item?id=36102377>`_.
"""
_WHEN_TO_TRISTATE: dict[str, bool | None] = {
"auto": None,
"always": True,
"never": False,
}
"""Map each :data:`COLOR_WHEN` choice to the ``ctx.color`` value it produces.
``None`` lets Click auto-detect colorization from the output stream's TTY status,
``True`` keeps ANSI codes, ``False`` strips them.
"""
COLOR_WHEN_ALIASES: dict[str, str] = {
# ``always`` synonyms.
"yes": "always",
"force": "always",
# ``never`` synonyms.
"no": "never",
"none": "never",
# ``auto`` synonyms.
"tty": "auto",
"if-tty": "auto",
}
"""GNU coreutils synonyms accepted as hidden aliases for each :data:`COLOR_WHEN`
value.
GNU ``ls`` accepts ``yes``/``force`` for ``always``, ``no``/``none`` for ``never``
and ``tty``/``if-tty`` for ``auto``, alongside the three canonical spellings (see
:data:`COLOR_WHEN`). Click Extra mirrors that leniency but keeps the synonyms out of
``--help`` output, error messages and shell completion, which only ever advertise
:data:`COLOR_WHEN`.
"""
_COLOR_WHEN_LOOKUP = {**{when: when for when in COLOR_WHEN}, **COLOR_WHEN_ALIASES}
"""Lowercase-keyed map folding every accepted spelling to its canonical
:data:`COLOR_WHEN` value.
Combines the identity mapping of the canonical values with
:data:`COLOR_WHEN_ALIASES`, for the case-insensitive lookup in
:meth:`ColorWhenChoice.convert`.
"""
_COLOR_CLI_OVERRIDE_KEY = "click_extra.color_cli_override"
"""Context meta key flagging an explicit ``--no-color`` on the command line.
Set by :meth:`NoColorOption.set_no_color` and read by :meth:`ColorOption.set_color` so
an explicit negative alias outranks the color environment variables, which may only
override the built-in default.
"""
[docs]
class ColorWhenChoice(click.Choice):
""":class:`click.Choice` over :data:`COLOR_WHEN` that also accepts the hidden GNU
synonyms (:data:`COLOR_WHEN_ALIASES`) and native configuration booleans, folding
them to a canonical value before validation.
Only the three canonical :data:`~click_extra.color.COLOR_WHEN` values reach
``--help``, error messages and shell completion, because the public ``choices``
stay canonical. Synonyms and booleans are accepted silently and normalized, so
downstream code (:meth:`ColorOption.set_color`, ``_WHEN_TO_TRISTATE``) only ever
sees ``auto``, ``always`` or ``never``.
Matching is case-insensitive and whitespace-tolerant, which also makes the
canonical values forgiving, such as ``--color=ALWAYS``.
"""
[docs]
def convert(
self,
value: Any,
param: click.Parameter | None,
ctx: click.Context | None,
) -> Any:
"""Fold synonyms and booleans to canonical, then defer to ``click.Choice``.
A native :class:`bool` only reaches this method from a structured
configuration file: TOML or JSON booleans, or YAML's coercion of
``yes``/``no``/``on``/``off``/``true``/``false``. ``True`` maps to
``always`` and ``False`` to ``never``, consistent with a bare ``--color``
and ``--no-color``. The command line always delivers strings, so this never
turns ``--color=true`` into a valid CLI spelling.
.. caution::
A configuration boolean therefore diverges from git's `color.ui
<https://git-scm.com/docs/git-config>`_, where ``true`` means ``auto``.
Click Extra keeps ``true`` equal to ``always`` so the ``yes`` string
synonym and YAML's coercion of ``yes`` to ``True`` resolve identically
across file formats.
"""
if isinstance(value, bool):
value = "always" if value else "never"
elif isinstance(value, str):
value = _COLOR_WHEN_LOOKUP.get(value.strip().lower(), value)
return super().convert(value, param, ctx)
[docs]
class ColorOption(ExtraOption):
"""A pre-configured ``--color[=WHEN]`` tri-state option.
Mirrors the `GNU coreutils convention
<https://www.gnu.org/software/coreutils/manual/html_node/General-output-formatting.html>`_:
``WHEN`` is one of :data:`~click_extra.color.COLOR_WHEN`
(``auto``, ``always`` or ``never``), and a bare ``--color`` (no value) means
``always``. The negative alias ``--no-color`` is carried by the separate
:class:`NoColorOption`, because Click forbids attaching ``/--no-x`` secondary
flags to a value option.
The resolved tri-state lands on ``ctx.color``, the Click-standard attribute that
``echo()`` reads through its ``resolve_color_default()`` β ``should_strip_ansi()``
chain: ``True`` keeps ANSI codes, ``False`` strips them, ``None`` (``auto``) defers
to the output stream's TTY status.
This option is eager by default, so other eager options (like ``--version``) are
rendered with the resolved color state.
.. note::
``--color`` is deliberately not wired to an ``envvar``. The color environment
variables (``NO_COLOR``, ``FORCE_COLOR``, β¦) are read manually through
:func:`~click_extra.color.resolve_color_env`. Letting Click manage them would
dump the whole :data:`~click_extra.color.color_envvars` set into the
``--show-params`` env-var column, and only bind one variable per option anyway.
"""
_gnu_optional_value: ClassVar[bool] = True
"""Marks the option for GNU-style optional-argument parsing.
Read by :meth:`add_to_parser` to make a bare ``--color`` resolve to
``flag_value`` instead of swallowing the following token.
"""
[docs]
def add_to_parser(self, parser: _OptionParser, ctx: click.Context) -> None:
"""Register the option, then teach the parser GNU optional-argument rules.
Click's optional-value parser binds ``--color`` to the next token whenever it
does not look like an option, so ``mycli --color subcommand`` would consume
``subcommand`` as the color value and fail. GNU instead binds an optional
argument only when it is attached with ``=``.
This wraps the parser's long-option matcher so a bare ``--color``
replays as ``--color=<flag_value>`` (``always``) and leaves the following
argument untouched, while ``--color=<when>`` keeps working. The wrapper stays
inert for every option that does not carry ``_gnu_optional_value``, so it
is safe to install on the shared parser.
"""
super().add_to_parser(parser, ctx)
wrapped = parser._match_long_opt
def _match_long_opt(opt, explicit_value, state):
if explicit_value is None:
parsed = parser._long_opt.get(opt)
if parsed is not None and getattr(
parsed.obj,
"_gnu_optional_value",
False,
):
# Replay as if the user had typed --color=<flag_value>.
wrapped(opt, parsed.obj.flag_value, state)
return
wrapped(opt, explicit_value, state)
parser._match_long_opt = _match_long_opt # type: ignore[method-assign]
[docs]
def set_color(
self,
ctx: click.Context,
param: click.Parameter,
value: str,
) -> None:
"""Resolve ``--color=<WHEN>`` against the environment and pin ``ctx.color``.
Precedence, highest first:
#. An explicit ``--color`` on the command line.
#. The color environment variables, but only when the value comes from the
built-in default. A configuration file or ``--accessible`` (both seen here
as a non-``DEFAULT`` source) therefore wins over the environment, matching
:class:`~click_extra.accessibility.AccessibleOption`.
#. A color state already pinned by ``--no-color``, a forced
test runner, or an explicit ``Context(color=...)``: preserved when this
option only resolves to ``auto`` from its default.
#. The ``auto`` default, leaving ``ctx.color`` at ``None`` for TTY detection.
"""
when = value
source = ctx.get_parameter_source("color")
# The environment can only override a pure built-in default, never a value
# coming from the command line, a configuration file or --accessible. An
# explicit --no-color (recorded by NoColorOption) is a
# command-line choice and therefore outranks the environment too.
if source == ParameterSource.DEFAULT and not ctx.meta.get(
_COLOR_CLI_OVERRIDE_KEY,
):
env_color = resolve_color_env()
if env_color is not None:
ctx.color = env_color
return
tristate = _WHEN_TO_TRISTATE[when]
# "auto" defers to TTY detection. Do not overwrite a color state already
# pinned upstream (--no-color, a forced runner, Context(color=...)) unless the
# user explicitly spelled out --color=auto on the command line.
if (
tristate is None
and source != ParameterSource.COMMANDLINE
and ctx.color is not None
):
return
ctx.color = tristate
def __init__(
self,
param_decls: Sequence[str] | None = None,
is_flag=False,
flag_value="always",
default="auto",
is_eager=True,
expose_value=False,
help=_("Colorize the output. A bare --color is the same as --color=always."),
**kwargs,
) -> None:
if not param_decls:
param_decls = ("--color",)
kwargs.setdefault("callback", self.set_color)
kwargs.setdefault("type", ColorWhenChoice(COLOR_WHEN))
super().__init__(
param_decls=param_decls,
is_flag=is_flag,
flag_value=flag_value,
default=default,
is_eager=is_eager,
expose_value=expose_value,
help=help,
**kwargs,
)
[docs]
class NoColorOption(ExtraOption):
"""``--no-color`` flag that forces ``--color=never``.
Click rejects ``/--no-x`` secondary flags on a value option, so the negative
alias of the tri-state :class:`ColorOption` cannot live on it and is provided
here as a standalone boolean flag. When set, it pins ``ctx.color`` to ``False``;
when absent it is a no-op, leaving the resolution to :class:`ColorOption`.
Shown on its own line directly below ``--color`` (mirroring ``--no-config``
below ``--config``), since every other negative in the default option set is
visible too. Eager by default, like :class:`ColorOption`, so the color state is
settled before other eager options render.
"""
[docs]
def set_no_color(
self,
ctx: click.Context,
param: click.Parameter,
value: bool,
) -> None:
"""Force ``ctx.color`` off when a negative alias is passed; no-op otherwise."""
if value:
ctx.color = False
# Flag the explicit choice so ColorOption keeps the environment from
# overriding it (the environment may only override the built-in default).
ctx.meta[_COLOR_CLI_OVERRIDE_KEY] = True
def __init__(
self,
param_decls: Sequence[str] | None = None,
is_flag=True,
default=False,
is_eager=True,
expose_value=False,
help=_("Disable colorization (alias of --color=never)."),
**kwargs,
) -> None:
if not param_decls:
param_decls = ("--no-color",)
kwargs.setdefault("callback", self.set_no_color)
super().__init__(
param_decls=param_decls,
is_flag=is_flag,
default=default,
is_eager=is_eager,
expose_value=expose_value,
help=help,
**kwargs,
)