PyCQA / PyCQA/docformatter

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

Open
#354 0 comments 3 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

fresh
Dominant language
Python
Stars
598
Forks
93
PR merge metrics
No merged PRs in 30d

Description

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.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the minimal reproductions in example.py and example_class.py, then run docformatter and ruff format with the shown configurations to reproduce both loops. Trace how docformatter classifies blank lines after nested one-line docstrings, and add regression coverage for the two cases. Done means repeated runs converge without either tool undoing the other.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
tooling
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.