diff --git a/CHANGES.md b/CHANGES.md index 4cea7fceaad..caa0c1bdcf3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -20,6 +20,7 @@ - Remove redundant parentheses around awaited objects (#2991) - Parentheses around return annotations are now managed (#2990) - Remove unnecessary parentheses from `with` statements (#2926) +- Require one empty line after module-level docstrings (#2996) ### _Blackd_ diff --git a/src/black/__init__.py b/src/black/__init__.py index 3a1ce24f059..962cd938c75 100644 --- a/src/black/__init__.py +++ b/src/black/__init__.py @@ -1177,15 +1177,13 @@ def _format_str_once(src_contents: str, *, mode: Mode) -> str: lines = LineGenerator(mode=mode) elt = EmptyLineTracker(is_pyi=mode.is_pyi) empty_line = Line(mode=mode) - after = 0 split_line_features = { feature for feature in {Feature.TRAILING_COMMA_IN_CALL, Feature.TRAILING_COMMA_IN_DEF} if supports_feature(versions, feature) } for current_line in lines.visit(src_node): - dst_contents.append(str(empty_line) * after) - before, after = elt.maybe_empty_lines(current_line) + before = elt.maybe_empty_lines(current_line) dst_contents.append(str(empty_line) * before) for line in transform_line( current_line, mode=mode, features=split_line_features diff --git a/src/black/linegen.py b/src/black/linegen.py index 91fdeef8f2f..08d37db1238 100644 --- a/src/black/linegen.py +++ b/src/black/linegen.py @@ -1,6 +1,7 @@ """ Generating lines of code. """ + from functools import partial, wraps import sys from typing import Collection, Iterator, List, Optional, Set, Union, cast diff --git a/src/black/lines.py b/src/black/lines.py index e455a507539..a3a7f622460 100644 --- a/src/black/lines.py +++ b/src/black/lines.py @@ -1,8 +1,10 @@ +from collections import deque from dataclasses import dataclass, field import itertools import sys from typing import ( Callable, + Deque, Dict, Iterator, List, @@ -419,7 +421,7 @@ def __bool__(self) -> bool: @dataclass class EmptyLineTracker: """Provides a stateful method that returns the number of potential extra - empty lines needed before and after the currently processed line. + empty lines needed before the currently processed line. Note: this tracker works on lines that haven't been split yet. It assumes the prefix of the first leaf consists of optional newlines. Those newlines @@ -427,29 +429,42 @@ class EmptyLineTracker: """ is_pyi: bool = False - previous_line: Optional[Line] = None - previous_after: int = 0 previous_defs: List[int] = field(default_factory=list) - - def maybe_empty_lines(self, current_line: Line) -> Tuple[int, int]: + # Since we shouldn't need to look back + # more than a couple lines we limit + # this window to the last 3 visited lines + # to keep memory constant when formatting large files. + previous_lines_window: Deque[Line] = field(default_factory=deque) + previous_lines_window_size = 3 + + def maybe_empty_lines(self, current_line: Line) -> int: """Return the number of extra empty lines before and after the `current_line`. This is for separating `def`, `async def` and `class` with extra empty lines (two on module-level). """ - before, after = self._maybe_empty_lines(current_line) + before = self._maybe_empty_lines(current_line) before = ( # Black should not insert empty lines at the beginning # of the file 0 - if self.previous_line is None - else before - self.previous_after + if not self.previous_lines_window + else before ) - self.previous_after = after - self.previous_line = current_line - return before, after + if ( + Preview.module_docstring_newlines in current_line.mode + and len(self.previous_lines_window) == 1 + and self.previous_lines_window[-1].is_triple_quoted_string + ): + before = 1 + + self.previous_lines_window.append(current_line) + if len(self.previous_lines_window) > self.previous_lines_window_size: + self.previous_lines_window.popleft() + + return before - def _maybe_empty_lines(self, current_line: Line) -> Tuple[int, int]: + def _maybe_empty_lines(self, current_line: Line) -> int: max_allowed = 1 if current_line.depth == 0: max_allowed = 1 if self.is_pyi else 2 @@ -464,8 +479,12 @@ def _maybe_empty_lines(self, current_line: Line) -> Tuple[int, int]: depth = current_line.depth while self.previous_defs and self.previous_defs[-1] >= depth: if self.is_pyi: - assert self.previous_line is not None - if depth and not current_line.is_def and self.previous_line.is_def: + assert self.previous_lines_window + if ( + depth + and not current_line.is_def + and self.previous_lines_window[-1].is_def + ): # Empty lines between attributes and methods should be preserved. before = min(1, before) elif depth: @@ -499,64 +518,74 @@ def _maybe_empty_lines(self, current_line: Line) -> Tuple[int, int]: return self._maybe_empty_lines_for_class_or_def(current_line, before) if ( - self.previous_line - and self.previous_line.is_import + self.previous_lines_window + and self.previous_lines_window[-1].is_import and not current_line.is_import - and depth == self.previous_line.depth + and depth == self.previous_lines_window[-1].depth ): - return (before or 1), 0 + return before or 1 if ( - self.previous_line - and self.previous_line.is_class - and current_line.is_triple_quoted_string + len(self.previous_lines_window) > 1 + and self.previous_lines_window[-2].is_class + and self.previous_lines_window[-1].is_triple_quoted_string + and current_line.depth == self.previous_lines_window[-1].depth ): - return before, 1 + return 1 - return before, 0 + return before def _maybe_empty_lines_for_class_or_def( self, current_line: Line, before: int - ) -> Tuple[int, int]: + ) -> int: if not current_line.is_decorator: self.previous_defs.append(current_line.depth) - if self.previous_line is None: + if not self.previous_lines_window: # Don't insert empty lines before the first line in the file. - return 0, 0 + return 0 - if self.previous_line.is_decorator: - if self.is_pyi and current_line.is_stub_class: - # Insert an empty line after a decorated stub class - return 0, 1 + if self.previous_lines_window[-1].is_decorator: + return 0 - return 0, 0 + if ( + self.is_pyi + and len(self.previous_lines_window) > 1 + and self.previous_lines_window[-1].is_stub_class + and self.previous_lines_window[-2].is_decorator + ): + # Insert an empty line after a decorated stub class + return 1 - if self.previous_line.depth < current_line.depth and ( - self.previous_line.is_class or self.previous_line.is_def + if self.previous_lines_window[-1].depth < current_line.depth and ( + self.previous_lines_window[-1].is_class + or self.previous_lines_window[-1].is_def ): - return 0, 0 + return 0 if ( - self.previous_line.is_comment - and self.previous_line.depth == current_line.depth + self.previous_lines_window[-1].is_comment + and self.previous_lines_window[-1].depth == current_line.depth and before == 0 ): - return 0, 0 + return 0 if self.is_pyi: - if current_line.is_class or self.previous_line.is_class: - if self.previous_line.depth < current_line.depth: + if current_line.is_class or self.previous_lines_window[-1].is_class: + if self.previous_lines_window[-1].depth < current_line.depth: newlines = 0 - elif self.previous_line.depth > current_line.depth: + elif self.previous_lines_window[-1].depth > current_line.depth: newlines = 1 - elif current_line.is_stub_class and self.previous_line.is_stub_class: + elif ( + current_line.is_stub_class + and self.previous_lines_window[-1].is_stub_class + ): # No blank line between classes with an empty body newlines = 0 else: newlines = 1 elif ( current_line.is_def or current_line.is_decorator - ) and not self.previous_line.is_def: + ) and not self.previous_lines_window[-1].is_def: if current_line.depth: # In classes empty lines between attributes and methods should # be preserved. @@ -565,13 +594,13 @@ def _maybe_empty_lines_for_class_or_def( # 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: + elif self.previous_lines_window[-1].depth > current_line.depth: newlines = 1 else: newlines = 0 else: newlines = 1 if current_line.depth else 2 - return newlines, 0 + return newlines def enumerate_reversed(sequence: Sequence[T]) -> Iterator[Tuple[Index, T]]: diff --git a/src/black/mode.py b/src/black/mode.py index 6bd4ce14421..0e9aadba90b 100644 --- a/src/black/mode.py +++ b/src/black/mode.py @@ -147,6 +147,7 @@ class Preview(Enum): remove_redundant_parens = auto() one_element_subscript = auto() annotation_parens = auto() + module_docstring_newlines = auto() class Deprecated(UserWarning): diff --git a/src/black/numerics.py b/src/black/numerics.py index 879e5b2cf36..67ac8595fcc 100644 --- a/src/black/numerics.py +++ b/src/black/numerics.py @@ -1,6 +1,7 @@ """ Formatting numeric literals. """ + from blib2to3.pytree import Leaf diff --git a/src/black/parsing.py b/src/black/parsing.py index 12726567948..e04cccf1d57 100644 --- a/src/black/parsing.py +++ b/src/black/parsing.py @@ -1,6 +1,7 @@ """ Parse Python code and perform AST validation. """ + import ast import platform import sys diff --git a/src/black/report.py b/src/black/report.py index 43b942c9e3c..6b226ee0810 100644 --- a/src/black/report.py +++ b/src/black/report.py @@ -1,6 +1,7 @@ """ Summarize Black runs to users. """ + from dataclasses import dataclass from enum import Enum from pathlib import Path diff --git a/src/black/rusty.py b/src/black/rusty.py index 822e3d7858a..56544d85bb7 100644 --- a/src/black/rusty.py +++ b/src/black/rusty.py @@ -2,6 +2,7 @@ See https://doc.rust-lang.org/book/ch09-00-error-handling.html. """ + from typing import Generic, TypeVar, Union diff --git a/src/black/trans.py b/src/black/trans.py index 01aa80eaaf8..9d527d4dd42 100644 --- a/src/black/trans.py +++ b/src/black/trans.py @@ -1,6 +1,7 @@ """ String transformers that can split and merge strings. """ + from abc import ABC, abstractmethod from collections import defaultdict from dataclasses import dataclass diff --git a/tests/data/module_docstring_1.py b/tests/data/module_docstring_1.py new file mode 100644 index 00000000000..5751154f7f0 --- /dev/null +++ b/tests/data/module_docstring_1.py @@ -0,0 +1,25 @@ +"""Single line module-level docstring should be followed by single newline.""" + + + + +a = 1 + + +"""I'm just a string so should be followed by 2 newlines.""" + + + + +b = 2 + +# output +"""Single line module-level docstring should be followed by single newline.""" + +a = 1 + + +"""I'm just a string so should be followed by 2 newlines.""" + + +b = 2 diff --git a/tests/data/module_docstring_2.py b/tests/data/module_docstring_2.py new file mode 100644 index 00000000000..368e5ef90ad --- /dev/null +++ b/tests/data/module_docstring_2.py @@ -0,0 +1,67 @@ +"""I am a very helpful module docstring. + +Lorem ipsum dolor sit amet, consectetur adipiscing elit, +sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. +Ut enim ad minim veniam, +quis nostrud exercitation ullamco laboris +nisi ut aliquip ex ea commodo consequat. +Duis aute irure dolor in reprehenderit in voluptate +velit esse cillum dolore eu fugiat nulla pariatur. +Excepteur sint occaecat cupidatat non proident, +sunt in culpa qui officia deserunt mollit anim id est laborum. +""" + + + + +a = 1 + + +"""Look at me I'm a docstring... + +............................................................ +............................................................ +............................................................ +............................................................ +............................................................ +............................................................ +............................................................ +........................................................NOT! +""" + + + + +b = 2 + +# output +"""I am a very helpful module docstring. + +Lorem ipsum dolor sit amet, consectetur adipiscing elit, +sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. +Ut enim ad minim veniam, +quis nostrud exercitation ullamco laboris +nisi ut aliquip ex ea commodo consequat. +Duis aute irure dolor in reprehenderit in voluptate +velit esse cillum dolore eu fugiat nulla pariatur. +Excepteur sint occaecat cupidatat non proident, +sunt in culpa qui officia deserunt mollit anim id est laborum. +""" + +a = 1 + + +"""Look at me I'm a docstring... + +............................................................ +............................................................ +............................................................ +............................................................ +............................................................ +............................................................ +............................................................ +........................................................NOT! +""" + + +b = 2 diff --git a/tests/data/string_quotes.py b/tests/data/string_quotes.py index 3384241f4ad..49fb37138fd 100644 --- a/tests/data/string_quotes.py +++ b/tests/data/string_quotes.py @@ -1,4 +1,5 @@ '''''' + '\'' '"' "'" @@ -57,8 +58,8 @@ f"\"{a}\"{'hello' * b}\"{c}\"" # output - """""" + "'" '"' "'" diff --git a/tests/test_format.py b/tests/test_format.py index 1916146e84d..6538e26af26 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -91,6 +91,8 @@ "one_element_subscript", "remove_await_parens", "return_annotation_brackets", + "module_docstring_1", + "module_docstring_2", ] SOURCES: List[str] = [