From 18973f6c1b985200dbf3f8c7b27c2416892cee5b Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Thu, 17 Dec 2020 02:55:05 +0900 Subject: [PATCH 1/3] refactor: autodoc: Add get_class_members() To enhance ClassDocumenter, Add a new helper function get_class_members(). At this moment, it is a copy of get_object_members(). It will be changed to return more detailed information of class members. --- sphinx/ext/autodoc/__init__.py | 5 +-- sphinx/ext/autodoc/importer.py | 64 ++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index 9ae115ef4..99aff05cb 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 @@ -1576,7 +1577,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 diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index ec05a8a3d..b9858f049 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -241,6 +241,70 @@ def get_object_members(subject: Any, objpath: List[str], attrgetter: Callable, return members +def get_class_members(subject: Any, objpath: List[str], attrgetter: Callable, + analyzer: ModuleAnalyzer = None) -> Dict[str, Attribute]: + """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, Attribute] + + # enum members + if isenumclass(subject): + for name, value in subject.__members__.items(): + if name not in members: + members[name] = Attribute(name, True, value) + + superclass = subject.__mro__[1] + for name in obj_dict: + if name not in superclass.__dict__: + value = safe_getattr(subject, name) + members[name] = Attribute(name, True, value) + + # members in __slots__ + try: + __slots__ = getslots(subject) + if __slots__: + from sphinx.ext.autodoc import SLOTSATTR + + for name in __slots__: + members[name] = Attribute(name, True, SLOTSATTR) + except (AttributeError, TypeError, ValueError): + pass + + # other members + for name in dir(subject): + try: + value = attrgetter(subject, name) + directly_defined = name in obj_dict + name = unmangle(subject, name) + if name and name not in members: + members[name] = Attribute(name, directly_defined, value) + except AttributeError: + continue + + # annotation only member (ex. attr: int) + for i, cls in enumerate(getmro(subject)): + try: + for name in getannotations(cls): + name = unmangle(cls, name) + if name and name not in members: + members[name] = Attribute(name, i == 0, INSTANCEATTR) + except AttributeError: + pass + + if analyzer: + # append instance attributes (cf. self.attr1) if analyzer knows + namespace = '.'.join(objpath) + for (ns, name) in analyzer.find_attr_docs(): + if namespace == ns and name not in members: + members[name] = Attribute(name, True, INSTANCEATTR) + + return members + + from sphinx.ext.autodoc.mock import (MockFinder, MockLoader, _MockModule, _MockObject, # NOQA mock) From 964392d316a76414645d1b2d26af478c621eb8d7 Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Sat, 21 Nov 2020 17:18:41 +0900 Subject: [PATCH 2/3] refactor: get_class_members() now returns docstring if available To detect a __slots__ attribute has docstring, get_class_members() returns the docstring of the class member. --- sphinx/ext/autodoc/__init__.py | 2 +- sphinx/ext/autodoc/importer.py | 38 ++++++++++++++++++++++------------ 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index 99aff05cb..02188bee8 100644 --- a/sphinx/ext/autodoc/__init__.py +++ b/sphinx/ext/autodoc/__init__.py @@ -1594,7 +1594,7 @@ class ClassDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # type: return False, [(m.name, m.value) for m in members.values()] else: return False, [(m.name, m.value) for m in members.values() - if m.directly_defined] + 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 b9858f049..4af3e6859 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -241,27 +241,37 @@ 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, Attribute]: + 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, Attribute] + members = {} # type: Dict[str, ClassAttribute] # enum members if isenumclass(subject): for name, value in subject.__members__.items(): if name not in members: - members[name] = Attribute(name, True, value) + 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] = Attribute(name, True, value) + members[name] = ClassAttribute(subject, name, value) # members in __slots__ try: @@ -269,8 +279,8 @@ def get_class_members(subject: Any, objpath: List[str], attrgetter: Callable, if __slots__: from sphinx.ext.autodoc import SLOTSATTR - for name in __slots__: - members[name] = Attribute(name, True, SLOTSATTR) + for name, docstring in __slots__.items(): + members[name] = ClassAttribute(subject, name, SLOTSATTR, docstring) except (AttributeError, TypeError, ValueError): pass @@ -278,20 +288,22 @@ def get_class_members(subject: Any, objpath: List[str], attrgetter: Callable, for name in dir(subject): try: value = attrgetter(subject, name) - directly_defined = name in obj_dict - name = unmangle(subject, name) - if name and name not in members: - members[name] = Attribute(name, directly_defined, value) + 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 i, cls in enumerate(getmro(subject)): + for cls in getmro(subject): try: for name in getannotations(cls): name = unmangle(cls, name) if name and name not in members: - members[name] = Attribute(name, i == 0, INSTANCEATTR) + members[name] = ClassAttribute(cls, name, INSTANCEATTR) except AttributeError: pass @@ -300,7 +312,7 @@ def get_class_members(subject: Any, objpath: List[str], attrgetter: Callable, namespace = '.'.join(objpath) for (ns, name) in analyzer.find_attr_docs(): if namespace == ns and name not in members: - members[name] = Attribute(name, True, INSTANCEATTR) + members[name] = ClassAttribute(subject, name, INSTANCEATTR) return members From 97213279897236c0a8db98902698a7c34ae95bbf Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Thu, 17 Dec 2020 03:31:45 +0900 Subject: [PATCH 3/3] Fix #8545: autodoc: a __slots__ attribute is not documented even having docstring To avoid filtering __slots__ attributes having docstring at filter_members(), this passes docstring captured at get_class_members() to the filter_members() via ObjectMember. --- CHANGES | 1 + sphinx/ext/autodoc/__init__.py | 19 ++++++++++---- sphinx/ext/autodoc/importer.py | 5 ++-- tests/roots/test-ext-autodoc/target/slots.py | 6 +++++ tests/test_ext_autodoc.py | 6 +++++ tests/test_ext_autodoc_autoclass.py | 26 ++++++++++++++++++++ 6 files changed, 56 insertions(+), 7 deletions(-) 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 02188bee8..6ce0fbf9c 100644 --- a/sphinx/ext/autodoc/__init__.py +++ b/sphinx/ext/autodoc/__init__.py @@ -275,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 @@ -709,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) @@ -1585,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.class_ == self.object] + 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 4af3e6859..d6e73d36a 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -310,9 +310,10 @@ def get_class_members(subject: Any, objpath: List[str], attrgetter: Callable, if analyzer: # append instance attributes (cf. self.attr1) if analyzer knows namespace = '.'.join(objpath) - for (ns, name) in analyzer.find_attr_docs(): + for (ns, name), docstring in analyzer.attr_docs.items(): if namespace == ns and name not in members: - members[name] = ClassAttribute(subject, name, INSTANCEATTR) + members[name] = ClassAttribute(subject, name, INSTANCEATTR, + '\n'.join(docstring)) return members 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):