Skip to content

Commit 2656f34

Browse files
picnixzAA-Turner
andauthored
Fix rendering of Literal annotations with enum values (#11517)
Co-authored-by: Adam Turner <[email protected]>
1 parent 137b3ad commit 2656f34

File tree

5 files changed

+130
-3
lines changed

5 files changed

+130
-3
lines changed

CHANGES

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ Bugs fixed
7777
Patch by Bénédikt Tran.
7878
* #10930: Highlight all search terms on the search results page.
7979
Patch by Dmitry Shachnev.
80+
* #11473: Type annotations containing :py:data:`~typing.Literal` enumeration
81+
values now render correctly.
82+
Patch by Bénédikt Tran.
8083

8184
Testing
8285
-------

sphinx/util/typing.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@
77
from collections.abc import Sequence
88
from struct import Struct
99
from types import TracebackType
10-
from typing import Any, Callable, ForwardRef, TypeVar, Union
10+
from typing import TYPE_CHECKING, Any, Callable, ForwardRef, TypeVar, Union
1111

1212
from docutils import nodes
1313
from docutils.parsers.rst.states import Inliner
1414

15+
if TYPE_CHECKING:
16+
import enum
17+
1518
try:
1619
from types import UnionType # type: ignore[attr-defined] # python 3.10 or above
1720
except ImportError:
@@ -186,7 +189,14 @@ def restify(cls: type | None, mode: str = 'fully-qualified-except-typing') -> st
186189
args = ', '.join(restify(a, mode) for a in cls.__args__[:-1])
187190
text += fr"\ [[{args}], {restify(cls.__args__[-1], mode)}]"
188191
elif cls.__module__ == 'typing' and getattr(origin, '_name', None) == 'Literal':
189-
text += r"\ [%s]" % ', '.join(repr(a) for a in cls.__args__)
192+
literal_args = []
193+
for a in cls.__args__:
194+
if inspect.isenumattribute(a):
195+
literal_args.append(_format_literal_enum_arg(a, mode=mode))
196+
else:
197+
literal_args.append(repr(a))
198+
text += r"\ [%s]" % ', '.join(literal_args)
199+
del literal_args
190200
elif cls.__args__:
191201
text += r"\ [%s]" % ", ".join(restify(a, mode) for a in cls.__args__)
192202

@@ -338,7 +348,21 @@ def stringify_annotation(
338348
returns = stringify_annotation(annotation_args[-1], mode)
339349
return f'{module_prefix}Callable[[{args}], {returns}]'
340350
elif qualname == 'Literal':
341-
args = ', '.join(repr(a) for a in annotation_args)
351+
from sphinx.util.inspect import isenumattribute # lazy loading
352+
353+
def format_literal_arg(arg):
354+
if isenumattribute(arg):
355+
enumcls = arg.__class__
356+
357+
if mode == 'smart':
358+
# MyEnum.member
359+
return f'{enumcls.__qualname__}.{arg.name}'
360+
361+
# module.MyEnum.member
362+
return f'{enumcls.__module__}.{enumcls.__qualname__}.{arg.name}'
363+
return repr(arg)
364+
365+
args = ', '.join(map(format_literal_arg, annotation_args))
342366
return f'{module_prefix}Literal[{args}]'
343367
elif str(annotation).startswith('typing.Annotated'): # for py39+
344368
return stringify_annotation(annotation_args[0], mode)
@@ -352,6 +376,14 @@ def stringify_annotation(
352376
return module_prefix + qualname
353377

354378

379+
def _format_literal_enum_arg(arg: enum.Enum, /, *, mode: str) -> str:
380+
enum_cls = arg.__class__
381+
if mode == 'smart' or enum_cls.__module__ == 'typing':
382+
return f':py:attr:`~{enum_cls.__module__}.{enum_cls.__qualname__}.{arg.name}`'
383+
else:
384+
return f':py:attr:`{enum_cls.__module__}.{enum_cls.__qualname__}.{arg.name}`'
385+
386+
355387
# deprecated name -> (object to return, canonical path or empty string)
356388
_DEPRECATED_OBJECTS = {
357389
'stringify': (stringify_annotation, 'sphinx.util.typing.stringify_annotation'),
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from __future__ import annotations
2+
3+
from enum import Enum
4+
from typing import Literal, TypeVar
5+
6+
7+
class MyEnum(Enum):
8+
a = 1
9+
10+
11+
T = TypeVar('T', bound=Literal[1234])
12+
"""docstring"""
13+
14+
15+
U = TypeVar('U', bound=Literal[MyEnum.a])
16+
"""docstring"""
17+
18+
19+
def bar(x: Literal[1234]):
20+
"""docstring"""
21+
22+
23+
def foo(x: Literal[MyEnum.a]):
24+
"""docstring"""

tests/test_ext_autodoc.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2467,3 +2467,59 @@ def test_canonical(app):
24672467
' docstring',
24682468
'',
24692469
]
2470+
2471+
2472+
@pytest.mark.sphinx('html', testroot='ext-autodoc')
2473+
def test_literal_render(app):
2474+
def bounded_typevar_rst(name, bound):
2475+
return [
2476+
'',
2477+
f'.. py:class:: {name}',
2478+
' :module: target.literal',
2479+
'',
2480+
' docstring',
2481+
'',
2482+
f' alias of TypeVar({name!r}, bound={bound})',
2483+
'',
2484+
]
2485+
2486+
def function_rst(name, sig):
2487+
return [
2488+
'',
2489+
f'.. py:function:: {name}({sig})',
2490+
' :module: target.literal',
2491+
'',
2492+
' docstring',
2493+
'',
2494+
]
2495+
2496+
# autodoc_typehints_format can take 'short' or 'fully-qualified' values
2497+
# and this will be interpreted as 'smart' or 'fully-qualified-except-typing' by restify()
2498+
# and 'smart' or 'fully-qualified' by stringify_annotation().
2499+
2500+
options = {'members': None, 'exclude-members': 'MyEnum'}
2501+
app.config.autodoc_typehints_format = 'short'
2502+
actual = do_autodoc(app, 'module', 'target.literal', options)
2503+
assert list(actual) == [
2504+
'',
2505+
'.. py:module:: target.literal',
2506+
'',
2507+
*bounded_typevar_rst('T', r'\ :py:obj:`~typing.Literal`\ [1234]'),
2508+
*bounded_typevar_rst('U', r'\ :py:obj:`~typing.Literal`\ [:py:attr:`~target.literal.MyEnum.a`]'),
2509+
*function_rst('bar', 'x: ~typing.Literal[1234]'),
2510+
*function_rst('foo', 'x: ~typing.Literal[MyEnum.a]'),
2511+
]
2512+
2513+
# restify() assumes that 'fully-qualified' is 'fully-qualified-except-typing'
2514+
# because it is more likely that a user wants to suppress 'typing.*'
2515+
app.config.autodoc_typehints_format = 'fully-qualified'
2516+
actual = do_autodoc(app, 'module', 'target.literal', options)
2517+
assert list(actual) == [
2518+
'',
2519+
'.. py:module:: target.literal',
2520+
'',
2521+
*bounded_typevar_rst('T', r'\ :py:obj:`~typing.Literal`\ [1234]'),
2522+
*bounded_typevar_rst('U', r'\ :py:obj:`~typing.Literal`\ [:py:attr:`target.literal.MyEnum.a`]'),
2523+
*function_rst('bar', 'x: typing.Literal[1234]'),
2524+
*function_rst('foo', 'x: typing.Literal[target.literal.MyEnum.a]'),
2525+
]

tests/test_util_typing.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Tests util.typing functions."""
22

33
import sys
4+
from enum import Enum
45
from numbers import Integral
56
from struct import Struct
67
from types import TracebackType
@@ -31,6 +32,10 @@ class MyClass2(MyClass1):
3132
__qualname__ = '<MyClass2>'
3233

3334

35+
class MyEnum(Enum):
36+
a = 1
37+
38+
3439
T = TypeVar('T')
3540
MyInt = NewType('MyInt', int)
3641

@@ -194,6 +199,9 @@ def test_restify_type_Literal():
194199
from typing import Literal # type: ignore[attr-defined]
195200
assert restify(Literal[1, "2", "\r"]) == ":py:obj:`~typing.Literal`\\ [1, '2', '\\r']"
196201

202+
assert restify(Literal[MyEnum.a], 'fully-qualified-except-typing') == ':py:obj:`~typing.Literal`\\ [:py:attr:`tests.test_util_typing.MyEnum.a`]'
203+
assert restify(Literal[MyEnum.a], 'smart') == ':py:obj:`~typing.Literal`\\ [:py:attr:`~tests.test_util_typing.MyEnum.a`]'
204+
197205

198206
def test_restify_pep_585():
199207
assert restify(list[str]) == ":py:class:`list`\\ [:py:class:`str`]" # type: ignore[attr-defined]
@@ -478,6 +486,10 @@ def test_stringify_type_Literal():
478486
assert stringify_annotation(Literal[1, "2", "\r"], "fully-qualified") == "typing.Literal[1, '2', '\\r']"
479487
assert stringify_annotation(Literal[1, "2", "\r"], "smart") == "~typing.Literal[1, '2', '\\r']"
480488

489+
assert stringify_annotation(Literal[MyEnum.a], 'fully-qualified-except-typing') == 'Literal[tests.test_util_typing.MyEnum.a]'
490+
assert stringify_annotation(Literal[MyEnum.a], 'fully-qualified') == 'typing.Literal[tests.test_util_typing.MyEnum.a]'
491+
assert stringify_annotation(Literal[MyEnum.a], 'smart') == '~typing.Literal[MyEnum.a]'
492+
481493

482494
@pytest.mark.skipif(sys.version_info[:2] <= (3, 9), reason='python 3.10+ is required.')
483495
def test_stringify_type_union_operator():

0 commit comments

Comments
 (0)