Skip to content

Commit 796a572

Browse files
authored
Combine type comment utilities into _type_comments.py (#14034)
1 parent 4d2bd1e commit 796a572

File tree

3 files changed

+151
-141
lines changed

3 files changed

+151
-141
lines changed

sphinx/ext/autodoc/_signatures.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
from sphinx.errors import PycodeError
1010
from sphinx.ext.autodoc._names import py_ext_sig_re
1111
from sphinx.ext.autodoc._property_types import _AssignStatementProperties
12+
from sphinx.ext.autodoc._type_comments import _update_annotations_using_type_comments
1213
from sphinx.ext.autodoc.preserve_defaults import update_default_value
13-
from sphinx.ext.autodoc.type_comment import _update_annotations_using_type_comments
1414
from sphinx.ext.autodoc.typehints import _record_typehints
1515
from sphinx.locale import __
1616
from sphinx.pycode import ModuleAnalyzer

sphinx/ext/autodoc/type_comment.py renamed to sphinx/ext/autodoc/_type_comments.py

Lines changed: 144 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,19 @@
33
from __future__ import annotations
44

55
import ast
6+
import contextlib
7+
import sys
68
from inspect import Parameter, Signature, getsource
9+
from types import ModuleType
710
from typing import TYPE_CHECKING, cast
11+
from weakref import WeakSet
812

13+
from sphinx.errors import PycodeError
914
from sphinx.locale import __
15+
from sphinx.pycode import ModuleAnalyzer
1016
from sphinx.pycode.ast import unparse as ast_unparse
1117
from sphinx.util import inspect, logging
18+
from sphinx.util.inspect import safe_getattr
1219

1320
if TYPE_CHECKING:
1421
from collections.abc import Sequence
@@ -18,16 +25,133 @@
1825
logger = logging.getLogger(__name__)
1926

2027

21-
def not_suppressed(argtypes: Sequence[ast.expr] = ()) -> bool:
22-
"""Check given *argtypes* is suppressed type_comment or not."""
23-
if len(argtypes) == 0: # no argtypees
24-
return False
25-
if len(argtypes) == 1:
26-
arg = argtypes[0]
27-
if isinstance(arg, ast.Constant) and arg.value is ...: # suppressed
28-
return False
29-
# not suppressed
30-
return True
28+
_objects_with_type_comment_annotations: WeakSet[Any] = WeakSet()
29+
"""Cache of objects with annotations updated from type comments."""
30+
31+
32+
def _ensure_annotations_from_type_comments(obj: Any) -> None:
33+
"""Ensures `obj.__annotations__` includes type comment information.
34+
35+
Failures to assign to `__annotations__` are silently ignored.
36+
37+
If `obj` is a class type, this also ensures that type comment
38+
information is incorporated into the `__annotations__` member of
39+
all parent classes, if possible.
40+
41+
This mutates the `__annotations__` of existing imported objects,
42+
in order to allow the existing `typing.get_type_hints` method to
43+
take the modified annotations into account.
44+
45+
Modifying existing imported objects is unfortunate but avoids the
46+
need to reimplement `typing.get_type_hints` in order to take into
47+
account type comment information.
48+
49+
Note that this does not directly include type comment information
50+
from parent classes, but `typing.get_type_hints` takes that into
51+
account.
52+
"""
53+
if obj in _objects_with_type_comment_annotations:
54+
return
55+
_objects_with_type_comment_annotations.add(obj)
56+
57+
if isinstance(obj, type):
58+
for cls in inspect.getmro(obj):
59+
modname = safe_getattr(cls, '__module__')
60+
mod = sys.modules.get(modname)
61+
if mod is not None:
62+
_ensure_annotations_from_type_comments(mod)
63+
64+
elif isinstance(obj, ModuleType):
65+
_update_module_annotations_from_type_comments(obj)
66+
67+
68+
def _update_module_annotations_from_type_comments(mod: ModuleType) -> None:
69+
"""Adds type comment annotations for a single module.
70+
71+
Both module-level and class-level annotations are added.
72+
"""
73+
mod_annotations = dict(inspect.getannotations(mod))
74+
mod.__annotations__ = mod_annotations
75+
76+
class_annotations: dict[str, dict[str, Any]] = {}
77+
78+
try:
79+
analyzer = ModuleAnalyzer.for_module(mod.__name__)
80+
analyzer.analyze()
81+
anns = analyzer.annotations
82+
for (classname, attrname), annotation in anns.items():
83+
if not classname:
84+
annotations = mod_annotations
85+
else:
86+
cls_annotations = class_annotations.get(classname)
87+
if cls_annotations is None:
88+
try:
89+
cls = mod
90+
for part in classname.split('.'):
91+
cls = safe_getattr(cls, part)
92+
annotations = dict(inspect.getannotations(cls))
93+
# Ignore errors setting __annotations__
94+
with contextlib.suppress(TypeError, AttributeError):
95+
cls.__annotations__ = annotations
96+
except AttributeError:
97+
annotations = {}
98+
class_annotations[classname] = annotations
99+
else:
100+
annotations = cls_annotations
101+
annotations.setdefault(attrname, annotation)
102+
except PycodeError:
103+
pass
104+
105+
106+
def _update_annotations_using_type_comments(obj: Any, bound_method: bool) -> None:
107+
"""Update annotations info of *obj* using type_comments."""
108+
try:
109+
type_sig = get_type_comment(obj, bound_method)
110+
if type_sig:
111+
sig = inspect.signature(obj, bound_method)
112+
for param in sig.parameters.values():
113+
if param.name not in obj.__annotations__:
114+
annotation = type_sig.parameters[param.name].annotation
115+
if annotation is not Parameter.empty:
116+
obj.__annotations__[param.name] = ast_unparse(annotation)
117+
118+
if 'return' not in obj.__annotations__:
119+
obj.__annotations__['return'] = type_sig.return_annotation
120+
except KeyError as exc:
121+
logger.warning(
122+
__('Failed to update signature for %r: parameter not found: %s'), obj, exc
123+
)
124+
except NotImplementedError as exc: # failed to ast.unparse()
125+
logger.warning(__('Failed to parse type_comment for %r: %s'), obj, exc)
126+
127+
128+
def get_type_comment(obj: Any, bound_method: bool = False) -> Signature | None:
129+
"""Get type_comment'ed FunctionDef object from living object.
130+
131+
This tries to parse original code for living object and returns
132+
Signature for given *obj*.
133+
"""
134+
try:
135+
source = getsource(obj)
136+
if source.startswith((' ', r'\t')):
137+
# subject is placed inside class or block. To read its docstring,
138+
# this adds if-block before the declaration.
139+
module = ast.parse('if True:\n' + source, type_comments=True)
140+
subject = cast('ast.FunctionDef', module.body[0].body[0]) # type: ignore[attr-defined]
141+
else:
142+
module = ast.parse(source, type_comments=True)
143+
subject = cast('ast.FunctionDef', module.body[0])
144+
145+
type_comment = getattr(subject, 'type_comment', None)
146+
if type_comment:
147+
function = ast.parse(type_comment, mode='func_type', type_comments=True)
148+
return signature_from_ast(subject, bound_method, function) # type: ignore[arg-type]
149+
else:
150+
return None
151+
except (OSError, TypeError): # failed to load source code
152+
return None
153+
except SyntaxError: # failed to parse type_comments
154+
return None
31155

32156

33157
def signature_from_ast(
@@ -95,52 +219,13 @@ def signature_from_ast(
95219
return Signature(params)
96220

97221

98-
def get_type_comment(obj: Any, bound_method: bool = False) -> Signature | None:
99-
"""Get type_comment'ed FunctionDef object from living object.
100-
101-
This tries to parse original code for living object and returns
102-
Signature for given *obj*.
103-
"""
104-
try:
105-
source = getsource(obj)
106-
if source.startswith((' ', r'\t')):
107-
# subject is placed inside class or block. To read its docstring,
108-
# this adds if-block before the declaration.
109-
module = ast.parse('if True:\n' + source, type_comments=True)
110-
subject = cast('ast.FunctionDef', module.body[0].body[0]) # type: ignore[attr-defined]
111-
else:
112-
module = ast.parse(source, type_comments=True)
113-
subject = cast('ast.FunctionDef', module.body[0])
114-
115-
type_comment = getattr(subject, 'type_comment', None)
116-
if type_comment:
117-
function = ast.parse(type_comment, mode='func_type', type_comments=True)
118-
return signature_from_ast(subject, bound_method, function) # type: ignore[arg-type]
119-
else:
120-
return None
121-
except (OSError, TypeError): # failed to load source code
122-
return None
123-
except SyntaxError: # failed to parse type_comments
124-
return None
125-
126-
127-
def _update_annotations_using_type_comments(obj: Any, bound_method: bool) -> None:
128-
"""Update annotations info of *obj* using type_comments."""
129-
try:
130-
type_sig = get_type_comment(obj, bound_method)
131-
if type_sig:
132-
sig = inspect.signature(obj, bound_method)
133-
for param in sig.parameters.values():
134-
if param.name not in obj.__annotations__:
135-
annotation = type_sig.parameters[param.name].annotation
136-
if annotation is not Parameter.empty:
137-
obj.__annotations__[param.name] = ast_unparse(annotation)
138-
139-
if 'return' not in obj.__annotations__:
140-
obj.__annotations__['return'] = type_sig.return_annotation
141-
except KeyError as exc:
142-
logger.warning(
143-
__('Failed to update signature for %r: parameter not found: %s'), obj, exc
144-
)
145-
except NotImplementedError as exc: # failed to ast.unparse()
146-
logger.warning(__('Failed to parse type_comment for %r: %s'), obj, exc)
222+
def not_suppressed(argtypes: Sequence[ast.expr] = ()) -> bool:
223+
"""Check given *argtypes* is suppressed type_comment or not."""
224+
if len(argtypes) == 0: # no argtypees
225+
return False
226+
if len(argtypes) == 1:
227+
arg = argtypes[0]
228+
if isinstance(arg, ast.Constant) and arg.value is ...: # suppressed
229+
return False
230+
# not suppressed
231+
return True

sphinx/ext/autodoc/importer.py

Lines changed: 6 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,8 @@
1414
from importlib.util import decode_source, find_spec, module_from_spec, spec_from_loader
1515
from inspect import Parameter
1616
from pathlib import Path
17-
from types import ModuleType, SimpleNamespace
17+
from types import SimpleNamespace
1818
from typing import TYPE_CHECKING, NewType, TypeVar
19-
from weakref import WeakSet
2019

2120
from sphinx.errors import PycodeError
2221
from sphinx.ext.autodoc._docstrings import (
@@ -38,8 +37,11 @@
3837
UNINITIALIZED_ATTR,
3938
)
4039
from sphinx.ext.autodoc._signatures import _format_signatures
40+
from sphinx.ext.autodoc._type_comments import (
41+
_ensure_annotations_from_type_comments,
42+
_update_annotations_using_type_comments,
43+
)
4144
from sphinx.ext.autodoc.mock import ismock, mock, undecorate
42-
from sphinx.ext.autodoc.type_comment import _update_annotations_using_type_comments
4345
from sphinx.locale import __
4446
from sphinx.pycode import ModuleAnalyzer
4547
from sphinx.util import inspect, logging
@@ -52,6 +54,7 @@
5254
if TYPE_CHECKING:
5355
from collections.abc import Mapping, MutableSet, Sequence
5456
from importlib.machinery import ModuleSpec
57+
from types import ModuleType
5558
from typing import Any, Protocol
5659

5760
from sphinx.config import Config
@@ -919,81 +922,3 @@ def _load_object_by_name(
919922
)
920923

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

0 commit comments

Comments
 (0)