diff --git a/CHANGES b/CHANGES index 7028c9e6860..30117663e2f 100644 --- a/CHANGES +++ b/CHANGES @@ -22,6 +22,8 @@ Features added Bugs fixed ---------- +* #8588: autodoc: autodoc_type_aliases does not support dotted name + Testing -------- diff --git a/sphinx/util/inspect.py b/sphinx/util/inspect.py index 7c9adb0bfa0..622acc8c0f9 100644 --- a/sphinx/util/inspect.py +++ b/sphinx/util/inspect.py @@ -18,8 +18,10 @@ import typing import warnings from functools import partial, partialmethod +from importlib import import_module from inspect import Parameter, isclass, ismethod, ismethoddescriptor, ismodule # NOQA from io import StringIO +from types import ModuleType from typing import Any, Callable, Dict, Mapping, Optional, Sequence, Tuple, Type, cast from sphinx.deprecation import RemovedInSphinx50Warning @@ -501,6 +503,63 @@ def __repr__(self) -> str: return self.value +class TypeAliasForwardRef: + """Pseudo typing class for autodoc_type_aliases. + + This avoids the error on evaluating the type inside `get_type_hints()`. + """ + def __init__(self, name: str) -> None: + self.name = name + + def __call__(self) -> None: + # Dummy method to imitate special typing classes + pass + + def __eq__(self, other: Any) -> bool: + return self.name == other + + +class TypeAliasNamespace(Dict[str, Any]): + """Pseudo namespace class for autodoc_type_aliases. + + This enables to look up nested modules and classes like `mod1.mod2.Class`. + """ + + def __init__(self, modname: Optional[str], mapping: Dict[str, str]) -> None: + self.__modname = modname + self.__mapping = mapping + + self.__module: Optional[ModuleType] = None + + def __getattr__(self, name: str) -> Any: + return self[name] + + def __getitem__(self, key: str) -> Any: + fullname = '.'.join(filter(None, [self.__modname, key])) + if fullname in self.__mapping: + # exactly matched + return TypeAliasForwardRef(self.__mapping[fullname]) + else: + prefix = fullname + '.' + nested = {k: v for k, v in self.__mapping.items() if k.startswith(prefix)} + if nested: + # sub modules or classes found + return TypeAliasNamespace(fullname, nested) + elif self.__modname: + # no sub modules or classes found. + try: + # return the real submodule if exists + return import_module(fullname) + except ImportError: + # return the real class + if self.__module is None: + self.__module = import_module(self.__modname) + + return getattr(self.__module, key) + else: + raise KeyError + + def _should_unwrap(subject: Callable) -> bool: """Check the function should be unwrapped on getting signature.""" __globals__ = getglobals(subject) @@ -549,12 +608,19 @@ def signature(subject: Callable, bound_method: bool = False, follow_wrapped: boo try: # Resolve annotations using ``get_type_hints()`` and type_aliases. - annotations = typing.get_type_hints(subject, None, type_aliases) + localns = TypeAliasNamespace(None, type_aliases) + annotations = typing.get_type_hints(subject, None, localns) for i, param in enumerate(parameters): if param.name in annotations: - parameters[i] = param.replace(annotation=annotations[param.name]) + annotation = annotations[param.name] + if isinstance(annotation, TypeAliasForwardRef): + annotation = annotation.name + parameters[i] = param.replace(annotation=annotation) if 'return' in annotations: - return_annotation = annotations['return'] + if isinstance(annotations['return'], TypeAliasForwardRef): + return_annotation = annotations['return'].name + else: + return_annotation = annotations['return'] except Exception: # ``get_type_hints()`` does not support some kind of objects like partial, # ForwardRef and so on. diff --git a/tests/roots/test-ext-autodoc/target/annotations.py b/tests/roots/test-ext-autodoc/target/autodoc_type_aliases.py similarity index 88% rename from tests/roots/test-ext-autodoc/target/annotations.py rename to tests/roots/test-ext-autodoc/target/autodoc_type_aliases.py index ef600e2af83..d8a2fecefce 100644 --- a/tests/roots/test-ext-autodoc/target/annotations.py +++ b/tests/roots/test-ext-autodoc/target/autodoc_type_aliases.py @@ -1,5 +1,6 @@ from __future__ import annotations +import io from typing import overload myint = int @@ -11,6 +12,10 @@ variable2 = None # type: myint +def read(r: io.BytesIO) -> io.StringIO: + """docstring""" + + def sum(x: myint, y: myint) -> myint: """docstring""" return x + y diff --git a/tests/test_ext_autodoc_configs.py b/tests/test_ext_autodoc_configs.py index bc8c01fbd97..04d35e3359a 100644 --- a/tests/test_ext_autodoc_configs.py +++ b/tests/test_ext_autodoc_configs.py @@ -792,27 +792,27 @@ def test_autodoc_typehints_description_for_invalid_node(app): def test_autodoc_type_aliases(app): # default options = {"members": None} - actual = do_autodoc(app, 'module', 'target.annotations', options) + actual = do_autodoc(app, 'module', 'target.autodoc_type_aliases', options) assert list(actual) == [ '', - '.. py:module:: target.annotations', + '.. py:module:: target.autodoc_type_aliases', '', '', '.. py:class:: Foo()', - ' :module: target.annotations', + ' :module: target.autodoc_type_aliases', '', ' docstring', '', '', ' .. py:attribute:: Foo.attr1', - ' :module: target.annotations', + ' :module: target.autodoc_type_aliases', ' :type: int', '', ' docstring', '', '', ' .. py:attribute:: Foo.attr2', - ' :module: target.annotations', + ' :module: target.autodoc_type_aliases', ' :type: int', '', ' docstring', @@ -820,26 +820,32 @@ def test_autodoc_type_aliases(app): '', '.. py:function:: mult(x: int, y: int) -> int', ' mult(x: float, y: float) -> float', - ' :module: target.annotations', + ' :module: target.autodoc_type_aliases', + '', + ' docstring', + '', + '', + '.. py:function:: read(r: _io.BytesIO) -> _io.StringIO', + ' :module: target.autodoc_type_aliases', '', ' docstring', '', '', '.. py:function:: sum(x: int, y: int) -> int', - ' :module: target.annotations', + ' :module: target.autodoc_type_aliases', '', ' docstring', '', '', '.. py:data:: variable', - ' :module: target.annotations', + ' :module: target.autodoc_type_aliases', ' :type: int', '', ' docstring', '', '', '.. py:data:: variable2', - ' :module: target.annotations', + ' :module: target.autodoc_type_aliases', ' :type: int', ' :value: None', '', @@ -848,28 +854,29 @@ def test_autodoc_type_aliases(app): ] # define aliases - app.config.autodoc_type_aliases = {'myint': 'myint'} - actual = do_autodoc(app, 'module', 'target.annotations', options) + app.config.autodoc_type_aliases = {'myint': 'myint', + 'io.StringIO': 'my.module.StringIO'} + actual = do_autodoc(app, 'module', 'target.autodoc_type_aliases', options) assert list(actual) == [ '', - '.. py:module:: target.annotations', + '.. py:module:: target.autodoc_type_aliases', '', '', '.. py:class:: Foo()', - ' :module: target.annotations', + ' :module: target.autodoc_type_aliases', '', ' docstring', '', '', ' .. py:attribute:: Foo.attr1', - ' :module: target.annotations', + ' :module: target.autodoc_type_aliases', ' :type: myint', '', ' docstring', '', '', ' .. py:attribute:: Foo.attr2', - ' :module: target.annotations', + ' :module: target.autodoc_type_aliases', ' :type: myint', '', ' docstring', @@ -877,26 +884,32 @@ def test_autodoc_type_aliases(app): '', '.. py:function:: mult(x: myint, y: myint) -> myint', ' mult(x: float, y: float) -> float', - ' :module: target.annotations', + ' :module: target.autodoc_type_aliases', + '', + ' docstring', + '', + '', + '.. py:function:: read(r: _io.BytesIO) -> my.module.StringIO', + ' :module: target.autodoc_type_aliases', '', ' docstring', '', '', '.. py:function:: sum(x: myint, y: myint) -> myint', - ' :module: target.annotations', + ' :module: target.autodoc_type_aliases', '', ' docstring', '', '', '.. py:data:: variable', - ' :module: target.annotations', + ' :module: target.autodoc_type_aliases', ' :type: myint', '', ' docstring', '', '', '.. py:data:: variable2', - ' :module: target.annotations', + ' :module: target.autodoc_type_aliases', ' :type: myint', ' :value: None', '', @@ -911,10 +924,10 @@ def test_autodoc_type_aliases(app): confoverrides={'autodoc_typehints': "description", 'autodoc_type_aliases': {'myint': 'myint'}}) def test_autodoc_typehints_description_and_type_aliases(app): - (app.srcdir / 'annotations.rst').write_text('.. autofunction:: target.annotations.sum') + (app.srcdir / 'autodoc_type_aliases.rst').write_text('.. autofunction:: target.autodoc_type_aliases.sum') app.build() - context = (app.outdir / 'annotations.txt').read_text() - assert ('target.annotations.sum(x, y)\n' + context = (app.outdir / 'autodoc_type_aliases.txt').read_text() + assert ('target.autodoc_type_aliases.sum(x, y)\n' '\n' ' docstring\n' '\n' diff --git a/tests/test_util_inspect.py b/tests/test_util_inspect.py index 7b86c6ade36..b0ac57bd4ee 100644 --- a/tests/test_util_inspect.py +++ b/tests/test_util_inspect.py @@ -19,7 +19,24 @@ import pytest from sphinx.util import inspect -from sphinx.util.inspect import stringify_signature +from sphinx.util.inspect import TypeAliasNamespace, stringify_signature + + +def test_TypeAliasNamespace(): + import logging.config + type_alias = TypeAliasNamespace(None, {'logging.Filter': 'MyFilter', + 'logging.Handler': 'MyHandler'}) + + assert type_alias['logging'].Filter == 'MyFilter' + assert type_alias['logging'].Handler == 'MyHandler' + assert type_alias['logging'].Logger == logging.Logger + assert type_alias['logging'].config == logging.config + + with pytest.raises(KeyError): + assert type_alias['log'] + + with pytest.raises(KeyError): + assert type_alias['unknown'] def test_signature():