Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix #9194: autodoc: types in typing module are not hyperlinked #9997

Merged
merged 7 commits into from Dec 26, 2021
1 change: 1 addition & 0 deletions CHANGES
Expand Up @@ -49,6 +49,7 @@ Bugs fixed
with Python 3.10
* #9968: autodoc: instance variables are not shown if __init__ method has
position-only-arguments
* #9194: autodoc: types under the "typing" module are not hyperlinked
* #9947: i18n: topic directive having a bullet list can't be translatable
* #9878: mathjax: MathJax configuration is placed after loading MathJax itself
* #9857: Generated RFC links use outdated base url
Expand Down
15 changes: 12 additions & 3 deletions sphinx/domains/python.py
Expand Up @@ -83,7 +83,8 @@ class ModuleEntry(NamedTuple):
def type_to_xref(target: str, env: BuildEnvironment = None, suppress_prefix: bool = False
) -> addnodes.pending_xref:
"""Convert a type string to a cross reference node."""
if target == 'None':
if target == 'None' or target.startswith('typing.'):
# typing module provides non-class types. Obj reference is good to refer them.
reftype = 'obj'
else:
reftype = 'class'
Expand All @@ -104,6 +105,8 @@ def type_to_xref(target: str, env: BuildEnvironment = None, suppress_prefix: boo
text = target.split('.')[-1]
elif suppress_prefix:
text = target.split('.')[-1]
elif target.startswith('typing.'):
text = target[7:]
else:
text = target

Expand Down Expand Up @@ -203,10 +206,16 @@ def unparse(node: ast.AST) -> List[Node]:
return result
else:
if sys.version_info < (3, 8):
if isinstance(node, ast.Ellipsis):
if isinstance(node, ast.Bytes):
return [addnodes.desc_sig_literal_string('', repr(node.s))]
elif isinstance(node, ast.Ellipsis):
return [addnodes.desc_sig_punctuation('', "...")]
elif isinstance(node, ast.NameConstant):
return [nodes.Text(node.value)]
elif isinstance(node, ast.Num):
return [addnodes.desc_sig_literal_string('', repr(node.n))]
elif isinstance(node, ast.Str):
return [addnodes.desc_sig_literal_string('', repr(node.s))]

raise SyntaxError # unsupported syntax

Expand Down Expand Up @@ -1481,7 +1490,7 @@ def istyping(s: str) -> bool:
return None
elif node.get('reftype') in ('class', 'obj') and node.get('reftarget') == 'None':
return contnode
elif node.get('reftype') in ('class', 'exc'):
elif node.get('reftype') in ('class', 'obj', 'exc'):
reftarget = node.get('reftarget')
if inspect.isclass(getattr(builtins, reftarget, None)):
# built-in class
Expand Down
9 changes: 7 additions & 2 deletions sphinx/util/inspect.py
Expand Up @@ -753,6 +753,11 @@ def stringify_signature(sig: inspect.Signature, show_annotation: bool = True,
:param unqualified_typehints: If enabled, show annotations as unqualified
(ex. io.StringIO -> StringIO)
"""
if unqualified_typehints:
mode = 'smart'
else:
mode = 'fully-qualified'

args = []
last_kind = None
for param in sig.parameters.values():
Expand All @@ -775,7 +780,7 @@ def stringify_signature(sig: inspect.Signature, show_annotation: bool = True,

if show_annotation and param.annotation is not param.empty:
arg.write(': ')
arg.write(stringify_annotation(param.annotation, unqualified_typehints))
arg.write(stringify_annotation(param.annotation, mode))
if param.default is not param.empty:
if show_annotation and param.annotation is not param.empty:
arg.write(' = ')
Expand All @@ -795,7 +800,7 @@ def stringify_signature(sig: inspect.Signature, show_annotation: bool = True,
show_return_annotation is False):
return '(%s)' % ', '.join(args)
else:
annotation = stringify_annotation(sig.return_annotation, unqualified_typehints)
annotation = stringify_annotation(sig.return_annotation, mode)
return '(%s) -> %s' % (', '.join(args), annotation)


Expand Down
78 changes: 45 additions & 33 deletions sphinx/util/typing.py
Expand Up @@ -299,18 +299,25 @@ def _restify_py36(cls: Optional[Type]) -> str:
return ':py:obj:`%s.%s`' % (cls.__module__, qualname)


def stringify(annotation: Any, smartref: bool = False) -> str:
def stringify(annotation: Any, mode: str = 'fully-qualified-except-typing') -> str:
"""Stringify type annotation object.

:param smartref: If true, add "~" prefix to the result to remove the leading
module and class names from the reference text
:param mode: Specify a method how annotations will be stringified.

'fully-qualified-except-typing'
Show the module name and qualified name of the annotation except
the "typing" module.
'smart'
Show the name of the annotation.
'fully-qualified'
Show the module name and qualified name of the annotation.
"""
from sphinx.util import inspect # lazy loading

if smartref:
prefix = '~'
if mode == 'smart':
modprefix = '~'
else:
prefix = ''
modprefix = ''

if isinstance(annotation, str):
if annotation.startswith("'") and annotation.endswith("'"):
Expand All @@ -319,22 +326,23 @@ def stringify(annotation: Any, smartref: bool = False) -> str:
else:
return annotation
elif isinstance(annotation, TypeVar):
if annotation.__module__ == 'typing':
if (annotation.__module__ == 'typing' and
mode in ('fully-qualified-except-typing', 'smart')):
return annotation.__name__
else:
return prefix + '.'.join([annotation.__module__, annotation.__name__])
return modprefix + '.'.join([annotation.__module__, annotation.__name__])
elif inspect.isNewType(annotation):
if sys.version_info > (3, 10):
# newtypes have correct module info since Python 3.10+
return prefix + '%s.%s' % (annotation.__module__, annotation.__name__)
return modprefix + '%s.%s' % (annotation.__module__, annotation.__name__)
else:
return annotation.__name__
elif not annotation:
return repr(annotation)
elif annotation is NoneType:
return 'None'
elif annotation in INVALID_BUILTIN_CLASSES:
return prefix + INVALID_BUILTIN_CLASSES[annotation]
return modprefix + INVALID_BUILTIN_CLASSES[annotation]
elif str(annotation).startswith('typing.Annotated'): # for py310+
pass
elif (getattr(annotation, '__module__', None) == 'builtins' and
Expand All @@ -347,12 +355,12 @@ def stringify(annotation: Any, smartref: bool = False) -> str:
return '...'

if sys.version_info >= (3, 7): # py37+
return _stringify_py37(annotation, smartref)
return _stringify_py37(annotation, mode)
else:
return _stringify_py36(annotation, smartref)
return _stringify_py36(annotation, mode)


def _stringify_py37(annotation: Any, smartref: bool = False) -> str:
def _stringify_py37(annotation: Any, mode: str = 'fully-qualified-except-typing') -> str:
"""stringify() for py37+."""
module = getattr(annotation, '__module__', None)
modprefix = ''
Expand All @@ -364,19 +372,21 @@ def _stringify_py37(annotation: Any, smartref: bool = False) -> str:
elif getattr(annotation, '__qualname__', None):
qualname = annotation.__qualname__
else:
qualname = stringify(annotation.__origin__) # ex. Union
qualname = stringify(annotation.__origin__).replace('typing.', '') # ex. Union

if smartref:
if mode == 'smart':
modprefix = '~%s.' % module
elif mode == 'fully-qualified':
modprefix = '%s.' % module
elif hasattr(annotation, '__qualname__'):
if smartref:
if mode == 'smart':
modprefix = '~%s.' % module
else:
modprefix = '%s.' % module
qualname = annotation.__qualname__
elif hasattr(annotation, '__origin__'):
# instantiated generic provided by a user
qualname = stringify(annotation.__origin__, smartref)
qualname = stringify(annotation.__origin__, mode)
elif UnionType and isinstance(annotation, UnionType): # types.Union (for py3.10+)
qualname = 'types.Union'
else:
Expand All @@ -391,13 +401,13 @@ def _stringify_py37(annotation: Any, smartref: bool = False) -> str:
elif qualname in ('Optional', 'Union'):
if len(annotation.__args__) > 1 and annotation.__args__[-1] is NoneType:
if len(annotation.__args__) > 2:
args = ', '.join(stringify(a, smartref) for a in annotation.__args__[:-1])
args = ', '.join(stringify(a, mode) for a in annotation.__args__[:-1])
return '%sOptional[%sUnion[%s]]' % (modprefix, modprefix, args)
else:
return '%sOptional[%s]' % (modprefix,
stringify(annotation.__args__[0], smartref))
stringify(annotation.__args__[0], mode))
else:
args = ', '.join(stringify(a, smartref) for a in annotation.__args__)
args = ', '.join(stringify(a, mode) for a in annotation.__args__)
return '%sUnion[%s]' % (modprefix, args)
elif qualname == 'types.Union':
if len(annotation.__args__) > 1 and None in annotation.__args__:
Expand All @@ -406,25 +416,25 @@ def _stringify_py37(annotation: Any, smartref: bool = False) -> str:
else:
return ' | '.join(stringify(a) for a in annotation.__args__)
elif qualname == 'Callable':
args = ', '.join(stringify(a, smartref) for a in annotation.__args__[:-1])
returns = stringify(annotation.__args__[-1], smartref)
args = ', '.join(stringify(a, mode) for a in annotation.__args__[:-1])
returns = stringify(annotation.__args__[-1], mode)
return '%s%s[[%s], %s]' % (modprefix, qualname, args, returns)
elif qualname == 'Literal':
args = ', '.join(repr(a) for a in annotation.__args__)
return '%s%s[%s]' % (modprefix, qualname, args)
elif str(annotation).startswith('typing.Annotated'): # for py39+
return stringify(annotation.__args__[0], smartref)
return stringify(annotation.__args__[0], mode)
elif all(is_system_TypeVar(a) for a in annotation.__args__):
# Suppress arguments if all system defined TypeVars (ex. Dict[KT, VT])
return modprefix + qualname
else:
args = ', '.join(stringify(a, smartref) for a in annotation.__args__)
args = ', '.join(stringify(a, mode) for a in annotation.__args__)
return '%s%s[%s]' % (modprefix, qualname, args)

return modprefix + qualname


def _stringify_py36(annotation: Any, smartref: bool = False) -> str:
def _stringify_py36(annotation: Any, mode: str = 'fully-qualified-except-typing') -> str:
"""stringify() for py36."""
module = getattr(annotation, '__module__', None)
modprefix = ''
Expand All @@ -440,10 +450,12 @@ def _stringify_py36(annotation: Any, smartref: bool = False) -> str:
else:
qualname = repr(annotation).replace('typing.', '')

if smartref:
if mode == 'smart':
modprefix = '~%s.' % module
elif mode == 'fully-qualified':
modprefix = '%s.' % module
elif hasattr(annotation, '__qualname__'):
if smartref:
if mode == 'smart':
modprefix = '~%s.' % module
else:
modprefix = '%s.' % module
Expand All @@ -455,7 +467,7 @@ def _stringify_py36(annotation: Any, smartref: bool = False) -> str:
not hasattr(annotation, '__tuple_params__')): # for Python 3.6
params = annotation.__args__
if params:
param_str = ', '.join(stringify(p, smartref) for p in params)
param_str = ', '.join(stringify(p, mode) for p in params)
return '%s%s[%s]' % (modprefix, qualname, param_str)
else:
return modprefix + qualname
Expand All @@ -466,25 +478,25 @@ def _stringify_py36(annotation: Any, smartref: bool = False) -> str:
elif annotation.__origin__ == Generator: # type: ignore
params = annotation.__args__ # type: ignore
else: # typing.Callable
args = ', '.join(stringify(arg, smartref) for arg
args = ', '.join(stringify(arg, mode) for arg
in annotation.__args__[:-1]) # type: ignore
result = stringify(annotation.__args__[-1]) # type: ignore
return '%s%s[[%s], %s]' % (modprefix, qualname, args, result)
if params is not None:
param_str = ', '.join(stringify(p, smartref) for p in params)
param_str = ', '.join(stringify(p, mode) for p in params)
return '%s%s[%s]' % (modprefix, qualname, param_str)
elif (hasattr(annotation, '__origin__') and
annotation.__origin__ is typing.Union):
params = annotation.__args__
if params is not None:
if len(params) > 1 and params[-1] is NoneType:
if len(params) > 2:
param_str = ", ".join(stringify(p, smartref) for p in params[:-1])
param_str = ", ".join(stringify(p, mode) for p in params[:-1])
return '%sOptional[%sUnion[%s]]' % (modprefix, modprefix, param_str)
else:
return '%sOptional[%s]' % (modprefix, stringify(params[0]))
return '%sOptional[%s]' % (modprefix, stringify(params[0], mode))
else:
param_str = ', '.join(stringify(p, smartref) for p in params)
param_str = ', '.join(stringify(p, mode) for p in params)
return '%sUnion[%s]' % (modprefix, param_str)

return modprefix + qualname
Expand Down
15 changes: 13 additions & 2 deletions tests/test_domain_py.py
Expand Up @@ -348,6 +348,17 @@ def test_parse_annotation(app):
assert_node(doctree, ([pending_xref, "None"],))
assert_node(doctree[0], pending_xref, refdomain="py", reftype="obj", reftarget="None")

# Literal type makes an object-reference (not a class reference)
doctree = _parse_annotation("typing.Literal['a', 'b']", app.env)
assert_node(doctree, ([pending_xref, "Literal"],
[desc_sig_punctuation, "["],
[desc_sig_literal_string, "'a'"],
[desc_sig_punctuation, ","],
desc_sig_space,
[desc_sig_literal_string, "'b'"],
[desc_sig_punctuation, "]"]))
assert_node(doctree[0], pending_xref, refdomain="py", reftype="obj", reftarget="typing.Literal")


def test_parse_annotation_suppress(app):
doctree = _parse_annotation("~typing.Dict[str, str]", app.env)
Expand All @@ -358,7 +369,7 @@ def test_parse_annotation_suppress(app):
desc_sig_space,
[pending_xref, "str"],
[desc_sig_punctuation, "]"]))
assert_node(doctree[0], pending_xref, refdomain="py", reftype="class", reftarget="typing.Dict")
assert_node(doctree[0], pending_xref, refdomain="py", reftype="obj", reftarget="typing.Dict")


@pytest.mark.skipif(sys.version_info < (3, 8), reason='python 3.8+ is required.')
Expand All @@ -373,7 +384,7 @@ def test_parse_annotation_Literal(app):
[desc_sig_punctuation, "]"]))

doctree = _parse_annotation("typing.Literal[0, 1, 'abc']", app.env)
assert_node(doctree, ([pending_xref, "typing.Literal"],
assert_node(doctree, ([pending_xref, "Literal"],
[desc_sig_punctuation, "["],
[desc_sig_literal_number, "0"],
[desc_sig_punctuation, ","],
Expand Down
2 changes: 1 addition & 1 deletion tests/test_ext_autodoc_autofunction.py
Expand Up @@ -162,7 +162,7 @@ def test_wrapped_function_contextmanager(app):
actual = do_autodoc(app, 'function', 'target.wrappedfunction.feeling_good')
assert list(actual) == [
'',
'.. py:function:: feeling_good(x: int, y: int) -> Generator',
'.. py:function:: feeling_good(x: int, y: int) -> typing.Generator',
' :module: target.wrappedfunction',
'',
" You'll feel better in this context!",
Expand Down
2 changes: 1 addition & 1 deletion tests/test_ext_autodoc_automodule.py
Expand Up @@ -130,4 +130,4 @@ def test_subclass_of_mocked_object(app):

options = {'members': None}
actual = do_autodoc(app, 'module', 'target.need_mocks', options)
assert '.. py:class:: Inherited(*args: Any, **kwargs: Any)' in actual
assert '.. py:class:: Inherited(*args: typing.Any, **kwargs: typing.Any)' in actual
12 changes: 4 additions & 8 deletions tests/test_ext_autodoc_configs.py
Expand Up @@ -612,7 +612,7 @@ def test_autodoc_typehints_signature(app):
' :type: int',
'',
'',
'.. py:class:: Math(s: str, o: Optional[Any] = None)',
'.. py:class:: Math(s: str, o: typing.Optional[typing.Any] = None)',
' :module: target.typehints',
'',
'',
Expand Down Expand Up @@ -677,7 +677,8 @@ def test_autodoc_typehints_signature(app):
' :module: target.typehints',
'',
'',
'.. py:function:: tuple_args(x: Tuple[int, Union[int, str]]) -> Tuple[int, int]',
'.. py:function:: tuple_args(x: typing.Tuple[int, typing.Union[int, str]]) '
'-> typing.Tuple[int, int]',
' :module: target.typehints',
'',
]
Expand Down Expand Up @@ -1145,11 +1146,6 @@ def test_autodoc_typehints_description_and_type_aliases(app):
@pytest.mark.sphinx('html', testroot='ext-autodoc',
confoverrides={'autodoc_typehints_format': "short"})
def test_autodoc_typehints_format_short(app):
if sys.version_info < (3, 7):
Any = 'Any'
else:
Any = '~typing.Any'

options = {"members": None,
"undoc-members": None}
actual = do_autodoc(app, 'module', 'target.typehints', options)
Expand All @@ -1163,7 +1159,7 @@ def test_autodoc_typehints_format_short(app):
' :type: int',
'',
'',
'.. py:class:: Math(s: str, o: ~typing.Optional[%s] = None)' % Any,
'.. py:class:: Math(s: str, o: ~typing.Optional[~typing.Any] = None)',
' :module: target.typehints',
'',
'',
Expand Down
8 changes: 4 additions & 4 deletions tests/test_ext_autodoc_preserve_defaults.py
Expand Up @@ -36,15 +36,15 @@ def test_preserve_defaults(app):
' docstring',
'',
'',
' .. py:method:: Class.meth(name: str = CONSTANT, sentinel: Any = SENTINEL, '
'now: datetime.datetime = datetime.now(), color: int = %s) -> None' % color,
' .. py:method:: Class.meth(name: str = CONSTANT, sentinel: typing.Any = '
'SENTINEL, now: datetime.datetime = datetime.now(), color: int = %s) -> None' % color,
' :module: target.preserve_defaults',
'',
' docstring',
'',
'',
'.. py:function:: foo(name: str = CONSTANT, sentinel: Any = SENTINEL, now: '
'datetime.datetime = datetime.now(), color: int = %s) -> None' % color,
'.. py:function:: foo(name: str = CONSTANT, sentinel: typing.Any = SENTINEL, '
'now: datetime.datetime = datetime.now(), color: int = %s) -> None' % color,
' :module: target.preserve_defaults',
'',
' docstring',
Expand Down