From 008636cdfdb5f146d88b9ed8a75a93a2bb9eab14 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Fri, 26 Dec 2025 17:37:14 +0000 Subject: [PATCH 1/2] test(autodoc): cover inherited attribute docs --- tests/roots/test-ext-autodoc/index.rst | 2 + .../test-ext-autodoc/inherited_attrs.rst | 11 ++++++ .../target/inherited_attrs.py | 25 ++++++++++++ tests/test_ext_autodoc_inherited_attrs.py | 38 +++++++++++++++++++ 4 files changed, 76 insertions(+) create mode 100644 tests/roots/test-ext-autodoc/inherited_attrs.rst create mode 100644 tests/roots/test-ext-autodoc/target/inherited_attrs.py create mode 100644 tests/test_ext_autodoc_inherited_attrs.py diff --git a/tests/roots/test-ext-autodoc/index.rst b/tests/roots/test-ext-autodoc/index.rst index 1746a0a0313..627079cc7da 100644 --- a/tests/roots/test-ext-autodoc/index.rst +++ b/tests/roots/test-ext-autodoc/index.rst @@ -11,3 +11,5 @@ .. autofunction:: target.typehints.incr .. autofunction:: target.typehints.tuple_args + +.. include:: inherited_attrs.rst diff --git a/tests/roots/test-ext-autodoc/inherited_attrs.rst b/tests/roots/test-ext-autodoc/inherited_attrs.rst new file mode 100644 index 00000000000..ad60804b58f --- /dev/null +++ b/tests/roots/test-ext-autodoc/inherited_attrs.rst @@ -0,0 +1,11 @@ +Inherited attributes +===================== + +.. autoclass:: target.inherited_attrs.Child + :members: + :undoc-members: + :inherited-members: + +.. autoclass:: target.inherited_attrs.Child + :members: + :undoc-members: diff --git a/tests/roots/test-ext-autodoc/target/inherited_attrs.py b/tests/roots/test-ext-autodoc/target/inherited_attrs.py new file mode 100644 index 00000000000..a405c59d70a --- /dev/null +++ b/tests/roots/test-ext-autodoc/target/inherited_attrs.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import ClassVar + + +class Base: + #: Docstring for the base class attribute. + base_attr = 1 + + #: Docstring defined on the base class but overridden in the subclass. + override_attr = 2 + + #: Docstring for an annotation-only ClassVar. + annotated_only: ClassVar[int] + + +class Child(Base): + #: Subclass-specific documentation that overrides the base docstring. + override_attr = 3 + + #: Subclass attribute documented in the subclass. + child_only = 4 + + def __init__(self) -> None: + self.instance_attr = "runtime" diff --git a/tests/test_ext_autodoc_inherited_attrs.py b/tests/test_ext_autodoc_inherited_attrs.py new file mode 100644 index 00000000000..bf8cc2a28c7 --- /dev/null +++ b/tests/test_ext_autodoc_inherited_attrs.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import pytest + +from tests.test_ext_autodoc import do_autodoc + + +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_autodoc_inherited_attribute_docs(app): + options = { + "members": None, + "undoc-members": True, + "inherited-members": True, + } + actual = do_autodoc(app, 'class', 'target.inherited_attrs.Child', options) + output = "\n".join(actual) + + assert '.. py:attribute:: Child.base_attr' in output + assert 'Docstring for the base class attribute.' in output + assert '.. py:attribute:: Child.override_attr' in output + assert 'Subclass-specific documentation that overrides the base docstring.' in output + assert 'Docstring defined on the base class but overridden in the subclass.' not in output + assert '.. py:attribute:: Child.annotated_only' in output + assert 'Docstring for an annotation-only ClassVar.' in output + assert 'instance_attr' not in output + + +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_autodoc_inherited_attribute_docs_control(app): + options = { + "members": None, + "undoc-members": True, + } + actual = do_autodoc(app, 'class', 'target.inherited_attrs.Child', options) + output = "\n".join(actual) + + assert '.. py:attribute:: Child.base_attr' not in output + assert 'Docstring for the base class attribute.' not in output From 3030a18d768dfa9862a75f0b782bc2b6893e72f9 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Fri, 26 Dec 2025 17:59:53 +0000 Subject: [PATCH 2/2] fix(autodoc): preserve inherited attribute docs --- sphinx/ext/autodoc/__init__.py | 87 +++++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index ddfd2b365b4..7fb5de83821 100644 --- a/sphinx/ext/autodoc/__init__.py +++ b/sphinx/ext/autodoc/__init__.py @@ -349,12 +349,66 @@ def __init__(self, directive: "DocumenterBridge", name: str, indent: str = '') - self.parent = None # type: Any # the module analyzer to get at attribute docs, or None self.analyzer = None # type: ModuleAnalyzer + # memoized analyzers for base classes + self._mro_analyzers = {} # type: Dict[str, Optional[ModuleAnalyzer]] @property def documenters(self) -> Dict[str, "Type[Documenter]"]: """Returns registered Documenter classes""" return self.env.app.registry.documenters + def _get_inherited_doc_store(self) -> Dict[type, Dict[str, Optional[List[str]]]]: + store = getattr(self.directive, '_autodoc_inherited_attr_docs', None) + if store is None: + store = {} + self.directive._autodoc_inherited_attr_docs = store + return store + + def _get_mro_analyzer(self, modname: str) -> Optional[ModuleAnalyzer]: + if modname in self._mro_analyzers: + return self._mro_analyzers[modname] + + analyzer = None + try: + analyzer = ModuleAnalyzer.for_module(modname) + analyzer.find_attr_docs() + except PycodeError as exc: + logger.debug('[autodoc] module analyzer failed for %s: %s', modname, exc) + analyzer = None + + self._mro_analyzers[modname] = analyzer + if analyzer: + self.directive.filename_set.add(analyzer.srcname) + return analyzer + + def _lookup_inherited_doc(self, attrname: str) -> Optional[List[str]]: + if not inspect.isclass(self.object): + return None + + store = self._get_inherited_doc_store() + class_store = store.setdefault(self.object, {}) + if attrname in class_store: + return class_store[attrname] + + for base in self.object.__mro__[1:]: # type: ignore[attr-defined] + modname = safe_getattr(base, '__module__', None) + qualname = safe_getattr(base, '__qualname__', None) + if not modname or not qualname: + continue + + analyzer = self._get_mro_analyzer(modname) + if analyzer is None: + continue + + attr_docs = analyzer.find_attr_docs() + if (qualname, attrname) in attr_docs: + doc = list(attr_docs[(qualname, attrname)]) + class_store[attrname] = doc + return doc + + class_store[attrname] = None + return None + def add_line(self, line: str, source: str, *lineno: int) -> None: """Append one line of generated reST to the output.""" if line.strip(): # not a blank line @@ -607,6 +661,17 @@ def add_content(self, more_content: Optional[StringList], no_docstring: bool = F for i, line in enumerate(self.process_doc(docstrings)): self.add_line(line, sourcename, i) + if (not no_docstring and self.options.inherited_members and self.objpath and + inspect.isclass(self.parent)): + store = getattr(self.directive, '_autodoc_inherited_attr_docs', {}) + inherited_doc = store.get(self.parent, {}).get(self.objpath[-1]) + if inherited_doc: + no_docstring = True + docstrings = [list(inherited_doc)] + + for i, line in enumerate(self.process_doc(docstrings)): + self.add_line(line, sourcename, i) + # add content from docstrings if not no_docstring: docstrings = self.get_doc() @@ -718,6 +783,13 @@ def is_filtered_inherited_member(name: str) -> bool: has_doc = bool(doc) + if ((namespace, membername) not in attr_docs and not has_doc and + self.options.inherited_members and inspect.isclass(self.object) and + member is not INSTANCEATTR and + self._lookup_inherited_doc(membername)): + has_doc = True + isattr = True + metadata = extract_metadata(doc) if 'private' in metadata: # consider a member private if docstring has "private" metadata @@ -743,7 +815,11 @@ def is_filtered_inherited_member(name: str) -> bool: elif is_filtered_inherited_member(membername): keep = False else: - keep = has_doc or self.options.undoc_members + if (membername == '__annotations__' and + self.options.special_members is ALL): + keep = False + else: + keep = has_doc or self.options.undoc_members else: keep = False elif (namespace, membername) in attr_docs: @@ -899,6 +975,8 @@ def generate(self, more_content: Optional[StringList] = None, real_modname: str guess_modname = self.get_real_modname() self.real_modname = real_modname or guess_modname + self._mro_analyzers = {} + # try to also get a source code analyzer for attribute docs try: self.analyzer = ModuleAnalyzer.for_module(self.real_modname) @@ -923,6 +1001,13 @@ def generate(self, more_content: Optional[StringList] = None, real_modname: str except PycodeError: pass + if self.options.inherited_members and inspect.isclass(self.object): + for base in getattr(self.object, '__mro__', ())[1:]: # type: ignore[attr-defined] + modname = safe_getattr(base, '__module__', None) + if not modname: + continue + self._get_mro_analyzer(modname) + # check __module__ of object (for members not given explicitly) if check_module: if not self.check_module():