Skip to content

Commit 6bd0f19

Browse files
authored
🐛 fix(resolver): survive PEP 649 lazy annotation evaluation errors (#713)
Documenting a function annotated with a non-subscriptable generic such as `DiGraph[int]` from `networkx` crashes the whole Sphinx build on Python 3.14. PEP 649 makes annotations lazy, so the module imports fine, but the first `__annotations__` access runs `__annotate__` and the `TypeError` from the annotation expression escapes through `process_signature`. The existing guards only caught the `NameError` raised by `TYPE_CHECKING`-only names. The guards in `process_signature` and `collect_documented_type_aliases` now cover the errors annotation expressions raise at runtime: `NameError`, `TypeError` and `AttributeError`. 🛟 When `typing.get_type_hints` raises `TypeError`, the resolver retries with `annotationlib`'s `FORWARDREF` format, which wraps unevaluatable expressions in `ForwardRef` proxies, so the parameter still renders as its source text (`g (DiGraph[int])`) instead of being dropped. `RecursionError` keeps returning no hints because re-evaluating after a recursion blowup is unsafe. Behavior on Python 3.13 and earlier is unchanged; eager annotation evaluation raises at import time there, before this code runs. Fixes #712.
1 parent 7a9f674 commit 6bd0f19

7 files changed

Lines changed: 101 additions & 16 deletions

File tree

src/sphinx_autodoc_typehints/__init__.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,10 @@ def process_signature( # noqa: C901, PLR0911, PLR0912, PLR0913, PLR0917
6767
obj = getattr(obj, "__init__", getattr(obj, "__new__", None)) if inspect.isclass(obj) else obj
6868
try:
6969
has_annotations = getattr(obj, "__annotations__", None)
70-
except NameError:
71-
# PEP 649 (Python 3.14+): accessing __annotations__ may raise NameError when annotations
72-
# reference TYPE_CHECKING-only names. Setting __annotations__ clears __annotate__, so we
73-
# only reach here when annotations come from the original function definition. If __annotate__
74-
# is callable, the function has annotations; proceed so sphinx_signature can use FORWARDREF.
70+
except (NameError, TypeError, AttributeError):
71+
# PEP 649 (Python 3.14+): annotation expressions only run on access and may fail at runtime
72+
# (TYPE_CHECKING-only names, subscripting a non-generic class). __annotate__ still signals
73+
# annotations exist, and sphinx_signature renders them without evaluation via FORWARDREF.
7574
has_annotations = getattr(obj, "__annotate__", None)
7675
if not has_annotations:
7776
return None

src/sphinx_autodoc_typehints/_resolver/_type_hints.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ def _get_type_hint(
121121
isinstance(exc, TypeError) and _future_annotations_imported(obj) and "unsupported operand type" in str(exc)
122122
): # pragma: <3.14 cover
123123
result = obj.__annotations__
124+
elif isinstance(exc, TypeError) and sys.version_info >= (3, 14): # pragma: >=3.14 cover
125+
result = _get_forward_ref_annotations(obj)
124126
else:
125127
result = {}
126128
except NameError as exc:
@@ -140,6 +142,14 @@ def _get_type_hint(
140142
return result
141143

142144

145+
def _get_forward_ref_annotations(obj: Any) -> dict[str, Any]: # pragma: >=3.14 cover
146+
# ForwardRef proxies keep unevaluatable annotations renderable as their source text — see #712
147+
try:
148+
return annotationlib.get_annotations(obj, format=annotationlib.Format.FORWARDREF)
149+
except (NameError, TypeError, AttributeError, RecursionError):
150+
return {}
151+
152+
143153
def _resolve_type_guarded_imports(autodoc_mock_imports: list[str], obj: Any) -> None:
144154
if _should_skip_guarded_import_resolution(obj):
145155
return

src/sphinx_autodoc_typehints/_resolver/_util.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,11 @@ def collect_documented_type_aliases(
3131
deferred = _collect_module_type_aliases(module_prefix, py_objects)
3232
eager: dict[int, MyTypeAliasForwardRef] = {}
3333

34-
# In Python 3.14+, accessing __annotations__ evaluates lazy annotations (PEP 649) and
35-
# may raise NameError for TYPE_CHECKING-only names before imports are resolved.
34+
# PEP 649 (Python 3.14+): annotation expressions only run on access and may fail at runtime
35+
# (TYPE_CHECKING-only names, subscripting a non-generic class)
3636
try:
3737
raw_annotations = getattr(obj, "__annotations__", {})
38-
except NameError:
38+
except (NameError, TypeError, AttributeError):
3939
return deferred, eager
4040
if not raw_annotations:
4141
return deferred, eager

tests/test_init.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -357,14 +357,21 @@ def test_process_docstring_trailing_underscore_param(
357357
assert any(line.startswith(":type ") and expected_type_name in line and "float" in line for line in lines)
358358

359359

360-
def test_process_signature_annotations_name_error() -> None:
361-
"""PEP 649 lazy annotations raising NameError must not propagate from process_signature."""
360+
@pytest.mark.parametrize(
361+
"error",
362+
[
363+
pytest.param(NameError("Callable"), id="name-error"),
364+
pytest.param(TypeError("type 'DiGraph' is not subscriptable"), id="type-error"),
365+
],
366+
)
367+
def test_process_signature_annotations_error(error: Exception) -> None:
368+
"""PEP 649 lazy annotation evaluation raising (NameError for TYPE_CHECKING-only names, TypeError
369+
for subscripting a non-generic class) must not propagate from process_signature (issue #712)."""
362370

363371
class _Func:
364372
@property
365373
def __annotations__(self) -> dict[str, object]: # noqa: PLW3201
366-
msg = "Callable"
367-
raise NameError(msg)
374+
raise error
368375

369376
def __call__(self) -> None: ...
370377

tests/test_pep695.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,3 +534,36 @@ def test_forward_ref_builds_without_errors( # pragma: >=3.14 cover
534534
assert "build succeeded" in status.getvalue()
535535
result = normalize_sphinx_text((Path(app.srcdir) / "_build/text/index.txt").read_text())
536536
assert "Tree" in result
537+
538+
539+
@pytest.mark.skipif(sys.version_info < (3, 14), reason="PEP 649 lazy annotation evaluation is Python 3.14+")
540+
@pytest.mark.sphinx("text", testroot="integration")
541+
def test_non_subscriptable_generic_annotation( # pragma: >=3.14 cover
542+
app: SphinxTestApp,
543+
status: StringIO,
544+
warning: StringIO,
545+
monkeypatch: pytest.MonkeyPatch,
546+
) -> None:
547+
"""Regression test for #712: annotations whose lazy evaluation raises (here TypeError from
548+
subscripting a non-generic class) must not crash the build; the hint degrades to its source text."""
549+
mod = types.ModuleType("mod_712")
550+
mod.__file__ = __file__
551+
source = dedent("""\
552+
class DiGraph:
553+
pass
554+
555+
556+
def add_node_between_nodes(g: DiGraph[int]) -> None:
557+
\"\"\"Stub.\"\"\"
558+
""")
559+
# dont_inherit keeps this file's `from __future__ import annotations` (PEP 563) out of the
560+
# compiled module so its annotations stay lazily evaluated (PEP 649)
561+
exec(compile(source, "<mod_712>", "exec", dont_inherit=True), mod.__dict__) # noqa: S102
562+
(Path(app.srcdir) / "index.rst").write_text(".. autofunction:: mod_712.add_node_between_nodes")
563+
monkeypatch.setitem(sys.modules, "mod_712", mod)
564+
app.config.__dict__["always_document_param_types"] = True
565+
app.build()
566+
assert "build succeeded" in status.getvalue()
567+
assert "threw an exception" not in warning.getvalue()
568+
result = normalize_sphinx_text((Path(app.srcdir) / "_build/text/index.txt").read_text())
569+
assert "DiGraph[int]" in result

tests/test_resolver/test_type_hints.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,34 @@ def func(x: int) -> str: ...
6666
assert _get_type_hint([], "test", func, {}) == {}
6767

6868

69+
@pytest.fixture
70+
def non_subscriptable_generic_func() -> Any: # pragma: >=3.14 cover
71+
# dont_inherit keeps this file's `from __future__ import annotations` (PEP 563) out of the
72+
# compiled module so its annotations stay lazily evaluated (PEP 649)
73+
source = "class NotGeneric: ...\n\n\ndef func(g: NotGeneric[int]) -> None: ...\n"
74+
ns: dict[str, Any] = {}
75+
exec(compile(source, "<mod_712>", "exec", dont_inherit=True), ns) # noqa: S102
76+
return ns["func"]
77+
78+
79+
@pytest.mark.skipif(sys.version_info < (3, 14), reason="PEP 649 lazy annotation evaluation is Python 3.14+")
80+
def test_get_type_hint_unevaluatable_annotation_falls_back_to_forward_ref(
81+
non_subscriptable_generic_func: Any,
82+
) -> None: # pragma: >=3.14 cover
83+
"""Annotations whose evaluation raises TypeError degrade to ForwardRef proxies (issue #712)."""
84+
result = _get_type_hint([], "test.func", non_subscriptable_generic_func, {})
85+
assert result["g"].__forward_arg__ == "NotGeneric[int]"
86+
87+
88+
@pytest.mark.skipif(sys.version_info < (3, 14), reason="PEP 649 lazy annotation evaluation is Python 3.14+")
89+
def test_get_type_hint_forward_ref_fallback_failure_returns_empty(
90+
non_subscriptable_generic_func: Any,
91+
) -> None: # pragma: >=3.14 cover
92+
"""When even the FORWARDREF format cannot evaluate the annotations, fall back to no hints."""
93+
with patch("sphinx_autodoc_typehints._resolver._type_hints.annotationlib.get_annotations", side_effect=TypeError):
94+
assert _get_type_hint([], "test.func", non_subscriptable_generic_func, {}) == {}
95+
96+
6997
def test_execute_guarded_code_catches_exception() -> None:
7098
module = type("FakeModule", (), {"__globals__": {}, "__dict__": {}})()
7199
with patch("sphinx_autodoc_typehints._resolver._type_hints._run_guarded_import", side_effect=RuntimeError("boom")):

tests/test_resolver/test_util.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import Any
44
from unittest.mock import MagicMock, create_autospec, patch
55

6+
import pytest
67
from sphinx.environment import BuildEnvironment
78

89
from sphinx_autodoc_typehints._annotations import MyTypeAliasForwardRef
@@ -90,14 +91,21 @@ def test_collect_documented_type_aliases_ignores_unqualified_names() -> None:
9091
assert eager == {}
9192

9293

93-
def test_collect_documented_type_aliases_annotations_name_error() -> None:
94-
"""PEP 649 lazy annotations raising NameError must not propagate."""
94+
@pytest.mark.parametrize(
95+
"error",
96+
[
97+
pytest.param(NameError("Callable"), id="name-error"),
98+
pytest.param(TypeError("type 'DiGraph' is not subscriptable"), id="type-error"),
99+
],
100+
)
101+
def test_collect_documented_type_aliases_annotations_error(error: Exception) -> None:
102+
"""PEP 649 lazy annotation evaluation raising (NameError for TYPE_CHECKING-only names, TypeError
103+
for subscripting a non-generic class) must not propagate (issue #712)."""
95104

96105
class _AnnotationsRaiser:
97106
@property
98107
def __annotations__(self) -> dict[str, object]: # noqa: PLW3201
99-
msg = "Callable"
100-
raise NameError(msg)
108+
raise error
101109

102110
env = _make_env_with_types(["mymod.MyType"])
103111
deferred, eager = collect_documented_type_aliases(_AnnotationsRaiser(), "mymod", env)

0 commit comments

Comments
 (0)