Skip to content

Commit 8b0599d

Browse files
authored
Handle ForwardRef, expand TypeVar and link Ellipsis (#214)
1 parent f75d19b commit 8b0599d

File tree

6 files changed

+121
-46
lines changed

6 files changed

+121
-46
lines changed

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ repos:
5353
hooks:
5454
- id: flake8
5555
additional_dependencies:
56-
- flake8-bugbear==21.11.29
57-
- flake8-comprehensions==3.7
56+
- flake8-bugbear==22.1.11
57+
- flake8-comprehensions==3.8
5858
- flake8-pytest-style==1.6
5959
- flake8-spellcheck==0.24
6060
- flake8-unused-arguments==0.0.9

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
- Add support for type subscriptions with multiple elements, where one or more elements
66
are tuples; e.g., `nptyping.NDArray[(Any, ...), nptyping.Float]`
77
- Fix bug for arbitrary types accepting singleton subscriptions; e.g., `nptyping.Float[64]`
8+
- Resolve forward references
9+
- Expand and better handle `TypeVar`
10+
- Add intershpinx reference link for `...` to `Ellipsis` (as is just an alias)
811

912
## 1.15.3
1013

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ other =
7272
*\sphinx-autodoc-typehints
7373

7474
[coverage:report]
75-
fail_under = 78
75+
fail_under = 82
7676

7777
[coverage:html]
7878
show_contexts = true

src/sphinx_autodoc_typehints/__init__.py

Lines changed: 61 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
import sys
66
import textwrap
77
from ast import FunctionDef, Module, stmt
8-
from typing import Any, AnyStr, Callable, NewType, TypeVar, get_type_hints
8+
from typing import _eval_type # type: ignore # no import defined in stubs
9+
from typing import Any, AnyStr, Callable, ForwardRef, NewType, TypeVar, get_type_hints
910

1011
from sphinx.application import Sphinx
1112
from sphinx.config import Config
@@ -24,7 +25,8 @@
2425
def get_annotation_module(annotation: Any) -> str:
2526
if annotation is None:
2627
return "builtins"
27-
if sys.version_info >= (3, 10) and isinstance(annotation, NewType): # type: ignore # isinstance NewType is Callable
28+
is_new_type = sys.version_info >= (3, 10) and isinstance(annotation, NewType) # type: ignore
29+
if is_new_type or isinstance(annotation, TypeVar):
2830
return "typing"
2931
if hasattr(annotation, "__module__"):
3032
return annotation.__module__ # type: ignore # deduced Any
@@ -79,13 +81,14 @@ def get_annotation_args(annotation: Any, module: str, class_name: str) -> tuple[
7981
return (annotation.type_var,)
8082
elif class_name == "ClassVar" and hasattr(annotation, "__type__"): # ClassVar on Python < 3.7
8183
return (annotation.__type__,)
84+
elif class_name == "TypeVar" and hasattr(annotation, "__constraints__"):
85+
return annotation.__constraints__ # type: ignore # no stubs defined
8286
elif class_name == "NewType" and hasattr(annotation, "__supertype__"):
8387
return (annotation.__supertype__,)
8488
elif class_name == "Literal" and hasattr(annotation, "__values__"):
8589
return annotation.__values__ # type: ignore # deduced Any
8690
elif class_name == "Generic":
8791
return annotation.__parameters__ # type: ignore # deduced Any
88-
8992
return getattr(annotation, "__args__", ())
9093

9194

@@ -104,29 +107,25 @@ def format_internal_tuple(t: tuple[Any, ...], config: Config) -> str:
104107
return f"({', '.join(fmt)})"
105108

106109

107-
def format_annotation(annotation: Any, config: Config) -> str:
110+
def format_annotation(annotation: Any, config: Config) -> str: # noqa: C901 # too complex
108111
typehints_formatter: Callable[..., str] | None = getattr(config, "typehints_formatter", None)
109112
if typehints_formatter is not None:
110113
formatted = typehints_formatter(annotation, config)
111114
if formatted is not None:
112115
return formatted
113116

114117
# Special cases
118+
if isinstance(annotation, ForwardRef):
119+
value = _resolve_forward_ref(annotation, config)
120+
return format_annotation(value, config)
115121
if annotation is None or annotation is type(None): # noqa: E721
116122
return ":py:obj:`None`"
117-
elif annotation is Ellipsis:
118-
return "..."
123+
if annotation is Ellipsis:
124+
return ":py:data:`...<Ellipsis>`"
119125

120126
if isinstance(annotation, tuple):
121127
return format_internal_tuple(annotation, config)
122128

123-
# Type variables are also handled specially
124-
try:
125-
if isinstance(annotation, TypeVar) and annotation is not AnyStr:
126-
return "\\" + repr(annotation)
127-
except TypeError:
128-
pass
129-
130129
try:
131130
module = get_annotation_module(annotation)
132131
class_name = get_annotation_class_name(annotation, module)
@@ -143,12 +142,22 @@ def format_annotation(annotation: Any, config: Config) -> str:
143142
prefix = "" if fully_qualified or full_name == class_name else "~"
144143
role = "data" if class_name in _PYDATA_ANNOTATIONS else "class"
145144
args_format = "\\[{}]"
146-
formatted_args = ""
145+
formatted_args: str | None = ""
147146

148147
# Some types require special handling
149148
if full_name == "typing.NewType":
150149
args_format = f"\\(``{annotation.__name__}``, {{}})"
151150
role = "class" if sys.version_info >= (3, 10) else "func"
151+
elif full_name == "typing.TypeVar":
152+
params = {k: getattr(annotation, f"__{k}__") for k in ("bound", "covariant", "contravariant")}
153+
params = {k: v for k, v in params.items() if v}
154+
if "bound" in params:
155+
params["bound"] = f" {format_annotation(params['bound'], config)}"
156+
args_format = f"\\(``{annotation.__name__}``{', {}' if args else ''}"
157+
if params:
158+
args_format += "".join(f", {k}={v}" for k, v in params.items())
159+
args_format += ")"
160+
formatted_args = None if args else args_format
152161
elif full_name == "typing.Optional":
153162
args = tuple(x for x in args if x is not type(None)) # noqa: E721
154163
elif full_name == "typing.Union" and type(None) in args:
@@ -176,7 +185,19 @@ def format_annotation(annotation: Any, config: Config) -> str:
176185
fmt = [format_annotation(arg, config) for arg in args]
177186
formatted_args = args_format.format(", ".join(fmt))
178187

179-
return f":py:{role}:`{prefix}{full_name}`{formatted_args}"
188+
result = f":py:{role}:`{prefix}{full_name}`{formatted_args}"
189+
return result
190+
191+
192+
def _resolve_forward_ref(annotation: ForwardRef, config: Config) -> Any:
193+
raw, base_globals = annotation.__forward_arg__, config._annotation_globals
194+
params = {"is_class": True} if (3, 10) > sys.version_info >= (3, 9, 8) or sys.version_info >= (3, 10, 1) else {}
195+
value = ForwardRef(raw, is_argument=False, **params)
196+
try:
197+
result = _eval_type(value, base_globals, None)
198+
except NameError:
199+
result = raw # fallback to the value itself as string
200+
return result
180201

181202

182203
# reference: https://github.com/pytorch/pytorch/pull/46548/files
@@ -284,14 +305,15 @@ def _future_annotations_imported(obj: Any) -> bool:
284305

285306
def get_all_type_hints(obj: Any, name: str) -> dict[str, Any]:
286307
result = _get_type_hint(name, obj)
287-
if result:
288-
return result
289-
result = backfill_type_hints(obj, name)
290-
try:
291-
obj.__annotations__ = result
292-
except (AttributeError, TypeError):
293-
return result
294-
return _get_type_hint(name, obj)
308+
if not result:
309+
result = backfill_type_hints(obj, name)
310+
try:
311+
obj.__annotations__ = result
312+
except (AttributeError, TypeError):
313+
pass
314+
else:
315+
result = _get_type_hint(name, obj)
316+
return result
295317

296318

297319
_TYPE_GUARD_IMPORT_RE = re.compile(r"\nif (typing.)?TYPE_CHECKING:[^\n]*([\s\S]*?)(?=\n\S)")
@@ -474,7 +496,22 @@ def process_docstring(
474496
except (ValueError, TypeError):
475497
signature = None
476498
type_hints = get_all_type_hints(obj, name)
477-
499+
app.config._annotation_globals = getattr(obj, "__globals__", {}) # type: ignore # config has no such attribute
500+
try:
501+
_inject_types_to_docstring(type_hints, signature, original_obj, app, what, name, lines)
502+
finally:
503+
delattr(app.config, "_annotation_globals")
504+
505+
506+
def _inject_types_to_docstring(
507+
type_hints: dict[str, Any],
508+
signature: inspect.Signature | None,
509+
original_obj: Any,
510+
app: Sphinx,
511+
what: str,
512+
name: str,
513+
lines: list[str],
514+
) -> None:
478515
for arg_name, annotation in type_hints.items():
479516
if arg_name == "return":
480517
continue # this is handled separately later

tests/test_sphinx_autodoc_typehints.py

Lines changed: 52 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@
4949
T = TypeVar("T")
5050
U = TypeVar("U", covariant=True)
5151
V = TypeVar("V", contravariant=True)
52+
X = TypeVar("X", str, int)
53+
Y = TypeVar("Y", bound=str)
54+
Z = TypeVar("Z", bound="A")
55+
S = TypeVar("S", bound="miss") # type: ignore # miss not defined on purpose # noqa: F821
5256
W = NewType("W", str)
5357

5458

@@ -61,8 +65,7 @@ class Inner:
6165

6266

6367
class B(Generic[T]):
64-
# This is set to make sure the correct class name ("B") is picked up
65-
name = "Foo"
68+
name = "Foo" # This is set to make sure the correct class name ("B") is picked up
6669

6770

6871
class C(B[str]):
@@ -147,21 +150,35 @@ def test_parse_annotation(annotation: Any, module: str, class_name: str, args: t
147150
(Type[A], ":py:class:`~typing.Type`\\[:py:class:`~%s.A`]" % __name__),
148151
(Any, ":py:data:`~typing.Any`"),
149152
(AnyStr, ":py:data:`~typing.AnyStr`"),
150-
(Generic[T], ":py:class:`~typing.Generic`\\[\\~T]"),
153+
(Generic[T], ":py:class:`~typing.Generic`\\[:py:class:`~typing.TypeVar`\\(``T``)]"),
151154
(Mapping, ":py:class:`~typing.Mapping`"),
152-
(Mapping[T, int], ":py:class:`~typing.Mapping`\\[\\~T, :py:class:`int`]"),
153-
(Mapping[str, V], ":py:class:`~typing.Mapping`\\[:py:class:`str`, \\-V]"),
154-
(Mapping[T, U], ":py:class:`~typing.Mapping`\\[\\~T, \\+U]"),
155+
(Mapping[T, int], ":py:class:`~typing.Mapping`\\[:py:class:`~typing.TypeVar`\\(``T``), :py:class:`int`]"),
156+
(
157+
Mapping[str, V],
158+
":py:class:`~typing.Mapping`\\[:py:class:`str`, :py:class:`~typing.TypeVar`\\(``V``, contravariant=True)]",
159+
),
160+
(
161+
Mapping[T, U],
162+
":py:class:`~typing.Mapping`\\[:py:class:`~typing.TypeVar`\\(``T``), "
163+
":py:class:`~typing.TypeVar`\\(``U``, covariant=True)]",
164+
),
155165
(Mapping[str, bool], ":py:class:`~typing.Mapping`\\[:py:class:`str`, " ":py:class:`bool`]"),
156166
(Dict, ":py:class:`~typing.Dict`"),
157-
(Dict[T, int], ":py:class:`~typing.Dict`\\[\\~T, :py:class:`int`]"),
158-
(Dict[str, V], ":py:class:`~typing.Dict`\\[:py:class:`str`, \\-V]"),
159-
(Dict[T, U], ":py:class:`~typing.Dict`\\[\\~T, \\+U]"),
167+
(Dict[T, int], ":py:class:`~typing.Dict`\\[:py:class:`~typing.TypeVar`\\(``T``), :py:class:`int`]"),
168+
(
169+
Dict[str, V],
170+
":py:class:`~typing.Dict`\\[:py:class:`str`, :py:class:`~typing.TypeVar`\\(``V``, contravariant=True)]",
171+
),
172+
(
173+
Dict[T, U],
174+
":py:class:`~typing.Dict`\\[:py:class:`~typing.TypeVar`\\(``T``),"
175+
" :py:class:`~typing.TypeVar`\\(``U``, covariant=True)]",
176+
),
160177
(Dict[str, bool], ":py:class:`~typing.Dict`\\[:py:class:`str`, " ":py:class:`bool`]"),
161178
(Tuple, ":py:data:`~typing.Tuple`"),
162179
(Tuple[str, bool], ":py:data:`~typing.Tuple`\\[:py:class:`str`, " ":py:class:`bool`]"),
163180
(Tuple[int, int, int], ":py:data:`~typing.Tuple`\\[:py:class:`int`, " ":py:class:`int`, :py:class:`int`]"),
164-
(Tuple[str, ...], ":py:data:`~typing.Tuple`\\[:py:class:`str`, ...]"),
181+
(Tuple[str, ...], ":py:data:`~typing.Tuple`\\[:py:class:`str`, :py:data:`...<Ellipsis>`]"),
165182
(Union, ":py:data:`~typing.Union`"),
166183
(Union[str, bool], ":py:data:`~typing.Union`\\[:py:class:`str`, " ":py:class:`bool`]"),
167184
(Union[str, bool, None], ":py:data:`~typing.Union`\\[:py:class:`str`, " ":py:class:`bool`, :py:obj:`None`]"),
@@ -178,7 +195,7 @@ def test_parse_annotation(annotation: Any, module: str, class_name: str, args: t
178195
":py:data:`~typing.Union`\\[:py:class:`str`, " ":py:class:`bool`, :py:obj:`None`]",
179196
),
180197
(Callable, ":py:data:`~typing.Callable`"),
181-
(Callable[..., int], ":py:data:`~typing.Callable`\\[..., :py:class:`int`]"),
198+
(Callable[..., int], ":py:data:`~typing.Callable`\\[:py:data:`...<Ellipsis>`, :py:class:`int`]"),
182199
(Callable[[int], int], ":py:data:`~typing.Callable`\\[\\[:py:class:`int`], " ":py:class:`int`]"),
183200
(
184201
Callable[[int, str], bool],
@@ -188,7 +205,11 @@ def test_parse_annotation(annotation: Any, module: str, class_name: str, args: t
188205
Callable[[int, str], None],
189206
":py:data:`~typing.Callable`\\[\\[:py:class:`int`, " ":py:class:`str`], :py:obj:`None`]",
190207
),
191-
(Callable[[T], T], ":py:data:`~typing.Callable`\\[\\[\\~T], \\~T]"),
208+
(
209+
Callable[[T], T],
210+
":py:data:`~typing.Callable`\\[\\[:py:class:`~typing.TypeVar`\\(``T``)],"
211+
" :py:class:`~typing.TypeVar`\\(``T``)]",
212+
),
192213
(Pattern, ":py:class:`~typing.Pattern`"),
193214
(Pattern[str], ":py:class:`~typing.Pattern`\\[:py:class:`str`]"),
194215
(IO, ":py:class:`~typing.IO`"),
@@ -202,14 +223,21 @@ def test_parse_annotation(annotation: Any, module: str, class_name: str, args: t
202223
(E, ":py:class:`~%s.E`" % __name__),
203224
(E[int], ":py:class:`~%s.E`\\[:py:class:`int`]" % __name__),
204225
(W, f':py:{"class" if PY310_PLUS else "func"}:' f"`~typing.NewType`\\(``W``, :py:class:`str`)"),
226+
(T, ":py:class:`~typing.TypeVar`\\(``T``)"),
227+
(U, ":py:class:`~typing.TypeVar`\\(``U``, covariant=True)"),
228+
(V, ":py:class:`~typing.TypeVar`\\(``V``, contravariant=True)"),
229+
(X, ":py:class:`~typing.TypeVar`\\(``X``, :py:class:`str`, :py:class:`int`)"),
230+
(Y, ":py:class:`~typing.TypeVar`\\(``Y``, bound= :py:class:`str`)"),
231+
(Z, ":py:class:`~typing.TypeVar`\\(``Z``, bound= :py:class:`~test_sphinx_autodoc_typehints.A`)"),
232+
(S, ":py:class:`~typing.TypeVar`\\(``S``, bound= miss)"),
205233
# ## These test for correct internal tuple rendering, even if not all are valid Tuple types
206234
# Zero-length tuple remains
207235
(Tuple[()], ":py:data:`~typing.Tuple`\\[()]"),
208236
# Internal single tuple with simple types is flattened in the output
209237
(Tuple[(int,)], ":py:data:`~typing.Tuple`\\[:py:class:`int`]"),
210238
(Tuple[(int, int)], ":py:data:`~typing.Tuple`\\[:py:class:`int`, :py:class:`int`]"),
211239
# Ellipsis in single tuple also gets flattened
212-
(Tuple[(int, ...)], ":py:data:`~typing.Tuple`\\[:py:class:`int`, ...]"),
240+
(Tuple[(int, ...)], ":py:data:`~typing.Tuple`\\[:py:class:`int`, :py:data:`...<Ellipsis>`]"),
213241
# Internal tuple with following additional type cannot be flattened (specific to nptyping?)
214242
# These cases will fail if nptyping restructures its internal module hierarchy
215243
(
@@ -236,7 +264,7 @@ def test_parse_annotation(annotation: Any, module: str, class_name: str, args: t
236264
(
237265
nptyping.NDArray[(Any, ...), nptyping.Float],
238266
(
239-
":py:class:`~nptyping.types._ndarray.NDArray`\\[(:py:data:`~typing.Any`, ...), "
267+
":py:class:`~nptyping.types._ndarray.NDArray`\\[(:py:data:`~typing.Any`, :py:data:`...<Ellipsis>`), "
240268
":py:class:`~nptyping.types._number.Float`]"
241269
),
242270
),
@@ -249,12 +277,15 @@ def test_parse_annotation(annotation: Any, module: str, class_name: str, args: t
249277
),
250278
(
251279
nptyping.NDArray[(3, ...), nptyping.Float],
252-
(":py:class:`~nptyping.types._ndarray.NDArray`\\[(3, ...), :py:class:`~nptyping.types._number.Float`]"),
280+
(
281+
":py:class:`~nptyping.types._ndarray.NDArray`\\[(3, :py:data:`...<Ellipsis>`),"
282+
" :py:class:`~nptyping.types._number.Float`]"
283+
),
253284
),
254285
],
255286
)
256287
def test_format_annotation(inv: Inventory, annotation: Any, expected_result: str) -> None:
257-
conf = create_autospec(Config)
288+
conf = create_autospec(Config, _annotation_globals=globals())
258289
result = format_annotation(annotation, conf)
259290
assert result == expected_result
260291

@@ -266,21 +297,23 @@ def test_format_annotation(inv: Inventory, annotation: Any, expected_result: str
266297
# encapsulate Union in typing.Optional
267298
expected_result_not_simplified = ":py:data:`~typing.Optional`\\[" + expected_result_not_simplified
268299
expected_result_not_simplified += "]"
269-
conf = create_autospec(Config, simplify_optional_unions=False)
300+
conf = create_autospec(Config, simplify_optional_unions=False, _annotation_globals=globals())
270301
assert format_annotation(annotation, conf) == expected_result_not_simplified
271302

272303
# Test with the "fully_qualified" flag turned on
273304
if "typing" in expected_result_not_simplified:
274305
expected_result_not_simplified = expected_result_not_simplified.replace("~typing", "typing")
275-
conf = create_autospec(Config, typehints_fully_qualified=True, simplify_optional_unions=False)
306+
conf = create_autospec(
307+
Config, typehints_fully_qualified=True, simplify_optional_unions=False, _annotation_globals=globals()
308+
)
276309
assert format_annotation(annotation, conf) == expected_result_not_simplified
277310

278311
# Test with the "fully_qualified" flag turned on
279312
if "typing" in expected_result or "nptyping" in expected_result or __name__ in expected_result:
280313
expected_result = expected_result.replace("~typing", "typing")
281314
expected_result = expected_result.replace("~nptyping", "nptyping")
282315
expected_result = expected_result.replace("~" + __name__, __name__)
283-
conf = create_autospec(Config, typehints_fully_qualified=True)
316+
conf = create_autospec(Config, typehints_fully_qualified=True, _annotation_globals=globals())
284317
assert format_annotation(annotation, conf) == expected_result
285318

286319
# Test for the correct role (class vs data) using the official Sphinx inventory

whitelist.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ contravariant
77
cpython
88
csv
99
dedent
10+
delattr
1011
dirname
1112
docnames
1213
dunder
14+
eval
1315
exc
1416
fget
1517
fmt

0 commit comments

Comments
 (0)