Skip to content

docformatter deadlock with ruff-format: blank lines around nested definitions #354

@Borda

Description

@Borda

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:

ruff format example.py

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    freshThis is a new issue

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions