Skip to content
This repository has been archived by the owner on Nov 3, 2023. It is now read-only.

Skip D401 for docstrings that start directly with a section. #556

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ Release Notes
**pydocstyle** version numbers follow the
`Semantic Versioning <http://semver.org/>`_ specification.

next
----

Bug Fixes

* No longer emit D401 for sections at the start of docstrings (#556).

6.3.0 - January 17th, 2023
--------------------------

Expand Down
127 changes: 80 additions & 47 deletions src/pydocstyle/checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,9 @@ def check_imperative_mood(self, function, docstring): # def context
"Returns the pathname ...".

"""
ctxs = list(self._get_section_contexts_autodetect(docstring))
if ctxs and ctxs[0].is_docstring_start:
return
if (
docstring
and not function.is_test
Expand Down Expand Up @@ -604,6 +607,16 @@ def check_starts_with_this(self, function, docstring):
if first_word.lower() == 'this':
return violations.D404()

@staticmethod
def _is_at_docstring_start(context):
"""Return whether a `SectionContext` occurs at the start of a docstring."""
return context.original_index == 1 and context.previous_line in [
'"',
"'",
'"""',
"'''",
]

@staticmethod
def _is_docstring_section(context):
"""Check if the suspected context is really a section header.
Expand Down Expand Up @@ -656,7 +669,9 @@ def _is_docstring_section(context):
)

prev_line_looks_like_end_of_paragraph = (
prev_line_ends_with_punctuation or is_blank(context.previous_line)
prev_line_ends_with_punctuation
or is_blank(context.previous_line)
or context.is_docstring_start
)

return (
Expand Down Expand Up @@ -766,7 +781,10 @@ def _check_common_section(
else:
yield violations.D410(capitalized_section)

if not is_blank(context.previous_line):
if (
not is_blank(context.previous_line)
and not context.is_docstring_start
):
yield violations.D411(capitalized_section)

yield from cls._check_blanks_and_section_underline(
Expand Down Expand Up @@ -960,12 +978,13 @@ def _check_google_section(cls, docstring, definition, context):
if capitalized_section in ("Args", "Arguments"):
yield from cls._check_args_section(docstring, definition, context)

@staticmethod
def _get_section_contexts(lines, valid_section_names):
@classmethod
def _get_section_contexts(cls, lines, valid_section_names):
"""Generate `SectionContext` objects for valid sections.

Given a list of `valid_section_names`, generate an
`Iterable[SectionContext]` which provides:
* Convention
* Section Name
* String value of the previous line
* The section line
Expand All @@ -977,6 +996,14 @@ def _get_section_contexts(lines, valid_section_names):
"""
lower_section_names = [s.lower() for s in valid_section_names]

convention = (
'numpy'
if valid_section_names == cls.NUMPY_SECTION_NAMES
else 'google'
if valid_section_names == cls.GOOGLE_SECTION_NAMES
else 'unknown'
)

def _suspected_as_section(_line):
result = get_leading_words(_line.lower())
return result in lower_section_names
Expand All @@ -989,11 +1016,13 @@ def _suspected_as_section(_line):
SectionContext = namedtuple(
'SectionContext',
(
'convention',
'section_name',
'previous_line',
'line',
'following_lines',
'original_index',
'is_docstring_start',
'is_last_section',
),
)
Expand All @@ -1002,38 +1031,67 @@ def _suspected_as_section(_line):
# `following_lines` member is until the end of the docstring.
contexts = (
SectionContext(
convention,
get_leading_words(lines[i].strip()),
lines[i - 1],
lines[i],
lines[i + 1 :],
i,
False,
False,
)
for i in suspected_section_indices
)

# Now that we have manageable objects - rule out false positives.
contexts = (
c for c in contexts if ConventionChecker._is_docstring_section(c)
c._replace(is_docstring_start=cls._is_at_docstring_start(c))
for c in contexts
)

# Now that we have manageable objects - rule out false positives.
contexts = (c for c in contexts if cls._is_docstring_section(c))

# Now we shall trim the `following lines` field to only reach the
# next section name.
for a, b in pairwise(contexts, None):
end = -1 if b is None else b.original_index
yield SectionContext(
convention,
a.section_name,
a.previous_line,
a.line,
lines[a.original_index + 1 : end],
a.original_index,
a.is_docstring_start,
b is None,
)

def _check_numpy_sections(self, lines, definition, docstring):
"""NumPy-style docstring sections checks.
@classmethod
def _get_section_contexts_autodetect(cls, docstring):
"""Generate `SectionContext` objects for valid sections.

Generate `Iterable[SectionContext]` as in `_get_section_contexts`, but
auto-detecting the docstring convention, with preference for 'numpy'.
"""
if not docstring:
return
lines = docstring.split("\n")
if len(lines) < 2:
return
found_numpy = False
for ctx in cls._get_section_contexts(lines, cls.NUMPY_SECTION_NAMES):
found_numpy = True
yield ctx
if found_numpy:
return
for ctx in cls._get_section_contexts(lines, cls.GOOGLE_SECTION_NAMES):
yield ctx

