Skip to content

Commit cc8393f

Browse files
committed
Adds ability to configure stderr output color
1 parent 343fe92 commit cc8393f

File tree

6 files changed

+35
-4
lines changed

6 files changed

+35
-4
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=[*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

Lines changed: 9 additions & 2 deletions
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 = 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:
@@ -265,7 +272,7 @@ def _assert_fail(self) -> NoReturn:
265272
if not self.out.endswith("\n"):
266273
sys.stdout.write("\n")
267274
if self.err:
268-
sys.stderr.write(Fore.RED)
275+
sys.stderr.write(Fore.GREEN)
269276
sys.stderr.write(self.err)
270277
sys.stderr.write(Fore.RESET)
271278
if not self.err.endswith("\n"):

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: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,20 +47,30 @@ 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",
52+
["RED", "YELLOW", "RESET"],
53+
ids=["stderr_color_default", "stderr_color_yellow", "stderr_color_reset"],
54+
)
5055
def test_local_execute_basic_pass( # noqa: PLR0913
5156
caplog: LogCaptureFixture,
5257
os_env: dict[str, str],
5358
out: str,
5459
err: str,
5560
show: bool,
5661
color: bool,
62+
stderr_color: str,
5763
) -> None:
5864
caplog.set_level(logging.NOTSET)
5965
executor = LocalSubProcessExecutor(colored=color)
66+
67+
tox_env = MagicMock()
68+
tox_env.conf._conf.options.stderr_color = stderr_color # noqa: SLF001
6069
code = f"import sys; print({out!r}, end=''); print({err!r}, end='', file=sys.stderr)"
6170
request = ExecuteRequest(cmd=[sys.executable, "-c", code], cwd=Path(), env=os_env, stdin=StdinSource.OFF, run_id="")
6271
out_err = FakeOutErr()
63-
with executor.call(request, show=show, out_err=out_err.out_err, env=MagicMock()) as status:
72+
73+
with executor.call(request, show=show, out_err=out_err.out_err, env=tox_env) as status:
6474
while status.exit_code is None: # pragma: no branch
6575
status.wait()
6676
assert status.out == out.encode()
@@ -76,7 +86,7 @@ def test_local_execute_basic_pass( # noqa: PLR0913
7686
out_got, err_got = out_err.read_out_err()
7787
if show:
7888
assert out_got == out
79-
expected = (f"{Fore.RED}{err}{Fore.RESET}" if color else err) if err else ""
89+
expected = f"{Fore.__dict__[stderr_color]}{err}{Fore.RESET}" if color and err else err
8090
assert err_got == expected
8191
else:
8292
assert not out_got

0 commit comments

Comments
 (0)