Repository: psf/black

Issue:
Error on invalid configuration
Currently if you make a typo when configuring Black you don't get an error

Discussion:

Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 2fe14cd..45cb967 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -17,7 +17,7 @@ changes:
 - Hex codes in Unicode escape sequences are now standardized to lowercase (#2916)
 - Allow empty first lines at the beginning of most blocks (#3967, #4061)
 - Add parentheses around long type annotations (#3899)
-- Standardize on a single newline after module docstrings (#3932)
+- Enforce newline after module docstrings (#3932, #4028)
 - Fix incorrect magic trailing comma handling in return types (#3916)
 - Remove blank lines before class docstrings (#3692)
 - Wrap multiple context managers in parentheses if combined in a single `with` statement
@@ -62,6 +62,7 @@ release:
 
 <!-- Changes to how Black can be configured -->
 
+- Print warning when toml config contains an invalid key (#4165)
 - Fix symlink handling, properly catch and ignore symlinks that point outside of root
   (#4161)
 - Fix cache mtime logic that resulted in false positive cache hits (#4128)
diff --git a/src/black/__init__.py b/src/black/__init__.py
index e3cbaab..961ed94 100644
--- a/src/black/__init__.py
+++ b/src/black/__init__.py
@@ -142,6 +142,7 @@ def read_pyproject_toml(
     if not config:
         return None
     else:
+        spellcheck_pyproject_toml_keys(ctx, list(config), value)
         # Sanitize the values to be Click friendly. For more information please see:
         # https://github.com/psf/black/issues/1458
         # https://github.com/pallets/click/issues/1567
@@ -181,6 +182,22 @@ def read_pyproject_toml(
     return value
 
 
+def spellcheck_pyproject_toml_keys(
+    ctx: click.Context, config_keys: List[str], config_file_path: str
+) -> None:
+    invalid_keys: List[str] = []
+    available_config_options = {param.name for param in ctx.command.params}
+    for key in config_keys:
+        if key not in available_config_options:
+            invalid_keys.append(key)
+    if invalid_keys:
+        keys_str = ", ".join(map(repr, invalid_keys))
+        out(
+            f"Invalid config keys detected: {keys_str} (in {config_file_path})",
+            fg="red",
+        )
+
+
 def target_version_option_callback(
     c: click.Context, p: Union[click.Option, click.Parameter], v: Tuple[str, ...]
 ) -> List[TargetVersion]:
diff --git a/src/black/lines.py b/src/black/lines.py
index 29f8713..72634fd 100644
--- a/src/black/lines.py
+++ b/src/black/lines.py
@@ -565,14 +565,9 @@ def maybe_empty_lines(self, current_line: Line) -> LinesBlock:
         )
         before, after = self._maybe_empty_lines(current_line)
         previous_after = self.previous_block.after if self.previous_block else 0
-        before = (
-            # Black should not insert empty lines at the beginning
-            # of the file
-            0
-            if self.previous_line is None
-            else before - previous_after
-        )
+        before = max(0, before - previous_after)
         if (
+            # Always have one empty line after a module docstring
             self.previous_block
             and self.previous_block.previous_block is None
             and len(self.previous_block.original_line.leaves) == 1
@@ -607,10 +602,11 @@ def maybe_empty_lines(self, current_line: Line) -> LinesBlock:
         self.previous_block = block
         return block
 
-    def _maybe_empty_lines(self, current_line: Line) -> Tuple[int, int]:
+    def _maybe_empty_lines(self, current_line: Line) -> Tuple[int, int]:  # noqa: C901
         max_allowed = 1
         if current_line.depth == 0:
             max_allowed = 1 if self.mode.is_pyi else 2
+
         if current_line.leaves:
             # Consume the first leaf's extra newlines.
             first_leaf = current_line.leaves[0]
@@ -623,9 +619,22 @@ def _maybe_empty_lines(self, current_line: Line) -> Tuple[int, int]:
         user_had_newline = bool(before)
         depth = current_line.depth
 
+        # Mutate self.previous_defs, remainder of this function should be pure
         previous_def = None
         while self.previous_defs and self.previous_defs[-1].depth >= depth:
             previous_def = self.previous_defs.pop()
+        if current_line.is_def or current_line.is_class:
+            self.previous_defs.append(current_line)
+
+        if self.previous_line is None:
+            # Don't insert empty lines before the first line in the file.
+            return 0, 0
+
+        if current_line.is_docstring:
+            if self.previous_line.is_class:
+                return 0, 1
+            if self.previous_line.opens_block and self.previous_line.is_def:
+                return 0, 0
 
         if previous_def is not None:
             assert self.previous_line is not None
@@ -668,49 +677,24 @@ def _maybe_empty_lines(self, current_line: Line) -> Tuple[int, int]:
             )
 
         if (
-            self.previous_line
-            and self.previous_line.is_import
+            self.previous_line.is_import
             and not current_line.is_import
             and not current_line.is_fmt_pass_converted(first_leaf_matches=is_import)
             and depth == self.previous_line.depth
         ):
             return (before or 1), 0
 
-        if (
-            self.previous_line
-            and self.previous_line.is_class
-            and current_line.is_docstring
-        ):
-            return 0, 1
-
-        # In preview mode, always allow blank lines, except right before a function
-        # docstring
-        is_empty_first_line_ok = not current_line.is_docstring or (
-            self.previous_line and not self.previous_line.is_def
-        )
-
-        if (
-            self.previous_line
-            and self.previous_line.opens_block
-            and not is_empty_first_line_ok
-        ):
-            return 0, 0
         return before, 0
 
     def _maybe_empty_lines_for_class_or_def(  # noqa: C901
         self, current_line: Line, before: int, user_had_newline: bool
     ) -> Tuple[int, int]:
-        if not current_line.is_decorator:
-            self.previous_defs.append(current_line)
-        if self.previous_line is None:
-            # Don't insert empty lines before the first line in the file.
-            return 0, 0
+        assert self.previous_line is not None
 
         if self.previous_line.is_decorator:
             if self.mode.is_pyi and current_line.is_stub_class:
                 # Insert an empty line after a decorated stub class
                 return 0, 1
-
             return 0, 0
 
         if self.previous_line.depth < current_line.depth and (
@@ -718,8 +702,7 @@ def _maybe_empty_lines_for_class_or_def(  # noqa: C901
         ):
             if self.mode.is_pyi:
                 return 0, 0
-            else:
-                return 1 if user_had_newline else 0, 0
+            return 1 if user_had_newline else 0, 0
 
         comment_to_add_newlines: Optional[LinesBlock] = None
         if (
@@ -750,9 +733,6 @@ def _maybe_empty_lines_for_class_or_def(  # noqa: C901
                     newlines = 0
                 else:
                     newlines = 1
-            # Remove case `self.previous_line.depth > current_line.depth` below when
-            # this becomes stable.
-            #
             # Don't inspect the previous line if it's part of the body of the previous
             # statement in the same level, we always want a blank line if there's
             # something with a body preceding.
@@ -769,8 +749,6 @@ def _maybe_empty_lines_for_class_or_def(  # noqa: C901
                     # Blank line between a block of functions (maybe with preceding
                     # decorators) and a block of non-functions
                     newlines = 1
-            elif self.previous_line.depth > current_line.depth:
-                newlines = 1
             else:
                 newlines = 0
         else:


Test patch:
diff --git a/tests/data/incorrect_spelling.toml b/tests/data/incorrect_spelling.toml
new file mode 100644
index 0000000..560c9e2
--- /dev/null
+++ b/tests/data/incorrect_spelling.toml
@@ -0,0 +1,5 @@
+[tool.black]
+ine_length = 50
+target-ersion = ['py37']
+exclude='\.pyi?$'
+include='\.py?$'
\ No newline at end of file
diff --git a/tests/test_black.py b/tests/test_black.py
index 2b5fab5..a979a95 100644
--- a/tests/test_black.py
+++ b/tests/test_black.py
@@ -106,6 +106,7 @@ class FakeContext(click.Context):
     def __init__(self) -> None:
         self.default_map: Dict[str, Any] = {}
         self.params: Dict[str, Any] = {}
+        self.command: click.Command = black.main
         # Dummy root, since most of the tests don't care about it
         self.obj: Dict[str, Any] = {"root": PROJECT_ROOT}
 
@@ -1538,6 +1539,22 @@ def test_parse_pyproject_toml(self) -> None:
         self.assertEqual(config["exclude"], r"\.pyi?$")
         self.assertEqual(config["include"], r"\.py?$")
 
+    def test_spellcheck_pyproject_toml(self) -> None:
+        test_toml_file = THIS_DIR / "data" / "incorrect_spelling.toml"
+        result = BlackRunner().invoke(
+            black.main,
+            [
+                "--code=print('hello world')",
+                "--verbose",
+                f"--config={str(test_toml_file)}",
+            ],
+        )
+
+        assert (
+            r"Invalid config keys detected: 'ine_length', 'target_ersion' (in"
+            rf" {test_toml_file})" in result.stderr
+        )
+
     def test_parse_pyproject_toml_project_metadata(self) -> None:
         for test_toml, expected in [
             ("only_black_pyproject.toml", ["py310"]),