From c0a75ce37daf0f188797bef0d43d33a948beb17e Mon Sep 17 00:00:00 2001 From: David Lynch Date: Thu, 3 Sep 2026 14:34:26 +0100 Subject: [PATCH 1/2] feat(ENG-14143): adding checks for colour and interactivity suppression Adding checks in the CLI to supporess ANSI output and interactive prompts ## ANSI suppression 1. Check for non-empty `NO_COLOUR` flag 2. Check for `CLOUDSMITH_FORCE_COLOR` flag to enable colour 3. Check for TERM=dumb # Prompt suppression 1. Check for CI=true env var 2. Respect shell TTY conditions --- .../core/tests/test_terminal_color.py | 123 ++++++++++++++++++ cloudsmith_cli/core/utils.py | 50 +++++++ 2 files changed, 173 insertions(+) create mode 100644 cloudsmith_cli/core/tests/test_terminal_color.py diff --git a/cloudsmith_cli/core/tests/test_terminal_color.py b/cloudsmith_cli/core/tests/test_terminal_color.py new file mode 100644 index 00000000..f1c9889c --- /dev/null +++ b/cloudsmith_cli/core/tests/test_terminal_color.py @@ -0,0 +1,123 @@ +from enum import Flag, auto +from typing import ClassVar + +import pytest + +from cloudsmith_cli.core.utils import ( + ColorMode, + TTYMode, + color_enabled, + is_interactive, +) + + +class Desired(Flag): + NONE = 0 + COLOR = auto() + INTERACTIVE = auto() + + +class TestTerminalUISuppression: + """Testing precedence in environment variables for controlling interactivity and colour""" + + env_tests: ClassVar[list] = [ + pytest.param( + {}, + ColorMode.AUTO, + TTYMode.ENABLED, + Desired.COLOR | Desired.INTERACTIVE, + id="default behaviour", + ), + pytest.param( + {}, + ColorMode.NEVER, + TTYMode.DISABLED, + Desired.NONE, + id="force disable tty and color mode", + ), + pytest.param( + {"NO_COLOR": ""}, + ColorMode.AUTO, + TTYMode.ENABLED, + Desired.COLOR | Desired.INTERACTIVE, + id="empty no color var", + ), + pytest.param( + {"CLOUDSMITH_FORCE_COLOR": "true"}, + ColorMode.AUTO, + TTYMode.ENABLED, + Desired.COLOR | Desired.INTERACTIVE, + id="force ANSI output", + ), + pytest.param( + {"NO_COLOR": "true", "CLOUDSMITH_FORCE_COLOR": "true"}, + ColorMode.AUTO, + TTYMode.ENABLED, + Desired.INTERACTIVE, + id="force ANSI output, respects NO_COLOR", + ), + pytest.param( + {"TERM": "dumb"}, + ColorMode.AUTO, + TTYMode.ENABLED, + Desired.INTERACTIVE, + id="no color enabled for TERM=dumb", + ), + pytest.param( + {"CI": "true"}, + ColorMode.AUTO, + TTYMode.ENABLED, + Desired.COLOR, + id="in a CI server, do not use interactive features", + ), + pytest.param( + {"CI": "true", "NO_COLOR": "true"}, + ColorMode.AUTO, + TTYMode.ENABLED, + Desired.NONE, + id="suppress interactivity and colour", + ), + pytest.param( + { + "NO_COLOR": "true", + "TERM": "dumb", + "CLOUDSMITH_FORCE_COLOR": "true", + }, + ColorMode.AUTO, + TTYMode.ENABLED, + Desired.INTERACTIVE, + id="test NO_COLOR always respected", + ), + pytest.param( + { + "TERM": "dumb", + "CLOUDSMITH_FORCE_COLOR": "true", + }, + ColorMode.AUTO, + TTYMode.ENABLED, + Desired.COLOR | Desired.INTERACTIVE, + id="ensure force tty respected over term dumb", + ), + ] + + @pytest.mark.parametrize("env,colorMode,ttyMode,desired", env_tests) + def test_no_color_environment_variables( + self, + env: dict[str, str], + colorMode: ColorMode, + ttyMode: TTYMode, + desired: Desired, + ): + want_color = Desired.COLOR in desired + assert color_enabled(env, colorMode, ttyMode) == want_color, ( + f"colour suppression check failed for environment: {env} wanted {desired}" + ) + + @pytest.mark.parametrize("env,colorMode,ttyMode,desired", env_tests) + def test_no_interactive_environment_variables( + self, env, colorMode, ttyMode, desired + ): + want_interactive = Desired.INTERACTIVE in desired + assert is_interactive(env, ttyMode) == want_interactive, ( + f"interactive suppression check failed for environment {env} wanted {desired}" + ) diff --git a/cloudsmith_cli/core/utils.py b/cloudsmith_cli/core/utils.py index 6d863910..c3fca8c9 100644 --- a/cloudsmith_cli/core/utils.py +++ b/cloudsmith_cli/core/utils.py @@ -2,6 +2,7 @@ import hashlib import os +from enum import Enum, auto import click @@ -11,6 +12,55 @@ def get_help_website(): return "https://docs.cloudsmith.com/developer-tools/cli" +class ColorMode(Enum): + AUTO = auto() + ALWAYS = auto() + NEVER = auto() + + +class TTYMode(Enum): + ENABLED = auto() + DISABLED = auto() + + +def color_enabled(env: dict[str, str], colorMode: ColorMode, ttyMode: TTYMode) -> bool: + """Suppresses ANSI colour based on environment and CLI input""" + match colorMode: + case ColorMode.ALWAYS: + return True + case ColorMode.NEVER: + return False + case ColorMode.AUTO: + pass + + if env.get("NO_COLOR"): + return False + if env.get("CLOUDSMITH_FORCE_COLOR") == "true": + return True + if env.get("TERM") == "dumb": + return False + + match ttyMode: + case TTYMode.ENABLED: + return True + case TTYMode.DISABLED: + return False + return True + + +def is_interactive(env: dict[str, str], tty_mode: TTYMode) -> bool: + """Suppresses interactive prompts for user input based on conditions in the shell environment""" + if env.get("CI"): + return False + + match tty_mode: + case TTYMode.ENABLED: + return True + case TTYMode.DISABLED: + return False + return True + + def get_github_website(): """Get the URL for the GitHub project.""" return "https://github.com/cloudsmith-io/cloudsmith-cli" From 58ba377c009c8dfca038ed5745dabaf0d2f64840 Mon Sep 17 00:00:00 2001 From: David Lynch Date: Thu, 3 Sep 2026 14:34:27 +0100 Subject: [PATCH 2/2] feat(ENG-14143): adding colour suppression to the CLI via environment variables feat(ENG-14143): adding colour suppression to the CLI via command line arguments --- cloudsmith_cli/cli/decorators.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/cloudsmith_cli/cli/decorators.py b/cloudsmith_cli/cli/decorators.py index a76305e8..0445ff2a 100644 --- a/cloudsmith_cli/cli/decorators.py +++ b/cloudsmith_cli/cli/decorators.py @@ -3,11 +3,13 @@ import functools import logging import os +import sys import click from click.core import ParameterSource from cloudsmith_cli.cli import validators +from cloudsmith_cli.core.utils import ColorMode, TTYMode, color_enabled from ..core.credentials.chain import CredentialProviderChain from ..core.credentials.models import CredentialContext @@ -151,6 +153,11 @@ def wrapper(ctx, *args, **kwargs): def common_cli_output_options(f): """Add common CLI output options to commands.""" + @click.option( + "--color", + default="auto", + type=click.Choice(ColorMode, case_sensitive=False), + ) @click.option( "-d", "--debug", @@ -178,6 +185,13 @@ def common_cli_output_options(f): def wrapper(ctx, *args, **kwargs): # pylint: disable=missing-docstring opts = config.get_or_create_options(ctx) + + ctx.color = color_enabled( + dict(os.environ), + kwargs.pop("color"), + TTYMode.ENABLED if sys.stdout.isatty() else TTYMode.DISABLED, + ) + opts.debug = kwargs.pop("debug") or opts.debug _configure_debug_logging(opts.debug) opts.output = kwargs.pop("output_format")