From 1e311a4eb935c58d488c928a86493ab3f3368f06 Mon Sep 17 00:00:00 2001 From: Michael Chow Date: Mon, 15 Jan 2024 08:13:58 -0500 Subject: [PATCH] feat: Support admonitions in Numpydoc docstrings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, sections titled "See also" and others were not recognized as sections, and therefore just parsed as regular text. Now, such unknown sections will be parsed as admonitions. In a previous breaking change, support for prose between sections was removed: this change compensantes the breakage by allowing users to use admonitions such as Note, Important, etc., to re-add prose between sections thanks to admonitions. This also further reduces the gap between Numpydoc and Google-style. Issue #214: https://github.com/mkdocstrings/griffe/issues/214 PR #219: https://github.com/mkdocstrings/griffe/pull/219 Co-authored-by: Timothée Mazzucotelli --- src/griffe/docstrings/numpy.py | 64 ++++++++++++++++++++----- tests/test_docstrings/test_numpy.py | 72 +++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 12 deletions(-) diff --git a/src/griffe/docstrings/numpy.py b/src/griffe/docstrings/numpy.py index 8fe956cf..89a21f36 100644 --- a/src/griffe/docstrings/numpy.py +++ b/src/griffe/docstrings/numpy.py @@ -34,6 +34,7 @@ DocstringReceive, DocstringReturn, DocstringSection, + DocstringSectionAdmonition, DocstringSectionAttributes, DocstringSectionClasses, DocstringSectionDeprecated, @@ -720,6 +721,19 @@ def _read_examples_section( return None, new_offset +def _append_section(sections: list, current: list[str], admonition_title: str) -> None: + if admonition_title: + sections.append( + DocstringSectionAdmonition( + kind=admonition_title.lower().replace(" ", "-"), + text="\n".join(current).rstrip("\n"), + title=admonition_title, + ), + ) + elif current and any(current): + sections.append(DocstringSectionText("\n".join(current).rstrip("\n"))) + + _section_reader = { DocstringSectionKind.parameters: _read_parameters_section, DocstringSectionKind.other_parameters: _read_other_parameters_section, @@ -762,6 +776,7 @@ def parse( """ sections: list[DocstringSection] = [] current_section = [] + admonition_title = "" in_code_block = False lines = docstring.lines @@ -787,32 +802,57 @@ def parse( while offset < len(lines): line_lower = lines[offset].lower() + # Code blocks can contain dash lines that we must not interpret. if in_code_block: + # End of code block. if line_lower.lstrip(" ").startswith("```"): in_code_block = False + # Lines in code block must not be interpreted in any way. current_section.append(lines[offset]) - elif line_lower in _section_kind and _is_dash_line(lines[offset + 1]): - if current_section: - if any(current_section): - sections.append(DocstringSectionText("\n".join(current_section).rstrip("\n"))) - current_section = [] - reader = _section_reader[_section_kind[line_lower]] - section, offset = reader(docstring, offset=offset + 2, **options) # type: ignore[operator] - if section: - sections.append(section) - + # Start of code block. elif line_lower.lstrip(" ").startswith("```"): in_code_block = True current_section.append(lines[offset]) + # Dash lines after empty lines lose their meaning. + elif _is_empty_line(lines[offset]): + current_section.append("") + + # End of the docstring, wrap up. + elif offset == len(lines) - 1: + current_section.append(lines[offset]) + _append_section(sections, current_section, admonition_title) + admonition_title = "" + current_section = [] + + # Dash line after regular, non-empty line. + elif _is_dash_line(lines[offset + 1]): + # Finish reading current section. + _append_section(sections, current_section, admonition_title) + current_section = [] + + # Start parsing new (known) section. + if line_lower in _section_kind: + admonition_title = "" + reader = _section_reader[_section_kind[line_lower]] + section, offset = reader(docstring, offset=offset + 2, **options) # type: ignore[operator] + if section: + sections.append(section) + + # Start parsing admonition. + else: + admonition_title = lines[offset] + offset += 1 # skip next dash line + + # Regular line. else: current_section.append(lines[offset]) offset += 1 - if current_section: - sections.append(DocstringSectionText("\n".join(current_section).rstrip("\n"))) + # Finish current section. + _append_section(sections, current_section, admonition_title) return sections diff --git a/tests/test_docstrings/test_numpy.py b/tests/test_docstrings/test_numpy.py index 81232d20..1d01a0a7 100644 --- a/tests/test_docstrings/test_numpy.py +++ b/tests/test_docstrings/test_numpy.py @@ -114,6 +114,78 @@ def test_doubly_indented_lines_in_section_items(parse_numpy: ParserType) -> None assert lines[-1].startswith(4 * " " + "- ") +# ============================================================================================= +# Admonitions +def test_admonition_see_also(parse_numpy: ParserType) -> None: + """Test a "See Also" admonition. + + Parameters: + parse_numpy: Fixture parser. + """ + docstring = """ + Summary text. + + See Also + -------- + some_function + + more text + """ + + sections, _ = parse_numpy(docstring) + assert len(sections) == 2 + assert sections[0].value == "Summary text." + assert sections[1].title == "See Also" + assert sections[1].value.description == "some_function\n\nmore text" + + +def test_admonition_empty(parse_numpy: ParserType) -> None: + """Test an empty "See Also" admonition. + + Parameters: + parse_numpy: Fixture parser. + """ + docstring = """ + Summary text. + + See Also + -------- + """ + + sections, _ = parse_numpy(docstring) + assert len(sections) == 2 + assert sections[0].value == "Summary text." + assert sections[1].title == "See Also" + assert sections[1].value.description == "" + + +def test_isolated_dash_lines_do_not_create_sections(parse_numpy: ParserType) -> None: + """An isolated dash-line (`---`) should not be parsed as a section. + + Parameters: + parse_numpy: Fixture parser. + """ + docstring = """ + Summary text. + + --- + Text. + + Note + ---- + Note contents. + + --- + Text. + """ + + sections, _ = parse_numpy(docstring) + assert len(sections) == 2 + assert sections[0].value == "Summary text.\n\n---\nText." + assert sections[1].title == "Note" + assert sections[1].value.description == "Note contents.\n\n---\nText." + + # ============================================================================================= # Annotations def test_prefer_docstring_type_over_annotation(parse_numpy: ParserType) -> None: