diff --git a/docs/release_notes.rst b/docs/release_notes.rst index 5ab5e11..e5ec725 100644 --- a/docs/release_notes.rst +++ b/docs/release_notes.rst @@ -4,6 +4,13 @@ Release Notes **pydocstyle** version numbers follow the `Semantic Versioning `_ specification. +next +---- + +Bug Fixes + +* No longer emit D401 for sections at the start of docstrings (#556). + 6.3.0 - January 17th, 2023 -------------------------- diff --git a/src/pydocstyle/checker.py b/src/pydocstyle/checker.py index 9b6376b..2304412 100644 --- a/src/pydocstyle/checker.py +++ b/src/pydocstyle/checker.py @@ -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 @@ -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. @@ -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 ( @@ -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( @@ -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 @@ -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 @@ -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', ), ) @@ -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 @@ -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: @@ -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() diff --git a/src/tests/test_cases/sections.py b/src/tests/test_cases/sections.py index 5bf9a7b..c2f3466 100644 --- a/src/tests/test_cases/sections.py +++ b/src/tests/test_cases/sections.py @@ -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 '!')" @@ -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')")