From d9cae0efe3b3a0781fd516bcfb79bcc6f02aa9b9 Mon Sep 17 00:00:00 2001 From: Sorin Sbarnea Date: Mon, 28 Oct 2024 15:35:55 +0000 Subject: [PATCH] Adds ability to configure stderr output color --- docs/changelog/3426.misc.rst | 2 ++ src/tox/config/cli/parser.py | 8 ++++++++ src/tox/execute/api.py | 19 ++++++++++--------- tests/config/cli/test_cli_env_var.py | 2 ++ tests/config/cli/test_cli_ini.py | 2 ++ .../local_subprocess/test_local_subprocess.py | 18 +++++++++++++++--- 6 files changed, 39 insertions(+), 12 deletions(-) create mode 100644 docs/changelog/3426.misc.rst diff --git a/docs/changelog/3426.misc.rst b/docs/changelog/3426.misc.rst new file mode 100644 index 000000000..69feebd2a --- /dev/null +++ b/docs/changelog/3426.misc.rst @@ -0,0 +1,2 @@ +Adds ability to configure the stderr color for output received from external +commands. diff --git a/src/tox/config/cli/parser.py b/src/tox/config/cli/parser.py index afbe578c9..87d441c3c 100644 --- a/src/tox/config/cli/parser.py +++ b/src/tox/config/cli/parser.py @@ -11,6 +11,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Dict, List, Literal, Optional, Sequence, Tuple, Type, TypeVar, cast +from colorama import Fore + from tox.config.loader.str_convert import StrConvert from tox.plugin import NAME from tox.util.ci import is_ci @@ -366,6 +368,12 @@ def add_color_flags(parser: ArgumentParser) -> None: choices=["yes", "no"], help="should output be enriched with colors, default is yes unless TERM=dumb or NO_COLOR is defined.", ) + parser.add_argument( + "--stderr-color", + default="RED", + choices=[*Fore.__dict__.keys()], + help="color for stderr output, use RESET for terminal defaults.", + ) def add_exit_and_dump_after(parser: ArgumentParser) -> None: diff --git a/src/tox/execute/api.py b/src/tox/execute/api.py index 80487a169..a374a9b99 100644 --- a/src/tox/execute/api.py +++ b/src/tox/execute/api.py @@ -122,18 +122,19 @@ def call( env: ToxEnv, ) -> Iterator[ExecuteStatus]: start = time.monotonic() + stderr_color = None + if self._colored: + try: + cfg_color = env.conf._conf.options.stderr_color # noqa: SLF001 + stderr_color = Fore.__dict__[cfg_color] + except (AttributeError, KeyError, TypeError): # many tests have a mocked 'env' + stderr_color = Fore.RED try: # collector is what forwards the content from the file streams to the standard streams out, err = out_err[0].buffer, out_err[1].buffer - out_sync = SyncWrite( - out.name, - out if show else None, # type: ignore[arg-type] - ) - err_sync = SyncWrite( - err.name, - err if show else None, # type: ignore[arg-type] - Fore.RED if self._colored else None, - ) + out_sync = SyncWrite(out.name, out if show else None) # type: ignore[arg-type] + err_sync = SyncWrite(err.name, err if show else None, stderr_color) # type: ignore[arg-type] + with out_sync, err_sync: instance = self.build_instance(request, self._option_class(env), out_sync, err_sync) with instance as status: diff --git a/tests/config/cli/test_cli_env_var.py b/tests/config/cli/test_cli_env_var.py index 7f265c78c..442fa65e5 100644 --- a/tests/config/cli/test_cli_env_var.py +++ b/tests/config/cli/test_cli_env_var.py @@ -31,6 +31,7 @@ def test_verbose_no_test() -> None: "verbose": 4, "quiet": 0, "colored": "no", + "stderr_color": "RED", "work_dir": None, "root_dir": None, "config_file": None, @@ -90,6 +91,7 @@ def test_env_var_exhaustive_parallel_values( assert vars(options.parsed) == { "always_copy": False, "colored": "no", + "stderr_color": "RED", "command": "legacy", "default_runner": "virtualenv", "develop": False, diff --git a/tests/config/cli/test_cli_ini.py b/tests/config/cli/test_cli_ini.py index 82fb7fc95..d608f57a0 100644 --- a/tests/config/cli/test_cli_ini.py +++ b/tests/config/cli/test_cli_ini.py @@ -29,6 +29,7 @@ def default_options() -> dict[str, Any]: return { "colored": "no", + "stderr_color": "RED", "command": "r", "default_runner": "virtualenv", "develop": False, @@ -200,6 +201,7 @@ def test_ini_exhaustive_parallel_values(core_handlers: dict[str, Callable[[State options = get_options("p") assert vars(options.parsed) == { "colored": "yes", + "stderr_color": "RED", "command": "p", "default_runner": "virtualenv", "develop": False, diff --git a/tests/execute/local_subprocess/test_local_subprocess.py b/tests/execute/local_subprocess/test_local_subprocess.py index 2bfb542e8..1ac1aee49 100644 --- a/tests/execute/local_subprocess/test_local_subprocess.py +++ b/tests/execute/local_subprocess/test_local_subprocess.py @@ -48,6 +48,11 @@ def read_out_err(self) -> tuple[str, str]: @pytest.mark.parametrize("color", [True, False], ids=["color", "no_color"]) @pytest.mark.parametrize(("out", "err"), [("out", "err"), ("", "")], ids=["simple", "nothing"]) @pytest.mark.parametrize("show", [True, False], ids=["show", "no_show"]) +@pytest.mark.parametrize( + "stderr_color", + ["RED", "YELLOW", "RESET"], + ids=["stderr_color_default", "stderr_color_yellow", "stderr_color_reset"], +) def test_local_execute_basic_pass( # noqa: PLR0913 caplog: LogCaptureFixture, os_env: dict[str, str], @@ -55,13 +60,18 @@ def test_local_execute_basic_pass( # noqa: PLR0913 err: str, show: bool, color: bool, + stderr_color: str, ) -> None: caplog.set_level(logging.NOTSET) executor = LocalSubProcessExecutor(colored=color) + + tox_env = MagicMock() + tox_env.conf._conf.options.stderr_color = stderr_color # noqa: SLF001 code = f"import sys; print({out!r}, end=''); print({err!r}, end='', file=sys.stderr)" request = ExecuteRequest(cmd=[sys.executable, "-c", code], cwd=Path(), env=os_env, stdin=StdinSource.OFF, run_id="") out_err = FakeOutErr() - with executor.call(request, show=show, out_err=out_err.out_err, env=MagicMock()) as status: + + with executor.call(request, show=show, out_err=out_err.out_err, env=tox_env) as status: while status.exit_code is None: # pragma: no branch status.wait() assert status.out == out.encode() @@ -77,7 +87,7 @@ def test_local_execute_basic_pass( # noqa: PLR0913 out_got, err_got = out_err.read_out_err() if show: assert out_got == out - expected = (f"{Fore.RED}{err}{Fore.RESET}" if color else err) if err else "" + expected = f"{Fore.__dict__[stderr_color]}{err}{Fore.RESET}" if color and err else err assert err_got == expected else: assert not out_got @@ -226,7 +236,9 @@ def test_local_execute_basic_fail(capsys: CaptureFixture, caplog: LogCaptureFixt out, err = capsys.readouterr() assert out == "out\n" - expected = f"{Fore.RED}err{Fore.RESET}\n" + # Because this is a command that is expected to fail, we expect it to be + # colored using green (_assert_fail) and not the default red. + expected = f"{Fore.GREEN}err{Fore.RESET}\n" assert err == expected assert len(caplog.records) == 1