Skip to content

Commit 4317689

Browse files
authored
🐛 fix(stubs): resolve stub imports for type hint evaluation (#654)
When stub annotations reference types only imported in the `.pyi` file (e.g., `Sequence`, `Optional`, `Callable`), `get_type_hints()` raises `NameError` because those names aren't in the runtime module's namespace. The fallback returns raw strings, and `format_annotation()` can't create cross-reference links — so rendered docs show plain text with no hyperlinks for any non-builtin type from a stub. 🐛 This is the issue @agronholm identified in #652. The fix extracts the stub's top-level import statements from the AST, resolves them via `importlib.import_module`, and passes the resulting namespace as `localns` to `get_type_hints()`. This complements the runtime module's `__dict__` (which already provides self-references and cross-class refs defined in the module) with the stub-specific imports needed for evaluation. ### Why not `eval` the AST annotation nodes directly? We investigated using `eval(compile(ast.Expression(body=annotation), ...))` instead of `ast.unparse` + `get_type_hints()`. This fails on the most common cases because class definitions in the stub aren't imports — they're AST nodes with no corresponding object in the eval namespace: | Scenario | `eval` | string + `get_type_hints()` | |---|---|---| | Self-reference (`Foo.method() -> Foo`) | `NameError` | Works via runtime `__dict__` | | Cross-class ref (`Bar.get_foo() -> Foo`) | `NameError` | Works | | Compound (`Optional[Foo]`) | `NameError` | Works | | Forward ref (class defined later in stub) | `NameError` | Works | ### How other tools handle this - **pdoc** imports the entire `.pyi` as a Python module via [`SourceFileLoader`](https://github.com/mitmproxy/pdoc/blob/main/pdoc/doc_pyi.py) — creates real objects but shadows runtime classes, breaking Sphinx's ability to link to the documented types - **griffe** builds a custom [expression tree](https://github.com/mkdocstrings/griffe/blob/main/src/_griffe/expressions.py) from AST nodes, reimplementing all name resolution independently of Python's runtime — effective but massive complexity for our use case - **sphinx autodoc** (built-in) had no native stub support for C extensions until [v8.2.0](sphinx-doc/sphinx#13253) (see [#7630](sphinx-doc/sphinx#7630)) No standalone library exists for "resolve stub imports into a namespace" — the operation is ~20 lines of `importlib.import_module` + `getattr`, and every tool that needs it has different requirements. Our implementation keeps it minimal and focused.
1 parent 189d15b commit 4317689

8 files changed

Lines changed: 111 additions & 5 deletions

File tree

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,9 @@ lint.per-file-ignores."tests/roots/test-pyi-stubs/stub_mod.py" = [
134134
"PLR6301", # method must be instance method to test stub resolution
135135
"RUF029", # async function must be async to test async stub resolution
136136
]
137+
lint.per-file-ignores."tests/roots/test-pyi-stubs/stub_mod.pyi" = [
138+
"I002", # stub files don't use `from __future__ import annotations`
139+
]
137140
lint.per-file-ignores."tests/test_resolver/test_type_comments.py" = [
138141
"ANN001", # type comment fixtures intentionally omit annotations
139142
"ARG001", # type comment fixtures have intentionally unused args

src/sphinx_autodoc_typehints/_resolver/_stubs.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from __future__ import annotations
44

55
import ast
6+
import contextlib
7+
import importlib
68
import inspect
79
from pathlib import Path
810
from typing import Any
@@ -18,6 +20,37 @@ def _backfill_from_stub(obj: Any) -> dict[str, str]:
1820
return {}
1921

2022

23+
def _get_stub_localns(obj: Any) -> dict[str, Any]:
24+
if (stub_path := _find_stub_path(obj)) and (tree := _parse_stub_ast(stub_path)):
25+
return _resolve_stub_imports(tree)
26+
return {}
27+
28+
29+
def _resolve_stub_imports(tree: ast.Module) -> dict[str, Any]:
30+
ns: dict[str, Any] = {}
31+
for node in tree.body:
32+
if isinstance(node, ast.Import):
33+
for alias in node.names:
34+
with contextlib.suppress(ImportError):
35+
if alias.asname:
36+
ns[alias.asname] = importlib.import_module(alias.name)
37+
else:
38+
top = alias.name.split(".")[0]
39+
ns[top] = importlib.import_module(top)
40+
elif isinstance(node, ast.ImportFrom) and node.module:
41+
try:
42+
mod = importlib.import_module(node.module)
43+
except ImportError:
44+
continue
45+
for alias in node.names:
46+
if alias.name == "*":
47+
continue
48+
name = alias.asname or alias.name
49+
if (val := getattr(mod, alias.name, None)) is not None:
50+
ns[name] = val
51+
return ns
52+
53+
2154
def _find_stub_path(obj: Any) -> Path | None:
2255
if (module := inspect.getmodule(obj)) is None:
2356
return None

src/sphinx_autodoc_typehints/_resolver/_type_hints.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from sphinx.ext.autodoc.mock import mock
1414
from sphinx.util import logging
1515

16-
from ._stubs import _backfill_from_stub
16+
from ._stubs import _backfill_from_stub, _get_stub_localns
1717
from ._type_comments import backfill_type_hints
1818
from ._util import get_obj_location
1919

@@ -45,14 +45,17 @@ def get_all_type_hints(
4545
result = _get_type_hint(autodoc_mock_imports, name, obj, localns)
4646
if not result:
4747
result = backfill_type_hints(obj, name)
48+
stub_localns: dict[str, Any] = {}
4849
if not result:
4950
result = _backfill_from_stub(obj)
51+
if result:
52+
stub_localns = _get_stub_localns(obj)
5053
try:
5154
obj.__annotations__ = result
5255
except (AttributeError, TypeError):
5356
pass
5457
else:
55-
result = _get_type_hint(autodoc_mock_imports, name, obj, localns)
58+
result = _get_type_hint(autodoc_mock_imports, name, obj, {**localns, **stub_localns})
5659
return result
5760

5861

tests/roots/test-pyi-stubs/conf.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99

1010
extensions = [
1111
"sphinx.ext.autodoc",
12+
"sphinx.ext.intersphinx",
1213
"sphinx.ext.napoleon",
1314
"sphinx_autodoc_typehints",
1415
]
16+
17+
intersphinx_mapping = {"python": ("https://docs.python.org/3", None)}
Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,7 @@
1-
Test
2-
====
1+
2+
.. autofunction:: stub_mod.greet
3+
4+
.. autoclass:: stub_mod.Calculator
5+
:members:
6+
7+
.. autofunction:: stub_mod.fetch

tests/roots/test-pyi-stubs/stub_mod.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,7 @@ def process(self, data):
1919

2020
async def fetch(url):
2121
return url
22+
23+
24+
def transform(value):
25+
return value

tests/roots/test-pyi-stubs/stub_mod.pyi

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from collections.abc import Sequence
2+
13
def greet(name: str, greeting: str) -> str: ...
24

35
class Calculator:
@@ -7,3 +9,4 @@ class Calculator:
79
def process(self, data: bytes) -> bytes: ...
810

911
async def fetch(url: str) -> str: ...
12+
def transform(value: Sequence[int]) -> list[str]: ...

tests/test_resolver/test_stubs.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
from __future__ import annotations
22

33
import ast
4+
import os
45
import sys
56
import types
7+
from collections.abc import Sequence
68
from pathlib import Path
7-
from typing import TYPE_CHECKING, Any
9+
from typing import TYPE_CHECKING, Any, Optional
810
from unittest.mock import MagicMock, patch
911

1012
import pytest
@@ -18,7 +20,9 @@
1820
_extract_func_annotations,
1921
_find_ast_node,
2022
_find_stub_path,
23+
_get_stub_localns,
2124
_parse_stub_ast,
25+
_resolve_stub_imports,
2226
)
2327

2428
if TYPE_CHECKING:
@@ -285,6 +289,7 @@ def test_extract_annotations_from_stub_no_qualname() -> None:
285289
pytest.param("greet", {"name": "str", "greeting": "str", "return": "str"}, id="function"),
286290
pytest.param("Calculator.Inner.process", {"data": "bytes", "return": "bytes"}, id="nested_class"),
287291
pytest.param("fetch", {"url": "str", "return": "str"}, id="async_function"),
292+
pytest.param("transform", {"value": "Sequence[int]", "return": "list[str]"}, id="typing_imports"),
288293
],
289294
)
290295
def test_backfill_from_stub(stub_mod: Any, attr: str, expected: dict[str, str]) -> None:
@@ -298,6 +303,36 @@ def test_backfill_from_stub_no_stub() -> None:
298303
assert _backfill_from_stub(test_backfill_from_stub_no_stub) == {}
299304

300305

306+
@pytest.mark.parametrize(
307+
("source", "expected"),
308+
[
309+
pytest.param("import os\nimport sys\n", {"os": os, "sys": sys}, id="basic_import"),
310+
pytest.param("import os as operating_system\n", {"operating_system": os}, id="import_as"),
311+
pytest.param("import os.path\n", {"os": os}, id="dotted_import"),
312+
pytest.param("from typing import Optional, Any\n", {"Optional": Optional, "Any": Any}, id="from_import"),
313+
pytest.param("from typing import Optional as Opt\n", {"Opt": Optional}, id="from_import_as"),
314+
pytest.param("from typing import *\n", {}, id="star_import_skipped"),
315+
pytest.param("import nonexistent_xyz\nfrom nonexistent_abc import Foo\n", {}, id="missing_module_skipped"),
316+
pytest.param("from typing import NonExistentThing\n", {}, id="missing_attr_skipped"),
317+
],
318+
)
319+
def test_resolve_stub_imports(source: str, expected: dict[str, Any]) -> None:
320+
ns = _resolve_stub_imports(ast.parse(source))
321+
for key, val in expected.items():
322+
assert ns[key] is val
323+
if not expected:
324+
assert ns == {}
325+
326+
327+
def test_get_stub_localns_returns_imports(stub_mod: Any) -> None:
328+
ns = _get_stub_localns(stub_mod.transform)
329+
assert ns["Sequence"] is Sequence
330+
331+
332+
def test_get_stub_localns_returns_empty_for_no_stub() -> None:
333+
assert _get_stub_localns(test_get_stub_localns_returns_empty_for_no_stub) == {}
334+
335+
301336
@pytest.mark.sphinx("text", testroot="pyi-stubs")
302337
def test_sphinx_build_uses_stub_types(app: SphinxTestApp, status: StringIO, warning: StringIO) -> None:
303338
template = """\
@@ -307,11 +342,28 @@ def test_sphinx_build_uses_stub_types(app: SphinxTestApp, status: StringIO, warn
307342
:members:
308343
309344
.. autofunction:: stub_mod.fetch
345+
346+
.. autofunction:: stub_mod.transform
310347
"""
311348
(Path(app.srcdir) / "index.rst").write_text(template)
312349
app.build()
313350
assert "build succeeded" in status.getvalue()
314351
result = normalize_sphinx_text((Path(app.srcdir) / "_build/text/index.txt").read_text())
315352
assert "str" in result
353+
assert "list" in result
316354
warn_text = warning.getvalue()
317355
assert "stub_mod" not in warn_text or "forward reference" not in warn_text
356+
sys.modules.pop("stub_mod", None)
357+
358+
359+
@pytest.mark.sphinx("pseudoxml", testroot="pyi-stubs")
360+
def test_sphinx_build_stub_types_produce_crossrefs(app: SphinxTestApp, status: StringIO) -> None:
361+
template = """\
362+
.. autofunction:: stub_mod.transform
363+
"""
364+
(Path(app.srcdir) / "index.rst").write_text(template)
365+
app.build()
366+
assert "build succeeded" in status.getvalue()
367+
result = (Path(app.srcdir) / "_build/pseudoxml/index.pseudoxml").read_text()
368+
assert 'classes="xref py py-class"' in result
369+
assert "docs.python.org" in result

0 commit comments

Comments
 (0)