diff --git a/CHANGES b/CHANGES index 52f00866b..b4dbf1ef7 100644 --- a/CHANGES +++ b/CHANGES @@ -79,6 +79,7 @@ Bugs fixed * #8522: autodoc: ``__bool__`` method could be called * #8067: autodoc: A typehint for the instance variable having type_comment on super class is not displayed +* #8545: autodoc: a __slots__ attribute is not documented even having docstring * #8477: autosummary: non utf-8 reST files are generated when template contains multibyte characters * #8501: autosummary: summary extraction splits text after "el at." unexpectedly diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index 9ae115ef4..6ce0fbf9c 100644 --- a/sphinx/ext/autodoc/__init__.py +++ b/sphinx/ext/autodoc/__init__.py @@ -26,7 +26,8 @@ from sphinx.config import ENUM, Config from sphinx.deprecation import (RemovedInSphinx40Warning, RemovedInSphinx50Warning, RemovedInSphinx60Warning) from sphinx.environment import BuildEnvironment -from sphinx.ext.autodoc.importer import get_module_members, get_object_members, import_object +from sphinx.ext.autodoc.importer import (get_class_members, get_module_members, + get_object_members, import_object) from sphinx.ext.autodoc.mock import mock from sphinx.locale import _, __ from sphinx.pycode import ModuleAnalyzer, PycodeError @@ -274,9 +275,11 @@ class ObjectMember(tuple): def __new__(cls, name: str, obj: Any, **kwargs: Any) -> Any: return super().__new__(cls, (name, obj)) # type: ignore - def __init__(self, name: str, obj: Any, skipped: bool = False) -> None: + def __init__(self, name: str, obj: Any, docstring: Optional[str] = None, + skipped: bool = False) -> None: self.__name__ = name self.object = obj + self.docstring = docstring self.skipped = skipped @@ -708,6 +711,11 @@ class Documenter: cls_doc = self.get_attr(cls, '__doc__', None) if cls_doc == doc: doc = None + + if isinstance(obj, ObjectMember) and obj.docstring: + # hack for ClassDocumenter to inject docstring via ObjectMember + doc = obj.docstring + has_doc = bool(doc) metadata = extract_metadata(doc) @@ -1576,7 +1584,7 @@ class ClassDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # type: self.add_line(' ' + _('Bases: %s') % ', '.join(bases), sourcename) def get_object_members(self, want_all: bool) -> Tuple[bool, ObjectMembers]: - members = get_object_members(self.object, self.objpath, self.get_attr, self.analyzer) + members = get_class_members(self.object, self.objpath, self.get_attr, self.analyzer) if not want_all: if not self.options.members: return False, [] # type: ignore @@ -1584,16 +1592,18 @@ class ClassDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # type: selected = [] for name in self.options.members: # type: str if name in members: - selected.append((name, members[name].value)) + selected.append(ObjectMember(name, members[name].value, + docstring=members[name].docstring)) else: logger.warning(__('missing attribute %s in object %s') % (name, self.fullname), type='autodoc') return False, selected elif self.options.inherited_members: - return False, [(m.name, m.value) for m in members.values()] + return False, [ObjectMember(m.name, m.value, docstring=m.docstring) + for m in members.values()] else: - return False, [(m.name, m.value) for m in members.values() - if m.directly_defined] + return False, [ObjectMember(m.name, m.value, docstring=m.docstring) + for m in members.values() if m.class_ == self.object] def get_doc(self, encoding: str = None, ignore: int = None) -> List[List[str]]: if encoding is not None: diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index ec05a8a3d..d6e73d36a 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -241,6 +241,83 @@ def get_object_members(subject: Any, objpath: List[str], attrgetter: Callable, return members +class ClassAttribute: + """The attribute of the class.""" + + def __init__(self, cls: Any, name: str, value: Any, docstring: Optional[str] = None): + self.class_ = cls + self.name = name + self.value = value + self.docstring = docstring + + +def get_class_members(subject: Any, objpath: List[str], attrgetter: Callable, + analyzer: ModuleAnalyzer = None) -> Dict[str, ClassAttribute]: + """Get members and attributes of target class.""" + from sphinx.ext.autodoc import INSTANCEATTR + + # the members directly defined in the class + obj_dict = attrgetter(subject, '__dict__', {}) + + members = {} # type: Dict[str, ClassAttribute] + + # enum members + if isenumclass(subject): + for name, value in subject.__members__.items(): + if name not in members: + members[name] = ClassAttribute(subject, name, value) + + superclass = subject.__mro__[1] + for name in obj_dict: + if name not in superclass.__dict__: + value = safe_getattr(subject, name) + members[name] = ClassAttribute(subject, name, value) + + # members in __slots__ + try: + __slots__ = getslots(subject) + if __slots__: + from sphinx.ext.autodoc import SLOTSATTR + + for name, docstring in __slots__.items(): + members[name] = ClassAttribute(subject, name, SLOTSATTR, docstring) + except (AttributeError, TypeError, ValueError): + pass + + # other members + for name in dir(subject): + try: + value = attrgetter(subject, name) + unmangled = unmangle(subject, name) + if unmangled and unmangled not in members: + if name in obj_dict: + members[unmangled] = ClassAttribute(subject, unmangled, value) + else: + members[unmangled] = ClassAttribute(None, unmangled, value) + except AttributeError: + continue + + # annotation only member (ex. attr: int) + for cls in getmro(subject): + try: + for name in getannotations(cls): + name = unmangle(cls, name) + if name and name not in members: + members[name] = ClassAttribute(cls, name, INSTANCEATTR) + except AttributeError: + pass + + if analyzer: + # append instance attributes (cf. self.attr1) if analyzer knows + namespace = '.'.join(objpath) + for (ns, name), docstring in analyzer.attr_docs.items(): + if namespace == ns and name not in members: + members[name] = ClassAttribute(subject, name, INSTANCEATTR, + '\n'.join(docstring)) + + return members + + from sphinx.ext.autodoc.mock import (MockFinder, MockLoader, _MockModule, _MockObject, # NOQA mock) diff --git a/tests/roots/test-ext-autodoc/target/slots.py b/tests/roots/test-ext-autodoc/target/slots.py index 144f97c95..32822fd38 100644 --- a/tests/roots/test-ext-autodoc/target/slots.py +++ b/tests/roots/test-ext-autodoc/target/slots.py @@ -1,8 +1,12 @@ class Foo: + """docstring""" + __slots__ = ['attr'] class Bar: + """docstring""" + __slots__ = {'attr1': 'docstring of attr1', 'attr2': 'docstring of attr2', 'attr3': None} @@ -12,4 +16,6 @@ class Bar: class Baz: + """docstring""" + __slots__ = 'attr' diff --git a/tests/test_ext_autodoc.py b/tests/test_ext_autodoc.py index a81a73b61..05b827c18 100644 --- a/tests/test_ext_autodoc.py +++ b/tests/test_ext_autodoc.py @@ -1172,6 +1172,8 @@ def test_slots(app): '.. py:class:: Bar()', ' :module: target.slots', '', + ' docstring', + '', '', ' .. py:attribute:: Bar.attr1', ' :module: target.slots', @@ -1192,6 +1194,8 @@ def test_slots(app): '.. py:class:: Baz()', ' :module: target.slots', '', + ' docstring', + '', '', ' .. py:attribute:: Baz.attr', ' :module: target.slots', @@ -1200,6 +1204,8 @@ def test_slots(app): '.. py:class:: Foo()', ' :module: target.slots', '', + ' docstring', + '', '', ' .. py:attribute:: Foo.attr', ' :module: target.slots', diff --git a/tests/test_ext_autodoc_autoclass.py b/tests/test_ext_autodoc_autoclass.py index 17c7f8944..c36305de3 100644 --- a/tests/test_ext_autodoc_autoclass.py +++ b/tests/test_ext_autodoc_autoclass.py @@ -77,6 +77,32 @@ def test_decorators(app): ] +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_slots_attribute(app): + options = {"members": None} + actual = do_autodoc(app, 'class', 'target.slots.Bar', options) + assert list(actual) == [ + '', + '.. py:class:: Bar()', + ' :module: target.slots', + '', + ' docstring', + '', + '', + ' .. py:attribute:: Bar.attr1', + ' :module: target.slots', + '', + ' docstring of attr1', + '', + '', + ' .. py:attribute:: Bar.attr2', + ' :module: target.slots', + '', + ' docstring of instance attr2', + '', + ] + + @pytest.mark.skipif(sys.version_info < (3, 7), reason='python 3.7+ is required.') @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_show_inheritance_for_subclass_of_generic_type(app):