Skip to content

Commit

Permalink
Color support
Browse files Browse the repository at this point in the history
Signed-off-by: Bernat Gabor <bgabor8@bloomberg.net>
  • Loading branch information
gaborbernat committed Jul 27, 2020
1 parent 4ec1dce commit 4afcf95
Show file tree
Hide file tree
Showing 17 changed files with 109 additions and 33 deletions.
2 changes: 2 additions & 0 deletions docs/changelog/2000.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Add option to disable colored output, and support ``NO_COLOR`` and ``FORCE_COLOR`` environment variables - by
:user:`gaborbernat`.
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ install_requires =
toml>=0.10
virtualenv>=20.0.20
importlib-metadata>=1.6.0;python_version<"3.8"
typing-extensions>=3.7.4.2;python_version<"3.8"
python_requires = >=3.6
package_dir =
=src
Expand Down
4 changes: 2 additions & 2 deletions src/tox/config/cli/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ def get_options(*args) -> Tuple[Parsed, List[str], Dict[str, Handler]]:
guess_verbosity = _get_base(args)
handlers, parsed, unknown = _get_core(args)
if guess_verbosity != parsed.verbosity:
setup_report(parsed.verbosity) # pragma: no cover
setup_report(parsed.verbosity, parsed.is_colored) # pragma: no cover
return parsed, unknown, handlers


def _get_base(args):
tox_parser = ToxParser.base()
parsed, unknown = tox_parser.parse(args)
guess_verbosity = parsed.verbosity
setup_report(guess_verbosity)
setup_report(guess_verbosity, parsed.is_colored)
return guess_verbosity


Expand Down
39 changes: 31 additions & 8 deletions src/tox/config/cli/parser.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import argparse
import logging
import os
import sys
from argparse import SUPPRESS, Action, ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace
from itertools import chain
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Type, TypeVar

from tox.config.source.ini import StrConvert
from tox.plugin.util import NAME
from tox.session.state import State

Expand Down Expand Up @@ -42,8 +45,16 @@ def fix_default(self, action: Action) -> None:
def get_type(action):
of_type = getattr(action, "of_type", None)
if of_type is None:
# noinspection PyProtectedMember
if action.default is not None:
if isinstance(action, argparse._StoreAction) and action.choices: # noqa
loc = locals()
if sys.version_info >= (3, 8):
from typing import Literal # noqa
else:
from typing_extensions import Literal # noqa
loc["Literal"] = Literal
as_literal = f"Literal[{', '.join(repr(i) for i in action.choices)}]"
of_type = eval(as_literal, globals(), loc)
elif action.default is not None:
of_type = type(action.default)
elif isinstance(action, argparse._StoreConstAction) and action.const is not None: # noqa
of_type = type(action.const)
Expand Down Expand Up @@ -71,6 +82,10 @@ class Parsed(Namespace):
def verbosity(self) -> int:
return max(self.verbose - self.quiet, 0)

@property
def is_colored(self) -> True:
return self.colored == "yes"


Handler = Callable[[State], Optional[int]]

