From 3209275a4ab0dee8cc6e8fecb9fdf127b95f9ec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Wed, 3 Jul 2024 04:03:41 +0200 Subject: [PATCH] Support ``typing_extensions.Unpack`` (#12258) Co-authored-by: Adam Turner <9087854+aa-turner@users.noreply.github.com> --- pyproject.toml | 1 + sphinx/util/typing.py | 35 ++++++++++++++++---- tests/test_util/test_util_typing.py | 50 +++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 32d3e1dfd..0d6e950e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,6 +97,7 @@ test = [ "defusedxml>=0.7.1", # for secure XML/HTML parsing "cython>=3.0", "setuptools>=67.0", # for Cython compilation + "typing_extensions", # for typing_extensions.Unpack ] [[project.authors]] diff --git a/sphinx/util/typing.py b/sphinx/util/typing.py index c8e89ee0a..e0ff3b28f 100644 --- a/sphinx/util/typing.py +++ b/sphinx/util/typing.py @@ -178,6 +178,23 @@ def _is_annotated_form(obj: Any) -> TypeIs[Annotated[Any, ...]]: return typing.get_origin(obj) is Annotated or str(obj).startswith('typing.Annotated') +def _is_unpack_form(obj: Any) -> bool: + """Check if the object is :class:`typing.Unpack` or equivalent.""" + if sys.version_info >= (3, 11): + from typing import Unpack + + # typing_extensions.Unpack != typing.Unpack for 3.11, but we assume + # that typing_extensions.Unpack should not be used in that case + return typing.get_origin(obj) is Unpack + + # 3.9 and 3.10 require typing_extensions.Unpack + origin = typing.get_origin(obj) + return ( + getattr(origin, '__module__', None) == 'typing_extensions' + and _typing_internal_name(origin) == 'Unpack' + ) + + def _typing_internal_name(obj: Any) -> str | None: if sys.version_info[:2] >= (3, 10): return obj.__name__ @@ -185,7 +202,7 @@ def _typing_internal_name(obj: Any) -> str | None: def restify(cls: Any, mode: _RestifyMode = 'fully-qualified-except-typing') -> str: - """Convert python class to a reST reference. + """Convert a type-like object to a reST reference. :param mode: Specify a method how annotations will be stringified. @@ -252,6 +269,9 @@ def restify(cls: Any, mode: _RestifyMode = 'fully-qualified-except-typing') -> s # *cls* is defined in ``typing``, and thus ``__args__`` must exist return ' | '.join(restify(a, mode) for a in cls.__args__) elif inspect.isgenericalias(cls): + # A generic alias always has an __origin__, but it is difficult to + # use a type guard on inspect.isgenericalias() + # (ideally, we would use ``TypeIs`` introduced in Python 3.13). cls_name = _typing_internal_name(cls) if isinstance(cls.__origin__, typing._SpecialForm): @@ -298,7 +318,7 @@ def restify(cls: Any, mode: _RestifyMode = 'fully-qualified-except-typing') -> s elif isinstance(cls, ForwardRef): return f':py:class:`{cls.__forward_arg__}`' else: - # not a class (ex. TypeVar) + # not a class (ex. TypeVar) but should have a __name__ return f':py:obj:`{module_prefix}{cls.__module__}.{cls.__name__}`' except (AttributeError, TypeError): return inspect.object_description(cls) @@ -366,7 +386,8 @@ def stringify_annotation( annotation_module_is_typing = annotation_module == 'typing' # Extract the annotation's base type by considering formattable cases - if isinstance(annotation, TypeVar): + if isinstance(annotation, TypeVar) and not _is_unpack_form(annotation): + # typing_extensions.Unpack is incorrectly determined as a TypeVar if annotation_module_is_typing and mode in {'fully-qualified-except-typing', 'smart'}: return annotation_name return module_prefix + f'{annotation_module}.{annotation_name}' @@ -391,6 +412,7 @@ def stringify_annotation( # PEP 585 generic if not args: # Empty tuple, list, ... return repr(annotation) + concatenated_args = ', '.join(stringify_annotation(arg, mode) for arg in args) return f'{annotation_qualname}[{concatenated_args}]' else: @@ -404,6 +426,8 @@ def stringify_annotation( module_prefix = f'~{module_prefix}' if annotation_module_is_typing and mode == 'fully-qualified-except-typing': module_prefix = '' + elif _is_unpack_form(annotation) and annotation_module == 'typing_extensions': + module_prefix = '~' if mode == 'smart' else '' else: module_prefix = '' @@ -412,9 +436,8 @@ def stringify_annotation( # handle ForwardRefs qualname = annotation_forward_arg else: - _name = getattr(annotation, '_name', '') - if _name: - qualname = _name + if internal_name := _typing_internal_name(annotation): + qualname = internal_name elif annotation_qualname: qualname = annotation_qualname else: diff --git a/tests/test_util/test_util_typing.py b/tests/test_util/test_util_typing.py index a6a17ac8f..2873bb1a1 100644 --- a/tests/test_util/test_util_typing.py +++ b/tests/test_util/test_util_typing.py @@ -332,6 +332,30 @@ def test_restify_pep_585(): ":py:class:`int`]") +def test_restify_Unpack(): + from typing_extensions import Unpack as UnpackCompat + + class X(t.TypedDict): + x: int + y: int + label: str + + # Unpack is considered as typing special form so we always have '~' + if sys.version_info[:2] >= (3, 12): + expect = r':py:obj:`~typing.Unpack`\ [:py:class:`X`]' + assert restify(UnpackCompat['X'], 'fully-qualified-except-typing') == expect + assert restify(UnpackCompat['X'], 'smart') == expect + else: + expect = r':py:obj:`~typing_extensions.Unpack`\ [:py:class:`X`]' + assert restify(UnpackCompat['X'], 'fully-qualified-except-typing') == expect + assert restify(UnpackCompat['X'], 'smart') == expect + + if sys.version_info[:2] >= (3, 11): + expect = r':py:obj:`~typing.Unpack`\ [:py:class:`X`]' + assert restify(t.Unpack['X'], 'fully-qualified-except-typing') == expect + assert restify(t.Unpack['X'], 'smart') == expect + + @pytest.mark.skipif(sys.version_info[:2] <= (3, 9), reason='python 3.10+ is required.') def test_restify_type_union_operator(): assert restify(int | None) == ":py:class:`int` | :py:obj:`None`" # type: ignore[attr-defined] @@ -480,6 +504,32 @@ def test_stringify_Annotated(): assert stringify_annotation(Annotated[str, "foo", "bar"], "smart") == "str" +def test_stringify_Unpack(): + from typing_extensions import Unpack as UnpackCompat + + class X(t.TypedDict): + x: int + y: int + label: str + + if sys.version_info[:2] >= (3, 11): + # typing.Unpack is introduced in 3.11 but typing_extensions.Unpack only + # uses typing.Unpack in 3.12+, so the objects are not synchronised with + # each other, but we will assume that users use typing.Unpack. + import typing + + UnpackCompat = typing.Unpack # NoQA: F811 + assert stringify_annotation(UnpackCompat['X']) == 'Unpack[X]' + assert stringify_annotation(UnpackCompat['X'], 'smart') == '~typing.Unpack[X]' + else: + assert stringify_annotation(UnpackCompat['X']) == 'typing_extensions.Unpack[X]' + assert stringify_annotation(UnpackCompat['X'], 'smart') == '~typing_extensions.Unpack[X]' + + if sys.version_info[:2] >= (3, 11): + assert stringify_annotation(t.Unpack['X']) == 'Unpack[X]' + assert stringify_annotation(t.Unpack['X'], 'smart') == '~typing.Unpack[X]' + + def test_stringify_type_hints_string(): assert stringify_annotation("int", 'fully-qualified-except-typing') == "int" assert stringify_annotation("int", 'fully-qualified') == "int"