Skip to content

Commit ae8dd3b

Browse files
authored
✨ feat(resolver): type C data descriptors from stub files (#716)
A stub-backed C extension gets full signatures on its functions and methods, yet its properties render as bare attributes. The cause sits at the top of `process_docstring`: getset and member descriptors are not `property` instances and are not callable, so the handler returns before any stub lookup runs, even though the type is sitting right there in the `.pyi` as a `@property` return annotation or a class-level annotated assignment. I hit this documenting `turbohtml`'s C `Token` type, where `attr()` rendered with full types while the eleven properties next to it showed nothing. The fix routes these descriptors through the same stub machinery that signatures already use. A new `get_descriptor_type_hint` finds the owning class in the stub via `__objclass__`, takes the `@property` return annotation or the matching `AnnAssign`, and evaluates it in the stub's namespace so type aliases keep their cross-referenced form. The result lands in the docstring as a `:type:` field, which the attribute directive renders the same way `:vartype:` injections do; an explicit `:type:` already present in the docstring wins, and objects without a stub stay untouched. The `c_ext_mod` fixture grows three getset descriptors covering the `@property` return, the alias-typed union, and the class-annotation forms. Behavior for pure-Python properties is unchanged, since those keep flowing through `fget`.
1 parent 6bd0f19 commit ae8dd3b

8 files changed

Lines changed: 173 additions & 2 deletions

File tree

src/sphinx_autodoc_typehints/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
backfill_type_hints,
3333
collect_documented_type_aliases,
3434
get_all_type_hints,
35+
get_descriptor_type_hint,
3536
get_instance_var_annotations,
3637
get_obj_location,
3738
)
@@ -157,6 +158,7 @@ def process_docstring( # noqa: PLR0913, PLR0917
157158
original_obj = obj
158159
obj = obj.fget if isinstance(obj, property) else obj
159160
if not callable(obj):
161+
_maybe_inject_descriptor_type(app, what, obj, lines)
160162
return
161163
if inspect.isclass(obj):
162164
backfill_attrs_annotations(obj)
@@ -207,6 +209,20 @@ def process_docstring( # noqa: PLR0913, PLR0917
207209
del app.config._typehints_module_prefix # noqa: SLF001
208210

209211

212+
def _maybe_inject_descriptor_type(app: Sphinx, what: str, obj: Any, lines: list[str]) -> None:
213+
"""C data descriptors document as plain attributes; lift their type from the stub."""
214+
if what != "attribute" or not (inspect.isgetsetdescriptor(obj) or inspect.ismemberdescriptor(obj)):
215+
return
216+
if any(line.startswith(":type:") for line in lines):
217+
return
218+
if (hint := get_descriptor_type_hint(obj)) is None:
219+
return
220+
formatted = add_type_css_class(
221+
format_annotation(hint, app.config, short_literals=app.config.python_display_short_literal_types)
222+
)
223+
lines.extend(["", f":type: {formatted}"])
224+
225+
210226
def _inject_overload_signatures(
211227
app: Sphinx,
212228
what: str,

src/sphinx_autodoc_typehints/_resolver/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@
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
8+
from ._type_hints import get_all_type_hints, get_descriptor_type_hint
99
from ._util import collect_documented_type_aliases, get_obj_location
1010

1111
__all__ = [
1212
"backfill_attrs_annotations",
1313
"backfill_type_hints",
1414
"collect_documented_type_aliases",
1515
"get_all_type_hints",
16+
"get_descriptor_type_hint",
1617
"get_instance_var_annotations",
1718
"get_obj_location",
1819
]

src/sphinx_autodoc_typehints/_resolver/_stubs.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,40 @@ def _extract_func_annotations(node: ast.FunctionDef | ast.AsyncFunctionDef) -> d
258258
return result
259259

260260

261+
def _backfill_descriptor_annotation(obj: Any) -> str | None:
262+
"""
263+
Return the stub annotation string for a C data descriptor, or ``None``.
264+
265+
getset and member descriptors carry no runtime annotations, so their type
266+
can only come from the owning class's stub: either a ``@property`` return
267+
annotation or a class-level annotated assignment with the same name.
268+
"""
269+
objclass = getattr(obj, "__objclass__", None)
270+
name = getattr(obj, "__name__", None)
271+
if objclass is None or not name:
272+
return None
273+
if (stub_path := _find_stub_path(obj)) is None or (tree := _parse_stub_ast(stub_path)) is None:
274+
return None
275+
node = _find_ast_node(tree.body, objclass.__qualname__.split("."))
276+
if not isinstance(node, ast.ClassDef):
277+
return None
278+
members: list[ast.stmt] = list(node.body)
279+
while members:
280+
member = members.pop()
281+
if isinstance(member, ast.If):
282+
# stubs guard members behind `if sys.version_info >= ...` blocks (numpy's ndarray does)
283+
members.extend(member.body)
284+
members.extend(member.orelse)
285+
continue
286+
if isinstance(member, ast.AnnAssign) and isinstance(member.target, ast.Name) and member.target.id == name:
287+
return ast.unparse(member.annotation)
288+
if not isinstance(member, ast.FunctionDef) or member.name != name or member.returns is None:
289+
continue
290+
if any(isinstance(decorator, ast.Name) and decorator.id == "property" for decorator in member.decorator_list):
291+
return ast.unparse(member.returns)
292+
return None
293+
294+
261295
def _extract_class_annotations(node: ast.ClassDef) -> dict[str, str]:
262296
result: dict[str, str] = {}
263297
for child in node.body:

src/sphinx_autodoc_typehints/_resolver/_type_hints.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
from sphinx_autodoc_typehints._annotations import MyTypeAliasForwardRef
1818

19-
from ._stubs import _backfill_from_stub, _get_stub_context
19+
from ._stubs import _backfill_descriptor_annotation, _backfill_from_stub, _get_stub_context
2020
from ._type_comments import backfill_type_hints
2121
from ._util import get_obj_location
2222

@@ -78,6 +78,25 @@ def _stub_target(cls: type) -> Any:
7878
return cls
7979

8080

81+
def get_descriptor_type_hint(obj: Any) -> Any | None:
82+
"""
83+
Resolve the documented type of a C data descriptor from its stub, or ``None``.
84+
85+
The signature-driven backfill never sees these objects because they are not
86+
callable; the annotation string from the stub is evaluated in the stub's
87+
namespace so aliases and forward references resolve the same way they do
88+
for function signatures.
89+
"""
90+
if (annotation := _backfill_descriptor_annotation(obj)) is None:
91+
return None
92+
localns, alias_names, owner_module = _get_stub_context(obj)
93+
for alias_name in alias_names:
94+
ref = MyTypeAliasForwardRef(alias_name)
95+
ref.crossref = True
96+
localns[alias_name] = ref
97+
return _resolve_string_annotations(obj, {"return": annotation}, localns, owner_module)["return"]
98+
99+
81100
def _resolve_string_annotations(
82101
obj: Any, annotations: dict[str, str], localns: dict[str, Any], owner_module: str = ""
83102
) -> dict[str, Any]:

tests/roots/test-pyi-stubs/c_ext_mod.c

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,26 @@ static PyObject *encoder_new(PyTypeObject *type, PyObject *args, PyObject *kwarg
1919
return type->tp_alloc(type, 0);
2020
}
2121

22+
static PyObject *encoder_get_depth(PyObject *self, void *closure) {
23+
(void)self;
24+
(void)closure;
25+
return PyLong_FromLong(0);
26+
}
27+
28+
static PyObject *encoder_get_hook(PyObject *self, void *closure) {
29+
(void)self;
30+
(void)closure;
31+
Py_RETURN_NONE;
32+
}
33+
34+
static PyGetSetDef encoder_getset[] = {
35+
{"depth", encoder_get_depth, NULL, "current nesting depth", NULL},
36+
{"hook", encoder_get_hook, NULL, "the active encoder hook", NULL},
37+
{"flags", encoder_get_depth, NULL, "encoder behavior flags", NULL},
38+
{"guarded", encoder_get_depth, NULL, "a member guarded by sys.version_info in the stub", NULL},
39+
{NULL, NULL, NULL, NULL, NULL}
40+
};
41+
2242
static PyTypeObject EncoderType = {
2343
PyVarObject_HEAD_INIT(NULL, 0)
2444
.tp_name = "c_ext_mod.Encoder",
@@ -27,6 +47,7 @@ static PyTypeObject EncoderType = {
2747
.tp_itemsize = 0,
2848
.tp_flags = Py_TPFLAGS_DEFAULT,
2949
.tp_new = encoder_new,
50+
.tp_getset = encoder_getset,
3051
};
3152

3253
static int module_exec(PyObject *m) {

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,11 @@ def greet(name: str, greeting: Sequence[str]) -> str: ...
88
def with_hook(callback: GreetHook) -> None: ...
99

1010
class Encoder:
11+
flags: int
1112
def __new__(cls, default: EncoderHook | None = None) -> Self: ...
13+
@property
14+
def depth(self) -> int: ...
15+
@property
16+
def hook(self) -> EncoderHook | None: ...
17+
@property
18+
def guarded(self) -> bool: ...

tests/test_init.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,26 @@ def func(x: int) -> str: ...
8585
assert ":param x: the x" in lines
8686

8787

88+
def test_process_docstring_descriptor_without_stub_is_untouched() -> None:
89+
"""A C data descriptor with no stub keeps its docstring as-is."""
90+
import array # noqa: PLC0415
91+
92+
app = make_docstring_app()
93+
lines = ["the typecode character used to create the array"]
94+
process_docstring(app, "attribute", "array.array.typecode", array.array.typecode, None, lines)
95+
assert lines == ["the typecode character used to create the array"]
96+
97+
98+
def test_process_docstring_descriptor_with_existing_type_field() -> None:
99+
"""An explicit :type: field wins over the stub annotation."""
100+
import array # noqa: PLC0415
101+
102+
app = make_docstring_app()
103+
lines = [":type: str"]
104+
process_docstring(app, "attribute", "array.array.typecode", array.array.typecode, None, lines)
105+
assert lines == [":type: str"]
106+
107+
88108
def test_inject_overload_no_qualname() -> None:
89109
"""Line 198: obj without __qualname__ returns False."""
90110
obj = MagicMock(spec=[])

tests/test_resolver/test_type_hints.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
import annotationlib
1717

1818
import pytest
19+
from conftest import make_docstring_app
1920

21+
from sphinx_autodoc_typehints import process_docstring
2022
from sphinx_autodoc_typehints._annotations import MyTypeAliasForwardRef
2123
from sphinx_autodoc_typehints._resolver._type_hints import (
2224
_TYPE_GUARD_IMPORTS_RESOLVED,
@@ -29,6 +31,7 @@
2931
_run_guarded_import,
3032
_should_skip_guarded_import_resolution,
3133
get_all_type_hints,
34+
get_descriptor_type_hint,
3235
)
3336

3437
STUB_ROOT = Path(__file__).parent.parent / "roots" / "test-pyi-stubs"
@@ -216,6 +219,56 @@ def test_get_all_type_hints_preserves_stub_type_aliases(c_ext_mod: Any) -> None:
216219
assert result["callback"].name == "GreetHook"
217220

218221

222+
def test_descriptor_type_hint_resolves_from_stub(c_ext_mod: Any) -> None:
223+
assert get_descriptor_type_hint(c_ext_mod.Encoder.depth) is int
224+
225+
226+
def test_descriptor_type_hint_preserves_stub_type_aliases(c_ext_mod: Any) -> None:
227+
hint = get_descriptor_type_hint(c_ext_mod.Encoder.hook)
228+
args = get_args(hint)
229+
assert len(args) == 2
230+
encoder_hook = args[0] if isinstance(args[0], MyTypeAliasForwardRef) else args[1]
231+
assert encoder_hook.name == "EncoderHook"
232+
233+
234+
def test_descriptor_type_hint_resolves_class_annotation(c_ext_mod: Any) -> None:
235+
assert get_descriptor_type_hint(c_ext_mod.Encoder.flags) is int
236+
237+
238+
def test_descriptor_type_hint_inside_version_guard(c_ext_mod: Any) -> None:
239+
assert get_descriptor_type_hint(c_ext_mod.Encoder.guarded) is bool
240+
241+
242+
def test_descriptor_type_hint_for_non_class_stub_node(c_ext_mod: Any) -> None:
243+
fake_class = type("greet", (), {"__module__": c_ext_mod.__name__, "__qualname__": "greet"})
244+
descriptor = types.SimpleNamespace(__objclass__=fake_class, __name__="depth")
245+
assert get_descriptor_type_hint(descriptor) is None
246+
247+
248+
def test_descriptor_type_hint_for_name_missing_from_stub(c_ext_mod: Any) -> None:
249+
descriptor = types.SimpleNamespace(__objclass__=c_ext_mod.Encoder, __name__="missing")
250+
assert get_descriptor_type_hint(descriptor) is None
251+
252+
253+
def test_process_docstring_injects_descriptor_type(c_ext_mod: Any) -> None:
254+
app = make_docstring_app()
255+
lines = ["current nesting depth"]
256+
process_docstring(app, "attribute", "c_ext_mod.Encoder.depth", c_ext_mod.Encoder.depth, None, lines)
257+
assert lines[0] == "current nesting depth"
258+
assert lines[-1].startswith(":type: ")
259+
assert "int" in lines[-1]
260+
261+
262+
def test_descriptor_type_hint_without_stub_is_none() -> None:
263+
import array # noqa: PLC0415
264+
265+
assert get_descriptor_type_hint(array.array.typecode) is None
266+
267+
268+
def test_descriptor_type_hint_ignores_non_descriptors() -> None:
269+
assert get_descriptor_type_hint(object()) is None
270+
271+
219272
def test_get_all_type_hints_resolves_c_extension_class_new(c_ext_mod: Any) -> None:
220273
result = get_all_type_hints([], c_ext_mod.Encoder.__new__, "c_ext_mod.Encoder.__new__", {})
221274
default_type = result["default"]

0 commit comments

Comments
 (0)