From 1ff9adfb62933a454e05bcd1bb45e168f3786564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 23 Apr 2024 09:37:04 +0200 Subject: [PATCH] Separate cases in ``stringify`` and ``restify`` with greater precision (#12284) Co-authored-by: Adam Turner <9087854+aa-turner@users.noreply.github.com> --- sphinx/util/typing.py | 119 ++++++++++++++++++++++++++---------------- 1 file changed, 74 insertions(+), 45 deletions(-) diff --git a/sphinx/util/typing.py b/sphinx/util/typing.py index 39056f91b44..fce1087e0d6 100644 --- a/sphinx/util/typing.py +++ b/sphinx/util/typing.py @@ -8,7 +8,16 @@ from collections.abc import Sequence from contextvars import Context, ContextVar, Token from struct import Struct -from typing import TYPE_CHECKING, Any, Callable, ForwardRef, TypedDict, TypeVar, Union +from typing import ( + TYPE_CHECKING, + Annotated, + Any, + Callable, + ForwardRef, + TypedDict, + TypeVar, + Union, +) from docutils import nodes from docutils.parsers.rst.states import Inliner @@ -17,7 +26,7 @@ from collections.abc import Mapping from typing import Final, Literal - from typing_extensions import TypeAlias + from typing_extensions import TypeAlias, TypeGuard from sphinx.application import Sphinx @@ -164,6 +173,17 @@ def is_system_TypeVar(typ: Any) -> bool: return modname == 'typing' and isinstance(typ, TypeVar) +def _is_annotated_form(obj: Any) -> TypeGuard[Annotated[Any, ...]]: + """Check if *obj* is an annotated type.""" + return typing.get_origin(obj) is Annotated or str(obj).startswith('typing.Annotated') + + +def _get_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. @@ -185,35 +205,34 @@ def restify(cls: Any, mode: _RestifyMode = 'fully-qualified-except-typing') -> s raise ValueError(msg) # things that are not types - if cls is None or cls is NoneType: + if cls in {None, NoneType}: return ':py:obj:`None`' if cls is Ellipsis: return '...' if isinstance(cls, str): return cls + cls_module_is_typing = getattr(cls, '__module__', '') == 'typing' + # If the mode is 'smart', we always use '~'. # If the mode is 'fully-qualified-except-typing', # we use '~' only for the objects in the ``typing`` module. - if mode == 'smart' or getattr(cls, '__module__', None) == 'typing': - modprefix = '~' - else: - modprefix = '' + module_prefix = '~' if mode == 'smart' or cls_module_is_typing else '' try: if ismockmodule(cls): - return f':py:class:`{modprefix}{cls.__name__}`' + return f':py:class:`{module_prefix}{cls.__name__}`' elif ismock(cls): - return f':py:class:`{modprefix}{cls.__module__}.{cls.__name__}`' + return f':py:class:`{module_prefix}{cls.__module__}.{cls.__name__}`' elif is_invalid_builtin_class(cls): # The above predicate never raises TypeError but should not be # evaluated before determining whether *cls* is a mocked object # or not; instead of two try-except blocks, we keep it here. - return f':py:class:`{modprefix}{_INVALID_BUILTIN_CLASSES[cls]}`' + return f':py:class:`{module_prefix}{_INVALID_BUILTIN_CLASSES[cls]}`' elif inspect.isNewType(cls): if sys.version_info[:2] >= (3, 10): # newtypes have correct module info since Python 3.10+ - return f':py:class:`{modprefix}{cls.__module__}.{cls.__name__}`' + return f':py:class:`{module_prefix}{cls.__module__}.{cls.__name__}`' return f':py:class:`{cls.__name__}`' elif UnionType and isinstance(cls, UnionType): # 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 return fr':py:class:`{cls.__name__}`\ [{concatenated_args}]' return f':py:class:`{cls.__name__}`' elif (inspect.isgenericalias(cls) - and cls.__module__ == 'typing' + and cls_module_is_typing and cls.__origin__ is Union): # *cls* is defined in ``typing``, and thus ``__args__`` must exist return ' | '.join(restify(a, mode) for a in cls.__args__) elif inspect.isgenericalias(cls): + cls_name = _get_typing_internal_name(cls) + if isinstance(cls.__origin__, typing._SpecialForm): + # ClassVar; Concatenate; Final; Literal; Unpack; TypeGuard + # Required/NotRequired text = restify(cls.__origin__, mode) - elif getattr(cls, '_name', None): - cls_name = cls._name - text = f':py:class:`{modprefix}{cls.__module__}.{cls_name}`' + elif cls_name: + text = f':py:class:`{module_prefix}{cls.__module__}.{cls_name}`' else: text = restify(cls.__origin__, mode) - origin = getattr(cls, '__origin__', None) - if not hasattr(cls, '__args__'): # NoQA: SIM114 - pass - elif all(is_system_TypeVar(a) for a in cls.__args__): - # Suppress arguments if all system defined TypeVars (ex. Dict[KT, VT]) - pass - elif cls.__module__ == 'typing' and cls._name == 'Callable': - args = ', '.join(restify(a, mode) for a in cls.__args__[:-1]) - text += fr'\ [[{args}], {restify(cls.__args__[-1], mode)}]' - elif cls.__module__ == 'typing' and getattr(origin, '_name', None) == 'Literal': + __args__ = getattr(cls, '__args__', ()) + if not __args__: + return text + if all(map(is_system_TypeVar, __args__)): + # Don't print the arguments; they're all system defined type variables. + return text + + # Callable has special formatting + if cls_module_is_typing and _get_typing_internal_name(cls) == 'Callable': + args = ', '.join(restify(a, mode) for a in __args__[:-1]) + returns = restify(__args__[-1], mode) + return fr'{text}\ [[{args}], {returns}]' + + if cls_module_is_typing and _get_typing_internal_name(cls.__origin__) == 'Literal': args = ', '.join(_format_literal_arg_restify(a, mode=mode) for a in cls.__args__) - text += fr"\ [{args}]" - elif cls.__args__: - text += fr"\ [{', '.join(restify(a, mode) for a in cls.__args__)}]" + return fr'{text}\ [{args}]' - return text + # generic representation of the parameters + args = ', '.join(restify(a, mode) for a in __args__) + return fr'{text}\ [{args}]' elif isinstance(cls, typing._SpecialForm): - return f':py:obj:`~{cls.__module__}.{cls._name}`' # type: ignore[attr-defined] + cls_name = _get_typing_internal_name(cls) + return f':py:obj:`~{cls.__module__}.{cls_name}`' elif sys.version_info[:2] >= (3, 11) and cls is typing.Any: # handle bpo-46998 return f':py:obj:`~{cls.__module__}.{cls.__name__}`' elif hasattr(cls, '__qualname__'): - return f':py:class:`{modprefix}{cls.__module__}.{cls.__qualname__}`' + return f':py:class:`{module_prefix}{cls.__module__}.{cls.__qualname__}`' elif isinstance(cls, ForwardRef): return f':py:class:`{cls.__forward_arg__}`' else: # not a class (ex. TypeVar) - return f':py:obj:`{modprefix}{cls.__module__}.{cls.__name__}`' + return f':py:obj:`{module_prefix}{cls.__module__}.{cls.__name__}`' except (AttributeError, TypeError): return inspect.object_description(cls) @@ -315,7 +342,7 @@ def stringify_annotation( raise ValueError(msg) # things that are not types - if annotation is None or annotation is NoneType: + if annotation in {None, NoneType}: return 'None' if annotation is Ellipsis: return '...' @@ -327,10 +354,7 @@ def stringify_annotation( if not annotation: return repr(annotation) - if mode == 'smart': - module_prefix = '~' - else: - module_prefix = '' + module_prefix = '~' if mode == 'smart' else '' # The values below must be strings if the objects are well-formed. annotation_qualname: str = getattr(annotation, '__qualname__', '') @@ -338,6 +362,7 @@ def stringify_annotation( annotation_name: str = getattr(annotation, '__name__', '') annotation_module_is_typing = annotation_module == 'typing' + # Extract the annotation's base type by considering formattable cases if isinstance(annotation, TypeVar): if annotation_module_is_typing and mode in {'fully-qualified-except-typing', 'smart'}: return annotation_name @@ -353,7 +378,7 @@ def stringify_annotation( return module_prefix + f'{annotation_module}.{annotation_name}' elif is_invalid_builtin_class(annotation): return module_prefix + _INVALID_BUILTIN_CLASSES[annotation] - elif str(annotation).startswith('typing.Annotated'): # for py39+ + elif _is_annotated_form(annotation): # for py39+ pass elif annotation_module == 'builtins' and annotation_qualname: args = getattr(annotation, '__args__', None) @@ -365,6 +390,9 @@ def stringify_annotation( return repr(annotation) concatenated_args = ', '.join(stringify_annotation(arg, mode) for arg in args) return f'{annotation_qualname}[{concatenated_args}]' + else: + # add other special cases that can be directly formatted + pass module_prefix = f'{annotation_module}.' annotation_forward_arg: str | None = getattr(annotation, '__forward_arg__', None) @@ -387,6 +415,8 @@ def stringify_annotation( elif annotation_qualname: qualname = annotation_qualname else: + # in this case, we know that the annotation is a member + # of ``typing`` and all of them define ``__origin__`` qualname = stringify_annotation( annotation.__origin__, 'fully-qualified-except-typing', ).replace('typing.', '') # ex. Union @@ -402,12 +432,11 @@ def stringify_annotation( # only make them appear twice return repr(annotation) - annotation_args = getattr(annotation, '__args__', None) - if annotation_args: - if not isinstance(annotation_args, (list, tuple)): - # broken __args__ found - pass - elif qualname in {'Optional', 'Union', 'types.UnionType'}: + # Process the generic arguments (if any). + # They must be a list or a tuple, otherwise they are considered 'broken'. + annotation_args = getattr(annotation, '__args__', ()) + if annotation_args and isinstance(annotation_args, (list, tuple)): + if qualname in {'Optional', 'Union', 'types.UnionType'}: return ' | '.join(stringify_annotation(a, mode) for a in annotation_args) elif qualname == 'Callable': args = ', '.join(stringify_annotation(a, mode) for a in annotation_args[:-1]) @@ -417,7 +446,7 @@ def stringify_annotation( args = ', '.join(_format_literal_arg_stringify(a, mode=mode) for a in annotation_args) return f'{module_prefix}Literal[{args}]' - elif str(annotation).startswith('typing.Annotated'): # for py39+ + elif _is_annotated_form(annotation): # for py39+ return stringify_annotation(annotation_args[0], mode) elif all(is_system_TypeVar(a) for a in annotation_args): # Suppress arguments if all system defined TypeVars (ex. Dict[KT, VT])