From 7318d1dfd4ba39d9aa74f718bb02fba2ded9648f Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Sun, 31 Jan 2021 22:05:29 +0900 Subject: [PATCH] autodoc: Support type union operator (PEP-604) (refs: #8775) Upgrade autodoc to support type union operator introduced in PEP-604. It's available only with python 3.10+. --- CHANGES | 1 + sphinx/util/typing.py | 19 ++++++++ tests/roots/test-ext-autodoc/target/pep604.py | 16 +++++++ tests/test_ext_autodoc.py | 44 +++++++++++++++++++ tests/test_util_typing.py | 14 ++++++ 5 files changed, 94 insertions(+) create mode 100644 tests/roots/test-ext-autodoc/target/pep604.py diff --git a/CHANGES b/CHANGES index 9384ef6d373..dd14f37b3b6 100644 --- a/CHANGES +++ b/CHANGES @@ -38,6 +38,7 @@ Features added info-field-list * #8514: autodoc: Default values of overloaded functions are taken from actual implementation if they're ellipsis +* #8775: autodoc: Support type union operator (PEP-604) in Python 3.10 or above * #8619: html: kbd role generates customizable HTML tags for compound keys * #8634: html: Allow to change the order of JS/CSS via ``priority`` parameter for :meth:`Sphinx.add_js_file()` and :meth:`Sphinx.add_css_file()` diff --git a/sphinx/util/typing.py b/sphinx/util/typing.py index e85c40cdf92..5cee1834a91 100644 --- a/sphinx/util/typing.py +++ b/sphinx/util/typing.py @@ -30,6 +30,11 @@ def _evaluate(self, globalns: Dict, localns: Dict) -> Any: ref = _ForwardRef(self.arg) return ref._eval_type(globalns, localns) +if sys.version_info > (3, 10): + from types import Union as types_Union +else: + types_Union = None + if False: # For type annotation from typing import Type # NOQA # for python3.5.1 @@ -100,6 +105,12 @@ def restify(cls: Optional["Type"]) -> str: return ':class:`struct.Struct`' elif inspect.isNewType(cls): return ':class:`%s`' % cls.__name__ + elif types_Union and isinstance(cls, types_Union): + if len(cls.__args__) > 1 and None in cls.__args__: + args = ' | '.join(restify(a) for a in cls.__args__ if a) + return 'Optional[%s]' % args + else: + return ' | '.join(restify(a) for a in cls.__args__) elif cls.__module__ in ('__builtin__', 'builtins'): return ':class:`%s`' % cls.__name__ else: @@ -336,6 +347,8 @@ def _stringify_py37(annotation: Any) -> str: elif hasattr(annotation, '__origin__'): # instantiated generic provided by a user qualname = stringify(annotation.__origin__) + elif types_Union and isinstance(annotation, types_Union): # types.Union (for py3.10+) + qualname = 'types.Union' else: # we weren't able to extract the base type, appending arguments would # only make them appear twice @@ -355,6 +368,12 @@ def _stringify_py37(annotation: Any) -> str: else: args = ', '.join(stringify(a) for a in annotation.__args__) return 'Union[%s]' % args + elif qualname == 'types.Union': + if len(annotation.__args__) > 1 and None in annotation.__args__: + args = ' | '.join(stringify(a) for a in annotation.__args__ if a) + return 'Optional[%s]' % args + else: + return ' | '.join(stringify(a) for a in annotation.__args__) elif qualname == 'Callable': args = ', '.join(stringify(a) for a in annotation.__args__[:-1]) returns = stringify(annotation.__args__[-1]) diff --git a/tests/roots/test-ext-autodoc/target/pep604.py b/tests/roots/test-ext-autodoc/target/pep604.py new file mode 100644 index 00000000000..9b1f94a59fd --- /dev/null +++ b/tests/roots/test-ext-autodoc/target/pep604.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +attr: int | str #: docstring + + +def sum(x: int | str, y: int | str) -> int | str: + """docstring""" + + +class Foo: + """docstring""" + + attr: int | str #: docstring + + def meth(self, x: int | str, y: int | str) -> int | str: + """docstring""" diff --git a/tests/test_ext_autodoc.py b/tests/test_ext_autodoc.py index d555359cfce..b954417038c 100644 --- a/tests/test_ext_autodoc.py +++ b/tests/test_ext_autodoc.py @@ -2237,6 +2237,50 @@ def test_name_mangling(app): ] +@pytest.mark.skipif(sys.version_info < (3, 10), reason='python 3.10+ is required.') +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_type_union_operator(app): + options = {'members': None} + actual = do_autodoc(app, 'module', 'target.pep604', options) + assert list(actual) == [ + '', + '.. py:module:: target.pep604', + '', + '', + '.. py:class:: Foo()', + ' :module: target.pep604', + '', + ' docstring', + '', + '', + ' .. py:attribute:: Foo.attr', + ' :module: target.pep604', + ' :type: int | str', + '', + ' docstring', + '', + '', + ' .. py:method:: Foo.meth(x: int | str, y: int | str) -> int | str', + ' :module: target.pep604', + '', + ' docstring', + '', + '', + '.. py:data:: attr', + ' :module: target.pep604', + ' :type: int | str', + '', + ' docstring', + '', + '', + '.. py:function:: sum(x: int | str, y: int | str) -> int | str', + ' :module: target.pep604', + '', + ' docstring', + '', + ] + + @pytest.mark.skipif(sys.version_info < (3, 6), reason='python 3.6+ is required.') @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_hide_value(app): diff --git a/tests/test_util_typing.py b/tests/test_util_typing.py index 9da5058147f..927db73fdf0 100644 --- a/tests/test_util_typing.py +++ b/tests/test_util_typing.py @@ -117,6 +117,13 @@ def test_restify_type_ForwardRef(): assert restify(ForwardRef("myint")) == ":class:`myint`" +@pytest.mark.skipif(sys.version_info < (3, 10), reason='python 3.10+ is required.') +def test_restify_type_union_operator(): + assert restify(int | None) == "Optional[:class:`int`]" # type: ignore + assert restify(int | str) == ":class:`int` | :class:`str`" # type: ignore + assert restify(int | str | None) == "Optional[:class:`int` | :class:`str`]" # type: ignore + + def test_restify_broken_type_hints(): assert restify(BrokenType) == ':class:`tests.test_util_typing.BrokenType`' @@ -206,5 +213,12 @@ def test_stringify_type_hints_alias(): assert stringify(MyTuple) == "Tuple[str, str]" # type: ignore +@pytest.mark.skipif(sys.version_info < (3, 10), reason='python 3.10+ is required.') +def test_stringify_type_union_operator(): + assert stringify(int | None) == "Optional[int]" # type: ignore + assert stringify(int | str) == "int | str" # type: ignore + assert stringify(int | str | None) == "Optional[int | str]" # type: ignore + + def test_stringify_broken_type_hints(): assert stringify(BrokenType) == 'tests.test_util_typing.BrokenType'