Skip to content

Commit

Permalink
Merge pull request #9997 from tk0miya/9194_Literal_type_not_hyperlinked
Browse files Browse the repository at this point in the history
Fix #9194: autodoc: types in typing module are not hyperlinked
  • Loading branch information
tk0miya committed Dec 26, 2021
2 parents 31ed71d + f3a098d commit 8ddf3f0
Show file tree
Hide file tree
Showing 11 changed files with 266 additions and 202 deletions.
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

0 comments on commit 8ddf3f0

Please sign in to comment.