Skip to content

Describe every class member once, with its real signature#72

Merged
tony merged 22 commits into
mainfrom
class-variables
Jul 27, 2026
Merged

Describe every class member once, with its real signature#72
tony merged 22 commits into
mainfrom
class-variables

Conversation

@tony

@tony tony commented Jul 26, 2026

Copy link
Copy Markdown
Member

A class member described in a NumPy Attributes section was rendered from docstring text alone. That text knows only what its author typed, so the entry arrived without the annotation and value autodoc reads off the live class — and for several kinds of member it arrived with no description at all, because the author's sentence had been deleted on the way.

Both halves are closed here. Describing a member now costs the reader nothing: the entry keeps the author's prose and the signature autodoc would have rendered.

Before:

kind: ClassVar[str]
READONLY
timeout
weight

After:

kind: ClassVar[str]
    Stable discriminator, also the registry key.
READONLY = 'readonly'
    Read-only operations.
timeout = 30
    Maximum wait in seconds.
weight: int = 0
    Relative weight.

Descriptions that were being deleted

A member with no docstring of its own reaches the page as a signature with nothing beneath it. Autodoc has no description to attach, so the Attributes entry describing it was discarded in favour of an empty entry — silently, with no warning.

FieldDocFallbackMixin supplies that description from get_doc(), which is the same seam autodoc already uses to reattach a #: source comment. It reaches class variables, init-only dataclass fields, properties, methods, and nested classes; an Attributes section is for attributes, so naming a method there is a docstring its author should rework, but rendering the entry keeps that decision theirs.

Three narrower cases fall out of the same path. A :no-index: on the owning class was written into each emitted directive and rode along into the reattached description, rendering as a No-index field above the author's sentence. A property overriding an inherited dataclass field has no docstring, so autodoc walked the MRO and found the base field's default — a str, whose docstring is str.__doc__; being non-empty it counted as a description and displaced the author's. And a class variable nothing describes is now withheld rather than shipped as a bare annotation, with gp_typehints_show_undocumented_class_vars to restore it.

Signatures that were being dropped

Every field directive that survives now has autodoc compute its own :type: and :value:, through the registered attribute documenter and the real class, folded into the emitted block. The authored type leaves the body once autodoc supplies a real annotation.

Asking the registered documenter rather than recomputing the answer is what makes this small. Slot-held fields, _tuplegetter descriptors, TypedDict keys, defaultless dataclass fields, mocks, :meta hide-value:, autodoc_typehints = "none", and this package's own long-value curation all behave correctly because autodoc decides each one, not a table maintained here that would drift against it.

A type alias is the exception and is left alone: autodoc documents a TypeVar or NewType as a class and prints alias of X, so its raw repr is not something autodoc would ever render.

Verifying

The invariant is stated as a differential build: the same class bodies are documented twice, once with Attributes sections and once without, and every member's rendered signature must match between them. The control build defines what each member is entitled to, so the check cannot drift away from autodoc's own behavior.

It covers enum.Enum and enum.StrEnum members, annotated and unannotated class constants, dataclass fields with and without defaults, a slots=True field, NamedTuple fields, TypedDict required and NotRequired keys, and both a plain and a #:-commented class variable.

Beyond the suite, consumer sites were rebuilt with this branch shadowing the installed package. tmuxp's Colors constants regain their values with their prose and type intact, at an unchanged warning count. An operations base class with ten class variables keeps every description. A pydantic-based site keeps pydantic's own text for model_config, which is described upstream and so is not an undescribed class variable.

Test plan

  • just ruff-format, just ruff, uv run mypy src tests packages
  • just test — full suite green
  • just build-docs — clean, no new warnings, no duplicate object descriptions
  • Differential signature check passes; it fails on the parent commit
  • tmuxp rebuild — Colors constants regain = 'green', warning count unchanged
  • Consumer rebuilds — no object lost, objects.inv unchanged

@codecov-commenter

codecov-commenter commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.98137% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.94%. Comparing base (3387bb2) to head (abb6682).

