Skip to content

Commit ee660b9

Browse files
authored
Adds ability to configure stderr output color (#3426)
1 parent eca61ed commit ee660b9

File tree

6 files changed

+36
-11
lines changed

6 files changed

+36
-11
lines changed

docs/changelog/3426.misc.rst

+2
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

+8
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=[*Fore.__dict__.keys()],
375+
help="color for stderr output, use RESET for terminal defaults.",
376+
)
369377

370378

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

src/tox/execute/api.py

+10-9
Original file line numberDiff line numberDiff line change
@@ -122,18 +122,19 @@ 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 = getattr(Fore, 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
128-
out_sync = SyncWrite(
129-
out.name,
130-
out if show else None, # type: ignore[arg-type]
131-
)
132-
err_sync = SyncWrite(
133-
err.name,
134-
err if show else None, # type: ignore[arg-type]
135-
Fore.RED if self._colored else None,
136-
)
135+
out_sync = SyncWrite(out.name, out if show else None) # type: ignore[arg-type]
136+
err_sync = SyncWrite(err.name, err if show else None, stderr_color) # type: ignore[arg-type]
137+
137138
with out_sync, err_sync:
138139
instance = self.build_instance(request, self._option_class(env), out_sync, err_sync)
139140
with instance as status:

tests/config/cli/test_cli_env_var.py

+2
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

+2
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

+12-2
Original file line numberDiff line numberDiff line change
@@ -48,20 +48,30 @@ def read_out_err(self) -> tuple[str, str]:
4848
@pytest.mark.parametrize("color", [True, False], ids=["color", "no_color"])
4949
@pytest.mark.parametrize(("out", "err"), [("out", "err"), ("", "")], ids=["simple", "nothing"])
5050
@pytest.mark.parametrize("show", [True, False], ids=["show", "no_show"])
51+
@pytest.mark.parametrize(
52+
"stderr_color",
53+
["RED", "YELLOW", "RESET"],
54+
ids=["stderr_color_default", "stderr_color_yellow", "stderr_color_reset"],
55+
)
5156
def test_local_execute_basic_pass( # noqa: PLR0913
5257
caplog: LogCaptureFixture,
5358
os_env: dict[str, str],
5459
out: str,
5560
err: str,
5661
show: bool,
5762
color: bool,
63+
stderr_color: str,
5864
) -> None:
5965
caplog.set_level(logging.NOTSET)
6066
executor = LocalSubProcessExecutor(colored=color)
67+
68+
tox_env = MagicMock()
69+
tox_env.conf._conf.options.stderr_color = stderr_color # noqa: SLF001
6170
code = f"import sys; print({out!r}, end=''); print({err!r}, end='', file=sys.stderr)"
6271
request = ExecuteRequest(cmd=[sys.executable, "-c", code], cwd=Path(), env=os_env, stdin=StdinSource.OFF, run_id="")
6372
out_err = FakeOutErr()
64-
with executor.call(request, show=show, out_err=out_err.out_err, env=MagicMock()) as status:
73+
74+
with executor.call(request, show=show, out_err=out_err.out_err, env=tox_env) as status:
6575
while status.exit_code is None: # pragma: no branch
6676
status.wait()
6777
assert status.out == out.encode()
@@ -77,7 +87,7 @@ def test_local_execute_basic_pass( # noqa: PLR0913
7787
out_got, err_got = out_err.read_out_err()
7888
if show:
7989
assert out_got == out
80-
expected = (f"{Fore.RED}{err}{Fore.RESET}" if color else err) if err else ""
90+
expected = f"{getattr(Fore, stderr_color)}{err}{Fore.RESET}" if color and err else err
8191
assert err_got == expected
8292
else:
8393
assert not out_got

0 commit comments

Comments
 (0)