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 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 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. 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 40166db1..90f58c12 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,19 @@ 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 {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 +[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 These are injected even though they are not exposed as `DEFAULT_*` constants: 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 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 diff --git a/packages/gp-sphinx/src/gp_sphinx/config.py b/packages/gp-sphinx/src/gp_sphinx/config.py index bd4c133d..1469a454 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 +: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. + +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/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..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,8 +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, + active_module_field, +) from sphinx_autodoc_typehints_gp._resolvers import ( ResolveContext, Resolver, @@ -96,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) @@ -113,12 +159,37 @@ 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" priority = AttributeDocumenter.priority + 1 + def update_annotations(self, parent: t.Any) -> None: + """Merge type-comment annotations, except into a typed dictionary. + + 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 + ---------- + 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) + 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) 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..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 @@ -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 @@ -36,11 +46,14 @@ 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.errors import PycodeError +from sphinx.ext.autodoc import Documenter, MethodDocumenter, PropertyDocumenter +from sphinx.pycode import ModuleAnalyzer if t.TYPE_CHECKING: from sphinx.application import Sphinx @@ -51,9 +64,28 @@ _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|$)") +_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): @@ -417,6 +449,209 @@ 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*. + + 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 + """ + 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) + if not isinstance(module, str) or not isinstance(qualname, str): + continue + try: + analyzer = ModuleAnalyzer.for_module(module) + analyzer.analyze() + except PycodeError: + continue + 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: + """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. @@ -456,7 +691,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 @@ -479,9 +715,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}.. "): @@ -563,16 +808,657 @@ 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. + + 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 + ---------- + 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.'] + + >>> _strip_rendered_fields([":no-index:", "", "Offset."]) + ['Offset.'] + """ + kept: list[str] = [] + dropping = False + for line in body: + if _DIRECTIVE_OPTION_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, _FieldDoc]: + """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, _FieldDoc] + Description and declared type by field name. + + Examples + -------- + >>> _field_doc_bodies( + ... [ + ... " .. gp-sphinx-documented-field: demo.Item value", + ... " .. attribute:: value", + ... "", + ... " Horizontal offset.", + ... "", + ... " :type: int", + ... ], + ... ".. gp-sphinx-documented-field: demo.Item ", + ... ) + {'value': _FieldDoc(prose=['Horizontal offset.'], declared_type='int')} + """ + bodies: dict[str, _FieldDoc] = {} + 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) + + dedented = textwrap.dedent("\n".join(block)).splitlines() + body = _strip_rendered_fields(dedented) + if body: + bodies[name] = _FieldDoc(prose=body, declared_type=_declared_type(dedented)) + return bodies + + +_ACTIVE_FIELD_DOCS: list[dict[str, _FieldDoc]] = [] +_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) -> _FieldDoc | 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 + ------- + _FieldDoc | None + Description and declared type, 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) + + +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. + + 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. + """ + + 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 + 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 + ------- + 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() + 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: + """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() + 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) + 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) + 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] + """``PropertyDocumenter`` that honors an owner's ``Attributes`` entry.""" + + objtype = "property" + 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 _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 ---------- @@ -592,14 +1478,101 @@ 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) - rendered = _rendered_field_names( - result.data[member_start:], - self.objpath, - candidates, - self.indent, - ) - _resolve_marked_fields(result, self.fullname, rendered) + _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() + + +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]: @@ -624,7 +1597,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) @@ -645,6 +1618,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 @@ -668,6 +1645,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 = ( @@ -692,8 +1671,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 ---------- @@ -713,6 +1693,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, @@ -762,6 +1748,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, @@ -783,7 +1804,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 ---------- @@ -817,9 +1841,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 @@ -837,7 +1864,13 @@ def register(app: Sphinx) -> None: >>> register # doctest: +ELLIPSIS """ - app.connect("autodoc-skip-member", skip_documented_fields) + app.add_autodocumenter(GpPropertyDocumenter, override=True) + app.add_autodocumenter(GpMethodDocumenter, override=True) + app.connect( + "autodoc-skip-member", + skip_documented_fields, + priority=_LATE_SKIP_PRIORITY, + ) app.connect( "autodoc-process-signature", _clear_documented_fields, 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/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" 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/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." + ) diff --git a/tests/ext/typehints_gp/test_documented_fields.py b/tests/ext/typehints_gp/test_documented_fields.py index ebddce72..d15b0b18 100644 --- a/tests/ext/typehints_gp/test_documented_fields.py +++ b/tests/ext/typehints_gp/test_documented_fields.py @@ -13,8 +13,12 @@ from sphinx_autodoc_typehints_gp._documented_fields import ( _attribute_directive_names, + _DirectiveEnrichment, + _field_doc_bodies, + _FieldDoc, _is_declared_field, _own_annotations, + _plan_enrichment, ) from tests._sphinx_scenarios import ( SCENARIO_SRCDIR_TOKEN, @@ -92,6 +96,132 @@ 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_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": _FieldDoc(prose=["Horizontal offset."], declared_type="") + } + + +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": _FieldDoc(prose=["Horizontal offset."], declared_type="int") + } + + +# --------------------------------------------------------------------------- +# _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 # --------------------------------------------------------------------------- @@ -466,6 +596,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 @@ -894,6 +1033,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, @@ -1048,6 +1202,124 @@ 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 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. + + 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 = "" + + + 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. + + Attributes + ---------- + reading : int + Most recent meter reading. + """ + + @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. + """ ''' ) @@ -1067,6 +1339,20 @@ 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 + + .. 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 """ ) @@ -1538,6 +1824,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 ) @@ -1615,6 +1905,101 @@ 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_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, +) -> 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_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, +) -> 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, +) -> 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, @@ -1679,68 +2064,772 @@ def test_inherited_field_metadata_does_not_hide_overriding_members( assert f"Render the {kind} override." in html -_CUSTOM_DOCUMENTERS_SOURCE = textwrap.dedent( - """\ +# --------------------------------------------------------------------------- +# visibility of class variables nothing describes +# --------------------------------------------------------------------------- + +_CLASS_VAR_VISIBILITY_SOURCE = textwrap.dedent( + '''\ from __future__ import annotations import typing as t - from sphinx.ext.autodoc import ClassDocumenter, ExceptionDocumenter + class Registry: + """A registry carrying class variables of every description state. - class MarkedClassDocumenter(ClassDocumenter): - def process_doc(self, docstrings: list[list[str]]) -> t.Iterator[str]: - yield from super().process_doc(docstrings) - yield "Custom class documenter marker." + Attributes + ---------- + described : str + Described by the Attributes section. + """ + described: t.ClassVar[str] = "described-value" - class MarkedExceptionDocumenter(ExceptionDocumenter): - def process_doc(self, docstrings: list[list[str]]) -> t.Iterator[str]: - yield from super().process_doc(docstrings) - yield "Custom exception documenter marker." + #: Described by a source comment. + commented: t.ClassVar[str] = "commented-value" + undescribed: t.ClassVar[str] = "undescribed-value" - def setup(app): - app.add_autodocumenter(MarkedClassDocumenter, override=True) - app.add_autodocumenter(MarkedExceptionDocumenter, override=True) - return {"parallel_read_safe": True} + plain_constant = "plain-value" + ''' +) + +_CLASS_VAR_VISIBILITY_INDEX_RST = textwrap.dedent( + """\ + Demo + ==== + + .. autoclass:: class_var_visibility_demo.Registry """ ) -_COMPOSED_DOCUMENTERS_MODULE_SOURCE = textwrap.dedent( - '''\ - from __future__ import annotations +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), + ), + ) - class Record: - """A record with one documented field. - Attributes - ---------- - value : int - Record value. - """ +@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",), + ) - value: int +@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",), + ) - class FieldError(Exception): - """An error with one documented field. - Attributes - ---------- - code : int - Error code. - """ +@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 + ) - code: int - ''' -) +@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") -@pytest.fixture( - scope="module", - params=("before", "after"), + 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 + ) + + +# --------------------------------------------------------------------------- +# 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 +# --------------------------------------------------------------------------- + +_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_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, +) -> 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 +# --------------------------------------------------------------------------- + +_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 +# --------------------------------------------------------------------------- + +_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 +# --------------------------------------------------------------------------- + +_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]] = {{}} + + +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 +""" + + +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."),), + "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( + """\ + 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 = ( + "Level", + "Perm", + "Commented", + "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"] + + +@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 + + import typing as t + + from sphinx.ext.autodoc import ClassDocumenter, ExceptionDocumenter + + + class MarkedClassDocumenter(ClassDocumenter): + def process_doc(self, docstrings: list[list[str]]) -> t.Iterator[str]: + yield from super().process_doc(docstrings) + yield "Custom class documenter marker." + + + class MarkedExceptionDocumenter(ExceptionDocumenter): + def process_doc(self, docstrings: list[list[str]]) -> t.Iterator[str]: + yield from super().process_doc(docstrings) + yield "Custom exception documenter marker." + + + def setup(app): + app.add_autodocumenter(MarkedClassDocumenter, override=True) + app.add_autodocumenter(MarkedExceptionDocumenter, override=True) + return {"parallel_read_safe": True} + """ +) + +_COMPOSED_DOCUMENTERS_MODULE_SOURCE = textwrap.dedent( + '''\ + from __future__ import annotations + + + class Record: + """A record with one documented field. + + Attributes + ---------- + value : int + Record value. + """ + + value: int + + + class FieldError(Exception): + """An error with one documented field. + + Attributes + ---------- + code : int + Error code. + """ + + code: int + ''' +) + + +@pytest.fixture( + scope="module", + params=("before", "after"), ids=("custom-before", "custom-after"), ) def composed_documenters_html_result( @@ -2257,10 +3346,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" ) 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