Skip to content

Commit 7672e3f

Browse files
authored
🐛 fix(annotations): expand aliases from unread modules (#765)
1 parent 60d74b3 commit 7672e3f

6 files changed

Lines changed: 128 additions & 10 deletions

File tree

src/sphinx_autodoc_typehints/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import re
77
import types
88
from contextlib import contextmanager
9+
from functools import partial
910
from typing import TYPE_CHECKING, Any, TypeVar
1011

1112
from docutils import nodes
@@ -36,6 +37,7 @@
3637
get_descriptor_type_hint,
3738
get_instance_var_annotations,
3839
get_obj_location,
40+
resolve_type_guarded_imports,
3941
)
4042
from .patches import _OVERLOADS_CACHE, install_patches
4143
from .version import __version__
@@ -203,6 +205,7 @@ def process_docstring( # ruff:ignore[too-many-arguments, too-many-positional-ar
203205
_annotation_globals=getattr(obj, "__globals__", {}),
204206
_typehints_env=env,
205207
_typehints_module_prefix=module_prefix,
208+
_typehints_resolve_guarded_imports=partial(resolve_type_guarded_imports, app.config.autodoc_mock_imports),
206209
):
207210
has_overloads = _inject_overload_signatures(app, what, name, obj, lines)
208211
_inject_types_to_docstring(type_hints, signature, original_obj, app, what, name, lines, has_overloads)

