Skip to content

Commit 8ddf3f0

Browse files
authored
Merge pull request #9997 from tk0miya/9194_Literal_type_not_hyperlinked
Fix #9194: autodoc: types in typing module are not hyperlinked
2 parents 31ed71d + f3a098d commit 8ddf3f0

11 files changed

+266
-202
lines changed

CHANGES

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ Bugs fixed
4949
with Python 3.10
5050
* #9968: autodoc: instance variables are not shown if __init__ method has
5151
position-only-arguments
52+
* #9194: autodoc: types under the "typing" module are not hyperlinked
5253
* #9947: i18n: topic directive having a bullet list can't be translatable
5354
* #9878: mathjax: MathJax configuration is placed after loading MathJax itself
5455
* #9857: Generated RFC links use outdated base url

sphinx/domains/python.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ class ModuleEntry(NamedTuple):
8383
def type_to_xref(target: str, env: BuildEnvironment = None, suppress_prefix: bool = False
8484
) -> addnodes.pending_xref:
8585
"""Convert a type string to a cross reference node."""
86-
if target == 'None':
86+
if target == 'None' or target.startswith('typing.'):
87+
# typing module provides non-class types. Obj reference is good to refer them.
8788
reftype = 'obj'
8889
else:
8990
reftype = 'class'
@@ -104,6 +105,8 @@ def type_to_xref(target: str, env: BuildEnvironment = None, suppress_prefix: boo
104105
text = target.split('.')[-1]
105106
elif suppress_prefix:
106107
text = target.split('.')[-1]
108+
elif target.startswith('typing.'):
109+
text = target[7:]
107110
else:
108111
text = target
109112

@@ -203,10 +206,16 @@ def unparse(node: ast.AST) -> List[Node]:
203206
return result
204207
else:
205208
if sys.version_info < (3, 8):
206-
if isinstance(node, ast.Ellipsis):
209+
if isinstance(node, ast.Bytes):
210+
return [addnodes.desc_sig_literal_string('', repr(node.s))]
211+
elif isinstance(node, ast.Ellipsis):
207212
return [addnodes.desc_sig_punctuation('', "...")]
208213
elif isinstance(node, ast.NameConstant):
209214
return [nodes.Text(node.value)]
215+
elif isinstance(node, ast.Num):
216+
return [addnodes.desc_sig_literal_string('', repr(node.n))]
217+
elif isinstance(node, ast.Str):
218+
return [addnodes.desc_sig_literal_string('', repr(node.s))]
210219

211220
raise SyntaxError # unsupported syntax
212221

@@ -1481,7 +1490,7 @@ def istyping(s: str) -> bool:
14811490
return None
14821491
elif node.get('reftype') in ('class', 'obj') and node.get('reftarget') == 'None':
14831492
return contnode
1484-
elif node.get('reftype') in ('class', 'exc'):
1493+
elif node.get('reftype') in ('class', 'obj', 'exc'):
14851494
reftarget = node.get('reftarget')
14861495
if inspect.isclass(getattr(builtins, reftarget, None)):
14871496
# built-in class

sphinx/util/inspect.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,11 @@ def stringify_signature(sig: inspect.Signature, show_annotation: bool = True,
753753
:param unqualified_typehints: If enabled, show annotations as unqualified
754754
(ex. io.StringIO -> StringIO)
755755
"""
756+
if unqualified_typehints:
757+
mode = 'smart'
758+
else:
759+
mode = 'fully-qualified'
760+
756761
args = []
757762
last_kind = None
758763
for param in sig.parameters.values():
@@ -775,7 +780,7 @@ def stringify_signature(sig: inspect.Signature, show_annotation: bool = True,
775780

776781
if show_annotation and param.annotation is not param.empty:
777782
arg.write(': ')
778-
arg.write(stringify_annotation(param.annotation, unqualified_typehints))
783+
arg.write(stringify_annotation(param.annotation, mode))
779784
if param.default is not param.empty:
780785
if show_annotation and param.annotation is not param.empty:
781786
arg.write(' = ')
@@ -795,7 +800,7 @@ def stringify_signature(sig: inspect.Signature, show_annotation: bool = True,
795800
show_return_annotation is False):
796801
return '(%s)' % ', '.join(args)
797802
else:
798-
annotation = stringify_annotation(sig.return_annotation, unqualified_typehints)
803+
annotation = stringify_annotation(sig.return_annotation, mode)
799804
return '(%s) -> %s' % (', '.join(args), annotation)
800805

801806

sphinx/util/typing.py

Lines changed: 45 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -299,18 +299,25 @@ def _restify_py36(cls: Optional[Type]) -> str:
299299
return ':py:obj:`%s.%s`' % (cls.__module__, qualname)
300300

301301

302-
def stringify(annotation: Any, smartref: bool = False) -> str:
302+
def stringify(annotation: Any, mode: str = 'fully-qualified-except-typing') -> str:
303303
"""Stringify type annotation object.
304304
305-
:param smartref: If true, add "~" prefix to the result to remove the leading
306-
module and class names from the reference text
305+
:param mode: Specify a method how annotations will be stringified.
306+
307+
'fully-qualified-except-typing'
308+
Show the module name and qualified name of the annotation except
309+
the "typing" module.
310+
'smart'
311+
Show the name of the annotation.
312+
'fully-qualified'
313+
Show the module name and qualified name of the annotation.
307314
"""
308315
from sphinx.util import inspect # lazy loading
309316

310-
if smartref:
311-
prefix = '~'
317+
if mode == 'smart':
318+
modprefix = '~'
312319
else:
313-
prefix = ''
320+
modprefix = ''
314321

315322
if isinstance(annotation, str):
316323
if annotation.startswith("'") and annotation.endswith("'"):
@@ -319,22 +326,23 @@ def stringify(annotation: Any, smartref: bool = False) -> str:
319326
else:
320327
return annotation
321328
elif isinstance(annotation, TypeVar):
322-
if annotation.__module__ == 'typing':
329+
if (annotation.__module__ == 'typing' and
330+
mode in ('fully-qualified-except-typing', 'smart')):
323331
return annotation.__name__
324332
else:
325-
return prefix + '.'.join([annotation.__module__, annotation.__name__])
333+
return modprefix + '.'.join([annotation.__module__, annotation.__name__])
326334
elif inspect.isNewType(annotation):
327335
if sys.version_info > (3, 10):
328336
# newtypes have correct module info since Python 3.10+
329-
return prefix + '%s.%s' % (annotation.__module__, annotation.__name__)
337+
return modprefix + '%s.%s' % (annotation.__module__, annotation.__name__)
330338
else:
331339
return annotation.__name__
332340
elif not annotation:
333341
return repr(annotation)
334342
elif annotation is NoneType:
335343
return 'None'
336344
elif annotation in INVALID_BUILTIN_CLASSES:
337-
return prefix + INVALID_BUILTIN_CLASSES[annotation]
345+
return modprefix + INVALID_BUILTIN_CLASSES[annotation]
338346
elif str(annotation).startswith('typing.Annotated'): # for py310+
339347
pass
340348
elif (getattr(annotation, '__module__', None) == 'builtins' and
@@ -347,12 +355,12 @@ def stringify(annotation: Any, smartref: bool = False) -> str:
347355
return '...'
348356

349357
if sys.version_info >= (3, 7): # py37+
350-
return _stringify_py37(annotation, smartref)
358+
return _stringify_py37(annotation, mode)
351359
else:
352-
return _stringify_py36(annotation, smartref)
360+
return _stringify_py36(annotation, mode)
353361

354362

355-
def _stringify_py37(annotation: Any, smartref: bool = False) -> str:
363+
def _stringify_py37(annotation: Any, mode: str = 'fully-qualified-except-typing') -> str:
356364
"""stringify() for py37+."""
357365
module = getattr(annotation, '__module__', None)
358366
modprefix = ''
@@ -364,19 +372,21 @@ def _stringify_py37(annotation: Any, smartref: bool = False) -> str:
364372
elif getattr(annotation, '__qualname__', None):
365373
qualname = annotation.__qualname__
366374
else:
367-
qualname = stringify(annotation.__origin__) # ex. Union
375+
qualname = stringify(annotation.__origin__).replace('typing.', '') # ex. Union
368376

369-
if smartref:
377+
if mode == 'smart':
370378
modprefix = '~%s.' % module
379+
elif mode == 'fully-qualified':
380+
modprefix = '%s.' % module
371381
elif hasattr(annotation, '__qualname__'):
372-
if smartref:
382+
if mode == 'smart':
373383
modprefix = '~%s.' % module
374384
else:
375385
modprefix = '%s.' % module
376386
qualname = annotation.__qualname__
377387
elif hasattr(annotation, '__origin__'):
378388
# instantiated generic provided by a user
379-
qualname = stringify(annotation.__origin__, smartref)
389+
qualname = stringify(annotation.__origin__, mode)
380390
elif UnionType and isinstance(annotation, UnionType): # types.Union (for py3.10+)
381391
qualname = 'types.Union'
382392
else:
@@ -391,13 +401,13 @@ def _stringify_py37(annotation: Any, smartref: bool = False) -> str:
391401
elif qualname in ('Optional', 'Union'):
392402
if len(annotation.__args__) > 1 and annotation.__args__[-1] is NoneType:
393403
if len(annotation.__args__) > 2:
394-
args = ', '.join(stringify(a, smartref) for a in annotation.__args__[:-1])
404+
args = ', '.join(stringify(a, mode) for a in annotation.__args__[:-1])
395405
return '%sOptional[%sUnion[%s]]' % (modprefix, modprefix, args)
396406
else:
397407
return '%sOptional[%s]' % (modprefix,
398-
stringify(annotation.__args__[0], smartref))
408+
stringify(annotation.__args__[0], mode))
399409
else:
400-
args = ', '.join(stringify(a, smartref) for a in annotation.__args__)
410+
args = ', '.join(stringify(a, mode) for a in annotation.__args__)
401411
return '%sUnion[%s]' % (modprefix, args)
402412
elif qualname == 'types.Union':
403413
if len(annotation.__args__) > 1 and None in annotation.__args__:
@@ -406,25 +416,25 @@ def _stringify_py37(annotation: Any, smartref: bool = False) -> str:
406416
else:
407417
return ' | '.join(stringify(a) for a in annotation.__args__)
408418
elif qualname == 'Callable':
409-
args = ', '.join(stringify(a, smartref) for a in annotation.__args__[:-1])
410-
returns = stringify(annotation.__args__[-1], smartref)
419+
args = ', '.join(stringify(a, mode) for a in annotation.__args__[:-1])
420+
returns = stringify(annotation.__args__[-1], mode)
411421
return '%s%s[[%s], %s]' % (modprefix, qualname, args, returns)
412422
elif qualname == 'Literal':
413423
args = ', '.join(repr(a) for a in annotation.__args__)
414424
return '%s%s[%s]' % (modprefix, qualname, args)
415425
elif str(annotation).startswith('typing.Annotated'): # for py39+
416-
return stringify(annotation.__args__[0], smartref)
426+
return stringify(annotation.__args__[0], mode)
417427
elif all(is_system_TypeVar(a) for a in annotation.__args__):
418428
# Suppress arguments if all system defined TypeVars (ex. Dict[KT, VT])
419429
return modprefix + qualname
420430
else:
421-
args = ', '.join(stringify(a, smartref) for a in annotation.__args__)
431+
args = ', '.join(stringify(a, mode) for a in annotation.__args__)
422432
return '%s%s[%s]' % (modprefix, qualname, args)
423433

424434
return modprefix + qualname
425435

426436

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

443-
if smartref:
453+
if mode == 'smart':
444454
modprefix = '~%s.' % module
455+
elif mode == 'fully-qualified':
456+
modprefix = '%s.' % module
445457
elif hasattr(annotation, '__qualname__'):
446-
if smartref:
458+
if mode == 'smart':
447459
modprefix = '~%s.' % module
448460
else:
449461
modprefix = '%s.' % module
@@ -455,7 +467,7 @@ def _stringify_py36(annotation: Any, smartref: bool = False) -> str:
455467
not hasattr(annotation, '__tuple_params__')): # for Python 3.6
456468
params = annotation.__args__
457469
if params:
458-
param_str = ', '.join(stringify(p, smartref) for p in params)
470+
param_str = ', '.join(stringify(p, mode) for p in params)
459471
return '%s%s[%s]' % (modprefix, qualname, param_str)
460472
else:
461473
return modprefix + qualname
@@ -466,25 +478,25 @@ def _stringify_py36(annotation: Any, smartref: bool = False) -> str:
466478
elif annotation.__origin__ == Generator: # type: ignore
467479
params = annotation.__args__ # type: ignore
468480
else: # typing.Callable
469-
args = ', '.join(stringify(arg, smartref) for arg
481+
args = ', '.join(stringify(arg, mode) for arg
470482
in annotation.__args__[:-1]) # type: ignore
471483
result = stringify(annotation.__args__[-1]) # type: ignore
472484
return '%s%s[[%s], %s]' % (modprefix, qualname, args, result)
473485
if params is not None:
474-
param_str = ', '.join(stringify(p, smartref) for p in params)
486+
param_str = ', '.join(stringify(p, mode) for p in params)
475487
return '%s%s[%s]' % (modprefix, qualname, param_str)
476488
elif (hasattr(annotation, '__origin__') and
477489
annotation.__origin__ is typing.Union):
478490
params = annotation.__args__
479491
if params is not None:
480492
if len(params) > 1 and params[-1] is NoneType:
481493
if len(params) > 2:
482-
param_str = ", ".join(stringify(p, smartref) for p in params[:-1])
494+
param_str = ", ".join(stringify(p, mode) for p in params[:-1])
483495
return '%sOptional[%sUnion[%s]]' % (modprefix, modprefix, param_str)
484496
else:
485-
return '%sOptional[%s]' % (modprefix, stringify(params[0]))
497+
return '%sOptional[%s]' % (modprefix, stringify(params[0], mode))
486498
else:
487-
param_str = ', '.join(stringify(p, smartref) for p in params)
499+
param_str = ', '.join(stringify(p, mode) for p in params)
488500
return '%sUnion[%s]' % (modprefix, param_str)
489501

490502
return modprefix + qualname

tests/test_domain_py.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,17 @@ def test_parse_annotation(app):
348348
assert_node(doctree, ([pending_xref, "None"],))
349349
assert_node(doctree[0], pending_xref, refdomain="py", reftype="obj", reftarget="None")
350350

351+
# Literal type makes an object-reference (not a class reference)
352+
doctree = _parse_annotation("typing.Literal['a', 'b']", app.env)
353+
assert_node(doctree, ([pending_xref, "Literal"],
354+
[desc_sig_punctuation, "["],
355+
[desc_sig_literal_string, "'a'"],
356+
[desc_sig_punctuation, ","],
357+
desc_sig_space,
358+
[desc_sig_literal_string, "'b'"],
359+
[desc_sig_punctuation, "]"]))
360+
assert_node(doctree[0], pending_xref, refdomain="py", reftype="obj", reftarget="typing.Literal")
361+
351362

352363
def test_parse_annotation_suppress(app):
353364
doctree = _parse_annotation("~typing.Dict[str, str]", app.env)
@@ -358,7 +369,7 @@ def test_parse_annotation_suppress(app):
358369
desc_sig_space,
359370
[pending_xref, "str"],
360371
[desc_sig_punctuation, "]"]))
361-
assert_node(doctree[0], pending_xref, refdomain="py", reftype="class", reftarget="typing.Dict")
372+
assert_node(doctree[0], pending_xref, refdomain="py", reftype="obj", reftarget="typing.Dict")
362373

363374

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

375386
doctree = _parse_annotation("typing.Literal[0, 1, 'abc']", app.env)
376-
assert_node(doctree, ([pending_xref, "typing.Literal"],
387+
assert_node(doctree, ([pending_xref, "Literal"],
377388
[desc_sig_punctuation, "["],
378389
[desc_sig_literal_number, "0"],
379390
[desc_sig_punctuation, ","],

tests/test_ext_autodoc_autofunction.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ def test_wrapped_function_contextmanager(app):
162162
actual = do_autodoc(app, 'function', 'target.wrappedfunction.feeling_good')
163163
assert list(actual) == [
164164
'',
165-
'.. py:function:: feeling_good(x: int, y: int) -> Generator',
165+
'.. py:function:: feeling_good(x: int, y: int) -> typing.Generator',
166166
' :module: target.wrappedfunction',
167167
'',
168168
" You'll feel better in this context!",

tests/test_ext_autodoc_automodule.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,4 +130,4 @@ def test_subclass_of_mocked_object(app):
130130

131131
options = {'members': None}
132132
actual = do_autodoc(app, 'module', 'target.need_mocks', options)
133-
assert '.. py:class:: Inherited(*args: Any, **kwargs: Any)' in actual
133+
assert '.. py:class:: Inherited(*args: typing.Any, **kwargs: typing.Any)' in actual

tests/test_ext_autodoc_configs.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -612,7 +612,7 @@ def test_autodoc_typehints_signature(app):
612612
' :type: int',
613613
'',
614614
'',
615-
'.. py:class:: Math(s: str, o: Optional[Any] = None)',
615+
'.. py:class:: Math(s: str, o: typing.Optional[typing.Any] = None)',
616616
' :module: target.typehints',
617617
'',
618618
'',
@@ -677,7 +677,8 @@ def test_autodoc_typehints_signature(app):
677677
' :module: target.typehints',
678678
'',
679679
'',
680-
'.. py:function:: tuple_args(x: Tuple[int, Union[int, str]]) -> Tuple[int, int]',
680+
'.. py:function:: tuple_args(x: typing.Tuple[int, typing.Union[int, str]]) '
681+
'-> typing.Tuple[int, int]',
681682
' :module: target.typehints',
682683
'',
683684
]
@@ -1145,11 +1146,6 @@ def test_autodoc_typehints_description_and_type_aliases(app):
11451146
@pytest.mark.sphinx('html', testroot='ext-autodoc',
11461147
confoverrides={'autodoc_typehints_format': "short"})
11471148
def test_autodoc_typehints_format_short(app):
1148-
if sys.version_info < (3, 7):
1149-
Any = 'Any'
1150-
else:
1151-
Any = '~typing.Any'
1152-
11531149
options = {"members": None,
11541150
"undoc-members": None}
11551151
actual = do_autodoc(app, 'module', 'target.typehints', options)
@@ -1163,7 +1159,7 @@ def test_autodoc_typehints_format_short(app):
11631159
' :type: int',
11641160
'',
11651161
'',
1166-
'.. py:class:: Math(s: str, o: ~typing.Optional[%s] = None)' % Any,
1162+
'.. py:class:: Math(s: str, o: ~typing.Optional[~typing.Any] = None)',
11671163
' :module: target.typehints',
11681164
'',
11691165
'',

tests/test_ext_autodoc_preserve_defaults.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,15 @@ def test_preserve_defaults(app):
3636
' docstring',
3737
'',
3838
'',
39-
' .. py:method:: Class.meth(name: str = CONSTANT, sentinel: Any = SENTINEL, '
40-
'now: datetime.datetime = datetime.now(), color: int = %s) -> None' % color,
39+
' .. py:method:: Class.meth(name: str = CONSTANT, sentinel: typing.Any = '
40+
'SENTINEL, now: datetime.datetime = datetime.now(), color: int = %s) -> None' % color,
4141
' :module: target.preserve_defaults',
4242
'',
4343
' docstring',
4444
'',
4545
'',
46-
'.. py:function:: foo(name: str = CONSTANT, sentinel: Any = SENTINEL, now: '
47-
'datetime.datetime = datetime.now(), color: int = %s) -> None' % color,
46+
'.. py:function:: foo(name: str = CONSTANT, sentinel: typing.Any = SENTINEL, '
47+
'now: datetime.datetime = datetime.now(), color: int = %s) -> None' % color,
4848
' :module: target.preserve_defaults',
4949
'',
5050
' docstring',

0 commit comments

Comments
 (0)