Skip to content

Commit eed675c

Browse files
authored
πŸ› fix(stubs): resolve stub type hints for C/Rust extensions (#655)
C/Rust extension functions re-exported from parent packages β€” like `cbor2._cbor2.dumps` exposed as `cbor2.dumps` β€” had no working stub resolution. Sphinx's built-in `_find_type_stub_spec()` only looks for `.pyi` files adjacent to the `.so` with the same filename, so stubs at the parent package level (e.g. `cbor2/__init__.pyi`) were never found. This meant type aliases like `EncoderHook` were expanded to raw `Callable[...]` signatures, annotations on C extension functions were lost entirely, and cross-references were missing from rendered docs. Additionally, C extension classes that define constructors via `__new__` (with `__init__` inherited from `object`) had no constructor parameter documentation because `process_docstring` only examined `__init__`. πŸ› The fix introduces a parent-package walk-up that follows the PEP 561 re-export pattern: when no stub is found next to the extension module, it traverses `sys.modules` upward to find the parent whose `__init__.pyi` documents the re-exported public API. A unified `_get_stub_context()` call retrieves the stub's local namespace, `TypeAlias` names (detected via AST including `typing.TypeAlias`, `typing_extensions.TypeAlias`, and `type` statements), and the owning module name in a single lookup β€” avoiding duplicate path discovery. The owning module name is then used as `globalns` during `eval()`-based annotation resolution, ensuring the correct namespace is used when the function's `__module__` points at the C extension child rather than the stub-owning parent. ✨ A `crossref` flag on `MyTypeAliasForwardRef` distinguishes stub-derived aliases (which emit `:py:type:` roles for Sphinx cross-referencing) from `autodoc_type_aliases` display names (which must not be wrapped to avoid double-quoting in text output). The `collect_documented_type_aliases` function no longer matches unqualified type names from other modules, eliminating a cross-contamination risk in multi-module projects. Constructor logic in `process_docstring` now falls back to `__new__` when `__init__` is `object.__init__` and `__new__` is overridden, and uses `inspect.signature(cls)` instead of `inspect.signature(cls.__new__)` to get the correct parameter list for C extensions where the latter returns `(*args, **kwargs)`. When injecting annotation-based types, preexisting `:type:` directives in the docstring (including multi-line continuations) are now removed and replaced rather than duplicated. Inline `:param type name:` formats are stripped to `:param name:` before injection, ensuring all parameter types use consistent CSS styling via the `sphinx_autodoc_typehints-type` class. Preexisting `:rtype:` directives are also replaced with the annotation-derived return type, fixing a bug where stale docstring return types would shadow the actual function signature.
1 parent 4317689 commit eed675c

19 files changed

+780
-62
lines changed

β€Ž.pre-commit-config.yamlβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ repos:
1515
- id: codespell
1616
additional_dependencies: ["tomli>=2.4"]
1717
- repo: https://github.com/tox-dev/tox-toml-fmt
18-
rev: "v1.8.0"
18+
rev: "v1.9.0"
1919
hooks:
2020
- id: tox-toml-fmt
2121
- repo: https://github.com/tox-dev/pyproject-fmt

β€Žsrc/sphinx_autodoc_typehints/__init__.pyβ€Ž

Lines changed: 54 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
)
2424
from ._formats import detect_format
2525
from ._formats._numpydoc import _convert_numpydoc_to_sphinx_fields # noqa: F401
26-
from ._formats._sphinx import _has_yields_section, _is_generator_type
26+
from ._formats._sphinx import _has_yields_section, _is_generator_type, _strip_inline_param_type
2727
from ._intersphinx import build_type_mapping
2828
from ._parser import parse
2929
from ._resolver import (
@@ -151,14 +151,22 @@ def process_docstring( # noqa: PLR0913, PLR0917
151151
return
152152
if inspect.isclass(obj):
153153
backfill_attrs_annotations(obj)
154-
obj = obj.__init__ if inspect.isclass(obj) else obj
154+
use_class_for_signature = False
155+
if inspect.isclass(obj):
156+
if obj.__init__ is object.__init__ and obj.__new__ is not object.__new__:
157+
use_class_for_signature = True
158+
obj = obj.__new__
159+
else:
160+
obj = obj.__init__
155161
try:
156162
obj = inspect.unwrap(obj)
157163
except ValueError:
158164
return
159165

160166
try:
161-
signature = sphinx_signature(obj, type_aliases=app.config["autodoc_type_aliases"])
167+
signature = sphinx_signature(
168+
original_obj if use_class_for_signature else obj, type_aliases=app.config["autodoc_type_aliases"]
169+
)
162170
except (ValueError, TypeError):
163171
signature = None
164172

@@ -305,6 +313,13 @@ def _inject_arg_signature( # noqa: PLR0913, PLR0917
305313

306314
insert_index = fmt.find_param(lines, arg_name)
307315

316+
if (
317+
insert_index is not None
318+
and annotation is not None
319+
and (stripped := _strip_inline_param_type(lines[insert_index], arg_name))
320+
):
321+
lines[insert_index] = stripped
322+
308323
if insert_index is not None and hasattr(fmt, "get_arg_name_from_line"):
309324
arg_name = fmt.get_arg_name_from_line(lines[insert_index]) or arg_name
310325

@@ -314,27 +329,44 @@ def _inject_arg_signature( # noqa: PLR0913, PLR0917
314329
elif annotation is not None and insert_index is None and app.config.always_document_param_types:
315330
insert_index = fmt.add_undocumented_param(lines, arg_name)
316331

317-
if insert_index is not None:
318-
has_preexisting_annotation = False
332+
if insert_index is None:
333+
return
334+
335+
has_preexisting = False
336+
if annotation is None:
337+
type_annotation, has_preexisting = fmt.find_preexisting_type(lines, arg_name)
338+
else:
339+
preexisting_line, has_preexisting = fmt.find_preexisting_type(lines, arg_name)
340+
if has_preexisting:
341+
insert_index = _remove_preexisting_type(lines, preexisting_line)
342+
type_annotation = ":type {}: {}".format(
343+
arg_name,
344+
add_type_css_class(
345+
format_annotation(annotation, app.config, short_literals=app.config.python_display_short_literal_types)
346+
),
347+
)
348+
349+
if app.config.typehints_defaults and (
350+
formatted_default := format_default(app, default, annotation is not None or has_preexisting)
351+
):
352+
type_annotation = fmt.append_default(
353+
lines,
354+
insert_index,
355+
type_annotation,
356+
formatted_default,
357+
after=app.config.typehints_defaults.endswith("after"),
358+
)
359+
360+
lines.insert(insert_index, type_annotation)
319361

320-
if annotation is None:
321-
type_annotation, has_preexisting_annotation = fmt.find_preexisting_type(lines, arg_name)
322-
else:
323-
short_literals = app.config.python_display_short_literal_types
324-
formatted_annotation = add_type_css_class(
325-
format_annotation(annotation, app.config, short_literals=short_literals)
326-
)
327-
type_annotation = f":type {arg_name}: {formatted_annotation}"
328-
329-
if app.config.typehints_defaults:
330-
formatted_default = format_default(app, default, annotation is not None or has_preexisting_annotation)
331-
if formatted_default:
332-
after = app.config.typehints_defaults.endswith("after")
333-
type_annotation = fmt.append_default(
334-
lines, insert_index, type_annotation, formatted_default, after=after
335-
)
336362

337-
lines.insert(insert_index, type_annotation)
363+
def _remove_preexisting_type(lines: list[str], preexisting_line: str) -> int:
364+
idx = lines.index(preexisting_line)
365+
end = idx + 1
366+
while end < len(lines) and (not lines[end] or lines[end][0].isspace()):
367+
end += 1
368+
del lines[idx:end]
369+
return idx
338370

339371

340372
def _inject_rtype( # noqa: PLR0913, PLR0917

β€Žsrc/sphinx_autodoc_typehints/_annotations.pyβ€Ž

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@
6565

6666

6767
class MyTypeAliasForwardRef(TypeAliasForwardRef):
68+
crossref: bool = False
69+
6870
def __or__(self, value: Any) -> Any: # ty: ignore[invalid-method-override]
6971
return Union[self, value] # noqa: UP007
7072

@@ -88,14 +90,16 @@ def format_annotation(annotation: Any, config: Config, *, short_literals: bool =
8890
return _format_internal_tuple(annotation, config)
8991

9092
if isinstance(annotation, TypeAliasForwardRef):
93+
fully_qualified: bool = getattr(config, "typehints_fully_qualified", False)
94+
prefix = "" if fully_qualified else "~"
9195
if (env := getattr(config, "_typehints_env", None)) is not None:
9296
py_domain = env.get_domain("py")
9397
module_prefix = getattr(config, "_typehints_module_prefix", "")
9498
for candidate in (f"{module_prefix}.{annotation.name}", annotation.name):
9599
if candidate in py_domain.objects and py_domain.objects[candidate].objtype == "type":
96-
fully_qualified: bool = getattr(config, "typehints_fully_qualified", False)
97-
prefix = "" if fully_qualified else "~"
98100
return f":py:type:`{prefix}{candidate}`"
101+
if isinstance(annotation, MyTypeAliasForwardRef) and annotation.crossref:
102+
return f":py:type:`{prefix}{annotation.name}`"
99103
return annotation.name
100104

101105
try:

β€Žsrc/sphinx_autodoc_typehints/_formats/_sphinx.pyβ€Ž

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,23 @@ def _line_is_param_line_for_arg(line: str, arg_name: str) -> bool:
6767
if keyword not in {"param", "parameter", "arg", "argument"}:
6868
return False
6969

70-
return any(doc_name == prefix + arg_name for prefix in ("", "\\*", "\\**", "\\*\\*"))
70+
if any(doc_name == prefix + arg_name for prefix in ("", "\\*", "\\**", "\\*\\*")):
71+
return True
72+
# Match `:param type name:` format where inline type precedes the argument name.
73+
return any(doc_name.endswith(f" {prefix}{arg_name}") for prefix in ("", "\\*", "\\**", "\\*\\*"))
74+
75+
76+
def _strip_inline_param_type(line: str, arg_name: str) -> str | None:
77+
parts = line.split(":", maxsplit=2)
78+
if len(parts) != 3: # noqa: PLR2004
79+
return None
80+
directive_and_name = parts[1]
81+
for prefix in ("", "\\*", "\\**", "\\*\\*"):
82+
suffix = f" {prefix}{arg_name}"
83+
if directive_and_name.endswith(suffix):
84+
keyword = directive_and_name.split(maxsplit=1)[0]
85+
return f":{keyword} {prefix}{arg_name}:{parts[2]}"
86+
return None
7187

7288

7389
def _is_generator_type(annotation: Any) -> bool:
@@ -150,8 +166,9 @@ def find_preexisting_type(self, lines: list[str], arg_name: str) -> tuple[str, b
150166
return type_annotation, False
151167

152168
def get_rtype_insert_info(self, app: Sphinx, lines: list[str]) -> InsertIndexInfo | None: # noqa: PLR6301
153-
if any(line.startswith(":rtype:") for line in lines):
154-
return None
169+
if (at := next((i for i, line in enumerate(lines) if line.startswith(":rtype:")), None)) is not None:
170+
del lines[at]
171+
return InsertIndexInfo(insert_index=at, found_return=True)
155172

156173
for at, line in enumerate(lines):
157174
if line.startswith((":return:", ":returns:")):

β€Žsrc/sphinx_autodoc_typehints/_resolver/_stubs.pyβ€Ž

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@
66
import contextlib
77
import importlib
88
import inspect
9+
import sys
910
from pathlib import Path
10-
from typing import Any
11+
from typing import TYPE_CHECKING, Any
1112

1213
from ._type_comments import _load_args
1314

15+
if TYPE_CHECKING:
16+
import types
17+
1418
_STUB_AST_CACHE: dict[Path, ast.Module | None] = {}
1519

1620

@@ -20,10 +24,22 @@ def _backfill_from_stub(obj: Any) -> dict[str, str]:
2024
return {}
2125

2226

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+
def _get_stub_context(obj: Any) -> tuple[dict[str, Any], set[str], str]:
28+
"""
29+
Return (localns, alias_names, owner_module_name) from the stub owning *obj*.
30+
31+
Single lookup avoids duplicate ``_find_stub_owner`` / ``_parse_stub_ast`` calls. The owner module name lets callers
32+
use ``vars(sys.modules[name])`` as the correct globalns β€” important when a C extension function lives in a child
33+
module but its stub belongs to a parent package (e.g. ``cbor2._cbor2.dumps`` β†’ ``cbor2/__init__.pyi``).
34+
"""
35+
if (info := _find_stub_owner(obj)) is None:
36+
return {}, set(), ""
37+
stub_path, owner_module = info
38+
if (tree := _parse_stub_ast(stub_path)) is None:
39+
return {}, set(), ""
40+
ns: dict[str, Any] = dict(vars(owner_module))
41+
ns.update(_resolve_stub_imports(tree))
42+
return ns, _extract_type_alias_names(tree), owner_module.__name__
2743

2844

2945
def _resolve_stub_imports(tree: ast.Module) -> dict[str, Any]:
@@ -51,9 +67,43 @@ def _resolve_stub_imports(tree: ast.Module) -> dict[str, Any]:
5167
return ns
5268

5369

70+
def _get_module(obj: Any) -> types.ModuleType | None:
71+
if module := inspect.getmodule(obj):
72+
return module
73+
# inspect.getmodule returns None for some C/Rust extension descriptors even when __module__ is valid.
74+
if (mod_name := getattr(obj, "__module__", None)) and (module := sys.modules.get(mod_name)):
75+
return module
76+
# Method descriptors on C extension types expose the owning class via __objclass__.
77+
if (owner_cls := getattr(obj, "__objclass__", None)) and (module := inspect.getmodule(owner_cls)):
78+
return module
79+
# Bound methods like __new__ on C extension classes have __self__ pointing to the class.
80+
if (owner_cls := getattr(obj, "__self__", None)) and inspect.isclass(owner_cls):
81+
return inspect.getmodule(owner_cls)
82+
return None
83+
84+
5485
def _find_stub_path(obj: Any) -> Path | None:
55-
if (module := inspect.getmodule(obj)) is None:
86+
if info := _find_stub_owner(obj):
87+
return info[0]
88+
return None
89+
90+
91+
def _find_stub_owner(obj: Any) -> tuple[Path, types.ModuleType] | None:
92+
if (module := _get_module(obj)) is None:
5693
return None
94+
if result := _find_stub_for_module(module):
95+
return result, module
96+
# PEP 561 re-export pattern: cbor2.__init__.pyi documents functions living in cbor2._cbor2 (C/Rust). Walk up to
97+
# find the parent package whose stub describes the re-exported public API.
98+
module_name = module.__name__
99+
while "." in module_name:
100+
module_name = module_name.rsplit(".", 1)[0]
101+
if (parent := sys.modules.get(module_name)) and (result := _find_stub_for_module(parent)):
102+
return result, parent
103+
return None
104+
105+
106+
def _find_stub_for_module(module: types.ModuleType) -> Path | None:
57107
try:
58108
source_file = inspect.getfile(module)
59109
except TypeError:
@@ -77,6 +127,24 @@ def _parse_stub_ast(stub_path: Path) -> ast.Module | None:
77127
return _STUB_AST_CACHE[stub_path]
78128

79129

130+
_TYPE_ALIAS_ANNOTATIONS = {"TypeAlias", "typing.TypeAlias", "typing_extensions.TypeAlias"}
131+
132+
133+
def _extract_type_alias_names(tree: ast.Module) -> set[str]:
134+
names: set[str] = set()
135+
for node in tree.body:
136+
if (
137+
isinstance(node, ast.AnnAssign)
138+
and isinstance(node.target, ast.Name)
139+
and isinstance(node.annotation, ast.Name | ast.Attribute)
140+
and ast.unparse(node.annotation) in _TYPE_ALIAS_ANNOTATIONS
141+
):
142+
names.add(node.target.id)
143+
elif isinstance(node, ast.TypeAlias) and isinstance(node.name, ast.Name):
144+
names.add(node.name.id)
145+
return names
146+
147+
80148
def _extract_annotations_from_stub(tree: ast.Module, obj: Any) -> dict[str, str]:
81149
qualname = getattr(obj, "__qualname__", None)
82150
if not qualname:

β€Žsrc/sphinx_autodoc_typehints/_resolver/_type_hints.pyβ€Ž

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,17 @@
88
import sys
99
import textwrap
1010
import types
11-
from typing import TYPE_CHECKING, Any, get_type_hints
11+
from typing import Any, get_type_hints
1212

1313
from sphinx.ext.autodoc.mock import mock
1414
from sphinx.util import logging
1515

16-
from ._stubs import _backfill_from_stub, _get_stub_localns
16+
from sphinx_autodoc_typehints._annotations import MyTypeAliasForwardRef
17+
18+
from ._stubs import _backfill_from_stub, _get_stub_context
1719
from ._type_comments import backfill_type_hints
1820
from ._util import get_obj_location
1921

20-
if TYPE_CHECKING:
21-
from sphinx_autodoc_typehints._annotations import MyTypeAliasForwardRef
22-
2322
if sys.version_info >= (3, 14): # pragma: >=3.14 cover
2423
import annotationlib
2524

@@ -46,19 +45,51 @@ def get_all_type_hints(
4645
if not result:
4746
result = backfill_type_hints(obj, name)
4847
stub_localns: dict[str, Any] = {}
48+
stub_alias_names: set[str] = set()
49+
stub_owner_module: str = ""
4950
if not result:
5051
result = _backfill_from_stub(obj)
5152
if result:
52-
stub_localns = _get_stub_localns(obj)
53+
stub_localns, stub_alias_names, stub_owner_module = _get_stub_context(obj)
54+
combined_localns = {**stub_localns, **localns}
55+
for alias_name in stub_alias_names:
56+
ref = MyTypeAliasForwardRef(alias_name)
57+
ref.crossref = True
58+
combined_localns[alias_name] = ref
5359
try:
5460
obj.__annotations__ = result
5561
except (AttributeError, TypeError):
56-
pass
62+
result = _resolve_string_annotations(obj, result, combined_localns, stub_owner_module)
5763
else:
58-
result = _get_type_hint(autodoc_mock_imports, name, obj, {**localns, **stub_localns})
64+
result = _get_type_hint(autodoc_mock_imports, name, obj, combined_localns)
5965
return result
6066

6167

68+
def _resolve_string_annotations(
69+
obj: Any, annotations: dict[str, str], localns: dict[str, Any], owner_module: str = ""
70+
) -> dict[str, Any]:
71+
# Use the stub owner module's namespace when available β€” the obj's __module__ may point at a C extension child
72+
# (e.g. cbor2._cbor2) while the stub lives in the parent (cbor2/__init__.pyi).
73+
module_name = owner_module or getattr(obj, "__module__", None)
74+
globalns = vars(sys.modules[module_name]) if module_name and module_name in sys.modules else {}
75+
resolved: dict[str, Any] = {}
76+
for key, value in annotations.items():
77+
if isinstance(value, str):
78+
try:
79+
resolved[key] = eval(value, globalns, localns) # noqa: S307
80+
except Exception: # noqa: BLE001
81+
_LOGGER.debug(
82+
"Failed to resolve annotation %r=%r for %s",
83+
key,
84+
value,
85+
getattr(obj, "__qualname__", "?"),
86+
)
87+
resolved[key] = value
88+
else:
89+
resolved[key] = value
90+
return resolved
91+
92+
6293
def _get_type_hint(
6394
autodoc_mock_imports: list[str], name: str, obj: Any, localns: dict[Any, MyTypeAliasForwardRef]
6495
) -> dict[str, Any]:

0 commit comments

Comments
Β (0)