Separate cases in `stringify and restify` with greater precision (#12284)

Co-authored-by: Adam Turner <9087854+aa-turner@users.noreply.github.com>
This commit is contained in:
Bénédikt Tran
2024-04-23 09:37:04 +02:00
committed by GitHub
parent acc92ff615
commit 1ff9adfb62

View File

@@ -8,7 +8,16 @@ import typing
from collections.abc import Sequence from collections.abc import Sequence
from contextvars import Context, ContextVar, Token from contextvars import Context, ContextVar, Token
from struct import Struct 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 import nodes
from docutils.parsers.rst.states import Inliner from docutils.parsers.rst.states import Inliner
@@ -17,7 +26,7 @@ if TYPE_CHECKING:
from collections.abc import Mapping from collections.abc import Mapping
from typing import Final, Literal from typing import Final, Literal
from typing_extensions import TypeAlias from typing_extensions import TypeAlias, TypeGuard
from sphinx.application import Sphinx from sphinx.application import Sphinx
@@ -164,6 +173,17 @@ def is_system_TypeVar(typ: Any) -> bool:
return modname == 'typing' and isinstance(typ, TypeVar) 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: def restify(cls: Any, mode: _RestifyMode = 'fully-qualified-except-typing') -> str:
"""Convert python class to a reST reference. """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) raise ValueError(msg)
# things that are not types # things that are not types
if cls is None or cls is NoneType: if cls in {None, NoneType}:
return ':py:obj:`None`' return ':py:obj:`None`'
if cls is Ellipsis: if cls is Ellipsis:
return '...' return '...'
if isinstance(cls, str): if isinstance(cls, str):
return cls return cls
cls_module_is_typing = getattr(cls, '__module__', '') == 'typing'
# If the mode is 'smart', we always use '~'. # If the mode is 'smart', we always use '~'.
# If the mode is 'fully-qualified-except-typing', # If the mode is 'fully-qualified-except-typing',
# we use '~' only for the objects in the ``typing`` module. # we use '~' only for the objects in the ``typing`` module.
if mode == 'smart' or getattr(cls, '__module__', None) == 'typing': module_prefix = '~' if mode == 'smart' or cls_module_is_typing else ''
modprefix = '~'
else:
modprefix = ''
try: try:
if ismockmodule(cls): if ismockmodule(cls):
return f':py:class:`{modprefix}{cls.__name__}`' return f':py:class:`{module_prefix}{cls.__name__}`'
elif ismock(cls): 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): elif is_invalid_builtin_class(cls):
# The above predicate never raises TypeError but should not be # The above predicate never raises TypeError but should not be
# evaluated before determining whether *cls* is a mocked object # evaluated before determining whether *cls* is a mocked object
# or not; instead of two try-except blocks, we keep it here. # 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): elif inspect.isNewType(cls):
if sys.version_info[:2] >= (3, 10): if sys.version_info[:2] >= (3, 10):
# newtypes have correct module info since Python 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__}`' return f':py:class:`{cls.__name__}`'
elif UnionType and isinstance(cls, UnionType): elif UnionType and isinstance(cls, UnionType):
# Union types (PEP 585) retain their definition order when they # 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 fr':py:class:`{cls.__name__}`\ [{concatenated_args}]'
return f':py:class:`{cls.__name__}`' return f':py:class:`{cls.__name__}`'
elif (inspect.isgenericalias(cls) elif (inspect.isgenericalias(cls)
and cls.__module__ == 'typing' and cls_module_is_typing
and cls.__origin__ is Union): and cls.__origin__ is Union):
# *cls* is defined in ``typing``, and thus ``__args__`` must exist # *cls* is defined in ``typing``, and thus ``__args__`` must exist
return ' | '.join(restify(a, mode) for a in cls.__args__) return ' | '.join(restify(a, mode) for a in cls.__args__)
elif inspect.isgenericalias(cls): elif inspect.isgenericalias(cls):
cls_name = _get_typing_internal_name(cls)
if isinstance(cls.__origin__, typing._SpecialForm): if isinstance(cls.__origin__, typing._SpecialForm):
# ClassVar; Concatenate; Final; Literal; Unpack; TypeGuard
# Required/NotRequired
text = restify(cls.__origin__, mode) text = restify(cls.__origin__, mode)
elif getattr(cls, '_name', None): elif cls_name:
cls_name = cls._name text = f':py:class:`{module_prefix}{cls.__module__}.{cls_name}`'
text = f':py:class:`{modprefix}{cls.__module__}.{cls_name}`'
else: else:
text = restify(cls.__origin__, mode) text = restify(cls.__origin__, mode)
origin = getattr(cls, '__origin__', None) __args__ = getattr(cls, '__args__', ())
if not hasattr(cls, '__args__'): # NoQA: SIM114 if not __args__:
pass return text
elif all(is_system_TypeVar(a) for a in cls.__args__): if all(map(is_system_TypeVar, __args__)):
# Suppress arguments if all system defined TypeVars (ex. Dict[KT, VT]) # Don't print the arguments; they're all system defined type variables.
pass return text
elif cls.__module__ == 'typing' and cls._name == 'Callable':
args = ', '.join(restify(a, mode) for a in cls.__args__[:-1]) # Callable has special formatting
text += fr'\ [[{args}], {restify(cls.__args__[-1], mode)}]' if cls_module_is_typing and _get_typing_internal_name(cls) == 'Callable':
elif cls.__module__ == 'typing' and getattr(origin, '_name', None) == 'Literal': 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) args = ', '.join(_format_literal_arg_restify(a, mode=mode)
for a in cls.__args__) for a in cls.__args__)
text += fr"\ [{args}]" return fr'{text}\ [{args}]'
elif cls.__args__:
text += fr"\ [{', '.join(restify(a, mode) for a in cls.__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): 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: elif sys.version_info[:2] >= (3, 11) and cls is typing.Any:
# handle bpo-46998 # handle bpo-46998
return f':py:obj:`~{cls.__module__}.{cls.__name__}`' return f':py:obj:`~{cls.__module__}.{cls.__name__}`'
elif hasattr(cls, '__qualname__'): 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): elif isinstance(cls, ForwardRef):
return f':py:class:`{cls.__forward_arg__}`' return f':py:class:`{cls.__forward_arg__}`'
else: else:
# not a class (ex. TypeVar) # 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): except (AttributeError, TypeError):
return inspect.object_description(cls) return inspect.object_description(cls)
@@ -315,7 +342,7 @@ def stringify_annotation(
raise ValueError(msg) raise ValueError(msg)
# things that are not types # things that are not types
if annotation is None or annotation is NoneType: if annotation in {None, NoneType}:
return 'None' return 'None'
if annotation is Ellipsis: if annotation is Ellipsis:
return '...' return '...'
@@ -327,10 +354,7 @@ def stringify_annotation(
if not annotation: if not annotation:
return repr(annotation) return repr(annotation)
if mode == 'smart': module_prefix = '~' if mode == 'smart' else ''
module_prefix = '~'
else:
module_prefix = ''
# The values below must be strings if the objects are well-formed. # The values below must be strings if the objects are well-formed.
annotation_qualname: str = getattr(annotation, '__qualname__', '') annotation_qualname: str = getattr(annotation, '__qualname__', '')
@@ -338,6 +362,7 @@ def stringify_annotation(
annotation_name: str = getattr(annotation, '__name__', '') annotation_name: str = getattr(annotation, '__name__', '')
annotation_module_is_typing = annotation_module == 'typing' 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):
if annotation_module_is_typing and mode in {'fully-qualified-except-typing', 'smart'}: if annotation_module_is_typing and mode in {'fully-qualified-except-typing', 'smart'}:
return annotation_name return annotation_name
@@ -353,7 +378,7 @@ def stringify_annotation(
return module_prefix + f'{annotation_module}.{annotation_name}' return module_prefix + f'{annotation_module}.{annotation_name}'
elif is_invalid_builtin_class(annotation): elif is_invalid_builtin_class(annotation):
return module_prefix + _INVALID_BUILTIN_CLASSES[annotation] return module_prefix + _INVALID_BUILTIN_CLASSES[annotation]
elif str(annotation).startswith('typing.Annotated'): # for py39+ elif _is_annotated_form(annotation): # for py39+
pass pass
elif annotation_module == 'builtins' and annotation_qualname: elif annotation_module == 'builtins' and annotation_qualname:
args = getattr(annotation, '__args__', None) args = getattr(annotation, '__args__', None)
@@ -365,6 +390,9 @@ def stringify_annotation(
return repr(annotation) return repr(annotation)
concatenated_args = ', '.join(stringify_annotation(arg, mode) for arg in args) concatenated_args = ', '.join(stringify_annotation(arg, mode) for arg in args)
return f'{annotation_qualname}[{concatenated_args}]' return f'{annotation_qualname}[{concatenated_args}]'
else:
# add other special cases that can be directly formatted
pass
module_prefix = f'{annotation_module}.' module_prefix = f'{annotation_module}.'
annotation_forward_arg: str | None = getattr(annotation, '__forward_arg__', None) annotation_forward_arg: str | None = getattr(annotation, '__forward_arg__', None)
@@ -387,6 +415,8 @@ def stringify_annotation(
elif annotation_qualname: elif annotation_qualname:
qualname = annotation_qualname qualname = annotation_qualname
else: else:
# in this case, we know that the annotation is a member
# of ``typing`` and all of them define ``__origin__``
qualname = stringify_annotation( qualname = stringify_annotation(
annotation.__origin__, 'fully-qualified-except-typing', annotation.__origin__, 'fully-qualified-except-typing',
).replace('typing.', '') # ex. Union ).replace('typing.', '') # ex. Union
@@ -402,12 +432,11 @@ def stringify_annotation(
# only make them appear twice # only make them appear twice
return repr(annotation) return repr(annotation)
annotation_args = getattr(annotation, '__args__', None) # Process the generic arguments (if any).
if annotation_args: # They must be a list or a tuple, otherwise they are considered 'broken'.
if not isinstance(annotation_args, (list, tuple)): annotation_args = getattr(annotation, '__args__', ())
# broken __args__ found if annotation_args and isinstance(annotation_args, (list, tuple)):
pass if qualname in {'Optional', 'Union', 'types.UnionType'}:
elif qualname in {'Optional', 'Union', 'types.UnionType'}:
return ' | '.join(stringify_annotation(a, mode) for a in annotation_args) return ' | '.join(stringify_annotation(a, mode) for a in annotation_args)
elif qualname == 'Callable': elif qualname == 'Callable':
args = ', '.join(stringify_annotation(a, mode) for a in annotation_args[:-1]) 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) args = ', '.join(_format_literal_arg_stringify(a, mode=mode)
for a in annotation_args) for a in annotation_args)
return f'{module_prefix}Literal[{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) return stringify_annotation(annotation_args[0], mode)
elif all(is_system_TypeVar(a) for a in annotation_args): elif all(is_system_TypeVar(a) for a in annotation_args):
# Suppress arguments if all system defined TypeVars (ex. Dict[KT, VT]) # Suppress arguments if all system defined TypeVars (ex. Dict[KT, VT])