Repository: dizballanze/gray (language: python)

Issue:
Add support for Black
I understand the idea is a bit counter to what gray intents to offer but we think this can make sense.

We use the following to format our code:
- pyupgrade 
- black -S -l 100
- isort (with google profile)
- unify

We have now added all of this as part of our CI pipeline and have a small script wrapping all that.
If we were to use gray, it would be able to handle all of this in a more straightforward manner and have better pre-commit support.

We settled on using Black as it turns out to be both pretty fast and quite compatible with modern python syntax, working on code where yapf would simply crash for example. The consistency of the formatted code is good as it removes the distraction of the formatting from PR reviews.

Since each formatter is optional in Gray, I think supporting black is actually natural. And black is just a very dark shade of gray, right?

If you are open to the idea I can look into submitting a PR.


Discussion / hints:
Hi!
I'm ok with that. But I think black should be disabled by default.
Definitely, the idea is to add that as an option so we have a single command to reformat python code.

Fix patch:
diff --git a/AUTHORS.md b/AUTHORS.md
index 400ed22..bf2984a 100644
--- a/AUTHORS.md
+++ b/AUTHORS.md
@@ -6,4 +6,5 @@
 
 ## Contributors
 
-None yet. Why not be the first?
+* Stephane Odul 
+
diff --git a/README.md b/README.md
index 1348c6a..e5ffd8a 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@ Gray stands on the shoulders of giants:
 - [trim](https://github.com/myint/trim) - remove trailing whitespaces
 - [unify](https://github.com/myint/unify) - unify quotes style
 - [fixit](https://github.com/Instagram/Fixit) - various code formatters on LibCST
-
+- [black](https://github.com/psf/black) - optional - the uncompromising Python code formatter
 
 ## Usage
 
@@ -42,8 +42,8 @@ Logging options:
 
 Formatters options:
   -f FORMATTERS, --formatters FORMATTERS
-                        Enabled formatters separated by comma (default: add-
-                        trailing-comma,autoflake,fixit,isort,pyupgrade,trim,unify)
+                        Enabled formatters separated by comma (optional: black)
+                        (default: add-trailing-comma,autoflake,fixit,isort,pyupgrade,trim,unify)
   --min-python-version MIN_PYTHON_VERSION
                         Minimum python version to support (default: (3, 9))
 
@@ -89,6 +89,14 @@ fixit options:
   --fixit-to-literals FIXIT_TO_LITERALS
   --fixit-to-fstrings FIXIT_TO_FSTRINGS
 
+black options:
+  --black-line-length BLACK_LINE_LENGTH
+                        How many characters per line to allow. (default: 88)
+  --black-skip-magic-trailing-comma BLACK_SKIP_MAGIC_TRAILING_COMMA
+                        Don't use trailing commas as a reason to split lines. (default: False)
+  --black-skip-string-normalization BLACK_SKIP_STRING_NORMALIZATION
+                        Don't normalize string quotes or prefixes. (default: False)
+
 Args that start with '--' (eg. --pool-size) can also be set in a config file
 (/Users/apple/.gray or /etc/gray.conf or ./gray.conf). Config file syntax
 allows: key=value, flag=true, stuff=[a,b,c] (for details, see syntax at
diff --git a/gray/formatters/__init__.py b/gray/formatters/__init__.py
index 0f28533..35e8f63 100644
--- a/gray/formatters/__init__.py
+++ b/gray/formatters/__init__.py
@@ -3,6 +3,7 @@
 from .add_trailing_comma import AddTrailingCommaFormatter
 from .autoflake import AutoflakeFormatter
 from .base import BaseFormatter
+from .black import BlackFormatter
 from .composite import CompositeFormatter
 from .fixit import FixitFormatter
 from .isort import SortImportsFormatter
@@ -14,6 +15,7 @@
 FORMATTERS = MappingProxyType({
     "add-trailing-comma": AddTrailingCommaFormatter,
     "autoflake": AutoflakeFormatter,
+    "black": BlackFormatter,
     "fixit": FixitFormatter,
     "isort": SortImportsFormatter,
     "pyupgrade": PyUpgradeFormatter,
@@ -21,5 +23,7 @@
     "unify": UnifyFormatter,
 })
 
+OPTIONAL_FORMATTERS = ('black',)
+
 
 __all__ = ("FORMATTERS", "BaseFormatter", "CompositeFormatter")
diff --git a/gray/formatters/black.py b/gray/formatters/black.py
new file mode 100644
index 0000000..ad77a5b
--- /dev/null
+++ b/gray/formatters/black.py
@@ -0,0 +1,35 @@
+"""Black formatter for Gray."""
+
+from argparse import Namespace
+from pathlib import Path
+
+import black
+from configargparse import Namespace
+
+from gray.formatters.base import BaseFormatter
+
+
+class BlackFormatter(BaseFormatter):
+    """Black formatter class."""
+
+    def __init__(self, arguments: Namespace):
+        self._args = Namespace(
+            line_length=arguments.black_line_length,
+            skip_magic_trailing_comma=arguments.black_skip_magic_trailing_comma,
+            skip_string_normalization=arguments.black_skip_string_normalization,
+        )
+        self._mode = black.Mode(
+            line_length=self._args.line_length,
+            magic_trailing_comma=not self._args.skip_magic_trailing_comma,
+            string_normalization=not self._args.skip_string_normalization,
+        )
+
+    def process(self, file_path: Path):
+        # Black does not provide an official public API yet:
+        # https://github.com/psf/black/issues/779
+        black.reformat_one(
+            file_path,
+            fast=False,
+            write_back=black.WriteBack.YES,
+            mode=self._mode,
+            report=black.Report())
diff --git a/gray/formatters/composite.py b/gray/formatters/composite.py
index e22ed56..a3882e5 100644
--- a/gray/formatters/composite.py
+++ b/gray/formatters/composite.py
@@ -4,10 +4,12 @@
 
 
 class CompositeFormatter(BaseFormatter):
-    _formatters: frozenset
+    # Use a list to guarantee order preserving, which can be important to
+    # ensure that the formatters are going to behave consistently.
+    _formatters: tuple
 
     def __init__(self, *formatters):
-        self._formatters = frozenset(formatters)
+        self._formatters = tuple(formatters)
 
     def process(self, file_path: Path):
         for formatter in self._formatters:
diff --git a/gray/main.py b/gray/main.py
index 54dfc11..b628b5d 100644
--- a/gray/main.py
+++ b/gray/main.py
@@ -7,12 +7,15 @@
 import configargparse
 from prettylog import LogFormat, basic_config
 
-from gray.formatters import FORMATTERS
+from gray.formatters import FORMATTERS, OPTIONAL_FORMATTERS
 from gray.processing import FormattingError, process
 from gray.utils.args import parse_bool, parse_formatters
 
 
-FORMATTERS_NAMES = ",".join(FORMATTERS.keys())
+FORMATTERS_NAMES = ",".join(
+    x for x in sorted(FORMATTERS.keys()) if x not in OPTIONAL_FORMATTERS
+)
+OPTIONAL_FORMATTERS_NAMES = ",".join(OPTIONAL_FORMATTERS)
 
 log = logging.getLogger(__name__)
 
@@ -67,7 +70,7 @@
 group.add_argument(
     "-f",
     "--formatters",
-    help="Enabled formatters separated by comma",
+    help=f"Enabled formatters separated by comma (optional: {OPTIONAL_FORMATTERS_NAMES})",
     type=parse_formatters,
     default=FORMATTERS_NAMES,
 )
@@ -197,6 +200,26 @@
     default=True,
 )
 
+group = parser.add_argument_group("black options")
+group.add_argument(
+    "--black-line-length",
+    help="How many characters per line to allow.",
+    type=int,
+    default=88,
+)
+group.add_argument(
+    "--black-skip-magic-trailing-comma",
+    help="Don't use trailing commas as a reason to split lines.",
+    type=parse_bool,
+    default=False,
+)
+group.add_argument(
+    "--black-skip-string-normalization",
+    help="Don't normalize string quotes or prefixes.",
+    type=parse_bool,
+    default=False,
+)
+
 
 def main():
     arguments = parser.parse_args()
diff --git a/requirements.txt b/requirements.txt
index 6b6ef1e..1453ec3 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,6 @@
 add-trailing-comma>=2.1.0
 autoflake>=1.3.1
+black>=21.6b0
 ConfigArgParse>=1.0.0,<2
 fixit>=0.1.3,<1
 isort>=5.4.2


Test patch:
diff --git a/tests/test_composite.py b/tests/test_composite.py
new file mode 100644
index 0000000..f97c920
--- /dev/null
+++ b/tests/test_composite.py
@@ -0,0 +1,50 @@
+"""Unittests for gray.formatters.composite."""
+
+import pathlib
+import unittest
+from unittest import mock
+from gray.formatters.composite import CompositeFormatter
+from gray.formatters.base import BaseFormatter
+
+class FakeFormatter(BaseFormatter):
+    """Fake Formatter Class for testing."""
+
+    def __init__(self, name, callback):
+        self.name = name
+        self.callback = callback
+
+    def process(self, file_path: pathlib.Path):
+        self.callback(self.name, file_path)
+
+
+class TestCompositeFormatter(unittest.TestCase):
+    """Test the CompositeFormatter class."""
+
+    def test_composite_preserves_order(self):
+        callback = mock.MagicMock()
+        formatter_a = FakeFormatter('a', callback)
+        formatter_b = FakeFormatter('b', callback)
+        formatter_c = FakeFormatter('c', callback)
+        self.assertIsNot(formatter_a, formatter_b)
+        self.assertIsNot(formatter_b, formatter_c)
+
+        fake_path = pathlib.Path('/fake/file.py')
+
+        formatters = CompositeFormatter(formatter_a, formatter_b, formatter_c)
+        formatters.process(fake_path)
+        self.assertEqual(3, callback.call_count)
+        callback.assert_has_calls([
+            mock.call('a', fake_path),
+            mock.call('b', fake_path),
+            mock.call('c', fake_path),
+        ])
+
+        callback.reset_mock()
+        formatters = CompositeFormatter(formatter_c, formatter_b, formatter_a)
+        formatters.process(fake_path)
+        self.assertEqual(3, callback.call_count)
+        callback.assert_has_calls([
+            mock.call('c', fake_path),
+            mock.call('b', fake_path),
+            mock.call('a', fake_path),
+        ])