Skip to content

Commit 1316db1

Browse files
committed
Adds ability to configure stderr output color
1 parent fc5ea9f commit 1316db1

File tree

6 files changed

+35
-3
lines changed

6 files changed

+35
-3
lines changed

docs/changelog/3426.misc.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Adds ability to configure the stderr color for output received from external
2+
commands.

src/tox/config/cli/parser.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from pathlib import Path
1212
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Literal, Optional, Sequence, Tuple, Type, TypeVar, cast
1313

14+
from colorama import Fore
15+
1416
from tox.config.loader.str_convert import StrConvert
1517
from tox.plugin import NAME
1618
from tox.util.ci import is_ci
@@ -366,6 +368,12 @@ def add_color_flags(parser: ArgumentParser) -> None:
366368
choices=["yes", "no"],
367369
help="should output be enriched with colors, default is yes unless TERM=dumb or NO_COLOR is defined.",
368370
)
371+
parser.add_argument(
372+
"--stderr-color",
373+
default="RED",
374+
choices=["0", *Fore.__dict__.keys()],
375+
help="color for stderr output, use 0 to disable coloring.",
376+
)
369377

370378

371379
def add_exit_and_dump_after(parser: ArgumentParser) -> None:

src/tox/execute/api.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,18 @@ def call(
122122
env: ToxEnv,
123123
) -> Iterator[ExecuteStatus]:
124124
start = time.monotonic()
125+
stderr_color = None
126+
if self._colored:
127+
try:
128+
cfg_color = env.conf._conf.options.stderr_color # noqa: SLF001
129+
stderr_color = None if cfg_color == "0" else Fore.__dict__[cfg_color]
130+
except (AttributeError, KeyError, TypeError): # many tests have a mocked 'env'
131+
stderr_color = Fore.RED
125132
try:
126133
# collector is what forwards the content from the file streams to the standard streams
127134
out, err = out_err[0].buffer, out_err[1].buffer
128135
out_sync = SyncWrite(out.name, out if show else None)
129-
err_sync = SyncWrite(err.name, err if show else None, Fore.RED if self._colored else None)
136+
err_sync = SyncWrite(err.name, err if show else None, stderr_color)
130137
with out_sync, err_sync:
131138
instance = self.build_instance(request, self._option_class(env), out_sync, err_sync)
132139
with instance as status:

tests/config/cli/test_cli_env_var.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ def test_verbose_no_test() -> None:
3131
"verbose": 4,
3232
"quiet": 0,
3333
"colored": "no",
34+
"stderr_color": "RED",
3435
"work_dir": None,
3536
"root_dir": None,
3637
"config_file": None,
@@ -90,6 +91,7 @@ def test_env_var_exhaustive_parallel_values(
9091
assert vars(options.parsed) == {
9192
"always_copy": False,
9293
"colored": "no",
94+
"stderr_color": "RED",
9395
"command": "legacy",
9496
"default_runner": "virtualenv",
9597
"develop": False,

tests/config/cli/test_cli_ini.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
def default_options() -> dict[str, Any]:
3030
return {
3131
"colored": "no",
32+
"stderr_color": "RED",
3233
"command": "r",
3334
"default_runner": "virtualenv",
3435
"develop": False,
@@ -200,6 +201,7 @@ def test_ini_exhaustive_parallel_values(core_handlers: dict[str, Callable[[State
200201
options = get_options("p")
201202
assert vars(options.parsed) == {
202203
"colored": "yes",
204+
"stderr_color": "RED",
203205
"command": "p",
204206
"default_runner": "virtualenv",
205207
"develop": False,

tests/execute/local_subprocess/test_local_subprocess.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,20 +47,28 @@ def read_out_err(self) -> tuple[str, str]:
4747
@pytest.mark.parametrize("color", [True, False], ids=["color", "no_color"])
4848
@pytest.mark.parametrize(("out", "err"), [("out", "err"), ("", "")], ids=["simple", "nothing"])
4949
@pytest.mark.parametrize("show", [True, False], ids=["show", "no_show"])
50+
@pytest.mark.parametrize(
51+
"stderr_color", ["RED", "YELLOW", "0"], ids=["stderr_color_default", "stderr_color_yellow", "stderr_color_no"]
52+
)
5053
def test_local_execute_basic_pass( # noqa: PLR0913
5154
caplog: LogCaptureFixture,
5255
os_env: dict[str, str],
5356
out: str,
5457
err: str,
5558
show: bool,
5659
color: bool,
60+
stderr_color: str,
5761
) -> None:
5862
caplog.set_level(logging.NOTSET)
5963
executor = LocalSubProcessExecutor(colored=color)
64+
65+
tox_env = MagicMock()
66+
tox_env.conf._conf.options.stderr_color = stderr_color # noqa: SLF001
6067
code = f"import sys; print({out!r}, end=''); print({err!r}, end='', file=sys.stderr)"
6168
request = ExecuteRequest(cmd=[sys.executable, "-c", code], cwd=Path(), env=os_env, stdin=StdinSource.OFF, run_id="")
6269
out_err = FakeOutErr()
63-
with executor.call(request, show=show, out_err=out_err.out_err, env=MagicMock()) as status:
70+
71+
with executor.call(request, show=show, out_err=out_err.out_err, env=tox_env) as status:
6472
while status.exit_code is None: # pragma: no branch
6573
status.wait()
6674
assert status.out == out.encode()
@@ -76,7 +84,10 @@ def test_local_execute_basic_pass( # noqa: PLR0913
7684
out_got, err_got = out_err.read_out_err()
7785
if show:
7886
assert out_got == out
79-
expected = (f"{Fore.RED}{err}{Fore.RESET}" if color else err) if err else ""
87+
if not color or stderr_color == "0": # noqa: SIM108
88+
expected = err
89+
else:
90+
expected = f"{Fore.__dict__[stderr_color]}{err}{Fore.RESET}"
8091
assert err_got == expected
8192
else:
8293
assert not out_got

0 commit comments

Comments
 (0)