Allow newlines after function headers without docstrings (#21110)

Summary
--

This is a first step toward fixing #9745. After reviewing our open
issues and several Black issues and PRs, I personally found the function
case the most compelling, especially with very long argument lists:

```py
def func(
	self,
	arg1: int,
	arg2: bool,
	arg3: bool,
	arg4: float,
	arg5: bool,
) -> tuple[...]:
	if arg2 and arg3:
		raise ValueError
```

or many annotations:

```py
def function(
    self, data: torch.Tensor | tuple[torch.Tensor, ...], other_argument: int
) -> torch.Tensor | tuple[torch.Tensor, ...]:
    do_something(data)
    return something
```

I think docstrings help the situation substantially both because syntax
highlighting will usually give a very clear separation between the
annotations and the docstring and because we already allow a blank line
_after_ the docstring:

```py
def function(
    self, data: torch.Tensor | tuple[torch.Tensor, ...], other_argument: int
) -> torch.Tensor | tuple[torch.Tensor, ...]:
    """
	A function doing something.

	And a longer description of the things it does.
	"""

    do_something(data)
    return something
```

There are still other comments on #9745, such as [this one] with 9
upvotes, where users specifically request blank lines in all block
types, or at least including conditionals and loops. I'm sympathetic to
that case as well, even if personally I don't find an [example] like
this:

```py
if blah:

    # Do some stuff that is logically related
    data = get_data()

    # Do some different stuff that is logically related
    results = calculate_results()

    return results
```

to be much more readable than:

```py
if blah:
    # Do some stuff that is logically related
    data = get_data()

    # Do some different stuff that is logically related
    results = calculate_results()

    return results
```

I'm probably just used to the latter from the formatters I've used, but
I do prefer it. I also think that functions are the least susceptible to
the accidental introduction of a newline after refactoring described in
Micha's [comment] on #8893.

I actually considered further restricting this change to functions with
multiline headers. I don't think very short functions like:

```py
def foo():

    return 1
```

benefit nearly as much from the allowed newline, but I just went with
any function without a docstring for now. I guess a marginal case like:

```py
def foo(a_long_parameter: ALongType, b_long_parameter: BLongType) -> CLongType:

    return 1
```

might be a good argument for not restricting it.

I caused a couple of syntax errors before adding special handling for
the ellipsis-only case, so I suspect that there are some other
interesting edge cases that may need to be handled better.

Test Plan
--

Existing tests, plus a few simple new ones. As noted above, I suspect
that we may need a few more for edge cases I haven't considered.

[this one]:
https://github.com/astral-sh/ruff/issues/9745#issuecomment-2876771400
[example]:
https://github.com/psf/black/issues/902#issuecomment-1562154809
[comment]:
https://github.com/astral-sh/ruff/issues/8893#issuecomment-1867259744
This commit is contained in:
Brent Westbrook 2025-10-31 14:53:40 -04:00 committed by GitHub
parent 1734ddfb3e
commit 827d8ae5d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 460 additions and 28 deletions

View file

@ -1,7 +1,6 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/newlines.py
snapshot_kind: text
---
## Input
```python
@ -342,6 +341,99 @@ def overload4():
# trailing comment
def overload4(a: int): ...
# In preview, we preserve these newlines at the start of functions:
def preserved1():
return 1
def preserved2():
pass
def preserved3():
def inner(): ...
def preserved4():
def inner():
print("with a body")
return 1
return 2
def preserved5():
...
# trailing comment prevents collapsing the stub
def preserved6():
# Comment
return 1
def preserved7():
# comment
# another line
# and a third
return 0
def preserved8(): # this also prevents collapsing the stub
...
# But we still discard these newlines:
def removed1():
"Docstring"
return 1
def removed2():
...
def removed3():
... # trailing same-line comment does not prevent collapsing the stub
# And we discard empty lines after the first:
def partially_preserved1():
return 1
# We only preserve blank lines, not add new ones
def untouched1():
# comment
return 0
def untouched2():
# comment
return 0
def untouched3():
# comment
# another line
# and a third
return 0
```
## Output
@ -732,6 +824,88 @@ def overload4():
def overload4(a: int): ...
# In preview, we preserve these newlines at the start of functions:
def preserved1():
return 1
def preserved2():
pass
def preserved3():
def inner(): ...
def preserved4():
def inner():
print("with a body")
return 1
return 2
def preserved5():
...
# trailing comment prevents collapsing the stub
def preserved6():
# Comment
return 1
def preserved7():
# comment
# another line
# and a third
return 0
def preserved8(): # this also prevents collapsing the stub
...
# But we still discard these newlines:
def removed1():
"Docstring"
return 1
def removed2(): ...
def removed3(): ... # trailing same-line comment does not prevent collapsing the stub
# And we discard empty lines after the first:
def partially_preserved1():
return 1
# We only preserve blank lines, not add new ones
def untouched1():
# comment
return 0
def untouched2():
# comment
return 0
def untouched3():
# comment
# another line
# and a third
return 0
```
@ -739,7 +913,15 @@ def overload4(a: int): ...
```diff
--- Stable
+++ Preview
@@ -277,6 +277,7 @@
@@ -253,6 +253,7 @@
def fakehttp():
+
class FakeHTTPConnection:
if mock_close:
@@ -277,6 +278,7 @@
def a():
return 1
@ -747,7 +929,7 @@ def overload4(a: int): ...
else:
pass
@@ -293,6 +294,7 @@
@@ -293,6 +295,7 @@
def a():
return 1
@ -755,7 +937,7 @@ def overload4(a: int): ...
case 1:
def a():
@@ -303,6 +305,7 @@
@@ -303,6 +306,7 @@
def a():
return 1
@ -763,7 +945,7 @@ def overload4(a: int): ...
except RuntimeError:
def a():
@@ -313,6 +316,7 @@
@@ -313,6 +317,7 @@
def a():
return 1
@ -771,7 +953,7 @@ def overload4(a: int): ...
finally:
def a():
@@ -323,18 +327,22 @@
@@ -323,18 +328,22 @@
def a():
return 1
@ -794,4 +976,64 @@ def overload4(a: int): ...
finally:
def a():
@@ -388,18 +397,22 @@
# In preview, we preserve these newlines at the start of functions:
def preserved1():
+
return 1
def preserved2():
+
pass
def preserved3():
+
def inner(): ...
def preserved4():
+
def inner():
print("with a body")
return 1
@@ -408,17 +421,20 @@
def preserved5():
+
...
# trailing comment prevents collapsing the stub
def preserved6():
+
# Comment
return 1
def preserved7():
+
# comment
# another line
# and a third
@@ -427,6 +443,7 @@
def preserved8(): # this also prevents collapsing the stub
+
...
@@ -445,6 +462,7 @@
# And we discard empty lines after the first:
def partially_preserved1():
+
return 1
```

View file

@ -67,6 +67,12 @@ def test6 ():
print("Format" )
print(3 + 4)<RANGE_END>
print("Format to fix indentation" )
def test7 ():
<RANGE_START>print("Format" )
print(3 + 4)<RANGE_END>
print("Format to fix indentation" )
```
## Outputs
@ -146,6 +152,27 @@ def test6 ():
print("Format")
print(3 + 4)
print("Format to fix indentation" )
def test7 ():
print("Format")
print(3 + 4)
print("Format to fix indentation" )
```
#### Preview changes
```diff
--- Stable
+++ Preview
@@ -55,6 +55,7 @@
def test6 ():
+
print("Format")
print(3 + 4)
print("Format to fix indentation" )
```
@ -225,6 +252,27 @@ def test6 ():
print("Format")
print(3 + 4)
print("Format to fix indentation")
def test7 ():
print("Format")
print(3 + 4)
print("Format to fix indentation")
```
#### Preview changes
```diff
--- Stable
+++ Preview
@@ -55,6 +55,7 @@
def test6 ():
+
print("Format")
print(3 + 4)
print("Format to fix indentation")
```
@ -304,4 +352,25 @@ def test6 ():
print("Format")
print(3 + 4)
print("Format to fix indentation")
def test7 ():
print("Format")
print(3 + 4)
print("Format to fix indentation")
```
#### Preview changes
```diff
--- Stable
+++ Preview
@@ -55,6 +55,7 @@
def test6 ():
+
print("Format")
print(3 + 4)
print("Format to fix indentation")
```