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 14 commits into
base: master
Choose a base branch
from
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
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
31 changes: 25 additions & 6 deletions sphinx/util/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,14 +178,27 @@ def _is_annotated_form(obj: Any) -> TypeGuard[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."""
origin = typing.get_origin(obj)
__module__ = getattr(origin, '__module__', None)
if __module__ == 'typing':
return getattr(origin, '__qualname__', None) == 'Unpack'

if __module__ == 'typing_extensions':
picnixz marked this conversation as resolved.
Show resolved Hide resolved
return getattr(origin, '_name', None) == 'Unpack'

return False


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 +265,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 +314,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 +382,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 +408,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 +422,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 +432,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
46 changes: 46 additions & 0 deletions tests/test_util/test_util_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,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 @@ -474,6 +498,28 @@ 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

# 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.
if sys.version_info[:2] >= (3, 12):
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