Repository: PyCQA/flake8

Issue:
add color support for formatting
~essentially replace `flake8-colors`

(needs a bit of design before implementation)

Comments:

Patch:
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 <top>`
+
+    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/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,


Test patch:
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)