# 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.
from __future__ import annotations
import logging
import os
import click
import pytest
from click_extra import (
Style,
color_option,
command,
echo,
group,
no_color_option,
pass_context,
secho,
style,
verbosity_option,
)
from click_extra.color import (
COLOR_DISABLING_TERMS,
color_envvars,
forced_color,
resolve_color_env,
)
from click_extra.pytest import (
default_debug_colored_log_end,
default_debug_colored_log_start,
default_debug_colored_logging,
default_debug_uncolored_log_end,
default_debug_uncolored_log_start,
default_debug_uncolored_logging,
)
from click_extra.theme import get_default_theme
theme = get_default_theme()
from .conftest import skip_windows_colors
[docs]
@skip_windows_colors
@pytest.mark.parametrize("option_decorator", (color_option, color_option()))
@pytest.mark.parametrize(
("param", "expecting_colors"),
(
("--color", True),
("--no-color", False),
(None, True),
),
)
def test_standalone_color_option(
invoke, option_decorator, param, expecting_colors, assert_output_regex
):
"""Check color option values, defaults and effects on all things colored, including
verbosity option."""
@click.command
@verbosity_option
@option_decorator
@no_color_option
def standalone_color():
echo(Style(fg="yellow")("It works!"))
echo("\x1b[0m\x1b[1;36mArt\x1b[46;34m\x1b[0m")
echo(style("Run command.", fg="magenta"))
logging.getLogger("click_extra").warning("Processing...")
print(style("print() bypass Click.", fg="blue"))
secho("Done.", fg="green")
result = invoke(standalone_color, param, "--verbosity", "DEBUG", color=True)
if expecting_colors:
assert result.stdout == (
"\x1b[33mIt works!\x1b[0m\n"
"\x1b[0m\x1b[1;36mArt\x1b[46;34m\x1b[0m\n"
"\x1b[35mRun command.\x1b[0m\n"
"\x1b[34mprint() bypass Click.\x1b[0m\n"
"\x1b[32mDone.\x1b[0m\n"
)
assert_output_regex(
result.stderr,
(
rf"{default_debug_colored_logging}"
r"\x1b\[33mwarning\x1b\[0m: Processing\.\.\.\n"
rf"{default_debug_colored_log_end}"
),
)
else:
assert result.stdout == (
"It works!\n"
"Art\n"
"Run command.\n"
"\x1b[34mprint() bypass Click.\x1b[0m\n"
"Done.\n"
)
assert_output_regex(
result.stderr,
(
rf"{default_debug_uncolored_logging}"
r"warning: Processing\.\.\.\n"
rf"{default_debug_uncolored_log_end}"
),
)
assert result.exit_code == 0
[docs]
@pytest.mark.parametrize(
("env", "env_expect_colors"),
(
({"COLOR": "True"}, True),
({"COLOR": "true"}, True),
({"COLOR": "1"}, True),
({"COLOR": ""}, True),
({"COLOR": "False"}, False),
({"COLOR": "false"}, False),
({"COLOR": "0"}, False),
({"NO_COLOR": "True"}, False),
({"NO_COLOR": "true"}, False),
({"NO_COLOR": "1"}, False),
({"NO_COLOR": ""}, False),
({"NO_COLOR": "False"}, True),
({"NO_COLOR": "false"}, True),
({"NO_COLOR": "0"}, True),
({"LLM": "True"}, False),
({"LLM": "true"}, False),
({"LLM": "1"}, False),
({"LLM": ""}, False),
({"LLM": "False"}, True),
({"LLM": "false"}, True),
({"LLM": "0"}, True),
(None, True),
),
)
@pytest.mark.parametrize(
("param", "param_expect_colors"),
(
("--color", True),
("--no-color", False),
(None, True),
),
)
def test_no_color_env_convention(
invoke,
env,
env_expect_colors,
param,
param_expect_colors,
):
@click.command
@color_option
@no_color_option
def color_cli7():
echo(Style(fg="yellow")("It works!"))
# Unset all recognized color env vars so the outer environment (like
# LLM=1 set by AI agents) doesn't leak into the baseline case.
if env is None:
env = {var: None for var in color_envvars if var in os.environ}
result = invoke(color_cli7, param, color=True, env=env)
# Params always overrides env's expectations.
expecting_colors = env_expect_colors
if param:
expecting_colors = param_expect_colors
if expecting_colors:
assert result.stdout == "\x1b[33mIt works!\x1b[0m\n"
else:
assert result.stdout == "It works!\n"
assert result.exit_code == 0
assert not result.stderr
[docs]
@pytest.mark.parametrize(
("term", "expected"),
(
# A dumb/unknown terminal cannot render ANSI: color-off even on a TTY.
("dumb", False),
("unknown", False),
# A capable, empty, or unset TERM expresses no opinion (auto).
("xterm-256color", None),
("", None),
(None, None),
),
)
def test_resolve_color_env_term(monkeypatch, term, expected):
"""A dumb/unknown TERM votes color-off, while any other value stays neutral."""
# Clear every recognized color variable so only TERM is under test.
for var in color_envvars:
monkeypatch.delenv(var, raising=False)
if term is None:
monkeypatch.delenv("TERM", raising=False)
else:
monkeypatch.setenv("TERM", term)
assert resolve_color_env() is expected
[docs]
@pytest.mark.parametrize("term", sorted(COLOR_DISABLING_TERMS))
def test_resolve_color_env_force_color_beats_dumb_term(monkeypatch, term):
"""An explicit FORCE_COLOR stays authoritative over a dumb/unknown TERM."""
for var in color_envvars:
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("TERM", term)
monkeypatch.setenv("FORCE_COLOR", "1")
assert resolve_color_env() is True
# --- GNU --color synonyms, hidden aliases, and forgiving configuration ---
#
# ``--color`` accepts the GNU coreutils synonyms (yes/force, no/none, tty/if-tty)
# as hidden aliases for always/never/auto, case-insensitively. Configuration files
# additionally accept native booleans (true -> always, false -> never), including
# YAML's coercion of yes/no/on/off. See click_extra.color.ColorWhenChoice.
def _no_color_env():
"""Unset every recognized color env var so auto/tty resolve to None and the
outer environment cannot leak into the synonym resolution."""
return {var: None for var in color_envvars if var in os.environ}
[docs]
@skip_windows_colors
@pytest.mark.parametrize(
("when", "ctx_color"),
(
# Canonical values.
("auto", "None"),
("always", "True"),
("never", "False"),
# Canonical values are case-insensitive too.
("AUTO", "None"),
("Always", "True"),
("NEVER", "False"),
# GNU "always" synonyms.
("yes", "True"),
("force", "True"),
("YES", "True"),
("Force", "True"),
# GNU "never" synonyms.
("no", "False"),
("none", "False"),
("NO", "False"),
("None", "False"),
# GNU "auto" synonyms.
("tty", "None"),
("if-tty", "None"),
("TTY", "None"),
("IF-TTY", "None"),
),
)
def test_color_synonym_cli_resolution(invoke, when, ctx_color):
"""Every canonical value and GNU synonym resolves --color to the right state."""
@click.command
@color_option
@no_color_option
@pass_context
def color_synonym_cli(ctx):
echo(f"ctx.color={ctx.color}")
result = invoke(color_synonym_cli, f"--color={when}", env=_no_color_env())
assert result.exit_code == 0
assert result.stdout == f"ctx.color={ctx_color}\n"
[docs]
@skip_windows_colors
@pytest.mark.parametrize("when", ("purple", "true", "false", "1", "0", ""))
def test_color_synonym_invalid_value(invoke, when):
"""Unknown values, including the git-style true/false, still error. The message
lists only the canonical choices, never the hidden synonyms."""
@click.command
@color_option
@no_color_option
def color_invalid_cli():
echo("unreached")
result = invoke(color_invalid_cli, f"--color={when}", env=_no_color_env())
assert result.exit_code == 2
assert "'auto', 'always', 'never'" in result.stderr
# The synonyms stay out of the error message.
for hidden in ("force", "none", "if-tty"):
assert hidden not in result.stderr
[docs]
@skip_windows_colors
def test_color_synonym_hidden_in_help(invoke):
"""The GNU synonyms are accepted but never advertised: --help shows only the
canonical metavar."""
@click.command
@color_option
@no_color_option
def color_help_cli():
echo("unreached")
result = invoke(color_help_cli, "--help", env=_no_color_env())
assert result.exit_code == 0
assert "[auto|always|never]" in result.stdout
for hidden in ("force", "if-tty"):
assert hidden not in result.stdout
[docs]
@skip_windows_colors
@pytest.mark.parametrize(
("when", "env_var", "ctx_color"),
(
# An explicit CLI synonym outranks the color env vars, like its canonical twin.
("yes", "NO_COLOR", "True"),
("force", "NO_COLOR", "True"),
("no", "FORCE_COLOR", "False"),
("none", "FORCE_COLOR", "False"),
),
)
def test_color_synonym_cli_beats_env(invoke, when, env_var, ctx_color):
"""A synonym on the command line keeps the same env precedence as its canonical
twin: a command-line choice outranks the color environment variables."""
@click.command
@color_option
@no_color_option
@pass_context
def color_env_cli(ctx):
echo(f"ctx.color={ctx.color}")
env = _no_color_env()
env[env_var] = "1"
result = invoke(color_env_cli, f"--color={when}", env=env)
assert result.exit_code == 0
assert result.stdout == f"ctx.color={ctx_color}\n"
[docs]
@skip_windows_colors
@pytest.mark.parametrize(
("when", "ctx_color"),
(
("always", "True"),
("yes", "True"),
("force", "True"),
("never", "False"),
("no", "False"),
("none", "False"),
("auto", "None"),
("tty", "None"),
("if-tty", "None"),
# Case-insensitive in configuration too.
("Force", "True"),
),
)
def test_color_synonym_config_string(invoke, create_config, when, ctx_color):
"""A configuration file accepts the GNU synonyms as strings, normalized exactly
like on the command line."""
conf_path = create_config("color.toml", f'[color-cfg-cli]\ncolor = "{when}"\n')
@command
@pass_context
def color_cfg_cli(ctx):
echo(f"ctx.color={ctx.color}")
result = invoke(color_cfg_cli, "--config", str(conf_path), env=_no_color_env())
assert result.exit_code == 0
assert f"ctx.color={ctx_color}\n" in result.stdout
[docs]
@skip_windows_colors
@pytest.mark.parametrize(
("filename", "raw", "ctx_color"),
(
# Native booleans in TOML and JSON.
("color.toml", "true", "True"),
("color.toml", "false", "False"),
("color.json", "true", "True"),
("color.json", "false", "False"),
# YAML's own true/false booleans.
("color.yaml", "true", "True"),
("color.yaml", "false", "False"),
# YAML 1.1 coerces these to booleans; they must agree with the string
# synonyms (yes == always, no == never).
("color.yaml", "yes", "True"),
("color.yaml", "no", "False"),
("color.yaml", "on", "True"),
("color.yaml", "off", "False"),
),
)
def test_color_synonym_config_boolean(invoke, create_config, filename, raw, ctx_color):
"""A configuration boolean maps true -> always and false -> never, including
YAML's coercion of yes/no/on/off, so a value means the same across formats."""
ext = filename.rsplit(".", 1)[1]
if ext == "json":
content = '{"color-cfg-cli": {"color": ' + raw + "}}"
elif ext == "yaml":
content = f"color-cfg-cli:\n color: {raw}\n"
else:
content = f"[color-cfg-cli]\ncolor = {raw}\n"
conf_path = create_config(filename, content)
@command
@pass_context
def color_cfg_cli(ctx):
echo(f"ctx.color={ctx.color}")
result = invoke(color_cfg_cli, "--config", str(conf_path), env=_no_color_env())
assert result.exit_code == 0
assert f"ctx.color={ctx_color}\n" in result.stdout
[docs]
@skip_windows_colors
@pytest.mark.parametrize(
("rhs", "env_var", "ctx_color"),
(
# A config value (string synonym or boolean) inherits the precedence of any
# DEFAULT_MAP value: it outranks the color environment variables.
('"yes"', "NO_COLOR", "True"),
("true", "NO_COLOR", "True"),
('"no"', "FORCE_COLOR", "False"),
("false", "FORCE_COLOR", "False"),
),
)
def test_color_synonym_config_beats_env(invoke, create_config, rhs, env_var, ctx_color):
"""A config synonym or boolean beats the color environment variables, matching
the documented precedence of canonical config values."""
conf_path = create_config("color.toml", f"[color-cfg-cli]\ncolor = {rhs}\n")
@command
@pass_context
def color_cfg_cli(ctx):
echo(f"ctx.color={ctx.color}")
env = _no_color_env()
env[env_var] = "1"
result = invoke(color_cfg_cli, "--config", str(conf_path), env=env)
assert result.exit_code == 0
assert f"ctx.color={ctx_color}\n" in result.stdout
[docs]
@pytest.mark.parametrize(
("param", "expecting_colors", "ctx_color"),
(
("--color", True, "True"),
("--no-color", False, "False"),
# No flag: the GNU auto default leaves ctx.color at None (TTY detection),
# yet a forced runner still renders colors.
(None, True, "None"),
),
)
def test_integrated_color_option(
invoke, param, expecting_colors, ctx_color, assert_output_regex
):
"""Check effect of color option on all things colored, including verbosity option.
Also checks the color option in subcommands is inherited from parent context.
"""
@group
@pass_context
def color_cli8(ctx):
echo(f"ctx.color={ctx.color}")
echo(Style(fg="yellow")("It works!"))
echo("\x1b[0m\x1b[1;36mArt\x1b[46;34m\x1b[0m")
@color_cli8.command()
@pass_context
def command1(ctx):
echo(f"ctx.color={ctx.color}")
echo(style("Run command #1.", fg="magenta"))
logging.getLogger("click_extra").warning("Processing...")
print(style("print() bypass Click.", fg="blue"))
secho("Done.", fg="green")
result = invoke(color_cli8, param, "--verbosity", "DEBUG", "command1", color=True)
if expecting_colors:
assert result.stdout == (
f"ctx.color={ctx_color}\n"
"\x1b[33mIt works!\x1b[0m\n"
"\x1b[0m\x1b[1;36mArt\x1b[46;34m\x1b[0m\n"
f"ctx.color={ctx_color}\n"
"\x1b[35mRun command #1.\x1b[0m\n"
"\x1b[34mprint() bypass Click.\x1b[0m\n"
"\x1b[32mDone.\x1b[0m\n"
)
assert_output_regex(
result.stderr,
(
rf"{default_debug_colored_log_start}"
r"\x1b\[33mwarning\x1b\[0m: Processing\.\.\.\n"
rf"{default_debug_colored_log_end}"
),
)
else:
assert result.stdout == (
f"ctx.color={ctx_color}\n"
"It works!\n"
"Art\n"
f"ctx.color={ctx_color}\n"
"Run command #1.\n"
"\x1b[34mprint() bypass Click.\x1b[0m\n"
"Done.\n"
)
assert_output_regex(
result.stderr,
(
rf"{default_debug_uncolored_log_start}"
r"warning: Processing\.\.\.\n"
rf"{default_debug_uncolored_log_end}"
),
)
assert result.exit_code == 0
[docs]
@pytest.mark.parametrize(
("args", "expecting_colors"),
(
# A --color placed before the eager screen has always worked.
pytest.param(("--color=always", "--help"), True, id="color-before-help"),
pytest.param(("--color=always", "--version"), True, id="color-before-version"),
# ...and now also when placed after it. Click processes eager options in
# command-line order, so a late --color used to pin ctx.color only after the
# screen had already rendered and exited.
pytest.param(("--help", "--color=always"), True, id="color-after-help"),
pytest.param(("--version", "--color=always"), True, id="color-after-version"),
# A bare --color (GNU optional value) trailing an eager screen still means
# always.
pytest.param(("--help", "--color"), True, id="bare-color-after-help"),
pytest.param(("--version", "--color"), True, id="bare-color-after-version"),
# No color request in a piped (non-TTY) run leaves the screens plain.
pytest.param(("--help",), False, id="help-plain"),
pytest.param(("--version",), False, id="version-plain"),
# The negative --no-color wins wherever it sits, even after an eager screen.
pytest.param(("--help", "--no-color"), False, id="no-color-after-help"),
pytest.param(("--version", "--no-color"), False, id="no-color-after-version"),
# The last color choice on the line wins, whatever --help's position.
pytest.param(
("--help", "--color=never", "--color=always"), True, id="last-wins-on"
),
pytest.param(
("--help", "--color=always", "--no-color"), False, id="last-wins-off"
),
# GNU synonyms colorize the eager screens exactly like their canonical twin.
pytest.param(("--color=force", "--help"), True, id="force-synonym-before-help"),
pytest.param(("--help", "--color=yes"), True, id="yes-synonym-after-help"),
pytest.param(("--version", "--color=no"), False, id="no-synonym-after-version"),
),
)
def test_color_settles_before_eager_help_and_version(invoke, args, expecting_colors):
"""--color / --no-color colorize the eager --help and --version screens whatever
their position on the command line.
Click processes eager options in command-line order, so a --color sitting after
--help or --version would otherwise pin ``ctx.color`` only once the screen had
already printed and exited. ``Command.parse_args`` settles the color options in a
pre-pass to close that gap. See ``Command._resolve_color_eagerly``.
"""
@command
def color_cli():
# Never reached: --help / --version short-circuit before invocation.
echo(style("Unreached.", fg="yellow"))
# Omitting the runner's color keeps it in piped (non-TTY) mode without stripping
# ANSI, so only an explicit --color can introduce color codes into the screen.
result = invoke(color_cli, *args)
assert result.exit_code == 0
assert ("\x1b[" in result.output) is expecting_colors
[docs]
def test_forced_color_sets_and_restores_env(monkeypatch):
"""``forced_color`` forces ``FORCE_COLOR`` and clears Click Extra's disabling vars.
Inside the context the capture sees ``FORCE_COLOR=1`` with every flag that would
disable color (``NO_COLOR``, ``LLM``, β¦) removed; on exit the prior environment,
including any pre-existing values, is restored untouched.
"""
monkeypatch.setenv("NO_COLOR", "1")
monkeypatch.setenv("LLM", "1")
monkeypatch.delenv("FORCE_COLOR", raising=False)
with forced_color():
assert os.environ["FORCE_COLOR"] == "1"
assert "NO_COLOR" not in os.environ
assert "LLM" not in os.environ
assert "FORCE_COLOR" not in os.environ
assert os.environ["NO_COLOR"] == "1"
assert os.environ["LLM"] == "1"