diff --git a/great_docs/_apiref/_render/_section_dispatch.py b/great_docs/_apiref/_render/_section_dispatch.py new file mode 100644 index 00000000..2d839a33 --- /dev/null +++ b/great_docs/_apiref/_render/_section_dispatch.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import griffe as gf + +from .._docstring_sections import ( + DCDocstringSectionInitParameters, + DCDocstringSectionParameterAttributes, + DocstringSectionNotes, + DocstringSectionSeeAlso, + DocstringSectionWarnings, +) + +# Which method renders which docstring section. +# +# Lookup is `getattr(self, SECTION_METHOD[type(el)], None)`, so a class that +# does not define the method has no renderer for that section. That is how a +# section is confined to the objects it can describe: `render_parameters_section` +# lives on the call mixin, so a module or a type alias cannot reach it. +# +# The method lookup follows the MRO (a subclass inherits what its parent +# defines), but the table lookup above it does not: it keys on `type(el)` +# exactly, so a subclass of a griffe section type will not be found. +SECTION_METHOD: dict[type, str] = { + # Any object + gf.DocstringSectionText: "render_text_section", + gf.DocstringSectionExamples: "render_examples_section", + gf.DocstringSectionDeprecated: "render_deprecated_section", + gf.DocstringSectionAdmonition: "render_admonition_section", + DocstringSectionNotes: "render_notes_section", + DocstringSectionSeeAlso: "render_see_also_section", + DocstringSectionWarnings: "render_warnings_section", + # Objects with members (classes and modules) + gf.DocstringSectionAttributes: "render_attributes_section", + # Generic objects (classes, functions, and type aliases) + gf.DocstringSectionTypeParameters: "render_type_parameters_section", + # Callables only + gf.DocstringSectionParameters: "render_parameters_section", + gf.DocstringSectionOtherParameters: "render_other_parameters_section", + gf.DocstringSectionReturns: "render_returns_section", + gf.DocstringSectionYields: "render_yields_section", + gf.DocstringSectionReceives: "render_receives_section", + gf.DocstringSectionRaises: "render_raises_section", + gf.DocstringSectionWarns: "render_warns_section", + DCDocstringSectionInitParameters: "render_init_parameters_section", + DCDocstringSectionParameterAttributes: "render_parameter_attributes_section", + # Hand-written member summaries that great-docs generates from the real + # members. Dropped on purpose and silently — they are common in valid + # numpydoc, so warning about them would be noise. Each gets its own + # method (sharing only the private `_suppress_section` helper) so + # overriding how one is dropped cannot affect the others. + gf.DocstringSectionFunctions: "render_functions_section", + gf.DocstringSectionClasses: "render_classes_section", + gf.DocstringSectionModules: "render_modules_section", + gf.DocstringSectionTypeAliases: "render_type_aliases_section", +} diff --git a/great_docs/_apiref/_render/doc.py b/great_docs/_apiref/_render/doc.py index e102a415..febc9dac 100644 --- a/great_docs/_apiref/_render/doc.py +++ b/great_docs/_apiref/_render/doc.py @@ -1,9 +1,10 @@ from __future__ import annotations +import logging import re from copy import copy from dataclasses import dataclass -from functools import cached_property, singledispatchmethod +from functools import cached_property from pathlib import Path from typing import TYPE_CHECKING, cast @@ -22,7 +23,7 @@ Para, ) from great_docs.pandoc.components import Attr -from great_docs.pandoc.inlines import Inline, Inlines, Inlines0, Link, Span +from great_docs.pandoc.inlines import Code, Inline, Inlines, Inlines0, Link, Span from .. import content from .._docstring_sections import ( @@ -44,6 +45,7 @@ repr_obj, ) from .._globals import package_info +from ._section_dispatch import SECTION_METHOD from .base import RenderBase if TYPE_CHECKING: @@ -57,8 +59,12 @@ AnyDocstringSection, DisplayNameFormat, DocObjectKind, + DocstringDefinitionType, + DocstringSectionWithDefinitions, ) +_log = logging.getLogger(__name__) + @dataclass class __RenderDoc(RenderBase): @@ -488,8 +494,7 @@ def render_body(self) -> BlockContent: """ return None if not self.docstring_sections else Blocks(self.docstring_sections) - @singledispatchmethod - def render_docstring_section(self, el: gf.DocstringSection) -> BlockContent: + def render_docstring_section(self, el: AnyDocstringSection) -> BlockContent: """ Render a section of a docstring @@ -498,29 +503,103 @@ def render_docstring_section(self, el: gf.DocstringSection) -> BlockContent: el : The section to render + Returns + ------- + : + The rendered section, or `None` when this renderer has no method + for it. + Notes ----- - To render a given type of section differently, register a - [](`~functools.singledispatchmethod`) method for that type - of section. + To render a given type of section differently, override the method + that `SECTION_METHOD` names for it. Defining that method on a subclass + confines the change to that subclass. """ - new_el = transform(el) - if isinstance(new_el, ExampleCode): - return CodeBlock(el.value, Attr(classes=["python"])) - if isinstance(new_el, ExampleText): - return el.value - return str(el.value) + name = SECTION_METHOD.get(type(el)) + method = getattr(self, name, None) if name else None + return method(el) if method else self._unhandled_section(el) + + def _unhandled_section(self, el: AnyDocstringSection) -> None: + """ + Discard a section this renderer has no method for, and say so + """ + name = el.title or el.kind.value + _log.warning( + "%s: no renderer for the %s docstring section, so it is omitted.", + self.obj.path, + name, + ) + return None + + def _suppress_section(self, el: AnyDocstringSection) -> None: + """ + Drop a hand-written summary of members that great-docs generates itself + """ + return None + + def render_functions_section(self, el: gf.DocstringSectionFunctions) -> None: + """ + Drop a hand-written `Functions` section + + numpydoc's `Methods` section is also parsed by griffe into a + `DocstringSectionFunctions`, so this method covers both `Methods` and + `Functions`. great-docs lists the real functions or methods from the + object's members, so a hand-written list would duplicate that and + risk going stale. + """ + return self._suppress_section(el) + + def render_classes_section(self, el: gf.DocstringSectionClasses) -> None: + """ + Drop a hand-written `Classes` section + + great-docs generates the list of nested classes from the object's + real members, so a hand-written summary would duplicate it and risk + contradicting it. + """ + return self._suppress_section(el) + + def render_modules_section(self, el: gf.DocstringSectionModules) -> None: + """ + Drop a hand-written `Modules` section + + great-docs generates the list of submodules from the package's real + members, so a hand-written summary would duplicate it and risk + contradicting it. + """ + return self._suppress_section(el) + + def render_type_aliases_section(self, el: gf.DocstringSectionTypeAliases) -> None: + """ + Drop a hand-written `Type Aliases` section + + great-docs generates the list of type aliases from the module's real + members, so a hand-written summary would duplicate it and risk + contradicting it. + """ + return self._suppress_section(el) - @render_docstring_section.register - def _(self, el: gf.DocstringSectionText): + def render_text_section(self, el: gf.DocstringSectionText) -> BlockContent: + """Render a `Text` section""" return el.value - @render_docstring_section.register - def _(self, el: gf.DocstringSectionExamples): - return Blocks([self.render_docstring_section(transform(c)) for c in el.value]) + def render_examples_section(self, el: gf.DocstringSectionExamples) -> BlockContent: + """Render an `Examples` section""" + return Blocks([self._render_example_fragment(c) for c in el.value]) - @render_docstring_section.register - def _(self, el: gf.DocstringSectionDeprecated): + def _render_example_fragment(self, fragment: object) -> BlockContent: + """ + Render one code or prose fragment of an `Examples` section + """ + el = transform(fragment) + if isinstance(el, ExampleCode): + return CodeBlock(el.value, Attr(classes=["python"])) + if isinstance(el, ExampleText): + return el.value + return "" + + def render_deprecated_section(self, el: gf.DocstringSectionDeprecated) -> BlockContent: + """Render a `Deprecated` section""" content = Div( Inlines( [ @@ -535,26 +614,20 @@ def _(self, el: gf.DocstringSectionDeprecated): ) return str(content) - @render_docstring_section.register - def _(self, el: gf.DocstringSectionAdmonition): - """ - Render an unofficial numpydoc section - """ + def render_admonition_section(self, el: gf.DocstringSectionAdmonition) -> BlockContent: + """Render an unofficial numpydoc section""" return el.value.description - @render_docstring_section.register - def _(self, el: DocstringSectionWarnings): + def render_warnings_section(self, el: DocstringSectionWarnings) -> BlockContent: + """Render a `Warnings` section""" return el.value - @render_docstring_section.register - def _(self, el: DocstringSectionNotes): + def render_notes_section(self, el: DocstringSectionNotes) -> BlockContent: + """Render a `Notes` section""" return el.value - @render_docstring_section.register - def _(self, el: DocstringSectionSeeAlso): - """ - Render the See Also section - """ + def render_see_also_section(self, el: DocstringSectionSeeAlso) -> BlockContent: + """Render a `See Also` section""" content = format_see_also(el.value) items: list[DefinitionItem] = [] for line in content.split("\n"): @@ -564,20 +637,72 @@ def _(self, el: DocstringSectionSeeAlso): items.append((term, ":".join(desc))) return DefinitionList(items) - @render_docstring_section.register(gf.DocstringSectionFunctions) - @render_docstring_section.register(gf.DocstringSectionClasses) - @render_docstring_section.register(gf.DocstringSectionModules) - def _(self, el): - """ - Suppress collection-style sections (Methods, Functions, Classes, Modules, - Attributes) emitted by the numpy parser - - These sections are hand-written summaries of class/module members (e.g., - `Methods\\n-------\\nfoo(x)\\n Description.`). Great Docs already auto-generates the same - data from the actual members, so rendering the docstring version produces redundant content. - Drop them silently rather than risking duplicate / out-of-sync tables. - """ - return None + def render_definition_items(self, el: DocstringSectionWithDefinitions) -> BlockContent: + """ + Render a section whose value is a list of definitions + + The definitions differ in what they describe — a parameter, an + attribute, a type parameter — but each renders as a term built from + name, annotation and default, followed by a description. + """ + + def render_section_item(el: DocstringDefinitionType) -> DefinitionItem: + """ + Render a single definition in a section + """ + name = getattr(el, "name", None) or "" + default = getattr(el, "default", None) + annotation = el.annotation + + # Parameter of kind *args or **kwargs have no default values + if isinstance(el, gf.DocstringParameter) and "*" in el.name: + default = None + + term = self.render_variable_definition(name, annotation, default) + + # Annotations are expressed in html so that contained interlink + # references can be processed. Pandoc does not process any markup + # within backquotes `...`, but it does if the markup is within + # html code tags. + desc = el.description or "" + return Code(str(term)).html, desc + + # For Returns/Yields/Receives, merge consecutive unnamed items that + # share the same annotation (griffe splits continuation paragraphs + # into separate DocstringReturn objects, each repeating the type). + items_to_render = list(el.value) + if isinstance( + el, (gf.DocstringSectionReturns, gf.DocstringSectionYields, gf.DocstringSectionReceives) + ): + items_to_render = cast( + "list[gf.DocstringReturn | gf.DocstringYield | gf.DocstringReceive]", + items_to_render, + ) + merged: list[gf.DocstringReturn | gf.DocstringYield | gf.DocstringReceive] = [] + merged = [] + for item in items_to_render: + name = getattr(item, "name", None) or "" + ann = getattr(item, "annotation", None) + if ( + not name + and merged + and not (getattr(merged[-1], "name", None) or "") + and getattr(merged[-1], "annotation", None) == ann + ): + # Merge description into the previous item + prev = merged[-1] + prev_desc = prev.description or "" + cur_desc = item.description or "" + sep = "\n\n" if prev_desc else "" + prev.description = prev_desc + sep + cur_desc + else: + merged.append(item) + items_to_render = merged + + items = [render_section_item(item) for item in items_to_render] + if not items: + return None # pragma: no cover + return Div(DefinitionList(items), Attr(classes=["doc-definition-items"])) @property def summary_name(self) -> str: diff --git a/great_docs/_apiref/_render/docattribute.py b/great_docs/_apiref/_render/docattribute.py index 09b2552c..14812774 100644 --- a/great_docs/_apiref/_render/docattribute.py +++ b/great_docs/_apiref/_render/docattribute.py @@ -13,6 +13,7 @@ if TYPE_CHECKING: import griffe as gf + from great_docs._apiref.typing import DocstringSectionWithDefinitions from great_docs.pandoc.blocks import BlockContent from .. import content @@ -66,6 +67,39 @@ def docstring_sections_content(self): items = super().docstring_sections_content return [(title, section) for title, section in items if title != "Returns"] + def _render_property_only_section(self, el: DocstringSectionWithDefinitions) -> BlockContent: + """ + Render a section that only makes sense for a property + + A property runs code on access, so it can legitimately document + `Raises`, `Warns`, or a `Yields`/`Receives` pair. A plain data + attribute cannot, so it falls through to the unhandled-section path + instead of rendering. Gate on the griffe fact (`"property" in + obj.labels`) rather than `self.label`: `get_label` runs annotation + heuristics (`TypeVar`, `TypeAlias`, ...) before it checks for the + `property` label, so a property with such a return annotation would + otherwise be misidentified as not a property. + """ + if "property" not in self.obj.labels: + return self._unhandled_section(el) + return self.render_definition_items(el) + + def render_raises_section(self, el: gf.DocstringSectionRaises) -> BlockContent: + """Render a `Raises` section on a property""" + return self._render_property_only_section(el) + + def render_warns_section(self, el: gf.DocstringSectionWarns) -> BlockContent: + """Render a `Warns` section on a property""" + return self._render_property_only_section(el) + + def render_yields_section(self, el: gf.DocstringSectionYields) -> BlockContent: + """Render a `Yields` section on a property""" + return self._render_property_only_section(el) + + def render_receives_section(self, el: gf.DocstringSectionReceives) -> BlockContent: + """Render a `Receives` section on a property""" + return self._render_property_only_section(el) + class RenderDocAttribute(__RenderDocAttribute): """ diff --git a/great_docs/_apiref/_render/doctypealias.py b/great_docs/_apiref/_render/doctypealias.py index 01270562..ca17faa1 100644 --- a/great_docs/_apiref/_render/doctypealias.py +++ b/great_docs/_apiref/_render/doctypealias.py @@ -88,6 +88,15 @@ def render_description(self) -> BlockContent: ] ) + def render_type_parameters_section(self, el: gf.DocstringSectionTypeParameters) -> BlockContent: + """ + Render a `Type Parameters` section + + A type alias can be generic without being callable, so it needs its + own renderer rather than inheriting one from the call mixin. + """ + return self.render_definition_items(el) + class RenderDocTypeAlias(__RenderDocTypeAlias): """ diff --git a/great_docs/_apiref/_render/mixin_call.py b/great_docs/_apiref/_render/mixin_call.py index 6fd59493..a4a16e93 100644 --- a/great_docs/_apiref/_render/mixin_call.py +++ b/great_docs/_apiref/_render/mixin_call.py @@ -1,19 +1,16 @@ from __future__ import annotations from functools import cached_property -from typing import TYPE_CHECKING, TypeAlias, cast +from typing import TYPE_CHECKING, cast import griffe as gf from great_docs.pandoc.blocks import ( BlockContent, CodeBlock, - DefinitionItem, - DefinitionList, Div, ) from great_docs.pandoc.components import Attr -from great_docs.pandoc.inlines import Code from .._docstring_sections import ( DCDocstringSectionInitParameters, @@ -24,21 +21,6 @@ if TYPE_CHECKING: from ..content import DocClass, DocFunction - from ..typing import DocstringDefinitionType - -# singledispatch needs this type at runtime -DocstringSectionWithDefinitions: TypeAlias = ( - gf.DocstringSectionParameters - | gf.DocstringSectionOtherParameters - | gf.DocstringSectionReturns - | gf.DocstringSectionYields - | gf.DocstringSectionReceives - | gf.DocstringSectionRaises - | gf.DocstringSectionWarns - | gf.DocstringSectionAttributes - | DCDocstringSectionParameterAttributes - | DCDocstringSectionInitParameters -) class __RenderDocCallMixin(RenderDoc): @@ -59,72 +41,54 @@ def __post_init__(self): # rendering needs it. self._parameter_kinds = {p.name: p.kind for p in self.parameters} - @RenderDoc.render_docstring_section.register # pyright: ignore[reportFunctionMemberAccess] - def _(self, el: DocstringSectionWithDefinitions): - """ - Render docstring sections that have a list of definitions + def render_parameters_section(self, el: gf.DocstringSectionParameters) -> BlockContent: + """Render a `Parameters` section""" + return self.render_definition_items(el) - e.g. Parameters, Other Parameters, Returns, Yields, Receives, - Warns, Attributes - """ + def render_other_parameters_section( + self, el: gf.DocstringSectionOtherParameters + ) -> BlockContent: + """Render an `Other Parameters` section""" + return self.render_definition_items(el) - def render_section_item(el: DocstringDefinitionType) -> DefinitionItem: - """ - Render a single definition in a section - """ - name = getattr(el, "name", None) or "" - default = getattr(el, "default", None) - annotation = el.annotation - - # Parameter of kind *args or **kwargs have no default values - if isinstance(el, gf.DocstringParameter) and "*" in el.name: - default = None - - term = self.render_variable_definition(name, annotation, default) - - # Annotations are expressed in html so that contained interlink - # references can be processed. Pandoc does not process any markup - # within backquotes `...`, but it does if the markup is within - # html code tags. - desc = el.description or "" - return Code(str(term)).html, desc - - # For Returns/Yields/Receives, merge consecutive unnamed items that - # share the same annotation (griffe splits continuation paragraphs - # into separate DocstringReturn objects, each repeating the type). - items_to_render = list(el.value) - if isinstance( - el, (gf.DocstringSectionReturns, gf.DocstringSectionYields, gf.DocstringSectionReceives) - ): - items_to_render = cast( - "list[gf.DocstringReturn | gf.DocstringYield | gf.DocstringReceive]", - items_to_render, - ) - merged: list[gf.DocstringReturn | gf.DocstringYield | gf.DocstringReceive] = [] - merged = [] - for item in items_to_render: - name = getattr(item, "name", None) or "" - ann = getattr(item, "annotation", None) - if ( - not name - and merged - and not (getattr(merged[-1], "name", None) or "") - and getattr(merged[-1], "annotation", None) == ann - ): - # Merge description into the previous item - prev = merged[-1] - prev_desc = prev.description or "" - cur_desc = item.description or "" - sep = "\n\n" if prev_desc else "" - prev.description = prev_desc + sep + cur_desc - else: - merged.append(item) - items_to_render = merged + def render_returns_section(self, el: gf.DocstringSectionReturns) -> BlockContent: + """Render a `Returns` section""" + return self.render_definition_items(el) + + def render_yields_section(self, el: gf.DocstringSectionYields) -> BlockContent: + """Render a `Yields` section""" + return self.render_definition_items(el) + + def render_receives_section(self, el: gf.DocstringSectionReceives) -> BlockContent: + """Render a `Receives` section""" + return self.render_definition_items(el) - items = [render_section_item(item) for item in items_to_render] - if not items: - return None # pragma: no cover - return Div(DefinitionList(items), Attr(classes=["doc-definition-items"])) + def render_raises_section(self, el: gf.DocstringSectionRaises) -> BlockContent: + """Render a `Raises` section""" + return self.render_definition_items(el) + + def render_warns_section(self, el: gf.DocstringSectionWarns) -> BlockContent: + """Render a `Warns` section""" + return self.render_definition_items(el) + + def render_init_parameters_section(self, el: DCDocstringSectionInitParameters) -> BlockContent: + """Render the `Init Parameters` section of a dataclass""" + return self.render_definition_items(el) + + def render_parameter_attributes_section( + self, el: DCDocstringSectionParameterAttributes + ) -> BlockContent: + """Render the `Parameter Attributes` section of a dataclass""" + return self.render_definition_items(el) + + def render_type_parameters_section(self, el: gf.DocstringSectionTypeParameters) -> BlockContent: + """ + Render a `Type Parameters` section + + A generic class or function declares its type parameters as part of + the signature this renderer builds. + """ + return self.render_definition_items(el) @cached_property def parameters(self) -> gf.Parameters: diff --git a/great_docs/_apiref/_render/mixin_members.py b/great_docs/_apiref/_render/mixin_members.py index 8ebfb4e3..b39044d4 100644 --- a/great_docs/_apiref/_render/mixin_members.py +++ b/great_docs/_apiref/_render/mixin_members.py @@ -459,6 +459,15 @@ def _render_member_pages_group( return RenderedMemberPagesGroup(title, summary) + def render_attributes_section(self, el: gf.DocstringSectionAttributes) -> BlockContent: + """ + Render an `Attributes` section + + Only an object with members — a class or a module — can have + attributes to describe. + """ + return self.render_definition_items(el) + class RenderDocMembersMixin(__RenderDocMembersMixin, RenderDoc): """ diff --git a/great_docs/_apiref/typing.py b/great_docs/_apiref/typing.py index 6e966dea..a998586a 100644 --- a/great_docs/_apiref/typing.py +++ b/great_docs/_apiref/typing.py @@ -4,7 +4,11 @@ import griffe as gf -from ._docstring_sections import DCDocstringSection +from ._docstring_sections import ( + DCDocstringSection, + DCDocstringSectionInitParameters, + DCDocstringSectionParameterAttributes, +) from ._render.api_page import RenderAPIPage from ._render.doc import RenderDoc from ._render.docattribute import RenderDocAttribute @@ -41,8 +45,23 @@ "type alias", ] +DocstringSectionWithDefinitions: TypeAlias = ( + gf.DocstringSectionParameters + | gf.DocstringSectionOtherParameters + | gf.DocstringSectionTypeParameters + | gf.DocstringSectionReturns + | gf.DocstringSectionYields + | gf.DocstringSectionReceives + | gf.DocstringSectionRaises + | gf.DocstringSectionWarns + | gf.DocstringSectionAttributes + | DCDocstringSectionParameterAttributes + | DCDocstringSectionInitParameters +) + DocstringDefinitionType: TypeAlias = ( gf.DocstringParameter + | gf.DocstringTypeParameter | gf.DocstringAttribute | gf.DocstringReturn | gf.DocstringYield diff --git a/great_docs/assets/great-docs.scss b/great_docs/assets/great-docs.scss index b9bc7e01..cf74d6d0 100644 --- a/great_docs/assets/great-docs.scss +++ b/great_docs/assets/great-docs.scss @@ -32,7 +32,7 @@ $signature-background-color: shade-color(tint-color($primary, 96%), 2%); $font-family-monospace: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; // Selectors for similar groups of elements that are created by the renderer -$docstring-sections: ":is(.doc-parameters, .doc-other-parameters, .doc-returns, .doc-yields, .doc-receives, .doc-raises, .doc-warns, .doc-attributes, .doc-parameter-attributes, .doc-init-parameters, .doc-see-also, .doc-examples)"; +$docstring-sections: ":is(.doc-parameters, .doc-other-parameters, .doc-type-parameters, .doc-returns, .doc-yields, .doc-receives, .doc-raises, .doc-warns, .doc-attributes, .doc-parameter-attributes, .doc-init-parameters, .doc-see-also, .doc-examples)"; $doc-object-names: "span.doc-object-name:is(.doc-class-name, .doc-method-name, .doc-module-name, .doc-function-name, .doc-alias-name, .doc-attribute-name, .doc-type-name, .doc-typevar)"; a { @@ -218,6 +218,7 @@ body.quarto-dark table.gd-summary-table td:last-child { // Remove h2 underlines on API reference pages (Quarto/Bootstrap adds border-bottom // to all h2 elements; we suppress it for doc section headings and group headings) h2.doc-parameters, +h2.doc-type-parameters, h2.doc-returns, h2.doc-attributes, h2.doc-functions, diff --git a/tests/renderer/test_sections.py b/tests/renderer/test_sections.py new file mode 100644 index 00000000..040c03e2 --- /dev/null +++ b/tests/renderer/test_sections.py @@ -0,0 +1,790 @@ +"""Tests for docstring section rendering and dispatch.""" + +from __future__ import annotations + +import sys +import textwrap + +import pytest + +requires_pep695 = pytest.mark.skipif( + sys.version_info < (3, 12), + reason="PEP 695 `type` statement and type-parameter syntax require Python 3.12+", +) + + +def _render(source: str, name: str | None) -> str: + """Render the named object from a source snippet to qmd""" + from great_docs._apiref._tools import render_code_variable + + return render_code_variable(textwrap.dedent(source), name) + + +def test_examples_section_renders_code_and_prose(): + """ + An `Examples` section interleaves prose and code fragments + + `render_examples_section` is defined on every renderer, so this must never + fall through to the unhandled-section path, which only logs and drops + content. + """ + source = ''' + def f(): + """ + Do a thing. + + Examples + -------- + Some explanatory prose. + + >>> f() + 3 + """ + ''' + qmd = _render(source, "f") + + assert "Some explanatory prose." in qmd + assert "f()" in qmd + assert "object at 0x" not in qmd + + +def test_example_fragment_renders_directly(): + """`_render_example_fragment` handles a fragment without re-entering dispatch""" + from great_docs._apiref._docstring_sections import ExampleCode, ExampleText + from great_docs._apiref._render.doc import __RenderDoc as RenderDocImpl # noqa: N813 + + render = object.__new__(RenderDocImpl) + + assert "x = 1" in str(render._render_example_fragment(ExampleCode("x = 1"))) + assert render._render_example_fragment(ExampleText("hello")) == "hello" + + +def test_section_method_table_covers_every_griffe_section_kind(): + """ + Every griffe section kind maps to a renderer or to deliberate suppression + + A kind missing from the table degrades to a logged warning and no output, + so the table is what keeps a griffe upgrade from silently dropping content. + """ + import griffe as gf + + from great_docs._apiref._render._section_dispatch import SECTION_METHOD + + covered = {getattr(t, "kind", None) for t in SECTION_METHOD} + missing = {kind for kind in gf.DocstringSectionKind if kind not in covered} + + assert not missing, f"griffe section kinds absent from SECTION_METHOD: {missing}" + + +def test_section_method_values_are_method_names(): + """Every value in `SECTION_METHOD` is a non-empty method name, not a callable or `None`""" + from great_docs._apiref._render._section_dispatch import SECTION_METHOD + + assert all(isinstance(name, str) and name for name in SECTION_METHOD.values()) + + +def test_parameters_section_on_a_module_is_not_rendered(caplog): + """ + A module has no parameters, so the section has no renderer and is omitted + + `RenderDocModule` does not inherit `RenderDocCallMixin`. Before table + dispatch it reached the mixin's handler anyway, because + `singledispatchmethod` shared one registry across every subclass. + """ + source = ''' + """ + A module. + + Parameters + ---------- + x : + Not a real thing. + """ + ''' + with caplog.at_level("WARNING"): + qmd = _render(source, None) + + assert "Not a real thing." not in qmd + assert "object at 0x" not in qmd + assert any("no renderer" in r.message for r in caplog.records) + + +def test_parameters_section_on_a_class_still_renders(): + """The mixin's method is still reachable from the classes that inherit it""" + source = ''' + class Widget: + """ + A widget. + + Parameters + ---------- + size : + How big. + """ + + def __init__(self, size: int): ... + ''' + qmd = _render(source, "Widget") + + assert "How big." in qmd + + +def test_methods_section_is_dropped_silently(caplog): + """Deliberate suppression must not warn — `Methods` is valid numpydoc""" + source = ''' + class Widget: + """ + A widget. + + Methods + ------- + go(x) + Do the thing. + """ + + def go(self, x): ... + ''' + with caplog.at_level("WARNING"): + qmd = _render(source, "Widget") + + assert "Do the thing." not in qmd + assert not [r for r in caplog.records if "no renderer" in r.message] + + +def test_type_aliases_section_is_dropped_silently(caplog): + """ + A hand-written `Type Aliases` section is a member summary, so it is dropped + + It restates what the real type-alias members already provide, so + `render_type_aliases_section` drops it without a warning, the same as + `render_functions_section` (which also covers `Methods`), + `render_classes_section` and `render_modules_section`. + """ + source = ''' + class Holder: + """ + A holder. + + Type Aliases + ------------ + Handwritten : int | str + A hand-written summary. + """ + ''' + with caplog.at_level("WARNING"): + qmd = _render(source, "Holder") + + assert "Handwritten" not in qmd + assert "A hand-written summary." not in qmd + assert "object at 0x" not in qmd + assert not [r for r in caplog.records if "no renderer" in r.message] + + +def test_functions_section_on_a_module_is_dropped_silently(caplog): + """A hand-written `Functions` section on a module is dropped, not warned about""" + source = ''' + """ + A module. + + Functions + --------- + helper(x) + Do the thing. + """ + ''' + with caplog.at_level("WARNING"): + qmd = _render(source, None) + + assert "Do the thing." not in qmd + assert "object at 0x" not in qmd + assert not [r for r in caplog.records if "no renderer" in r.message] + + +def test_classes_section_on_a_module_is_dropped_silently(caplog): + """A hand-written `Classes` section on a module is dropped, not warned about""" + source = ''' + """ + A module. + + Classes + ------- + Thing + Do the thing. + """ + ''' + with caplog.at_level("WARNING"): + qmd = _render(source, None) + + assert "Do the thing." not in qmd + assert "object at 0x" not in qmd + assert not [r for r in caplog.records if "no renderer" in r.message] + + +def test_suppression_methods_are_independently_overridable(): + """ + Overriding one suppression method must not affect the other three + + The four suppressed section kinds (`Functions`/`Methods`, `Classes`, + `Modules`, `Type Aliases`) share only the private `_suppress_section` + helper, not a single dispatched method, so overriding + `render_type_aliases_section` on a subclass must leave `Methods` + (`render_functions_section`) dropped as before. + + Subclassing a public `Render*` class outside `great_docs` normally + triggers `RenderBase.__init_subclass__`, which copies the subclass's + attributes onto its immediate base class (`extend_base_class`) so that + user overrides "fill in" the package's internal classes everywhere. That + is a deliberate feature for real usage, but here it would leak a + test-only override into global state and affect unrelated tests. It is + neutralised for the duration of this test by patching + `extend_base_class` to a no-op, so the subclass created below stays + local to this test. + """ + import great_docs._apiref._render.base as base_module + from great_docs._apiref import RenderDocClass + + original_extend_base_class = base_module.extend_base_class + base_module.extend_base_class = lambda cls: None + try: + + class _MarkedRenderDocClass(RenderDocClass): + def render_type_aliases_section(self, el): + return "MARKER" + + finally: + base_module.extend_base_class = original_extend_base_class + + render = object.__new__(_MarkedRenderDocClass) + + import griffe as gf + + assert render.render_docstring_section(gf.DocstringSectionTypeAliases([])) == "MARKER" + assert render.render_docstring_section(gf.DocstringSectionFunctions([])) is None + + +def test_raises_section_on_a_property_still_renders(caplog): + """ + A `Raises` section on a property renders, same as on a function + + `RenderDocAttribute` renders properties and plain attributes alike, and + does not inherit the call mixin that defines `render_raises_section` for + functions and classes. A `Raises` section is still ordinary, valid + numpydoc on a property, so it must render rather than fall through to the + unhandled-section path. + """ + source = ''' + class Widget: + """A widget.""" + + @property + def size(self): + """ + The size. + + Raises + ------ + ValueError + If the size cannot be determined. + """ + ''' + with caplog.at_level("WARNING"): + qmd = _render(source, "Widget.size") + + assert "If the size cannot be determined." in qmd + assert "object at 0x" not in qmd + assert not [r for r in caplog.records if "no renderer" in r.message] + + +def test_warns_section_on_a_property_still_renders(caplog): + """A `Warns` section on a property renders, same as `Raises`""" + source = ''' + class Widget: + """A widget.""" + + @property + def size(self): + """ + The size. + + Warns + ----- + UserWarning + If the size is guessed. + """ + ''' + with caplog.at_level("WARNING"): + qmd = _render(source, "Widget.size") + + assert "If the size is guessed." in qmd + assert "object at 0x" not in qmd + assert not [r for r in caplog.records if "no renderer" in r.message] + + +def test_yields_section_on_a_property_still_renders(caplog): + """A `Yields` section on a property renders, same as `Raises`""" + source = ''' + class Widget: + """A widget.""" + + @property + def size(self): + """ + The size. + + Yields + ------ + int + Each candidate size. + """ + ''' + with caplog.at_level("WARNING"): + qmd = _render(source, "Widget.size") + + assert "Each candidate size." in qmd + assert "object at 0x" not in qmd + assert not [r for r in caplog.records if "no renderer" in r.message] + + +def test_yields_and_receives_sections_on_a_property_both_render(caplog): + """ + `Yields` and `Receives` are a mandatory pair, and both must render + + numpydoc requires `Receives` to be documented together with `Yields` + since it describes what the same generator accepts via `.send()`. A + property documenting both must render both halves, not just `Yields`. + """ + source = ''' + class Widget: + """A widget.""" + + @property + def size(self): + """ + The size. + + Yields + ------ + int + Each candidate size. + + Receives + -------- + float + A scale factor to apply. + """ + ''' + with caplog.at_level("WARNING"): + qmd = _render(source, "Widget.size") + + assert "Each candidate size." in qmd + assert "A scale factor to apply." in qmd + assert "object at 0x" not in qmd + assert not [r for r in caplog.records if "no renderer" in r.message] + + +def test_raises_section_on_a_typevar_annotated_property_still_renders(caplog): + """ + A `Raises` section on a property still renders even if mislabelled + + `get_label` runs annotation heuristics before it checks for the + `property` label, so a property whose return annotation stringifies to + contain `TypeVar` gets the label `"typevar"`, not `"property"`. + `_render_property_only_section` must gate on the griffe fact + (`"property" in obj.labels`), not on `self.label`, or this case falls + through to the unhandled-section path and silently drops the section. + """ + source = ''' + from typing import TypeVar + + class Widget: + """A widget.""" + + @property + def factory(self) -> TypeVar: + """ + The factory. + + Raises + ------ + ValueError + If no factory is available. + """ + ''' + with caplog.at_level("WARNING"): + qmd = _render(source, "Widget.factory") + + assert "If no factory is available." in qmd + assert "object at 0x" not in qmd + assert not [r for r in caplog.records if "no renderer" in r.message] + + +def test_receives_section_on_a_plain_attribute_is_unhandled(caplog): + """ + A `Receives` section on a plain data attribute is a mistake, not content + + A plain attribute is not a generator, so it cannot receive anything via + `.send()`. Unlike a property, it must take the unhandled-section path: + nothing rendered, and a warning logged. + """ + source = ''' + class Widget: + """A widget.""" + + size: int = 3 + """ + The size. + + Receives + -------- + float + A scale factor to apply. + """ + ''' + with caplog.at_level("WARNING"): + qmd = _render(source, "Widget.size") + + assert "A scale factor to apply." not in qmd + assert "object at 0x" not in qmd + assert any("no renderer" in r.message for r in caplog.records) + + +def test_raises_section_on_a_plain_attribute_is_unhandled(caplog): + """ + A `Raises` section on a plain data attribute is a mistake, not content + + A plain attribute executes no code on access, so it cannot raise. Unlike + a property, it must take the unhandled-section path: nothing rendered, + and a warning logged. + """ + source = ''' + class Widget: + """A widget.""" + + size: int = 3 + """ + The size. + + Raises + ------ + ValueError + If the size cannot be determined. + """ + ''' + with caplog.at_level("WARNING"): + qmd = _render(source, "Widget.size") + + assert "If the size cannot be determined." not in qmd + assert "object at 0x" not in qmd + assert any("no renderer" in r.message for r in caplog.records) + + +def test_warns_section_on_a_plain_attribute_is_unhandled(caplog): + """ + A `Warns` section on a plain data attribute is a mistake, not content + + A plain attribute executes no code on access, so it cannot warn. Unlike + a property, it must take the unhandled-section path: nothing rendered, + and a warning logged. + """ + source = ''' + class Widget: + """A widget.""" + + size: int = 3 + """ + The size. + + Warns + ----- + UserWarning + If the size is guessed. + """ + ''' + with caplog.at_level("WARNING"): + qmd = _render(source, "Widget.size") + + assert "If the size is guessed." not in qmd + assert "object at 0x" not in qmd + assert any("no renderer" in r.message for r in caplog.records) + + +def test_yields_section_on_a_plain_attribute_is_unhandled(caplog): + """ + A `Yields` section on a plain data attribute is a mistake, not content + + A plain attribute is not a generator, so it cannot yield anything. Unlike + a property, it must take the unhandled-section path: nothing rendered, + and a warning logged. + """ + source = ''' + class Widget: + """A widget.""" + + size: int = 3 + """ + The size. + + Yields + ------ + int + Each candidate size. + """ + ''' + with caplog.at_level("WARNING"): + qmd = _render(source, "Widget.size") + + assert "Each candidate size." not in qmd + assert "object at 0x" not in qmd + assert any("no renderer" in r.message for r in caplog.records) + + +def test_property_only_section_methods_are_independently_overridable(): + """ + Overriding one property-only section method must not affect the others + + `render_raises_section`, `render_warns_section`, `render_yields_section` + and `render_receives_section` on `RenderDocAttribute` all delegate to the + shared `_render_property_only_section` helper rather than to one + dispatched method, so overriding `render_raises_section` on a subclass + must leave `render_warns_section` rendering normally. + + Subclassing a public `Render*` class outside `great_docs` normally + triggers `RenderBase.__init_subclass__`, which copies the subclass's + attributes onto its immediate base class (`extend_base_class`) so that + user overrides "fill in" the package's internal classes everywhere. That + is a deliberate feature for real usage, but here it would leak a + test-only override into global state and affect unrelated tests. It is + neutralised for the duration of this test by patching + `extend_base_class` to a no-op, so the subclass created below stays + local to this test. + """ + import great_docs._apiref._render.base as base_module + from great_docs._apiref import RenderDocAttribute + + original_extend_base_class = base_module.extend_base_class + base_module.extend_base_class = lambda cls: None + try: + + class _MarkedRenderDocAttribute(RenderDocAttribute): + def render_raises_section(self, el): + return "MARKER" + + finally: + base_module.extend_base_class = original_extend_base_class + + source = ''' + class Widget: + """A widget.""" + + @property + def size(self): + """ + The size. + + Raises + ------ + ValueError + If the size cannot be determined. + + Warns + ----- + UserWarning + If the size is guessed. + """ + ''' + + import great_docs._apiref._render as render_module + from great_docs._apiref.content import DocAttribute + + original_render_doc_attribute = render_module._class_mapping[DocAttribute] + render_module._class_mapping[DocAttribute] = _MarkedRenderDocAttribute + try: + qmd = _render(source, "Widget.size") + finally: + render_module._class_mapping[DocAttribute] = original_render_doc_attribute + + assert "MARKER" in qmd + assert "If the size is guessed." in qmd + + +def test_every_table_entry_names_a_real_method(): + """ + A misspelt method name in `SECTION_METHOD` degrades to a silent warning + + Nothing raises when a name is wrong — the section is simply dropped and a + warning is logged — so this is the only check that catches a rename. + """ + from great_docs._apiref import ( + RenderDocAttribute, + RenderDocClass, + RenderDocFunction, + RenderDocModule, + RenderDocTypeAlias, + ) + from great_docs._apiref._render._section_dispatch import SECTION_METHOD + + classes = ( + RenderDocClass, + RenderDocFunction, + RenderDocAttribute, + RenderDocModule, + RenderDocTypeAlias, + ) + orphans = { + name for name in SECTION_METHOD.values() if not any(hasattr(c, name) for c in classes) + } + + assert not orphans, f"SECTION_METHOD names methods that do not exist: {orphans}" + + +@requires_pep695 +def test_attributes_section_on_a_type_alias_is_unhandled(caplog): + """ + A type alias has no attributes, so the section has no renderer + + `Attributes` describes the members of a class or a module. Anything else + documenting one is an authoring mistake worth reporting. + """ + source = ''' + type Pair = tuple[int, int] + """ + Two integers. + + Attributes + ---------- + first : int + Not a real attribute. + """ + ''' + with caplog.at_level("WARNING"): + qmd = _render(source, "Pair") + + assert "Not a real attribute." not in qmd + assert "object at 0x" not in qmd + assert any("no renderer" in r.message for r in caplog.records) + + +def test_attributes_section_on_a_class_still_renders(): + source = ''' + class Widget: + """ + A widget. + + Attributes + ---------- + size : int + How big it is. + """ + + size: int = 3 + ''' + assert "How big it is." in _render(source, "Widget") + + +def test_attributes_section_on_a_module_still_renders(): + source = ''' + """ + A module. + + Attributes + ---------- + MAX : int + The largest allowed value. + """ + + MAX: int = 3 + ''' + assert "The largest allowed value." in _render(source, None) + + +def test_type_parameters_section_on_a_module_is_unhandled(caplog): + """ + A module is not generic, so it has no type parameters to describe + """ + source = ''' + """ + A module. + + Type Parameters + --------------- + T : + Not a real type parameter. + """ + ''' + with caplog.at_level("WARNING"): + qmd = _render(source, None) + + assert "Not a real type parameter." not in qmd + assert "object at 0x" not in qmd + assert any("no renderer" in r.message for r in caplog.records) + + +def test_type_parameters_section_on_a_plain_attribute_is_unhandled(caplog): + source = ''' + class Holder: + """A holder.""" + + size: int = 3 + """ + The size. + + Type Parameters + --------------- + T : + Not a real type parameter. + """ + ''' + with caplog.at_level("WARNING"): + qmd = _render(source, "Holder") + + assert "Not a real type parameter." not in qmd + assert any("no renderer" in r.message for r in caplog.records) + + +@requires_pep695 +def test_type_parameters_section_on_a_generic_class_still_renders(): + source = ''' + class Repo[T]: + """ + A repository. + + Type Parameters + --------------- + T : + The entity type. + """ + ''' + assert "The entity type." in _render(source, "Repo") + + +@requires_pep695 +def test_type_parameters_section_on_a_generic_function_still_renders(): + source = ''' + def first[T](items: list[T]) -> T: + """ + Take the first item. + + Type Parameters + --------------- + T : + The element type. + """ + return items[0] + ''' + assert "The element type." in _render(source, "first") + + +@requires_pep695 +def test_type_parameters_section_on_a_generic_type_alias_still_renders(): + source = ''' + type Pair[T] = tuple[T, T] + """ + Two values of the same type. + + Type Parameters + --------------- + T : + The type of both elements. + """ + ''' + assert "The type of both elements." in _render(source, "Pair") diff --git a/tests/renderer/test_type_parameters.py b/tests/renderer/test_type_parameters.py new file mode 100644 index 00000000..8d901005 --- /dev/null +++ b/tests/renderer/test_type_parameters.py @@ -0,0 +1,215 @@ +"""Tests for the docstring `Type Parameters` section.""" + +from __future__ import annotations + +import sys +import textwrap + +import pytest + +pytestmark = pytest.mark.skipif( + sys.version_info < (3, 12), + reason="PEP 695 type parameter syntax requires Python 3.12+", +) +requires_pep696 = pytest.mark.skipif( + sys.version_info < (3, 13), + reason="PEP 696 type parameter defaults require Python 3.13+", +) + + +def _render(source: str, name: str | None) -> str: + """Render the named object from a source snippet to qmd""" + from great_docs._apiref._tools import render_code_variable + + return render_code_variable(textwrap.dedent(source), name) + + +def test_generic_class_section_renders(): + """ + The bound is omitted in the docstring, so griffe fills it from the signature + + An unrendered section is not the only failure mode here: the heading renders + on its own, so the section must be checked for leaked object reprs too. + """ + source = ''' + class Model: ... + + class Repo[T: Model, K]: + """ + A repository. + + Type Parameters + --------------- + T : + The entity type stored in the repository. + K : + The primary-key type used to look entities up. + """ + ''' + qmd = _render(source, "Repo") + + assert "Type Parameters" in qmd + assert "The entity type stored in the repository." in qmd + assert "The primary-key type used to look entities up." in qmd + assert "object at 0x" not in qmd + + +def test_generic_function_section_renders(): + source = ''' + from collections.abc import Callable, Iterable + + def group_by[T, K](items: Iterable[T], key: Callable[[T], K]) -> dict[K, list[T]]: + """ + Group items by a computed key. + + Type Parameters + --------------- + T : + The element type, inferred from `items`. + K : + The grouping key type. + """ + ''' + qmd = _render(source, "group_by") + + assert "The element type, inferred from" in qmd + assert "The grouping key type." in qmd + assert "object at 0x" not in qmd + + +def test_generic_type_alias_section_renders(): + """ + A generic type alias documents type parameters even though it is not callable + + `RenderDocTypeAlias` does not inherit `RenderDocCallMixin`, so it defines + its own `render_type_parameters_section` rather than sharing the mixin's. + """ + source = ''' + type Pair[T] = tuple[T, T] + """ + Two values of the same type. + + Type Parameters + --------------- + T : + The type of both elements. + """ + ''' + qmd = _render(source, "Pair") + + assert "The type of both elements." in qmd + assert "object at 0x" not in qmd + + +def test_unbounded_parameter_omits_the_annotation_separator(): + """An unbounded parameter has no annotation, so no `:` separator is rendered""" + source = ''' + class Box[K]: + """ + A box. + + Type Parameters + --------------- + K : + The key type. + """ + ''' + qmd = _render(source, "Box") + + assert "The key type." in qmd + assert "doc-parameter-annotation-sep" not in qmd + assert "object at 0x" not in qmd + + +def test_constrained_parameter_renders_the_constraints(): + """ + A constrained parameter renders as the tuple griffe puts in `.annotation` + + `.constraints` needs no special handling: griffe already exposes + `(str, bytes)` through the same attribute a bound uses. + """ + source = ''' + class Both[S: (str, bytes)]: + """ + A thing. + + Type Parameters + --------------- + S : + Either flavour of string. + """ + ''' + qmd = _render(source, "Both") + + assert "Either flavour of string." in qmd + assert "str" in qmd and "bytes" in qmd + assert "object at 0x" not in qmd + + +@requires_pep696 +def test_default_renders(): + source = ''' + class Holder[T: int = bool]: + """ + A holder. + + Type Parameters + --------------- + T : + The held type. + """ + ''' + qmd = _render(source, "Holder") + + assert "The held type." in qmd + assert "doc-parameter-default" in qmd + assert "object at 0x" not in qmd + + +def test_module_attributes_section_renders(): + """ + A module can document an `Attributes` section + + `RenderDocModule` reaches `render_attributes_section` through the members + mixin it shares with `RenderDocClass`, not through `RenderDocCallMixin` — + a module has no `Type Parameters` section. + """ + source = ''' + """ + A module. + + Attributes + ---------- + MAX : int + The largest allowed value. + """ + + MAX: int = 3 + ''' + qmd = _render(source, None) + + assert "The largest allowed value." in qmd + assert "object at 0x" not in qmd + + +def test_docstring_bound_overrides_the_signature(): + """griffe lets an explicit docstring bound win over the one in the signature""" + source = ''' + class Model: ... + class Other: ... + + class Repo[T: Model]: + """ + A repository. + + Type Parameters + --------------- + T : Other + The entity type. + """ + ''' + qmd = _render(source, "Repo") + + assert "The entity type." in qmd + assert "Other" in qmd + assert "object at 0x" not in qmd diff --git a/tests/test_great_docs.py b/tests/test_great_docs.py index 41b32ac8..1bcec212 100644 --- a/tests/test_great_docs.py +++ b/tests/test_great_docs.py @@ -30588,7 +30588,7 @@ def test_render_example_text_fences_doctests(): doc_fn = content.DocFunction(name="f", obj=func) render = RenderDocFunction(doc_fn, level=1) - out = str(render.render_docstring_section(ExampleText(func.docstring.value))) + out = str(render._render_example_fragment(ExampleText(func.docstring.value))) assert "```" in out