PyCQA / PyCQA/docformatter

Attribute docstring + top-level def: 'two blank lines' rule (12.1.1) only fires in the first half of the token stream (buggy loop bound, ruff/Black ping-pong)

Open Beginner friendly
#383 0 comments 0 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 collapses the two blank lines between a module-level attribute docstring and a following top-level def/class down to one blank line — but only when the docstring happens to sit in the second half of the file's token stream. Two structurally identical snippets are formatted differently purely based on how much code precedes them.

Since ruff format (and Black) always require two blank lines before a top-level definition, this also produces an infinite correction loop between the two tools.

Environment

  • docformatter 1.7.8 (also reproduced on current master, commit d5c7b77)
  • Python 3.14, Linux
  • Default options (reproduced with plain docformatter --diff file.py, no config needed)

Minimal reproduction

a.py (attribute docstring early in the token stream, two blank lines before def):

x = 1
"""Docstring."""


def f():
    pass

b.pyidentical structure, only preceded by filler statements:

_filler_1 = 1
_filler_2 = 2
_filler_3 = 3
_filler_4 = 4
_filler_5 = 5
_filler_6 = 6
_filler_7 = 7
_filler_8 = 8
_filler_9 = 9
_filler_10 = 10
_filler_11 = 11
_filler_12 = 12
x = 1
"""Docstring."""


def f():
    pass
$ docformatter --diff a.py
# (no output - file left unchanged, two blank lines kept) ✔ expected

$ docformatter --diff b.py
--- before/b.py
+++ after/b.py
@@ -13,6 +13,5 @@
 x = 1
 """Docstring."""
 
-
 def f():
     pass

And the ping-pong with ruff:

$ docformatter --in-place b.py     # one blank line left
$ ruff format b.py                 # two blank lines restored
$ docformatter --in-place b.py     # one blank line again ... forever

Actual vs. expected

  • Actual (b.py): one blank line before def f():.

  • Expected: two blank lines, i.e. the same result as a.py, per docformatter's own rule in _get_attribute_docstring_newlines (src/docformatter/format.py, master line 263):

    docformatter_12.1.1: Two blank lines if followed by top-level class or function definition.

    This also matches PEP 8 and what ruff format/Black enforce.

Root cause

In _get_attribute_docstring_newlines (loop at master line 263 / v1.7.8 line 262):

_num_tokens = len(tokens)
_offset = 2

for i in range(index + 2, _num_tokens - index - 1):
    if tokens[i].line == "\n":
        _offset += 1
    else:
        break

if tokens[index + _offset].line.startswith("class") or tokens[
    index + _offset
].line.startswith("def"):
    return 2

return 1

The upper bound _num_tokens - index - 1 shrinks as index (the docstring's token index) grows. Once the docstring is past the middle of the token stream (index + 2 >= _num_tokens - index - 1), the range is empty, _offset stays 2, and the class/def check inspects tokens[index + 2] (a blank NL line) instead of the following definition line — so the function returns 1 instead of 2.

Concrete numbers for the repro above:

file total tokens docstring index range result
a.py 19 4 range(6, 14) valid returns 2
b.py 67 52 range(54, 14) empty returns 1

I verified locally that changing the bound to the full token list makes both files behave identically (two blank lines kept, and no more conflict with ruff format):

-    for i in range(index + 2, _num_tokens - index - 1):
+    for i in range(index + 2, _num_tokens):

(This was tested by patching the installed v1.7.8 and a checkout of master; happy to turn it into a PR if the fix direction looks right.)

Related issues

Same symptom class (blank-line ping-pong with ruff/Black) but different code paths:

  • #350 — module docstring + top-level class (_get_module_docstring_newlines), closed
  • #354 — nested def/class inside functions (_get_function_docstring_newlines)

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 in src/docformatter/format.py at _get_attribute_docstring_newlines and compare its behavior for the provided a.py and b.py reproductions. Run docformatter --diff on both files, then verify that the attribute docstring keeps two blank lines before the top-level def regardless of preceding code and no longer ping-pongs with ruff format.

Written by the indexing model from the issue text.

Assessment

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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.