mirror of
https://github.com/sphinx-doc/sphinx.git
synced 2025-02-25 18:55:22 -06:00
Support `typing_extensions.Unpack
` (#12258)
Co-authored-by: Adam Turner <9087854+aa-turner@users.noreply.github.com>
This commit is contained in:
parent
778013f91a
commit
3209275a4a
@ -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]]
|
||||
|
@ -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:
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user