From caa1d1f3c212bd58fda6b7a1a97c15055b62e7a3 Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Sun, 13 Dec 2020 19:49:36 +0900 Subject: [PATCH] Fix #8534: autoattribute failed to document a commented attribute in alias class So far, autoattribute uses "given class name" to fetch comments of uninitialized instance attributes. But pycode expects to use "real" class name on searching attribute comments. This adds UninitializedInstanceAttributeMixin to handle it simply. --- CHANGES | 2 + sphinx/ext/autodoc/__init__.py | 110 +++++++++++++++--- .../test-ext-autodoc/target/typed_vars.py | 3 + tests/test_ext_autodoc.py | 5 + tests/test_ext_autodoc_autoattribute.py | 28 +++++ 5 files changed, 130 insertions(+), 18 deletions(-) diff --git a/CHANGES b/CHANGES index 484e654ed..8fa9f5707 100644 --- a/CHANGES +++ b/CHANGES @@ -66,6 +66,8 @@ Bugs fixed attributes * #8503: autodoc: autoattribute could not create document for a GenericAlias as class attributes correctly +* #8534: autodoc: autoattribute could not create document for a commented + attribute in alias class * #8452: autodoc: autodoc_type_aliases doesn't work when autodoc_typehints is set to "description" * #8460: autodoc: autodata and autoattribute directives do not display type diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index e27b33eee..267adb0bb 100644 --- a/sphinx/ext/autodoc/__init__.py +++ b/sphinx/ext/autodoc/__init__.py @@ -2123,8 +2123,90 @@ class SlotsMixin(DataDocumenterMixinBase): return super().get_doc(encoding, ignore) # type: ignore +class UninitializedInstanceAttributeMixin(DataDocumenterMixinBase): + """ + Mixin for AttributeDocumenter to provide the feature for supporting uninitialized + instance attributes (that are defined in __init__() methods with doc-comments). + + Example: + + class Foo: + def __init__(self): + self.attr = None #: This is a target of this mix-in. + """ + + def get_attribute_comment(self, parent: Any) -> Optional[List[str]]: + try: + analyzer = ModuleAnalyzer.for_module(self.modname) + analyzer.analyze() + + qualname = safe_getattr(parent, '__qualname__', None) + if qualname and self.objpath: + key = (qualname, self.objpath[-1]) + if key in analyzer.attr_docs: + return list(analyzer.attr_docs[key]) + except (AttributeError, PycodeError): + pass + + return None + + def is_uninitialized_instance_attribute(self, parent: Any) -> bool: + """Check the subject is an attribute defined in __init__().""" + # An instance variable defined in __init__(). + if self.get_attribute_comment(parent): + return True + else: + return False + + def import_object(self, raiseerror: bool = False) -> bool: + """Check the exisitence of uninitizlied instance attribute when failed to import + the attribute. + """ + try: + return super().import_object(raiseerror=True) # type: ignore + except ImportError as exc: + try: + ret = import_object(self.modname, self.objpath[:-1], 'class', + attrgetter=self.get_attr, # type: ignore + warningiserror=self.config.autodoc_warningiserror) + parent = ret[3] + if self.is_uninitialized_instance_attribute(parent): + self.object = UNINITIALIZED_ATTR + self.parent = parent + return True + except ImportError: + pass + + if raiseerror: + raise + else: + logger.warning(exc.args[0], type='autodoc', subtype='import_object') + self.env.note_reread() + return False + + def should_suppress_value_header(self) -> bool: + return (self.object is UNINITIALIZED_ATTR or + super().should_suppress_value_header()) + + def get_doc(self, encoding: str = None, ignore: int = None) -> List[List[str]]: + if self.object is UNINITIALIZED_ATTR: + comment = self.get_attribute_comment(self.parent) + if comment: + return [comment] + + return super().get_doc(encoding, ignore) # type: ignore + + def add_content(self, more_content: Optional[StringList], no_docstring: bool = False + ) -> None: + if self.object is UNINITIALIZED_ATTR: + self.analyzer = None + + super().add_content(more_content, no_docstring=no_docstring) # type: ignore + + class AttributeDocumenter(GenericAliasMixin, NewTypeMixin, SlotsMixin, # type: ignore - TypeVarMixin, NonDataDescriptorMixin, DocstringStripSignatureMixin, + TypeVarMixin, UninitializedInstanceAttributeMixin, + NonDataDescriptorMixin, DocstringStripSignatureMixin, ClassLevelDocumenter): """ Specialized Documenter subclass for attributes. @@ -2175,19 +2257,6 @@ class AttributeDocumenter(GenericAliasMixin, NewTypeMixin, SlotsMixin, # type: except ImportError: pass - # An instance variable defined inside __init__(). - try: - analyzer = ModuleAnalyzer.for_module(self.modname) - attr_docs = analyzer.find_attr_docs() - if self.objpath: - key = ('.'.join(self.objpath[:-1]), self.objpath[-1]) - if key in attr_docs: - return True - - return False - except PycodeError: - pass - return False def import_object(self, raiseerror: bool = False) -> bool: @@ -2226,10 +2295,15 @@ class AttributeDocumenter(GenericAliasMixin, NewTypeMixin, SlotsMixin, # type: objrepr = stringify_typehint(annotations.get(self.objpath[-1])) self.add_line(' :type: ' + objrepr, sourcename) else: - key = ('.'.join(self.objpath[:-1]), self.objpath[-1]) - if self.analyzer and key in self.analyzer.annotations: - self.add_line(' :type: ' + self.analyzer.annotations[key], - sourcename) + try: + qualname = safe_getattr(self.parent, '__qualname__', + '.'.join(self.objpath[:-1])) + key = (qualname, self.objpath[-1]) + if self.analyzer and key in self.analyzer.annotations: + self.add_line(' :type: ' + self.analyzer.annotations[key], + sourcename) + except AttributeError: + pass try: if (self.object is INSTANCEATTR or self.options.no_value or diff --git a/tests/roots/test-ext-autodoc/target/typed_vars.py b/tests/roots/test-ext-autodoc/target/typed_vars.py index ba9657f18..4e2de2398 100644 --- a/tests/roots/test-ext-autodoc/target/typed_vars.py +++ b/tests/roots/test-ext-autodoc/target/typed_vars.py @@ -29,3 +29,6 @@ class Class: class Derived(Class): attr7: int + + +Alias = Class diff --git a/tests/test_ext_autodoc.py b/tests/test_ext_autodoc.py index 1577f32e6..52f69dd2e 100644 --- a/tests/test_ext_autodoc.py +++ b/tests/test_ext_autodoc.py @@ -1561,6 +1561,11 @@ def test_autodoc_typed_instance_variables(app): '.. py:module:: target.typed_vars', '', '', + '.. py:attribute:: Alias', + ' :module: target.typed_vars', + '', + ' alias of :class:`target.typed_vars.Class`', + '', '.. py:class:: Class()', ' :module: target.typed_vars', '', diff --git a/tests/test_ext_autodoc_autoattribute.py b/tests/test_ext_autodoc_autoattribute.py index e44395ee3..7f79ca332 100644 --- a/tests/test_ext_autodoc_autoattribute.py +++ b/tests/test_ext_autodoc_autoattribute.py @@ -57,6 +57,19 @@ def test_autoattribute_typed_variable(app): ] +@pytest.mark.skipif(sys.version_info < (3, 6), reason='python 3.6+ is required.') +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_autoattribute_typed_variable_in_alias(app): + actual = do_autodoc(app, 'attribute', 'target.typed_vars.Alias.attr2') + assert list(actual) == [ + '', + '.. py:attribute:: Alias.attr2', + ' :module: target.typed_vars', + ' :type: int', + '', + ] + + @pytest.mark.skipif(sys.version_info < (3, 6), reason='python 3.6+ is required.') @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_autoattribute_instance_variable(app): @@ -72,6 +85,21 @@ def test_autoattribute_instance_variable(app): ] +@pytest.mark.skipif(sys.version_info < (3, 6), reason='python 3.6+ is required.') +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_autoattribute_instance_variable_in_alias(app): + actual = do_autodoc(app, 'attribute', 'target.typed_vars.Alias.attr4') + assert list(actual) == [ + '', + '.. py:attribute:: Alias.attr4', + ' :module: target.typed_vars', + ' :type: int', + '', + ' attr4', + '', + ] + + @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_autoattribute_slots_variable_list(app): actual = do_autodoc(app, 'attribute', 'target.slots.Foo.attr')