Skip to content

Commit 97e4fc7

Browse files
authored
🐛 fix(resolver): resolve forward refs in nested attrs classes (#646)
Nested attrs classes that reference sibling types via their parent (e.g. `Outer.Foo` inside `Outer.Bar`) fail with `NameError: name 'Outer' is not defined` during Sphinx doc generation. 🐛 This happens because attrs-generated `__init__` methods have `__globals__` pointing to attrs internals rather than the defining module's namespace, so the parent class can't be found for forward reference resolution. The fix walks the full `__qualname__` chain using `inspect.getmodule` to obtain the correct module namespace, resolving each ancestor class and adding it to `localns`. This falls back to `obj.__globals__` for dynamic modules where `inspect.getmodule` returns `None`, preserving compatibility with PEP 695 type parameter resolution. Fixes #643
1 parent 622b562 commit 97e4fc7

5 files changed

Lines changed: 79 additions & 14 deletions

File tree

src/sphinx_autodoc_typehints/_resolver/_type_hints.py

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def _get_type_hint(
6060
autodoc_mock_imports: list[str], name: str, obj: Any, localns: dict[Any, MyTypeAliasForwardRef]
6161
) -> dict[str, Any]:
6262
_resolve_type_guarded_imports(autodoc_mock_imports, obj)
63-
localns = _add_type_params_to_localns(obj, localns)
63+
localns = _build_localns(obj, localns)
6464
try:
6565
result = get_type_hints(obj, None, localns, include_extras=True)
6666
except (AttributeError, TypeError, RecursionError) as exc:
@@ -146,21 +146,26 @@ def _run_guarded_import(autodoc_mock_imports: list[str], obj: Any, guarded_code:
146146
pass
147147

148148

149-
def _add_type_params_to_localns(
150-
obj: Any, localns: dict[Any, MyTypeAliasForwardRef]
151-
) -> dict[Any, MyTypeAliasForwardRef]:
149+
def _build_localns(obj: Any, localns: dict[Any, MyTypeAliasForwardRef]) -> dict[Any, MyTypeAliasForwardRef]:
152150
if type_params := getattr(obj, "__type_params__", None):
153151
localns = {**localns, **{p.__name__: p for p in type_params}}
154-
qualname = getattr(obj, "__qualname__", "") or ""
155-
parts = qualname.rsplit(".", 1)
156-
if len(parts) > 1:
157-
parent_name = parts[0]
158-
ns = getattr(obj, "__globals__", None)
159-
if ns is None:
160-
module = inspect.getmodule(obj)
161-
ns = vars(module) if module else None
162-
if ns and (parent := ns.get(parent_name)) and (parent_params := getattr(parent, "__type_params__", None)):
163-
localns = {**localns, **{p.__name__: p for p in parent_params}}
152+
parts = (getattr(obj, "__qualname__", "") or "").split(".")
153+
if len(parts) <= 1:
154+
return localns
155+
if ns := (vars(module) if (module := inspect.getmodule(obj)) else getattr(obj, "__globals__", None)):
156+
current: Any = None
157+
for part in parts[:-1]:
158+
current = (
159+
(ns if current is None else vars(current)).get(part)
160+
if current is None or hasattr(current, "__dict__")
161+
else None
162+
)
163+
if current is None:
164+
break
165+
if inspect.isclass(current):
166+
localns = {**localns, part: current}
167+
if ancestor_params := getattr(current, "__type_params__", None):
168+
localns = {**localns, **{p.__name__: p for p in ancestor_params}}
164169
return localns
165170

166171

tests/roots/test-attrs/attrs_mod.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,16 @@ class ModernAttrs:
2727

2828
name: str
2929
age: int
30+
31+
32+
class Outer:
33+
"""A class with nested attrs classes to test forward reference resolution."""
34+
35+
class Foo:
36+
"""A nested class referenced by Bar."""
37+
38+
@attrs.define
39+
class Bar:
40+
"""An attrs class referencing a sibling nested class."""
41+
42+
foo: Outer.Foo
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from __future__ import annotations
2+
3+
4+
class Outer:
5+
class Inner:
6+
def __init__(self) -> None: ...

tests/test_resolver/test_attrs.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,24 @@ class NoAnnotations:
8989
assert NoAnnotations.__annotations__["x"] is int
9090

9191

92+
@pytest.mark.sphinx("text", testroot="attrs")
93+
def test_sphinx_build_nested_attrs_forward_ref(app: SphinxTestApp, status: StringIO, warning: StringIO) -> None:
94+
template = """\
95+
.. autoclass:: attrs_mod.Outer
96+
:members:
97+
:undoc-members:
98+
99+
.. autoclass:: attrs_mod.Outer.Bar
100+
:members:
101+
:undoc-members:
102+
"""
103+
(Path(app.srcdir) / "index.rst").write_text(template)
104+
app.build()
105+
assert "build succeeded" in status.getvalue()
106+
assert "Foo" in normalize_sphinx_text((Path(app.srcdir) / "_build/text/index.txt").read_text())
107+
assert "forward reference" not in warning.getvalue()
108+
109+
92110
@pytest.mark.sphinx("text", testroot="attrs")
93111
def test_sphinx_build_attrs_types(app: SphinxTestApp, status: StringIO, warning: StringIO) -> None:
94112
template = """\

tests/test_resolver/test_type_hints.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from unittest.mock import MagicMock, patch
77

88
from sphinx_autodoc_typehints._resolver._type_hints import (
9+
_build_localns,
910
_execute_guarded_code,
1011
_future_annotations_imported,
1112
_get_type_hint,
@@ -87,3 +88,25 @@ def test_guarded_import_warning_includes_module() -> None:
8788
mock_logger.warning.assert_called_once()
8889
args = mock_logger.warning.call_args
8990
assert "fake_mod" in str(args)
91+
92+
93+
def test_build_localns_adds_ancestor_classes() -> None:
94+
import tests.roots.test_nested_attrs_localns as mod # noqa: PLC0415
95+
96+
assert _build_localns(mod.Outer.Inner.__init__, {})["Outer"] is mod.Outer
97+
98+
99+
def test_build_localns_no_qualname() -> None:
100+
def func() -> None: ...
101+
102+
func.__qualname__ = "func"
103+
localns: dict[Any, Any] = {"existing": 1}
104+
assert _build_localns(func, localns) == {"existing": 1}
105+
106+
107+
def test_build_localns_preserves_existing_localns() -> None:
108+
import tests.roots.test_nested_attrs_localns as mod # noqa: PLC0415
109+
110+
localns: dict[Any, Any] = {"key": "value"}
111+
assert (result := _build_localns(mod.Outer.Inner.__init__, localns))["key"] == "value"
112+
assert result["Outer"] is mod.Outer

0 commit comments

Comments
 (0)