Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
87 changes: 86 additions & 1 deletion sphinx/ext/autodoc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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():
Expand Down
2 changes: 2 additions & 0 deletions tests/roots/test-ext-autodoc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@
.. autofunction:: target.typehints.incr

.. autofunction:: target.typehints.tuple_args

.. include:: inherited_attrs.rst
11 changes: 11 additions & 0 deletions tests/roots/test-ext-autodoc/inherited_attrs.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Inherited attributes
=====================

.. autoclass:: target.inherited_attrs.Child
:members:
:undoc-members:
:inherited-members:

.. autoclass:: target.inherited_attrs.Child
:members:
:undoc-members:
25 changes: 25 additions & 0 deletions tests/roots/test-ext-autodoc/target/inherited_attrs.py
Original file line number Diff line number Diff line change
@@ -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"
38 changes: 38 additions & 0 deletions tests/test_ext_autodoc_inherited_attrs.py
Original file line number Diff line number Diff line change
@@ -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