Expand Down Expand Up @@ -121,12 +136,20 @@ def _add_base_options(self) -> None:
verbosity_group = self.add_argument_group(
f"verbosity=verbose-quiet, default {logging.getLevelName(LEVELS[3])}, map {level_map}",
)
verbosity_exclusive = verbosity_group.add_mutually_exclusive_group()
verbosity_exclusive.add_argument(
"-v", "--verbose", action="count", dest="verbose", help="increase verbosity", default=2,
)
verbosity_exclusive.add_argument(
"-q", "--quiet", action="count", dest="quiet", help="decrease verbosity", default=0,
verbosity = verbosity_group.add_mutually_exclusive_group()
verbosity.add_argument("-v", "--verbose", action="count", dest="verbose", help="increase verbosity", default=2)
verbosity.add_argument("-q", "--quiet", action="count", dest="quiet", help="decrease verbosity", default=0)

converter = StrConvert()
if converter.to_bool(os.environ.get("NO_COLOR", "")):
color = "no"
elif converter.to_bool(os.environ.get("FORCE_COLOR", "")):
color = "yes"
else:
color = "yes" if sys.stdout.isatty() else "no"

verbosity_group.add_argument(
"--colored", default=color, choices=["yes", "no"], help="should output be enriched with colors",
)
self.fix_defaults()

Expand Down
19 changes: 17 additions & 2 deletions src/tox/config/source/api.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import sys
from abc import ABC, abstractmethod
from collections import OrderedDict
from pathlib import Path
from typing import Any, Dict, List, Sequence, Set, Union

from tox.execute.request import shell_cmd

if sys.version_info >= (3, 8):
from typing import Literal
else:
from typing_extensions import Literal

_NO_MAPPING = object()


Expand Down Expand Up @@ -39,7 +45,8 @@ def __iter__(self):

class Convert(ABC):
def to(self, raw, of_type):
if getattr(of_type, "__module__", None) == "typing":
from_module = getattr(of_type, "__module__", None)
if from_module in ("typing", "typing_extensions"):
return self._to_typing(raw, of_type)
elif issubclass(of_type, Path):
return self.to_path(raw)
Expand All @@ -54,7 +61,7 @@ def to(self, raw, of_type):
return of_type(raw)

def _to_typing(self, raw, of_type):
origin = getattr(of_type, "__origin__", None)
origin = getattr(of_type, "__origin__", getattr(of_type, "__class__", None))
if origin is not None:
result = _NO_MAPPING # type: Any
if origin in (list, List):
Expand All @@ -72,6 +79,14 @@ def _to_typing(self, raw, of_type):
else:
new_type = next(i for i in of_type.__args__ if i != type(None)) # noqa
result = self._to_typing(raw, new_type)
elif origin == Literal or origin == type(Literal):
if sys.version_info >= (3, 7):
choice = of_type.__args__
else:
choice = of_type.__values__
if raw not in choice:
raise ValueError(f"{raw} must be one of {choice}")
result = raw
if result is not _NO_MAPPING:
return result
raise TypeError(f"{raw} cannot cast to {of_type!r}")
Expand Down
2 changes: 1 addition & 1 deletion src/tox/config/source/ini/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def to_env_list(value):
return EnvList(elements)

TRUTHFUL_VALUES = {"true", "1", "yes", "on"}
FALSY_VALUES = {"false", "0", "no", "off"}
FALSY_VALUES = {"false", "0", "no", "off", ""}
VALID_BOOL = list(sorted(TRUTHFUL_VALUES | FALSY_VALUES))

@staticmethod
Expand Down
4 changes: 2 additions & 2 deletions src/tox/execute/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,13 +103,13 @@ def __init__(self, outcome: Outcome, exc: KeyboardInterrupt):


class Execute(ABC):
def __call__(self, request: ExecuteRequest, show_on_standard: bool) -> Outcome:
def __call__(self, request: ExecuteRequest, show_on_standard: bool, colored: bool) -> Outcome:
start = timer()
executor = self.executor()
interrupt = None
try:
with CollectWrite(sys.stdout if show_on_standard else None) as out:
with CollectWrite(sys.stderr if show_on_standard else None, Fore.RED) as err:
with CollectWrite(sys.stderr if show_on_standard else None, Fore.RED if colored else None) as err:
instance = executor(request, out.collect, err.collect) # type: ExecuteInstance
try:
exit_code = instance.run()
Expand Down
5 changes: 3 additions & 2 deletions src/tox/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def _get_formatter(level):
return formatter


def setup_report(verbosity):
def setup_report(verbosity, is_colored):
_clean_handlers(LOGGER)
level = _get_level(verbosity)
LOGGER.setLevel(level)
Expand All @@ -42,7 +42,8 @@ def setup_report(verbosity):
LOGGER.addHandler(handler)

logging.debug("setup logging to %s", logging.getLevelName(level))
init()
if is_colored:
init()


def _get_level(verbosity):
Expand Down
2 changes: 1 addition & 1 deletion src/tox/tox_env/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ def execute(
show_on_standard = self.options.verbosity > 3
request = ExecuteRequest(cmd, cwd, self.environment_variables, allow_stdin)
self.logger.warning("run => %s$ %s", request.cwd, request.shell_cmd)
outcome = self._executor(request=request, show_on_standard=show_on_standard)
outcome = self._executor(request=request, show_on_standard=show_on_standard, colored=self.options.colored)
self.logger.info("done => code %d in %s for %s", outcome.exit_code, outcome.elapsed, outcome.shell_cmd)
return outcome

Expand Down
3 changes: 1 addition & 2 deletions src/tox/tox_env/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,11 @@ def _run(self) -> None:
)

def _build_run_env(self, env_conf: ConfigSet, env_name):
# noinspection PyUnresolvedReferences
env_conf.add_config(
keys="runner",
desc="the tox execute used to evaluate this environment",
of_type=str,
default=self.options.default_runner,
default=self.options.default_runner, # noqa
)
runner = cast(str, env_conf["runner"])
from .register import REGISTER
Expand Down
4 changes: 3 additions & 1 deletion tests/unit/config/cli/test_cli_env_var.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ def test_verbose_compound(monkeypatch):
def test_verbose_no_test_skip_missing(monkeypatch):
parsed, _, __ = get_options("--notest", "-vv", "--skip-missing-interpreters", "false", "--runner", "virtualenv")
assert vars(parsed) == {
"colored": "no",
"verbose": 4,
"quiet": 0,
"command": "run",
Expand All @@ -39,11 +40,12 @@ def test_env_var_exhaustive_parallel_values(monkeypatch, core_handlers):

parsed, unknown, handlers = get_options()
assert vars(parsed) == {
"colored": "no",
"verbose": 5,
"quiet": 1,
"command": "run-parallel",
"env": ["py37", "py36"],
"default_runner": "magic",
"default_runner": "virtualenv",
"recreate": True,
"no_test": True,
"parallel": 3,
Expand Down
9 changes: 7 additions & 2 deletions tests/unit/config/cli/test_cli_ini.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@ def exhaustive_ini(tmp_path: Path, monkeypatch: MonkeyPatch):
textwrap.dedent(
"""
[tox]
colored = yes
verbose = 5
quiet = 1
command = run-parallel
env = py37, py36
default_runner = magic
default_runner = virtualenv
recreate = true
no_test = true
parallel = 3
Expand Down Expand Up @@ -49,6 +50,7 @@ def empty_ini(tmp_path: Path, monkeypatch: MonkeyPatch):
def test_ini_empty(empty_ini, core_handlers):
parsed, unknown, handlers = get_options()
assert vars(parsed) == {
"colored": "no",
"verbose": 2,
"quiet": 0,
"command": "run",
Expand All @@ -65,11 +67,12 @@ def test_ini_empty(empty_ini, core_handlers):
def test_ini_exhaustive_parallel_values(exhaustive_ini, core_handlers):
parsed, unknown, handlers = get_options()
assert vars(parsed) == {
"colored": "yes",
"verbose": 5,
"quiet": 1,
"command": "run-parallel",
"env": ["py37", "py36"],
"default_runner": "magic",
"default_runner": "virtualenv",
"recreate": True,
"no_test": True,
"parallel": 3,
Expand Down Expand Up @@ -100,6 +103,7 @@ def test_bad_cli_ini(tmp_path: Path, monkeypatch: MonkeyPatch, caplog):
)
assert caplog.messages == [f"failed to read config file {tmp_path} because {msg}"]
assert vars(parsed) == {
"colored": "no",
"verbose": 2,
"quiet": 0,
"command": "run",
Expand Down Expand Up @@ -130,6 +134,7 @@ def test_bad_option_cli_ini(tmp_path: Path, monkeypatch: MonkeyPatch, caplog, va
),
]
assert vars(parsed) == {
"colored": "no",
"verbose": 2,
"quiet": 0,
"command": "run",
Expand Down
30 changes: 29 additions & 1 deletion tests/unit/config/cli/test_parser.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from tox.config.cli.parser import ToxParser
import pytest

from tox.config.cli.parser import Parsed, ToxParser


def test_parser_const_with_default_none(monkeypatch):
Expand All @@ -16,3 +18,29 @@ def test_parser_const_with_default_none(monkeypatch):

result, _ = parser.parse([])
assert result.alpha == 2


@pytest.mark.parametrize("is_atty", [True, False])
@pytest.mark.parametrize("no_color", [None, "0", "1"])
@pytest.mark.parametrize("force_color", [None, "0", "1"])
@pytest.mark.parametrize("tox_color", [None, "bad", "no", "yes"])
def test_parser_color(monkeypatch, mocker, no_color, force_color, tox_color, is_atty):
for key, value in {"NO_COLOR": no_color, "TOX_COLORED": tox_color, "FORCE_COLOR": force_color}.items():
if value is None:
monkeypatch.delenv(key, raising=False)
else:
monkeypatch.setenv(key, value)
stdout_mock = mocker.patch("tox.config.cli.parser.sys.stdout")
stdout_mock.isatty.return_value = is_atty

if tox_color in ("yes", "no"):
expected = True if tox_color == "yes" else False
elif no_color == "1":
expected = False
elif force_color == "1":
expected = True
else:
expected = is_atty

is_colored = ToxParser.base().parse_args([], Parsed()).is_colored
assert is_colored is expected
2 changes: 1 addition & 1 deletion tests/unit/config/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ def test_config_some_envs(tox_project: ToxProjectCreator):
config_set.add_config(keys="bad_bool", of_type=bool, default=False, desc="bad_bool")
with pytest.raises(TypeError) as context:
assert config_set["bad_bool"]
error = "value whatever cannot be transformed to bool, valid: 0, 1, false, no, off, on, true, yes"
error = "value whatever cannot be transformed to bool, valid: , 0, 1, false, no, off, on, true, yes"
assert str(context.value) == error

config_set.add_constant(keys="a", value=1, desc="ok")
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/execute/local_subprocess_sigint.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@


try:
executor(request, show_on_standard=False)
executor(request, show_on_standard=False, colored=False)
except ToxKeyboardInterrupt as exception:
outcome = exception.outcome
print(outcome.exit_code)
Expand Down
Loading

0 comments on commit 4afcf95

Please sign in to comment.