diff --git a/docs/source/user/options.rst b/docs/source/user/options.rst index faeb17e..9cdb0ee 100644 --- a/docs/source/user/options.rst +++ b/docs/source/user/options.rst @@ -40,6 +40,8 @@ Index of Options - :option:`flake8 --quiet` +- :option:`flake8 --color` + - :option:`flake8 --count` - :option:`flake8 --diff` @@ -181,6 +183,35 @@ Options and their Descriptions quiet = 1 +.. option:: --color + + :ref:`Go back to index ` + + Whether to use color in output. Defaults to ``auto``. + + Possible options are ``auto``, ``always``, and ``never``. + + This **can** be specified in config files. + + When color is enabled, the following substitutions are enabled: + + - ``%(bold)s`` + - ``%(black)s`` + - ``%(red)s`` + - ``%(green)s`` + - ``%(yellow)s`` + - ``%(blue)s`` + - ``%(magenta)s`` + - ``%(cyan)s`` + - ``%(white)s`` + - ``%(reset)s`` + + Example config file usage: + + .. code-block:: ini + + color = never + .. option:: --count diff --git a/src/flake8/formatting/_windows_color.py b/src/flake8/formatting/_windows_color.py new file mode 100644 index 0000000..1d2c73f --- /dev/null +++ b/src/flake8/formatting/_windows_color.py @@ -0,0 +1,59 @@ +"""ctypes hackery to enable color processing on windows. + +See: https://github.com/pre-commit/pre-commit/blob/cb40e96/pre_commit/color.py +""" +import sys + +if sys.platform == "win32": # pragma: no cover (windows) + + def _enable() -> None: + from ctypes import POINTER + from ctypes import windll + from ctypes import WinError + from ctypes import WINFUNCTYPE + from ctypes.wintypes import BOOL + from ctypes.wintypes import DWORD + from ctypes.wintypes import HANDLE + + STD_ERROR_HANDLE = -12 + ENABLE_VIRTUAL_TERMINAL_PROCESSING = 4 + + def bool_errcheck(result, func, args): + if not result: + raise WinError() + return args + + GetStdHandle = WINFUNCTYPE(HANDLE, DWORD)( + ("GetStdHandle", windll.kernel32), + ((1, "nStdHandle"),), + ) + + GetConsoleMode = WINFUNCTYPE(BOOL, HANDLE, POINTER(DWORD))( + ("GetConsoleMode", windll.kernel32), + ((1, "hConsoleHandle"), (2, "lpMode")), + ) + GetConsoleMode.errcheck = bool_errcheck + + SetConsoleMode = WINFUNCTYPE(BOOL, HANDLE, DWORD)( + ("SetConsoleMode", windll.kernel32), + ((1, "hConsoleHandle"), (1, "dwMode")), + ) + SetConsoleMode.errcheck = bool_errcheck + + # As of Windows 10, the Windows console supports (some) ANSI escape + # sequences, but it needs to be enabled using `SetConsoleMode` first. + # + # More info on the escape sequences supported: + # https://msdn.microsoft.com/en-us/library/windows/desktop/mt638032(v=vs.85).aspx + stderr = GetStdHandle(STD_ERROR_HANDLE) + flags = GetConsoleMode(stderr) + SetConsoleMode(stderr, flags | ENABLE_VIRTUAL_TERMINAL_PROCESSING) + + try: + _enable() + except OSError: + terminal_supports_color = False + else: + terminal_supports_color = True +else: # pragma: win32 no cover + terminal_supports_color = True diff --git a/src/flake8/formatting/base.py b/src/flake8/formatting/base.py index 7919f92..a17cb44 100644 --- a/src/flake8/formatting/base.py +++ b/src/flake8/formatting/base.py @@ -8,6 +8,8 @@ from typing import Optional from typing import Tuple from typing import TYPE_CHECKING +from flake8.formatting import _windows_color + if TYPE_CHECKING: from flake8.statistics import Statistics from flake8.style_guide import Violation @@ -51,6 +53,11 @@ class BaseFormatter: self.filename = options.output_file self.output_fd: Optional[IO[str]] = None self.newline = "\n" + self.color = options.color == "always" or ( + options.color == "auto" + and sys.stdout.isatty() + and _windows_color.terminal_supports_color + ) self.after_init() def after_init(self) -> None: diff --git a/src/flake8/formatting/default.py b/src/flake8/formatting/default.py index 0a8e09d..7c8073e 100644 --- a/src/flake8/formatting/default.py +++ b/src/flake8/formatting/default.py @@ -8,6 +8,20 @@ from flake8.formatting import base if TYPE_CHECKING: from flake8.style_guide import Violation +COLORS = { + "bold": "\033[1m", + "black": "\033[30m", + "red": "\033[31m", + "green": "\033[32m", + "yellow": "\033[33m", + "blue": "\033[34m", + "magenta": "\033[35m", + "cyan": "\033[36m", + "white": "\033[37m", + "reset": "\033[m", +} +COLORS_OFF = {k: "" for k in COLORS} + class SimpleFormatter(base.BaseFormatter): """Simple abstraction for Default and Pylint formatter commonality. @@ -39,6 +53,7 @@ class SimpleFormatter(base.BaseFormatter): "path": error.filename, "row": error.line_number, "col": error.column_number, + **(COLORS if self.color else COLORS_OFF), } @@ -49,7 +64,11 @@ class Default(SimpleFormatter): format string. """ - error_format = "%(path)s:%(row)d:%(col)d: %(code)s %(text)s" + error_format = ( + "%(bold)s%(path)s%(reset)s" + "%(cyan)s:%(reset)s%(row)d%(cyan)s:%(reset)s%(col)d%(cyan)s:%(reset)s " + "%(bold)s%(red)s%(code)s%(reset)s %(text)s" + ) def after_init(self) -> None: """Check for a custom format string.""" diff --git a/src/flake8/main/options.py b/src/flake8/main/options.py index c35dbc6..2a214a7 100644 --- a/src/flake8/main/options.py +++ b/src/flake8/main/options.py @@ -91,6 +91,7 @@ def register_default_options(option_manager): The default options include: - ``-q``/``--quiet`` + - ``--color`` - ``--count`` - ``--diff`` - ``--exclude`` @@ -118,7 +119,6 @@ def register_default_options(option_manager): """ add_option = option_manager.add_option - # pep8 options add_option( "-q", "--quiet", @@ -128,6 +128,13 @@ def register_default_options(option_manager): help="Report only file names, or nothing. This option is repeatable.", ) + add_option( + "--color", + choices=("auto", "always", "never"), + default="auto", + help="Whether to use color in output. Defaults to `%(default)s`.", + ) + add_option( "--count", action="store_true", diff --git a/src/flake8/plugins/manager.py b/src/flake8/plugins/manager.py index 840bf65..d2b9187 100644 --- a/src/flake8/plugins/manager.py +++ b/src/flake8/plugins/manager.py @@ -269,6 +269,12 @@ class PluginManager: # pylint: disable=too-few-public-methods "flake8>=3.7 (which implements per-file-ignores itself)." ) continue + elif entry_point.name == "flake8-colors": + LOG.warning( + "flake8-colors plugin is incompatible with " + "flake8>=4.1 (which implements colors itself)." + ) + continue self._load_plugin_from_entrypoint(entry_point) def _load_plugin_from_entrypoint(self, entry_point, local=False): diff --git a/tests/unit/test_base_formatter.py b/tests/unit/test_base_formatter.py index 8958903..d096457 100644 --- a/tests/unit/test_base_formatter.py +++ b/tests/unit/test_base_formatter.py @@ -1,15 +1,18 @@ """Tests for the BaseFormatter object.""" import argparse +import sys from unittest import mock import pytest from flake8 import style_guide +from flake8.formatting import _windows_color from flake8.formatting import base def options(**kwargs): """Create an argparse.Namespace instance.""" + kwargs.setdefault("color", "auto") kwargs.setdefault("output_file", None) kwargs.setdefault("tee", False) return argparse.Namespace(**kwargs) @@ -136,6 +139,49 @@ def test_write_produces_stdout(capsys): assert capsys.readouterr().out == f"{line}\n{source}\n" +def test_color_always_is_true(): + """Verify that color='always' sets it to True.""" + formatter = base.BaseFormatter(options(color="always")) + assert formatter.color is True + + +def _mock_isatty(val): + attrs = {"isatty.return_value": val} + return mock.patch.object(sys, "stdout", **attrs) + + +def _mock_windows_color(val): + return mock.patch.object(_windows_color, "terminal_supports_color", val) + + +def test_color_auto_is_true_for_tty(): + """Verify that color='auto' sets it to True for a tty.""" + with _mock_isatty(True), _mock_windows_color(True): + formatter = base.BaseFormatter(options(color="auto")) + assert formatter.color is True + + +def test_color_auto_is_false_without_tty(): + """Verify that color='auto' sets it to False without a tty.""" + with _mock_isatty(False), _mock_windows_color(True): + formatter = base.BaseFormatter(options(color="auto")) + assert formatter.color is False + + +def test_color_auto_is_false_if_not_supported_on_windows(): + """Verify that color='auto' is False if not supported on windows.""" + with _mock_isatty(True), _mock_windows_color(False): + formatter = base.BaseFormatter(options(color="auto")) + assert formatter.color is False + + +def test_color_never_is_false(): + """Verify that color='never' sets it to False despite a tty.""" + with _mock_isatty(True), _mock_windows_color(True): + formatter = base.BaseFormatter(options(color="never")) + assert formatter.color is False + + class AfterInitFormatter(base.BaseFormatter): """Subclass for testing after_init.""" diff --git a/tests/unit/test_filenameonly_formatter.py b/tests/unit/test_filenameonly_formatter.py index 7dda50b..165ef69 100644 --- a/tests/unit/test_filenameonly_formatter.py +++ b/tests/unit/test_filenameonly_formatter.py @@ -7,6 +7,7 @@ from flake8.formatting import default def options(**kwargs): """Create an argparse.Namespace instance.""" + kwargs.setdefault("color", "auto") kwargs.setdefault("output_file", None) kwargs.setdefault("tee", False) return argparse.Namespace(**kwargs) diff --git a/tests/unit/test_nothing_formatter.py b/tests/unit/test_nothing_formatter.py index d7cbea6..c019bdf 100644 --- a/tests/unit/test_nothing_formatter.py +++ b/tests/unit/test_nothing_formatter.py @@ -7,6 +7,7 @@ from flake8.formatting import default def options(**kwargs): """Create an argparse.Namespace instance.""" + kwargs.setdefault("color", "auto") kwargs.setdefault("output_file", None) kwargs.setdefault("tee", False) return argparse.Namespace(**kwargs) diff --git a/tox.ini b/tox.ini index 246f914..5b90a6d 100644 --- a/tox.ini +++ b/tox.ini @@ -127,6 +127,8 @@ commands = # Once Flake8 3.0 is released and in a good state, we can use both and it will # work well \o/ ignore = D203, W503, E203, N818 +per-file-ignores = + src/flake8/formatting/_windows_color.py: N806 exclude = .tox, .git,