Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ff7a990
typehints-gp(fix): Describe class members once
tony Jul 26, 2026
032ccd9
typehints-gp(feat): Hide undescribed class vars
tony Jul 26, 2026
068a653
docs(typehints): Drop stale member exclusion
tony Jul 26, 2026
1752ada
typehints-gp(fix): Keep :no-index: out of prose
tony Jul 26, 2026
fbba774
typehints-gp(fix): Ignore inherited docstrings
tony Jul 26, 2026
8ed214c
typehints-gp(fix): Keep method and class prose
tony Jul 26, 2026
f6c6d84
typehints-gp(fix): Render members with signatures
tony Jul 26, 2026
d4b8a0b
typehints-gp(test): Match autodoc's own signatures
tony Jul 26, 2026
cc71217
docs(fields): Describe the remaining shape fields
tony Jul 26, 2026
679991a
ai(rules[AGENTS]) Describe every class-level name
tony Jul 26, 2026
0040466
tests(docs): Enforce class-level name descriptions
tony Jul 26, 2026
cba967a
typehints-gp(fix): Honor a consumer's skip decision
tony Jul 26, 2026
722607d
typehints-gp(fix): Keep inherited typed-dictionary keys
tony Jul 26, 2026
db74cc9
typehints-gp(feat): Describe module constants with their value
tony Jul 26, 2026
cffeedf
typehints-gp(test): Pin the awkward member shapes
tony Jul 26, 2026
d5b78b2
typehints-gp(fix): Keep a described constant's declared type
tony Jul 26, 2026
a58ce40
typehints-gp(feat): Describe a field from the base that declares it
tony Jul 26, 2026
751c672
config(feat[autodoc]): Hide machinery members
tony Jul 26, 2026
8ef2215
docs(config): Give the machinery filter real links
tony Jul 27, 2026
775b843
docs(typehints): Explain class-level name rules
tony Jul 27, 2026
850c02a
ai(rules[AGENTS]): Correct the xref build claim
tony Jul 27, 2026
abb6682
docs(CHANGES) Class-level name rendering
tony Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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

Expand Down
71 changes: 71 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,77 @@ $ uv add gp-sphinx --prerelease allow

<!-- To maintainers and contributors: Please add notes for the forthcoming version below -->

### 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
Expand Down
16 changes: 14 additions & 2 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<a>`.

Link the first prose mention of any symbol that has a useful
destination on that page. This includes Python objects, gp-sphinx
Expand Down Expand Up @@ -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.
12 changes: 12 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
12 changes: 11 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,17 +85,27 @@ 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 |
| --- | --- |
| `app.add_js_file("js/spa-nav.js", loading_method="defer")` | Registers the bundled SPA navigation script from `sphinx-gp-theme` |
| `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:
Expand Down
73 changes: 73 additions & 0 deletions docs/packages/sphinx-autodoc-typehints-gp/how-to.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 0 additions & 5 deletions docs/packages/sphinx-autodoc-typehints-gp/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 89 additions & 2 deletions packages/gp-sphinx/src/gp_sphinx/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------
Expand All @@ -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)
Loading