Skip to content

Commit

Permalink
Adds ability to configure stderr output color
Browse files Browse the repository at this point in the history
  • Loading branch information
ssbarnea committed Nov 19, 2024
1 parent 343fe92 commit 891cf18
Show file tree
Hide file tree
Showing 6 changed files with 36 additions and 4 deletions.
2 changes: 2 additions & 0 deletions docs/changelog/3426.misc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Adds ability to configure the stderr color for output received from external
commands.
8 changes: 8 additions & 0 deletions src/tox/config/cli/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
11 changes: 9 additions & 2 deletions src/tox/execute/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,18 @@ 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)
err_sync = SyncWrite(err.name, err if show else None, Fore.RED if self._colored else None)
err_sync = SyncWrite(err.name, err if show else None, stderr_color)
with out_sync, err_sync:
instance = self.build_instance(request, self._option_class(env), out_sync, err_sync)
with instance as status:
Expand Down Expand Up @@ -265,7 +272,7 @@ def _assert_fail(self) -> NoReturn:
if not self.out.endswith("\n"):
sys.stdout.write("\n")
if self.err:
sys.stderr.write(Fore.RED)
sys.stderr.write(Fore.GREEN)
sys.stderr.write(self.err)
sys.stderr.write(Fore.RESET)
if not self.err.endswith("\n"):
Expand Down
2 changes: 2 additions & 0 deletions tests/config/cli/test_cli_env_var.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions tests/config/cli/test_cli_ini.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
def default_options() -> dict[str, Any]:
return {
"colored": "no",
"stderr_color": "RED",
"command": "r",
"default_runner": "virtualenv",
"develop": False,
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 13 additions & 2 deletions tests/execute/local_subprocess/test_local_subprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,20 +47,28 @@ 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", "0"], ids=["stderr_color_default", "stderr_color_yellow", "stderr_color_no"]
)
def test_local_execute_basic_pass( # noqa: PLR0913
caplog: LogCaptureFixture,
os_env: dict[str, str],
out: str,
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()
Expand All @@ -76,7 +84,10 @@ 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 ""
if not color or stderr_color == "0": # noqa: SIM108
expected = err
else:
expected = f"{Fore.__dict__[stderr_color]}{err}{Fore.RESET}"
assert err_got == expected
else:
assert not out_got
Expand Down

0 comments on commit 891cf18

Please sign in to comment.