Skip to content

Commit 1ff9adf

Browse files
picnixzAA-Turner
andauthored
Separate cases in stringify and restify with greater precision (#12284)
Co-authored-by: Adam Turner <[email protected]>
1 parent acc92ff commit 1ff9adf

File tree

1 file changed

+74
-45
lines changed

1 file changed

+74
-45
lines changed

sphinx/util/typing.py

Lines changed: 74 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,16 @@
88
from collections.abc import Sequence
99
from contextvars import Context, ContextVar, Token
1010
from struct import Struct
11-
from typing import TYPE_CHECKING, Any, Callable, ForwardRef, TypedDict, TypeVar, Union
11+
from typing import (
12+
TYPE_CHECKING,
13+
Annotated,
14+
Any,
15+
Callable,
16+
ForwardRef,
17+
TypedDict,
18+
TypeVar,
19+
Union,
20+
)
1221

1322
from docutils import nodes
1423
from docutils.parsers.rst.states import Inliner
@@ -17,7 +26,7 @@
1726
from collections.abc import Mapping
1827
from typing import Final, Literal
1928

20-
from typing_extensions import TypeAlias
29+
from typing_extensions import TypeAlias, TypeGuard
2130

2231
from sphinx.application import Sphinx
2332

@@ -164,6 +173,17 @@ def is_system_TypeVar(typ: Any) -> bool:
164173
return modname == 'typing' and isinstance(typ, TypeVar)
165174

166175

176+
def _is_annotated_form(obj: Any) -> TypeGuard[Annotated[Any, ...]]:
177+
"""Check if *obj* is an annotated type."""
178+
return typing.get_origin(obj) is Annotated or str(obj).startswith('typing.Annotated')
179+
180+
181+
def _get_typing_internal_name(obj: Any) -> str | None:
182+
if sys.version_info[:2] >= (3, 10):
183+
return obj.__name__
184+
return getattr(obj, '_name', None)
185+
186+
167187
def restify(cls: Any, mode: _RestifyMode = 'fully-qualified-except-typing') -> str:
168188
"""Convert python class to a reST reference.
169189
@@ -185,35 +205,34 @@ def restify(cls: Any, mode: _RestifyMode = 'fully-qualified-except-typing') -> s
185205
raise ValueError(msg)
186206

187207
# things that are not types
188-
if cls is None or cls is NoneType:
208+
if cls in {None, NoneType}:
189209
return ':py:obj:`None`'
190210
if cls is Ellipsis:
191211
return '...'
192212
if isinstance(cls, str):
193213
return cls
194214

215+
cls_module_is_typing = getattr(cls, '__module__', '') == 'typing'
216+
195217
# If the mode is 'smart', we always use '~'.
196218
# If the mode is 'fully-qualified-except-typing',
197219
# we use '~' only for the objects in the ``typing`` module.
198-
if mode == 'smart' or getattr(cls, '__module__', None) == 'typing':
199-
modprefix = '~'
200-
else:
201-
modprefix = ''
220+
module_prefix = '~' if mode == 'smart' or cls_module_is_typing else ''
202221

203222
try:
204223
if ismockmodule(cls):
205-
return f':py:class:`{modprefix}{cls.__name__}`'
224+
return f':py:class:`{module_prefix}{cls.__name__}`'
206225
elif ismock(cls):
207-
return f':py:class:`{modprefix}{cls.__module__}.{cls.__name__}`'
226+
return f':py:class:`{module_prefix}{cls.__module__}.{cls.__name__}`'
208227
elif is_invalid_builtin_class(cls):
209228
# The above predicate never raises TypeError but should not be
210229
# evaluated before determining whether *cls* is a mocked object
211230
# or not; instead of two try-except blocks, we keep it here.
212-
return f':py:class:`{modprefix}{_INVALID_BUILTIN_CLASSES[cls]}`'
231+
return f':py:class:`{module_prefix}{_INVALID_BUILTIN_CLASSES[cls]}`'
213232
elif inspect.isNewType(cls):
214233
if sys.version_info[:2] >= (3, 10):
215234
# newtypes have correct module info since Python 3.10+
216-
return f':py:class:`{modprefix}{cls.__module__}.{cls.__name__}`'
235+
return f':py:class:`{module_prefix}{cls.__module__}.{cls.__name__}`'
217236
return f':py:class:`{cls.__name__}`'
218237
elif UnionType and isinstance(cls, UnionType):
219238
# Union types (PEP 585) retain their definition order when they
@@ -228,48 +247,56 @@ def restify(cls: Any, mode: _RestifyMode = 'fully-qualified-except-typing') -> s
228247
return fr':py:class:`{cls.__name__}`\ [{concatenated_args}]'
229248
return f':py:class:`{cls.__name__}`'
230249
elif (inspect.isgenericalias(cls)
231-
and cls.__module__ == 'typing'
250+
and cls_module_is_typing
232251
and cls.__origin__ is Union):
233252
# *cls* is defined in ``typing``, and thus ``__args__`` must exist
234253
return ' | '.join(restify(a, mode) for a in cls.__args__)
235254
elif inspect.isgenericalias(cls):
255+
cls_name = _get_typing_internal_name(cls)
256+
236257
if isinstance(cls.__origin__, typing._SpecialForm):
258+
# ClassVar; Concatenate; Final; Literal; Unpack; TypeGuard
259+
# Required/NotRequired
237260
text = restify(cls.__origin__, mode)
238-
elif getattr(cls, '_name', None):
239-
cls_name = cls._name
240-
text = f':py:class:`{modprefix}{cls.__module__}.{cls_name}`'
261+
elif cls_name:
262+
text = f':py:class:`{module_prefix}{cls.__module__}.{cls_name}`'
241263
else:
242264
text = restify(cls.__origin__, mode)
243265

244-
origin = getattr(cls, '__origin__', None)
245-
if not hasattr(cls, '__args__'): # NoQA: SIM114
246-
pass
247-
elif all(is_system_TypeVar(a) for a in cls.__args__):
248-
# Suppress arguments if all system defined TypeVars (ex. Dict[KT, VT])
249-
pass
250-
elif cls.__module__ == 'typing' and cls._name == 'Callable':
251-
args = ', '.join(restify(a, mode) for a in cls.__args__[:-1])
252-
text += fr'\ [[{args}], {restify(cls.__args__[-1], mode)}]'
253-
elif cls.__module__ == 'typing' and getattr(origin, '_name', None) == 'Literal':
266+
__args__ = getattr(cls, '__args__', ())
267+
if not __args__:
268+
return text
269+
if all(map(is_system_TypeVar, __args__)):
270+
# Don't print the arguments; they're all system defined type variables.
271+
return text
272+
273+
# Callable has special formatting
274+
if cls_module_is_typing and _get_typing_internal_name(cls) == 'Callable':
275+
args = ', '.join(restify(a, mode) for a in __args__[:-1])
276+
returns = restify(__args__[-1], mode)
277+
return fr'{text}\ [[{args}], {returns}]'
278+
279+
if cls_module_is_typing and _get_typing_internal_name(cls.__origin__) == 'Literal':
254280
args = ', '.join(_format_literal_arg_restify(a, mode=mode)
255281
for a in cls.__args__)
256-
text += fr"\ [{args}]"
257-
elif cls.__args__:
258-
text += fr"\ [{', '.join(restify(a, mode) for a in cls.__args__)}]"
282+
return fr'{text}\ [{args}]'
259283

260-
return text
284+
# generic representation of the parameters
285+
args = ', '.join(restify(a, mode) for a in __args__)
286+
return fr'{text}\ [{args}]'
261287
elif isinstance(cls, typing._SpecialForm):
262-
return f':py:obj:`~{cls.__module__}.{cls._name}`' # type: ignore[attr-defined]
288+
cls_name = _get_typing_internal_name(cls)
289+
return f':py:obj:`~{cls.__module__}.{cls_name}`'
263290
elif sys.version_info[:2] >= (3, 11) and cls is typing.Any:
264291
# handle bpo-46998
265292
return f':py:obj:`~{cls.__module__}.{cls.__name__}`'
266293
elif hasattr(cls, '__qualname__'):
267-
return f':py:class:`{modprefix}{cls.__module__}.{cls.__qualname__}`'
294+
return f':py:class:`{module_prefix}{cls.__module__}.{cls.__qualname__}`'
268295
elif isinstance(cls, ForwardRef):
269296
return f':py:class:`{cls.__forward_arg__}`'
270297
else:
271298
# not a class (ex. TypeVar)
272-
return f':py:obj:`{modprefix}{cls.__module__}.{cls.__name__}`'
299+
return f':py:obj:`{module_prefix}{cls.__module__}.{cls.__name__}`'
273300
except (AttributeError, TypeError):
274301
return inspect.object_description(cls)
275302

@@ -315,7 +342,7 @@ def stringify_annotation(
315342
raise ValueError(msg)
316343

317344
# things that are not types
318-
if annotation is None or annotation is NoneType:
345+
if annotation in {None, NoneType}:
319346
return 'None'
320347
if annotation is Ellipsis:
321348
return '...'
@@ -327,17 +354,15 @@ def stringify_annotation(
327354
if not annotation:
328355
return repr(annotation)
329356

330-
if mode == 'smart':
331-
module_prefix = '~'
332-
else:
333-
module_prefix = ''
357+
module_prefix = '~' if mode == 'smart' else ''
334358

335359
# The values below must be strings if the objects are well-formed.
336360
annotation_qualname: str = getattr(annotation, '__qualname__', '')
337361
annotation_module: str = getattr(annotation, '__module__', '')
338362
annotation_name: str = getattr(annotation, '__name__', '')
339363
annotation_module_is_typing = annotation_module == 'typing'
340364

365+
# Extract the annotation's base type by considering formattable cases
341366
if isinstance(annotation, TypeVar):
342367
if annotation_module_is_typing and mode in {'fully-qualified-except-typing', 'smart'}:
343368
return annotation_name
@@ -353,7 +378,7 @@ def stringify_annotation(
353378
return module_prefix + f'{annotation_module}.{annotation_name}'
354379
elif is_invalid_builtin_class(annotation):
355380
return module_prefix + _INVALID_BUILTIN_CLASSES[annotation]
356-
elif str(annotation).startswith('typing.Annotated'): # for py39+
381+
elif _is_annotated_form(annotation): # for py39+
357382
pass
358383
elif annotation_module == 'builtins' and annotation_qualname:
359384
args = getattr(annotation, '__args__', None)
@@ -365,6 +390,9 @@ def stringify_annotation(
365390
return repr(annotation)
366391
concatenated_args = ', '.join(stringify_annotation(arg, mode) for arg in args)
367392
return f'{annotation_qualname}[{concatenated_args}]'
393+
else:
394+
# add other special cases that can be directly formatted
395+
pass
368396

369397
module_prefix = f'{annotation_module}.'
370398
annotation_forward_arg: str | None = getattr(annotation, '__forward_arg__', None)
@@ -387,6 +415,8 @@ def stringify_annotation(
387415
elif annotation_qualname:
388416
qualname = annotation_qualname
389417
else:
418+
# in this case, we know that the annotation is a member
419+
# of ``typing`` and all of them define ``__origin__``
390420
qualname = stringify_annotation(
391421
annotation.__origin__, 'fully-qualified-except-typing',
392422
).replace('typing.', '') # ex. Union
@@ -402,12 +432,11 @@ def stringify_annotation(
402432
# only make them appear twice
403433
return repr(annotation)
404434

405-
annotation_args = getattr(annotation, '__args__', None)
406-
if annotation_args:
407-
if not isinstance(annotation_args, (list, tuple)):
408-
# broken __args__ found
409-
pass
410-
elif qualname in {'Optional', 'Union', 'types.UnionType'}:
435+
# Process the generic arguments (if any).
436+
# They must be a list or a tuple, otherwise they are considered 'broken'.
437+
annotation_args = getattr(annotation, '__args__', ())
438+
if annotation_args and isinstance(annotation_args, (list, tuple)):
439+
if qualname in {'Optional', 'Union', 'types.UnionType'}:
411440
return ' | '.join(stringify_annotation(a, mode) for a in annotation_args)
412441
elif qualname == 'Callable':
413442
args = ', '.join(stringify_annotation(a, mode) for a in annotation_args[:-1])
@@ -417,7 +446,7 @@ def stringify_annotation(
417446
args = ', '.join(_format_literal_arg_stringify(a, mode=mode)
418447
for a in annotation_args)
419448
return f'{module_prefix}Literal[{args}]'
420-
elif str(annotation).startswith('typing.Annotated'): # for py39+
449+
elif _is_annotated_form(annotation): # for py39+
421450
return stringify_annotation(annotation_args[0], mode)
422451
elif all(is_system_TypeVar(a) for a in annotation_args):
423452
# Suppress arguments if all system defined TypeVars (ex. Dict[KT, VT])

0 commit comments

Comments
 (0)