Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
instead. This fixes the awkward break that was showing up in comprehension `if`
clauses (#4514) as well as the same shape inside `if`, `elif`, `assert`, and
parenthesized expressions (#5135)
- In `.pyi` stub files, enforce a blank line after a function or method that has a
docstring-only body when another comment or statement follows it (#5158)

### Configuration

Expand Down
39 changes: 39 additions & 0 deletions docs/the_black_code_style/future_style.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ Currently, the following features are included in the preview style:
- `pyi_blank_line_before_decorated_class`: In `.pyi` stub files, enforce a blank line
before a decorated class definition when it follows a function definition.
([see below](labels/pyi-blank-line-before-decorated-class))
- `pyi_blank_line_after_function_docstring`: In `.pyi` stub files, enforce a blank line
after a function or method body that consists of a docstring.
([see below](labels/pyi-blank-line-after-function-docstring))
- `hug_comparator`: Don't break a comparator (`not in`, `==`, `is`, ...) away from its
left operand when the right operand is a bracketed expression that has to break
anyway; let the bracket explode instead. ([see below](labels/hug-comparator))
Expand Down Expand Up @@ -233,6 +236,42 @@ classes are handled:
class Spam: ...
```

(labels/pyi-blank-line-after-function-docstring)=

### Blank line after function docstrings in stub files

In `.pyi` stub files, functions and methods sometimes use a docstring as their whole
body. Black already separated these definitions from a following function definition,
but did not consistently do the same before a following comment, conditional block,
variable annotation, or other statement:

```python
# Before

class Example:
def method(self) -> None:
"""Documentation."""
# comment for the next member
attr: int
```

With this feature enabled, the docstring-only function body is consistently separated
from the next comment or statement:

```python
# After (with --preview)

class Example:
def method(self) -> None:
"""Documentation."""

# comment for the next member
attr: int
```

Black still keeps same-name decorated functions, such as `@overload` groups and property
setters, together without inserting blank lines between them.

(labels/hug-comparator)=

### Keep comparators next to their left operand
Expand Down
75 changes: 63 additions & 12 deletions src/black/lines.py
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,19 @@ def _find_adjacent_decorated(
return None
return None

@staticmethod
def _get_suite_first_decorated_funcname(suite: Node) -> str | None:
"""Return the function name of the first decorated function in a suite."""
for child in suite.children:
if isinstance(child, Node) and child.type == syms.decorated:
for sub in child.children:
if sub.type in (syms.funcdef, syms.async_funcdef):
return EmptyLineTracker._get_funcdef_name(sub)
break
if child.type not in (token.NEWLINE, token.NL, token.INDENT, token.DEDENT):
break
return None

@staticmethod
def _if_stmt_branch_has_func_named(
if_stmt: Node, exclude_suite: Node, name: str
Expand Down Expand Up @@ -687,6 +700,41 @@ def _decorator_decorates_class(line: Line) -> bool:
return False
return any(child.type == syms.classdef for child in decorated.children)

@staticmethod
def _def_is_followed_by_same_name_decorated_func(
line: Line, *, include_conditional_blocks: bool = False
) -> bool:
"""Check if a decorated function is followed by a same-name decorated func.

If *include_conditional_blocks* is true, the next decorated function may
be inside an adjacent conditional block.
"""
name = EmptyLineTracker._get_def_name(line)
decorated = EmptyLineTracker._find_decorated_node(line)
if name is None or decorated is None:
return False

sibling = decorated.next_sibling
while sibling is not None and sibling.type in _WHITESPACE_TOKENS:
sibling = sibling.next_sibling

if sibling is None or not isinstance(sibling, Node):
return False
if sibling.type == syms.decorated:
return EmptyLineTracker._decorated_node_has_func_named(sibling, name)
if not include_conditional_blocks or sibling.type != syms.if_stmt:
return False
for child in sibling.children:
if not isinstance(child, Node):
continue
if child.type != syms.suite:
continue
first_decorated_funcname = (
EmptyLineTracker._get_suite_first_decorated_funcname(child)
)
return first_decorated_funcname == name
return False

def _is_in_current_group(self, current_line: Line) -> bool:
"""Check if current_line belongs to the same overload group being tracked."""
prev = self._pyi_previous_decorated_func
Expand Down Expand Up @@ -750,15 +798,7 @@ def _get_block_first_decorated_funcname(line: Line) -> str | None:
suite = line.leaves[-1].next_sibling
if suite is None or not isinstance(suite, Node) or suite.type != syms.suite:
return None
for child in suite.children:
if isinstance(child, Node) and child.type == syms.decorated:
for sub in child.children:
if sub.type in (syms.funcdef, syms.async_funcdef):
return EmptyLineTracker._get_funcdef_name(sub)
break
if child.type not in (token.NEWLINE, token.NL, token.INDENT, token.DEDENT):
break
return None
return EmptyLineTracker._get_suite_first_decorated_funcname(suite)

@staticmethod
def _block_is_part_of_overload_group(line: Line) -> bool:
Expand Down Expand Up @@ -1005,6 +1045,17 @@ def _maybe_empty_lines(self, current_line: Line) -> tuple[int, int]:
# else/elif continuing a conditional overload group:
# don't insert a blank line above.
before = 0
elif (
Preview.pyi_blank_line_after_function_docstring in self.mode
and previous_def.is_def
and self.previous_line.is_docstring
and self.previous_line.depth == previous_def.depth + 1
and not self._def_is_followed_by_same_name_decorated_func(
previous_def,
include_conditional_blocks=overload_groups,
)
):
before = 1
elif depth and not current_line.is_def and self.previous_line.is_def:
if (
overload_groups
Expand Down Expand Up @@ -1129,9 +1180,9 @@ def _maybe_empty_lines_for_class_or_def(
newlines = 0
else:
newlines = 1
# 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.
# Don't inspect only the previous line if it's part of the body of the
# preceding statement. We always want a blank line after something with a
# body.
elif self.previous_line.depth > current_line.depth:
if overload_groups and self._is_in_current_group(current_line):
newlines = 0
Expand Down
1 change: 1 addition & 0 deletions src/black/mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ class Preview(Enum):
fix_if_guard_explosion_in_case_statement = auto()
pyi_overload_group_blank_lines = auto()
pyi_blank_line_before_decorated_class = auto()
pyi_blank_line_after_function_docstring = auto()
hug_comparator = auto()


Expand Down
1 change: 1 addition & 0 deletions src/black/resources/black.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
"fix_if_guard_explosion_in_case_statement",
"pyi_overload_group_blank_lines",
"pyi_blank_line_before_decorated_class",
"pyi_blank_line_after_function_docstring",
"hug_comparator"
]
},
Expand Down
164 changes: 164 additions & 0 deletions tests/data/cases/pyi_docstring_blank_lines.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# flags: --preview --pyi
import sys
from typing import overload

def top_level_comment() -> None:
"""docs"""
# leading comment for the next statement
top_level_attr: int

def top_level_if() -> None:
"""docs"""
if sys.version_info >= (3, 12):
top_level_if_attr: int

class C:
def before_comment(self) -> None:
"""docs"""
# leading comment for the next method
def after_comment(self) -> None: ...

def before_if(self) -> None:
"""docs"""
if sys.version_info >= (3, 12):
if_attr: int

if sys.version_info >= (3, 12):
def conditional_before_attr(self) -> None:
"""docs"""
conditional_attr: int

def before_attr(self) -> None:
"""docs"""
attr: int

@decorator
def before_decorator(self) -> None:
"""docs"""
@decorator
def after_decorator(self) -> None: ...

@decorator
def decorated_before_comment(self) -> None:
"""docs"""
# leading comment for the next attribute
decorated_attr: int

@overload
def overloaded(self, value: int) -> int:
"""Int overload."""
@overload
def overloaded(self, value: str) -> str: ...

@overload
def overloaded_with_comment(self, value: int) -> int:
"""Int overload."""
# comment inside the overload group
@overload
def overloaded_with_comment(self, value: str) -> str: ...

@overload
def overloaded_with_commented_if(self, value: int) -> int:
"""Int overload."""
# comment inside the conditional overload group
if sys.version_info >= (3, 12):
@overload
def overloaded_with_commented_if(self, value: str) -> str: ...

@property
def prop(self) -> int:
"""Property docs."""
@prop.setter
def prop(self, value: int) -> None: ...

class EndsWithDecoratedDocstringAndComment:
@decorator
def method(self) -> None:
"""docs"""
# trailing member comment

# output
import sys
from typing import overload

def top_level_comment() -> None:
"""docs"""

# leading comment for the next statement
top_level_attr: int

def top_level_if() -> None:
"""docs"""

if sys.version_info >= (3, 12):
top_level_if_attr: int

class C:
def before_comment(self) -> None:
"""docs"""

# leading comment for the next method
def after_comment(self) -> None: ...
def before_if(self) -> None:
"""docs"""

if sys.version_info >= (3, 12):
if_attr: int

if sys.version_info >= (3, 12):
def conditional_before_attr(self) -> None:
"""docs"""

conditional_attr: int

def before_attr(self) -> None:
"""docs"""

attr: int

@decorator
def before_decorator(self) -> None:
"""docs"""

@decorator
def after_decorator(self) -> None: ...
@decorator
def decorated_before_comment(self) -> None:
"""docs"""

# leading comment for the next attribute
decorated_attr: int

@overload
def overloaded(self, value: int) -> int:
"""Int overload."""
@overload
def overloaded(self, value: str) -> str: ...

@overload
def overloaded_with_comment(self, value: int) -> int:
"""Int overload."""
# comment inside the overload group
@overload
def overloaded_with_comment(self, value: str) -> str: ...

@overload
def overloaded_with_commented_if(self, value: int) -> int:
"""Int overload."""
# comment inside the conditional overload group
if sys.version_info >= (3, 12):
@overload
def overloaded_with_commented_if(self, value: str) -> str: ...

@property
def prop(self) -> int:
"""Property docs."""
@prop.setter
def prop(self, value: int) -> None: ...

class EndsWithDecoratedDocstringAndComment:
@decorator
def method(self) -> None:
"""docs"""

# trailing member comment
12 changes: 12 additions & 0 deletions tests/data/cases/pyi_docstring_blank_lines_no_preview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# flags: --pyi
import sys

class C:
def before_comment(self) -> None:
"""docs"""
# leading comment for the next method
def after_comment(self) -> None: ...
def before_if(self) -> None:
"""docs"""
if sys.version_info >= (3, 12):
if_attr: int
Loading