Skip to content

Commit df44c1c

Browse files
authored
Color support (#1630)
1 parent c91a77a commit df44c1c

File tree

22 files changed

+143
-59
lines changed

22 files changed

+143
-59
lines changed

docs/changelog/1630.feature.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add option to disable colored output, and support ``NO_COLOR`` and ``FORCE_COLOR`` environment variables - by
2+
:user:`gaborbernat`.

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ install_requires =
4242
toml>=0.10
4343
virtualenv>=20.0.20
4444
importlib-metadata>=1.6.0;python_version<"3.8"
45+
typing-extensions>=3.7.4.2;python_version<"3.8"
4546
python_requires = >=3.6
4647
package_dir =
4748
=src

src/tox/config/cli/parse.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,15 @@ def get_options(*args) -> Tuple[Parsed, List[str], Dict[str, Handler]]:
99
guess_verbosity = _get_base(args)
1010
handlers, parsed, unknown = _get_core(args)
1111
if guess_verbosity != parsed.verbosity:
12-
setup_report(parsed.verbosity) # pragma: no cover
12+
setup_report(parsed.verbosity, parsed.is_colored) # pragma: no cover
1313
return parsed, unknown, handlers
1414

1515

1616
def _get_base(args):
1717
tox_parser = ToxParser.base()
1818
parsed, unknown = tox_parser.parse(args)
1919
guess_verbosity = parsed.verbosity
20-
setup_report(guess_verbosity)
20+
setup_report(guess_verbosity, parsed.is_colored)
2121
return guess_verbosity
2222

2323

src/tox/config/cli/parser.py

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import argparse
22
import logging
3+
import os
4+
import sys
35
from argparse import SUPPRESS, Action, ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace
46
from itertools import chain
57
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Type, TypeVar
68

9+
from tox.config.source.ini import StrConvert
710
from tox.plugin.util import NAME
811
from tox.session.state import State
912

@@ -42,8 +45,16 @@ def fix_default(self, action: Action) -> None:
4245
def get_type(action):
4346
of_type = getattr(action, "of_type", None)
4447
if of_type is None:
45-
# noinspection PyProtectedMember
46-
if action.default is not None:
48+
if isinstance(action, argparse._StoreAction) and action.choices: # noqa
49+
loc = locals()
50+
if sys.version_info >= (3, 8):
51+
from typing import Literal # noqa
52+
else:
53+
from typing_extensions import Literal # noqa
54+
loc["Literal"] = Literal
55+
as_literal = f"Literal[{', '.join(repr(i) for i in action.choices)}]"
56+
of_type = eval(as_literal, globals(), loc)
57+
elif action.default is not None:
4758
of_type = type(action.default)
4859
elif isinstance(action, argparse._StoreConstAction) and action.const is not None: # noqa
4960
of_type = type(action.const)
@@ -71,6 +82,10 @@ class Parsed(Namespace):
7182
def verbosity(self) -> int:
7283
return max(self.verbose - self.quiet, 0)
7384

85+
@property
86+
def is_colored(self) -> True:
87+
return self.colored == "yes"
88+
7489

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

@@ -121,12 +136,20 @@ def _add_base_options(self) -> None:
121136
verbosity_group = self.add_argument_group(
122137
f"verbosity=verbose-quiet, default {logging.getLevelName(LEVELS[3])}, map {level_map}",
123138
)
124-
verbosity_exclusive = verbosity_group.add_mutually_exclusive_group()
125-
verbosity_exclusive.add_argument(
126-
"-v", "--verbose", action="count", dest="verbose", help="increase verbosity", default=2,
127-
)
128-
verbosity_exclusive.add_argument(
129-
"-q", "--quiet", action="count", dest="quiet", help="decrease verbosity", default=0,
139+
verbosity = verbosity_group.add_mutually_exclusive_group()
140+
verbosity.add_argument("-v", "--verbose", action="count", dest="verbose", help="increase verbosity", default=2)
141+
verbosity.add_argument("-q", "--quiet", action="count", dest="quiet", help="decrease verbosity", default=0)
142+
143+
converter = StrConvert()
144+
if converter.to_bool(os.environ.get("NO_COLOR", "")):
145+
color = "no"
146+
elif converter.to_bool(os.environ.get("FORCE_COLOR", "")):
147+
color = "yes"
148+
else:
149+
color = "yes" if sys.stdout.isatty() else "no"
150+
151+
verbosity_group.add_argument(
152+
"--colored", default=color, choices=["yes", "no"], help="should output be enriched with colors",
130153
)
131154
self.fix_defaults()
132155

src/tox/config/source/api.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1+
import sys
12
from abc import ABC, abstractmethod
23
from collections import OrderedDict
34
from pathlib import Path
45
from typing import Any, Dict, List, Sequence, Set, Union
56

67
from tox.execute.request import shell_cmd
78

9+
if sys.version_info >= (3, 8):
10+
from typing import Literal
11+
else:
12+
from typing_extensions import Literal
13+
814
_NO_MAPPING = object()
915

1016

@@ -39,7 +45,8 @@ def __iter__(self):
3945

4046
class Convert(ABC):
4147
def to(self, raw, of_type):
42-
if getattr(of_type, "__module__", None) == "typing":
48+
from_module = getattr(of_type, "__module__", None)
49+
if from_module in ("typing", "typing_extensions"):
4350
return self._to_typing(raw, of_type)
4451
elif issubclass(of_type, Path):
4552
return self.to_path(raw)
@@ -54,7 +61,7 @@ def to(self, raw, of_type):
5461
return of_type(raw)
5562

5663
def _to_typing(self, raw, of_type):
57-
origin = getattr(of_type, "__origin__", None)
64+
origin = getattr(of_type, "__origin__", getattr(of_type, "__class__", None))
5865
if origin is not None:
5966
result = _NO_MAPPING # type: Any
6067
if origin in (list, List):
@@ -72,6 +79,14 @@ def _to_typing(self, raw, of_type):
7279
else:
7380
new_type = next(i for i in of_type.__args__ if i != type(None)) # noqa
7481
result = self._to_typing(raw, new_type)
82+
elif origin == Literal or origin == type(Literal):
83+
if sys.version_info >= (3, 7):
84+
choice = of_type.__args__
85+
else:
86+
choice = of_type.__values__
87+
if raw not in choice:
88+
raise ValueError(f"{raw} must be one of {choice}")
89+
result = raw
7590
if result is not _NO_MAPPING:
7691
return result
7792
raise TypeError(f"{raw} cannot cast to {of_type!r}")

src/tox/config/source/ini/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ def _load_raw_from(self, as_name, conf, key):
170170
if self._section is None:
171171
raise KeyError(key)
172172
value = self._section[key]
173-
collapsed_newlines = value.replace("\\\n", "") # collapse explicit line splits
173+
collapsed_newlines = value.replace("\\\r", "").replace("\\\n", "") # collapse explicit line splits
174174
replace_executed = replace(collapsed_newlines, conf, as_name, self._section_loader) # do replacements
175175
factor_selected = filter_for_env(replace_executed, as_name) # select matching factors
176176
# extend factors

src/tox/config/source/ini/convert.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ def to_path(value):
1313
@staticmethod
1414
def to_list(value):
1515
splitter = "\n" if "\n" in value else ","
16+
splitter = splitter.replace("\r", "")
1617
for token in value.split(splitter):
1718
value = token.strip()
1819
if value:
@@ -48,7 +49,7 @@ def to_env_list(value):
4849
return EnvList(elements)
4950

5051
TRUTHFUL_VALUES = {"true", "1", "yes", "on"}
51-
FALSY_VALUES = {"false", "0", "no", "off"}
52+
FALSY_VALUES = {"false", "0", "no", "off", ""}
5253
VALID_BOOL = list(sorted(TRUTHFUL_VALUES | FALSY_VALUES))
5354

5455
@staticmethod

src/tox/execute/api.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,13 +103,13 @@ def __init__(self, outcome: Outcome, exc: KeyboardInterrupt):
103103

104104

105105
class Execute(ABC):
106-
def __call__(self, request: ExecuteRequest, show_on_standard: bool) -> Outcome:
106+
def __call__(self, request: ExecuteRequest, show_on_standard: bool, colored: bool) -> Outcome:
107107
start = timer()
108108
executor = self.executor()
109109
interrupt = None
110110
try:
111111
with CollectWrite(sys.stdout if show_on_standard else None) as out:
112-
with CollectWrite(sys.stderr if show_on_standard else None, Fore.RED) as err:
112+
with CollectWrite(sys.stderr if show_on_standard else None, Fore.RED if colored else None) as err:
113113
instance = executor(request, out.collect, err.collect) # type: ExecuteInstance
114114
try:
115115
exit_code = instance.run()

src/tox/execute/outcome.py

Whitespace-only changes.

src/tox/pytest.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22
import sys
33
import textwrap
4+
from contextlib import contextmanager
45
from pathlib import Path
56
from typing import Any, Callable, Dict, List, Optional, Sequence
67

@@ -23,6 +24,7 @@ def ensure_logging_framework_not_altered():
2324
LOGGER.handlers = before_handlers
2425

2526

27+
@contextmanager
2628
def check_os_environ():
2729
old = os.environ.copy()
2830
to_clean = {k: os.environ.pop(k, None) for k in {ENV_VAR_KEY, "TOX_WORK_DIR", "PYTHONPATH"}}
@@ -51,7 +53,16 @@ def check_os_environ():
5153
pytest.fail(msg)
5254

5355

54-
check_os_environ_stable = pytest.fixture(autouse=True)(check_os_environ)
56+
@pytest.fixture(autouse=True)
57+
def check_os_environ_stable(monkeypatch):
58+
with check_os_environ():
59+
yield
60+
monkeypatch.undo()
61+
62+
63+
@pytest.fixture(autouse=True)
64+
def no_color(monkeypatch, check_os_environ_stable):
65+
monkeypatch.setenv("NO_COLOR", "yes")
5566

5667

5768
@pytest.fixture(name="tox_project")

src/tox/report.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def _get_formatter(level):
3333
return formatter
3434

3535

36-
def setup_report(verbosity):
36+
def setup_report(verbosity, is_colored):
3737
_clean_handlers(LOGGER)
3838
level = _get_level(verbosity)
3939
LOGGER.setLevel(level)
@@ -42,7 +42,8 @@ def setup_report(verbosity):
4242
LOGGER.addHandler(handler)
4343

4444
logging.debug("setup logging to %s", logging.getLevelName(level))
45-
init()
45+
if is_colored:
46+
init()
4647

4748

4849
def _get_level(verbosity):

src/tox/tox_env/api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ def execute(
126126
show_on_standard = self.options.verbosity > 3
127127
request = ExecuteRequest(cmd, cwd, self.environment_variables, allow_stdin)
128128
self.logger.warning("run => %s$ %s", request.cwd, request.shell_cmd)
129-
outcome = self._executor(request=request, show_on_standard=show_on_standard)
129+
outcome = self._executor(request=request, show_on_standard=show_on_standard, colored=self.options.colored)
130130
self.logger.info("done => code %d in %s for %s", outcome.exit_code, outcome.elapsed, outcome.shell_cmd)
131131
return outcome
132132

src/tox/tox_env/builder.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,11 @@ def _run(self) -> None:
4040
)
4141

4242
def _build_run_env(self, env_conf: ConfigSet, env_name):
43-
# noinspection PyUnresolvedReferences
4443
env_conf.add_config(
4544
keys="runner",
4645
desc="the tox execute used to evaluate this environment",
4746
of_type=str,
48-
default=self.options.default_runner,
47+
default=self.options.default_runner, # noqa
4948
)
5049
runner = cast(str, env_conf["runner"])
5150
from .register import REGISTER

src/tox/tox_env/python/virtual_env/runner.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,8 @@ def package_env_name_type(self):
7878

7979
def install_package(self):
8080
package = self.package_env.perform_packaging()
81-
self.install_python_packages(
82-
package, no_deps=True, develop=self.conf["package"] is PackageType.dev, force_reinstall=True,
83-
)
81+
develop = self.conf["package"] is PackageType.dev
82+
self.install_python_packages(package, no_deps=True, develop=develop, force_reinstall=True)
8483

8584

8685
@impl

tests/unit/config/cli/test_cli_env_var.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ def test_verbose_compound(monkeypatch):
1616
def test_verbose_no_test_skip_missing(monkeypatch):
1717
parsed, _, __ = get_options("--notest", "-vv", "--skip-missing-interpreters", "false", "--runner", "virtualenv")
1818
assert vars(parsed) == {
19+
"colored": "no",
1920
"verbose": 4,
2021
"quiet": 0,
2122
"command": "run",
@@ -39,11 +40,12 @@ def test_env_var_exhaustive_parallel_values(monkeypatch, core_handlers):
3940

4041
parsed, unknown, handlers = get_options()
4142
assert vars(parsed) == {
43+
"colored": "no",
4244
"verbose": 5,
4345
"quiet": 1,
4446
"command": "run-parallel",
4547
"env": ["py37", "py36"],
46-
"default_runner": "magic",
48+
"default_runner": "virtualenv",
4749
"recreate": True,
4850
"no_test": True,
4951
"parallel": 3,

tests/unit/config/cli/test_cli_ini.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,12 @@ def exhaustive_ini(tmp_path: Path, monkeypatch: MonkeyPatch):
1616
textwrap.dedent(
1717
"""
1818
[tox]
19+
colored = yes
1920
verbose = 5
2021
quiet = 1
2122
command = run-parallel
2223
env = py37, py36
23-
default_runner = magic
24+
default_runner = virtualenv
2425
recreate = true
2526
no_test = true
2627
parallel = 3
@@ -49,6 +50,7 @@ def empty_ini(tmp_path: Path, monkeypatch: MonkeyPatch):
4950
def test_ini_empty(empty_ini, core_handlers):
5051
parsed, unknown, handlers = get_options()
5152
assert vars(parsed) == {
53+
"colored": "no",
5254
"verbose": 2,
5355
"quiet": 0,
5456
"command": "run",
@@ -65,11 +67,12 @@ def test_ini_empty(empty_ini, core_handlers):
6567
def test_ini_exhaustive_parallel_values(exhaustive_ini, core_handlers):
6668
parsed, unknown, handlers = get_options()
6769
assert vars(parsed) == {
70+
"colored": "yes",
6871
"verbose": 5,
6972
"quiet": 1,
7073
"command": "run-parallel",
7174
"env": ["py37", "py36"],
72-
"default_runner": "magic",
75+
"default_runner": "virtualenv",
7376
"recreate": True,
7477
"no_test": True,
7578
"parallel": 3,
@@ -100,6 +103,7 @@ def test_bad_cli_ini(tmp_path: Path, monkeypatch: MonkeyPatch, caplog):
100103
)
101104
assert caplog.messages == [f"failed to read config file {tmp_path} because {msg}"]
102105
assert vars(parsed) == {
106+
"colored": "no",
103107
"verbose": 2,
104108
"quiet": 0,
105109
"command": "run",
@@ -130,6 +134,7 @@ def test_bad_option_cli_ini(tmp_path: Path, monkeypatch: MonkeyPatch, caplog, va
130134
),
131135
]
132136
assert vars(parsed) == {
137+
"colored": "no",
133138
"verbose": 2,
134139
"quiet": 0,
135140
"command": "run",

tests/unit/config/cli/test_parser.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
from tox.config.cli.parser import ToxParser
1+
import pytest
2+
3+
from tox.config.cli.parser import Parsed, ToxParser
24

35

46
def test_parser_const_with_default_none(monkeypatch):
@@ -16,3 +18,29 @@ def test_parser_const_with_default_none(monkeypatch):
1618

1719
result, _ = parser.parse([])
1820
assert result.alpha == 2
21+
22+
23+
@pytest.mark.parametrize("is_atty", [True, False])
24+
@pytest.mark.parametrize("no_color", [None, "0", "1"])
25+
@pytest.mark.parametrize("force_color", [None, "0", "1"])
26+
@pytest.mark.parametrize("tox_color", [None, "bad", "no", "yes"])
27+
def test_parser_color(monkeypatch, mocker, no_color, force_color, tox_color, is_atty):
28+
for key, value in {"NO_COLOR": no_color, "TOX_COLORED": tox_color, "FORCE_COLOR": force_color}.items():
29+
if value is None:
30+
monkeypatch.delenv(key, raising=False)
31+
else:
32+
monkeypatch.setenv(key, value)
33+
stdout_mock = mocker.patch("tox.config.cli.parser.sys.stdout")
34+
stdout_mock.isatty.return_value = is_atty
35+
36+
if tox_color in ("yes", "no"):
37+
expected = True if tox_color == "yes" else False
38+
elif no_color == "1":
39+
expected = False
40+
elif force_color == "1":
41+
expected = True
42+
else:
43+
expected = is_atty
44+
45+
is_colored = ToxParser.base().parse_args([], Parsed()).is_colored
46+
assert is_colored is expected

0 commit comments

Comments
 (0)