Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support typing_extensions.Unpack #12258

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
1 change: 1 addition & 0 deletions pyproject.toml
Expand Up @@ -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]]
Expand Down
35 changes: 29 additions & 6 deletions sphinx/util/typing.py
Expand Up @@ -178,14 +178,31 @@ 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__
return getattr(obj, '_name', 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.

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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}'
Expand All @@ -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:
Expand All @@ -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 = ''

Expand All @@ -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:
Expand Down
50 changes: 50 additions & 0 deletions tests/test_util/test_util_typing.py
Expand Up @@ -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]
Expand Down Expand Up @@ -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"
Expand Down