Skip to content

Commit e4cb9f3

Browse files
committed
🐛 fix(resolver): accept guarded code the interpreter rejects
A TYPE_CHECKING block is read by type checkers, not run by the interpreter, so it holds constructs that raise when executed. xarray writes TypeVar("T_XarrayOther", bound="DataArray" | Dataset), which is a TypeError at runtime. Executing the guard to recover its names hit that error, warned about a failed type import, and then warned a second time when the annotation using the name could not resolve. Both warnings pointed at code that is correct. An import that fails still warns, since a missing dependency is worth knowing about. Any other statement now stands its names in as forward references, which is what a type checker sees and what the renderer already knows how to print. Closes #751
1 parent c4b99d0 commit e4cb9f3

7 files changed

Lines changed: 128 additions & 3 deletions

File tree

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,9 @@ lint.per-file-ignores."tests/roots/test-integration/mod_forward_ref.py" = [
131131
"I002", # intentionally omits `from __future__ import annotations` to test forward references
132132
"PLR6301", # method must be instance method to test class forward refs
133133
]
134+
lint.per-file-ignores."tests/roots/test-unexecutable-guard/demo_unexecutable_guard.py" = [
135+
"UP047", # the legacy TypeVar spelling is what the guard cannot execute
136+
]
134137
lint.per-file-ignores."tests/roots/test-pyi-stubs/stub_mod.py" = [
135138
"ANN", # intentionally has no type annotations to simulate a C extension
136139
"I002", # intentionally omits `from __future__ import annotations`

src/sphinx_autodoc_typehints/_resolver/_type_hints.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,15 +201,18 @@ def _execute_guarded_code(autodoc_mock_imports: list[str], obj: Any, module_code
201201
for _, part in _TYPE_GUARD_IMPORT_RE.findall(module_code):
202202
try:
203203
# One statement at a time, so an unimportable optional dependency cannot strand the names after it — #741
204-
statements = [ast.unparse(node) for node in ast.parse(textwrap.dedent(part)).body]
204+
statements = ast.parse(textwrap.dedent(part)).body
205205
except SyntaxError as exc:
206206
_warn_guarded_import(obj, exc)
207207
continue
208208
for statement in statements:
209209
try:
210-
_run_guarded_import(autodoc_mock_imports, obj, statement)
210+
_run_guarded_import(autodoc_mock_imports, obj, ast.unparse(statement))
211211
except Exception as exc: # ruff:ignore[blind-except]
212-
_warn_guarded_import(obj, exc)
212+
if isinstance(statement, ast.Import | ast.ImportFrom):
213+
_warn_guarded_import(obj, exc)
214+
else:
215+
_bind_unresolvable_names(obj, statement)
213216

214217

215218
def _warn_guarded_import(obj: Any, exc: Exception) -> None:
@@ -223,6 +226,21 @@ def _warn_guarded_import(obj: Any, exc: Exception) -> None:
223226
)
224227

225228

229+
def _bind_unresolvable_names(obj: Any, statement: ast.stmt) -> None:
230+
"""
231+
Bind the names a guarded statement would have defined, so annotations can still use them.
232+
233+
Type checkers accept constructs the interpreter rejects, e.g. ``TypeVar("T", bound="A" | B)`` (#751).
234+
"""
235+
if isinstance(statement, ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef):
236+
names = [statement.name]
237+
else:
238+
names = [n.id for n in ast.walk(statement) if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store)]
239+
namespace = getattr(obj, "__globals__", obj.__dict__)
240+
for name in names:
241+
namespace.setdefault(name, MyTypeAliasForwardRef(name))
242+
243+
226244
def _run_guarded_import(autodoc_mock_imports: list[str], obj: Any, guarded_code: str) -> None:
227245
ns = getattr(obj, "__globals__", obj.__dict__)
228246
try:
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from __future__ import annotations
2+
3+
import pathlib
4+
import sys
5+
6+
master_doc = "index"
7+
sys.path.insert(0, str(pathlib.Path(__file__).parent))
8+
extensions = [
9+
"sphinx.ext.autodoc",
10+
"sphinx_autodoc_typehints",
11+
]
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Module demonstrating type guarded code that the interpreter cannot run."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING, TypeVar
6+
7+
if TYPE_CHECKING:
8+
from demo_unexecutable_guard_dummy import Dataset
9+
10+
T_Other = TypeVar("T_Other", bound="DataArray" | Dataset)
11+
12+
class Wrapper(Dataset[int]):
13+
"""A wrapped dataset."""
14+
15+
16+
class DataArray:
17+
"""An array."""
18+
19+
20+
def combine(array: DataArray, other: T_Other) -> T_Other:
21+
"""
22+
Combine two arrays.
23+
24+
:param array: the first array
25+
:param other: the second array
26+
:return: the combination
27+
"""
28+
raise NotImplementedError
29+
30+
31+
def wrap(array: DataArray) -> Wrapper:
32+
"""
33+
Wrap an array.
34+
35+
:param array: the array to wrap
36+
:return: the wrapper
37+
"""
38+
raise NotImplementedError
39+
40+
41+
__all__ = ["DataArray", "combine", "wrap"]
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from __future__ import annotations
2+
3+
4+
class Dataset:
5+
"""Generic to a type checker only, so subscripting it raises."""
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.. automodule:: demo_unexecutable_guard
2+
:members:

tests/test_guarded_import.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,48 @@ def func(x: int) -> int:
5757
app.build()
5858
assert "build succeeded" in status.getvalue()
5959
assert "Failed guarded type import" not in warning.getvalue()
60+
61+
62+
@pytest.mark.sphinx("text", testroot="unexecutable-guard")
63+
def test_guarded_code_the_interpreter_rejects(app: SphinxTestApp, status: StringIO, warning: StringIO) -> None:
64+
"""Names from type-checker-only code render without warnings (#751)."""
65+
app.build()
66+
assert "build succeeded" in status.getvalue()
67+
assert not warning.getvalue()
68+
text = (Path(app.srcdir) / "_build" / "text" / "index.txt").read_text()
69+
assert text == dedent("""\
70+
Module demonstrating type guarded code that the interpreter cannot
71+
run.
72+
73+
class demo_unexecutable_guard.DataArray
74+
75+
An array.
76+
77+
demo_unexecutable_guard.combine(array, other)
78+
79+
Combine two arrays.
80+
81+
Parameters:
82+
* **array** ("DataArray") -- the first array
83+
84+
* **other** (T_Other) -- the second array
85+
86+
Return type:
87+
T_Other
88+
89+
Returns:
90+
the combination
91+
92+
demo_unexecutable_guard.wrap(array)
93+
94+
Wrap an array.
95+
96+
Parameters:
97+
**array** ("DataArray") -- the array to wrap
98+
99+
Return type:
100+
Wrapper
101+
102+
Returns:
103+
the wrapper
104+
""")

0 commit comments

Comments
 (0)