From ff7a990c1009404b1c96cd242ae5ee6ecaa5e0f7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 08:12:33 -0500 Subject: [PATCH 01/22] typehints-gp(fix): Describe class members once why: A class variable, an init-only dataclass field, and a property without a docstring all reach the page as a signature with nothing beneath it. Autodoc renders the annotation and value but has no description to attach, so the Attributes entry the author wrote was deleted in favor of an empty entry -- silently, with no warning. what: - Capture each marked field directive's prose while a class documents its members, dropping the type and value the member renders itself - Add FieldDocFallbackMixin, returning that prose from get_doc() when the member has no description of its own, the way autodoc already reattaches a #: source comment - Apply the mixin to the attribute documenter and register a property documenter carrying it - Cover the class variable, InitVar, and undocumented property cases --- .../_data_defaults.py | 3 +- .../_documented_fields.py | 195 +++++++++++++++++- .../typehints_gp/test_documented_fields.py | 91 ++++++++ 3 files changed, 286 insertions(+), 3 deletions(-) diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_data_defaults.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_data_defaults.py index 7dbbd01b..ce3b2c43 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_data_defaults.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_data_defaults.py @@ -29,6 +29,7 @@ from sphinx.ext.autodoc import AttributeDocumenter, DataDocumenter +from sphinx_autodoc_typehints_gp._documented_fields import FieldDocFallbackMixin from sphinx_autodoc_typehints_gp._resolvers import ( ResolveContext, Resolver, @@ -113,7 +114,7 @@ def add_line(self, line: str, source: str, *lineno: int) -> None: super().add_line(result, source, *lineno) -class GpAttributeDocumenter(AttributeDocumenter): +class GpAttributeDocumenter(FieldDocFallbackMixin, AttributeDocumenter): # type: ignore[misc] """``AttributeDocumenter`` that curates ``:value:`` text via the resolver chain.""" objtype = "attribute" diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py index 57afcf0f..f206d0be 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py @@ -36,11 +36,12 @@ import itertools import re import sys +import textwrap import typing as t import weakref from docutils.statemachine import StringList -from sphinx.ext.autodoc import Documenter +from sphinx.ext.autodoc import Documenter, PropertyDocumenter if t.TYPE_CHECKING: from sphinx.application import Sphinx @@ -54,6 +55,7 @@ _UNSET = object() _CLASS_VAR_RE = re.compile(r"\s*(?:\w+\.)*ClassVar\b") _OBJECT_DIRECTIVE_RE = re.compile(r"^\s*\.\. (?:\w+:)?[\w-]+::\s+([^\s(=:]+)") +_RENDERED_FIELD_RE = re.compile(r"^\s*:(?:type|value):(?:\s|$)") class _ProcessedFields(t.NamedTuple): @@ -563,6 +565,190 @@ def _resolve_marked_fields( del lines[block_index] +def _strip_rendered_fields(body: list[str]) -> list[str]: + """Drop the ``:type:`` and ``:value:`` entries and surrounding blanks. + + Parameters + ---------- + body : list[str] + Description lines dedented to column zero. + + Returns + ------- + list[str] + The description alone. + + Examples + -------- + >>> _strip_rendered_fields(["Horizontal offset.", "", ":type: int"]) + ['Horizontal offset.'] + + >>> _strip_rendered_fields([":type: int", " wrapped", "Offset."]) + ['Offset.'] + """ + kept: list[str] = [] + dropping = False + for line in body: + if _RENDERED_FIELD_RE.match(line) is not None: + dropping = True + continue + if dropping: + if not line.strip() or line[:1].isspace(): + continue + dropping = False + kept.append(line) + while kept and not kept[0].strip(): + kept.pop(0) + while kept and not kept[-1].strip(): + kept.pop() + return kept + + +def _field_doc_bodies( + lines: t.Iterable[str], + marker_prefix: str, +) -> dict[str, list[str]]: + """Return the prose each marked field directive carries. + + The type and value are dropped: the member's own directive renders + both from the annotation itself, which is what a class variable's + entry cannot express. + + Parameters + ---------- + lines : typing.Iterable[str] + Generated reStructuredText holding marked field directives. + marker_prefix : str + Marker prefix identifying the current class documenter. + + Returns + ------- + dict[str, list[str]] + Description lines by field name, dedented to column zero. + + Examples + -------- + >>> _field_doc_bodies( + ... [ + ... " .. gp-sphinx-documented-field: demo.Item value", + ... " .. attribute:: value", + ... "", + ... " Horizontal offset.", + ... "", + ... " :type: int", + ... ], + ... ".. gp-sphinx-documented-field: demo.Item ", + ... ) + {'value': ['Horizontal offset.']} + """ + bodies: dict[str, list[str]] = {} + lines = list(lines) + for index, line in enumerate(lines): + stripped = line.lstrip() + if not stripped.startswith(marker_prefix): + continue + name = stripped[len(marker_prefix) :].strip() + marker_indent = len(line) - len(stripped) + directive_index = index + 1 + if directive_index >= len(lines) or not lines[ + directive_index + ].lstrip().startswith(_ATTRIBUTE_DIRECTIVE): + continue + + block: list[str] = [] + for candidate in lines[directive_index + 1 :]: + candidate_stripped = candidate.lstrip() + if ( + candidate_stripped + and len(candidate) - len(candidate_stripped) <= marker_indent + ): + break + block.append(candidate) + + body = _strip_rendered_fields(textwrap.dedent("\n".join(block)).splitlines()) + if body: + bodies[name] = body + return bodies + + +_ACTIVE_FIELD_DOCS: list[dict[str, list[str]]] = [] + + +def active_field_doc(name: str) -> list[str] | None: + """Return the ``Attributes`` prose for *name* on the class being rendered. + + Consulted by the attribute documenter while a class documents its + members, so a member autodoc renders concretely — a class variable, a + slot-free pseudo-field, an undocumented property — can carry the + description its owner's ``Attributes`` entry wrote. + + Parameters + ---------- + name : str + Bare member name under consideration. + + Returns + ------- + list[str] | None + Description lines, or ``None`` when nothing described *name*. + + Examples + -------- + >>> active_field_doc("value") is None + True + """ + if not _ACTIVE_FIELD_DOCS: + return None + return _ACTIVE_FIELD_DOCS[-1].get(name) + + +class FieldDocFallbackMixin: + """Describe a bare member from its owner's ``Attributes`` entry. + + A class variable, an init-only dataclass field, and a property + without a docstring all reach the page as a signature with nothing + beneath it: ``NonDataDescriptorMixin.get_doc`` returns ``None`` for a + plain class-level value, and an undocumented property has no + docstring to return. A ``#:`` source comment is the one description + autodoc already reattaches at that point. This does the same for the + description the owning class wrote, so the entry keeps the prose its + author supplied alongside the annotation and value only autodoc can + render. + """ + + objpath: list[str] + + def get_doc(self) -> list[list[str]] | None: + """Return the member's own docstring, or its owner's description. + + Returns + ------- + list[list[str]] | None + Docstring blocks, or ``None`` when nothing describes the member. + + Examples + -------- + >>> FieldDocFallbackMixin.get_doc # doctest: +ELLIPSIS + + """ + doc = t.cast(t.Any, super()).get_doc() + if doc and any(line.strip() for block in doc for line in block): + return t.cast("list[list[str]] | None", doc) + if not self.objpath: + return t.cast("list[list[str]] | None", doc) + fallback = active_field_doc(self.objpath[-1]) + if fallback is None: + return t.cast("list[list[str]] | None", doc) + return [list(fallback)] + + +class GpPropertyDocumenter(FieldDocFallbackMixin, PropertyDocumenter): # type: ignore[misc] + """``PropertyDocumenter`` that honors an owner's ``Attributes`` entry.""" + + objtype = "property" + priority = PropertyDocumenter.priority + 1 + + class _RenderedFieldsDocumenterMixin: """Resolve field ownership after the wrapped documenter renders members.""" @@ -592,7 +778,11 @@ def document_members(self, all_members: bool = False) -> None: if line.lstrip().startswith(marker_prefix) } member_start = len(result) - t.cast(t.Any, super()).document_members(all_members) + _ACTIVE_FIELD_DOCS.append(_field_doc_bodies(result, marker_prefix)) + try: + t.cast(t.Any, super()).document_members(all_members) + finally: + _ACTIVE_FIELD_DOCS.pop() rendered = _rendered_field_names( result.data[member_start:], self.objpath, @@ -837,6 +1027,7 @@ def register(app: Sphinx) -> None: >>> register # doctest: +ELLIPSIS """ + app.add_autodocumenter(GpPropertyDocumenter, override=True) app.connect("autodoc-skip-member", skip_documented_fields) app.connect( "autodoc-process-signature", diff --git a/tests/ext/typehints_gp/test_documented_fields.py b/tests/ext/typehints_gp/test_documented_fields.py index ebddce72..0865527d 100644 --- a/tests/ext/typehints_gp/test_documented_fields.py +++ b/tests/ext/typehints_gp/test_documented_fields.py @@ -13,6 +13,7 @@ from sphinx_autodoc_typehints_gp._documented_fields import ( _attribute_directive_names, + _field_doc_bodies, _is_declared_field, _own_annotations, ) @@ -92,6 +93,28 @@ def test_attribute_directive_names( assert _attribute_directive_names(lines) == expected +# --------------------------------------------------------------------------- +# _field_doc_bodies +# --------------------------------------------------------------------------- + + +_MARKER_PREFIX = ".. gp-sphinx-documented-field: demo.Item " + + +def test_field_doc_bodies_drops_the_trailing_type_field() -> None: + """A member's own directive supplies the type, so the body omits it.""" + lines = [ + " .. gp-sphinx-documented-field: demo.Item value", + " .. attribute:: value", + "", + " Horizontal offset.", + "", + " :type: int", + ] + + assert _field_doc_bodies(lines, _MARKER_PREFIX) == {"value": ["Horizontal offset."]} + + # --------------------------------------------------------------------------- # _is_declared_field # --------------------------------------------------------------------------- @@ -1048,6 +1071,39 @@ def render(self) -> str: return self.label registry: t.ClassVar[dict[str, str]] = {"kind": "namedtuple"} + + + @dataclasses.dataclass + class Ingest: + """A dataclass taking an init-only value. + + Attributes + ---------- + raw : str + Payload handed to the initializer only. + parsed : str + Payload after parsing. + """ + + raw: dataclasses.InitVar[str] + parsed: str = "" + + def __post_init__(self, raw: str) -> None: + self.parsed = raw.strip() + + + class Meter: + """A meter whose reading property carries no docstring. + + Attributes + ---------- + reading : int + Most recent meter reading. + """ + + @property + def reading(self) -> int: + return 1 ''' ) @@ -1067,6 +1123,10 @@ def render(self) -> str: .. autoclass:: non_field_members_demo.DataRecord .. autoclass:: non_field_members_demo.TupleRecord + + .. autoclass:: non_field_members_demo.Ingest + + .. autoclass:: non_field_members_demo.Meter """ ) @@ -1615,6 +1675,37 @@ def test_classvar_named_in_attributes_still_renders( ] == ["registry: ClassVar[dict[str, str]] = {}"] +@pytest.mark.integration +def test_classvar_named_in_attributes_keeps_its_description( + non_field_members_html_result: SharedSphinxResult, +) -> None: + """A documented ClassVar carries the description its entry wrote.""" + html = read_output(non_field_members_html_result, "index.html") + + assert "Backends registered so far." in html + + +@pytest.mark.integration +def test_init_var_named_in_attributes_keeps_its_description( + non_field_members_html_result: SharedSphinxResult, +) -> None: + """An init-only dataclass field carries its Attributes description.""" + html = read_output(non_field_members_html_result, "index.html") + + assert _attribute_names(non_field_members_html_result).count("Ingest.raw") == 1 + assert "Payload handed to the initializer only." in html + + +@pytest.mark.integration +def test_undocumented_property_keeps_its_description( + non_field_members_html_result: SharedSphinxResult, +) -> None: + """A property without a docstring falls back to its Attributes entry.""" + html = read_output(non_field_members_html_result, "index.html") + + assert "Most recent meter reading." in html + + @pytest.mark.integration def test_quoted_classvar_named_in_attributes_still_renders( non_field_members_html_result: SharedSphinxResult, From 032ccd9dc88faaf624de1d2ca011869b8e78ac83 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 08:14:15 -0500 Subject: [PATCH 02/22] typehints-gp(feat): Hide undescribed class vars why: undoc-members is on by default, so autodoc emits a stub for every class variable a class declares whether or not anything describes it. A reference page fills with bare annotations nobody wrote about -- pydantic's model_config on every model, a dataclass's dispatch metadata on every operation -- and the described members are lost in the run. what: - Skip a class variable with no Attributes entry and no #: comment - Add gp_typehints_show_undocumented_class_vars to restore them - Look up source comments the way autodoc does, walking the MRO through ModuleAnalyzer, so a described variable always renders --- .../_documented_fields.py | 84 ++++++++++++- .../sphinx_autodoc_typehints_gp/extension.py | 6 + .../typehints_gp/test_documented_fields.py | 115 ++++++++++++++++++ 3 files changed, 203 insertions(+), 2 deletions(-) diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py index f206d0be..ea304e61 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py @@ -41,7 +41,9 @@ import weakref from docutils.statemachine import StringList +from sphinx.errors import PycodeError from sphinx.ext.autodoc import Documenter, PropertyDocumenter +from sphinx.pycode import ModuleAnalyzer if t.TYPE_CHECKING: from sphinx.application import Sphinx @@ -419,6 +421,46 @@ def _is_declared_field(klass: type, name: str) -> bool: return not is_non_field_member +def _has_attribute_comment(klass: type, name: str) -> bool: + """Return whether a source comment or attribute docstring describes *name*. + + Mirrors the lookup autodoc performs in + ``AttributeDocumenter.get_attribute_comment``, so a class variable + described at its assignment -- including one a base class in another + package describes -- is never mistaken for an undescribed one. + + Parameters + ---------- + klass : type + Class being documented. + name : str + Member name under consideration. + + Returns + ------- + bool + Whether any class in the MRO carries a description for *name*. + + Examples + -------- + >>> _has_attribute_comment(_ProcessedFields, "names") + False + """ + for base in getattr(klass, "__mro__", (klass,)): + module = getattr(base, "__module__", None) + qualname = getattr(base, "__qualname__", None) + if not isinstance(module, str) or not isinstance(qualname, str): + continue + try: + analyzer = ModuleAnalyzer.for_module(module) + analyzer.analyze() + except PycodeError: + continue + if (qualname, name) in analyzer.attr_docs: + return True + return False + + def _mark_attribute_directives(lines: list[str], owner: str) -> None: """Mark field directives until autodoc finishes rendering members. @@ -952,6 +994,41 @@ def _clear_documented_fields( _PROCESSED_FIELDS.pop(app, None) +def _is_undescribed_class_var(app: Sphinx, klass: type, name: str) -> bool: + """Return whether *name* is a class variable nothing describes. + + A class variable carries no docstring of its own, so autodoc renders + it as an annotation and a value with no prose beneath. Withholding + that entry keeps a reference page to the members someone wrote about; + ``gp_typehints_show_undocumented_class_vars`` restores them. + + Parameters + ---------- + app : Sphinx + Sphinx application instance being built. + klass : type + Class whose members are being filtered. + name : str + Member name under consideration. + + Returns + ------- + bool + Whether the member is an undescribed class variable. + + Examples + -------- + >>> _is_undescribed_class_var # doctest: +ELLIPSIS + + """ + if getattr(app.config, "gp_typehints_show_undocumented_class_vars", False): + return False + annotations = _declared_annotations(klass) + if name not in annotations or not _is_class_var(annotations[name]): + return False + return not _has_attribute_comment(klass, name) + + def skip_documented_fields( app: Sphinx, what: str, @@ -1007,9 +1084,12 @@ def skip_documented_fields( if processed is None or processed.options is not options: return None - if not _is_declared_field(processed.owner, name): - return None if name not in processed.names: + if _is_undescribed_class_var(app, processed.owner, name): + return True + return None + + if not _is_declared_field(processed.owner, name): return None return True diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py index 3fcb6b8b..096b37a2 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py @@ -658,6 +658,12 @@ def _add_static_path(app: Sphinx) -> None: rebuild="env", types=frozenset({bool}), ) + app.add_config_value( + "gp_typehints_show_undocumented_class_vars", + default=False, + rebuild="env", + types=frozenset({bool}), + ) app.add_autodocumenter(GpDataDocumenter, override=True) app.add_autodocumenter(GpAttributeDocumenter, override=True) register_default_xref_transform(app) diff --git a/tests/ext/typehints_gp/test_documented_fields.py b/tests/ext/typehints_gp/test_documented_fields.py index 0865527d..96b027cc 100644 --- a/tests/ext/typehints_gp/test_documented_fields.py +++ b/tests/ext/typehints_gp/test_documented_fields.py @@ -1770,6 +1770,121 @@ def test_inherited_field_metadata_does_not_hide_overriding_members( assert f"Render the {kind} override." in html +# --------------------------------------------------------------------------- +# visibility of class variables nothing describes +# --------------------------------------------------------------------------- + +_CLASS_VAR_VISIBILITY_SOURCE = textwrap.dedent( + '''\ + from __future__ import annotations + + import typing as t + + + class Registry: + """A registry carrying class variables of every description state. + + Attributes + ---------- + described : str + Described by the Attributes section. + """ + + described: t.ClassVar[str] = "described-value" + + #: Described by a source comment. + commented: t.ClassVar[str] = "commented-value" + + undescribed: t.ClassVar[str] = "undescribed-value" + + plain_constant = "plain-value" + ''' +) + +_CLASS_VAR_VISIBILITY_INDEX_RST = textwrap.dedent( + """\ + Demo + ==== + + .. autoclass:: class_var_visibility_demo.Registry + """ +) + + +def _class_var_visibility_scenario(*, show_undocumented: bool) -> SphinxScenario: + """Return a scenario pinning the undocumented class-variable policy.""" + conf = _CONF_PY.replace("__SCENARIO_SRCDIR__", SCENARIO_SRCDIR_TOKEN) + if show_undocumented: + conf += "gp_typehints_show_undocumented_class_vars = True\n" + return SphinxScenario( + files=( + ScenarioFile("class_var_visibility_demo.py", _CLASS_VAR_VISIBILITY_SOURCE), + ScenarioFile("conf.py", conf, substitute_srcdir=True), + ScenarioFile("index.rst", _CLASS_VAR_VISIBILITY_INDEX_RST), + ), + ) + + +@pytest.fixture(scope="module") +def class_var_visibility_html_result( + tmp_path_factory: pytest.TempPathFactory, +) -> SharedSphinxResult: + """Build with the shipped undocumented class-variable policy.""" + return build_shared_sphinx_result( + tmp_path_factory.mktemp("class-var-visibility"), + _class_var_visibility_scenario(show_undocumented=False), + purge_modules=("class_var_visibility_demo",), + ) + + +@pytest.fixture(scope="module") +def shown_class_var_html_result( + tmp_path_factory: pytest.TempPathFactory, +) -> SharedSphinxResult: + """Build with undocumented class variables restored.""" + return build_shared_sphinx_result( + tmp_path_factory.mktemp("class-var-shown"), + _class_var_visibility_scenario(show_undocumented=True), + purge_modules=("class_var_visibility_demo",), + ) + + +@pytest.mark.integration +def test_undescribed_class_var_is_not_rendered( + class_var_visibility_html_result: SharedSphinxResult, +) -> None: + """A class variable nothing describes stays out of the reference.""" + assert ( + _attribute_names(class_var_visibility_html_result).count("Registry.undescribed") + == 0 + ) + + +@pytest.mark.integration +def test_described_class_vars_survive_the_visibility_default( + class_var_visibility_html_result: SharedSphinxResult, +) -> None: + """Only a class variable without any description is withheld.""" + attributes = _attribute_names(class_var_visibility_html_result) + html = read_output(class_var_visibility_html_result, "index.html") + + assert attributes.count("Registry.described") == 1 + assert attributes.count("Registry.commented") == 1 + assert attributes.count("Registry.plain_constant") == 1 + assert "Described by the Attributes section." in html + assert "Described by a source comment." in html + + +@pytest.mark.integration +def test_showing_undocumented_class_vars_restores_them( + shown_class_var_html_result: SharedSphinxResult, +) -> None: + """The opt-in brings a class variable nothing describes back.""" + assert ( + _attribute_names(shown_class_var_html_result).count("Registry.undescribed") == 1 + ) + + _CUSTOM_DOCUMENTERS_SOURCE = textwrap.dedent( """\ from __future__ import annotations From 068a65354353711b0fdaf3e8bf129b18b1c1d98c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 08:15:26 -0500 Subject: [PATCH 03/22] docs(typehints): Drop stale member exclusion why: AnnotationDisplay holds its fields in slots, and the exclusion predates the commit that taught the field matcher to recognize a slot-held field. Removing it leaves one description per field with no duplicate-description warning. what: - Document every AnnotationDisplay field through :members: alone --- docs/packages/sphinx-autodoc-typehints-gp/reference.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs/packages/sphinx-autodoc-typehints-gp/reference.md b/docs/packages/sphinx-autodoc-typehints-gp/reference.md index 5f2cabb5..6f0c05bb 100644 --- a/docs/packages/sphinx-autodoc-typehints-gp/reference.md +++ b/docs/packages/sphinx-autodoc-typehints-gp/reference.md @@ -35,13 +35,8 @@ registrations in .. autofunction:: sphinx_autodoc_typehints_gp.classify_annotation_display -.. Each field is already described by the class docstring's ``Attributes`` - section, which renders its own ``.. attribute::``. Excluding the fields - here leaves one description per object for the Python domain. - .. autoclass:: sphinx_autodoc_typehints_gp.AnnotationDisplay :members: - :exclude-members: text, is_literal_enum, literal_members ``` ## Extension entry point From 1752adaed5ff2722f8c2db71acfd541bfe5b5aa0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 09:32:53 -0500 Subject: [PATCH 04/22] typehints-gp(fix): Keep :no-index: out of prose why: A class documented with :no-index: has that option written into each attribute directive its Attributes section emits. Reattaching the description to the member carried the option along with it, so the member rendered a "No-index" field above the author's sentence. what: - Drop :no-index: and :noindex: alongside the type and value when lifting a description out of its directive --- .../_documented_fields.py | 12 ++++++++++-- tests/ext/typehints_gp/test_documented_fields.py | 13 +++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py index ea304e61..62239269 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py @@ -57,7 +57,7 @@ _UNSET = object() _CLASS_VAR_RE = re.compile(r"\s*(?:\w+\.)*ClassVar\b") _OBJECT_DIRECTIVE_RE = re.compile(r"^\s*\.\. (?:\w+:)?[\w-]+::\s+([^\s(=:]+)") -_RENDERED_FIELD_RE = re.compile(r"^\s*:(?:type|value):(?:\s|$)") +_RENDERED_FIELD_RE = re.compile(r"^\s*:(?:type|value|no-index|noindex):(?:\s|$)") class _ProcessedFields(t.NamedTuple): @@ -608,7 +608,12 @@ def _resolve_marked_fields( def _strip_rendered_fields(body: list[str]) -> list[str]: - """Drop the ``:type:`` and ``:value:`` entries and surrounding blanks. + """Drop what the member's own directive states, and surrounding blanks. + + The type and value are dropped because the member renders both from + the annotation itself. ``:no-index:`` is dropped because it is the + directive's own option: left in place it becomes a ``No-index`` field + in the reattached description. Parameters ---------- @@ -627,6 +632,9 @@ def _strip_rendered_fields(body: list[str]) -> list[str]: >>> _strip_rendered_fields([":type: int", " wrapped", "Offset."]) ['Offset.'] + + >>> _strip_rendered_fields([":no-index:", "", "Offset."]) + ['Offset.'] """ kept: list[str] = [] dropping = False diff --git a/tests/ext/typehints_gp/test_documented_fields.py b/tests/ext/typehints_gp/test_documented_fields.py index 96b027cc..341fb10c 100644 --- a/tests/ext/typehints_gp/test_documented_fields.py +++ b/tests/ext/typehints_gp/test_documented_fields.py @@ -101,6 +101,19 @@ def test_attribute_directive_names( _MARKER_PREFIX = ".. gp-sphinx-documented-field: demo.Item " +def test_field_doc_bodies_drops_the_directive_options() -> None: + """A directive's own options never become part of its description.""" + lines = [ + " .. gp-sphinx-documented-field: demo.Item value", + " .. attribute:: value", + " :no-index:", + "", + " Horizontal offset.", + ] + + assert _field_doc_bodies(lines, _MARKER_PREFIX) == {"value": ["Horizontal offset."]} + + def test_field_doc_bodies_drops_the_trailing_type_field() -> None: """A member's own directive supplies the type, so the body omits it.""" lines = [ From fbba774dbfd8ef0a236144790fbba799bfc300b8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 09:35:40 -0500 Subject: [PATCH 05/22] typehints-gp(fix): Ignore inherited docstrings why: A property overriding an inherited dataclass field has no docstring of its own, so autodoc walks the MRO and finds the base field's default value. The default is a str, so str.__doc__ arrived as though it described the property -- and, being non-empty, it counted as a description and displaced the author's Attributes entry. The rendered page showed "str(object='') -> str" where the author had written a sentence. what: - Use the Attributes description unless the member itself carries one, either as its own __doc__ or as a #: comment anywhere in the MRO - Cover a property that overrides an inherited dataclass field --- .../_documented_fields.py | 32 ++++++++++++++++++- .../typehints_gp/test_documented_fields.py | 32 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py index 62239269..fd924cfc 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py @@ -766,7 +766,36 @@ class FieldDocFallbackMixin: render. """ + object: t.Any objpath: list[str] + parent: t.Any + + def _describes_itself(self) -> bool: + """Return whether the docstring autodoc found belongs to this member. + + A member with no docstring of its own still receives one when + autodoc walks the MRO: a property overriding an inherited + dataclass field inherits the *default value's* docstring, so + ``str.__doc__`` arrives as though it described the property. A + ``#:`` comment is a description of this member wherever it sits + in the MRO, so it counts. + + Returns + ------- + bool + Whether a description of this member itself exists. + + Examples + -------- + >>> FieldDocFallbackMixin._describes_itself # doctest: +ELLIPSIS + + """ + own = getattr(self.object, "__doc__", None) + if isinstance(own, str) and own.strip(): + return True + if isinstance(self.parent, type) and self.objpath: + return _has_attribute_comment(self.parent, self.objpath[-1]) + return False def get_doc(self) -> list[list[str]] | None: """Return the member's own docstring, or its owner's description. @@ -782,7 +811,8 @@ def get_doc(self) -> list[list[str]] | None: """ doc = t.cast(t.Any, super()).get_doc() - if doc and any(line.strip() for block in doc for line in block): + described = bool(doc) and any(line.strip() for block in doc for line in block) + if described and self._describes_itself(): return t.cast("list[list[str]] | None", doc) if not self.objpath: return t.cast("list[list[str]] | None", doc) diff --git a/tests/ext/typehints_gp/test_documented_fields.py b/tests/ext/typehints_gp/test_documented_fields.py index 341fb10c..be736578 100644 --- a/tests/ext/typehints_gp/test_documented_fields.py +++ b/tests/ext/typehints_gp/test_documented_fields.py @@ -1105,6 +1105,25 @@ def __post_init__(self, raw: str) -> None: self.parsed = raw.strip() + @dataclasses.dataclass + class BaseLabel: + label: str = "" + + + class DerivedLabel(BaseLabel): + """A rule overriding a field with an undocumented property. + + Attributes + ---------- + label : str + Derived label documentation. + """ + + @property + def label(self) -> str: + return "derived" + + class Meter: """A meter whose reading property carries no docstring. @@ -1140,6 +1159,8 @@ def reading(self) -> int: .. autoclass:: non_field_members_demo.Ingest .. autoclass:: non_field_members_demo.Meter + + .. autoclass:: non_field_members_demo.DerivedLabel """ ) @@ -1709,6 +1730,17 @@ def test_init_var_named_in_attributes_keeps_its_description( assert "Payload handed to the initializer only." in html +@pytest.mark.integration +def test_inherited_docstring_does_not_replace_the_description( + non_field_members_html_result: SharedSphinxResult, +) -> None: + """A docstring inherited from a base value describes that value, not this.""" + html = read_output(non_field_members_html_result, "index.html") + + assert "Derived label documentation." in html + assert "str(object=" not in html + + @pytest.mark.integration def test_undocumented_property_keeps_its_description( non_field_members_html_result: SharedSphinxResult, From 8ed214c632926600a9d1b346ebe75a454289cb5c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 09:38:09 -0500 Subject: [PATCH 06/22] typehints-gp(fix): Keep method and class prose why: An Attributes entry naming a method, staticmethod, classmethod, or nested class had its directive deleted once autodoc rendered that member, and the description reattached nowhere -- the fallback reached only the attribute and property documenters. The author's sentence vanished with no warning. what: - Register a method documenter carrying the description fallback - Give the wrapped class documenters the same fallback, so a nested class keeps the entry describing it - Cover a method, a staticmethod, and a nested class --- .../_documented_fields.py | 17 +++++++- .../typehints_gp/test_documented_fields.py | 41 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py index fd924cfc..0cf110c1 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py @@ -42,7 +42,7 @@ from docutils.statemachine import StringList from sphinx.errors import PycodeError -from sphinx.ext.autodoc import Documenter, PropertyDocumenter +from sphinx.ext.autodoc import Documenter, MethodDocumenter, PropertyDocumenter from sphinx.pycode import ModuleAnalyzer if t.TYPE_CHECKING: @@ -829,6 +829,18 @@ class GpPropertyDocumenter(FieldDocFallbackMixin, PropertyDocumenter): # type: priority = PropertyDocumenter.priority + 1 +class GpMethodDocumenter(FieldDocFallbackMixin, MethodDocumenter): # type: ignore[misc] + """``MethodDocumenter`` that honors an owner's ``Attributes`` entry. + + An ``Attributes`` section is for attributes, so naming a method there + is a docstring the author should rework. Rendering the entry keeps + that decision theirs; deleting it silently does not. + """ + + objtype = "method" + priority = MethodDocumenter.priority + 1 + + class _RenderedFieldsDocumenterMixin: """Resolve field ownership after the wrapped documenter renders members.""" @@ -894,7 +906,7 @@ def _wrap_documenter(documenter: type[Documenter]) -> type[Documenter]: return documenter wrapped = type( f"RenderedFields{documenter.__name__}", - (_RenderedFieldsDocumenterMixin, documenter), + (_RenderedFieldsDocumenterMixin, FieldDocFallbackMixin, documenter), {"__module__": documenter.__module__}, ) return t.cast("type[Documenter]", wrapped) @@ -1146,6 +1158,7 @@ def register(app: Sphinx) -> None: """ app.add_autodocumenter(GpPropertyDocumenter, override=True) + app.add_autodocumenter(GpMethodDocumenter, override=True) app.connect("autodoc-skip-member", skip_documented_fields) app.connect( "autodoc-process-signature", diff --git a/tests/ext/typehints_gp/test_documented_fields.py b/tests/ext/typehints_gp/test_documented_fields.py index be736578..ed17cc29 100644 --- a/tests/ext/typehints_gp/test_documented_fields.py +++ b/tests/ext/typehints_gp/test_documented_fields.py @@ -1105,6 +1105,30 @@ def __post_init__(self, raw: str) -> None: self.parsed = raw.strip() + class Callables: + """A class whose Attributes name callables and a nested class. + + Attributes + ---------- + Handler : type + Nested handler documentation. + dispatch : Callable + Dispatch documentation. + build : Callable + Build documentation. + """ + + class Handler: + pass + + def dispatch(self, key: str) -> None: + pass + + @staticmethod + def build() -> None: + pass + + @dataclasses.dataclass class BaseLabel: label: str = "" @@ -1161,6 +1185,8 @@ def reading(self) -> int: .. autoclass:: non_field_members_demo.Meter .. autoclass:: non_field_members_demo.DerivedLabel + + .. autoclass:: non_field_members_demo.Callables """ ) @@ -1730,6 +1756,21 @@ def test_init_var_named_in_attributes_keeps_its_description( assert "Payload handed to the initializer only." in html +@pytest.mark.integration +def test_described_callables_and_nested_classes_keep_their_prose( + non_field_members_html_result: SharedSphinxResult, +) -> None: + """An Attributes entry survives whatever kind of member it names.""" + html = read_output(non_field_members_html_result, "index.html") + + assert "Nested handler documentation." in html + assert "Dispatch documentation." in html + assert "Build documentation." in html + assert "duplicate object description" not in ( + non_field_members_html_result.warnings + ) + + @pytest.mark.integration def test_inherited_docstring_does_not_replace_the_description( non_field_members_html_result: SharedSphinxResult, From f6c6d84141a0882b9e02c50dd3da9dbb740cfdc7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 09:42:39 -0500 Subject: [PATCH 07/22] typehints-gp(fix): Render members with signatures why: A directive written from docstring text knows only what its author typed, so describing a member in an Attributes section cost the reader the annotation and value autodoc renders from the live class. An enum member showed READONLY instead of READONLY = 'readonly', and a plain constant lost its value entirely -- documenting a member made its entry worse than leaving it undocumented. what: - Have autodoc compute :type: and :value: for each surviving field directive, through the registered attribute documenter and the real class, and fold them into the emitted block - Drop the authored type from the body once autodoc supplies a real one - Leave a type alias alone; autodoc documents one as a class and prints "alias of X" rather than a value - Cover the value curation, :meta hide-value:, and absent-member paths --- .../_documented_fields.py | 282 +++++++++++++++++- .../typehints_gp/test_documented_fields.py | 203 +++++++++++++ 2 files changed, 475 insertions(+), 10 deletions(-) diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py index 0cf110c1..1b74f9e2 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py @@ -26,6 +26,16 @@ fallback only when a concrete directive was actually emitted. The member names come from the exact processed class docstring, so other docstring and skip handlers remain authoritative. + +A directive written from docstring text knows only what its author typed, so +on its own it would drop the annotation and the value autodoc renders from +the live class. Every field directive that survives therefore has autodoc +compute its own ``:type:`` and ``:value:`` header options, through the +registered attribute documenter and the real class, and folds them into the +emitted block. Describing a member costs the reader nothing: a plain constant +keeps ``= 30``, a dataclass field keeps ``: int = 0``, and a slot, a +defaultless field, and a typed-dictionary key keep the value autodoc withholds +for them. """ from __future__ import annotations @@ -57,7 +67,8 @@ _UNSET = object() _CLASS_VAR_RE = re.compile(r"\s*(?:\w+\.)*ClassVar\b") _OBJECT_DIRECTIVE_RE = re.compile(r"^\s*\.\. (?:\w+:)?[\w-]+::\s+([^\s(=:]+)") -_RENDERED_FIELD_RE = re.compile(r"^\s*:(?:type|value|no-index|noindex):(?:\s|$)") +_RENDERED_FIELD_RE = re.compile(r"^\s*:(?:type|value):(?:\s|$)") +_DIRECTIVE_OPTION_RE = re.compile(r"^\s*:(?:type|value|no-index|noindex):(?:\s|$)") class _ProcessedFields(t.NamedTuple): @@ -639,7 +650,7 @@ def _strip_rendered_fields(body: list[str]) -> list[str]: kept: list[str] = [] dropping = False for line in body: - if _RENDERED_FIELD_RE.match(line) is not None: + if _DIRECTIVE_OPTION_RE.match(line) is not None: dropping = True continue if dropping: @@ -841,16 +852,266 @@ class GpMethodDocumenter(FieldDocFallbackMixin, MethodDocumenter): # type: igno priority = MethodDocumenter.priority + 1 +class _DirectiveEnrichment(t.NamedTuple): + """Placement of autodoc's header options in a field directive block. + + Attributes + ---------- + insert_at : int + Block-relative index the option lines are inserted at. + option_lines : list[str] + Indented ``:type:`` and ``:value:`` lines to insert. + drop : tuple[int, int] + Half-open block-relative range of body lines the options replace. + """ + + insert_at: int + option_lines: list[str] + drop: tuple[int, int] + + +def _body_type_field(block: list[str], start: int) -> tuple[int, int]: + """Return the range holding the authored ``:type:`` field. + + Parameters + ---------- + block : list[str] + Directive line first, followed by its options and content. + start : int + First block index past the directive's own option lines. + + Returns + ------- + tuple[int, int] + Half-open range of the field, empty when the body carries none. + + Examples + -------- + >>> _body_type_field([".. attribute:: x", "", " :type: int", ""], 1) + (2, 3) + + >>> _body_type_field([".. attribute:: x", "", " Offset."], 1) + (1, 1) + """ + for index in range(start, len(block)): + line = block[index] + stripped = line.lstrip() + if not stripped.startswith(":type:"): + continue + width = len(line) - len(stripped) + end = index + 1 + while end < len(block): + candidate = block[end] + candidate_stripped = candidate.lstrip() + if ( + not candidate_stripped + or len(candidate) - len(candidate_stripped) <= width + ): + break + end += 1 + return (index, end) + return (start, start) + + +def _plan_enrichment( + block: list[str], + options: t.Sequence[str], +) -> _DirectiveEnrichment: + """Place autodoc's header options inside a field directive *block*. + + The options join whatever option block the directive already carries, at + that block's own indentation, so a manually written ``:no-index:`` keeps + the field list docutils parsed it into. An authored ``:type:`` in the body + is dropped only when autodoc supplies one, so a member autodoc has no + annotation for keeps the type its ``Attributes`` entry wrote. + + Parameters + ---------- + block : list[str] + Directive line first, followed by its options and content. + options : typing.Sequence[str] + Stripped header option lines autodoc emitted for the member. + + Returns + ------- + _DirectiveEnrichment + Where the options go and which body lines they replace. + + Examples + -------- + >>> _plan_enrichment( + ... [".. attribute:: x", "", " Offset.", "", " :type: int"], + ... [":type: int", ":value: 3"], + ... ) + _DirectiveEnrichment(insert_at=1, option_lines=[' :type: int', ' :value: 3'], drop=(4, 5)) + + >>> _plan_enrichment( + ... [".. attribute:: x", " :no-index:", "", " Offset."], + ... [":value: 3"], + ... ) + _DirectiveEnrichment(insert_at=2, option_lines=[' :value: 3'], drop=(2, 2)) + """ # noqa: E501 + directive = block[0] + indent = directive[: len(directive) - len(directive.lstrip())] + option_indent = f"{indent} " + run_end = 1 + while run_end < len(block): + candidate = block[run_end] + stripped = candidate.lstrip() + candidate_indent = candidate[: len(candidate) - len(stripped)] + if not stripped.startswith(":") or len(candidate_indent) <= len(indent): + break + if run_end == 1: + option_indent = candidate_indent + run_end += 1 + drop = (run_end, run_end) + if any(option.startswith(":type:") for option in options): + drop = _body_type_field(block, run_end) + return _DirectiveEnrichment( + insert_at=run_end, + option_lines=[f"{option_indent}{option}" for option in options], + drop=drop, + ) + + +def _member_header_options(owner: t.Any, name: str) -> list[str]: + """Return the header options autodoc would emit for *name*. + + The registered attribute documenter renders the header into a throwaway + result list, so its annotation lookup, its mock, slot, and hidden-value + gating, and this package's own ``:value:`` curation all apply exactly as + they would to a member autodoc rendered itself. A name the class does not + expose raises out of ``import_object`` and contributes nothing. + + Parameters + ---------- + owner : typing.Any + Class documenter whose docstring emitted the field directive. + name : str + Bare member name the directive describes. + + Returns + ------- + list[str] + Stripped ``:type:`` and ``:value:`` lines, empty when neither applies. + + Examples + -------- + >>> _member_header_options # doctest: +ELLIPSIS + + """ + documenter = t.cast( + "type[Documenter] | None", + owner.documenters.get("attribute"), + ) + if documenter is None or not name.isidentifier(): + return [] + member = documenter( + owner.directive, + f"{owner.modname}::" + ".".join((*owner.objpath, name)), + owner.indent, + ) + bridge = owner.directive + captured = StringList() + result = bridge.result + bridge.result = captured + try: + if member.parse_name() and member.import_object(raiseerror=True): + if isinstance(member.object, t.TypeVar | t.NewType): + # Autodoc documents a type alias as a class and prints + # "alias of X" rather than a value; its raw repr is not + # something it would ever render. + return [] + member.add_directive_header("") + except (AttributeError, ImportError, TypeError, ValueError): + return [] + finally: + bridge.result = result + return [ + line.strip() for line in captured if _RENDERED_FIELD_RE.match(line) is not None + ] + + +def _enrich_marked_fields( + owner: t.Any, + lines: StringList, + marker_prefix: str, + names: t.Container[str], +) -> None: + """Give each surviving field directive autodoc's type and value. + + Parameters + ---------- + owner : typing.Any + Class documenter whose docstring emitted the field directives. + lines : docutils.statemachine.StringList + Generated autodoc reStructuredText, modified in place. + marker_prefix : str + Marker prefix identifying the current class documenter. + names : typing.Container[str] + Field names no concrete member directive replaced. + + Examples + -------- + >>> _enrich_marked_fields # doctest: +ELLIPSIS + + """ + index = 0 + while index < len(lines): + stripped = lines[index].lstrip() + if not stripped.startswith(marker_prefix): + index += 1 + continue + + name = stripped[len(marker_prefix) :].strip() + directive_index = index + 1 + index = directive_index + if name not in names or directive_index >= len(lines): + continue + directive = lines[directive_index] + if not directive.lstrip().startswith(_ATTRIBUTE_DIRECTIVE): + continue + + width = len(directive) - len(directive.lstrip()) + block_end = directive_index + 1 + while block_end < len(lines): + candidate = lines[block_end] + candidate_stripped = candidate.lstrip() + if candidate_stripped and len(candidate) - len(candidate_stripped) <= width: + break + block_end += 1 + + options = _member_header_options(owner, name) + if not options: + index = block_end + continue + + plan = _plan_enrichment(list(lines.data[directive_index:block_end]), options) + source, offset = lines.info(directive_index) + for position in reversed(range(*plan.drop)): + del lines[directive_index + position] + for position, option in enumerate(plan.option_lines): + lines.insert( + directive_index + plan.insert_at + position, + option, + source, + offset or 0, + ) + index = block_end + len(plan.option_lines) - (plan.drop[1] - plan.drop[0]) + + class _RenderedFieldsDocumenterMixin: """Resolve field ownership after the wrapped documenter renders members.""" directive: t.Any + documenters: dict[str, type[Documenter]] fullname: str indent: str + modname: str objpath: list[str] def document_members(self, all_members: bool = False) -> None: - """Render members, then remove only replaced field directives. + """Render members, then enrich or remove each field directive. Parameters ---------- @@ -873,15 +1134,16 @@ def document_members(self, all_members: bool = False) -> None: _ACTIVE_FIELD_DOCS.append(_field_doc_bodies(result, marker_prefix)) try: t.cast(t.Any, super()).document_members(all_members) + rendered = _rendered_field_names( + result.data[member_start:], + self.objpath, + candidates, + self.indent, + ) + _enrich_marked_fields(self, result, marker_prefix, candidates - rendered) + _resolve_marked_fields(result, self.fullname, rendered) finally: _ACTIVE_FIELD_DOCS.pop() - rendered = _rendered_field_names( - result.data[member_start:], - self.objpath, - candidates, - self.indent, - ) - _resolve_marked_fields(result, self.fullname, rendered) def _wrap_documenter(documenter: type[Documenter]) -> type[Documenter]: diff --git a/tests/ext/typehints_gp/test_documented_fields.py b/tests/ext/typehints_gp/test_documented_fields.py index ed17cc29..9b44c945 100644 --- a/tests/ext/typehints_gp/test_documented_fields.py +++ b/tests/ext/typehints_gp/test_documented_fields.py @@ -13,9 +13,11 @@ from sphinx_autodoc_typehints_gp._documented_fields import ( _attribute_directive_names, + _DirectiveEnrichment, _field_doc_bodies, _is_declared_field, _own_annotations, + _plan_enrichment, ) from tests._sphinx_scenarios import ( SCENARIO_SRCDIR_TOKEN, @@ -128,6 +130,93 @@ def test_field_doc_bodies_drops_the_trailing_type_field() -> None: assert _field_doc_bodies(lines, _MARKER_PREFIX) == {"value": ["Horizontal offset."]} +# --------------------------------------------------------------------------- +# _plan_enrichment +# --------------------------------------------------------------------------- + + +class _PlanEnrichmentFixture(t.NamedTuple): + """Test case for _plan_enrichment(). + + Attributes + ---------- + test_id : str + Short identifier used as the pytest parameter id. + block : list[str] + Directive line first, followed by its options and content. + options : list[str] + Stripped header option lines autodoc emitted for the member. + expected : _DirectiveEnrichment + Placement the planner is expected to choose. + """ + + test_id: str + block: list[str] + options: list[str] + expected: _DirectiveEnrichment + + +_PLAN_ENRICHMENT_FIXTURES: list[_PlanEnrichmentFixture] = [ + _PlanEnrichmentFixture( + test_id="type-replaces-body-field", + block=[".. attribute:: x", "", " Offset.", "", " :type: int"], + options=[":type: int", ":value: 3"], + expected=_DirectiveEnrichment( + insert_at=1, + option_lines=[" :type: int", " :value: 3"], + drop=(4, 5), + ), + ), + _PlanEnrichmentFixture( + test_id="value-alone-keeps-body-field", + block=[".. attribute:: x", "", " Offset.", "", " :type: Safety"], + options=[":value: 'readonly'"], + expected=_DirectiveEnrichment( + insert_at=1, + option_lines=[" :value: 'readonly'"], + drop=(1, 1), + ), + ), + _PlanEnrichmentFixture( + test_id="joins-existing-option-block", + block=[".. attribute:: x", " :no-index:", "", " Offset."], + options=[":type: int"], + expected=_DirectiveEnrichment( + insert_at=2, + option_lines=[" :type: int"], + drop=(2, 2), + ), + ), + _PlanEnrichmentFixture( + test_id="indented-block", + block=[ + " .. attribute:: x", + "", + " Offset.", + "", + " :type: int", + " wrapped", + ], + options=[":type: int"], + expected=_DirectiveEnrichment( + insert_at=1, + option_lines=[" :type: int"], + drop=(4, 6), + ), + ), +] + + +@pytest.mark.parametrize( + "case", + _PLAN_ENRICHMENT_FIXTURES, + ids=lambda case: case.test_id, +) +def test_plan_enrichment(case: _PlanEnrichmentFixture) -> None: + """Header options join the option block and replace an authored type.""" + assert _plan_enrichment(case.block, case.options) == case.expected + + # --------------------------------------------------------------------------- # _is_declared_field # --------------------------------------------------------------------------- @@ -502,6 +591,15 @@ def _attribute_names(result: SharedSphinxResult) -> list[str]: ] +def _attribute_signatures(result: SharedSphinxResult) -> dict[str, str]: + """Return the rendered signature of every attribute description.""" + return { + member.fullname: member.signature + for member in _described_members(get_doctree(result, "index")) + if member.objtype == "attribute" + } + + _CONF_PY = textwrap.dedent( """\ from __future__ import annotations @@ -930,6 +1028,21 @@ def test_documented_typeddict_keys_keep_their_descriptions( ) +@pytest.mark.integration +def test_documented_fields_keep_their_annotation_and_value( + documented_fields_html_result: SharedSphinxResult, +) -> None: + """Describing a field costs it none of what autodoc renders.""" + signatures = _attribute_signatures(documented_fields_html_result) + + assert signatures["Point.x"] == "x: int" + assert signatures["Rule.label"] == "label: str" + assert signatures["Rule.weight"] == "weight: int = 0" + assert signatures["Gauge.reading"] == "reading: int" + assert signatures["Gauge.scale"] == "scale: int" + assert signatures["Options.retries"] == "retries: int" + + @pytest.mark.integration def test_undocumented_field_still_renders( documented_fields_html_result: SharedSphinxResult, @@ -1105,6 +1218,21 @@ def __post_init__(self, raw: str) -> None: self.parsed = raw.strip() + class Aliases: + """A class whose Attributes name type aliases. + + Attributes + ---------- + Token : TypeVar + Token alias documentation. + UserId : NewType + User id alias documentation. + """ + + Token = t.TypeVar("Token") + UserId = t.NewType("UserId", int) + + class Callables: """A class whose Attributes name callables and a nested class. @@ -1160,6 +1288,33 @@ class Meter: @property def reading(self) -> int: return 1 + + + class Curated: + """Plain fields whose values autodoc curates or withholds. + + Attributes + ---------- + HUGE : list[str] + Constant too long to print in a signature. + SECRET : str + Value the entry withholds. + + :meta hide-value: + """ + + HUGE = ["x" * 60] * 20 + SECRET = "s3cret" + + + class Absent: + """A class describing a member it never declares. + + Attributes + ---------- + ghost : int + Description of a member nothing exposes. + """ ''' ) @@ -1187,6 +1342,12 @@ def reading(self) -> int: .. autoclass:: non_field_members_demo.DerivedLabel .. autoclass:: non_field_members_demo.Callables + + .. autoclass:: non_field_members_demo.Aliases + + .. autoclass:: non_field_members_demo.Curated + + .. autoclass:: non_field_members_demo.Absent """ ) @@ -1658,6 +1819,10 @@ def test_documented_runtime_data_attribute_is_described_once( assert attributes.count("RuntimeDefaults.timeout") == 1 assert "Maximum wait in seconds." in html + assert ( + _attribute_signatures(non_field_members_html_result)["RuntimeDefaults.timeout"] + == "timeout = 30" + ) assert "duplicate object description" not in ( non_field_members_html_result.warnings ) @@ -1756,6 +1921,17 @@ def test_init_var_named_in_attributes_keeps_its_description( assert "Payload handed to the initializer only." in html +@pytest.mark.integration +def test_described_type_alias_gains_no_value( + non_field_members_html_result: SharedSphinxResult, +) -> None: + """A type alias renders no value, as autodoc renders none for one.""" + signatures = _attribute_signatures(non_field_members_html_result) + + assert signatures["Aliases.Token"] == "Token" + assert signatures["Aliases.UserId"] == "UserId" + + @pytest.mark.integration def test_described_callables_and_nested_classes_keep_their_prose( non_field_members_html_result: SharedSphinxResult, @@ -1771,6 +1947,33 @@ def test_described_callables_and_nested_classes_keep_their_prose( ) +@pytest.mark.integration +def test_documented_field_values_stay_curated_and_hidden( + non_field_members_html_result: SharedSphinxResult, +) -> None: + """An enriched value passes through the gating autodoc applies to its own.""" + signatures = _attribute_signatures(non_field_members_html_result) + + assert signatures["Curated.HUGE"] == "HUGE = <...truncated, 1280 chars>" + assert signatures["Curated.SECRET"] == "SECRET" + + +@pytest.mark.integration +def test_field_describing_an_absent_member_renders_quietly( + non_field_members_html_result: SharedSphinxResult, +) -> None: + """A described name the class never exposes gains no header and no warning.""" + signatures = _attribute_signatures(non_field_members_html_result) + + assert signatures["Absent.ghost"] == "ghost" + assert "Description of a member nothing exposes." in read_output( + non_field_members_html_result, "index.html" + ) + assert "non_field_members_demo.Absent.ghost" not in ( + non_field_members_html_result.warnings + ) + + @pytest.mark.integration def test_inherited_docstring_does_not_replace_the_description( non_field_members_html_result: SharedSphinxResult, From d4b8a0b6cff94871035ca8bf0b3086899f64ad3d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 09:56:56 -0500 Subject: [PATCH 08/22] typehints-gp(test): Match autodoc's own signatures why: The assertions guarding this behavior spell out an expected signature per shape, so they restate what autodoc does rather than check against it. A change in how autodoc renders a shape would need the table edited to match, which is the kind of test that drifts in lockstep with the code it guards. what: - Build one set of class bodies twice, described and undescribed, and require every member's rendered signature to match between them - Cover the enum, constant, generic alias, dataclass, slot, named tuple, typed dictionary, and class variable shapes in one table --- .../typehints_gp/test_documented_fields.py | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/tests/ext/typehints_gp/test_documented_fields.py b/tests/ext/typehints_gp/test_documented_fields.py index 9b44c945..0d98b79f 100644 --- a/tests/ext/typehints_gp/test_documented_fields.py +++ b/tests/ext/typehints_gp/test_documented_fields.py @@ -2174,6 +2174,188 @@ def test_showing_undocumented_class_vars_restores_them( ) +# --------------------------------------------------------------------------- +# a described member renders the signature autodoc would have rendered +# --------------------------------------------------------------------------- + +_PARITY_BODIES = """\ +class Safety(enum.Enum): + \"\"\"A shape under test.{safety} + \"\"\" + + READONLY = "readonly" + + +class Tier(enum.StrEnum): + \"\"\"A shape under test.{tier} + \"\"\" + + BASIC = "basic" + + +class Constants: + \"\"\"A shape under test.{constants} + \"\"\" + + bare = 30 + annotated: int = 3 + alias = dict[str, int] + + +@dataclasses.dataclass +class Rule: + \"\"\"A shape under test.{rule} + \"\"\" + + required: str + defaulted: int = 0 + + +@dataclasses.dataclass(slots=True) +class Gauge: + \"\"\"A shape under test.{gauge} + \"\"\" + + reading: int = 1 + + +class Span(t.NamedTuple): + \"\"\"A shape under test.{span} + \"\"\" + + start: int + stop: int = 10 + + +class Payload(t.TypedDict): + \"\"\"A shape under test.{payload} + \"\"\" + + label: str + nickname: t.NotRequired[str] + + +class Registry: + \"\"\"A shape under test.{registry} + \"\"\" + + lookup: t.ClassVar[dict[str, str]] = {{}} +""" + + +def _parity_section(entries: tuple[tuple[str, str, str], ...]) -> str: + """Return a NumPy ``Attributes`` section describing *entries*.""" + lines = ["", "", " Attributes", " ----------"] + for name, type_, description in entries: + lines.append(f" {name} : {type_}") + lines.append(f" {description}") + return "\n".join(lines) + + +_PARITY_ENTRIES: dict[str, tuple[tuple[str, str, str], ...]] = { + "safety": (("READONLY", "Safety", "Read-only tier."),), + "tier": (("BASIC", "Tier", "Basic tier."),), + "constants": ( + ("bare", "int", "Unannotated constant."), + ("annotated", "int", "Annotated constant."), + ("alias", "type", "Generic alias."), + ), + "rule": ( + ("required", "str", "Field with no default."), + ("defaulted", "int", "Field with a default."), + ), + "gauge": (("reading", "int", "Slot-held field."),), + "span": ( + ("start", "int", "Tuple field."), + ("stop", "int", "Tuple field with a default."), + ), + "payload": ( + ("label", "str", "Required key."), + ("nickname", "str", "Not-required key."), + ), + "registry": (("lookup", "dict[str, str]", "Class variable."),), +} + +_PARITY_PREAMBLE = textwrap.dedent( + """\ + from __future__ import annotations + + import dataclasses + import enum + import typing as t + + + """ +) + + +def _parity_source(*, described: bool) -> str: + """Return one parity module, with or without its Attributes sections.""" + sections = { + key: (_parity_section(entries) if described else "") + for key, entries in _PARITY_ENTRIES.items() + } + return _PARITY_PREAMBLE + _PARITY_BODIES.format(**sections) + + +_PARITY_CLASSES = ( + "Safety", + "Tier", + "Constants", + "Rule", + "Gauge", + "Span", + "Payload", + "Registry", +) + + +@pytest.fixture(scope="module") +def signature_parity_html_result( + tmp_path_factory: pytest.TempPathFactory, +) -> SharedSphinxResult: + """Build described and undescribed copies of one set of class bodies.""" + index = "Demo\n====\n\n" + "\n".join( + f".. autoclass:: parity_{flavor}_demo.{name}\n" + for flavor in ("described", "plain") + for name in _PARITY_CLASSES + ) + conf = _CONF_PY.replace("__SCENARIO_SRCDIR__", SCENARIO_SRCDIR_TOKEN) + conf += "gp_typehints_show_undocumented_class_vars = True\n" + scenario = SphinxScenario( + files=( + ScenarioFile("parity_described_demo.py", _parity_source(described=True)), + ScenarioFile("parity_plain_demo.py", _parity_source(described=False)), + ScenarioFile("conf.py", conf, substitute_srcdir=True), + ScenarioFile("index.rst", index), + ), + ) + return build_shared_sphinx_result( + tmp_path_factory.mktemp("signature-parity"), + scenario, + purge_modules=("parity_described_demo", "parity_plain_demo"), + ) + + +@pytest.mark.integration +def test_describing_a_member_keeps_the_signature_autodoc_renders( + signature_parity_html_result: SharedSphinxResult, +) -> None: + """Describing a member costs the reader nothing autodoc would show.""" + doctree = get_doctree(signature_parity_html_result, "index") + rendered: dict[str, dict[str, str]] = {"described": {}, "plain": {}} + for signature in doctree.findall(addnodes.desc_signature): + if signature.parent.get("objtype") != "attribute": + continue + module = signature.get("module") or "" + flavor = module.removeprefix("parity_").removesuffix("_demo") + if flavor in rendered: + rendered[flavor][signature.get("fullname", "")] = signature.astext() + + assert rendered["plain"], "the undescribed copy rendered nothing to compare" + assert rendered["described"] == rendered["plain"] + + _CUSTOM_DOCUMENTERS_SOURCE = textwrap.dedent( """\ from __future__ import annotations From cc71217f5040529807ac77cf522e1b1320a31ce2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 10:12:23 -0500 Subject: [PATCH 09/22] docs(fields): Describe the remaining shape fields why: BadgeSpec and ApiFactRow documented their fields under Parameters, which describes the initializer rather than the attributes -- every field rendered with an empty description beneath it. Mode's members carried none at all. what: - Move the BadgeSpec and ApiFactRow entries to Attributes sections - Describe Mode's DEV and PROD members --- .../src/sphinx_ux_autodoc_layout/_sections.py | 2 +- .../sphinx-ux-badges/src/sphinx_ux_badges/_builders.py | 2 +- .../src/sphinx_vite_builder/_internal/config.py | 7 +++++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_sections.py b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_sections.py index 241f187a..bb71bb51 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_sections.py +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_sections.py @@ -41,7 +41,7 @@ class ApiFactRow: """Typed fact row rendered inside a shared ``gp-sphinx-api-facts`` section. - Parameters + Attributes ---------- label : str Field label displayed in the facts grid. diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_builders.py b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_builders.py index 352374ff..c3f16b7e 100644 --- a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_builders.py +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_builders.py @@ -29,7 +29,7 @@ class BadgeSpec: """Typed badge descriptor used by producer extensions. - Parameters + Attributes ---------- text : str Visible badge label. diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/config.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/config.py index 0acb6fef..197d3f45 100644 --- a/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/config.py +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/config.py @@ -23,6 +23,13 @@ class Mode(str, enum.Enum): `str` mixin so the value compares equal to the literal string the user wrote in ``conf.py``. + + Attributes + ---------- + DEV : Mode + Vite serves assets and rebuilds them as sources change. + PROD : Mode + Assets are built once and read from the manifest. """ DEV = "dev" From 679991ae0eeabbcf32e97dede6a31d88dec4b060 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 10:25:11 -0500 Subject: [PATCH 10/22] ai(rules[AGENTS]) Describe every class-level name why: The previous rule required an Attributes section on NamedTuple and dataclass classes. Both its scope and its stated consequence have since proven wrong. This is a rule we expect to port to other repositories, so the reasoning belongs here rather than inside the commit that enforces it. Scope: autodoc renders one entry per class-level name, whatever shape declares it. TypedDict keys, Enum members, ClassVars, InitVars, plain constants, and nested classes all reach the reference the same way. That list also kept growing while the rule was being applied -- InitVar and nested classes were both found after the fact -- so enumerating shapes dates faster than stating the principle. Consequence: the old rule said an undescribed field ships as "Alias for field number 0" or as a bare entry. An undescribed ClassVar is now withheld from the reference entirely, so it goes missing rather than rendering empty. That is a failure a reader cannot see and a reviewer will not spot in the diff. Styles: the workspace documents fields three ways and autodoc honors all three -- an Attributes entry, a docstring under the assignment, and a #: comment. A rule naming only Attributes marks correctly documented code as broken; two classes here use the docstring form. Trap: a Parameters section documents the initializer, not the attributes. BadgeSpec and ApiFactRow each described every field under Parameters and still rendered every attribute with an empty body. what: - Restate the rule around every class-level name autodoc renders - Name the three accepted styles, preferring Attributes - Warn that Parameters does not describe attributes - Correct what happens to an undescribed class variable --- AGENTS.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 99f10d6a..6486a692 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -518,8 +518,14 @@ type """ ``` -**Classes with fields** — `NamedTuple`, dataclasses — document every field in -an `Attributes` section: +**Class-level names** — every name a class declares that autodoc renders gets +exactly one description. That covers `NamedTuple` fields, dataclass fields +including `InitVar`, `TypedDict` keys, `Enum` members, `ClassVar`s, and plain +constants. The shape does not change the rule. + +Three styles count, and `tests/docs/test_docstring_policy.py` enforces them: a +NumPy `Attributes` entry in the class docstring, a docstring under the +assignment, or a `#:` comment above it. Prefer `Attributes`: ```python class ToctreeSection(t.NamedTuple): @@ -534,10 +540,12 @@ class ToctreeSection(t.NamedTuple): """ ``` -Autodoc renders every field whether or not you describe it, so an -undocumented `NamedTuple` field ships to the API docs as "Alias for field -number 0" and a dataclass field ships bare. Document all of them — a class -with three fields and two documented still ships a stub for the third. +A `Parameters` section does **not** describe attributes — it documents the +initializer, and the attribute entries still render bare beneath it. + +A field nobody describes reaches the reference as a bare name. A `ClassVar` +nobody describes is withheld from it entirely, so an undescribed one is +silently missing rather than visibly empty. ### Doctests From 004046629a97f8063bcbf86e962e98136df376cf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 10:25:11 -0500 Subject: [PATCH 11/22] tests(docs): Enforce class-level name descriptions why: Nothing enforced the field-documentation rule. tests/docs held policy tests for hand-authored pages only, so the docstrings the API reference is generated from went unchecked -- and three classes had fields nothing described. what: - Walk every public NamedTuple, dataclass, TypedDict, and Enum under packages/ and fail on a field no supported style describes - Accept an Attributes entry, a docstring under the assignment, or a #: comment, since the workspace uses more than one - Report the offending class and field names in the failure message --- tests/docs/test_docstring_policy.py | 235 ++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 tests/docs/test_docstring_policy.py diff --git a/tests/docs/test_docstring_policy.py b/tests/docs/test_docstring_policy.py new file mode 100644 index 00000000..5c79af00 --- /dev/null +++ b/tests/docs/test_docstring_policy.py @@ -0,0 +1,235 @@ +"""Policy tests for the docstrings the API reference is generated from. + +Sibling to :mod:`tests.docs.test_docs_policy`, which polices hand-authored +pages. This module polices the Python source those pages render from. +""" + +from __future__ import annotations + +import ast +import pathlib +import typing as t + +import pytest + +_PACKAGES = pathlib.Path(__file__).resolve().parents[2] / "packages" +_NUMPY_SECTIONS = frozenset( + { + "Examples", + "Notes", + "Parameters", + "Raises", + "References", + "Returns", + "See Also", + "Yields", + } +) + + +class ShapeClass(t.NamedTuple): + """A class whose fields autodoc renders one description each for. + + Attributes + ---------- + test_id : str + Short identifier used as the pytest parameter id. + path : pathlib.Path + File the class is declared in. + name : str + Class name. + undescribed : tuple[str, ...] + Public fields carrying no description in any supported style. + """ + + test_id: str + path: pathlib.Path + name: str + undescribed: tuple[str, ...] + + +def _attributes_section(docstring: str | None) -> frozenset[str]: + r"""Return the names a NumPy ``Attributes`` section describes. + + Parameters + ---------- + docstring : str | None + Class docstring, already dedented by :func:`ast.get_docstring`. + + Returns + ------- + frozenset[str] + Names carried by entries of the ``Attributes`` section. + + Examples + -------- + >>> section = "Summary.\n\nAttributes\n----------\nx : int\n Offset." + >>> _attributes_section(section) + frozenset({'x'}) + + >>> _attributes_section("Summary.") + frozenset() + """ + if not docstring: + return frozenset() + lines = docstring.splitlines() + described: set[str] = set() + inside = False + for index, line in enumerate(lines): + stripped = line.strip() + if stripped == "Attributes": + following = lines[index + 1].strip() if index + 1 < len(lines) else "" + inside = bool(following) and set(following) == {"-"} + continue + if not inside: + continue + if stripped in _NUMPY_SECTIONS: + break + if " : " in stripped: + described.add(stripped.split(" : ")[0].strip()) + return frozenset(described) + + +def _is_shape(node: ast.ClassDef) -> bool: + """Return whether *node* declares a shape autodoc renders fields for. + + Parameters + ---------- + node : ast.ClassDef + Class definition under consideration. + + Returns + ------- + bool + Whether the class is a NamedTuple, TypedDict, dataclass, or Enum. + + Examples + -------- + >>> _is_shape(ast.parse("class A(t.NamedTuple): pass").body[0]) + True + + >>> _is_shape(ast.parse("class A: pass").body[0]) + False + """ + bases = " ".join(ast.unparse(base) for base in node.bases) + decorators = " ".join(ast.unparse(deco) for deco in node.decorator_list) + return "dataclass" in decorators or any( + shape in bases for shape in ("NamedTuple", "TypedDict", "Enum") + ) + + +def _undescribed_fields(node: ast.ClassDef, source_lines: list[str]) -> tuple[str, ...]: + r"""Return public fields *node* declares with no description anywhere. + + Three styles count, because autodoc honors all three and the workspace + uses more than one: a NumPy ``Attributes`` entry, a PEP 224 string + literal after the assignment, and a ``#:`` comment above it. + + Parameters + ---------- + node : ast.ClassDef + Class definition under consideration. + source_lines : list[str] + Lines of the file the class is declared in. + + Returns + ------- + tuple[str, ...] + Field names carrying no description, in declaration order. + + Examples + -------- + >>> source = "class A:\n x: int\n y: int\n #: Doc.\n z: int\n" + >>> _undescribed_fields(ast.parse(source).body[0], source.splitlines()) + ('x', 'y') + """ + described = _attributes_section(ast.get_docstring(node)) + undescribed: list[str] = [] + for index, statement in enumerate(node.body): + if isinstance(statement, ast.AnnAssign) and isinstance( + statement.target, ast.Name + ): + names = [statement.target.id] + elif isinstance(statement, ast.Assign): + names = [ + target.id + for target in statement.targets + if isinstance(target, ast.Name) + ] + else: + continue + + following = node.body[index + 1] if index + 1 < len(node.body) else None + has_attribute_docstring = ( + isinstance(following, ast.Expr) + and isinstance(following.value, ast.Constant) + and isinstance(following.value.value, str) + ) + above = statement.lineno - 2 + has_comment = above >= 0 and source_lines[above].strip().startswith("#:") + + for name in names: + if name.startswith("_") or name in described: + continue + if has_attribute_docstring or has_comment: + continue + undescribed.append(name) + return tuple(undescribed) + + +def _shape_classes() -> list[ShapeClass]: + """Return every public shape class declared under ``packages/``.""" + found: list[ShapeClass] = [] + for path in sorted(_PACKAGES.rglob("*.py")): + if "tests" in path.parts: + continue + source = path.read_text() + try: + tree = ast.parse(source) + except SyntaxError: # pragma: no cover - a package that cannot parse + continue + source_lines = source.splitlines() + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef) or node.name.startswith("_"): + continue + if not _is_shape(node): + continue + found.append( + ShapeClass( + test_id=f"{path.stem}.{node.name}", + path=path, + name=node.name, + undescribed=_undescribed_fields(node, source_lines), + ) + ) + return found + + +_SHAPE_CLASSES = _shape_classes() + + +def test_shape_classes_are_discovered() -> None: + """The walk finds the workspace's shape classes rather than nothing.""" + assert len(_SHAPE_CLASSES) > 20 + + +@pytest.mark.parametrize( + "shape", + _SHAPE_CLASSES, + ids=[shape.test_id for shape in _SHAPE_CLASSES], +) +def test_every_shape_field_is_described(shape: ShapeClass) -> None: + """Autodoc renders one entry per field, so every field needs prose. + + A field nobody describes reaches the API reference as a bare name, and + a class variable nobody describes is withheld from it entirely. Either + way the reader is worse off than if the class had not been documented + at all. + """ + relative = shape.path.relative_to(_PACKAGES.parent) + assert not shape.undescribed, ( + f"{relative}:{shape.name} declares fields nothing describes: " + f"{', '.join(shape.undescribed)}. Describe each in the class " + f"docstring's Attributes section, in a docstring under the " + f"assignment, or in a #: comment above it." + ) From cba967afb7e3dda0ac3ce70c9c4b01f222240e23 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 14:01:15 -0500 Subject: [PATCH 12/22] typehints-gp(fix): Honor a consumer's skip decision why: autodoc-skip-member stops at the first listener returning a decision, and this extension connected at the default priority, so it answered before a project's own conf.py handler. A project could not re-include a class variable the extension withholds -- returning False was silently ignored, which the module docstring already promised would not happen. what: - Answer after the default priority, leaving a project's explicit decision authoritative - Cover a handler returning False for a member the extension withholds --- .../_documented_fields.py | 11 ++- .../typehints_gp/test_documented_fields.py | 78 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py index 1b74f9e2..707aec51 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py @@ -68,6 +68,11 @@ _CLASS_VAR_RE = re.compile(r"\s*(?:\w+\.)*ClassVar\b") _OBJECT_DIRECTIVE_RE = re.compile(r"^\s*\.\. (?:\w+:)?[\w-]+::\s+([^\s(=:]+)") _RENDERED_FIELD_RE = re.compile(r"^\s*:(?:type|value):(?:\s|$)") +# ``autodoc-skip-member`` stops at the first listener returning a decision, and +# a project's own ``conf.py`` connects at the default priority. Answering after +# it leaves the project's explicit decision authoritative, which is what this +# module's docstring already promises. +_LATE_SKIP_PRIORITY = 800 _DIRECTIVE_OPTION_RE = re.compile(r"^\s*:(?:type|value|no-index|noindex):(?:\s|$)") @@ -1421,7 +1426,11 @@ def register(app: Sphinx) -> None: """ app.add_autodocumenter(GpPropertyDocumenter, override=True) app.add_autodocumenter(GpMethodDocumenter, override=True) - app.connect("autodoc-skip-member", skip_documented_fields) + app.connect( + "autodoc-skip-member", + skip_documented_fields, + priority=_LATE_SKIP_PRIORITY, + ) app.connect( "autodoc-process-signature", _clear_documented_fields, diff --git a/tests/ext/typehints_gp/test_documented_fields.py b/tests/ext/typehints_gp/test_documented_fields.py index 0d98b79f..c85e94df 100644 --- a/tests/ext/typehints_gp/test_documented_fields.py +++ b/tests/ext/typehints_gp/test_documented_fields.py @@ -2174,6 +2174,84 @@ def test_showing_undocumented_class_vars_restores_them( ) +# --------------------------------------------------------------------------- +# a consumer's explicit skip decision +# --------------------------------------------------------------------------- + +_CONSUMER_SKIP_SOURCE = textwrap.dedent( + '''\ + from __future__ import annotations + + import typing as t + + + class Registry: + """A registry carrying a class variable nothing describes. + + Attributes + ---------- + described : str + Described class variable. + """ + + described: t.ClassVar[str] = "described-value" + + undescribed: t.ClassVar[str] = "undescribed-value" + ''' +) + +_CONSUMER_SKIP_CONF_EXTRA = textwrap.dedent( + '''\ + + def _keep_undescribed(app, what, name, obj, skip, options): + """Refuse to withhold one member the extension would drop.""" + if name == "undescribed": + return False + return None + + + def setup(app): + app.connect("autodoc-skip-member", _keep_undescribed) + ''' +) + + +@pytest.fixture(scope="module") +def consumer_skip_html_result( + tmp_path_factory: pytest.TempPathFactory, +) -> SharedSphinxResult: + """Build a project whose own handler refuses one skip.""" + conf = _CONF_PY.replace("__SCENARIO_SRCDIR__", SCENARIO_SRCDIR_TOKEN) + scenario = SphinxScenario( + files=( + ScenarioFile("consumer_skip_demo.py", _CONSUMER_SKIP_SOURCE), + ScenarioFile( + "conf.py", conf + _CONSUMER_SKIP_CONF_EXTRA, substitute_srcdir=True + ), + ScenarioFile( + "index.rst", + "Demo\n====\n\n.. autoclass:: consumer_skip_demo.Registry\n", + ), + ), + ) + return build_shared_sphinx_result( + tmp_path_factory.mktemp("consumer-skip"), + scenario, + purge_modules=("consumer_skip_demo",), + ) + + +@pytest.mark.integration +def test_a_consumer_refusing_to_skip_a_member_is_obeyed( + consumer_skip_html_result: SharedSphinxResult, +) -> None: + """Returning ``False`` outranks the extension's own withholding.""" + attributes = _attribute_names(consumer_skip_html_result) + + assert attributes.count("Registry.undescribed") == 1 + assert attributes.count("Registry.described") == 1 + + # --------------------------------------------------------------------------- # a described member renders the signature autodoc would have rendered # --------------------------------------------------------------------------- From 722607dabe35102aae2e92b05c8d648fc39c738d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 14:03:39 -0500 Subject: [PATCH 13/22] typehints-gp(fix): Keep inherited typed-dictionary keys why: Autodoc materializes a class's __annotations__ so a # type: comment can join it. A TypedDict computes its annotations lazily by merging every base's, and materializing one base's mapping severs that merge -- so documenting a base first made every key its subclasses inherit vanish from their own pages. The loss hits any project documenting both, with or without an Attributes section. what: - Leave a typed dictionary's lazy annotations alone; it declares keys by annotation and has no type comments to merge - Cover a subclass whose base is documented first --- .../_data_defaults.py | 25 +++++++ .../typehints_gp/test_documented_fields.py | 73 +++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_data_defaults.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_data_defaults.py index ce3b2c43..c86450c2 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_data_defaults.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_data_defaults.py @@ -129,3 +129,28 @@ def add_line(self, line: str, source: str, *lineno: int) -> None: return else: super().add_line(result, source, *lineno) + + def update_annotations(self, parent: t.Any) -> None: + """Merge type-comment annotations, except into a typed dictionary. + + Autodoc materializes ``parent.__annotations__`` so that a ``# + type:`` comment can join it. A :class:`typing.TypedDict` computes + its annotations lazily and merges every base's, so materializing + one base's annotations severs that merge: every key the subclass + inherits disappears from its own mapping, and from the page. A + typed dictionary declares its keys by annotation alone and has no + type comments to merge, so it keeps its lazy mapping instead. + + Parameters + ---------- + parent : typing.Any + Class the member being documented belongs to. + + Examples + -------- + >>> GpAttributeDocumenter.update_annotations # doctest: +ELLIPSIS + + """ + if isinstance(parent, type) and t.is_typeddict(parent): + return + super().update_annotations(parent) diff --git a/tests/ext/typehints_gp/test_documented_fields.py b/tests/ext/typehints_gp/test_documented_fields.py index c85e94df..009d8792 100644 --- a/tests/ext/typehints_gp/test_documented_fields.py +++ b/tests/ext/typehints_gp/test_documented_fields.py @@ -2174,6 +2174,79 @@ def test_showing_undocumented_class_vars_restores_them( ) +# --------------------------------------------------------------------------- +# a typed-dictionary key declared by a separate base +# --------------------------------------------------------------------------- + +_INHERITED_KEY_SOURCE = textwrap.dedent( + '''\ + from __future__ import annotations + + import typing as t + + + class BaseOptions(t.TypedDict): + """Options a subclass builds on.""" + + inherited: str + + + class Options(BaseOptions): + """Options declaring one key and inheriting another. + + Attributes + ---------- + inherited : str + Key the base declares. + own : int + Key this class declares. + """ + + own: int + ''' +) + + +@pytest.fixture(scope="module") +def inherited_key_html_result( + tmp_path_factory: pytest.TempPathFactory, +) -> SharedSphinxResult: + """Build a typed dictionary whose base is documented first.""" + index = textwrap.dedent( + """\ + Demo + ==== + + .. autoclass:: inherited_key_demo.BaseOptions + + .. autoclass:: inherited_key_demo.Options + """ + ) + scenario = SphinxScenario( + files=( + ScenarioFile("inherited_key_demo.py", _INHERITED_KEY_SOURCE), + _conf_file(), + ScenarioFile("index.rst", index), + ), + ) + return build_shared_sphinx_result( + tmp_path_factory.mktemp("inherited-key"), + scenario, + purge_modules=("inherited_key_demo",), + ) + + +@pytest.mark.integration +def test_a_key_declared_by_a_base_keeps_its_annotation( + inherited_key_html_result: SharedSphinxResult, +) -> None: + """Documenting the base does not strip the subclass's inherited key.""" + signatures = _attribute_signatures(inherited_key_html_result) + + assert signatures["Options.inherited"] == "inherited: str" + assert signatures["Options.own"] == "own: int" + + # --------------------------------------------------------------------------- # a consumer's explicit skip decision # --------------------------------------------------------------------------- From db74cc9a7028303326d2a9504b10be583c804d51 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 14:08:54 -0500 Subject: [PATCH 14/22] typehints-gp(feat): Describe module constants with their value why: A module docstring describing its own constant rendered the prose and the authored type, never the value. Autodoc reaches for its data documenter only for a name the source annotates or comments, so a plain constant was claimed by no documenter at all and the docstring-derived directive was the only thing rendering it -- as a py:attribute, which is what a class member is, not a module constant. what: - Route a described module constant to the data documenter, so autodoc owns the directive and supplies the value from the live module - Carry the module docstring's prose into it through the existing description fallback - Record the constant as py:data in the inventory - Cover the value, the objtype, and the description --- .../_data_defaults.py | 87 +++++-- .../_documented_fields.py | 212 ++++++++++++++++-- .../typehints_gp/test_documented_fields.py | 86 ++++++- 3 files changed, 349 insertions(+), 36 deletions(-) diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_data_defaults.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_data_defaults.py index c86450c2..35c860a8 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_data_defaults.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_data_defaults.py @@ -27,9 +27,12 @@ import typing as t -from sphinx.ext.autodoc import AttributeDocumenter, DataDocumenter +from sphinx.ext.autodoc import AttributeDocumenter, DataDocumenter, ModuleDocumenter -from sphinx_autodoc_typehints_gp._documented_fields import FieldDocFallbackMixin +from sphinx_autodoc_typehints_gp._documented_fields import ( + FieldDocFallbackMixin, + active_module_field, +) from sphinx_autodoc_typehints_gp._resolvers import ( ResolveContext, Resolver, @@ -97,12 +100,54 @@ def _curate_value_line( return f"{_VALUE_PREFIX}{text}" -class GpDataDocumenter(DataDocumenter): - """``DataDocumenter`` that curates ``:value:`` text via the resolver chain.""" +class GpDataDocumenter(FieldDocFallbackMixin, DataDocumenter): + """``DataDocumenter`` that curates ``:value:`` text via the resolver chain. + + Also documents a module constant the module docstring described. + Autodoc reaches for its data documenter only for a name the source + annotates or comments; a name an ``Attributes`` section describes is + just as deliberate, and routing it here is what lets the entry keep + the value only the live module can supply. + """ objtype = "data" priority = DataDocumenter.priority + 1 + @classmethod + def can_document_member( + cls, + member: t.Any, + membername: str, + isattr: bool, + parent: t.Any, + ) -> bool: + """Return whether this documenter should render *membername*. + + Parameters + ---------- + member : typing.Any + The member object under consideration. + membername : str + Bare member name. + isattr : bool + Whether autodoc classified the member as an attribute. + parent : typing.Any + Documenter of the object the member belongs to. + + Returns + ------- + bool + Whether the member is module data this documenter renders. + + Examples + -------- + >>> GpDataDocumenter.can_document_member(1, "LIMIT", False, None) + False + """ + if super().can_document_member(member, membername, isattr, parent): + return True + return isinstance(parent, ModuleDocumenter) and active_module_field(membername) + def add_line(self, line: str, source: str, *lineno: int) -> None: """Curate ``:value:`` lines; pass everything else through unchanged.""" result = _curate_value_line(self, line) @@ -120,26 +165,16 @@ class GpAttributeDocumenter(FieldDocFallbackMixin, AttributeDocumenter): # type objtype = "attribute" priority = AttributeDocumenter.priority + 1 - def add_line(self, line: str, source: str, *lineno: int) -> None: - """Curate ``:value:`` lines; pass everything else through unchanged.""" - result = _curate_value_line(self, line) - if result is None: - super().add_line(line, source, *lineno) - elif result == "": - return - else: - super().add_line(result, source, *lineno) - def update_annotations(self, parent: t.Any) -> None: """Merge type-comment annotations, except into a typed dictionary. - Autodoc materializes ``parent.__annotations__`` so that a ``# - type:`` comment can join it. A :class:`typing.TypedDict` computes - its annotations lazily and merges every base's, so materializing - one base's annotations severs that merge: every key the subclass - inherits disappears from its own mapping, and from the page. A - typed dictionary declares its keys by annotation alone and has no - type comments to merge, so it keeps its lazy mapping instead. + Autodoc materializes ``parent.__annotations__`` so a ``# type:`` + comment can join it. A :class:`typing.TypedDict` computes its + annotations lazily and merges every base's, so materializing a + base's annotations severs that merge and every key the subclass + inherits disappears from the page. A typed dictionary declares its + keys by annotation alone and has no type comments to merge, so it + keeps its own lazy mapping. Parameters ---------- @@ -154,3 +189,13 @@ def update_annotations(self, parent: t.Any) -> None: if isinstance(parent, type) and t.is_typeddict(parent): return super().update_annotations(parent) + + def add_line(self, line: str, source: str, *lineno: int) -> None: + """Curate ``:value:`` lines; pass everything else through unchanged.""" + result = _curate_value_line(self, line) + if result is None: + super().add_line(line, source, *lineno) + elif result == "": + return + else: + super().add_line(result, source, *lineno) diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py index 707aec51..e8de5d86 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py @@ -64,15 +64,15 @@ _ATTRIBUTE_DIRECTIVE = ".. attribute:: " _FIELD_MARKER = ".. gp-sphinx-documented-field: " _CLASS_LIKE_AUTODOC_TYPES = frozenset({"class", "exception"}) +_MODULE_AUTODOC_TYPE = "module" +# Sphinx runs event listeners in ascending priority and stops at the first +# non-None answer, so anything above its default of 500 answers after the +# handlers a project connects for itself. +_LATE_SKIP_PRIORITY = 800 _UNSET = object() _CLASS_VAR_RE = re.compile(r"\s*(?:\w+\.)*ClassVar\b") _OBJECT_DIRECTIVE_RE = re.compile(r"^\s*\.\. (?:\w+:)?[\w-]+::\s+([^\s(=:]+)") _RENDERED_FIELD_RE = re.compile(r"^\s*:(?:type|value):(?:\s|$)") -# ``autodoc-skip-member`` stops at the first listener returning a decision, and -# a project's own ``conf.py`` connects at the default priority. Answering after -# it leaves the project's explicit decision authoritative, which is what this -# module's docstring already promises. -_LATE_SKIP_PRIORITY = 800 _DIRECTIVE_OPTION_RE = re.compile(r"^\s*:(?:type|value|no-index|noindex):(?:\s|$)") @@ -477,6 +477,46 @@ def _has_attribute_comment(klass: type, name: str) -> bool: return False +def _has_module_comment(module: t.Any, name: str) -> bool: + """Return whether a source comment or docstring describes module *name*. + + Mirrors the lookup autodoc performs in + ``DataDocumenter.get_module_comment``, so a module constant described at + its assignment keeps that description rather than the one an + ``Attributes`` entry supplies. + + Parameters + ---------- + module : typing.Any + Module the member belongs to. + name : str + Member name under consideration. + + Returns + ------- + bool + Whether the module source carries a description for *name*. + + Examples + -------- + >>> import sys + >>> _has_module_comment(sys, "path") + False + + >>> _has_module_comment(t, "Any") + False + """ + modname = getattr(module, "__name__", None) + if not isinstance(modname, str): + return False + try: + analyzer = ModuleAnalyzer.for_module(modname) + analyzer.analyze() + except PycodeError: + return False + return ("", name) in analyzer.attr_docs + + def _mark_attribute_directives(lines: list[str], owner: str) -> None: """Mark field directives until autodoc finishes rendering members. @@ -516,7 +556,8 @@ def _rendered_field_names( lines : typing.Iterable[str] Generated member output added after the class docstring. object_path : typing.Iterable[str] - Class path used in generated Python directives. + Class path used in generated Python directives. Empty at module + scope, where a member directive names the member alone. candidates : typing.Iterable[str] Marked field names under consideration. indent : str @@ -539,9 +580,18 @@ def _rendered_field_names( ... " ", ... ) frozenset({'value'}) + + >>> _rendered_field_names( + ... [".. py:data:: LIMIT", ".. py:class:: Item"], + ... [], + ... {"LIMIT"}, + ... "", + ... ) + frozenset({'LIMIT'}) """ candidate_names = set(candidates) - object_prefix = f"{'.'.join(object_path)}." + path = list(object_path) + object_prefix = f"{'.'.join(path)}." if path else "" rendered: set[str] = set() for line in lines: if not line.startswith(f"{indent}.. "): @@ -738,6 +788,34 @@ def _field_doc_bodies( _ACTIVE_FIELD_DOCS: list[dict[str, list[str]]] = [] +_ACTIVE_MODULE_FIELDS: list[frozenset[str]] = [] + + +def active_module_field(name: str) -> bool: + """Return whether the module being rendered describes *name*. + + Consulted by the data documenter's ``can_document_member``. Autodoc + documents a module constant only when the source carries a ``#:`` + comment or an annotation for it; a name the module docstring described + is just as deliberate, so it becomes documentable too, and autodoc + renders the annotation and value that only the live module knows. + + Parameters + ---------- + name : str + Bare member name under consideration. + + Returns + ------- + bool + Whether the module's ``Attributes`` section named *name*. + + Examples + -------- + >>> active_module_field("LIMIT") + False + """ + return bool(_ACTIVE_MODULE_FIELDS) and name in _ACTIVE_MODULE_FIELDS[-1] def active_field_doc(name: str) -> list[str] | None: @@ -793,8 +871,12 @@ def _describes_itself(self) -> bool: autodoc walks the MRO: a property overriding an inherited dataclass field inherits the *default value's* docstring, so ``str.__doc__`` arrives as though it described the property. A - ``#:`` comment is a description of this member wherever it sits - in the MRO, so it counts. + module constant is worse still — ``LIMIT = 99`` hands autodoc the + whole of :class:`int`'s docstring — so a docstring a member shares + with its own type describes the type, not the member; autodoc + applies the same rule when it decides which members are + documented. A ``#:`` comment or attribute docstring is a + description of this member wherever it sits, so it counts. Returns ------- @@ -807,10 +889,16 @@ def _describes_itself(self) -> bool: """ own = getattr(self.object, "__doc__", None) - if isinstance(own, str) and own.strip(): + if ( + isinstance(own, str) + and own.strip() + and own != getattr(type(self.object), "__doc__", None) + ): return True if isinstance(self.parent, type) and self.objpath: return _has_attribute_comment(self.parent, self.objpath[-1]) + if inspect.ismodule(self.parent) and self.objpath: + return _has_module_comment(self.parent, self.objpath[-1]) return False def get_doc(self) -> list[list[str]] | None: @@ -1151,6 +1239,88 @@ def document_members(self, all_members: bool = False) -> None: _ACTIVE_FIELD_DOCS.pop() +class _ModuleFieldsDocumenterMixin: + """Hand a module's ``Attributes`` prose to the members autodoc renders. + + A class hands its field descriptions to a directive it owns, because + the description is the only thing that knows the field exists. A module + constant is the other way round: the live module holds the value, and + only autodoc can render it. So the module's entries are opened up as + documentable members, the prose is offered to whichever documenter + picks each one up, and every entry autodoc rendered has its directive + removed. An entry autodoc still declines to render keeps its directive. + """ + + directive: t.Any + fullname: str + indent: str + objpath: list[str] + + def document_members(self, all_members: bool = False) -> None: + """Render members with the module's field descriptions in scope. + + Parameters + ---------- + all_members : bool + Whether the wrapped documenter was asked to include every member. + + Examples + -------- + >>> _ModuleFieldsDocumenterMixin.document_members # doctest: +ELLIPSIS + + """ + result = t.cast(StringList, self.directive.result) + marker_prefix = f"{_FIELD_MARKER}{self.fullname} " + candidates = { + line.lstrip()[len(marker_prefix) :].strip() + for line in result + if line.lstrip().startswith(marker_prefix) + } + member_start = len(result) + _ACTIVE_FIELD_DOCS.append(_field_doc_bodies(result, marker_prefix)) + _ACTIVE_MODULE_FIELDS.append(frozenset(candidates)) + try: + t.cast(t.Any, super()).document_members(all_members) + rendered = _rendered_field_names( + result.data[member_start:], + self.objpath, + candidates, + self.indent, + ) + _resolve_marked_fields(result, self.fullname, rendered) + finally: + _ACTIVE_MODULE_FIELDS.pop() + _ACTIVE_FIELD_DOCS.pop() + + +def _wrap_module_documenter(documenter: type[Documenter]) -> type[Documenter]: + """Wrap the registered module documenter without replacing its behavior. + + Parameters + ---------- + documenter : type[sphinx.ext.autodoc.Documenter] + Final module documenter registered by the loaded extension stack. + + Returns + ------- + type[sphinx.ext.autodoc.Documenter] + A subclass resolving module field directives after member rendering. + + Examples + -------- + >>> _wrap_module_documenter # doctest: +ELLIPSIS + + """ + if issubclass(documenter, _ModuleFieldsDocumenterMixin): + return documenter + wrapped = type( + f"ModuleFields{documenter.__name__}", + (_ModuleFieldsDocumenterMixin, documenter), + {"__module__": documenter.__module__}, + ) + return t.cast("type[Documenter]", wrapped) + + def _wrap_documenter(documenter: type[Documenter]) -> type[Documenter]: """Wrap a registered class-like documenter without replacing its behavior. @@ -1194,6 +1364,10 @@ def record_documented_fields( carries declared fields into ``autodoc-skip-member``; transient markers carry other field directives into post-render ownership resolution. + A module docstring records nothing: nothing about a module constant is + the docstring's to own, so its entries are only marked, and the marks + open each name up to autodoc and carry the prose to it. + Parameters ---------- app : Sphinx @@ -1217,6 +1391,8 @@ def record_documented_fields( if what not in _CLASS_LIKE_AUTODOC_TYPES or not isinstance(obj, type): if options.no_index or options.noindex: _add_attribute_no_index(lines) + if what == _MODULE_AUTODOC_TYPE and inspect.ismodule(obj): + _mark_attribute_directives(lines, name) return previous = _PROCESSED_FIELDS.get(app) documented = ( @@ -1241,8 +1417,9 @@ def record_documented_fields( def _prepare_documented_fields(app: Sphinx) -> None: """Install field finalization after every extension has initialized. - Wrap the final class and exception documenters already registered by the - extension stack, then install the final docstring processor. + Wrap the final class, exception, and module documenters already + registered by the extension stack, then install the final docstring + processor. Parameters ---------- @@ -1262,6 +1439,12 @@ def _prepare_documented_fields(app: Sphinx) -> None: _wrap_documenter(documenter), override=True, ) + module_documenter = app.registry.documenters.get(_MODULE_AUTODOC_TYPE) + if module_documenter is not None: + app.add_autodocumenter( + _wrap_module_documenter(module_documenter), + override=True, + ) app.connect( "autodoc-process-docstring", record_documented_fields, @@ -1367,7 +1550,10 @@ def skip_documented_fields( *name*. Returns ``None`` in every other case, leaving the member to autodoc and - to any other handler. + to any other handler. Sphinx stops at the first handler to answer, so + this one is connected to answer last: a project that says outright + whether one of its own members belongs on the page has settled the + question, and no default of ours should reach autodoc ahead of it. Parameters ---------- diff --git a/tests/ext/typehints_gp/test_documented_fields.py b/tests/ext/typehints_gp/test_documented_fields.py index 009d8792..f07fde30 100644 --- a/tests/ext/typehints_gp/test_documented_fields.py +++ b/tests/ext/typehints_gp/test_documented_fields.py @@ -2174,6 +2174,84 @@ def test_showing_undocumented_class_vars_restores_them( ) +# --------------------------------------------------------------------------- +# a module constant the module docstring describes +# --------------------------------------------------------------------------- + +_MODULE_FIELD_SOURCE = '''\ +"""Module describing the constant it declares. + +Attributes +---------- +LIMIT : int + Maximum retries before giving up. +""" + +from __future__ import annotations + +LIMIT = 99 +''' + + +@pytest.fixture(scope="module") +def module_field_html_result( + tmp_path_factory: pytest.TempPathFactory, +) -> SharedSphinxResult: + """Build a module whose docstring describes its own constant.""" + scenario = SphinxScenario( + files=( + ScenarioFile("module_field_demo.py", _MODULE_FIELD_SOURCE), + _conf_file(), + ScenarioFile( + "index.rst", + "Demo\n====\n\n.. automodule:: module_field_demo\n :members:\n", + ), + ), + ) + return build_shared_sphinx_result( + tmp_path_factory.mktemp("module-field"), + scenario, + purge_modules=("module_field_demo",), + ) + + +@pytest.mark.integration +def test_a_described_module_constant_keeps_its_value( + module_field_html_result: SharedSphinxResult, +) -> None: + """A module constant keeps the value only the live module supplies.""" + members = _described_members(get_doctree(module_field_html_result, "index")) + signatures = {member.fullname: member.signature for member in members} + + assert signatures["LIMIT"] == "module_field_demo.LIMIT = 99" + + +@pytest.mark.integration +def test_a_described_module_constant_is_data_not_an_attribute( + module_field_html_result: SharedSphinxResult, +) -> None: + """A module constant is data; only a class member is an attribute.""" + members = _described_members(get_doctree(module_field_html_result, "index")) + objtypes = { + member.fullname: member.objtype + for member in members + if member.fullname.endswith("LIMIT") + } + + assert objtypes == {"LIMIT": "data"} + + +@pytest.mark.integration +def test_a_described_module_constant_carries_its_description( + module_field_html_result: SharedSphinxResult, +) -> None: + """The description the module docstring wrote reaches the entry.""" + html = read_output(module_field_html_result, "index.html") + + assert "Maximum retries before giving up." in html + assert "duplicate object description" not in module_field_html_result.warnings + + # --------------------------------------------------------------------------- # a typed-dictionary key declared by a separate base # --------------------------------------------------------------------------- @@ -3085,10 +3163,14 @@ def test_no_index_applies_to_module_attribute( no_index_html_result: SharedSphinxResult, ) -> None: """A no-index module keeps emitted fields out of the inventory.""" - attributes = _attribute_names(no_index_html_result) + described = [ + member.fullname + for member in _described_members(get_doctree(no_index_html_result, "index")) + if member.objtype in {"attribute", "data"} + ] objects = no_index_html_result.app.env.domains.python_domain.objects - assert sum(name.endswith("MODULE_VALUE") for name in attributes) == 1 + assert sum(name.endswith("MODULE_VALUE") for name in described) == 1 assert "Visible but unregistered module field." in read_output( no_index_html_result, "index.html" ) From cffeedf9b08f0a81ec89c4dfc5dd7780dee3d91c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 14:11:58 -0500 Subject: [PATCH 15/22] typehints-gp(test): Pin the awkward member shapes why: An integer-backed enum, a flag enum built with auto(), and an attribute annotated only by a type comment were each verified by hand and then left unheld -- the proof lived in a scratch build that no longer exists. The parity comparison also had a blind spot: it compares two mappings, so a shape that stopped rendering on both sides would drop out of both and still compare equal. what: - Add the three shapes to the differential parity table, where each is measured against its undescribed twin rather than a literal - Pin the table's own coverage, so a shape leaving the roster fails instead of passing empty --- .../typehints_gp/test_documented_fields.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/ext/typehints_gp/test_documented_fields.py b/tests/ext/typehints_gp/test_documented_fields.py index f07fde30..bb25094b 100644 --- a/tests/ext/typehints_gp/test_documented_fields.py +++ b/tests/ext/typehints_gp/test_documented_fields.py @@ -2469,6 +2469,29 @@ class Registry: \"\"\" lookup: t.ClassVar[dict[str, str]] = {{}} + + +class Level(enum.IntEnum): + \"\"\"A shape under test.{level} + \"\"\" + + LOW = 1 + HIGH = 2 + + +class Perm(enum.Flag): + \"\"\"A shape under test.{perm} + \"\"\" + + READ = enum.auto() + WRITE = enum.auto() + + +class Commented: + \"\"\"A shape under test.{commented} + \"\"\" + + legacy = 0 # type: int """ @@ -2503,6 +2526,15 @@ def _parity_section(entries: tuple[tuple[str, str, str], ...]) -> str: ("nickname", "str", "Not-required key."), ), "registry": (("lookup", "dict[str, str]", "Class variable."),), + "level": ( + ("LOW", "Level", "Lowest level."), + ("HIGH", "Level", "Highest level."), + ), + "perm": ( + ("READ", "Perm", "Read permission."), + ("WRITE", "Perm", "Write permission."), + ), + "commented": (("legacy", "int", "Annotated by a type comment."),), } _PARITY_PREAMBLE = textwrap.dedent( @@ -2528,6 +2560,9 @@ def _parity_source(*, described: bool) -> str: _PARITY_CLASSES = ( + "Level", + "Perm", + "Commented", "Safety", "Tier", "Constants", @@ -2585,6 +2620,32 @@ def test_describing_a_member_keeps_the_signature_autodoc_renders( assert rendered["described"] == rendered["plain"] +@pytest.mark.integration +def test_parity_covers_every_shape_it_names( + signature_parity_html_result: SharedSphinxResult, +) -> None: + """A shape missing from the twin cannot let the comparison pass empty. + + The parity assertion compares two dictionaries. A shape that stopped + rendering on both sides would drop out of both and still compare + equal, so the table's coverage is pinned separately from its content. + """ + doctree = get_doctree(signature_parity_html_result, "index") + plain = { + signature.get("fullname", "") + for signature in doctree.findall(addnodes.desc_signature) + if signature.parent.get("objtype") == "attribute" + and (signature.get("module") or "") == "parity_plain_demo" + } + named = { + f"{section.capitalize()}.{name}" + for section, fields in _PARITY_ENTRIES.items() + for name, _type, _description in fields + } + + assert named - plain == set() + + _CUSTOM_DOCUMENTERS_SOURCE = textwrap.dedent( """\ from __future__ import annotations From d5b78b288f211bd326a0f3664d15a8305a3a8f00 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 16:21:26 -0500 Subject: [PATCH 16/22] typehints-gp(fix): Keep a described constant's declared type why: Routing a described module constant to the data documenter gave it the value it had been missing, but cost it the type. Autodoc renders an annotation only where the source writes one, so for a plain constant the Attributes entry is the only statement of its type anywhere -- and the entry's type was being dropped on the assumption the member would render its own. The reader ended up with prose and a value but no type. what: - Carry the type an entry declares alongside its prose - Reattach it only where the owner annotates nothing itself, so an annotated member still states its type once - Cover an unannotated module constant described with a type --- .../_documented_fields.py | 99 ++++++++++++++++--- .../typehints_gp/test_documented_fields.py | 31 +++++- 2 files changed, 116 insertions(+), 14 deletions(-) diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py index e8de5d86..26cc4f4f 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py @@ -673,6 +673,49 @@ def _resolve_marked_fields( del lines[block_index] +class _FieldDoc(t.NamedTuple): + """What one field directive states about a member. + + Attributes + ---------- + prose : list[str] + Description lines, dedented to column zero. + declared_type : str + Type the entry declared, or ``""`` when it declared none. + """ + + prose: list[str] + declared_type: str + + +def _declared_type(body: list[str]) -> str: + """Return the type a field directive declares, if it declares one. + + Parameters + ---------- + body : list[str] + Directive body lines, dedented to column zero. + + Returns + ------- + str + The declared type, or ``""``. + + Examples + -------- + >>> _declared_type(["Horizontal offset.", "", ":type: int"]) + 'int' + + >>> _declared_type(["Horizontal offset."]) + '' + """ + for line in body: + stripped = line.strip() + if stripped.startswith(":type:"): + return stripped[len(":type:") :].strip() + return "" + + def _strip_rendered_fields(body: list[str]) -> list[str]: """Drop what the member's own directive states, and surrounding blanks. @@ -723,7 +766,7 @@ def _strip_rendered_fields(body: list[str]) -> list[str]: def _field_doc_bodies( lines: t.Iterable[str], marker_prefix: str, -) -> dict[str, list[str]]: +) -> dict[str, _FieldDoc]: """Return the prose each marked field directive carries. The type and value are dropped: the member's own directive renders @@ -739,8 +782,8 @@ def _field_doc_bodies( Returns ------- - dict[str, list[str]] - Description lines by field name, dedented to column zero. + dict[str, _FieldDoc] + Description and declared type by field name. Examples -------- @@ -755,9 +798,9 @@ def _field_doc_bodies( ... ], ... ".. gp-sphinx-documented-field: demo.Item ", ... ) - {'value': ['Horizontal offset.']} + {'value': _FieldDoc(prose=['Horizontal offset.'], declared_type='int')} """ - bodies: dict[str, list[str]] = {} + bodies: dict[str, _FieldDoc] = {} lines = list(lines) for index, line in enumerate(lines): stripped = line.lstrip() @@ -781,13 +824,14 @@ def _field_doc_bodies( break block.append(candidate) - body = _strip_rendered_fields(textwrap.dedent("\n".join(block)).splitlines()) + dedented = textwrap.dedent("\n".join(block)).splitlines() + body = _strip_rendered_fields(dedented) if body: - bodies[name] = body + bodies[name] = _FieldDoc(prose=body, declared_type=_declared_type(dedented)) return bodies -_ACTIVE_FIELD_DOCS: list[dict[str, list[str]]] = [] +_ACTIVE_FIELD_DOCS: list[dict[str, _FieldDoc]] = [] _ACTIVE_MODULE_FIELDS: list[frozenset[str]] = [] @@ -818,7 +862,7 @@ def active_module_field(name: str) -> bool: return bool(_ACTIVE_MODULE_FIELDS) and name in _ACTIVE_MODULE_FIELDS[-1] -def active_field_doc(name: str) -> list[str] | None: +def active_field_doc(name: str) -> _FieldDoc | None: """Return the ``Attributes`` prose for *name* on the class being rendered. Consulted by the attribute documenter while a class documents its @@ -833,8 +877,8 @@ def active_field_doc(name: str) -> list[str] | None: Returns ------- - list[str] | None - Description lines, or ``None`` when nothing described *name*. + _FieldDoc | None + Description and declared type, or ``None`` when nothing described *name*. Examples -------- @@ -923,7 +967,38 @@ def get_doc(self) -> list[list[str]] | None: fallback = active_field_doc(self.objpath[-1]) if fallback is None: return t.cast("list[list[str]] | None", doc) - return [list(fallback)] + lines = list(fallback.prose) + if fallback.declared_type and not self._owner_annotates(): + lines += ["", f":type: {fallback.declared_type}"] + return [lines] + + def _owner_annotates(self) -> bool: + """Return whether the owner annotates this member itself. + + An annotated member has its type rendered into the signature, so + repeating the entry's declaration would state it twice. An + unannotated one -- a plain module constant, a bare class + constant -- has the entry as the only statement of its type + anywhere, and dropping it loses what the author wrote. + + Returns + ------- + bool + Whether the owner declares an annotation for this member. + + Examples + -------- + >>> FieldDocFallbackMixin._owner_annotates # doctest: +ELLIPSIS + + """ + if not self.objpath: + return False + name = self.objpath[-1] + parent = getattr(self, "parent", None) + if isinstance(parent, type): + return name in _declared_annotations(parent) + annotations = getattr(parent, "__annotations__", None) + return isinstance(annotations, dict) and name in annotations class GpPropertyDocumenter(FieldDocFallbackMixin, PropertyDocumenter): # type: ignore[misc] diff --git a/tests/ext/typehints_gp/test_documented_fields.py b/tests/ext/typehints_gp/test_documented_fields.py index bb25094b..96f7436f 100644 --- a/tests/ext/typehints_gp/test_documented_fields.py +++ b/tests/ext/typehints_gp/test_documented_fields.py @@ -15,6 +15,7 @@ _attribute_directive_names, _DirectiveEnrichment, _field_doc_bodies, + _FieldDoc, _is_declared_field, _own_annotations, _plan_enrichment, @@ -113,7 +114,9 @@ def test_field_doc_bodies_drops_the_directive_options() -> None: " Horizontal offset.", ] - assert _field_doc_bodies(lines, _MARKER_PREFIX) == {"value": ["Horizontal offset."]} + assert _field_doc_bodies(lines, _MARKER_PREFIX) == { + "value": _FieldDoc(prose=["Horizontal offset."], declared_type="") + } def test_field_doc_bodies_drops_the_trailing_type_field() -> None: @@ -127,7 +130,9 @@ def test_field_doc_bodies_drops_the_trailing_type_field() -> None: " :type: int", ] - assert _field_doc_bodies(lines, _MARKER_PREFIX) == {"value": ["Horizontal offset."]} + assert _field_doc_bodies(lines, _MARKER_PREFIX) == { + "value": _FieldDoc(prose=["Horizontal offset."], declared_type="int") + } # --------------------------------------------------------------------------- @@ -2241,6 +2246,28 @@ def test_a_described_module_constant_is_data_not_an_attribute( assert objtypes == {"LIMIT": "data"} +@pytest.mark.integration +def test_a_described_module_constant_keeps_its_declared_type( + module_field_html_result: SharedSphinxResult, +) -> None: + """An unannotated constant keeps the type only its entry declares. + + The source writes ``LIMIT = 99``, so autodoc has no annotation to + render. The ``Attributes`` entry declaring ``LIMIT : int`` is then the + only statement of the type anywhere, and dropping it costs the reader + the one thing the author wrote it for. + """ + doctree = get_doctree(module_field_html_result, "index") + stated = [ + signature.parent.astext() + for signature in doctree.findall(addnodes.desc_signature) + if signature.get("fullname") == "LIMIT" + ] + + assert stated, "the module constant did not render at all" + assert "int" in stated[0] + + @pytest.mark.integration def test_a_described_module_constant_carries_its_description( module_field_html_result: SharedSphinxResult, From a58ce40e7c8593b1cf665ec80370889863c511d0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 16:30:41 -0500 Subject: [PATCH 17/22] typehints-gp(feat): Describe a field from the base that declares it why: A dataclass inherits its bases' fields and a typed dictionary inherits its bases' keys, but the description stays behind on the base. The subclass entry therefore rendered bare while the prose sat one class away -- 134 options on a real consumer, plus 11 typed-dictionary keys. Asking an author to restate every inherited field on every subclass is not a reasonable alternative. what: - Consult the bases when the class being rendered describes nothing, accepting a NumPy Attributes entry or a description at the assignment - Search __mro__ and __orig_bases__ together; a typed dictionary keeps no base in its MRO, so its declaring class is reachable only through __orig_bases__ - Cover both shapes --- .../_documented_fields.py | 185 +++++++++++++++++- .../typehints_gp/test_documented_fields.py | 95 +++++++++ 2 files changed, 277 insertions(+), 3 deletions(-) diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py index 26cc4f4f..596ef2e7 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_documented_fields.py @@ -74,6 +74,18 @@ _OBJECT_DIRECTIVE_RE = re.compile(r"^\s*\.\. (?:\w+:)?[\w-]+::\s+([^\s(=:]+)") _RENDERED_FIELD_RE = re.compile(r"^\s*:(?:type|value):(?:\s|$)") _DIRECTIVE_OPTION_RE = re.compile(r"^\s*:(?:type|value|no-index|noindex):(?:\s|$)") +_NUMPY_SECTION_NAMES = frozenset( + { + "Examples", + "Notes", + "Parameters", + "Raises", + "References", + "Returns", + "See Also", + "Yields", + } +) class _ProcessedFields(t.NamedTuple): @@ -437,6 +449,105 @@ def _is_declared_field(klass: type, name: str) -> bool: return not is_non_field_member +def _describing_bases(owner: t.Any) -> t.Iterator[type]: + """Yield every base that could describe one of *owner*'s members. + + Walks the MRO and ``__orig_bases__`` together. A + :class:`typing.TypedDict` keeps no base in its MRO -- it inherits + from :class:`dict` -- so the class that declared an inherited key is + reachable only through ``__orig_bases__``. + + Parameters + ---------- + owner : typing.Any + Class whose bases are being searched. + + Yields + ------ + type + Each base, nearest first, without repeats. + + Examples + -------- + >>> class Base: + ... pass + >>> class Child(Base): + ... pass + >>> [base.__name__ for base in _describing_bases(Child)] + ['Base'] + """ + seen: set[type] = set() + queue = [ + *getattr(owner, "__mro__", (owner,))[1:], + *getattr(owner, "__orig_bases__", ()), + ] + while queue: + base = queue.pop(0) + if not isinstance(base, type) or base is object or base in seen: + continue + seen.add(base) + yield base + queue.extend(getattr(base, "__orig_bases__", ())) + + +def _numpy_attributes(docstring: str) -> dict[str, _FieldDoc]: + r"""Return what a NumPy ``Attributes`` section states, by member name. + + Parameters + ---------- + docstring : str + Class docstring, already dedented. + + Returns + ------- + dict[str, _FieldDoc] + Description and declared type for each entry. + + Examples + -------- + >>> section = [ + ... "Summary.", + ... "", + ... "Attributes", + ... "----------", + ... "x : int", + ... " Offset.", + ... ] + >>> _numpy_attributes("\n".join(section)) + {'x': _FieldDoc(prose=['Offset.'], declared_type='int')} + """ + lines = docstring.splitlines() + entries: dict[str, _FieldDoc] = {} + inside = False + name = "" + declared = "" + prose: list[str] = [] + + def flush() -> None: + if name: + entries[name] = _FieldDoc(prose=list(prose), declared_type=declared) + + for index, line in enumerate(lines): + stripped = line.strip() + if not inside: + following = lines[index + 1].strip() if index + 1 < len(lines) else "" + inside = stripped == "Attributes" and set(following) == {"-"} + continue + if set(stripped) == {"-"}: + continue + if stripped and not line[:1].isspace(): + flush() + name, _, declared = (part.strip() for part in stripped.partition(":")) + prose = [] + if not declared and stripped in _NUMPY_SECTION_NAMES: + name = "" + break + elif stripped: + prose.append(stripped) + flush() + return entries + + def _has_attribute_comment(klass: type, name: str) -> bool: """Return whether a source comment or attribute docstring describes *name*. @@ -462,6 +573,29 @@ def _has_attribute_comment(klass: type, name: str) -> bool: >>> _has_attribute_comment(_ProcessedFields, "names") False """ + return bool(_attribute_comment(klass, name)) + + +def _attribute_comment(klass: type, name: str) -> list[str]: + """Return the source comment or attribute docstring describing *name*. + + Parameters + ---------- + klass : type + Class being documented. + name : str + Member name under consideration. + + Returns + ------- + list[str] + Description lines, empty when the source describes nothing. + + Examples + -------- + >>> _attribute_comment(_ProcessedFields, "names") + [] + """ for base in getattr(klass, "__mro__", (klass,)): module = getattr(base, "__module__", None) qualname = getattr(base, "__qualname__", None) @@ -472,9 +606,10 @@ def _has_attribute_comment(klass: type, name: str) -> bool: analyzer.analyze() except PycodeError: continue - if (qualname, name) in analyzer.attr_docs: - return True - return False + comment = analyzer.attr_docs.get((qualname, name)) + if comment: + return [line for line in comment if line.strip()] + return [] def _has_module_comment(module: t.Any, name: str) -> bool: @@ -890,6 +1025,45 @@ def active_field_doc(name: str) -> _FieldDoc | None: return _ACTIVE_FIELD_DOCS[-1].get(name) +def _inherited_field_doc(owner: t.Any, name: str) -> _FieldDoc | None: + """Return what a base class says about *name*, if anything does. + + A field a base declares reaches the subclass whether or not the + subclass repeats it: a dataclass inherits its bases' fields, and a + typed dictionary inherits its bases' keys. The description stays + behind on the base, so the subclass entry renders bare unless the + bases are consulted. Both description styles count -- a NumPy + ``Attributes`` entry and a description at the assignment. + + Parameters + ---------- + owner : typing.Any + Class the member is being documented on. + name : str + Member name under consideration. + + Returns + ------- + _FieldDoc | None + The nearest base's description, or ``None``. + + Examples + -------- + >>> _inherited_field_doc(_ProcessedFields, "names") is None + True + """ + if not isinstance(owner, type): + return None + for base in _describing_bases(owner): + entry = _numpy_attributes(inspect.getdoc(base) or "").get(name) + if entry is not None and entry.prose: + return entry + comment = _attribute_comment(base, name) + if comment: + return _FieldDoc(prose=comment, declared_type="") + return None + + class FieldDocFallbackMixin: """Describe a bare member from its owner's ``Attributes`` entry. @@ -965,6 +1139,11 @@ def get_doc(self) -> list[list[str]] | None: if not self.objpath: return t.cast("list[list[str]] | None", doc) fallback = active_field_doc(self.objpath[-1]) + if fallback is None: + fallback = _inherited_field_doc( + getattr(self, "parent", None), + self.objpath[-1], + ) if fallback is None: return t.cast("list[list[str]] | None", doc) lines = list(fallback.prose) diff --git a/tests/ext/typehints_gp/test_documented_fields.py b/tests/ext/typehints_gp/test_documented_fields.py index 96f7436f..d15b0b18 100644 --- a/tests/ext/typehints_gp/test_documented_fields.py +++ b/tests/ext/typehints_gp/test_documented_fields.py @@ -2179,6 +2179,101 @@ def test_showing_undocumented_class_vars_restores_them( ) +# --------------------------------------------------------------------------- +# a field a base class describes +# --------------------------------------------------------------------------- + +_INHERITED_DESC_SOURCE = textwrap.dedent( + '''\ + from __future__ import annotations + + import dataclasses + import typing as t + + + @dataclasses.dataclass + class ServerBase: + """Server-scoped options. + + Attributes + ---------- + activity_action : str | None + Which windows raise an alert on activity. + """ + + activity_action: str | None = None + + + @dataclasses.dataclass + class Options(ServerBase): + """Container for every option.""" + + + class KeyBase(t.TypedDict): + """Keys a subclass builds on.""" + + directory: str + """Path for the worktree.""" + + + class Config(KeyBase): + """Config declaring one key and inheriting another.""" + + label: str + ''' +) + + +@pytest.fixture(scope="module") +def inherited_description_html_result( + tmp_path_factory: pytest.TempPathFactory, +) -> SharedSphinxResult: + """Build a class and a typed dictionary that inherit descriptions.""" + index = textwrap.dedent( + """\ + Demo + ==== + + .. autoclass:: inherited_desc_demo.Options + :inherited-members: + + .. autoclass:: inherited_desc_demo.Config + """ + ) + scenario = SphinxScenario( + files=( + ScenarioFile("inherited_desc_demo.py", _INHERITED_DESC_SOURCE), + _conf_file(), + ScenarioFile("index.rst", index), + ), + ) + return build_shared_sphinx_result( + tmp_path_factory.mktemp("inherited-desc"), + scenario, + purge_modules=("inherited_desc_demo",), + ) + + +@pytest.mark.integration +def test_a_field_described_by_a_base_class_carries_that_description( + inherited_description_html_result: SharedSphinxResult, +) -> None: + """An inherited dataclass field keeps the base's Attributes entry.""" + html = read_output(inherited_description_html_result, "index.html") + + assert "Which windows raise an alert on activity." in html + + +@pytest.mark.integration +def test_a_key_described_by_a_base_typed_dict_carries_that_description( + inherited_description_html_result: SharedSphinxResult, +) -> None: + """A TypedDict base is reachable only through ``__orig_bases__``.""" + html = read_output(inherited_description_html_result, "index.html") + + assert "Path for the worktree." in html + + # --------------------------------------------------------------------------- # a module constant the module docstring describes # --------------------------------------------------------------------------- From 751c672ac4b8d694681c9e5e7fca9f0205a362f1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 18:30:34 -0500 Subject: [PATCH 18/22] config(feat[autodoc]): Hide machinery members why: private-members is on by default, so abc, typing.Protocol, and NamedTuple each put names on the page that no author declared and no docstring can reach. A consumer documenting 45 pydantic models ships 45 _abc_impl entries, each rendering as a name and nothing else. what: - Add skip_machinery_members, hiding _abc_impl, _is_protocol, _is_runtime_protocol, _fields, and _field_defaults - Connect it late, so a project wanting one of them keeps it by answering False from its own handler - Leave _replace and _asdict alone; they document themselves - Drop the one machinery entry from the objects.inv baseline, which was never a target anyone could cross-reference --- docs/configuration.md | 11 ++- packages/gp-sphinx/src/gp_sphinx/config.py | 91 ++++++++++++++++++- .../__snapshots__/objects-inv-baseline.txt | 1 - tests/test_config.py | 77 ++++++++++++++++ 4 files changed, 176 insertions(+), 4 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 40166db1..a5fe5b27 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -85,7 +85,7 @@ already present. The returned config includes a `setup(app)` function from {py:func}`gp_sphinx.config.setup`. It wires the coordinator's shared -browser helpers and lexer aliases into the Sphinx app: +browser helpers, autodoc filter, and lexer aliases into the Sphinx app: | Action | Effect | | --- | --- | @@ -93,9 +93,18 @@ browser helpers and lexer aliases into the Sphinx app: | `app.connect("html-page-context", _inject_copybutton_bridge)` | Adds copybutton prompt settings to the page context so copied examples stay prompt-aware after SPA navigation | | `app.connect("html-page-context", _inject_fowt_prevention)` | Injects the early theme script that prevents a flash of the wrong theme before Furo initializes | | `app.connect("build-finished", remove_tabs_js)` | Removes `_static/tabs.js` after HTML builds as a `sphinx-inline-tabs` workaround | +| `app.connect("autodoc-skip-member", skip_machinery_members, priority=900)` | Hides the {py:data}`gp_sphinx.config.MACHINERY_MEMBERS` names that `abc`, {py:class}`typing.Protocol`, and {py:func}`typing.NamedTuple` write into a class | | `app.add_lexer("myst", MystLexer)` | Registers the MyST lexer alias used by Markdown examples | | `app.add_lexer("myst-md", MystLexer)` | Registers the alternate MyST lexer alias used by Markdown examples | +`private-members` is on by default, so without that filter every +{py:class}`~pydantic.BaseModel` subclass ships an `_abc_impl` entry and +every {py:func}`~typing.NamedTuple` ships `_fields` and +`_field_defaults` — names no docstring can reach, because no author +wrote them. Since `autodoc-skip-member` takes the first non-`None` +answer and this handler is connected late, a project that wants one of +them on the page registers its own handler and answers `False`. + ## Always-set coordinator values These are injected even though they are not exposed as `DEFAULT_*` constants: diff --git a/packages/gp-sphinx/src/gp_sphinx/config.py b/packages/gp-sphinx/src/gp_sphinx/config.py index bd4c133d..1cb093e5 100644 --- a/packages/gp-sphinx/src/gp_sphinx/config.py +++ b/packages/gp-sphinx/src/gp_sphinx/config.py @@ -778,12 +778,94 @@ class is present and ``body[data-theme]`` is unset — body becomes context["metatags"] = context.get("metatags", "") + snippet +MACHINERY_MEMBERS: frozenset[str] = frozenset( + { + "_abc_impl", + "_is_protocol", + "_is_runtime_protocol", + "_fields", + "_field_defaults", + } +) +"""Private names Python's own machinery attaches to a class. + +:class:`abc.ABCMeta`, :class:`typing.Protocol`, and +:func:`typing.NamedTuple` each write attributes into the classes they +build. No author declares them, so no docstring can reach them, yet +:data:`DEFAULT_AUTODOC_OPTIONS` turns ``private-members`` on and autodoc +renders every one as an entry with a name and nothing else. + +Members that carry their own docstring are absent: ``_replace`` and +``_asdict`` render describing themselves and are worth keeping. + +Examples +-------- +>>> "_abc_impl" in MACHINERY_MEMBERS +True +>>> "_replace" in MACHINERY_MEMBERS +False +""" + +_MACHINERY_SKIP_PRIORITY: t.Final = 900 + + +def skip_machinery_members( + app: t.Any, + what: str, + name: str, + obj: t.Any, + skip: bool, + options: t.Any, +) -> bool | None: + """Hide a member Python's machinery wrote, defer on everything else. + + Parameters + ---------- + app : typing.Any + The Sphinx application object. + what : str + Kind of object the member belongs to. + name : str + Bare member name autodoc is considering. + obj : typing.Any + The member itself. + skip : bool + Whether autodoc would skip the member on its own. + options : typing.Any + Options given to the directive. + + Returns + ------- + bool | None + ``True`` to hide a name from :data:`MACHINERY_MEMBERS`, otherwise + ``None`` so another handler decides. + + Notes + ----- + ``autodoc-skip-member`` resolves by first non-``None`` answer, and + gp-sphinx connects this late. A project wanting one of these members + on the page registers its own handler, which is asked first and can + answer ``False`` to keep it. + + Examples + -------- + >>> skip_machinery_members(None, "class", "_abc_impl", None, False, None) + True + >>> skip_machinery_members(None, "class", "timeout", None, False, None) is None + True + """ + if name in MACHINERY_MEMBERS: + return True + return None + + def setup(app: Sphinx) -> None: """Configure Sphinx app hooks for gp-sphinx workarounds. Registers the bundled ``spa-nav.js`` script, wires the copy-button - configuration bridge, the FOWT-prevention head snippet, and - connects the ``remove_tabs_js`` post-build hook. + configuration bridge, the FOWT-prevention head snippet, hides the + private names Python's machinery writes, and connects the + ``remove_tabs_js`` post-build hook. Parameters ---------- @@ -794,5 +876,10 @@ def setup(app: Sphinx) -> None: app.connect("html-page-context", _inject_copybutton_bridge) app.connect("html-page-context", _inject_fowt_prevention) app.connect("build-finished", remove_tabs_js) + app.connect( + "autodoc-skip-member", + skip_machinery_members, + priority=_MACHINERY_SKIP_PRIORITY, + ) app.add_lexer("myst", MystLexer) app.add_lexer("myst-md", MystLexer) diff --git a/tests/docs/__snapshots__/objects-inv-baseline.txt b/tests/docs/__snapshots__/objects-inv-baseline.txt index cc710349..dbc7332d 100644 --- a/tests/docs/__snapshots__/objects-inv-baseline.txt +++ b/tests/docs/__snapshots__/objects-inv-baseline.txt @@ -12,7 +12,6 @@ argparse:program myapp myothersubcommand argparse:program myapp mysubcommand argparse:subcommand myapp myothersubcommand argparse:subcommand myapp mysubcommand -py:attribute gp_demo_api.DemoAbstractBase._abc_impl py:attribute gp_demo_api.DemoClass.demo_attr py:attribute sphinx_ux_badges.BadgeSpec.classes py:attribute sphinx_ux_badges.BadgeSpec.fill diff --git a/tests/test_config.py b/tests/test_config.py index 7805ec7b..990c5485 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,6 +3,7 @@ from __future__ import annotations import pathlib +import typing as t import pytest @@ -12,6 +13,7 @@ make_linkcode_resolve, make_workspace_linkcode_resolve, merge_sphinx_config, + skip_machinery_members, ) from gp_sphinx.defaults import DEFAULT_EXTENSIONS, DEFAULT_MYST_EXTENSIONS @@ -709,3 +711,78 @@ def test_merge_sphinx_config_linkcode_auto_added_with_workspace_resolver( linkcode_resolve=resolver, ) assert "sphinx.ext.linkcode" in result["extensions"] + + +class MachineryFixture(t.NamedTuple): + """Test case for skip_machinery_members(). + + Attributes + ---------- + test_id : str + Human-readable label for the parametrized case. + name : str + Member name autodoc offers to the handler. + expected : bool | None + ``True`` to hide the member, ``None`` to defer to other handlers. + """ + + test_id: str + name: str + expected: bool | None + + +_MACHINERY_FIXTURES: list[MachineryFixture] = [ + MachineryFixture(test_id="abc-impl", name="_abc_impl", expected=True), + MachineryFixture(test_id="is-protocol", name="_is_protocol", expected=True), + MachineryFixture( + test_id="is-runtime-protocol", + name="_is_runtime_protocol", + expected=True, + ), + MachineryFixture(test_id="namedtuple-fields", name="_fields", expected=True), + MachineryFixture( + test_id="namedtuple-field-defaults", + name="_field_defaults", + expected=True, + ), + MachineryFixture(test_id="authored-private", name="_resolve", expected=None), + MachineryFixture(test_id="namedtuple-method", name="_replace", expected=None), + MachineryFixture(test_id="dunder", name="__init__", expected=None), + MachineryFixture(test_id="public", name="timeout", expected=None), +] + + +@pytest.mark.parametrize( + list(MachineryFixture._fields), + _MACHINERY_FIXTURES, + ids=[f.test_id for f in _MACHINERY_FIXTURES], +) +def test_skip_machinery_members( + test_id: str, + name: str, + expected: bool | None, +) -> None: + """Names Python injects are hidden; everything else defers.""" + assert skip_machinery_members(None, "class", name, None, False, None) is expected + + +def test_skip_machinery_members_defers_to_a_consumer() -> None: + """gp-sphinx connects late so a consumer's own handler decides first.""" + connected: list[tuple[str, object, int]] = [] + + class _FakeApp: + def add_js_file(self, *args: object, **kwargs: object) -> None: ... + def add_lexer(self, *args: object, **kwargs: object) -> None: ... + def connect( + self, + event: str, + handler: object, + priority: int = 500, + ) -> None: + connected.append((event, handler, priority)) + + gp_sphinx.config.setup(t.cast("t.Any", _FakeApp())) + skips = [c for c in connected if c[0] == "autodoc-skip-member"] + assert len(skips) == 1 + assert skips[0][1] is skip_machinery_members + assert skips[0][2] > 500 From 8ef2215e3e6749aada76bf08bcfc2ce5b363427b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 19:12:23 -0500 Subject: [PATCH 19/22] docs(config): Give the machinery filter real links why: Three cross-references in the setup table and its paragraph resolved to nothing. typing.NamedTuple is a class, so the function role could never match and appended a spurious call suffix; pydantic has no intersphinx inventory here; and MACHINERY_MEMBERS was cited before anything documented it. nitpicky is unset, so all three rendered as plain text and the build stayed silent. what: - Document skip_machinery_members and MACHINERY_MEMBERS in the API reference, giving the data role a destination - Use the class role for typing.NamedTuple, and name abc.ABCMeta, which is what actually writes _abc_impl - Link pydantic as an external project, per the docs voice guide --- docs/api.md | 12 ++++++++++++ docs/configuration.md | 15 ++++++++------- packages/gp-sphinx/src/gp_sphinx/config.py | 2 +- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/docs/api.md b/docs/api.md index c5d28236..e3c87dca 100644 --- a/docs/api.md +++ b/docs/api.md @@ -56,3 +56,15 @@ globals().update(conf) ```{eval-rst} .. autofunction:: gp_sphinx.config.setup ``` + +## skip_machinery_members + +Python writes a handful of private attributes into the classes it +builds, and `private-members` is on by default, so autodoc would render +each one as a name with nothing after it. This handler hides them. + +```{eval-rst} +.. autofunction:: gp_sphinx.config.skip_machinery_members + +.. autodata:: gp_sphinx.config.MACHINERY_MEMBERS +``` diff --git a/docs/configuration.md b/docs/configuration.md index a5fe5b27..90f58c12 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -93,17 +93,18 @@ browser helpers, autodoc filter, and lexer aliases into the Sphinx app: | `app.connect("html-page-context", _inject_copybutton_bridge)` | Adds copybutton prompt settings to the page context so copied examples stay prompt-aware after SPA navigation | | `app.connect("html-page-context", _inject_fowt_prevention)` | Injects the early theme script that prevents a flash of the wrong theme before Furo initializes | | `app.connect("build-finished", remove_tabs_js)` | Removes `_static/tabs.js` after HTML builds as a `sphinx-inline-tabs` workaround | -| `app.connect("autodoc-skip-member", skip_machinery_members, priority=900)` | Hides the {py:data}`gp_sphinx.config.MACHINERY_MEMBERS` names that `abc`, {py:class}`typing.Protocol`, and {py:func}`typing.NamedTuple` write into a class | +| `app.connect("autodoc-skip-member", skip_machinery_members, priority=900)` | Hides the {py:data}`gp_sphinx.config.MACHINERY_MEMBERS` names that {py:class}`abc.ABCMeta`, {py:class}`typing.Protocol`, and {py:class}`typing.NamedTuple` write into a class | | `app.add_lexer("myst", MystLexer)` | Registers the MyST lexer alias used by Markdown examples | | `app.add_lexer("myst-md", MystLexer)` | Registers the alternate MyST lexer alias used by Markdown examples | `private-members` is on by default, so without that filter every -{py:class}`~pydantic.BaseModel` subclass ships an `_abc_impl` entry and -every {py:func}`~typing.NamedTuple` ships `_fields` and -`_field_defaults` — names no docstring can reach, because no author -wrote them. Since `autodoc-skip-member` takes the first non-`None` -answer and this handler is connected late, a project that wants one of -them on the page registers its own handler and answers `False`. +[pydantic](https://docs.pydantic.dev/) model subclass ships an +`_abc_impl` entry and every {py:class}`~typing.NamedTuple` ships +`_fields` and `_field_defaults` — names no docstring can reach, because +no author wrote them. Since `autodoc-skip-member` takes the first +non-`None` answer and this handler is connected late, a project that +wants one of them on the page registers its own handler and answers +`False`. ## Always-set coordinator values diff --git a/packages/gp-sphinx/src/gp_sphinx/config.py b/packages/gp-sphinx/src/gp_sphinx/config.py index 1cb093e5..1469a454 100644 --- a/packages/gp-sphinx/src/gp_sphinx/config.py +++ b/packages/gp-sphinx/src/gp_sphinx/config.py @@ -790,7 +790,7 @@ class is present and ``body[data-theme]`` is unset — body becomes """Private names Python's own machinery attaches to a class. :class:`abc.ABCMeta`, :class:`typing.Protocol`, and -:func:`typing.NamedTuple` each write attributes into the classes they +:class:`typing.NamedTuple` each write attributes into the classes they build. No author declares them, so no docstring can reach them, yet :data:`DEFAULT_AUTODOC_OPTIONS` turns ``private-members`` on and autodoc renders every one as an entry with a name and nothing else. From 775b84392f150732e20a38791db3c59946affbb6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 19:14:54 -0500 Subject: [PATCH 20/22] docs(typehints): Explain class-level name rules why: The extension decides what every field, key, enum member, and class variable shows on a reference page, and none of that was written down. The consequence a reader most needs to know -- an undescribed class variable is withheld rather than rendered bare -- was reachable only by noticing a member had gone missing. what: - Add a class-level names section covering the three places a description can live and what each shape renders - State that a base class's description carries to its subclasses - Name the withholding trade-off and the config value that opts back in --- .../sphinx-autodoc-typehints-gp/how-to.md | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/docs/packages/sphinx-autodoc-typehints-gp/how-to.md b/docs/packages/sphinx-autodoc-typehints-gp/how-to.md index 24a1e43f..ac2e4415 100644 --- a/docs/packages/sphinx-autodoc-typehints-gp/how-to.md +++ b/docs/packages/sphinx-autodoc-typehints-gp/how-to.md @@ -32,9 +32,82 @@ and skips its own plain-text duplicates — cooperation, not conflict. - No text-level race conditions with Napoleon. - Hides incidental doctest setup marked `# doctest: +HIDE` from rendered docstrings, so plumbing can execute without cluttering the example. +- Gives every class-level name — fields, keys, enum members, class + variables — exactly one description, wherever you wrote it. See + {ref}`class-level-names`. - Exposes reusable helpers for annotation display classification and rendered type paragraphs used by the other autodoc packages. +(class-level-names)= + +## Describing class-level names + +A class declares more than methods. `NamedTuple` fields, dataclass fields +including {py:class}`~dataclasses.InitVar`, {py:class}`~typing.TypedDict` +keys, {py:class}`~enum.Enum` members, {py:data}`~typing.ClassVar` +declarations, and plain constants all reach your reference page. Each one +gets its description exactly once, from whichever of three places you +wrote it in. + +A NumPy `Attributes` entry in the class docstring is the usual choice, +because it keeps every field's prose together where a reader meets the +class: + +```python +class Retry(t.NamedTuple): + """How often to retry, and how long to wait. + + Attributes + ---------- + attempts : int + Total tries, including the first. + backoff : float + Seconds multiplied by the attempt number between tries. + """ + + attempts: int + backoff: float +``` + +A docstring directly under the assignment, or a `#:` comment above it, +counts equally. Reach for those when a field's explanation is long enough +to crowd the class docstring: + +```python +class Limits: + #: Requests allowed per minute before throttling starts. + rate: t.ClassVar[int] = 60 + + timeout: t.ClassVar[float] = 5.0 + """Seconds to wait for a response before giving up.""" +``` + +Describing a name costs you nothing in the rendered signature. The entry +keeps whatever autodoc computed for it — an enum member still shows its +value, a dataclass field its annotation and its default. + +Write the description on the class that *declares* the name. A subclass +that inherits the field inherits the description with it, so a base class +documenting forty fields does not oblige each subclass to repeat them. + +### What happens to a name you describe nowhere + +A field nobody describes reaches the page as a bare name with a type and +no prose. That is the honest result: the reader can see the field exists +and that nothing was said about it. + +A `ClassVar` nobody describes is withheld from the page instead. Class +variables are frequently internal — a registry, a cached sentinel, a +counter — and a reference page listing them bare says less than one that +omits them. The trade-off is that an undescribed class variable goes +missing rather than looking empty, so a name you *meant* to publish +disappears until you describe it. If your project would rather see them +all, turn them back on: + +```python +gp_typehints_show_undocumented_class_vars = True +``` + ## Hiding incidental doctest setup A docstring example often needs plumbing to run — building an environment From 850c02a4d66854334cc450a52686a13bdbb3db59 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 19:18:32 -0500 Subject: [PATCH 21/22] ai(rules[AGENTS]): Correct the xref build claim why: The cross-reference guidance said a docs build catches a broken reference and nothing else does. That holds for a {ref}, which warns on an unresolved label, but not for a py-domain role: nitpicky is unset, so an unresolved {py:class} or {py:data} renders as plain text in silence. Following the rule as written and trusting a clean build shipped three dead links on this branch. what: - Scope the build-catches-it claim to {ref} - Name the two ways a py-domain role fails silently: a symbol nothing autodocs, and a project missing from intersphinx_mapping - Add a checklist line to confirm each new role rendered as a link --- docs/AGENTS.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 4b998ef6..e6b8c1c3 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -100,8 +100,18 @@ root `AGENTS.md`; docs pages here usually spell the py-domain forms explicitly (`{py:func}`, `{py:data}`, `{py:mod}`). A `{ref}` must match its target's anchor exactly — anchors mix hyphen and underscore forms, sometimes inside one anchor (`from-docs_url`). `just -build-docs` catches a broken cross-reference; nothing else does — so -build the docs before you commit. +build-docs` catches a broken `{ref}`; nothing else does — so build the +docs before you commit. + +A py-domain role is not covered by that build check. `nitpicky` is +unset, so an unresolved `{py:class}` or `{py:data}` renders as plain +text and the build stays silent — a clean build is no evidence the +link works. Two ways to get one wrong: citing a symbol nothing +autodocs (add the directive, or drop the role), and citing a project +absent from `intersphinx_mapping` in `docs/conf.py`, which currently +maps only `py` and `sphinx` — every other project is a Markdown link. +Confirm by opening the built page and checking the name sits inside an +``. Link the first prose mention of any symbol that has a useful destination on that page. This includes Python objects, gp-sphinx @@ -142,3 +152,5 @@ reshaping another page. cross-reference exact — and generated sections to their directives? - Did `just build-docs` stay clean — no new warning, no broken cross-reference? +- Did you open the built page and confirm each new py-domain role + rendered as a link? A silent build does not prove it. From abb668292bad484b6d8a3c495b3524c41b101467 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 19:53:12 -0500 Subject: [PATCH 22/22] docs(CHANGES) Class-level name rendering why: The branch changes what a reference page shows for every field, key, enum member, and class variable, against behavior users of the published release already have. what: - Record the withheld undescribed class variable as a breaking change, with the description styles and config value that restore it - Describe inherited descriptions, module constant values, and the machinery-member filter - Limit Fixes to the three regressions a published release exhibited, leaving out repairs to behavior this branch itself introduced - Note the how-to and the packages whose reference fields gained descriptions --- CHANGES | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/CHANGES b/CHANGES index 9b689978..21ba7ea7 100644 --- a/CHANGES +++ b/CHANGES @@ -18,6 +18,77 @@ $ uv add gp-sphinx --prerelease allow +### Breaking changes + +#### An undescribed class variable is withheld from the reference + +A `ClassVar` nobody describes no longer renders. Class variables are +often internal — a registry, a cached sentinel, a counter — and a page +listing them bare says less than one that omits them. The cost is that a +class variable you meant to publish goes missing rather than looking +empty. + +Describe it — in an `Attributes` entry, a docstring under the +assignment, or a `#:` comment — and it returns. To render them all +regardless, set `gp_typehints_show_undocumented_class_vars = True`. See +{ref}`class-level-names`. (#72) + +### What's new + +#### A description written on the base reaches every subclass + +A dataclass field or typed-dictionary key described where it is declared +now carries that description to every subclass inheriting it. A base +documenting a wide option set no longer obliges each subclass to restate +it. (#72) + +#### Module constants render their value + +A constant described in its module docstring's `Attributes` section now +renders the value the live module holds, and registers in the Python +domain as data, so a cross-reference to it resolves. (#72) + +#### Machinery members stay off the page + +`private-members` is on by default, so the private attributes Python's +own machinery writes into a class — an abstract base's registry cache, a +named tuple's field list — reached the page as names no docstring could +describe. They are filtered out. A project wanting one back answers from +its own `autodoc-skip-member` handler, which is asked first. (#72) + +### Fixes + +#### A described member keeps its description + +An `Attributes` entry naming a class variable, an init-only field, a +property, a method, or a nested class lost its description once autodoc +rendered that member, leaving the entry empty. Each now carries the +prose its author wrote. (#72) + +#### A described member keeps its signature + +Describing a member cost the reader the annotation and value autodoc +computes from the live class — an enum member rendered without its +value, a constant without any. Both are restored. (#72) + +#### Documenting a typed dictionary keeps its subclasses' keys + +Documenting a typed dictionary alongside its subclasses made every key +the subclasses inherit disappear from their own pages. (#72) + +### Documentation + +#### Class-level names have a how-to + +The rules every class-level name follows — the three places a +description can live, what each shape renders, and which names are +withheld — are written down. See {ref}`class-level-names`. (#72) + +#### More fields describe themselves in the API reference + +The layout, badges, and Vite-builder packages now say what each field +holds rather than rendering a bare name and its type. (#72) + ### Development #### Lint toolchain: ruff 0.16