diff --git a/sphinx/util/typing.py b/sphinx/util/typing.py index f00dce0ca93..17b31eb4fcb 100644 --- a/sphinx/util/typing.py +++ b/sphinx/util/typing.py @@ -175,8 +175,13 @@ def _is_unpack_form(obj: Any) -> bool: # cannot use type guards """Check if the object is :class:`typing.Unpack` or equivalent.""" origin = typing.get_origin(obj) __module__ = getattr(origin, '__module__', None) - __qualname__ = getattr(origin, '__qualname__', None) - return __module__ in {'typing', 'typing_extensions'} and __qualname__ == 'Unpack' + if __module__ == 'typing': + return getattr(origin, '__qualname__', None) == 'Unpack' + + if __module__ == 'typing_extensions': + return getattr(origin, '_name', None) == 'Unpack' + + return False def _is_annotated_form(obj: Any) -> TypeGuard[Annotated[Any, ...]]: @@ -412,6 +417,8 @@ def stringify_annotation( module_prefix = '~' + 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 = '' diff --git a/tests/test_util/test_util_typing.py b/tests/test_util/test_util_typing.py index 8bc49a83957..4c252d0a96b 100644 --- a/tests/test_util/test_util_typing.py +++ b/tests/test_util/test_util_typing.py @@ -303,15 +303,20 @@ def test_restify_Unpack(): from typing_extensions import Unpack as UnpackCompat + class X(typing.TypedDict): + x: int + y: int + label: str + # Unpack is considered as typing special form so we always have '~' expect = rf':py:obj:`~{UnpackCompat.__module__}.Unpack`\ [:py:class:`X`]' - assert restify(UnpackCompat['X'], 'fully-qualified-except-typing') == expect # NoQA: F821 - assert restify(UnpackCompat['X'], 'smart') == expect # NoQA: F821 + assert restify(UnpackCompat['X'], 'fully-qualified-except-typing') == expect + assert restify(UnpackCompat['X'], 'smart') == expect if NativeUnpack := getattr(typing, 'Unpack', None): expect = r':py:obj:`~typing.Unpack`\ [:py:class:`X`]' - assert restify(NativeUnpack['X'], 'fully-qualified-except-typing') == expect # NoQA: F821 - assert restify(NativeUnpack['X'], 'smart') == expect # NoQA: F821 + assert restify(NativeUnpack['X'], 'fully-qualified-except-typing') == expect + assert restify(NativeUnpack['X'], 'smart') == expect @pytest.mark.skipif(sys.version_info[:2] <= (3, 9), reason='python 3.10+ is required.') @@ -460,13 +465,24 @@ def test_stringify_Unpack(): from typing_extensions import Unpack as UnpackCompat - qualname = rf'{UnpackCompat.__module__}.Unpack[X]' - assert stringify_annotation(UnpackCompat['X']) == qualname # NoQA: F821 - assert stringify_annotation(UnpackCompat['X'], 'smart') == f'~{qualname}' # NoQA: F821 + class X(typing.TypedDict): + x: int + y: int + label: str + + # typing.Unpack is introduced in 3.11 but typing_extensions.Unpack + # is only using typing.Unpack since 3.12, so those objects are not + # synchronized with each other. + if hasattr(typing, 'Unpack') and typing.Unpack is UnpackCompat: + 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 NativeUnpack := getattr(typing, 'Unpack', None): - assert stringify_annotation(NativeUnpack['X']) == 'Unpack[X]' # NoQA: F821 - assert stringify_annotation(NativeUnpack['X'], 'smart') == '~typing.Unpack[X]' # NoQA: F821 + assert stringify_annotation(NativeUnpack['X']) == 'Unpack[X]' + assert stringify_annotation(NativeUnpack['X'], 'smart') == '~typing.Unpack[X]' def test_stringify_type_hints_string():