src/sphinx_autodoc_typehints/_annotations.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@ def _format_node( # ruff:ignore[complex-structure, too-many-return-statements,
171171
if isinstance(annotation, TypeAliasType):
172172
if (crossref := _type_alias_crossref(annotation, config)) is not None:
173173
return crossref
174+
if not _can_expand_alias(annotation, config):
175+
return _alias_name_reference(annotation, config)
174176
return (yield annotation.__value__)
175177

176178
if isinstance(alias := get_origin(annotation), TypeAliasType | TypeAliasForwardRef):
@@ -182,9 +184,9 @@ def _format_node( # ruff:ignore[complex-structure, too-many-return-statements,
182184
rendered_alias = yield alias
183185
elif (crossref := _type_alias_crossref(alias, config)) is not None:
184186
rendered_alias = crossref
185-
elif _matches_type_params(alias, args): # an undocumented alias: expand its value, with args substituted
187+
elif _matches_type_params(alias, args) and _can_expand_alias(alias, config): # undocumented: expand its value
186188
return (yield _substitute_type_params(alias, args))
187-
else: # a wrong-arity subscript cannot expand, so name the alias instead of leaking its type params
189+
else: # cannot expand, so name the alias instead of leaking its type params
188190
rendered_alias = _alias_name_reference(alias, config)
189191
parts = []
190192
for arg in args:
@@ -390,6 +392,30 @@ def _underlying_type_alias(annotation: Any) -> TypeAliasType | None:
390392
return None
391393

392394

395+
def _can_expand_alias(alias: TypeAliasType, config: Config) -> bool:
396+
"""
397+
Whether the alias' lazily evaluated value is available.
398+
399+
A module reached only through an annotation has not had its ``if TYPE_CHECKING`` block executed, so the names
400+
the value uses can still be missing; run that block before giving up, so what we render does not depend on the
401+
order Sphinx reads modules in (#764).
402+
"""
403+
if _alias_value_evaluates(alias):
404+
return True
405+
if (resolve_guarded_imports := getattr(config, "_typehints_resolve_guarded_imports", None)) is None:
406+
return False
407+
resolve_guarded_imports(inspect.getmodule(alias))
408+
return _alias_value_evaluates(alias)
409+
410+
411+
def _alias_value_evaluates(alias: TypeAliasType) -> bool:
412+
try:
413+
_ = alias.__value__
414+
except NameError:
415+
return False
416+
return True
417+
418+
393419
def _matches_type_params(alias: TypeAliasType, args: tuple[Any, ...]) -> bool:
394420
"""Whether ``args`` can be substituted: the runtime accepts a wrong-arity subscript, so check it here."""
395421
params = alias.__type_params__

src/sphinx_autodoc_typehints/_resolver/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from ._attrs import backfill_attrs_annotations
66
from ._instance_vars import get_instance_var_annotations
77
from ._type_comments import backfill_type_hints
8-
from ._type_hints import get_all_type_hints, get_descriptor_type_hint
8+
from ._type_hints import get_all_type_hints, get_descriptor_type_hint, resolve_type_guarded_imports
99
from ._util import collect_documented_type_aliases, get_obj_location
1010

1111
__all__ = [
@@ -16,4 +16,5 @@
1616
"get_descriptor_type_hint",
1717
"get_instance_var_annotations",
1818
"get_obj_location",
19+
"resolve_type_guarded_imports",
1920
]

src/sphinx_autodoc_typehints/_resolver/_type_hints.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ def _resolve_string_annotations(
127127

128128

129129
def _get_type_hint(autodoc_mock_imports: list[str], name: str, obj: Any, localns: Mapping[str, Any]) -> dict[str, Any]:
130-
_resolve_type_guarded_imports(autodoc_mock_imports, obj)
130+
resolve_type_guarded_imports(autodoc_mock_imports, obj)
131131
localns = _build_localns(obj, localns)
132132
try:
133133
if getattr(obj, "__no_type_check__", False):
@@ -171,7 +171,8 @@ def _get_forward_ref_annotations(obj: Any) -> dict[str, Any]: # pragma: >=3.14
171171
return {}
172172

173173

174-
def _resolve_type_guarded_imports(autodoc_mock_imports: list[str], obj: Any) -> None:
174+
def resolve_type_guarded_imports(autodoc_mock_imports: list[str], obj: Any) -> None:
175+
"""Execute the ``if TYPE_CHECKING`` block of *obj*'s module, binding the names it guards."""
175176
if _should_skip_guarded_import_resolution(obj):
176177
return
177178

@@ -189,7 +190,7 @@ def _resolve_type_guarded_imports(autodoc_mock_imports: list[str], obj: Any) ->
189190

190191
def _should_skip_guarded_import_resolution(obj: Any) -> bool:
191192
if isinstance(obj, types.ModuleType):
192-
return False
193+
return obj.__name__ in _TYPE_GUARD_IMPORTS_RESOLVED
193194

194195
if not hasattr(obj, "__globals__"):
195196
return True
@@ -260,7 +261,7 @@ def _run_guarded_import(autodoc_mock_imports: list[str], obj: Any, guarded_code:
260261
except ImportError as exc:
261262
if not exc.name:
262263
return
263-
_resolve_type_guarded_imports(autodoc_mock_imports, importlib.import_module(exc.name))
264+
resolve_type_guarded_imports(autodoc_mock_imports, importlib.import_module(exc.name))
264265
try:
265266
with mock(autodoc_mock_imports):
266267
exec(guarded_code, ns) # ruff:ignore[exec-builtin]
@@ -303,4 +304,7 @@ def _future_annotations_imported(obj: Any) -> bool:
303304
return bool(annotations_.compiler_flag == 0x1000000) # ruff:ignore[magic-value-comparison]
304305

305306

306-
__all__ = ["get_all_type_hints"]
307+
__all__ = [
308+
"get_all_type_hints",
309+
"resolve_type_guarded_imports",
310+
]

tests/test_pep695.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,90 @@ def some_func(some_param: RecType) -> None:
574574
assert '"int" | "list"["RecType"]' in result
575575

576576

577+
@pytest.mark.parametrize(
578+
("package", "alias", "annotation", "expected"),
579+
[
580+
pytest.param(
581+
"pkg_764",
582+
"type Alias = int | Sequence[str]",
583+
"Alias | None",
584+
'"int" | "Sequence"["str"] | "None"',
585+
id="plain",
586+
),
587+
pytest.param(
588+
"pkg_764_generic",
589+
"type Alias[T] = Sequence[T]",
590+
"Alias[int]",
591+
'"Sequence"["int"]',
592+
id="generic",
593+
),
594+
],
595+
)
596+
@pytest.mark.sphinx("text", testroot="integration")
597+
def test_type_alias_value_needs_guarded_import(
598+
app: SphinxTestApp,
599+
status: StringIO,
600+
warning: StringIO,
601+
monkeypatch: pytest.MonkeyPatch,
602+
package: str,
603+
alias: str,
604+
annotation: str,
605+
expected: str,
606+
) -> None:
607+
"""An alias expands even when its value needs the guarded imports of the module defining it (#764)."""
608+
pkg = Path(app.srcdir) / package
609+
pkg.mkdir()
610+
(pkg / "__init__.py").touch()
611+
(pkg / "_types.py").write_text(
612+
dedent(f"""\
613+
from __future__ import annotations
614+
615+
from typing import TYPE_CHECKING
616+
617+
if TYPE_CHECKING:
618+
from collections.abc import Sequence
619+
620+
{alias}
621+
""")
622+
)
623+
(pkg / "api.py").write_text(
624+
dedent(f"""\
625+
from __future__ import annotations
626+
627+
from typing import TYPE_CHECKING
628+
629+
if TYPE_CHECKING:
630+
from ._types import Alias
631+
632+
633+
def f(x: {annotation}) -> None:
634+
\"\"\"Do nothing.
635+
636+
:param x: an argument.
637+
\"\"\"
638+
""")
639+
)
640+
(Path(app.srcdir) / "index.rst").write_text(f".. autofunction:: {package}.api.f\n")
641+
monkeypatch.syspath_prepend(str(app.srcdir))
642+
app.build()
643+
assert "build succeeded" in status.getvalue()
644+
assert not warning.getvalue().strip()
645+
646+
result = normalize_sphinx_text((Path(app.srcdir) / "_build/text/index.txt").read_text())
647+
assert expected in result
648+
649+
650+
_mod_unresolvable = types.ModuleType("mod_unresolvable")
651+
_mod_unresolvable.__file__ = __file__
652+
exec("type Broken = Missing\n", _mod_unresolvable.__dict__) # ruff:ignore[exec-builtin]
653+
654+
655+
def test_alias_whose_value_never_evaluates_renders_as_its_name() -> None:
656+
"""An alias nothing can evaluate falls back to a reference to its own name (#764)."""
657+
formatted = format_annotation(_mod_unresolvable.Broken, create_autospec(Config))
658+
assert formatted == ":py:type:`~mod_unresolvable.Broken`"
659+
660+
577661
@pytest.mark.sphinx("text", testroot="integration")
578662
def test_eager_annotations(
579663
app: SphinxTestApp,

tests/test_resolver/test_type_hints.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,18 @@
2929
_future_annotations_imported,
3030
_get_type_hint,
3131
_resolve_string_annotations,
32-
_resolve_type_guarded_imports,
3332
_run_guarded_import,
3433
_should_skip_guarded_import_resolution,
3534
get_all_type_hints,
3635
get_descriptor_type_hint,
36+
resolve_type_guarded_imports,
3737
)
3838

3939
STUB_ROOT = Path(__file__).parent.parent / "roots" / "test-pyi-stubs"
4040

4141

4242
def test_no_source_code_type_guard() -> None:
43-
_resolve_type_guarded_imports([], Error)
43+
resolve_type_guarded_imports([], Error)
4444

4545

4646
def test_future_annotations_not_imported() -> None:

0 commit comments

Comments
 (0)