Skip to content

Commit 3209275

Browse files
picnixzAA-Turner
andauthored
Support typing_extensions.Unpack (#12258)
Co-authored-by: Adam Turner <[email protected]>
1 parent 778013f commit 3209275

File tree

3 files changed

+80
-6
lines changed

3 files changed

+80
-6
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ test = [
9797
"defusedxml>=0.7.1", # for secure XML/HTML parsing
9898
"cython>=3.0",
9999
"setuptools>=67.0", # for Cython compilation
100+
"typing_extensions", # for typing_extensions.Unpack
100101
]
101102

102103
[[project.authors]]

sphinx/util/typing.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -178,14 +178,31 @@ def _is_annotated_form(obj: Any) -> TypeIs[Annotated[Any, ...]]:
178178
return typing.get_origin(obj) is Annotated or str(obj).startswith('typing.Annotated')
179179

180180

181+
def _is_unpack_form(obj: Any) -> bool:
182+
"""Check if the object is :class:`typing.Unpack` or equivalent."""
183+
if sys.version_info >= (3, 11):
184+
from typing import Unpack
185+
186+
# typing_extensions.Unpack != typing.Unpack for 3.11, but we assume
187+
# that typing_extensions.Unpack should not be used in that case
188+
return typing.get_origin(obj) is Unpack
189+
190+
# 3.9 and 3.10 require typing_extensions.Unpack
191+
origin = typing.get_origin(obj)
192+
return (
193+
getattr(origin, '__module__', None) == 'typing_extensions'
194+
and _typing_internal_name(origin) == 'Unpack'
195+
)
196+
197+
181198
def _typing_internal_name(obj: Any) -> str | None:
182199
if sys.version_info[:2] >= (3, 10):
183200
return obj.__name__
184201
return getattr(obj, '_name', None)
185202

186203

187204
def restify(cls: Any, mode: _RestifyMode = 'fully-qualified-except-typing') -> str:
188-
"""Convert python class to a reST reference.
205+
"""Convert a type-like object to a reST reference.
189206
190207
:param mode: Specify a method how annotations will be stringified.
191208
@@ -252,6 +269,9 @@ def restify(cls: Any, mode: _RestifyMode = 'fully-qualified-except-typing') -> s
252269
# *cls* is defined in ``typing``, and thus ``__args__`` must exist
253270
return ' | '.join(restify(a, mode) for a in cls.__args__)
254271
elif inspect.isgenericalias(cls):
272+
# A generic alias always has an __origin__, but it is difficult to
273+
# use a type guard on inspect.isgenericalias()
274+
# (ideally, we would use ``TypeIs`` introduced in Python 3.13).
255275
cls_name = _typing_internal_name(cls)
256276

257277
if isinstance(cls.__origin__, typing._SpecialForm):
@@ -298,7 +318,7 @@ def restify(cls: Any, mode: _RestifyMode = 'fully-qualified-except-typing') -> s
298318
elif isinstance(cls, ForwardRef):
299319
return f':py:class:`{cls.__forward_arg__}`'
300320
else:
301-
# not a class (ex. TypeVar)
321+
# not a class (ex. TypeVar) but should have a __name__
302322
return f':py:obj:`{module_prefix}{cls.__module__}.{cls.__name__}`'
303323
except (AttributeError, TypeError):
304324
return inspect.object_description(cls)
@@ -366,7 +386,8 @@ def stringify_annotation(
366386
annotation_module_is_typing = annotation_module == 'typing'
367387

368388
# Extract the annotation's base type by considering formattable cases
369-
if isinstance(annotation, TypeVar):
389+
if isinstance(annotation, TypeVar) and not _is_unpack_form(annotation):
390+
# typing_extensions.Unpack is incorrectly determined as a TypeVar
370391
if annotation_module_is_typing and mode in {'fully-qualified-except-typing', 'smart'}:
371392
return annotation_name
372393
return module_prefix + f'{annotation_module}.{annotation_name}'
@@ -391,6 +412,7 @@ def stringify_annotation(
391412
# PEP 585 generic
392413
if not args: # Empty tuple, list, ...
393414
return repr(annotation)
415+
394416
concatenated_args = ', '.join(stringify_annotation(arg, mode) for arg in args)
395417
return f'{annotation_qualname}[{concatenated_args}]'
396418
else:
@@ -404,6 +426,8 @@ def stringify_annotation(
404426
module_prefix = f'~{module_prefix}'
405427
if annotation_module_is_typing and mode == 'fully-qualified-except-typing':
406428
module_prefix = ''
429+
elif _is_unpack_form(annotation) and annotation_module == 'typing_extensions':
430+
module_prefix = '~' if mode == 'smart' else ''
407431
else:
408432
module_prefix = ''
409433

@@ -412,9 +436,8 @@ def stringify_annotation(
412436
# handle ForwardRefs
413437
qualname = annotation_forward_arg
414438
else:
415-
_name = getattr(annotation, '_name', '')
416-
if _name:
417-
qualname = _name
439+
if internal_name := _typing_internal_name(annotation):
440+
qualname = internal_name
418441
elif annotation_qualname:
419442
qualname = annotation_qualname
420443
else:

tests/test_util/test_util_typing.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,30 @@ def test_restify_pep_585():
332332
":py:class:`int`]")
333333

334334

335+
def test_restify_Unpack():
336+
from typing_extensions import Unpack as UnpackCompat
337+
338+
class X(t.TypedDict):
339+
x: int
340+
y: int
341+
label: str
342+
343+
# Unpack is considered as typing special form so we always have '~'
344+
if sys.version_info[:2] >= (3, 12):
345+
expect = r':py:obj:`~typing.Unpack`\ [:py:class:`X`]'
346+
assert restify(UnpackCompat['X'], 'fully-qualified-except-typing') == expect
347+
assert restify(UnpackCompat['X'], 'smart') == expect
348+
else:
349+
expect = r':py:obj:`~typing_extensions.Unpack`\ [:py:class:`X`]'
350+
assert restify(UnpackCompat['X'], 'fully-qualified-except-typing') == expect
351+
assert restify(UnpackCompat['X'], 'smart') == expect
352+
353+
if sys.version_info[:2] >= (3, 11):
354+
expect = r':py:obj:`~typing.Unpack`\ [:py:class:`X`]'
355+
assert restify(t.Unpack['X'], 'fully-qualified-except-typing') == expect
356+
assert restify(t.Unpack['X'], 'smart') == expect
357+
358+
335359
@pytest.mark.skipif(sys.version_info[:2] <= (3, 9), reason='python 3.10+ is required.')
336360
def test_restify_type_union_operator():
337361
assert restify(int | None) == ":py:class:`int` | :py:obj:`None`" # type: ignore[attr-defined]
@@ -480,6 +504,32 @@ def test_stringify_Annotated():
480504
assert stringify_annotation(Annotated[str, "foo", "bar"], "smart") == "str"
481505

482506

507+
def test_stringify_Unpack():
508+
from typing_extensions import Unpack as UnpackCompat
509+
510+
class X(t.TypedDict):
511+
x: int
512+
y: int
513+
label: str
514+
515+
if sys.version_info[:2] >= (3, 11):
516+
# typing.Unpack is introduced in 3.11 but typing_extensions.Unpack only
517+
# uses typing.Unpack in 3.12+, so the objects are not synchronised with
518+
# each other, but we will assume that users use typing.Unpack.
519+
import typing
520+
521+
UnpackCompat = typing.Unpack # NoQA: F811
522+
assert stringify_annotation(UnpackCompat['X']) == 'Unpack[X]'
523+
assert stringify_annotation(UnpackCompat['X'], 'smart') == '~typing.Unpack[X]'
524+
else:
525+
assert stringify_annotation(UnpackCompat['X']) == 'typing_extensions.Unpack[X]'
526+
assert stringify_annotation(UnpackCompat['X'], 'smart') == '~typing_extensions.Unpack[X]'
527+
528+
if sys.version_info[:2] >= (3, 11):
529+
assert stringify_annotation(t.Unpack['X']) == 'Unpack[X]'
530+
assert stringify_annotation(t.Unpack['X'], 'smart') == '~typing.Unpack[X]'
531+
532+
483533
def test_stringify_type_hints_string():
484534
assert stringify_annotation("int", 'fully-qualified-except-typing') == "int"
485535
assert stringify_annotation("int", 'fully-qualified') == "int"

0 commit comments

Comments
 (0)