Files with missing lines Patch % Lines
.../sphinx_autodoc_typehints_gp/_documented_fields.py 96.66% 11 Missing ⚠️
.../src/sphinx_autodoc_typehints_gp/_data_defaults.py 92.30% 1 Missing ⚠️
tests/docs/test_docstring_policy.py 98.70% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #72      +/-   ##
==========================================
+ Coverage   92.80%   92.94%   +0.14%     
==========================================
  Files         275      276       +1     
  Lines       22123    22753     +630     
==========================================
+ Hits        20531    21148     +617     
- Misses       1592     1605      +13     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tony tony changed the title Describe class-level members once, and only when documented Describe every class member once, with its real signature Jul 26, 2026
@tony
tony force-pushed the class-variables branch 4 times, most recently from d3694be to a4c4183 Compare July 26, 2026 16:19
tony added 20 commits July 26, 2026 19:41
why: A class variable, an init-only dataclass field, and a property
without a docstring all reach the page as a signature with nothing
beneath it. Autodoc renders the annotation and value but has no
description to attach, so the Attributes entry the author wrote was
deleted in favor of an empty entry -- silently, with no warning.

what:
- Capture each marked field directive's prose while a class documents
  its members, dropping the type and value the member renders itself
- Add FieldDocFallbackMixin, returning that prose from get_doc() when
  the member has no description of its own, the way autodoc already
  reattaches a #: source comment
- Apply the mixin to the attribute documenter and register a property
  documenter carrying it
- Cover the class variable, InitVar, and undocumented property cases
why: undoc-members is on by default, so autodoc emits a stub for every
class variable a class declares whether or not anything describes it.
A reference page fills with bare annotations nobody wrote about --
pydantic's model_config on every model, a dataclass's dispatch
metadata on every operation -- and the described members are lost in
the run.

what:
- Skip a class variable with no Attributes entry and no #: comment
- Add gp_typehints_show_undocumented_class_vars to restore them
- Look up source comments the way autodoc does, walking the MRO
  through ModuleAnalyzer, so a described variable always renders
why: AnnotationDisplay holds its fields in slots, and the exclusion
predates the commit that taught the field matcher to recognize a
slot-held field. Removing it leaves one description per field with no
duplicate-description warning.

what:
- Document every AnnotationDisplay field through :members: alone
why: A class documented with :no-index: has that option written into
each attribute directive its Attributes section emits. Reattaching the
description to the member carried the option along with it, so the
member rendered a "No-index" field above the author's sentence.

what:
- Drop :no-index: and :noindex: alongside the type and value when
  lifting a description out of its directive
why: A property overriding an inherited dataclass field has no
docstring of its own, so autodoc walks the MRO and finds the base
field's default value. The default is a str, so str.__doc__ arrived as
though it described the property -- and, being non-empty, it counted as
a description and displaced the author's Attributes entry. The rendered
page showed "str(object='') -> str" where the author had written a
sentence.

what:
- Use the Attributes description unless the member itself carries one,
  either as its own __doc__ or as a #: comment anywhere in the MRO
- Cover a property that overrides an inherited dataclass field
why: An Attributes entry naming a method, staticmethod, classmethod, or
nested class had its directive deleted once autodoc rendered that member,
and the description reattached nowhere -- the fallback reached only the
attribute and property documenters. The author's sentence vanished with
no warning.

what:
- Register a method documenter carrying the description fallback
- Give the wrapped class documenters the same fallback, so a nested
  class keeps the entry describing it
- Cover a method, a staticmethod, and a nested class
why: A directive written from docstring text knows only what its author
typed, so describing a member in an Attributes section cost the reader
the annotation and value autodoc renders from the live class. An enum
member showed READONLY instead of READONLY = 'readonly', and a plain
constant lost its value entirely -- documenting a member made its entry
worse than leaving it undocumented.

what:
- Have autodoc compute :type: and :value: for each surviving field
  directive, through the registered attribute documenter and the real
  class, and fold them into the emitted block
- Drop the authored type from the body once autodoc supplies a real one
- Leave a type alias alone; autodoc documents one as a class and prints
  "alias of X" rather than a value
- Cover the value curation, :meta hide-value:, and absent-member paths
why: The assertions guarding this behavior spell out an expected
signature per shape, so they restate what autodoc does rather than
check against it. A change in how autodoc renders a shape would need
the table edited to match, which is the kind of test that drifts in
lockstep with the code it guards.