@check_for(Definition)
def check_docstring_sections(self, definition, docstring):
"""Check for docstring sections.

Check the general format of a sectioned docstring:
If a Numpy section is found, check the
general format of a sectioned Numpy docstring:
'''This is my one-liner.

Short Summary
Expand All @@ -1046,21 +1104,10 @@ def _check_numpy_sections(self, lines, definition, docstring):

'''

Section names appear in `NUMPY_SECTION_NAMES`.
Yields all violation from `_check_numpy_section` for each valid
Numpy-style section.
"""
found_any_numpy_section = False
for ctx in self._get_section_contexts(lines, self.NUMPY_SECTION_NAMES):
found_any_numpy_section = True
yield from self._check_numpy_section(docstring, definition, ctx)
Numpy-style section (as listed in `NUMPY_SECTION_NAMES`).

return found_any_numpy_section

def _check_google_sections(self, lines, definition, docstring):
"""Google-style docstring section checks.

Check the general format of a sectioned docstring:
Otherwise, check the general format of a sectioned Google docstring:
'''This is my one-liner.

Note:
Expand All @@ -1071,32 +1118,18 @@ def _check_google_sections(self, lines, definition, docstring):

'''

Section names appear in `GOOGLE_SECTION_NAMES`.
Yields all violation from `_check_google_section` for each valid
Google-style section.
Google-style section (as listed in `GOOGLE_SECTION_NAMES`).
"""
for ctx in self._get_section_contexts(
lines, self.GOOGLE_SECTION_NAMES
):
yield from self._check_google_section(docstring, definition, ctx)

@check_for(Definition)
def check_docstring_sections(self, definition, docstring):
"""Check for docstring sections."""
if not docstring:
return

lines = docstring.split("\n")
if len(lines) < 2:
return

found_numpy = yield from self._check_numpy_sections(
lines, definition, docstring
)
if not found_numpy:
yield from self._check_google_sections(
lines, definition, docstring
)
for ctx in self._get_section_contexts_autodetect(docstring):
if ctx.convention == 'numpy':
yield from self._check_numpy_section(
docstring, definition, ctx
)
elif ctx.convention == 'google':
yield from self._check_google_section(
docstring, definition, ctx
)


parse = Parser()
Expand Down
16 changes: 16 additions & 0 deletions src/tests/test_cases/sections.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
expect = expectation.expect


_D212 = 'D212: Multi-line docstring summary should start at the first line'
_D213 = 'D213: Multi-line docstring summary should start at the second line'
_D400 = "D400: First line should end with a period (not '!')"

Expand Down Expand Up @@ -191,6 +192,21 @@ def section_name_in_first_line(): # noqa: D416
"""


@expect(_D212)
@expect("D400: First line should end with a period (not 's')")
@expect("D415: First line should end with a period, question "
"mark, or exclamation point (not 's')")
@expect("D205: 1 blank line required between summary line and description "
"(found 0)")
def section_name_in_first_nonblank_line(): # noqa: D416
"""
Returns
-------
A value of some sort.

"""


@expect(_D213)
@expect("D405: Section name should be properly capitalized "
"('Short Summary', not 'Short summary')")
Expand Down