Skip to content

Commit

Permalink
Close #8588: autodoc: autodoc_type_aliases supports dotted name
Browse files Browse the repository at this point in the history
It allows users to define an alias for a class with module name
like `foo.bar.BazClass`.
  • Loading branch information
tk0miya committed May 3, 2021
1 parent b237e78 commit 28ab5f2
Show file tree
Hide file tree
Showing 5 changed files with 147 additions and 26 deletions.
3 changes: 3 additions & 0 deletions CHANGES
Expand Up @@ -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
Expand Down
87 changes: 84 additions & 3 deletions sphinx/util/inspect.py
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
@@ -1,5 +1,6 @@
from __future__ import annotations

import io
from typing import overload

myint = int
Expand All @@ -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
Expand Down
57 changes: 35 additions & 22 deletions tests/test_ext_autodoc_configs.py
Expand Up @@ -792,54 +792,60 @@ 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',
'',
'',
'.. 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',
'',
Expand All @@ -848,55 +854,62 @@ 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',
'',
'',
'.. 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',
'',
Expand All @@ -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'
Expand Down
21 changes: 20 additions & 1 deletion tests/test_util_inspect.py
Expand Up @@ -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():
Expand Down

0 comments on commit 28ab5f2

Please sign in to comment.