From 28ab5f233ce4265deecbe02b70e2c7a9bdf620c2 Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Mon, 3 May 2021 00:51:22 +0900 Subject: [PATCH] Close #8588: autodoc: autodoc_type_aliases supports dotted name It allows users to define an alias for a class with module name like `foo.bar.BazClass`. --- CHANGES | 3 + sphinx/util/inspect.py | 87 ++++++++++++++++++- ...annotations.py => autodoc_type_aliases.py} | 5 ++ tests/test_ext_autodoc_configs.py | 57 +++++++----- tests/test_util_inspect.py | 21 ++++- 5 files changed, 147 insertions(+), 26 deletions(-) rename tests/roots/test-ext-autodoc/target/{annotations.py => autodoc_type_aliases.py} (88%) diff --git a/CHANGES b/CHANGES index 5b7a840dc7c..970eee214f5 100644 --- a/CHANGES +++ b/CHANGES @@ -16,6 +16,9 @@ Features added * #8107: autodoc: Add ``class-doc-from`` option to :rst:dir:`autoclass` directive to control the content of the specific class like :confval:`autoclass_content` +* #8588: autodoc: :confval:`autodoc_type_aliases` now supports dotted name. It + allows you to define an alias for a class with module name like + ``foo.bar.BazClass`` * #9129: html search: Show search summaries when html_copy_source = False * #9120: html theme: Eliminate prompt characters of code-block from copyable text diff --git a/sphinx/util/inspect.py b/sphinx/util/inspect.py index 7c9adb0bfa0..f216e87978b 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,78 @@ 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 TypeAliasModule: + """Pseudo module class for autodoc_type_aliases.""" + + def __init__(self, modname: str, mapping: Dict[str, str]) -> None: + self.__modname = modname + self.__mapping = mapping + + self.__module: Optional[ModuleType] = None + + def __getattr__(self, name: str) -> Any: + fullname = '.'.join(filter(None, [self.__modname, name])) + 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 TypeAliasModule(fullname, nested) + else: + # 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, name) + + +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, mapping: Dict[str, str]) -> None: + self.__mapping = mapping + + def __getitem__(self, key: str) -> Any: + if key in self.__mapping: + # exactly matched + return TypeAliasForwardRef(self.__mapping[key]) + else: + prefix = key + '.' + nested = {k: v for k, v in self.__mapping.items() if k.startswith(prefix)} + if nested: + # sub modules or classes found + return TypeAliasModule(key, nested) + else: + raise KeyError + + def _should_unwrap(subject: Callable) -> bool: """Check the function should be unwrapped on getting signature.""" __globals__ = getglobals(subject) @@ -549,12 +623,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(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..fbf243ba10b 100644 --- a/tests/test_util_inspect.py +++ b/tests/test_util_inspect.py @@ -19,7 +19,26 @@ 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({'logging.Filter': 'MyFilter', + 'logging.Handler': 'MyHandler', + 'logging.handlers.SyslogHandler': 'MySyslogHandler'}) + + assert type_alias['logging'].Filter == 'MyFilter' + assert type_alias['logging'].Handler == 'MyHandler' + assert type_alias['logging'].handlers.SyslogHandler == 'MySyslogHandler' + 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():