From f9d8229e858d173b3d6b0f6d419d8154daa0194f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 25 Jul 2023 12:50:21 +0200 Subject: [PATCH] Fix rendering of type annotations with ``Literal`` enumeration values --- CHANGES | 3 + sphinx/util/typing.py | 28 +++++++++- .../roots/test-ext-autodoc/target/literal.py | 18 ++++++ tests/test_ext_autodoc.py | 56 +++++++++++++++++++ tests/test_util_typing.py | 14 +++++ 5 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 tests/roots/test-ext-autodoc/target/literal.py diff --git a/CHANGES b/CHANGES index 3a2f997ff47..293123583e4 100644 --- a/CHANGES +++ b/CHANGES @@ -21,6 +21,9 @@ Features added Bugs fixed ---------- +* #11473: Type annotations containing :py:data:`~typing.Literal` enumeration + values now render correctly. Patch by Bénédikt Tran. + Testing ------- diff --git a/sphinx/util/typing.py b/sphinx/util/typing.py index 7af34dbefb2..ca47408d475 100644 --- a/sphinx/util/typing.py +++ b/sphinx/util/typing.py @@ -182,7 +182,17 @@ def restify(cls: type | None, mode: str = 'fully-qualified-except-typing') -> st args = ', '.join(restify(a, mode) for a in cls.__args__[:-1]) text += fr"\ [[{args}], {restify(cls.__args__[-1], mode)}]" elif cls.__module__ == 'typing' and getattr(origin, '_name', None) == 'Literal': - text += r"\ [%s]" % ', '.join(repr(a) for a in cls.__args__) + def format_literal_arg(arg): + if inspect.isenumattribute(arg): + enumcls = arg.__class__ + reftarget = f'{enumcls.__module__}.{enumcls.__name__}.{arg.name}' + + if mode == 'smart' or enumcls.__module__ == 'typing': + reftarget = f'~{reftarget}' + return f':py:attr:`{reftarget}`' + return repr(arg) + + text += r"\ [%s]" % ', '.join(map(format_literal_arg, cls.__args__)) elif cls.__args__: text += r"\ [%s]" % ", ".join(restify(a, mode) for a in cls.__args__) @@ -329,7 +339,21 @@ def stringify_annotation( returns = stringify_annotation(annotation_args[-1], mode) return f'{module_prefix}Callable[[{args}], {returns}]' elif qualname == 'Literal': - args = ', '.join(repr(a) for a in annotation_args) + from sphinx.util.inspect import isenumattribute # lazy loading + + def format_literal_arg(arg): + if isenumattribute(arg): + enumcls = arg.__class__ + + if mode == 'smart': + # MyEnum.member + return f'{enumcls.__qualname__}.{arg.name}' + + # module.MyEnum.member + return f'{enumcls.__module__}.{enumcls.__qualname__}.{arg.name}' + return repr(arg) + + args = ', '.join(map(format_literal_arg, annotation_args)) return f'{module_prefix}Literal[{args}]' elif str(annotation).startswith('typing.Annotated'): # for py39+ return stringify_annotation(annotation_args[0], mode) diff --git a/tests/roots/test-ext-autodoc/target/literal.py b/tests/roots/test-ext-autodoc/target/literal.py new file mode 100644 index 00000000000..c3754b9e72a --- /dev/null +++ b/tests/roots/test-ext-autodoc/target/literal.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from enum import Enum +from typing import Literal, TypeVar + +class MyEnum(Enum): + a = 1 + +T = TypeVar('T', bound=Literal[1234]) +"""docstring""" +U = TypeVar('U', bound=Literal[MyEnum.a]) +"""docstring""" + +def bar(x: Literal[1234]): + """docstring""" + +def foo(x: Literal[MyEnum.a]): + """docstring""" \ No newline at end of file diff --git a/tests/test_ext_autodoc.py b/tests/test_ext_autodoc.py index a281b6d46ac..b0a1ac5c773 100644 --- a/tests/test_ext_autodoc.py +++ b/tests/test_ext_autodoc.py @@ -2469,3 +2469,59 @@ def test_canonical(app): ' docstring', '', ] + + +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_literal_render(app): + def bounded_typevar_rst(name, bound): + return [ + '', + f'.. py:class:: {name}', + ' :module: target.literal', + '', + ' docstring', + '', + f' alias of TypeVar({name!r}, bound={bound})', + '', + ] + + def function_rst(name, sig): + return [ + '', + f'.. py:function:: {name}({sig})', + ' :module: target.literal', + '', + ' docstring', + '', + ] + + # autodoc_typehints_format can take 'short' or 'fully-qualified' values + # and this will be interpreted as 'smart' or 'fully-qualified-except-typing' by restify() + # and 'smart' or 'fully-qualified' by stringify_annotation(). + + options = {'members': None, 'exclude-members': 'MyEnum'} + app.config.autodoc_typehints_format = 'short' + actual = do_autodoc(app, 'module', 'target.literal', options) + assert list(actual) == [ + '', + '.. py:module:: target.literal', + '', + *bounded_typevar_rst('T', r'\ :py:obj:`~typing.Literal`\ [1234]'), + *bounded_typevar_rst('U', r'\ :py:obj:`~typing.Literal`\ [:py:attr:`~target.literal.MyEnum.a`]'), + *function_rst('bar', 'x: ~typing.Literal[1234]'), + *function_rst('foo', 'x: ~typing.Literal[MyEnum.a]'), + ] + + # restify() assumes that 'fully-qualified' is 'fully-qualified-except-typing' + # because it is more likely that a user wants to suppress 'typing.*' + app.config.autodoc_typehints_format = 'fully-qualified' + actual = do_autodoc(app, 'module', 'target.literal', options) + assert list(actual) == [ + '', + '.. py:module:: target.literal', + '', + *bounded_typevar_rst('T', r'\ :py:obj:`~typing.Literal`\ [1234]'), + *bounded_typevar_rst('U', r'\ :py:obj:`~typing.Literal`\ [:py:attr:`target.literal.MyEnum.a`]'), + *function_rst('bar', 'x: typing.Literal[1234]'), + *function_rst('foo', 'x: typing.Literal[target.literal.MyEnum.a]'), + ] diff --git a/tests/test_util_typing.py b/tests/test_util_typing.py index a036cea1459..c73cfadc9ec 100644 --- a/tests/test_util_typing.py +++ b/tests/test_util_typing.py @@ -1,6 +1,7 @@ """Tests util.typing functions.""" import sys +from enum import Enum from numbers import Integral from struct import Struct from types import TracebackType @@ -31,6 +32,10 @@ class MyClass2(MyClass1): __qualname__ = '' +class MyEnum(Enum): + a = 1 + + T = TypeVar('T') MyInt = NewType('MyInt', int) @@ -183,8 +188,12 @@ def test_restify_type_ForwardRef(): def test_restify_type_Literal(): from typing import Literal # type: ignore + assert restify(Literal[1, "2", "\r"]) == ":py:obj:`~typing.Literal`\\ [1, '2', '\\r']" + assert restify(Literal[MyEnum.a], 'fully-qualified-except-typing') == ':py:obj:`~typing.Literal`\\ [:py:attr:`tests.test_util_typing.MyEnum.a`]' + assert restify(Literal[MyEnum.a], 'smart') == ':py:obj:`~typing.Literal`\\ [:py:attr:`~tests.test_util_typing.MyEnum.a`]' + def test_restify_pep_585(): assert restify(list[str]) == ":py:class:`list`\\ [:py:class:`str`]" # type: ignore @@ -430,10 +439,15 @@ def test_stringify_type_hints_alias(): def test_stringify_type_Literal(): from typing import Literal # type: ignore + assert stringify_annotation(Literal[1, "2", "\r"], 'fully-qualified-except-typing') == "Literal[1, '2', '\\r']" assert stringify_annotation(Literal[1, "2", "\r"], "fully-qualified") == "typing.Literal[1, '2', '\\r']" assert stringify_annotation(Literal[1, "2", "\r"], "smart") == "~typing.Literal[1, '2', '\\r']" + assert stringify_annotation(Literal[MyEnum.a], 'fully-qualified-except-typing') == 'Literal[tests.test_util_typing.MyEnum.a]' + assert stringify_annotation(Literal[MyEnum.a], 'fully-qualified') == 'typing.Literal[tests.test_util_typing.MyEnum.a]' + assert stringify_annotation(Literal[MyEnum.a], 'smart') == '~typing.Literal[MyEnum.a]' + @pytest.mark.skipif(sys.version_info[:2] <= (3, 9), reason='python 3.10+ is required.') def test_stringify_type_union_operator():