what:
- Build one set of class bodies twice, described and undescribed, and
  require every member's rendered signature to match between them
- Cover the enum, constant, generic alias, dataclass, slot, named
  tuple, typed dictionary, and class variable shapes in one table
why: BadgeSpec and ApiFactRow documented their fields under Parameters,
which describes the initializer rather than the attributes -- every
field rendered with an empty description beneath it. Mode's members
carried none at all.

what:
- Move the BadgeSpec and ApiFactRow entries to Attributes sections
- Describe Mode's DEV and PROD members
why: The previous rule required an Attributes section on NamedTuple and
dataclass classes. Both its scope and its stated consequence have since
proven wrong. This is a rule we expect to port to other repositories, so
the reasoning belongs here rather than inside the commit that enforces
it.

Scope: autodoc renders one entry per class-level name, whatever shape
declares it. TypedDict keys, Enum members, ClassVars, InitVars, plain
constants, and nested classes all reach the reference the same way. That
list also kept growing while the rule was being applied -- InitVar and
nested classes were both found after the fact -- so enumerating shapes
dates faster than stating the principle.

Consequence: the old rule said an undescribed field ships as "Alias for
field number 0" or as a bare entry. An undescribed ClassVar is now
withheld from the reference entirely, so it goes missing rather than
rendering empty. That is a failure a reader cannot see and a reviewer
will not spot in the diff.

Styles: the workspace documents fields three ways and autodoc honors all
three -- an Attributes entry, a docstring under the assignment, and a #:
comment. A rule naming only Attributes marks correctly documented code
as broken; two classes here use the docstring form.

Trap: a Parameters section documents the initializer, not the
attributes. BadgeSpec and ApiFactRow each described every field under
Parameters and still rendered every attribute with an empty body.

what:
- Restate the rule around every class-level name autodoc renders
- Name the three accepted styles, preferring Attributes
- Warn that Parameters does not describe attributes
- Correct what happens to an undescribed class variable
why: Nothing enforced the field-documentation rule. tests/docs held
policy tests for hand-authored pages only, so the docstrings the API
reference is generated from went unchecked -- and three classes had
fields nothing described.

what:
- Walk every public NamedTuple, dataclass, TypedDict, and Enum under
  packages/ and fail on a field no supported style describes
- Accept an Attributes entry, a docstring under the assignment, or a
  #: comment, since the workspace uses more than one
- Report the offending class and field names in the failure message
why: autodoc-skip-member stops at the first listener returning a
decision, and this extension connected at the default priority, so it
answered before a project's own conf.py handler. A project could not
re-include a class variable the extension withholds -- returning False
was silently ignored, which the module docstring already promised would
not happen.

what:
- Answer after the default priority, leaving a project's explicit
  decision authoritative
- Cover a handler returning False for a member the extension withholds
why: Autodoc materializes a class's __annotations__ so a # type: comment
can join it. A TypedDict computes its annotations lazily by merging
every base's, and materializing one base's mapping severs that merge --
so documenting a base first made every key its subclasses inherit vanish
from their own pages. The loss hits any project documenting both, with
or without an Attributes section.

what:
- Leave a typed dictionary's lazy annotations alone; it declares keys by
  annotation and has no type comments to merge
- Cover a subclass whose base is documented first
why: A module docstring describing its own constant rendered the prose
and the authored type, never the value. Autodoc reaches for its data
documenter only for a name the source annotates or comments, so a plain
constant was claimed by no documenter at all and the docstring-derived
directive was the only thing rendering it -- as a py:attribute, which is
what a class member is, not a module constant.

what:
- Route a described module constant to the data documenter, so autodoc
  owns the directive and supplies the value from the live module
- Carry the module docstring's prose into it through the existing
  description fallback
- Record the constant as py:data in the inventory
- Cover the value, the objtype, and the description
why: An integer-backed enum, a flag enum built with auto(), and an
attribute annotated only by a type comment were each verified by hand
and then left unheld -- the proof lived in a scratch build that no
longer exists. The parity comparison also had a blind spot: it compares
two mappings, so a shape that stopped rendering on both sides would drop
out of both and still compare equal.

what:
- Add the three shapes to the differential parity table, where each is
  measured against its undescribed twin rather than a literal
