Summary
docformatter and ruff-format enter an infinite correction loop in pre-commit when
test functions contain nested def or class definitions whose only body is a one-line
docstring. The tools disagree on whether a blank line should follow the nested definition,
so each tool's output is the other tool's input and pre-commit never converges.
Two distinct sub-issues are present:
- Loop A — nested
def with one-liner docstring: docformatter removes the blank line
after it; ruff-format adds it back.
- Loop B — nested
class with one-liner docstring (blank = true in config): docformatter
adds an extra blank line; ruff-format removes it.
Versions
| Tool |
Version |
| docformatter (pip / pre-commit hook) |
1.7.8 |
ruff-format (pre-commit hook astral-sh/ruff-pre-commit) |
v0.15.9 |
| Python |
3.10.11 |
Configuration
pyproject.toml:
[tool.docformatter]
recursive = true
wrap-summaries = 120
wrap-descriptions = 120
blank = true
.pre-commit-config.yaml (relevant hooks):
- repo: https://github.com/PyCQA/docformatter
rev: v1.7.8
hooks:
- id: docformatter
language_version: python3.10
additional_dependencies: [tomli]
args: ["--in-place"]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.9
hooks:
- id: ruff-check
args: ["--fix"]
- id: ruff-format
Minimal reproduction — Loop A
Create example.py:
def outer() -> None:
"""Outer function."""
def inner() -> None:
"""One-liner docstring."""
next_statement = 1
Run docformatter:
docformatter --in-place --blank example.py
Result — blank line removed:
def outer() -> None:
"""Outer function."""
def inner() -> None:
"""One-liner docstring."""
next_statement = 1 # ← blank line gone
Run ruff-format on that output:
Result — blank line restored (back to original):
def outer() -> None:
"""Outer function."""
def inner() -> None:
"""One-liner docstring."""
next_statement = 1 # ← blank line back
Running both again repeats the cycle indefinitely.
Minimal reproduction — Loop B (blank = true)
Create example_class.py:
def outer() -> None:
"""Outer function."""
class Inner:
"""One-liner class docstring."""
@some_decorator
class Another:
"""Another class."""
With blank = true, docformatter inserts an extra blank line after the one-liner
class docstring (before @some_decorator), producing two consecutive blank lines.
ruff-format then removes the extra one. Each tool undoes the other.
Root cause
Loop A
docformatter interprets the blank line between the closing """ of inner()'s docstring
and next_statement as being inside inner()'s function body and removes it as a
PEP 257 D202 violation ("no blank lines allowed after function docstring").
The blank line is not inside inner() — it is in the outer scope, separating two
statements. docformatter misattributes it because the last token of inner()'s body is
the closing """ of a one-liner docstring on the same line as the opening """, with no
other body statements.
ruff-format (Black-compatible) correctly requires the blank line between the nested
definition and the following statement per E301 / PEP 8.
Loop B
blank = true causes docformatter to insert a blank line at the end of one-liner class
docstrings when they are followed by another definition. Combined with the blank line
already present, this produces two blank lines (E303), which ruff-format then reduces
back to one.
Expected behaviour
docformatter should not remove the blank line that follows a nested function definition
whose only body is a one-liner docstring. That blank line belongs to the enclosing
scope, not to the nested function.
Workaround
Remove blank = true from [tool.docformatter] to mitigate Loop B.
Loop A has no configuration-level workaround; the only option is to exclude the affected
files from docformatter or avoid the nested-def-with-one-liner-docstring pattern in test
code.
Summary
docformatterandruff-formatenter an infinite correction loop inpre-commitwhentest functions contain nested
deforclassdefinitions whose only body is a one-linedocstring. The tools disagree on whether a blank line should follow the nested definition,
so each tool's output is the other tool's input and
pre-commitnever converges.Two distinct sub-issues are present:
defwith one-liner docstring: docformatter removes the blank lineafter it; ruff-format adds it back.
classwith one-liner docstring (blank = truein config): docformatteradds an extra blank line; ruff-format removes it.
Versions
astral-sh/ruff-pre-commit)Configuration
pyproject.toml:.pre-commit-config.yaml(relevant hooks):Minimal reproduction — Loop A
Create
example.py:Run docformatter:
Result — blank line removed:
Run ruff-format on that output:
Result — blank line restored (back to original):
Running both again repeats the cycle indefinitely.
Minimal reproduction — Loop B (
blank = true)Create
example_class.py:With
blank = true, docformatter inserts an extra blank line after the one-linerclass docstring (before
@some_decorator), producing two consecutive blank lines.ruff-format then removes the extra one. Each tool undoes the other.
Root cause
Loop A
docformatter interprets the blank line between the closing
"""ofinner()'s docstringand
next_statementas being insideinner()'s function body and removes it as aPEP 257 D202 violation ("no blank lines allowed after function docstring").
The blank line is not inside
inner()— it is in the outer scope, separating twostatements. docformatter misattributes it because the last token of
inner()'s body isthe closing
"""of a one-liner docstring on the same line as the opening""", with noother body statements.
ruff-format (Black-compatible) correctly requires the blank line between the nested
definition and the following statement per E301 / PEP 8.
Loop B
blank = truecauses docformatter to insert a blank line at the end of one-liner classdocstrings when they are followed by another definition. Combined with the blank line
already present, this produces two blank lines (E303), which ruff-format then reduces
back to one.
Expected behaviour
docformatter should not remove the blank line that follows a nested function definition
whose only body is a one-liner docstring. That blank line belongs to the enclosing
scope, not to the nested function.
Workaround
Remove
blank = truefrom[tool.docformatter]to mitigate Loop B.Loop A has no configuration-level workaround; the only option is to exclude the affected
files from docformatter or avoid the nested-def-with-one-liner-docstring pattern in test
code.