Skip to content

Commit f3e8999

Browse files
jbmsAA-Turner
andauthored
autodoc: Avoid updating __annotations__ with parent class members (#13935)
This commit ensures that the ``__annotations__`` dict of a class is updated only with type comment-derived annotations for direct members; the type comment-derived annotations for parent classes are added to those parent classes own ``__annotations__`` dicts. The inherited member annotations are still found by ``typing.get_type_hints``. This commit also improves the efficiency of incorporating the annotations found by the ``ModuleAnalyzer``. Previously, each call to ``_load_object_by_name`` for a data member or attribute would result in separately iterating over all attributes (including within classes) found by ModuleAnalyzer for the containing module, and in the case of classes, the containing modules of all parent classes. For modules with many members this cost could likely be significant. With this commit, at least for the purpose of updating ``__annotations__`` dicts, each module's attributes only processed a single time in total. Co-authored-by: Adam Turner <[email protected]>
1 parent 5e0b3ef commit f3e8999

File tree

4 files changed

+98
-77
lines changed

4 files changed

+98
-77
lines changed

CHANGES.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,10 @@ Bugs fixed
126126
* #13929: Duplicate equation label warnings now have a new warning
127127
sub-type, ``ref.equation``.
128128
Patch by Jared Dillard.
129+
* #13935: autoclass: parent class members no longer considered
130+
directly defined in certain cases, depending on autodoc processing
131+
order.
132+
Patch by Jeremy Maitin-Shepard.
129133

130134

131135
Testing

sphinx/ext/autodoc/_documenters.py

Lines changed: 0 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1546,20 +1546,6 @@ def can_document_member(
15461546
) -> bool:
15471547
return isinstance(parent, ModuleDocumenter) and isattr
15481548

1549-
def update_annotations(self, parent: Any) -> None:
1550-
"""Update __annotations__ to support type_comment and so on."""
1551-
annotations = dict(inspect.getannotations(parent))
1552-
parent.__annotations__ = annotations
1553-
1554-
try:
1555-
analyzer = ModuleAnalyzer.for_module(self.props.module_name)
1556-
analyzer.analyze()
1557-
for (classname, attrname), annotation in analyzer.annotations.items():
1558-
if not classname and attrname not in annotations:
1559-
annotations[attrname] = annotation
1560-
except PycodeError:
1561-
pass
1562-
15631549
def should_suppress_value_header(self) -> bool:
15641550
if self.props._obj is UNINITIALIZED_ATTR:
15651551
return True
@@ -1878,29 +1864,6 @@ def can_document_member(
18781864
return True
18791865
return not inspect.isroutine(member) and not isinstance(member, type)
18801866

1881-
def update_annotations(self, parent: Any) -> None:
1882-
"""Update __annotations__ to support type_comment and so on."""
1883-
try:
1884-
annotations = dict(inspect.getannotations(parent))
1885-
parent.__annotations__ = annotations
1886-
1887-
for cls in inspect.getmro(parent):
1888-
try:
1889-
module = safe_getattr(cls, '__module__')
1890-
qualname = safe_getattr(cls, '__qualname__')
1891-
1892-
analyzer = ModuleAnalyzer.for_module(module)
1893-
analyzer.analyze()
1894-
anns = analyzer.annotations
1895-
for (classname, attrname), annotation in anns.items():
1896-
if classname == qualname and attrname not in annotations:
1897-
annotations[attrname] = annotation
1898-
except (AttributeError, PycodeError):
1899-
pass
1900-
except (AttributeError, TypeError):
1901-
# Failed to set __annotations__ (built-in, extensions, etc.)
1902-
pass
1903-
19041867
@property
19051868
def _is_non_data_descriptor(self) -> bool:
19061869
return not inspect.isattributedescriptor(self.props._obj)

sphinx/ext/autodoc/importer.py

Lines changed: 83 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212
from importlib.machinery import EXTENSION_SUFFIXES
1313
from importlib.util import decode_source, find_spec, module_from_spec, spec_from_loader
1414
from pathlib import Path
15-
from types import SimpleNamespace
15+
from types import ModuleType, SimpleNamespace
1616
from typing import TYPE_CHECKING, NewType, TypeVar
17+
from weakref import WeakSet
1718

1819
from sphinx.errors import PycodeError
1920
from sphinx.ext.autodoc._property_types import (
@@ -41,7 +42,6 @@
4142
if TYPE_CHECKING:
4243
from collections.abc import Sequence
4344
from importlib.machinery import ModuleSpec
44-
from types import ModuleType
4545
from typing import Any, Protocol
4646

4747
from sphinx.config import Config
@@ -660,20 +660,7 @@ def _load_object_by_name(
660660
)
661661
elif objtype == 'data':
662662
# Update __annotations__ to support type_comment and so on
663-
annotations = dict(inspect.getannotations(parent))
664-
parent.__annotations__ = annotations
665-
666-
try:
667-
analyzer = ModuleAnalyzer.for_module(module_name)
668-
analyzer.analyze()
669-
for (
670-
classname,
671-
attrname,
672-
), annotation in analyzer.annotations.items():
673-
if not classname and attrname not in annotations:
674-
annotations[attrname] = annotation
675-
except PycodeError:
676-
pass
663+
_ensure_annotations_from_type_comments(parent)
677664

678665
# obtain annotation
679666
annotations = get_type_hints(
@@ -729,27 +716,8 @@ def _load_object_by_name(
729716
elif inspect.isenumattribute(obj):
730717
obj = obj.value
731718
if parent:
732-
# Update __annotations__ to support type_comment and so on.
733-
try:
734-
annotations = dict(inspect.getannotations(parent))
735-
parent.__annotations__ = annotations
736-
737-
for cls in inspect.getmro(parent):
738-
try:
739-
module = safe_getattr(cls, '__module__')
740-
qualname = safe_getattr(cls, '__qualname__')
741-
742-
analyzer = ModuleAnalyzer.for_module(module)
743-
analyzer.analyze()
744-
anns = analyzer.annotations
745-
for (classname, attrname), annotation in anns.items():
746-
if classname == qualname and attrname not in annotations:
747-
annotations[attrname] = annotation
748-
except (AttributeError, PycodeError):
749-
pass
750-
except (AttributeError, TypeError):
751-
# Failed to set __annotations__ (built-in, extensions, etc.)
752-
pass
719+
# Update __annotations__ to support type_comment and so on
720+
_ensure_annotations_from_type_comments(parent)
753721

754722
# obtain annotation
755723
annotations = get_type_hints(
@@ -964,3 +932,81 @@ def _resolve_name(
964932
return module_name, (*parents, base)
965933

966934
return None
935+
936+
937+
_objects_with_type_comment_annotations: WeakSet[Any] = WeakSet()
938+
"""Cache of objects with annotations updated from type comments."""
939+
940+
941+
def _ensure_annotations_from_type_comments(obj: Any) -> None:
942+
"""Ensures `obj.__annotations__` includes type comment information.
943+
944+
Failures to assign to `__annotations__` are silently ignored.
945+
946+
If `obj` is a class type, this also ensures that type comment
947+
information is incorporated into the `__annotations__` member of
948+
all parent classes, if possible.
949+
950+
This mutates the `__annotations__` of existing imported objects,
951+
in order to allow the existing `typing.get_type_hints` method to
952+
take the modified annotations into account.
953+
954+
Modifying existing imported objects is unfortunate but avoids the
955+
need to reimplement `typing.get_type_hints` in order to take into
956+
account type comment information.
957+
958+
Note that this does not directly include type comment information
959+
from parent classes, but `typing.get_type_hints` takes that into
960+
account.
961+
"""
962+
if obj in _objects_with_type_comment_annotations:
963+
return
964+
_objects_with_type_comment_annotations.add(obj)
965+
966+
if isinstance(obj, type):
967+
for cls in inspect.getmro(obj):
968+
modname = safe_getattr(cls, '__module__')
969+
mod = sys.modules.get(modname)
970+
if mod is not None:
971+
_ensure_annotations_from_type_comments(mod)
972+
973+
elif isinstance(obj, ModuleType):
974+
_update_module_annotations_from_type_comments(obj)
975+
976+
977+
def _update_module_annotations_from_type_comments(mod: ModuleType) -> None:
978+
"""Adds type comment annotations for a single module.
979+
980+
Both module-level and class-level annotations are added.
981+
"""
982+
mod_annotations = dict(inspect.getannotations(mod))
983+
mod.__annotations__ = mod_annotations
984+
985+
class_annotations: dict[str, dict[str, Any]] = {}
986+
987+
try:
988+
analyzer = ModuleAnalyzer.for_module(mod.__name__)
989+
analyzer.analyze()
990+
anns = analyzer.annotations
991+
for (classname, attrname), annotation in anns.items():
992+
if not classname:
993+
annotations = mod_annotations
994+
else:
995+
cls_annotations = class_annotations.get(classname)
996+
if cls_annotations is None:
997+
try:
998+
cls = mod
999+
for part in classname.split('.'):
1000+
cls = safe_getattr(cls, part)
1001+
annotations = dict(inspect.getannotations(cls))
1002+
# Ignore errors setting __annotations__
1003+
with contextlib.suppress(TypeError, AttributeError):
1004+
cls.__annotations__ = annotations
1005+
except AttributeError:
1006+
annotations = {}
1007+
class_annotations[classname] = annotations
1008+
else:
1009+
annotations = cls_annotations
1010+
annotations.setdefault(attrname, annotation)
1011+
except PycodeError:
1012+
pass

tests/test_ext_autodoc/test_ext_autodoc.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -976,13 +976,16 @@ def test_autodoc_special_members(app):
976976
if sys.version_info >= (3, 13, 0, 'alpha', 5):
977977
options['exclude-members'] = '__static_attributes__,__firstlineno__'
978978
if sys.version_info >= (3, 14, 0, 'alpha', 7):
979-
ann_attr_name = '__annotations_cache__'
979+
ann_attrs = (
980+
' .. py:attribute:: Class.__annotate_func__',
981+
' .. py:attribute:: Class.__annotations_cache__',
982+
)
980983
else:
981-
ann_attr_name = '__annotations__'
984+
ann_attrs = (' .. py:attribute:: Class.__annotations__',)
982985
actual = do_autodoc(app, 'class', 'target.Class', options)
983986
assert list(filter(lambda l: '::' in l, actual)) == [
984987
'.. py:class:: Class(arg)',
985-
f' .. py:attribute:: Class.{ann_attr_name}',
988+
*ann_attrs,
986989
' .. py:attribute:: Class.__dict__',
987990
' .. py:method:: Class.__init__(arg)',
988991
' .. py:attribute:: Class.__module__',
@@ -2287,6 +2290,11 @@ def test_autodoc_typed_instance_variables(app):
22872290
'members': None,
22882291
'undoc-members': None,
22892292
}
2293+
# First compute autodoc of a `Derived` member to verify that it
2294+
# doesn't result in inherited members in
2295+
# `Derived.__annotations__`.
2296+
# https://github.com/sphinx-doc/sphinx/issues/13934
2297+
do_autodoc(app, 'attribute', 'target.typed_vars.Derived.attr2')
22902298
actual = do_autodoc(app, 'module', 'target.typed_vars', options)
22912299
assert list(actual) == [
22922300
'',

0 commit comments

Comments
 (0)