Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds ability to configure stderr output color #3426

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
18 changes: 15 additions & 3 deletions tests/execute/local_subprocess/test_local_subprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,20 +48,30 @@ 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],
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 @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use getattr instead of this dictionary lookup.

assert err_got == expected
else:
assert not out_got
Expand Down Expand Up @@ -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
Expand Down
Loading