diff --git a/docs/changelog/1630.feature.rst b/docs/changelog/1630.feature.rst new file mode 100644 index 000000000..495473a6a --- /dev/null +++ b/docs/changelog/1630.feature.rst @@ -0,0 +1,2 @@ +Add option to disable colored output, and support ``NO_COLOR`` and ``FORCE_COLOR`` environment variables - by +:user:`gaborbernat`. diff --git a/setup.cfg b/setup.cfg index f357c8ed4..26703c2c1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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 diff --git a/src/tox/config/cli/parse.py b/src/tox/config/cli/parse.py index a31a01f64..5ba8c5f13 100644 --- a/src/tox/config/cli/parse.py +++ b/src/tox/config/cli/parse.py @@ -9,7 +9,7 @@ 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 @@ -17,7 +17,7 @@ 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 diff --git a/src/tox/config/cli/parser.py b/src/tox/config/cli/parser.py index 19cd3be4d..4fff9c847 100644 --- a/src/tox/config/cli/parser.py +++ b/src/tox/config/cli/parser.py @@ -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 @@ -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) @@ -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]] @@ -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() diff --git a/src/tox/config/source/api.py b/src/tox/config/source/api.py index d548f16ce..7d8405a84 100644 --- a/src/tox/config/source/api.py +++ b/src/tox/config/source/api.py @@ -1,3 +1,4 @@ +import sys from abc import ABC, abstractmethod from collections import OrderedDict from pathlib import Path @@ -5,6 +6,11 @@ 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() @@ -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) @@ -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): @@ -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}") diff --git a/src/tox/config/source/ini/__init__.py b/src/tox/config/source/ini/__init__.py index e1cd135ad..9ec14e6f9 100644 --- a/src/tox/config/source/ini/__init__.py +++ b/src/tox/config/source/ini/__init__.py @@ -170,7 +170,7 @@ def _load_raw_from(self, as_name, conf, key): if self._section is None: raise KeyError(key) value = self._section[key] - collapsed_newlines = value.replace("\\\n", "") # collapse explicit line splits + collapsed_newlines = value.replace("\\\r", "").replace("\\\n", "") # collapse explicit line splits replace_executed = replace(collapsed_newlines, conf, as_name, self._section_loader) # do replacements factor_selected = filter_for_env(replace_executed, as_name) # select matching factors # extend factors diff --git a/src/tox/config/source/ini/convert.py b/src/tox/config/source/ini/convert.py index 7e0b002e2..155365a93 100644 --- a/src/tox/config/source/ini/convert.py +++ b/src/tox/config/source/ini/convert.py @@ -13,6 +13,7 @@ def to_path(value): @staticmethod def to_list(value): splitter = "\n" if "\n" in value else "," + splitter = splitter.replace("\r", "") for token in value.split(splitter): value = token.strip() if value: @@ -48,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 diff --git a/src/tox/execute/api.py b/src/tox/execute/api.py index 8a14b5539..9e7da4b7e 100644 --- a/src/tox/execute/api.py +++ b/src/tox/execute/api.py @@ -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() diff --git a/src/tox/execute/outcome.py b/src/tox/execute/outcome.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/tox/pytest.py b/src/tox/pytest.py index 1665181de..d6f201b9f 100644 --- a/src/tox/pytest.py +++ b/src/tox/pytest.py @@ -1,6 +1,7 @@ import os import sys import textwrap +from contextlib import contextmanager from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Sequence @@ -23,6 +24,7 @@ def ensure_logging_framework_not_altered(): LOGGER.handlers = before_handlers +@contextmanager def check_os_environ(): old = os.environ.copy() 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(): pytest.fail(msg) -check_os_environ_stable = pytest.fixture(autouse=True)(check_os_environ) +@pytest.fixture(autouse=True) +def check_os_environ_stable(monkeypatch): + with check_os_environ(): + yield + monkeypatch.undo() + + +@pytest.fixture(autouse=True) +def no_color(monkeypatch, check_os_environ_stable): + monkeypatch.setenv("NO_COLOR", "yes") @pytest.fixture(name="tox_project") diff --git a/src/tox/report.py b/src/tox/report.py index a7e9ca2f5..8ca144e23 100644 --- a/src/tox/report.py +++ b/src/tox/report.py @@ -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) @@ -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): diff --git a/src/tox/tox_env/api.py b/src/tox/tox_env/api.py index 1ec149ff6..f264965ac 100644 --- a/src/tox/tox_env/api.py +++ b/src/tox/tox_env/api.py @@ -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 diff --git a/src/tox/tox_env/builder.py b/src/tox/tox_env/builder.py index 813eb6fb9..13ea024e9 100644 --- a/src/tox/tox_env/builder.py +++ b/src/tox/tox_env/builder.py @@ -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 diff --git a/src/tox/tox_env/python/virtual_env/runner.py b/src/tox/tox_env/python/virtual_env/runner.py index dc0741c46..86fc22544 100644 --- a/src/tox/tox_env/python/virtual_env/runner.py +++ b/src/tox/tox_env/python/virtual_env/runner.py @@ -78,9 +78,8 @@ def package_env_name_type(self): def install_package(self): package = self.package_env.perform_packaging() - self.install_python_packages( - package, no_deps=True, develop=self.conf["package"] is PackageType.dev, force_reinstall=True, - ) + develop = self.conf["package"] is PackageType.dev + self.install_python_packages(package, no_deps=True, develop=develop, force_reinstall=True) @impl diff --git a/tests/unit/config/cli/test_cli_env_var.py b/tests/unit/config/cli/test_cli_env_var.py index bf36c3717..1e0037c25 100644 --- a/tests/unit/config/cli/test_cli_env_var.py +++ b/tests/unit/config/cli/test_cli_env_var.py @@ -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", @@ -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, diff --git a/tests/unit/config/cli/test_cli_ini.py b/tests/unit/config/cli/test_cli_ini.py index 31dd0858e..bdc178e42 100644 --- a/tests/unit/config/cli/test_cli_ini.py +++ b/tests/unit/config/cli/test_cli_ini.py @@ -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 @@ -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", @@ -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, @@ -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", @@ -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", diff --git a/tests/unit/config/cli/test_parser.py b/tests/unit/config/cli/test_parser.py index d068182fb..5f1923bfc 100644 --- a/tests/unit/config/cli/test_parser.py +++ b/tests/unit/config/cli/test_parser.py @@ -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): @@ -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 diff --git a/tests/unit/config/test_main.py b/tests/unit/config/test_main.py index 58affc1db..f2cc66d9c 100644 --- a/tests/unit/config/test_main.py +++ b/tests/unit/config/test_main.py @@ -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") diff --git a/tests/unit/execute/local_subprocess_sigint.py b/tests/unit/execute/local_subprocess_sigint.py index 685a27297..ceab812ea 100644 --- a/tests/unit/execute/local_subprocess_sigint.py +++ b/tests/unit/execute/local_subprocess_sigint.py @@ -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) diff --git a/tests/unit/execute/test_local_subprocess.py b/tests/unit/execute/test_local_subprocess.py index 5714620ec..968c570c5 100644 --- a/tests/unit/execute/test_local_subprocess.py +++ b/tests/unit/execute/test_local_subprocess.py @@ -22,7 +22,7 @@ def test_local_execute_basic_pass(capsys, caplog): env=os.environ, allow_stdin=False, ) - outcome = executor.__call__(request, show_on_standard=False) + outcome = executor.__call__(request, show_on_standard=False, colored=False) assert bool(outcome) is True assert outcome.exit_code == Outcome.OK assert outcome.err == "err" @@ -43,7 +43,7 @@ def test_local_execute_basic_pass_show_on_standard(capsys, caplog): env=os.environ, allow_stdin=False, ) - outcome = executor.__call__(request, show_on_standard=True) + outcome = executor.__call__(request, show_on_standard=True, colored=True) assert bool(outcome) is True assert outcome.exit_code == Outcome.OK assert outcome.err == "err" @@ -64,7 +64,7 @@ def test_local_execute_basic_pass_show_on_standard_newline_flush(capsys, caplog) env=os.environ, allow_stdin=False, ) - outcome = executor.__call__(request, show_on_standard=True) + outcome = executor.__call__(request, show_on_standard=True, colored=False) assert bool(outcome) is True assert outcome.exit_code == Outcome.OK assert not outcome.err @@ -95,7 +95,7 @@ def test_local_execute_write_a_lot(capsys, caplog): env=os.environ, allow_stdin=False, ) - outcome = executor.__call__(request, show_on_standard=False) + outcome = executor.__call__(request, show_on_standard=False, colored=False) assert bool(outcome) expected_out = f"{'o' * count}{os.linesep}{'b' * count}{os.linesep}" assert outcome.out == expected_out @@ -115,7 +115,7 @@ def test_local_execute_basic_fail(caplog, capsys): request = ExecuteRequest(cmd=cmd, cwd=cwd, env=os.environ, allow_stdin=False) # run test - outcome = executor.__call__(request, show_on_standard=False) + outcome = executor.__call__(request, show_on_standard=False, colored=False) # assert no output, no logs out, err = capsys.readouterr() @@ -158,7 +158,7 @@ def test_command_does_not_exist(capsys, caplog): caplog.set_level(logging.NOTSET) executor = LocalSubProcessExecutor() request = ExecuteRequest(cmd=["sys-must-be-missing"], cwd=Path().absolute(), env=os.environ, allow_stdin=False) - outcome = executor.__call__(request, show_on_standard=False) + outcome = executor.__call__(request, show_on_standard=False, colored=False) assert bool(outcome) is False assert outcome.exit_code != Outcome.OK diff --git a/tests/unit/pytest/test_init.py b/tests/unit/pytest/test_init.py index 0bd2cb6dc..8d260c761 100644 --- a/tests/unit/pytest/test_init.py +++ b/tests/unit/pytest/test_init.py @@ -19,24 +19,21 @@ def test_init_base(tox_project): def test_env_var(monkeypatch): - monkeypatch.setenv("MORE", "B") - monkeypatch.setenv("EXTRA", "1") - monkeypatch.setenv("PYTHONPATH", "yes") - gen = check_os_environ() - next(gen) - monkeypatch.setenv("MAGIC", "A") - monkeypatch.setenv("MORE", "D") - monkeypatch.delenv("EXTRA") - - from tox.pytest import pytest as tox_pytest - - exp = "test changed environ extra {'MAGIC': 'A'} miss {'EXTRA': '1'} diff {'MORE = B vs D'}" - - def fail(msg): - assert msg == exp - - monkeypatch.setattr(tox_pytest, "fail", fail) - try: - gen.send(None) - except StopIteration: - pass + with monkeypatch.context() as m: + m.setenv("MORE", "B") + m.setenv("EXTRA", "1") + m.setenv("PYTHONPATH", "yes") + + with check_os_environ(): + m.setenv("MAGIC", "A") + m.setenv("MORE", "D") + m.delenv("EXTRA") + + from tox.pytest import pytest as tox_pytest + + exp = "test changed environ extra {'MAGIC': 'A'} miss {'EXTRA': '1'} diff {'MORE = B vs D'}" + + def fail(msg): + assert msg == exp + + m.setattr(tox_pytest, "fail", fail) diff --git a/tox.ini b/tox.ini index 2d2bcfea0..9bdb3e9fc 100644 --- a/tox.ini +++ b/tox.ini @@ -48,7 +48,7 @@ commands = coverage report -m coverage xml -o {toxworkdir}/coverage.xml coverage html -d {toxworkdir}/htmlcov - diff-cover --compare-branch {env:DIFF_AGAINST:origin/master} {toxworkdir}/coverage.xml + diff-cover --compare-branch {env:DIFF_AGAINST:origin/rewrite} {toxworkdir}/coverage.xml depends = py38 py37