- Pin the table's own coverage, so a shape leaving the roster fails
  instead of passing empty
why: Routing a described module constant to the data documenter gave it
the value it had been missing, but cost it the type. Autodoc renders an
annotation only where the source writes one, so for a plain constant the
Attributes entry is the only statement of its type anywhere -- and the
entry's type was being dropped on the assumption the member would render
its own. The reader ended up with prose and a value but no type.

what:
- Carry the type an entry declares alongside its prose
- Reattach it only where the owner annotates nothing itself, so an
  annotated member still states its type once
- Cover an unannotated module constant described with a type
why: A dataclass inherits its bases' fields and a typed dictionary
inherits its bases' keys, but the description stays behind on the base.
The subclass entry therefore rendered bare while the prose sat one class
away -- 134 options on a real consumer, plus 11 typed-dictionary keys.
Asking an author to restate every inherited field on every subclass is
not a reasonable alternative.

what:
- Consult the bases when the class being rendered describes nothing,
  accepting a NumPy Attributes entry or a description at the assignment
- Search __mro__ and __orig_bases__ together; a typed dictionary keeps
  no base in its MRO, so its declaring class is reachable only through
  __orig_bases__
- Cover both shapes
why: private-members is on by default, so abc, typing.Protocol, and
NamedTuple each put names on the page that no author declared and no
docstring can reach. A consumer documenting 45 pydantic models ships
45 _abc_impl entries, each rendering as a name and nothing else.

what:
- Add skip_machinery_members, hiding _abc_impl, _is_protocol,
  _is_runtime_protocol, _fields, and _field_defaults
- Connect it late, so a project wanting one of them keeps it by
  answering False from its own handler
- Leave _replace and _asdict alone; they document themselves
- Drop the one machinery entry from the objects.inv baseline, which
  was never a target anyone could cross-reference
why: Three cross-references in the setup table and its paragraph
resolved to nothing. typing.NamedTuple is a class, so the function
role could never match and appended a spurious call suffix; pydantic
has no intersphinx inventory here; and MACHINERY_MEMBERS was cited
before anything documented it. nitpicky is unset, so all three
rendered as plain text and the build stayed silent.

what:
- Document skip_machinery_members and MACHINERY_MEMBERS in the API
  reference, giving the data role a destination
- Use the class role for typing.NamedTuple, and name abc.ABCMeta,
  which is what actually writes _abc_impl
- Link pydantic as an external project, per the docs voice guide
why: The extension decides what every field, key, enum member, and
class variable shows on a reference page, and none of that was
written down. The consequence a reader most needs to know -- an
undescribed class variable is withheld rather than rendered bare --
was reachable only by noticing a member had gone missing.

what:
- Add a class-level names section covering the three places a
  description can live and what each shape renders
- State that a base class's description carries to its subclasses
- Name the withholding trade-off and the config value that opts back
  in
@tony
tony force-pushed the class-variables branch from 3eec008 to 27cd85d Compare July 27, 2026 00:44
why: The cross-reference guidance said a docs build catches a broken
reference and nothing else does. That holds for a {ref}, which warns
on an unresolved label, but not for a py-domain role: nitpicky is
unset, so an unresolved {py:class} or {py:data} renders as plain text
in silence. Following the rule as written and trusting a clean build
shipped three dead links on this branch.

what:
- Scope the build-catches-it claim to {ref}
- Name the two ways a py-domain role fails silently: a symbol nothing
  autodocs, and a project missing from intersphinx_mapping
- Add a checklist line to confirm each new role rendered as a link
@tony
tony force-pushed the class-variables branch from 27cd85d to 850c02a Compare July 27, 2026 00:50
why: The branch changes what a reference page shows for every field,
key, enum member, and class variable, against behavior users of the
published release already have.

what:
- Record the withheld undescribed class variable as a breaking change,
  with the description styles and config value that restore it
- Describe inherited descriptions, module constant values, and the
  machinery-member filter
- Limit Fixes to the three regressions a published release exhibited,
  leaving out repairs to behavior this branch itself introduced
- Note the how-to and the packages whose reference fields gained
  descriptions
@tony
tony merged commit a3c5e92 into main Jul 27, 2026
44 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants