diff --git a/README.md b/README.md index bb41409..4a159ad 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,16 @@ This will output a JSON file with the following information: Larger files naturally take longer to check, but files that take way too long should be looked into, as an issue might only manifest themselves when a file reaches a certain size. +## Disable Color + +Color output is enabled by default in Refurb. To disable it, do one of the following: + +* Set the `NO_COLOR` env var. + +* Use the `--no-color` flag. + +* Set `color = false` in the config file. + ## Developing / Contributing ### Setup diff --git a/refurb/main.py b/refurb/main.py index 38ef477..67c569f 100644 --- a/refurb/main.py +++ b/refurb/main.py @@ -1,7 +1,7 @@ import json import re import time -from collections.abc import Sequence +from collections.abc import Callable, Sequence from contextlib import suppress from functools import cache, partial from importlib import metadata @@ -257,10 +257,53 @@ def format_as_github_annotation(error: Error | str) -> str: ) -def format_errors(errors: Sequence[Error | str], settings: Settings) -> str: - formatter = format_as_github_annotation if settings.format == "github" else str +ERROR_DIFF_PATTERN = re.compile(r"`([^`]*)`([^`]*)`([^`]*)`") + + +def format_with_color(error: Error | str) -> str: + if isinstance(error, str): + return error + + blue = "\x1b[94m" + yellow = "\x1b[33m" + gray = "\x1b[90m" + green = "\x1b[92m" + red = "\x1b[91m" + reset = "\x1b[0m" + + # Add red/green color for diffs, assuming the 2 pairs of backticks are in the form: + # Replace `old` with `new` + if error.msg.count("`") == 4: + parts = [ + f"{gray}`{red}\\1{gray}`{reset}", + "\\2", + f"{gray}`{green}\\3{gray}`{reset}", + ] - done = "\n".join(formatter(error) for error in errors) # type: ignore + error.msg = ERROR_DIFF_PATTERN.sub("".join(parts), error.msg) + + parts = [ + f"{blue}{error.filename}{reset}", + f"{gray}:{error.line}:{error.column + 1}{reset}", + " ", + f"{yellow}[{error.prefix}{error.code}]{reset}", + f"{gray}:{reset}", + " ", + error.msg, + ] + + return "".join(parts) + + +def format_errors(errors: Sequence[Error | str], settings: Settings) -> str: + if settings.format == "github": + formatter: Callable[[Error | str], str] = format_as_github_annotation + elif settings.color: + formatter = format_with_color + else: + formatter = str + + done = "\n".join(formatter(error) for error in errors) if not settings.quiet and any(isinstance(err, Error) for err in errors): done += "\n\nRun `refurb --explain ERR` to further explain an error. Use `--quiet` to silence this message" diff --git a/refurb/settings.py b/refurb/settings.py index a7dc542..aa185f3 100644 --- a/refurb/settings.py +++ b/refurb/settings.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import re import sys from dataclasses import dataclass, field, replace @@ -43,6 +44,7 @@ class Settings: sort_by: Literal["filename", "error"] | None = None verbose: bool = False timing_stats: Path | None = None + color: bool = True def __post_init__(self) -> None: if self.enable_all and self.disable_all: @@ -50,6 +52,9 @@ def __post_init__(self) -> None: 'refurb: "enable all" and "disable all" can\'t be used at the same time' # noqa: E501 ) + if os.getenv("NO_COLOR"): + self.color = False + @staticmethod def merge(old: Settings, new: Settings) -> Settings: if not old.disable_all and new.disable_all: @@ -85,6 +90,7 @@ def merge(old: Settings, new: Settings) -> Settings: sort_by=new.sort_by or old.sort_by, verbose=old.verbose or new.verbose, timing_stats=old.timing_stats or new.timing_stats, + color=old.color and new.color, ) def get_python_version(self) -> tuple[int, int]: @@ -155,8 +161,8 @@ def parse_amendment(amendment: dict[str, Any]) -> set[ErrorClassifier]: # type: def pop_type(ty: type[T], type_name: str = "") -> Callable[..., T]: # type: ignore[misc] - def inner(config: dict[str, Any], name: str) -> T: # type: ignore[misc] - x = config.pop(name, ty()) + def inner(config: dict[str, Any], name: str, *, default: T | None = None) -> T: # type: ignore[misc] + x = config.pop(name, default or ty()) if isinstance(x, ty): return x @@ -188,6 +194,7 @@ def parse_config_file(contents: str) -> Settings: settings.quiet = pop_bool(config, "quiet") settings.disable_all = pop_bool(config, "disable_all") settings.enable_all = pop_bool(config, "enable_all") + settings.color = pop_bool(config, "color", default=True) enable = pop_list(config, "enable") disable = pop_list(config, "disable") @@ -313,6 +320,9 @@ def get_next_arg(arg: str, args: Iterator[str]) -> str: elif arg == "--timing-stats": settings.timing_stats = Path(get_next_arg(arg, iargs)) + elif arg == "--no-color": + settings.color = False + elif arg == "--": settings.mypy_args = list(iargs) diff --git a/test/test_arg_parsing.py b/test/test_arg_parsing.py index 9ce8fc7..f03f1cb 100644 --- a/test/test_arg_parsing.py +++ b/test/test_arg_parsing.py @@ -1,4 +1,6 @@ +import os from pathlib import Path +from unittest.mock import patch import pytest @@ -125,6 +127,7 @@ def test_parse_config_file() -> None: enable = ["FURB111", "FURB222"] format = "github" sort_by = "error" +color = false """ config = parse_config_file(contents) @@ -135,6 +138,7 @@ def test_parse_config_file() -> None: enable={ErrorCode(111), ErrorCode(222)}, format="github", sort_by="error", + color=False, ) @@ -666,3 +670,14 @@ def test_parse_timing_stats_flag() -> None: def test_parse_timing_stats_flag_without_arg_is_an_error() -> None: with pytest.raises(ValueError, match='refurb: missing argument after "--timing-stats"'): parse_args(["--timing-stats"]) + + +def test_parse_no_color_flag() -> None: + assert parse_args(["--no-color"]) == Settings(color=False) + + +def test_no_color_env_var_disables_color() -> None: + with patch.dict(os.environ, {"NO_COLOR": "1"}): + settings = Settings() + + assert not settings.color diff --git a/test/test_main.py b/test/test_main.py index 2736e95..9cd214d 100644 --- a/test/test_main.py +++ b/test/test_main.py @@ -299,3 +299,27 @@ def test_timing_stats_outputs_stats_file() -> None: return pytest.fail("Data is not in proper format") + + +def test_color_is_enabled_by_default(): + with patch("builtins.print") as p: + main(["test/data/err_123.py"]) + + p.assert_called_once() + assert "\x1b" in p.call_args[0][0] + + +def test_no_color_printed_when_disabled(): + with patch("builtins.print") as p: + main(["test/data/err_123.py", "--no-color"]) + + p.assert_called_once() + assert "\x1b" not in p.call_args[0][0] + + +def test_error_github_actions_formatting(): + with patch("builtins.print") as p: + main(["test/data/err_123.py", "--format", "github"]) + + p.assert_called_once() + assert "::error" in p.call_args[0][0]